fleet-manager: checkpoint 4 — InfluxDB metrics gateway (write + Flux read)

The manager is the ONLY thing that talks to InfluxDB, both directions; hosts hold
no token and never reach it.

- influx.go: influxSink implements MetricsSink — each agent HealthSnapshot is
  written as a fleet_health point, identity (serial/hostnqn/starship) as TAGS
  from the AUTHENTICATED stream (a host can only write its own series), telemetry
  as fields. QueryField answers the SPA's read: a Flux query built with a strict
  FIELD WHITELIST + hex-serial guard + parsed-duration range, so no caller input
  is interpolated raw (injection-safe). Server-side influxdb-client-go/v2.
- admin.go: GET /api/hosts/{serial}/metrics?field=&range= -> time-series JSON;
  503 when InfluxDB isn't wired, 400 on a bad range, 404 for unknown host.
- main.go: sink created when FLEET_MGR_INFLUX_URL+_TOKEN set, fed to the agent
  hub; nil -> no-op sink so the manager runs without metrics.

Tests: snapshotPoint tags/fields/time, fluxQuery contents + %q quoting, and
QueryField rejects a non-hex serial + non-whitelisted field. build/test/vet/gofmt
clean. Live InfluxDB path validated in the compose bench (ckpt 6).
This commit is contained in:
Audiobuzz
2026-07-04 03:59:18 +00:00
parent df4fa95bdd
commit 9c86cd4c5a
7 changed files with 316 additions and 6 deletions
+26 -2
View File
@@ -42,6 +42,9 @@ type config struct {
brokerClientKey string
brokerCA string
// InfluxDB (the manager is the only reader/writer; token is server-side only).
influx influxConfig
inventoryPath string
seed bool
hostnqnBase string
@@ -65,6 +68,13 @@ func loadConfig() config {
brokerClientKey: env("FLEET_MGR_BROKER_CLIENT_KEY", ""),
brokerCA: env("FLEET_MGR_BROKER_CA", ""),
influx: influxConfig{
url: env("FLEET_MGR_INFLUX_URL", ""),
token: env("FLEET_MGR_INFLUX_TOKEN", ""),
org: env("FLEET_MGR_INFLUX_ORG", "fleet"),
bucket: env("FLEET_MGR_INFLUX_BUCKET", "fleet_health"),
},
inventoryPath: env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"),
seed: env("FLEET_MGR_SEED", "") == "true",
hostnqnBase: env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-"),
@@ -77,6 +87,7 @@ type server struct {
store Store
hub *agentHub // live agent streams; nil if the agent plane isn't configured
broker *brokerClient // enrol-broker admin client; nil if authorise isn't configured
influx *influxSink // metrics gateway; nil if InfluxDB isn't configured
}
func main() {
@@ -106,11 +117,24 @@ func main() {
log.Printf("authorise pillar DISABLED (set FLEET_MGR_BROKER_URL + client cert/key + CA to enable)")
}
// Metrics gateway: the manager is the only reader/writer of InfluxDB. nil (a
// no-op MetricsSink) when unconfigured, so the manager runs without metrics.
var sink MetricsSink
if cfg.influx.url != "" && cfg.influx.token != "" {
s.influx = newInfluxSink(cfg.influx)
defer s.influx.Close()
sink = s.influx
log.Printf("InfluxDB metrics gateway -> %s (org=%s bucket=%s)", cfg.influx.url, cfg.influx.org, cfg.influx.bucket)
} else {
log.Printf("InfluxDB DISABLED (set FLEET_MGR_INFLUX_URL + _TOKEN to enable metrics)")
}
// Agent plane (gRPC/mTLS, fleet CA). Started only when configured; the admin
// plane can run standalone (e.g. before any host is enrolled). The hub is the
// live-stream registry the lifecycle API pushes desired-state through.
// live-stream registry the lifecycle API pushes desired-state through, and it
// writes each snapshot to the metrics sink.
if cfg.agentCert != "" && cfg.agentKey != "" && cfg.agentClientCA != "" {
s.hub = newAgentHub(store, nil) // InfluxDB sink wired in checkpoint 4
s.hub = newAgentHub(store, sink)
go func() {
if err := serveAgent(cfg, s.hub); err != nil {
log.Fatalf("agent plane: %v", err)