Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/cmd/hive/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
4 changes: 0 additions & 4 deletions src/pkg/hub/advisory_diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"net/http"
"sort"
"testing"
"time"
)

Expand Down Expand Up @@ -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()))
Expand Down
29 changes: 29 additions & 0 deletions src/pkg/hub/background_pollers.go
Original file line number Diff line number Diff line change
@@ -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)
}
14 changes: 9 additions & 5 deletions src/pkg/hub/reach_diag_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package hub

import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http/httptest"
Expand Down Expand Up @@ -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")
}
}

Expand Down
18 changes: 0 additions & 18 deletions src/pkg/hub/saas.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
"strconv"
"strings"
"sync"
"testing"
"time"

"github.com/kubestellar/hive/pkg/config"
Expand Down Expand Up @@ -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
Expand Down
Loading