Files
felhom-agent/internal/hub/collect.go
T
admin aa4dfb75ea 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>
2026-06-10 16:16:03 +02:00

272 lines
9.5 KiB
Go

package hub
import (
"context"
"fmt"
"log/slog"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// proxmoxReader is the read-only subset the collector needs. Signatures match the
// REAL internal/proxmox.Client surface (slice 1): the node is held by the Client
// (no per-call node arg), reads return values (not pointers), and the guest type is
// proxmox.Guest. (The task's sketch used node-arg/pointer/LXC shapes; adapted to
// the actual exports per its instruction — no proxmox changes were needed: ListLXC
// already carries status/maxmem/maxdisk, GuestConfig carries cores.)
type proxmoxReader interface {
Node() string
NodeStatus(ctx context.Context) (proxmox.NodeStatus, error)
ListLXC(ctx context.Context) ([]proxmox.Guest, error)
GuestConfig(ctx context.Context, vmid int) (proxmox.GuestConfig, error)
}
// StorageObserver is the seam the storage layer (internal/storage) plugs into to fill the
// report's storage_targets. Defined here (consumer-side) so hub does NOT import storage —
// storage imports hub for the wire type, and main.go wires the concrete observer in. Same
// pattern as proxmoxReader / CloudflaredProber. A nil observer (slice-3 behaviour, or a
// host with no storage layer) yields an empty []StorageTarget without error.
type StorageObserver interface {
Observe(ctx context.Context) ([]StorageTarget, error)
}
// BackupReporter / RestoreTestReporter are the slice-6 seams the backup layer plugs into
// (same consumer-side pattern as StorageObserver — hub does not import the backup package).
// They return the agent's LATEST-known backup-per-target / restore-test result (point-in-time
// state the backup layer accumulates), not a live scan. A nil reporter → empty slice.
type BackupReporter interface {
Backups(ctx context.Context) []Backup
}
type RestoreTestReporter interface {
RestoreTests(ctx context.Context) []RestoreTest
}
// PBSReporter is the slice-6-Phase-B seam the pbs verify loop plugs into (same pattern).
// Returns the agent's latest-known PBS snapshot inventory + verify-state. nil → empty.
type PBSReporter interface {
PBSSnapshots(ctx context.Context) []PBSSnapshot
}
// Collector builds a HostReport from read-only sources. All deps are behind narrow
// interfaces for unit testing.
type Collector struct {
px proxmoxReader
cf CloudflaredProber
storage StorageObserver
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
now func() time.Time
}
// NewCollector builds a collector. hostID echoes config.Hub.HostID; agentVersion is
// the binary version. storage/backups/restoreTests/pbs may be nil (their collections emit empty).
func NewCollector(px proxmoxReader, cf CloudflaredProber, storage StorageObserver, backups BackupReporter, restoreTests RestoreTestReporter, pbs PBSReporter, hostID, agentVersion string, logger *slog.Logger) *Collector {
if logger == nil {
logger = slog.Default()
}
return &Collector{
px: px,
cf: cf,
storage: storage,
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,
now: func() time.Time { return time.Now().UTC() },
}
}
// 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;
// a cloudflared probe failure yields status="unknown" and is never fatal.
func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
ns, err := c.px.NodeStatus(ctx)
if err != nil {
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: 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 [].
StorageTargets: c.collectStorage(ctx),
Backups: c.collectBackups(ctx),
RestoreTests: c.collectRestoreTests(ctx),
PBSSnapshots: c.collectPBSSnapshots(ctx),
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)},
}
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,
CPUPercent: ns.CPU * 100, // PVE cpu is a 0..1 fraction
MemoryTotalBytes: ns.Memory.Total,
MemoryUsedBytes: ns.Memory.Used,
DiskTotalBytes: ns.RootFS.Total,
DiskUsedBytes: ns.RootFS.Used,
LoadAvg: ns.LoadAvg,
UptimeSeconds: ns.Uptime,
}
h.MemoryPercent = percent(ns.Memory.Used, ns.Memory.Total)
h.DiskPercent = percent(ns.RootFS.Used, ns.RootFS.Total)
if h.LoadAvg == nil {
h.LoadAvg = []string{}
}
return h
}
func (c *Collector) collectGuests(ctx context.Context) []Guest {
lxc, err := c.px.ListLXC(ctx)
if err != nil {
// Not fatal: a report with no guest list still carries host liveness.
c.logger.Warn("hub: ListLXC failed; reporting no guests", "err", err)
return []Guest{}
}
guests := make([]Guest, 0, len(lxc))
for _, g := range lxc {
entry := Guest{VMID: g.VMID, Name: g.Name, Status: g.Status, ControllerVersion: ""}
// Normalize an empty run-status to "unknown" so the wire value is always one
// of running|stopped|unknown (matches the hub handler's empty→unknown default).
if entry.Status == "" {
entry.Status = "unknown"
}
// GuestConfig supplies cores; memory/disk come from the list entry (bytes).
// On failure, KEEP the known run-status from ListLXC — only the spec is lost.
cfg, err := c.px.GuestConfig(ctx, g.VMID)
if err != nil {
c.logger.Warn("hub: GuestConfig failed; spec omitted (run-status kept)",
"vmid", g.VMID, "err", err)
entry.Spec = nil
} else {
entry.Spec = &GuestSpec{
Cores: cfg.Cores,
MemoryBytes: g.MaxMem,
DiskBytes: g.MaxDisk,
}
}
guests = append(guests, entry)
}
return guests
}
// collectStorage builds the storage_targets via the observer. A nil observer (no storage
// layer wired) or an observe error degrades to an empty list — storage detail is
// best-effort and must never sink the heartbeat (host liveness is the priority).
func (c *Collector) collectStorage(ctx context.Context) []StorageTarget {
if c.storage == nil {
return []StorageTarget{}
}
targets, err := c.storage.Observe(ctx)
if err != nil {
c.logger.Warn("hub: storage observe failed; reporting no storage targets", "err", err)
return []StorageTarget{}
}
if targets == nil {
return []StorageTarget{}
}
return targets
}
// collectBackups / collectRestoreTests read the agent's latest backup + restore-test state
// via the seams. Best-effort: a nil reporter or nil slice degrades to an empty (non-nil)
// list so the collection always marshals as [].
func (c *Collector) collectBackups(ctx context.Context) []Backup {
if c.backups == nil {
return []Backup{}
}
if b := c.backups.Backups(ctx); b != nil {
return b
}
return []Backup{}
}
func (c *Collector) collectRestoreTests(ctx context.Context) []RestoreTest {
if c.restoreTests == nil {
return []RestoreTest{}
}
if r := c.restoreTests.RestoreTests(ctx); r != nil {
return r
}
return []RestoreTest{}
}
// collectPBSSnapshots reads the latest PBS snapshot inventory via the seam (nil → empty).
func (c *Collector) collectPBSSnapshots(ctx context.Context) []PBSSnapshot {
if c.pbs == nil {
return []PBSSnapshot{}
}
if s := c.pbs.PBSSnapshots(ctx); s != nil {
return s
}
return []PBSSnapshot{}
}
func (c *Collector) cloudflaredStatus(ctx context.Context) string {
if c.cf == nil {
return "unknown"
}
st, err := c.cf.Status(ctx)
if err != nil || st == "" {
c.logger.Warn("hub: cloudflared probe failed", "err", err)
return "unknown"
}
return st
}
func percent(used, total int64) float64 {
if total <= 0 {
return 0
}
return float64(used) / float64(total) * 100
}