Skip to content

Commit d553b94

Browse files
authored
feat: add optional separate GitHub token for runners polling (#10)
## Summary - Add optional `runners_token` config field for listing self-hosted runners separately from the dispatch token - Update status endpoint to expose rate limits for both GitHub clients (runners and dispatch) - Update UI StatusIndicator to display rate limits for both clients separately ## Details When `runners_token` is configured, the system uses two separate GitHub clients: - **Runners client**: Uses `runners_token` for polling runner status - **Dispatch client**: Uses `token` for triggering workflows If `runners_token` is not set, both operations fall back to the main `token`. The `/api/v1/status` endpoint now returns: ```json { "github": { "runners": { "status": "healthy", "rate_limit_remaining": 4850, ... }, "dispatch": { "status": "healthy", "rate_limit_remaining": 4900, ... } } } ``` ## Test plan - [x] Build and run server with only `token` configured - verify both clients show same rate limits - [x] Build and run server with both `token` and `runners_token` - verify separate rate limits shown - [x] Verify UI StatusIndicator displays both clients correctly - [x] Verify runner polling works with `runners_token` - [x] Verify workflow dispatch works with `token`
1 parent 10e66af commit d553b94

6 files changed

Lines changed: 190 additions & 94 deletions

File tree

cmd/dispatchoor/server.go

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -80,27 +80,33 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error
8080
m := metrics.New()
8181
m.SetBuildInfo(Version, GitCommit, BuildDate)
8282

83-
// Create GitHub client (may operate in disconnected mode if no token or invalid token).
84-
var ghClient github.Client
83+
// Create GitHub clients.
84+
// - runnersClient: used for polling runner status (uses runners_token if set, else token)
85+
// - dispatchClient: used for dispatching workflows (uses token)
86+
var runnersClient github.Client
87+
88+
var dispatchClient github.Client
8589

8690
var poller github.Poller
8791

88-
if cfg.HasGitHubToken() {
89-
ghClient = github.NewClient(log, cfg.GitHub.Token)
92+
// Create runners client for polling (uses runners_token if configured, else falls back to token).
93+
if cfg.HasRunnersToken() {
94+
runnersToken := cfg.GetRunnersToken()
95+
runnersClient = github.NewClient(log.WithField("client", "runners"), runnersToken)
9096

91-
if err := ghClient.Start(ctx); err != nil {
97+
if err := runnersClient.Start(ctx); err != nil {
9298
return err
9399
}
94100

95101
defer func() {
96-
if err := ghClient.Stop(); err != nil {
97-
log.WithError(err).Warn("Failed to stop GitHub client")
102+
if err := runnersClient.Stop(); err != nil {
103+
log.WithError(err).Warn("Failed to stop runners GitHub client")
98104
}
99105
}()
100106

101-
// Only start poller if GitHub client is connected.
102-
if ghClient.IsConnected() {
103-
poller = github.NewPoller(log, cfg, ghClient, st, m)
107+
// Only start poller if runners client is connected.
108+
if runnersClient.IsConnected() {
109+
poller = github.NewPoller(log, cfg, runnersClient, st, m)
104110

105111
if err := poller.Start(ctx); err != nil {
106112
return err
@@ -112,10 +118,31 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error
112118
}
113119
}()
114120
} else {
115-
log.Warn("GitHub client not connected - runner polling disabled")
121+
log.Warn("Runners GitHub client not connected - runner polling disabled")
122+
}
123+
} else {
124+
log.Warn("No GitHub token configured for runners - runner polling disabled")
125+
}
126+
127+
// Create dispatch client for workflow dispatching (uses main token).
128+
if cfg.HasGitHubToken() {
129+
dispatchClient = github.NewClient(log.WithField("client", "dispatch"), cfg.GitHub.Token)
130+
131+
if err := dispatchClient.Start(ctx); err != nil {
132+
return err
133+
}
134+
135+
defer func() {
136+
if err := dispatchClient.Stop(); err != nil {
137+
log.WithError(err).Warn("Failed to stop dispatch GitHub client")
138+
}
139+
}()
140+
141+
if !dispatchClient.IsConnected() {
142+
log.Warn("Dispatch GitHub client not connected - workflow dispatch disabled")
116143
}
117144
} else {
118-
log.Warn("No GitHub token configured - GitHub integration disabled")
145+
log.Warn("No GitHub token configured for dispatch - workflow dispatch disabled")
119146
}
120147

121148
// Create queue service.
@@ -127,11 +154,11 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error
127154

128155
defer queueSvc.Stop()
129156

130-
// Create and start dispatcher (only if GitHub client is connected).
157+
// Create and start dispatcher (only if dispatch client is connected).
131158
var disp dispatcher.Dispatcher
132159

133-
if ghClient != nil && ghClient.IsConnected() {
134-
disp = dispatcher.NewDispatcher(log, cfg, st, queueSvc, ghClient)
160+
if dispatchClient != nil && dispatchClient.IsConnected() {
161+
disp = dispatcher.NewDispatcher(log, cfg, st, queueSvc, dispatchClient)
135162

136163
if err := disp.Start(ctx); err != nil {
137164
return err
@@ -142,8 +169,6 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error
142169
log.WithError(err).Warn("Failed to stop dispatcher")
143170
}
144171
}()
145-
} else {
146-
log.Warn("Dispatcher disabled - GitHub client not connected")
147172
}
148173

149174
// Create and start auth service.
@@ -156,7 +181,7 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error
156181
defer authSvc.Stop()
157182

158183
// Create and start API server.
159-
srv := api.NewServer(log, cfg, st, queueSvc, authSvc, ghClient, m)
184+
srv := api.NewServer(log, cfg, st, queueSvc, authSvc, runnersClient, dispatchClient, m)
160185

161186
// Set up runner change callbacks to broadcast via WebSocket.
162187
if poller != nil {

config.example.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ database:
2222

2323
github:
2424
token: ${GITHUB_TOKEN}
25+
# Optional: separate token for listing runners (falls back to token if not set)
26+
# runners_token: ${GITHUB_RUNNERS_TOKEN}
2527
poll_interval: 60s
2628
rate_limit_buffer: 100
2729

pkg/api/api.go

Lines changed: 74 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -31,34 +31,36 @@ type Server interface {
3131

3232
// server implements Server.
3333
type server struct {
34-
log logrus.FieldLogger
35-
cfg *config.Config
36-
store store.Store
37-
queue queue.Service
38-
auth auth.Service
39-
ghClient github.Client
40-
metrics *metrics.Metrics
41-
hub *Hub
42-
srv *http.Server
43-
router chi.Router
34+
log logrus.FieldLogger
35+
cfg *config.Config
36+
store store.Store
37+
queue queue.Service
38+
auth auth.Service
39+
runnersClient github.Client
40+
dispatchClient github.Client
41+
metrics *metrics.Metrics
42+
hub *Hub
43+
srv *http.Server
44+
router chi.Router
4445
}
4546

4647
// Ensure server implements Server.
4748
var _ Server = (*server)(nil)
4849

4950
// NewServer creates a new API server.
50-
func NewServer(log logrus.FieldLogger, cfg *config.Config, st store.Store, q queue.Service, authSvc auth.Service, ghClient github.Client, m *metrics.Metrics) Server {
51+
func NewServer(log logrus.FieldLogger, cfg *config.Config, st store.Store, q queue.Service, authSvc auth.Service, runnersClient, dispatchClient github.Client, m *metrics.Metrics) Server {
5152
hub := NewHub(log)
5253

5354
s := &server{
54-
log: log.WithField("component", "api"),
55-
cfg: cfg,
56-
store: st,
57-
queue: q,
58-
auth: authSvc,
59-
ghClient: ghClient,
60-
metrics: m,
61-
hub: hub,
55+
log: log.WithField("component", "api"),
56+
cfg: cfg,
57+
store: st,
58+
queue: q,
59+
auth: authSvc,
60+
runnersClient: runnersClient,
61+
dispatchClient: dispatchClient,
62+
metrics: m,
63+
hub: hub,
6264
}
6365

6466
// Set up callback to broadcast job state changes via WebSocket.
@@ -367,58 +369,64 @@ func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) {
367369
}
368370
}
369371

370-
// GitHub connection and rate limit info.
371-
if s.ghClient == nil {
372-
resp.GitHub = GitHubStatus{
373-
Status: ComponentStatusUnhealthy,
374-
Connected: false,
375-
Error: "GitHub token not configured",
376-
}
372+
// GitHub connection and rate limit info for both clients.
373+
resp.GitHub = GitHubClientsStatus{}
377374

378-
if resp.Status == ComponentStatusHealthy {
379-
resp.Status = ComponentStatusDegraded
380-
}
381-
} else if !s.ghClient.IsConnected() {
382-
resp.GitHub = GitHubStatus{
383-
Status: ComponentStatusUnhealthy,
384-
Connected: false,
385-
Error: s.ghClient.ConnectionError(),
375+
// Helper function to get status for a single client.
376+
getClientStatus := func(client github.Client, name string) *GitHubClientStatus {
377+
if client == nil {
378+
return &GitHubClientStatus{
379+
Status: ComponentStatusUnhealthy,
380+
Connected: false,
381+
Error: name + " token not configured",
382+
}
386383
}
387384

388-
if resp.Status == ComponentStatusHealthy {
389-
resp.Status = ComponentStatusDegraded
385+
if !client.IsConnected() {
386+
return &GitHubClientStatus{
387+
Status: ComponentStatusUnhealthy,
388+
Connected: false,
389+
Error: client.ConnectionError(),
390+
}
390391
}
391-
} else {
392-
remaining := s.ghClient.RateLimitRemaining()
393-
resetTime := s.ghClient.RateLimitReset()
394392

395-
githubStatus := ComponentStatusHealthy
393+
remaining := client.RateLimitRemaining()
394+
resetTime := client.RateLimitReset()
395+
396+
clientStatus := ComponentStatusHealthy
396397
if remaining < 100 {
397-
githubStatus = ComponentStatusDegraded
398+
clientStatus = ComponentStatusDegraded
398399
}
399400

400401
if remaining < 10 {
401-
githubStatus = ComponentStatusUnhealthy
402-
403-
if resp.Status == ComponentStatusHealthy {
404-
resp.Status = ComponentStatusDegraded
405-
}
402+
clientStatus = ComponentStatusUnhealthy
406403
}
407404

408405
resetIn := time.Until(resetTime)
409406
if resetIn < 0 {
410407
resetIn = 0
411408
}
412409

413-
resp.GitHub = GitHubStatus{
414-
Status: githubStatus,
410+
return &GitHubClientStatus{
411+
Status: clientStatus,
415412
Connected: true,
416413
RateLimitRemaining: remaining,
417414
RateLimitReset: resetTime.UTC().Format(time.RFC3339),
418415
ResetIn: resetIn.Round(time.Second).String(),
419416
}
420417
}
421418

419+
resp.GitHub.Runners = getClientStatus(s.runnersClient, "Runners")
420+
resp.GitHub.Dispatch = getClientStatus(s.dispatchClient, "Dispatch")
421+
422+
// Update overall status based on GitHub clients.
423+
if (resp.GitHub.Runners != nil && resp.GitHub.Runners.Status == ComponentStatusUnhealthy) ||
424+
(resp.GitHub.Dispatch != nil && resp.GitHub.Dispatch.Status == ComponentStatusUnhealthy) {
425+
if resp.Status == ComponentStatusHealthy {
426+
resp.Status = ComponentStatusDegraded
427+
}
428+
}
429+
422430
// Queue statistics.
423431
pendingJobs, _ := s.store.ListJobsByStatus(ctx, store.JobStatusPending)
424432
triggeredJobs, _ := s.store.ListJobsByStatus(ctx, store.JobStatusTriggered)
@@ -1127,21 +1135,21 @@ func (s *server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
11271135
return
11281136
}
11291137

1130-
// Check if GitHub client is available.
1131-
if s.ghClient == nil || !s.ghClient.IsConnected() {
1138+
// Check if dispatch client is available.
1139+
if s.dispatchClient == nil || !s.dispatchClient.IsConnected() {
11321140
s.writeError(w, http.StatusServiceUnavailable, "GitHub integration is not available")
11331141

11341142
return
11351143
}
11361144

11371145
// Cancel the workflow run on GitHub.
1138-
if err := s.ghClient.CancelWorkflowRun(r.Context(), owner, repo, *job.RunID); err != nil {
1146+
if err := s.dispatchClient.CancelWorkflowRun(r.Context(), owner, repo, *job.RunID); err != nil {
11391147
s.log.WithError(err).Warn("Cancel request returned error, checking actual run status")
11401148

11411149
// Check if the run was actually cancelled despite the error.
11421150
// GitHub can return transient errors like "job scheduled on GitHub side"
11431151
// even when the cancellation succeeds.
1144-
run, getErr := s.ghClient.GetWorkflowRun(r.Context(), owner, repo, *job.RunID)
1152+
run, getErr := s.dispatchClient.GetWorkflowRun(r.Context(), owner, repo, *job.RunID)
11451153
if getErr != nil {
11461154
s.log.WithError(getErr).Error("Failed to verify workflow run status after cancel error")
11471155
s.writeError(w, http.StatusInternalServerError, "Failed to cancel workflow run on GitHub")
@@ -1320,8 +1328,8 @@ type DatabaseStatus struct {
13201328
Error string `json:"error,omitempty"`
13211329
}
13221330

1323-
// GitHubStatus contains GitHub API rate limit information.
1324-
type GitHubStatus struct {
1331+
// GitHubClientStatus contains status and rate limit information for a single GitHub client.
1332+
type GitHubClientStatus struct {
13251333
Status ComponentStatus `json:"status"`
13261334
Connected bool `json:"connected"`
13271335
Error string `json:"error,omitempty"`
@@ -1330,6 +1338,12 @@ type GitHubStatus struct {
13301338
ResetIn string `json:"reset_in,omitempty"`
13311339
}
13321340

1341+
// GitHubClientsStatus contains status for both GitHub clients.
1342+
type GitHubClientsStatus struct {
1343+
Runners *GitHubClientStatus `json:"runners,omitempty"`
1344+
Dispatch *GitHubClientStatus `json:"dispatch,omitempty"`
1345+
}
1346+
13331347
// QueueStats contains queue statistics.
13341348
type QueueStats struct {
13351349
PendingJobs int `json:"pending_jobs"`
@@ -1346,12 +1360,12 @@ type VersionInfo struct {
13461360

13471361
// SystemStatusResponse is the comprehensive status response.
13481362
type SystemStatusResponse struct {
1349-
Status ComponentStatus `json:"status"`
1350-
Timestamp string `json:"timestamp"`
1351-
Database DatabaseStatus `json:"database"`
1352-
GitHub GitHubStatus `json:"github"`
1353-
Queue QueueStats `json:"queue"`
1354-
Version VersionInfo `json:"version"`
1363+
Status ComponentStatus `json:"status"`
1364+
Timestamp string `json:"timestamp"`
1365+
Database DatabaseStatus `json:"database"`
1366+
GitHub GitHubClientsStatus `json:"github"`
1367+
Queue QueueStats `json:"queue"`
1368+
Version VersionInfo `json:"version"`
13551369
}
13561370

13571371
// HistoryResponse wraps the paginated history response.

pkg/config/config.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ type PostgresConfig struct {
5555
// GitHubConfig contains GitHub API settings.
5656
type GitHubConfig struct {
5757
Token string `yaml:"token"`
58+
RunnersToken string `yaml:"runners_token"`
5859
PollInterval time.Duration `yaml:"poll_interval"`
5960
RateLimitBuffer int `yaml:"rate_limit_buffer"`
6061
}
@@ -475,6 +476,21 @@ func (c *Config) HasGitHubToken() bool {
475476
return c.GitHub.Token != ""
476477
}
477478

479+
// GetRunnersToken returns the token to use for listing runners.
480+
// Returns RunnersToken if configured, otherwise falls back to Token.
481+
func (c *Config) GetRunnersToken() string {
482+
if c.GitHub.RunnersToken != "" {
483+
return c.GitHub.RunnersToken
484+
}
485+
486+
return c.GitHub.Token
487+
}
488+
489+
// HasRunnersToken returns true if a token for listing runners is available.
490+
func (c *Config) HasRunnersToken() bool {
491+
return c.GetRunnersToken() != ""
492+
}
493+
478494
// String returns a sanitized string representation of the config (no secrets).
479495
func (c *Config) String() string {
480496
var sb strings.Builder

0 commit comments

Comments
 (0)