From fa2b69015cd844ad2360f2aeba42b68d31d5f37d Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Fri, 9 Jan 2026 11:16:49 +0100 Subject: [PATCH] feat: make GitHub token optional for graceful degradation Allow the API to start without a GitHub token or with an invalid token. The server will operate in degraded mode with GitHub-dependent features disabled (polling, dispatching, job cancellation). Changes: - Remove github.token requirement from config validation - Add IsConnected() and ConnectionError() methods to GitHub client - Start() catches auth failures gracefully instead of returning error - Conditional initialization of poller and dispatcher based on connection - Status endpoint shows GitHub connection state with error details - Cancel job endpoint returns 503 when GitHub is unavailable --- cmd/dispatchoor/server.go | 90 +++++++++++++++++++++++++++------------ pkg/api/api.go | 74 +++++++++++++++++++++++--------- pkg/config/config.go | 10 ++--- pkg/github/github.go | 47 +++++++++++++++++--- 4 files changed, 160 insertions(+), 61 deletions(-) diff --git a/cmd/dispatchoor/server.go b/cmd/dispatchoor/server.go index 47ac7fb..840b8d3 100644 --- a/cmd/dispatchoor/server.go +++ b/cmd/dispatchoor/server.go @@ -80,24 +80,44 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error m := metrics.New() m.SetBuildInfo(Version, GitCommit, BuildDate) - // Create GitHub client. - ghClient := github.NewClient(log, cfg.GitHub.Token) - - if err := ghClient.Start(ctx); err != nil { - return err - } - - defer ghClient.Stop() - - // Create and start runner poller. - poller := github.NewPoller(log, cfg, ghClient, st, m) - - if err := poller.Start(ctx); err != nil { - return err + // Create GitHub client (may operate in disconnected mode if no token or invalid token). + var ghClient github.Client + + var poller github.Poller + + if cfg.HasGitHubToken() { + ghClient = github.NewClient(log, cfg.GitHub.Token) + + if err := ghClient.Start(ctx); err != nil { + return err + } + + defer func() { + if err := ghClient.Stop(); err != nil { + log.WithError(err).Warn("Failed to stop GitHub client") + } + }() + + // Only start poller if GitHub client is connected. + if ghClient.IsConnected() { + poller = github.NewPoller(log, cfg, ghClient, st, m) + + if err := poller.Start(ctx); err != nil { + return err + } + + defer func() { + if err := poller.Stop(); err != nil { + log.WithError(err).Warn("Failed to stop poller") + } + }() + } else { + log.Warn("GitHub client not connected - runner polling disabled") + } + } else { + log.Warn("No GitHub token configured - GitHub integration disabled") } - defer poller.Stop() - // Create queue service. queueSvc := queue.NewService(log, cfg, st) @@ -107,14 +127,24 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error defer queueSvc.Stop() - // Create and start dispatcher. - disp := dispatcher.NewDispatcher(log, cfg, st, queueSvc, ghClient) + // Create and start dispatcher (only if GitHub client is connected). + var disp dispatcher.Dispatcher - if err := disp.Start(ctx); err != nil { - return err - } + if ghClient != nil && ghClient.IsConnected() { + disp = dispatcher.NewDispatcher(log, cfg, st, queueSvc, ghClient) - defer disp.Stop() + if err := disp.Start(ctx); err != nil { + return err + } + + defer func() { + if err := disp.Stop(); err != nil { + log.WithError(err).Warn("Failed to stop dispatcher") + } + }() + } else { + log.Warn("Dispatcher disabled - GitHub client not connected") + } // Create and start auth service. authSvc := auth.NewService(log, cfg, st) @@ -129,13 +159,17 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error srv := api.NewServer(log, cfg, st, queueSvc, authSvc, ghClient, m) // Set up runner change callbacks to broadcast via WebSocket. - poller.SetRunnerChangeCallback(func(runner *store.Runner) { - srv.BroadcastRunnerChange(runner) - }) + if poller != nil { + poller.SetRunnerChangeCallback(func(runner *store.Runner) { + srv.BroadcastRunnerChange(runner) + }) + } - disp.SetRunnerChangeCallback(func(runner *store.Runner) { - srv.BroadcastRunnerChange(runner) - }) + if disp != nil { + disp.SetRunnerChangeCallback(func(runner *store.Runner) { + srv.BroadcastRunnerChange(runner) + }) + } if err := srv.Start(ctx); err != nil { return err diff --git a/pkg/api/api.go b/pkg/api/api.go index 6010902..66cc038 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -367,33 +367,56 @@ func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { } } - // GitHub rate limit info. - remaining := s.ghClient.RateLimitRemaining() - resetTime := s.ghClient.RateLimitReset() - - githubStatus := ComponentStatusHealthy - if remaining < 100 { - githubStatus = ComponentStatusDegraded - } + // GitHub connection and rate limit info. + if s.ghClient == nil { + resp.GitHub = GitHubStatus{ + Status: ComponentStatusUnhealthy, + Connected: false, + Error: "GitHub token not configured", + } - if remaining < 10 { - githubStatus = ComponentStatusUnhealthy + if resp.Status == ComponentStatusHealthy { + resp.Status = ComponentStatusDegraded + } + } else if !s.ghClient.IsConnected() { + resp.GitHub = GitHubStatus{ + Status: ComponentStatusUnhealthy, + Connected: false, + Error: s.ghClient.ConnectionError(), + } if resp.Status == ComponentStatusHealthy { resp.Status = ComponentStatusDegraded } - } + } else { + remaining := s.ghClient.RateLimitRemaining() + resetTime := s.ghClient.RateLimitReset() - resetIn := time.Until(resetTime) - if resetIn < 0 { - resetIn = 0 - } + githubStatus := ComponentStatusHealthy + if remaining < 100 { + githubStatus = ComponentStatusDegraded + } - resp.GitHub = GitHubStatus{ - Status: githubStatus, - RateLimitRemaining: remaining, - RateLimitReset: resetTime.UTC().Format(time.RFC3339), - ResetIn: resetIn.Round(time.Second).String(), + if remaining < 10 { + githubStatus = ComponentStatusUnhealthy + + if resp.Status == ComponentStatusHealthy { + resp.Status = ComponentStatusDegraded + } + } + + resetIn := time.Until(resetTime) + if resetIn < 0 { + resetIn = 0 + } + + resp.GitHub = GitHubStatus{ + Status: githubStatus, + Connected: true, + RateLimitRemaining: remaining, + RateLimitReset: resetTime.UTC().Format(time.RFC3339), + ResetIn: resetIn.Round(time.Second).String(), + } } // Queue statistics. @@ -1104,6 +1127,13 @@ func (s *server) handleCancelJob(w http.ResponseWriter, r *http.Request) { return } + // Check if GitHub client is available. + if s.ghClient == nil || !s.ghClient.IsConnected() { + s.writeError(w, http.StatusServiceUnavailable, "GitHub integration is not available") + + return + } + // Cancel the workflow run on GitHub. if err := s.ghClient.CancelWorkflowRun(r.Context(), owner, repo, *job.RunID); err != nil { s.log.WithError(err).Warn("Cancel request returned error, checking actual run status") @@ -1293,8 +1323,10 @@ type DatabaseStatus struct { // GitHubStatus contains GitHub API rate limit information. type GitHubStatus struct { Status ComponentStatus `json:"status"` + Connected bool `json:"connected"` + Error string `json:"error,omitempty"` RateLimitRemaining int `json:"rate_limit_remaining"` - RateLimitReset string `json:"rate_limit_reset"` + RateLimitReset string `json:"rate_limit_reset,omitempty"` ResetIn string `json:"reset_in,omitempty"` } diff --git a/pkg/config/config.go b/pkg/config/config.go index 60868d8..38dece2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -388,11 +388,6 @@ func (c *Config) Validate() error { return fmt.Errorf("unsupported database driver: %s", c.Database.Driver) } - // Validate GitHub config. - if c.GitHub.Token == "" { - return fmt.Errorf("github.token is required") - } - // Validate auth config. if !c.Auth.Basic.Enabled && !c.Auth.GitHub.Enabled { return fmt.Errorf("at least one auth method (basic or github) must be enabled") @@ -475,6 +470,11 @@ func (c *Config) GetDSN() string { } } +// HasGitHubToken returns true if a GitHub token is configured. +func (c *Config) HasGitHubToken() bool { + return c.GitHub.Token != "" +} + // String returns a sanitized string representation of the config (no secrets). func (c *Config) String() string { var sb strings.Builder diff --git a/pkg/github/github.go b/pkg/github/github.go index c0954cc..c11ea32 100644 --- a/pkg/github/github.go +++ b/pkg/github/github.go @@ -17,6 +17,10 @@ type Client interface { Start(ctx context.Context) error Stop() error + // Connection status. + IsConnected() bool + ConnectionError() string + // Runners. ListOrgRunners(ctx context.Context, org string) ([]*Runner, error) ListRepoRunners(ctx context.Context, owner, repo string) ([]*Runner, error) @@ -80,12 +84,14 @@ type WorkflowJob struct { // client implements Client. type client struct { - log logrus.FieldLogger - token string - gh *github.Client - mu sync.RWMutex - rateRemaining int - rateReset time.Time + log logrus.FieldLogger + token string + gh *github.Client + mu sync.RWMutex + rateRemaining int + rateReset time.Time + connected bool + connectionError string } // Ensure client implements Client. @@ -100,6 +106,8 @@ func NewClient(log logrus.FieldLogger, token string) Client { } // Start initializes the GitHub client. +// If authentication fails, the client will be marked as disconnected but no error is returned. +// Use IsConnected() and ConnectionError() to check the connection status. func (c *client) Start(ctx context.Context) error { c.log.Info("Initializing GitHub client") @@ -111,12 +119,21 @@ func (c *client) Start(ctx context.Context) error { // Test authentication by getting rate limit. rate, _, err := c.gh.RateLimit.Get(ctx) if err != nil { - return fmt.Errorf("testing GitHub authentication: %w", err) + c.mu.Lock() + c.connected = false + c.connectionError = fmt.Sprintf("authentication failed: %v", err) + c.mu.Unlock() + + c.log.WithError(err).Warn("GitHub authentication failed - client will operate in disconnected mode") + + return nil } c.mu.Lock() c.rateRemaining = rate.Core.Remaining c.rateReset = rate.Core.Reset.Time + c.connected = true + c.connectionError = "" c.mu.Unlock() c.log.WithFields(logrus.Fields{ @@ -164,6 +181,22 @@ func (c *client) RateLimitReset() time.Time { return c.rateReset } +// IsConnected returns true if the GitHub client is connected and authenticated. +func (c *client) IsConnected() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.connected +} + +// ConnectionError returns the connection error message, if any. +func (c *client) ConnectionError() string { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.connectionError +} + // ListOrgRunners lists all self-hosted runners for an organization. func (c *client) ListOrgRunners(ctx context.Context, org string) ([]*Runner, error) { c.log.WithField("org", org).Debug("Listing organization runners")