Skip to content

Commit 0b23093

Browse files
committed
fix(#95): address Copilot review findings on PR #96
- manager.go: split ResolvePrimarySources (silent, called on every /api/v1/system/capabilities request) from ValidatePrimarySourceOverrides (logs warnings, called once from ProbeAll). The previous design re-logged the same override warning on every dashboard poll. - manager.go: rename deps_override → overrideForCategory; the snake_case was inconsistent with the rest of the file. - manager.go: replace hand-rolled containsString / withoutString with slices.Contains and slices.Clone + slices.DeleteFunc. Module is on Go 1.25; reaching for stdlib here trims maintenance surface. - manager.go: defensive slices.Clone of the Alternatives slice in SourceSelection so a future change to the local `ordered` slice can't alias into a returned value. - manager.go: replace overrideEnvVarFor's switch with a categoryEnvVar map keyed by MetricCategory; missing entry is a build-time-visible hole rather than a runtime "(unknown category)" placeholder leaking into operator-facing log messages and SourceSelection.Reason. - config_test.go: switch new tests from os.Setenv + t.Cleanup to t.Setenv. Safer under panics, idiomatic since Go 1.17. New tests: - TestEveryCategoryHasOverrideEnvVar: guards categoryEnvVar against forgotten entries when a new MetricCategory is added. - TestValidatePrimarySourceOverridesCountsMisses: confirms the validate-vs-resolve split — Validate reports mis-targeted overrides via its return value, Resolve stays silent.
1 parent 6c0e673 commit 0b23093

3 files changed

Lines changed: 153 additions & 70 deletions

File tree

gearbox-agent/internal/framework/config/config_test.go

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -395,16 +395,10 @@ func TestNormaliseSourceOverride(t *testing.T) {
395395
}
396396

397397
func TestLoad_HTTPSourceOverride(t *testing.T) {
398-
saved := os.Getenv("GEARBOX_AGENT_HTTP_SOURCE")
399-
t.Cleanup(func() {
400-
if saved != "" {
401-
os.Setenv("GEARBOX_AGENT_HTTP_SOURCE", saved)
402-
} else {
403-
os.Unsetenv("GEARBOX_AGENT_HTTP_SOURCE")
404-
}
405-
})
406-
407-
os.Setenv("GEARBOX_AGENT_HTTP_SOURCE", " Nginx ")
398+
// t.Setenv handles save/restore automatically and prevents
399+
// parallel-test interference; the old os.Setenv + t.Cleanup pattern
400+
// would leak the env var if the test panicked before Cleanup ran.
401+
t.Setenv("GEARBOX_AGENT_HTTP_SOURCE", " Nginx ")
408402
cfg, err := Load()
409403
if err != nil {
410404
t.Fatalf("Load: %v", err)
@@ -415,13 +409,12 @@ func TestLoad_HTTPSourceOverride(t *testing.T) {
415409
}
416410

417411
func TestLoad_HTTPSourceDefaultsEmpty(t *testing.T) {
418-
saved := os.Getenv("GEARBOX_AGENT_HTTP_SOURCE")
412+
// Unset within the test; t.Setenv with an empty string only marks
413+
// the env var for restoration but doesn't unset it, so explicit
414+
// Unsetenv is the right tool when the test specifically needs the
415+
// var absent.
416+
t.Setenv("GEARBOX_AGENT_HTTP_SOURCE", "") // record original for restore
419417
os.Unsetenv("GEARBOX_AGENT_HTTP_SOURCE")
420-
t.Cleanup(func() {
421-
if saved != "" {
422-
os.Setenv("GEARBOX_AGENT_HTTP_SOURCE", saved)
423-
}
424-
})
425418

426419
cfg, err := Load()
427420
if err != nil {

gearbox-agent/internal/framework/gear/manager.go

Lines changed: 96 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"log/slog"
99
"net/http"
1010
"os"
11+
"slices"
1112
"sort"
1213
"strings"
1314
"sync"
@@ -96,7 +97,13 @@ func (m *Manager) ProbeAll(ctx context.Context) {
9697
// a glance which source the dashboard will treat as authoritative.
9798
// Categories with no available producer are silently skipped — they're
9899
// just absent from this host, no log noise warranted.
100+
//
101+
// Override-validation warnings are emitted here (once per agent start)
102+
// rather than from inside ResolvePrimarySources, so the API endpoint
103+
// that calls Resolve on every request doesn't replay the same warning
104+
// on every dashboard poll.
99105
func (m *Manager) logPrimarySources() {
106+
m.ValidatePrimarySourceOverrides()
100107
picks := m.ResolvePrimarySources()
101108
if len(picks) == 0 {
102109
return
@@ -537,6 +544,16 @@ var preferenceOrder = map[MetricCategory][]string{
537544
CategoryHTTPRequests: {"haproxy", "nginx", "apache", "caddy", "traefik"},
538545
}
539546

547+
// categoryEnvVar names the env var that controls each metric category's
548+
// primary-source override. Kept as a map (rather than a switch) so
549+
// adding a new category requires only an entry here — and the
550+
// exhaustiveness test in source_test.go fails until that entry exists,
551+
// preventing the "(unknown category)" placeholder from ever appearing
552+
// in operator-facing warnings or SourceSelection.Reason strings.
553+
var categoryEnvVar = map[MetricCategory]string{
554+
CategoryHTTPRequests: "GEARBOX_AGENT_HTTP_SOURCE",
555+
}
556+
540557
// ResolvePrimarySources picks the primary metric source for each
541558
// defined MetricCategory based on the probe table, the built-in
542559
// preference order, and any operator overrides in deps.SourceOverrides.
@@ -554,11 +571,56 @@ var preferenceOrder = map[MetricCategory][]string{
554571
// 3. If neither path finds anything, the category is omitted.
555572
//
556573
// Overrides that name an unknown gear, a not-Available gear, or a gear
557-
// that doesn't produce data for this category log a warning at startup
558-
// and fall through to auto-detection — operators templating env files
559-
// across heterogeneous fleets shouldn't lose metrics because one host
560-
// doesn't have the override's target installed.
574+
// that doesn't produce data for this category fall through to auto-
575+
// detection — operators templating env files across heterogeneous
576+
// fleets shouldn't lose metrics because one host doesn't have the
577+
// override's target installed. Warnings about mis-targeted overrides
578+
// are emitted **once at startup** by ValidatePrimarySourceOverrides,
579+
// not here, so the API endpoint that calls Resolve on every request
580+
// doesn't turn dashboard polling into log spam.
581+
//
582+
// The function is side-effect-free apart from reading the probe table,
583+
// safe to call concurrently from the capabilities API handler.
561584
func (m *Manager) ResolvePrimarySources() map[MetricCategory]SourceSelection {
585+
picks, _ := m.resolvePrimarySources()
586+
return picks
587+
}
588+
589+
// ValidatePrimarySourceOverrides re-runs the resolver and logs a
590+
// warning for each operator override that doesn't apply on this host
591+
// — unknown gear, not-Available gear, or gear that doesn't produce
592+
// the category in question. Called once from ProbeAll so operators
593+
// see actionable warnings in journalctl at startup without the
594+
// /api/v1/system/capabilities endpoint replaying them on every poll.
595+
//
596+
// Returns the number of override entries that failed validation,
597+
// purely so tests can assert without parsing log output. Production
598+
// callers can ignore the return value.
599+
func (m *Manager) ValidatePrimarySourceOverrides() int {
600+
_, invalid := m.resolvePrimarySources()
601+
for _, inv := range invalid {
602+
m.logger.Warn(inv.message,
603+
"category", inv.category,
604+
"override", inv.override,
605+
"env_var", categoryEnvVar[inv.category])
606+
}
607+
return len(invalid)
608+
}
609+
610+
// overrideValidationFailure captures one mis-targeted override so
611+
// ValidatePrimarySourceOverrides can log it from a single place
612+
// (consistent slog key-set) and tests can count failures.
613+
type overrideValidationFailure struct {
614+
category MetricCategory
615+
override string
616+
message string
617+
}
618+
619+
// resolvePrimarySources does the actual work shared between Resolve
620+
// (silent) and Validate (logs warnings). Returns the picks plus any
621+
// override failures the caller may want to surface. Unexported so the
622+
// validation-vs-resolution split stays an implementation detail.
623+
func (m *Manager) resolvePrimarySources() (map[MetricCategory]SourceSelection, []overrideValidationFailure) {
562624
probed := m.ProbeResults()
563625

564626
// Build category-to-producers from the registered gears that
@@ -584,6 +646,7 @@ func (m *Manager) ResolvePrimarySources() map[MetricCategory]SourceSelection {
584646
}
585647

586648
out := make(map[MetricCategory]SourceSelection)
649+
var failures []overrideValidationFailure
587650
for _, cat := range AllMetricCategories() {
588651
available := producers[cat]
589652
if len(available) == 0 {
@@ -597,64 +660,62 @@ func (m *Manager) ResolvePrimarySources() map[MetricCategory]SourceSelection {
597660
ordered := orderByPreference(available, preferenceOrder[cat])
598661

599662
// Operator override: must be available AND a registered
600-
// producer. Fall through with a warning otherwise.
601-
override := strings.ToLower(strings.TrimSpace(deps_override(m.deps, cat)))
663+
// producer. Capture validation failures for the caller to log,
664+
// but fall through to auto-detect either way.
665+
override := strings.ToLower(strings.TrimSpace(overrideForCategory(m.deps, cat)))
602666
if override != "" {
603667
if _, isProducer := registeredFor[cat][override]; !isProducer {
604-
m.logger.Warn("source override names a gear that doesn't produce this metric category; falling back to auto-detect",
605-
"category", cat,
606-
"override", override,
607-
"env_var", overrideEnvVarFor(cat))
608-
} else if !containsString(ordered, override) {
609-
m.logger.Warn("source override names an unavailable gear; falling back to auto-detect",
610-
"category", cat,
611-
"override", override,
612-
"env_var", overrideEnvVarFor(cat))
668+
failures = append(failures, overrideValidationFailure{
669+
category: cat,
670+
override: override,
671+
message: "source override names a gear that doesn't produce this metric category; falling back to auto-detect",
672+
})
673+
} else if !slices.Contains(ordered, override) {
674+
failures = append(failures, overrideValidationFailure{
675+
category: cat,
676+
override: override,
677+
message: "source override names an unavailable gear; falling back to auto-detect",
678+
})
613679
} else {
680+
// Defensive Clone+DeleteFunc — the Alternatives slice
681+
// returned to callers must not share backing storage
682+
// with `ordered`, which a future change to this
683+
// function could mutate.
684+
alts := slices.Clone(ordered)
685+
alts = slices.DeleteFunc(alts, func(s string) bool { return s == override })
614686
out[cat] = SourceSelection{
615687
Category: cat,
616688
Source: override,
617-
Reason: "operator override via " + overrideEnvVarFor(cat),
618-
Alternatives: withoutString(ordered, override),
689+
Reason: "operator override via " + categoryEnvVar[cat],
690+
Alternatives: alts,
619691
}
620692
continue
621693
}
622694
}
623695

624-
// Auto-detect from preference order.
696+
// Auto-detect from preference order. Clone the alternatives
697+
// slice for the same defensive reason as the override branch.
625698
out[cat] = SourceSelection{
626699
Category: cat,
627700
Source: ordered[0],
628701
Reason: "auto-detected from preference order",
629-
Alternatives: ordered[1:],
702+
Alternatives: slices.Clone(ordered[1:]),
630703
}
631704
}
632705

633-
return out
706+
return out, failures
634707
}
635708

636-
// deps_override fetches the operator's override for a category from
637-
// Dependencies. Standalone (not a method) so tests that build
709+
// overrideForCategory fetches the operator's override for a category
710+
// from Dependencies. Standalone (not a method) so tests that build
638711
// Dependencies directly stay readable.
639-
func deps_override(d Dependencies, cat MetricCategory) string {
712+
func overrideForCategory(d Dependencies, cat MetricCategory) string {
640713
if d.SourceOverrides == nil {
641714
return ""
642715
}
643716
return d.SourceOverrides[cat]
644717
}
645718

646-
// overrideEnvVarFor names the env var that controls a category. Kept
647-
// in one place so warning messages and docs stay aligned with what
648-
// operators actually set in their env files.
649-
func overrideEnvVarFor(cat MetricCategory) string {
650-
switch cat {
651-
case CategoryHTTPRequests:
652-
return "GEARBOX_AGENT_HTTP_SOURCE"
653-
default:
654-
return "(unknown category)"
655-
}
656-
}
657-
658719
// orderByPreference returns the subset of `available` ordered by where
659720
// each entry appears in `preferred`. Entries in `available` that
660721
// aren't in `preferred` get appended at the end (sorted alphabetically
@@ -688,25 +749,6 @@ func orderByPreference(available, preferred []string) []string {
688749
return out
689750
}
690751

691-
func containsString(haystack []string, needle string) bool {
692-
for _, s := range haystack {
693-
if s == needle {
694-
return true
695-
}
696-
}
697-
return false
698-
}
699-
700-
func withoutString(in []string, drop string) []string {
701-
out := make([]string, 0, len(in))
702-
for _, s := range in {
703-
if s != drop {
704-
out = append(out, s)
705-
}
706-
}
707-
return out
708-
}
709-
710752
// RegisterSystemRoutes registers cross-cutting agent endpoints that aren't
711753
// tied to a single gear. Today that's just the capability table; future
712754
// system-wide endpoints belong here too. Mount under the same auth group as

gearbox-agent/internal/framework/gear/source_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,54 @@ func newSourceGear(name string, status ProbeStatus, cats ...MetricCategory) *moc
4848
return g
4949
}
5050

51+
// TestEveryCategoryHasOverrideEnvVar guards against adding a new
52+
// MetricCategory and forgetting to map it to an env var. Without this
53+
// test the missing entry only surfaces as the literal string
54+
// "(unknown category)" leaking into operator-facing log messages and
55+
// SourceSelection.Reason values — exactly the placeholder Copilot
56+
// flagged on PR #96 review. Driving the map from this test means a
57+
// new category that's added to AllMetricCategories() and skipped in
58+
// categoryEnvVar fails CI immediately.
59+
func TestEveryCategoryHasOverrideEnvVar(t *testing.T) {
60+
for _, cat := range AllMetricCategories() {
61+
envVar, ok := categoryEnvVar[cat]
62+
if !ok {
63+
t.Errorf("MetricCategory %q is missing an entry in categoryEnvVar — add one to manager.go", cat)
64+
continue
65+
}
66+
if envVar == "" {
67+
t.Errorf("categoryEnvVar[%q] is empty — set the env-var name", cat)
68+
}
69+
if !strings.HasPrefix(envVar, "GEARBOX_AGENT_") {
70+
t.Errorf("categoryEnvVar[%q] = %q; agent-wide overrides should use the GEARBOX_AGENT_ prefix", cat, envVar)
71+
}
72+
}
73+
}
74+
75+
// TestValidatePrimarySourceOverridesCountsMisses guards the split
76+
// between Resolve (silent) and Validate (logs warnings). The endpoint
77+
// handler calls Resolve on every dashboard poll, so warnings must
78+
// stay in the Validate path that only runs at startup.
79+
func TestValidatePrimarySourceOverridesCountsMisses(t *testing.T) {
80+
hap := newSourceGear("haproxy", ProbeStatusAvailable, CategoryHTTPRequests)
81+
withTestRegistry(t, hap)
82+
83+
deps := Dependencies{
84+
SourceOverrides: map[MetricCategory]string{
85+
CategoryHTTPRequests: "imaginary-server",
86+
},
87+
}
88+
m, _ := newTestManagerWithDeps(t, deps)
89+
m.ProbeAll(context.Background())
90+
91+
// ProbeAll already invoked Validate (via logPrimarySources).
92+
// A second explicit call should still report the same failure
93+
// without changing Resolve's output — Validate is idempotent.
94+
if got := m.ValidatePrimarySourceOverrides(); got != 1 {
95+
t.Errorf("Validate count = %d, want 1 (one mis-targeted override)", got)
96+
}
97+
}
98+
5199
func TestResolvePrimarySources_AutoDetectPicksFirstAvailableFromPreference(t *testing.T) {
52100
// HAProxy first in preferenceOrder, nginx second; both Available.
53101
// Expect HAProxy to win and nginx to be listed as an alternative.

0 commit comments

Comments
 (0)