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
+36 -7
View File
@@ -25,11 +25,12 @@ import (
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.4.0"
var version = "0.5.0-rc1"
func main() {
var (
@@ -109,10 +110,34 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
return 1
}
hcfg := cfg.Hub.WithDefaults()
collector := hub.NewCollector(px, hub.SystemctlProber{}, cfg.Hub.HostID, version, logger)
// Storage observer (slice 5): read-only, builds the report's storage_targets from
// Proxmox + non-privileged host reads. Wired into the collector via the StorageObserver
// seam (so hub does not import storage).
hostReader := storage.NewProcHostReader()
observer := storage.NewObserver(px, hostReader, logger)
collector := hub.NewCollector(px, hub.SystemctlProber{}, observer, cfg.Hub.HostID, version, logger)
loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger)
interval := time.Duration(hcfg.PollSeconds) * time.Second
// Storage watchdog (slice 5): a third daemon goroutine fast-polling the known target
// set for attached↔disconnected transitions and triggering an immediate, debounced
// out-of-band report. With no removable/network storage it simply finds nothing to flag.
storageTrigger := make(chan struct{}, 1)
loop.SetTrigger(storageTrigger)
watchdog := storage.NewWatchdog(storage.WatchdogOptions{
Targets: storage.NewCachingKnownTargets(observer, cfg.Storage.KnownRefresh()),
Liveness: storage.NewHostLiveness(hostReader, 0),
Trigger: func() {
select {
case storageTrigger <- struct{}{}:
default:
}
},
Interval: cfg.Storage.WatchdogInterval(),
Debounce: cfg.Storage.WatchdogDebounce(),
Logger: logger,
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
logger.Info("felhom-agent daemon starting",
@@ -167,14 +192,17 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
// last died BEFORE issuing new mutations. With an empty journal this is a no-op.
engine.Recover(ctx)
// Run reconcile and the hub loop concurrently; either returning ends the daemon.
errc := make(chan error, 2)
// Run reconcile, the hub loop, and the storage watchdog concurrently; any one
// returning ends the daemon (then ctx cancellation tears the others down).
errc := make(chan error, 3)
go func() { errc <- engine.Run(ctx, interval) }()
go func() { errc <- loop.Run(ctx) }()
go func() { errc <- watchdog.Run(ctx) }()
err = <-errc
stop() // tear down the sibling on the first exit
<-errc // wait for it
stop() // tear down the siblings on the first exit
<-errc // wait for the second
<-errc // wait for the third
if err != nil && err != context.Canceled {
logger.Error("daemon: exited with error", "err", err)
return 1
@@ -242,7 +270,8 @@ func runSelftestHub(ctx context.Context, cfg config.Config, logger *slog.Logger)
fmt.Fprintln(os.Stderr, "selftest: hub client:", err)
return 1
}
collector := hub.NewCollector(px, hub.SystemctlProber{}, cfg.Hub.HostID, version, logger)
observer := storage.NewObserver(px, storage.NewProcHostReader(), logger)
collector := hub.NewCollector(px, hub.SystemctlProber{}, observer, cfg.Hub.HostID, version, logger)
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()