v0.5.0-rc1: slice 5 Phase A — storage observe/report + watchdog (read-only, live)

Fill the slice-3 storage_targets stub and add the fast-poll storage watchdog.
Read-only this phase; the host-root surface (mounts/SMART/grow/destructive gate)
is Phase B. Hub-owned desired manifest is slice 10, so reconcile against it is
built-but-unfed.

- internal/storage: StorageTarget wire contract, durable_id derivation per type,
  HostReader seam (procfs/sysfs, root-free), Observer (storage_targets from
  ListStorage/NodeStorage + host reads, lvmthin thin-pool fill), and the watchdog
  (third daemon goroutine; debounced out-of-band report on a known target's
  attach/disconnect transition).
- proxmox.Storage: additive parse-only config fields (durable_id sources).
- collector StorageObserver seam; Loop.SetTrigger out-of-band report; daemon runs
  the watchdog as a third goroutine; StorageConfig knobs.
- cross-repo golden kept byte-identical with felhom.eu/hub; bidirectional key-set test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:59:05 +02:00
parent 1af21a6cac
commit 27b68f043b
22 changed files with 2129 additions and 103 deletions
+34 -4
View File
@@ -22,11 +22,21 @@ type proxmoxReader interface {
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)
}
// 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
hostID string
agentVersion string
logger *slog.Logger
@@ -34,14 +44,15 @@ type Collector struct {
}
// NewCollector builds a collector. hostID echoes config.Hub.HostID; agentVersion is
// the binary version.
func NewCollector(px proxmoxReader, cf CloudflaredProber, hostID, agentVersion string, logger *slog.Logger) *Collector {
// the binary version. storage may be nil (storage_targets emitted empty).
func NewCollector(px proxmoxReader, cf CloudflaredProber, storage StorageObserver, hostID, agentVersion string, logger *slog.Logger) *Collector {
if logger == nil {
logger = slog.Default()
}
return &Collector{
px: px,
cf: cf,
storage: storage,
hostID: hostID,
agentVersion: agentVersion,
logger: logger,
@@ -65,8 +76,9 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
AgentVersion: c.agentVersion,
Host: hostMetrics(c.px.Node(), ns),
Guests: c.collectGuests(ctx),
// Defined-but-empty this slice (slices 5/6). Non-nil so they marshal as [].
StorageTargets: []StorageTarget{},
// 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: []Backup{},
RestoreTests: []RestoreTest{},
PBSSnapshots: []PBSSnapshot{},
@@ -129,6 +141,24 @@ func (c *Collector) collectGuests(ctx context.Context) []Guest {
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
}
func (c *Collector) cloudflaredStatus(ctx context.Context) string {
if c.cf == nil {
return "unknown"