Skip to content

Commit 5819f15

Browse files
authored
feat(#95): agent — metric-source primary selection + per-category overrides (#96)
* feat(#95): agent — metric-source primary selection + per-category overrides First foundational PR for issue #95 (Phase 3 of #91). Establishes the "who's the primary metric source on this host" mechanism so future PRs can drop in nginx / Apache / Caddy / Traefik detectors without re- architecting how the dashboard picks whose numbers to render. Background: most hosts have one obvious producer per metric category (HTTP requests, response codes, etc.). Where two coexist — e.g. HAProxy fronting nginx, both genuinely serving traffic — the agent now picks one as primary using a built-in preference list and surfaces the choice in the capability manifest. The dashboard reads primary_sources and renders that source's data; alternatives travel along so a "switch source" UI can offer them. Operators override the pick per-category via env var for the edge case where auto-detection chooses wrong. Adds: - MetricCategory + MetricSourceGear interface (gear/source.go). One category defined today: CategoryHTTPRequests. The interface is opt-in — gears that don't produce metrics simply don't implement it. - preferenceOrder map keyed by category; first-match-wins among Available producers. Documented rationale for the homelab-flavoured ordering (HAProxy first as the L7 entry point). - Manager.ResolvePrimarySources() — resolves the primary for each category, honouring an operator override if present + valid, falling back to auto-detection with a warning otherwise. Producers not in preferenceOrder still surface as alternatives (alphabetised) so newer gears don't disappear from the UI before someone gets around to ranking them. - SourceSelection struct surfaced in CapabilitiesResponse under primary_sources — fields are stable JSON keys: source, reason, alternatives. - Per-category override env var: GEARBOX_AGENT_HTTP_SOURCE. Names a gear; case-insensitive, trimmed, lowercased at load. Validated against registered producers; invalid names log a warning and fall back rather than dropping metrics for the category. - Startup log line per resolved category so operators can confirm their override took effect via journalctl without hitting the API. - HAProxy gear declares CategoryHTTPRequests so the existing source works end-to-end immediately. Tests (8 new in gear/source_test.go, 3 new in config_test.go): - Auto-detect picks first Available from preferenceOrder. - Operator override wins over preference; reason names env var. - Override target unavailable → fall back to auto + warning. - Override target unknown / not a producer for the category → same. - No available producers → category omitted from result entirely. - Unranked producer still surfaces as alternative (alphabetised). - End-to-end /api/v1/system/capabilities includes primary_sources. - normaliseSourceOverride trims + lowercases edge cases. - Load() picks up GEARBOX_AGENT_HTTP_SOURCE; default is empty. Docs: README "Metric-source overrides" section explains the model and override behaviour, with explicit "auto-detection is the default" copy. Out of scope (later phases of #95): - nginx / Apache / Caddy / Traefik / Docker gear stubs. - Additional metric categories (backend health, container metrics). - Dashboard consumption of primary_sources. Refs: docs/research/metrics-source-agnostic.md (#91), issue #95. * 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 2a3fc24 commit 5819f15

9 files changed

Lines changed: 784 additions & 4 deletions

File tree

gearbox-agent/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,26 @@ HAPROXY_STATS_USER=admin
206206
HAPROXY_STATS_PASSWORD=secret
207207
```
208208

209+
### Metric-source overrides
210+
211+
Most hosts have one obvious producer per metric category. Where two coexist — for example HAProxy fronting nginx, with both genuinely serving HTTP — the agent auto-picks a primary using a built-in preference list and surfaces it in the capability manifest. Operators can override that pick when auto-detection chooses wrong on their host.
212+
213+
Auto-detection is the default. The override env vars below exist for the rare edge cases.
214+
215+
```bash
216+
# Force a specific gear as the primary for HTTP-request metrics
217+
# (request volume, response codes, response times, vhost breakdowns).
218+
# Valid values match gear identifiers in /api/v1/system/capabilities —
219+
# today only "haproxy" is implemented; more land with issue #95.
220+
GEARBOX_AGENT_HTTP_SOURCE=nginx
221+
```
222+
223+
Override behaviour:
224+
225+
- Names are case-insensitive and trimmed (`HAProxy` and `haproxy` both work).
226+
- An override pointing at a gear that didn't probe Available, or doesn't produce data for the category, logs a warning at startup and **falls back to auto-detect** — locking out HTTP metrics because the override's target isn't installed on this box would be worse than serving auto-picked data.
227+
- The selected primary plus the chosen reason and the alternatives that were also available appear in `/api/v1/system/capabilities` under `primary_sources` so dashboards and humans can confirm the resolution.
228+
209229
## Authentication
210230

211231
### API Key

gearbox-agent/cmd/gearbox-agent/main.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ func main() {
383383
HAProxyStatsPassword: cfg.HAProxyStatsPassword,
384384
HAProxyConfigPath: cfg.HAProxyConfigFile,
385385
CertbotTimer: cfg.CertbotTimer,
386+
SourceOverrides: buildSourceOverrides(cfg),
386387
}
387388

388389
// Create plugin manager
@@ -565,3 +566,16 @@ func main() {
565566

566567
logger.Info("Server stopped")
567568
}
569+
570+
// buildSourceOverrides packs the per-category override env vars from
571+
// the agent's Config into the category-keyed map that Dependencies
572+
// (and the manager's primary-source resolver) consume. Empty values
573+
// are omitted so the map only contains explicit operator picks —
574+
// callers downstream treat absence as "auto-detect".
575+
func buildSourceOverrides(cfg *config.Config) map[gear.MetricCategory]string {
576+
out := make(map[gear.MetricCategory]string)
577+
if cfg.HTTPSource != "" {
578+
out[gear.CategoryHTTPRequests] = cfg.HTTPSource
579+
}
580+
return out
581+
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ type Config struct {
6666
// endpoint + schema list to unauthenticated callers in production. See
6767
// 2026-05 security audit P3-2.
6868
SwaggerEnabled bool
69+
70+
// Metric-source overrides. Each one points a single metric category
71+
// at a specific gear, bypassing auto-detection. Empty = pure
72+
// auto-detection (built-in preference order). Lowercased at load
73+
// time; the manager validates that each named gear is actually
74+
// available before honouring the override (falls back to auto and
75+
// warns otherwise).
76+
//
77+
// Background: most hosts have one obvious producer per metric
78+
// category. When two coexist (e.g. HAProxy + nginx both serving
79+
// HTTP), the agent picks one as primary; this override lets the
80+
// operator force the choice for boxes where the built-in
81+
// preference picks wrong. See [docs/source-detection.md] /
82+
// issue #95.
83+
HTTPSource string // GEARBOX_AGENT_HTTP_SOURCE — primary for CategoryHTTPRequests
6984
}
7085

7186
// DefaultConfig returns the default configuration.
@@ -161,9 +176,22 @@ func Load() (*Config, error) {
161176
// Swagger UI off by default; opt in for dev / API debugging.
162177
cfg.SwaggerEnabled = os.Getenv("HAPROXY_AGENT_SWAGGER_ENABLED") == "true"
163178

179+
// Metric-source overrides. Lowercased + trimmed so 'HAProxy ' and
180+
// 'haproxy' both match the gear's Info().Name. Empty = auto-detect.
181+
cfg.HTTPSource = normaliseSourceOverride(os.Getenv("GEARBOX_AGENT_HTTP_SOURCE"))
182+
164183
return cfg, nil
165184
}
166185

186+
// normaliseSourceOverride trims + lowercases an operator-supplied gear
187+
// name so case / whitespace differences ('HAProxy ' vs 'haproxy') don't
188+
// cause silent misses against Info().Name. Returns "" for an empty or
189+
// whitespace-only input, which downstream callers treat as "no
190+
// override, auto-detect".
191+
func normaliseSourceOverride(raw string) string {
192+
return strings.ToLower(strings.TrimSpace(raw))
193+
}
194+
167195
// Validate validates the configuration.
168196
func (c *Config) Validate() error {
169197
if c.ListenAddr == "" {

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,3 +375,52 @@ func TestGetEnvDurationSecondsOrDefault(t *testing.T) {
375375
})
376376
}
377377
}
378+
379+
func TestNormaliseSourceOverride(t *testing.T) {
380+
cases := []struct {
381+
in, want string
382+
}{
383+
{"", ""},
384+
{" ", ""},
385+
{"haproxy", "haproxy"},
386+
{"HAProxy", "haproxy"},
387+
{" HAPROXY ", "haproxy"},
388+
{" Nginx ", "nginx"},
389+
}
390+
for _, tc := range cases {
391+
if got := normaliseSourceOverride(tc.in); got != tc.want {
392+
t.Errorf("normaliseSourceOverride(%q) = %q, want %q", tc.in, got, tc.want)
393+
}
394+
}
395+
}
396+
397+
func TestLoad_HTTPSourceOverride(t *testing.T) {
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 ")
402+
cfg, err := Load()
403+
if err != nil {
404+
t.Fatalf("Load: %v", err)
405+
}
406+
if cfg.HTTPSource != "nginx" {
407+
t.Errorf("HTTPSource = %q, want %q (trimmed + lowercased)", cfg.HTTPSource, "nginx")
408+
}
409+
}
410+
411+
func TestLoad_HTTPSourceDefaultsEmpty(t *testing.T) {
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
417+
os.Unsetenv("GEARBOX_AGENT_HTTP_SOURCE")
418+
419+
cfg, err := Load()
420+
if err != nil {
421+
t.Fatalf("Load: %v", err)
422+
}
423+
if cfg.HTTPSource != "" {
424+
t.Errorf("HTTPSource = %q, want empty (auto-detect)", cfg.HTTPSource)
425+
}
426+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ type Dependencies struct {
4545

4646
// CertbotTimer is the name of the certbot systemd timer.
4747
CertbotTimer string
48+
49+
// SourceOverrides maps metric categories to an operator-chosen
50+
// primary gear name, bypassing auto-detection for that category.
51+
// Empty map (or missing key) means auto-detect. Sourced from
52+
// per-category env vars at startup (GEARBOX_AGENT_HTTP_SOURCE for
53+
// CategoryHTTPRequests, etc.). Names are pre-lowercased to match
54+
// Info().Name lookups in the manager.
55+
SourceOverrides map[MetricCategory]string
4856
}
4957

5058
// Common event types used across plugins.

0 commit comments

Comments
 (0)