diff --git a/src/cmd/hive/main.go b/src/cmd/hive/main.go index 979379694..8eba4ca5b 100644 --- a/src/cmd/hive/main.go +++ b/src/cmd/hive/main.go @@ -9032,6 +9032,12 @@ func runHub(logger *slog.Logger, configPath string) { // the endpoint would keep answering from the empty stub forever. hubSrv.SetReachReporter(hubSrv.RegistryReachReporter()) + // Long-lived SaaS pollers (provision watcher, SHA poller, auth audit, + // advisory diagnostics) are started here — at the composition root — not + // inside route registration, so constructing a HubServer stays free of + // background goroutines. + hubSrv.StartBackgroundPollers(context.Background()) + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/src/pkg/hub/advisory_diagnostics.go b/src/pkg/hub/advisory_diagnostics.go index 3b19a1e77..87d4570e2 100644 --- a/src/pkg/hub/advisory_diagnostics.go +++ b/src/pkg/hub/advisory_diagnostics.go @@ -5,7 +5,6 @@ import ( "encoding/json" "net/http" "sort" - "testing" "time" ) @@ -227,9 +226,6 @@ const advisoryDiagnosticsInterval = 30 * time.Minute // StartAdvisoryDiagnostics logs the fleet advisory-suppression profile on a // timer. Read-only: it never mutates the registry and never raises an alert. func (s *HubServer) StartAdvisoryDiagnostics(ctx context.Context) { - if testing.Testing() { - return - } ticker := time.NewTicker(advisoryDiagnosticsInterval) defer ticker.Stop() s.logAdvisoryDiagnostics(s.advisoryDiagnostics(time.Now())) diff --git a/src/pkg/hub/background_pollers.go b/src/pkg/hub/background_pollers.go new file mode 100644 index 000000000..54cb29198 --- /dev/null +++ b/src/pkg/hub/background_pollers.go @@ -0,0 +1,29 @@ +package hub + +import "context" + +// StartBackgroundPollers launches the hub's long-lived SaaS pollers. It is the +// explicit lifecycle entrypoint the composition root (cmd/hive runHub) calls +// once, after constructing the server and before serving traffic. +// +// This used to happen implicitly inside registerSaaSRoutes behind a +// !testing.Testing() guard, which coupled production code to the testing +// package and hid a lifecycle decision inside route registration. Route +// registration is now side-effect free; anything that constructs a HubServer +// without calling this method (every test, and any embedder that only wants +// the handlers) gets no background goroutines. Pollers immediately hit the +// GitHub API and read the package-level saas path variables, so starting them +// must remain an explicit, caller-owned choice. +// +// The provided ctx bounds every poller; cancelling it stops them all. +func (s *HubServer) StartBackgroundPollers(ctx context.Context) { + go s.startProvisionWatcher(ctx) + go s.StartLatestSHAPoller(ctx) + // Periodically probe every spoke's unauthenticated /api/status and alert + // on any that answer 200 (wide open) — catches auth drift automatically. + go s.StartAuthAudit(ctx) + // Advisory-suppression profile (#4167): one structured log line every + // cycle saying how many hives are stale and how many are stale but + // UNREPORTED. Read-only measurement — no alert, no registry write. + go s.StartAdvisoryDiagnostics(ctx) +} diff --git a/src/pkg/hub/reach_diag_wiring_test.go b/src/pkg/hub/reach_diag_wiring_test.go index 24ed5da3f..8c836f835 100644 --- a/src/pkg/hub/reach_diag_wiring_test.go +++ b/src/pkg/hub/reach_diag_wiring_test.go @@ -2,6 +2,7 @@ package hub import ( "bytes" + "context" "encoding/json" "log/slog" "net/http/httptest" @@ -56,19 +57,22 @@ func TestAdvisoryDiagnosticsEndpoint(t *testing.T) { } } -// Under `go test` the diagnostics ticker must refuse to start (it would leak -// a goroutine logging every 30 minutes into unrelated tests). -func TestStartAdvisoryDiagnosticsNoopInTests(t *testing.T) { +// The diagnostics ticker must honor context cancellation so the pollers all +// stop when the composition root's context ends (and so tests can bound it). +// It performs one immediate sample, then exits on a cancelled context. +func TestStartAdvisoryDiagnosticsStopsOnCancel(t *testing.T) { srv := newHubServerForTest(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() done := make(chan struct{}) go func() { - srv.StartAdvisoryDiagnostics(t.Context()) + srv.StartAdvisoryDiagnostics(ctx) close(done) }() select { case <-done: case <-time.After(5 * time.Second): - t.Fatal("StartAdvisoryDiagnostics did not return under testing.Testing()") + t.Fatal("StartAdvisoryDiagnostics did not return on cancelled context") } } diff --git a/src/pkg/hub/saas.go b/src/pkg/hub/saas.go index 1e9d78ecf..2b1288f6d 100644 --- a/src/pkg/hub/saas.go +++ b/src/pkg/hub/saas.go @@ -22,7 +22,6 @@ import ( "strconv" "strings" "sync" - "testing" "time" "github.com/kubestellar/hive/pkg/config" @@ -705,23 +704,6 @@ func (s *HubServer) registerSaaSRoutes() { s.mux.HandleFunc("POST /api/saas/admin/slack/broadcast", s.requireAdmin(s.handleSlackBroadcast)) s.mux.HandleFunc("POST /api/saas/admin/journey-snooze", s.requireAdmin(s.handleJourneySnooze)) s.mux.HandleFunc("GET /api/saas/admin/journey-status", s.requireAdmin(s.handleJourneyStatus)) - - // Under `go test` these long-lived pollers leak across test cases: they - // immediately hit the GitHub API and read the package-level saas path - // variables that the filesystem test helper swaps per-test, which the - // race detector rightly flags. Production behavior is unchanged; tests - // that need poller logic call the functions directly. - if !testing.Testing() { - go s.startProvisionWatcher(context.Background()) - go s.StartLatestSHAPoller(context.Background()) - // Periodically probe every spoke's unauthenticated /api/status and alert - // on any that answer 200 (wide open) — catches auth drift automatically. - go s.StartAuthAudit(context.Background()) - // Advisory-suppression profile (#4167): one structured log line every - // cycle saying how many hives are stale and how many are stale but - // UNREPORTED. Read-only measurement — no alert, no registry write. - go s.StartAdvisoryDiagnostics(context.Background()) - } } // impersonateExitPath is the one mutating endpoint that stays callable while