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
+38 -1
View File
@@ -8,6 +8,7 @@
package main
import (
"context"
"encoding/json"
"io"
"log"
@@ -35,7 +36,8 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/enrol/{id}/approve", s.handleEnrolApprove)
mux.HandleFunc("POST /api/enrol/{id}/deny", s.handleEnrolDeny)
// METRICS routes (/api/hosts/{serial}/metrics) — checkpoint 4.
// METRICS — time-series for one host/field, read from InfluxDB via the manager.
mux.HandleFunc("GET /api/hosts/{serial}/metrics", s.handleMetrics)
}
func (s *server) handleListHosts(w http.ResponseWriter, r *http.Request) {
@@ -141,6 +143,41 @@ func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, born)
}
// ---- METRICS (InfluxDB time-series via the manager gateway) ---------------
func (s *server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if s.influx == nil {
writeErr(w, http.StatusServiceUnavailable, "metrics not configured (no InfluxDB wired)")
return
}
serial := r.PathValue("serial")
if _, ok := s.store.Get(serial); !ok {
writeErr(w, http.StatusNotFound, "no such host")
return
}
field := r.URL.Query().Get("field")
if field == "" {
field = "tmpfs_used_pct"
}
rng := time.Hour
if rs := r.URL.Query().Get("range"); rs != "" {
d, err := time.ParseDuration(rs)
if err != nil || d <= 0 {
writeErr(w, http.StatusBadRequest, "invalid range (use e.g. 30m, 1h, 24h)")
return
}
rng = d
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
pts, err := s.influx.QueryField(ctx, serial, field, rng)
if err != nil {
writeErr(w, http.StatusBadGateway, "metrics query: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"serial": serial, "field": field, "range": rng.String(), "points": pts})
}
func (s *server) handleEnrolDeny(w http.ResponseWriter, r *http.Request) {
if s.broker == nil {
writeErr(w, http.StatusServiceUnavailable, "authorise plane not configured (no enrol-broker wired)")