slice 9: GET /host/metrics + CPU/chassis-temp collector (v0.14.0)

Add a host-wide, token-authed GET /host/metrics local-API endpoint that
re-serves the slice-4 collector's host + per-storage view to the customer
(the de-privileged controller can't read the host itself). Add the one new
collector — CPU/chassis temperature via sysfs hwmon/thermal-zones, graceful-
null — to the shared HostMetrics struct, so the hub report carries cpu_temp_c
too. Cross-repo host-report golden updated byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:16:03 +02:00
parent 9a0e7e168b
commit aa4dfb75ea
13 changed files with 664 additions and 55 deletions
+49
View File
@@ -0,0 +1,49 @@
package localapi
import (
"net/http"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// Host metrics (slice 9, doc 03 §6). The de-privileged controller (slice 8C) can only see its own
// cgroup, so it cannot read host health itself. This endpoint re-serves the slice-4 collector's
// host + per-storage view to the customer so the controller can render the box's health.
//
// Host-wide, token-authed, fresh: the metrics are about the BOX (not per-guest), so any valid
// per-guest token gets the host-wide view (assumption: one customer per host — the home-server
// model). It is a live collect (fresh cpu%/temp), not the 15-min hub-report snapshot.
// HostMetricsResponse is GET /host/metrics: the host block + per-storage capacity targets.
type HostMetricsResponse struct {
VMID int `json:"vmid"`
Host hub.HostMetrics `json:"host"` // cpu%/mem/load/uptime/cpu_temp_c
StorageTargets []hub.StorageTarget `json:"storage_targets"` // per-storage total/used/thin-pool/SMART temp+wear
}
// handleHostMetrics serves a fresh host-health snapshot. The host block comes from a live
// collector read; the per-storage capacity comes from the observer (the same source the hub
// report uses). Best-effort on storage: a storage-view error still returns the host block (the
// CPU/mem/temp view is the headline) with an empty targets list.
func (s *Server) handleHostMetrics(w http.ResponseWriter, r *http.Request, vmid int) {
if s.hostMetrics == nil {
writeErr(w, http.StatusServiceUnavailable, "host metrics not configured on this host")
return
}
host, err := s.hostMetrics.HostMetricsNow(r.Context())
if err != nil {
s.logger.Error("local-api: /host/metrics collect", "vmid", vmid, "err", err)
writeErr(w, http.StatusBadGateway, "could not read host metrics")
return
}
targets, err := s.storage.Observe(r.Context())
if err != nil {
// Host health is the headline — don't sink it on a storage-view hiccup.
s.logger.Warn("local-api: /host/metrics storage view unavailable", "vmid", vmid, "err", err)
targets = []hub.StorageTarget{}
}
if targets == nil {
targets = []hub.StorageTarget{}
}
writeOK(w, HostMetricsResponse{VMID: vmid, Host: host, StorageTargets: targets})
}
+144
View File
@@ -0,0 +1,144 @@
package localapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// fakeHostMetrics is a HostMetricsProvider returning a fixed host block (or an error).
type fakeHostMetrics struct {
host hub.HostMetrics
err error
}
func (f fakeHostMetrics) HostMetricsNow(context.Context) (hub.HostMetrics, error) {
return f.host, f.err
}
func newHostMetricsServer(t *testing.T, hm HostMetricsProvider, sv StorageView) http.Handler {
t.Helper()
if sv == nil {
sv = fakeStorage{}
}
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0",
Guests: &fakeGuests{},
Backups: &fakeBackups{},
Store: &fakeStore{},
Storage: sv,
Tokens: staticTokens{"A": 8200, "B": 9300},
HostMetrics: hm,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("new server: %v", err)
}
srv.baseCtx = context.Background()
return srv.Handler()
}
func cpuTempPtr(v int) *int { return &v }
// A valid token gets a populated host + storage view (host-wide, token-authed).
func TestHostMetrics_PopulatedWithValidToken(t *testing.T) {
hm := fakeHostMetrics{host: hub.HostMetrics{
Node: "demo-felhom", CPUPercent: 12.5, MemoryTotalBytes: 16 << 30, MemoryUsedBytes: 4 << 30,
MemoryPercent: 25, UptimeSeconds: 86400, LoadAvg: []string{"0.10", "0.20", "0.15"},
CPUTempC: cpuTempPtr(46),
}}
sv := fakeStorage{targets: []hub.StorageTarget{
{Name: "local", Type: hub.StorageTypeLocal, State: hub.StorageStateAttached, Reachable: true,
TotalBytes: 100 << 30, UsedBytes: 20 << 30, UsedFraction: 0.2},
}}
h := newHostMetricsServer(t, hm, sv)
w := do(t, h, "GET", "/host/metrics", "A", "")
if w.Code != http.StatusOK {
t.Fatalf("GET /host/metrics: got %d, want 200 (body=%s)", w.Code, w.Body.String())
}
var env struct {
OK bool `json:"ok"`
Data HostMetricsResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v", err)
}
if !env.OK {
t.Fatal("ok=false")
}
if env.Data.VMID != 8200 {
t.Errorf("vmid = %d, want 8200 (token's guest)", env.Data.VMID)
}
if env.Data.Host.Node != "demo-felhom" || env.Data.Host.CPUPercent != 12.5 {
t.Errorf("host = %+v", env.Data.Host)
}
if env.Data.Host.CPUTempC == nil || *env.Data.Host.CPUTempC != 46 {
t.Errorf("cpu_temp_c = %v, want 46", env.Data.Host.CPUTempC)
}
if len(env.Data.StorageTargets) != 1 || env.Data.StorageTargets[0].Name != "local" {
t.Errorf("storage targets = %+v", env.Data.StorageTargets)
}
}
// Null cpu_temp_c marshals as JSON null (the controller renders "n/a").
func TestHostMetrics_NullTempSerializes(t *testing.T) {
hm := fakeHostMetrics{host: hub.HostMetrics{Node: "n", LoadAvg: []string{}, CPUTempC: nil}}
h := newHostMetricsServer(t, hm, nil)
w := do(t, h, "GET", "/host/metrics", "A", "")
if w.Code != http.StatusOK {
t.Fatalf("got %d, want 200", w.Code)
}
// The raw JSON must contain `"cpu_temp_c":null` (a stable key, not omitted).
if got := w.Body.String(); !strings.Contains(got, `"cpu_temp_c":null`) {
t.Errorf("body missing cpu_temp_c null: %s", got)
}
}
// No token → 401, and the collector is never invoked.
func TestHostMetrics_Requires401WithoutToken(t *testing.T) {
called := false
hm := callbackHostMetrics{fn: func() { called = true }}
h := newHostMetricsServer(t, hm, nil)
if w := do(t, h, "GET", "/host/metrics", "", ""); w.Code != http.StatusUnauthorized {
t.Fatalf("absent token: got %d, want 401", w.Code)
}
if w := do(t, h, "GET", "/host/metrics", "bogus", ""); w.Code != http.StatusUnauthorized {
t.Fatalf("unknown token: got %d, want 401", w.Code)
}
if called {
t.Fatal("host metrics collected despite failed auth")
}
}
// When no provider is wired the endpoint reports "not configured" (503), not a crash.
func TestHostMetrics_NotConfigured(t *testing.T) {
h := newHostMetricsServer(t, nil, nil)
if w := do(t, h, "GET", "/host/metrics", "A", ""); w.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d, want 503 (not configured)", w.Code)
}
}
// A cross-guest probe (?vmid=other) is refused 403 even though the data is host-wide — the
// self-scoping invariant is uniform across endpoints.
func TestHostMetrics_CrossGuestQueryRefused(t *testing.T) {
hm := fakeHostMetrics{host: hub.HostMetrics{Node: "n", LoadAvg: []string{}}}
h := newHostMetricsServer(t, hm, nil)
if w := do(t, h, "GET", "/host/metrics?vmid=9300", "A", ""); w.Code != http.StatusForbidden {
t.Fatalf("cross-guest query: got %d, want 403", w.Code)
}
}
// callbackHostMetrics records that the collector was invoked (to assert it is NOT on a 401).
type callbackHostMetrics struct{ fn func() }
func (c callbackHostMetrics) HostMetricsNow(context.Context) (hub.HostMetrics, error) {
c.fn()
return hub.HostMetrics{LoadAvg: []string{}}, nil
}
+22 -5
View File
@@ -54,6 +54,13 @@ type TokenAuthority interface {
Lookup(token string) (int, bool)
}
// HostMetricsProvider does a FRESH host-metrics collect (cpu%/mem/load/uptime/cpu-temp) for
// GET /host/metrics (slice 9). Satisfied by *hub.Collector (which reuses the slice-4 collector —
// no duplicate collection). Optional: when nil, /host/metrics reports "not configured".
type HostMetricsProvider interface {
HostMetricsNow(ctx context.Context) (hub.HostMetrics, error)
}
// Options configures a Server.
type Options struct {
ListenAddr string // bridge IP:port
@@ -73,7 +80,11 @@ type Options struct {
Disks DiskOps
DiskGate StorageGate
Guests2 GuestLister
Logger *slog.Logger
// HostMetrics serves GET /host/metrics (slice 9) — host-wide health (cpu%/mem/load/uptime/
// cpu-temp) + per-storage capacity, host-wide and token-authed (one-customer-per-host). When
// nil the endpoint reports "not configured" (host still reports/reconciles).
HostMetrics HostMetricsProvider
Logger *slog.Logger
}
// defaultBackupCadence is the fallback /backup/due window when none is configured.
@@ -117,6 +128,8 @@ type Server struct {
diskGate StorageGate // slice 8C (optional)
guestList GuestLister // slice 8C (optional)
hostMetrics HostMetricsProvider // slice 9 (optional)
jobsMu sync.Mutex
jobs map[int]*backupJob // per-guest backup job state (slice 8B)
@@ -149,10 +162,11 @@ func NewServer(o Options) (*Server, error) {
cadence: cadence,
logger: o.Logger,
now: func() time.Time { return time.Now().UTC() },
disks: o.Disks,
diskGate: o.DiskGate,
guestList: o.Guests2,
jobs: map[int]*backupJob{},
disks: o.Disks,
diskGate: o.DiskGate,
guestList: o.Guests2,
hostMetrics: o.HostMetrics,
jobs: map[int]*backupJob{},
}, nil
}
@@ -166,6 +180,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /backup/due", s.withGuest(s.handleBackupDue))
mux.HandleFunc("GET /backup/status", s.withGuest(s.handleBackupStatus))
mux.HandleFunc("GET /restore-test/status", s.withGuest(s.handleRestoreTestStatus))
// Host metrics (slice 9): host-wide health + per-storage capacity for the customer's monitoring
// view. Host-wide, token-authed, fresh (a live collect, not the 15-min hub snapshot).
mux.HandleFunc("GET /host/metrics", s.withGuest(s.handleHostMetrics))
// Disk management (slice 8C) — self-scoped; format routes through the data-bearing classifier+gate.
mux.HandleFunc("GET /disks", s.withGuest(s.handleDisks))
mux.HandleFunc("POST /disks/assign", s.withGuest(s.handleDiskAssign))