Skip to content

Commit e3c581f

Browse files
sarg3ntclaude
andauthored
feat(agent): probe phase + startup capability table (#74)
* feat(agent): probe phase + startup capability table Adds a lightweight version of the ProbeableGear mechanism from #60 §1. Each gear self-reports whether its prerequisites are present; gears that probe non-Available are skipped entirely (no Initialize, no Start, no routes, no collectors/streamers). After the probe phase the manager writes a summary table to stderr — readable in the systemd journal — so operators can see at startup which gears are running on this host and which aren't: Gear probe summary: GEAR STATUS REASON certificates enabled haproxy enabled logs enabled metrics enabled security disabled neither fail2ban-client nor nft found on PATH traffic enabled updates enabled Framework changes (internal/framework/gear/): - Add ProbeStatus enum: available, not_installed, inaccessible, disabled - Add ProbeResult struct + helper constructors (ProbeAvailable, etc.) - Add ProbeableGear sub-interface; gears that don't implement it default to Available, so the migration is incremental - Add Manager.ProbeAll, ProbeResults, isLoaded; Initialize/Start/ RegisterRoutes/startCollectors/startStreamers/setupEventHandlers all skip non-Available gears silently (the table already explained why) - Inject tableWriter so tests can capture the rendered output Per-gear probes: - certificates: certbot or acme.sh on PATH/common paths (side-effect-free variant of the existing detectCertbot) - haproxy: stats URL, stats socket existence, or haproxy binary present (with distinct inaccessible reason when the socket path is set but missing — operator's fix differs from "install HAProxy") - logs: journalctl or tail on PATH - metrics: /proc/stat readable (containerized agents without /proc bind- mounted land here as inaccessible) - security: fail2ban-client or nft on PATH - traffic: same surface as haproxy (stick tables go over the same socket) - updates: any of apt-get/apt/dnf/yum/zypper/apk on PATH Tests cover lifecycle skip behavior, default-Available for non-probeable gears, the snapshot copy semantics of ProbeResults, and table formatting (headers, alignment, reasons surfaced only for disabled rows). 9 tests pass under -race. Refs #60 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(agent): document probe phase + capability-driven loading - Add gearbox-agent/docs/gear-probes.md as the canonical reference for the new probe lifecycle: status enum, per-gear contracts, the startup summary table, rules for writing a good Probe(), and an honest accounting of what the probe phase actually saves (CPU/IO and runtime allocations, NOT binary size — every gear is still linked into the binary via blank imports). - Update gearbox-agent/README.md: replace the vague "Auto-discovery" bullet with a concrete description of capability-driven loading and a link to the new doc. - Update docs/gears.md (the shared gear-architecture doc): add a Probe Phase subsection to the "Gearbox Agent Gears" chapter with the ProbeableGear interface, ProbeStatus enum, lifecycle diagram, sample startup table, and a note clarifying that "skipped" means runtime skipping rather than binary-level unloading. Refs #60 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 667268d commit e3c581f

15 files changed

Lines changed: 989 additions & 14 deletions

File tree

docs/gears.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,56 @@ type Gear interface {
350350
}
351351
```
352352

353+
### Probe Phase (ProbeableGear)
354+
355+
The agent runs on hosts with very different software stacks. A gear can optionally implement `ProbeableGear` to declare whether its prerequisites are present on this host. Gears that probe non-`available` are skipped for the rest of the lifecycle — no `Initialize`, no `Start`, no routes, no collectors, no streamers.
356+
357+
```go
358+
type ProbeableGear interface {
359+
Gear
360+
361+
// Probe runs before Initialize. It must be side-effect-free and fast.
362+
Probe(ctx context.Context, deps Dependencies) ProbeResult
363+
}
364+
365+
type ProbeStatus string
366+
367+
const (
368+
ProbeStatusAvailable ProbeStatus = "available"
369+
ProbeStatusNotInstalled ProbeStatus = "not_installed"
370+
ProbeStatusInaccessible ProbeStatus = "inaccessible"
371+
ProbeStatusDisabled ProbeStatus = "disabled"
372+
)
373+
```
374+
375+
Gears that do **not** implement `ProbeableGear` are treated as always-available, so adoption is incremental.
376+
377+
The lifecycle becomes:
378+
379+
```text
380+
Probe → Initialize → Start → (later) Stop
381+
```
382+
383+
After all gears are probed, the manager writes a single human-readable summary table to the journal so an operator can see at startup which gears apply on this box:
384+
385+
```text
386+
Gear probe summary:
387+
388+
GEAR STATUS REASON
389+
certificates enabled
390+
haproxy enabled
391+
logs enabled
392+
metrics enabled
393+
security disabled neither fail2ban-client nor nft found on PATH
394+
traffic enabled
395+
updates enabled
396+
```
397+
398+
The full reference — status semantics, per-gear contracts, container-mode considerations, and the rules for writing a good `Probe()` — lives in [gearbox-agent/docs/gear-probes.md](../gearbox-agent/docs/gear-probes.md).
399+
400+
> [!IMPORTANT]
401+
> "Skipped" means the gear's `Initialize` and `Start` are not called, so its collectors, streamers, and HTTP routes do not run. The compiled binary still contains the gear's code (gears are linked in via blank imports in `cmd/gearbox-agent/main.go`); Go does not support module unloading. The wins are runtime CPU/IO and heap allocations, not binary size.
402+
353403
### Collector Gear
354404

355405
Gears that collect data periodically also implement `CollectorGear`:
@@ -408,6 +458,15 @@ func (g *Gear) Info() gear.Info {
408458
### Initialization and Lifecycle
409459

410460
```go
461+
// Probe is optional. Implement it if your gear can be skipped on hosts
462+
// that lack its prerequisites; see the "Probe Phase" section above.
463+
func (g *Gear) Probe(ctx context.Context, deps gear.Dependencies) gear.ProbeResult {
464+
if _, err := exec.LookPath("mytool"); err != nil {
465+
return gear.ProbeNotInstalled("mytool not found on PATH")
466+
}
467+
return gear.ProbeAvailable("mytool found", nil)
468+
}
469+
411470
func (g *Gear) Initialize(ctx context.Context, deps gear.Dependencies) error {
412471
if err := g.BaseGear.Initialize(ctx, deps); err != nil {
413472
return err

gearbox-agent/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ A Go service that runs on monitored servers and workstations to provide:
55
1. **Gear-based data collection** - Gathers system metrics, service stats, logs, and security data
66
2. **Secure REST API** - Exposes collected data over HTTPS with API key authentication
77
3. **Real-time events** - WebSocket endpoint for live updates to Gearbox dashboard
8-
4. **Auto-discovery** - Detects installed services and enables relevant collectors
8+
4. **Capability-driven gear loading** - At startup the agent probes the host for each gear's prerequisites and loads only the gears that apply
99

10-
**Universal Monitoring:** Works on ANY Linux system - HAProxy hosts, Docker hosts, TrueNAS Scale, workstations, bare servers.
10+
**Universal Monitoring:** Works on ANY Linux system - HAProxy hosts, Docker hosts, TrueNAS Scale, workstations, bare servers. Gears that don't apply to the current host (e.g. `haproxy` on a TrueNAS box) are skipped at startup — no Initialize, no background goroutines, no failing collectors. See [docs/gear-probes.md](docs/gear-probes.md) for the probe lifecycle, status enum, and per-gear contracts.
1111

1212
**HAProxy-Specific Features (when HAProxy is detected):**
1313

@@ -361,6 +361,7 @@ gearbox-agent/
361361

362362
## Documentation
363363

364+
- [Gear Probes](docs/gear-probes.md) - Capability-driven gear loading, status enum, per-gear contracts
364365
- [HAProxy API](docs/haproxy-api.md) - Stats, runtime info, validation
365366
- [Logs API](docs/logs-api.md) - Log streaming
366367
- [Security API](docs/security-api.md) - Fail2ban and firewall stats

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,8 +388,15 @@ func main() {
388388
// Create plugin manager
389389
gearManager := gear.NewManager(gearDeps, logger)
390390

391-
// Initialize all plugins
391+
// Probe the host: each gear self-reports whether its prerequisites are
392+
// present. Gears that probe negative are skipped for the rest of the
393+
// lifecycle (no Initialize, no Start, no routes). A summary table is
394+
// written to stderr (→ systemd journal) so operators can see at a
395+
// glance which gears are running on this box and why others aren't.
392396
ctx := context.Background()
397+
gearManager.ProbeAll(ctx)
398+
399+
// Initialize the gears that probed Available.
393400
if err := gearManager.InitializeAll(ctx); err != nil {
394401
logger.Error("Failed to initialize plugins", "error", err)
395402
os.Exit(1)

gearbox-agent/docs/gear-probes.md

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Gear Probes — capability-driven gear loading
2+
3+
**Version:** 0.1.0 | **Last updated:** 2026-05-13
4+
5+
The gearbox-agent runs on hosts with wildly different software stacks: an HAProxy gateway, a TrueNAS storage box, a Docker host, a workstation. The agent ships every gear it knows about, but a given host can only usefully run a subset of them. The **probe phase** is how the agent figures out, at startup, which gears apply on *this* box.
6+
7+
This document explains:
8+
9+
- What the probe phase does and what it does not do
10+
- The four-state status enum
11+
- Per-gear probe contracts
12+
- The startup summary table
13+
- How to add `Probe()` to a new gear
14+
15+
For background and the broader capability-driven loading design, see issue [#60](https://github.com/sarg3nt/gearbox/issues/60).
16+
17+
## Lifecycle
18+
19+
The agent's gear lifecycle now has four phases, in this order:
20+
21+
```text
22+
Probe → Initialize → Start → (later) Stop
23+
```
24+
25+
1. **Probe** — each gear is asked `Probe(ctx, deps) ProbeResult`. The result is recorded in the manager and rendered to the journal as a summary table.
26+
2. **Initialize** — only gears whose probe returned `available` go through `Initialize`. Their `Collector`, `Streamer`, runner, and event-handler objects are constructed here.
27+
3. **Start** — the same loaded gears have `Start(ctx)` called; background goroutines (log streamers, periodic tickers, etc.) launch.
28+
4. **Stop** — on shutdown, only gears that were started get `Stop(ctx)`.
29+
30+
Gears that probe non-`available` are skipped at every subsequent stage:
31+
32+
- No `Initialize` (no state objects allocated)
33+
- No `Start` (no goroutines, no exec'd subprocesses, no periodic collection)
34+
- No `RegisterRoutes` (their HTTP endpoints simply do not exist on this host)
35+
- No collector/streamer registration
36+
- No event-handler registration
37+
38+
### What is *not* saved
39+
40+
Be honest about the boundaries:
41+
42+
- **Binary size is unchanged.** Every gear is blank-imported in [`cmd/gearbox-agent/main.go`](../cmd/gearbox-agent/main.go), so all gear code is linked into the binary regardless of probe verdict. Go does not support module unloading.
43+
- **Gear `init()` functions still run.** That is how each gear registers itself with `gear.Register(...)`. The empty `&Gear{}` instance is held in the global registry forever.
44+
- **Probe itself runs on every gear.** That is the point — we need the verdict.
45+
46+
### What is saved
47+
48+
The dominant wins are **CPU/IO and runtime RAM**, not code size:
49+
50+
| Skipped step | Cost avoided |
51+
|---------------------------------------|-----------------------------------------------------------------------------------------|
52+
| Construct collectors / streamers | Heap allocations for per-source `tail`/`journalctl` pipelines, parser state, etc. |
53+
| Start background goroutines | Goroutine stacks + scheduler load; with log streaming this is the largest single win |
54+
| Spawn `tail` / `journalctl` children | Subprocess RSS + parent-side read pumps; was the source of the original log-spam loops |
55+
| Periodic collection tickers | Continuous CPU every N seconds, often per-source |
56+
| Chi route entries + event subscribers | Small but real — keeps the router and event bus pointed at gears that actually answer |
57+
58+
For a TrueNAS host that genuinely has nothing this agent cares about (no HAProxy, no certbot, no fail2ban, no apt), the probe phase reduces the runtime from "five permanently-degraded gears noisily failing every two seconds" to "everything skipped, agent sits idle." That is the regression the probe phase exists to fix.
59+
60+
## Status enum
61+
62+
Each gear reports one of four statuses via `ProbeResult.Status`. The statuses are not interchangeable: each implies a different operator fix, and conflating them is what historically cost debugging time.
63+
64+
| Status | Meaning | Operator action |
65+
|-----------------|---------------------------------------------------------------|--------------------------------------------------------------------------|
66+
| `available` | Prereqs present and reachable. Gear loads normally. | None. |
67+
| `not_installed` | The thing the gear manages isn't on this host at all. | Expected on hosts that don't run this software. |
68+
| `inaccessible` | Prereqs exist, but the agent can't reach them. | Fix the access — add bind mount, adjust permissions, set correct path. |
69+
| `disabled` | Forced off by configuration. | Change the config to re-enable. (Reserved; no gear returns this today.) |
70+
71+
The distinction between `not_installed` and `inaccessible` matters most in container mode. An agent running in a Docker container on an HAProxy host that wasn't given the right bind mounts should report `inaccessible` for the haproxy gear — *not* `not_installed`, because that would send the operator to install HAProxy on a box that already has it.
72+
73+
`ProbeResult.Reason` is a human-readable sentence that names what surface was probed and what was wrong. Examples:
74+
75+
- `"stats socket configured at /run/haproxy/admin.sock but does not exist (HAProxy not running, or bind mount missing in container mode)"`
76+
- `"neither fail2ban-client nor nft found on PATH"`
77+
- `"no haproxy binary on PATH; no stats socket or URL configured"`
78+
79+
Bad reasons (do not ship these):
80+
81+
- `"prereqs not met"`
82+
- `"haproxy unavailable"`
83+
- `"see logs"`
84+
85+
## Per-gear probe contracts
86+
87+
Each gear's `Probe()` determines `available` from a clearly-named host surface. The table below is the current contract; expect this to evolve as #60 lands its container-mode probing work.
88+
89+
| Gear | `available` when | Capabilities reported |
90+
|----------------|-------------------------------------------------------------------------------|--------------------------------------------------|
91+
| `certificates` | `certbot` on PATH or in common install paths, **or** `acme.sh` home present | `manager`, `path` / `home` |
92+
| `haproxy` | Stats URL set, **or** stats socket file exists, **or** `haproxy` on PATH | `stats_url` / `stats_socket` |
93+
| `logs` | `journalctl` **or** `tail` on PATH | `journalctl`, `tail` |
94+
| `metrics` | `/proc/stat` readable ||
95+
| `security` | `fail2ban-client` **or** `nft` on PATH | `fail2ban`, `nftables` |
96+
| `traffic` | Same as `haproxy` (stick tables share the stats socket) | `stats_url` / `stats_socket` |
97+
| `updates` | One of `apt-get` / `apt` / `dnf` / `yum` / `zypper` / `apk` on PATH | `package_manager`, `path` |
98+
99+
The `haproxy` and `traffic` probes deliberately distinguish three states:
100+
101+
- **`available`** — stats URL configured, or stats socket file actually exists.
102+
- **`inaccessible`** — socket path is configured but missing on disk; or haproxy binary is present but neither stats source is configured. The fix differs from "install HAProxy."
103+
- **`not_installed`** — no haproxy binary, no socket, no URL. The host genuinely doesn't run HAProxy.
104+
105+
## Startup summary table
106+
107+
After all probes run, the manager writes a single human-readable table to stderr (which lands in the systemd journal on systemd hosts, and in `docker logs` for container deployments):
108+
109+
```text
110+
Gear probe summary:
111+
112+
GEAR STATUS REASON
113+
certificates enabled
114+
haproxy enabled
115+
logs enabled
116+
metrics enabled
117+
security disabled neither fail2ban-client nor nft found on PATH
118+
traffic enabled
119+
updates enabled
120+
```
121+
122+
Conventions:
123+
124+
- Status column shows `enabled` (probe returned `available`) or `disabled` (any other status). The distinct `not_installed` / `inaccessible` / `disabled` reasons live in the reason column for `disabled` rows.
125+
- Reason column is **blank** for enabled rows — clutter-suppressed, since the detected version/path is already in the structured slog stream via `gear probe complete`.
126+
- Columns are auto-aligned to the widest cell; the table works for one gear or twenty.
127+
128+
In addition to the table, the probe phase emits structured slog lines that operators or log shippers can parse:
129+
130+
```text
131+
INFO probing host for gear capabilities registered_gears=7
132+
INFO gear probe complete registered=7 available=6 unavailable=1
133+
```
134+
135+
The dashboard (gearbox web UI) consumes this information via the upcoming `/api/v1/system/capabilities` endpoint — tracked in [#60](https://github.com/sarg3nt/gearbox/issues/60) §6.
136+
137+
## Adding `Probe()` to a new gear
138+
139+
`ProbeableGear` is a **sub-interface** of `Gear`. Implementing it is optional: gears that don't are treated as always-available, which preserves the pre-probe-phase behaviour. Concretely:
140+
141+
```go
142+
package mygear
143+
144+
import (
145+
"context"
146+
"os/exec"
147+
148+
"github.com/sarg3nt/gearbox-agent/internal/framework/gear"
149+
)
150+
151+
func (g *Gear) Probe(ctx context.Context, deps gear.Dependencies) gear.ProbeResult {
152+
// Detect prereqs side-effect-free. No connections, no state mutation,
153+
// no loud logging — the manager logs a single summary line per gear.
154+
path, err := exec.LookPath("the-thing-i-need")
155+
if err != nil {
156+
return gear.ProbeNotInstalled(
157+
"the-thing-i-need binary not found on PATH",
158+
)
159+
}
160+
return gear.ProbeAvailable(
161+
"the-thing-i-need found",
162+
map[string]string{"path": path},
163+
)
164+
}
165+
```
166+
167+
Rules for a good `Probe()`:
168+
169+
1. **Be fast.** Probe runs synchronously before Initialize. Don't open network connections, don't shell out to slow commands (`find /` etc.), don't read multi-megabyte config files.
170+
2. **Be side-effect-free.** No state mutation on the gear receiver. No log spam. The manager logs once per gear; if your probe is chatty, raise it to `Debug` or remove it.
171+
3. **Use the helpers.** `ProbeAvailable`, `ProbeNotInstalled`, `ProbeInaccessible`, `ProbeDisabled` exist so reviewers can see the verdict at a glance.
172+
4. **Reason like a sentence.** "What did I probe, what did I expect, what did I get?" An operator should be able to act on the reason without reading source.
173+
5. **Distinguish missing-thing from missing-access.** If the configured path doesn't exist *as a file*, that may be `not_installed` (host doesn't run it) or `inaccessible` (bind mount missing). Check the parent directory: if the *parent* doesn't exist, mount is missing → `inaccessible`. If parent exists but the file doesn't, host genuinely lacks it → `not_installed`.
174+
175+
## Testing
176+
177+
Manager-level tests live in [`internal/framework/gear/manager_probe_test.go`](../internal/framework/gear/manager_probe_test.go). They use a `withTestRegistry` helper that swaps the global registry's plugin map for an isolated one, so each test controls exactly which gears are registered.
178+
179+
A typical unit test for per-gear `Probe()` should:
180+
181+
- Stub out filesystem / `exec.LookPath` lookups (use `t.TempDir()` and prepend it to `$PATH` via `t.Setenv("PATH", ...)`).
182+
- Assert both the `Status` and the substring of `Reason` an operator would search for.
183+
184+
## Related
185+
186+
- [#60 — capability-driven gear loading + containerizable agent](https://github.com/sarg3nt/gearbox/issues/60) — the broader design; §6 covers the future `/api/v1/system/capabilities` endpoint and the container-mode probing rules.
187+
- [docs/docker.md](docker.md) — current container deployment, including the bind mounts each probe expects to see in container mode.
188+
- [Top-level gear architecture (docs/gears.md)](../../docs/gears.md) — gear system overview shared by gearbox and gearbox-agent.

0 commit comments

Comments
 (0)