Skip to content

Commit 6c0e673

Browse files
committed
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.
1 parent 2a3fc24 commit 6c0e673

9 files changed

Lines changed: 701 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: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,3 +375,59 @@ 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+
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 ")
408+
cfg, err := Load()
409+
if err != nil {
410+
t.Fatalf("Load: %v", err)
411+
}
412+
if cfg.HTTPSource != "nginx" {
413+
t.Errorf("HTTPSource = %q, want %q (trimmed + lowercased)", cfg.HTTPSource, "nginx")
414+
}
415+
}
416+
417+
func TestLoad_HTTPSourceDefaultsEmpty(t *testing.T) {
418+
saved := os.Getenv("GEARBOX_AGENT_HTTP_SOURCE")
419+
os.Unsetenv("GEARBOX_AGENT_HTTP_SOURCE")
420+
t.Cleanup(func() {
421+
if saved != "" {
422+
os.Setenv("GEARBOX_AGENT_HTTP_SOURCE", saved)
423+
}
424+
})
425+
426+
cfg, err := Load()
427+
if err != nil {
428+
t.Fatalf("Load: %v", err)
429+
}
430+
if cfg.HTTPSource != "" {
431+
t.Errorf("HTTPSource = %q, want empty (auto-detect)", cfg.HTTPSource)
432+
}
433+
}

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)