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
+35 -1
View File
@@ -57,6 +57,7 @@ type Collector struct {
backups BackupReporter
restoreTests RestoreTestReporter
pbs PBSReporter
temp TempReader // slice 9: host CPU/chassis temp (nil-safe → nil temp)
hostID string
agentVersion string
logger *slog.Logger
@@ -76,6 +77,7 @@ func NewCollector(px proxmoxReader, cf CloudflaredProber, storage StorageObserve
backups: backups,
restoreTests: restoreTests,
pbs: pbs,
temp: SysfsTempReader{}, // slice 9: real sysfs reader by default; tests inject a fake
hostID: hostID,
agentVersion: agentVersion,
logger: logger,
@@ -83,6 +85,13 @@ func NewCollector(px proxmoxReader, cf CloudflaredProber, storage StorageObserve
}
}
// SetTempReader overrides the host-temp source (tests inject a fake; a nil reader disables temp).
// Returns the collector for chaining.
func (c *Collector) SetTempReader(t TempReader) *Collector {
c.temp = t
return c
}
// Collect builds the report. Best-effort liveness: a failed NodeStatus is a hard
// error (no useful report — the cycle skips the POST); a failed per-guest
// GuestConfig degrades that guest to status="unknown" without spec but still sends;
@@ -93,11 +102,13 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
return nil, fmt.Errorf("hub: NodeStatus failed (no useful report): %w", err)
}
host := hostMetrics(c.px.Node(), ns)
host.CPUTempC = c.cpuTempC(ctx) // slice 9: operator freebie — temp now rides the hub report too
report := &HostReport{
HostID: c.hostID,
ReportedAt: c.now().Format(time.RFC3339),
AgentVersion: c.agentVersion,
Host: hostMetrics(c.px.Node(), ns),
Host: host,
Guests: c.collectGuests(ctx),
// storage_targets populated this slice (slice 5) via the observer; the rest stay
// defined-but-empty (slice 6). Non-nil so they marshal as [].
@@ -112,6 +123,29 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
return report, nil
}
// HostMetricsNow does a FRESH NodeStatus + CPU-temp read and returns just the host block (no
// guests/storage). It is the source for the local API's GET /host/metrics (slice 9) — current
// cpu%/temp, not the 15-min hub-report snapshot. Storage targets come from the observer
// separately. A NodeStatus failure is a hard error (no useful host view); a missing temp sensor
// degrades to nil (never an error).
func (c *Collector) HostMetricsNow(ctx context.Context) (HostMetrics, error) {
ns, err := c.px.NodeStatus(ctx)
if err != nil {
return HostMetrics{}, fmt.Errorf("hub: NodeStatus failed: %w", err)
}
h := hostMetrics(c.px.Node(), ns)
h.CPUTempC = c.cpuTempC(ctx)
return h, nil
}
// cpuTempC reads the host CPU/chassis temp via the TempReader seam (nil-safe → nil).
func (c *Collector) cpuTempC(ctx context.Context) *int {
if c.temp == nil {
return nil
}
return c.temp.CPUTempC(ctx)
}
func hostMetrics(node string, ns proxmox.NodeStatus) HostMetrics {
h := HostMetrics{
Node: node,