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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user