agent v0.27.0: slice 10 P3 — self-heal watchdog reconcile + 4-state intent model

IntentStore (durable-id-keyed: new/enrolled/ejected/decommissioned, OnAbsent
replug rule). Watchdog re-mounts only enrolled drives (out-of-band unmount heals;
ejected/decommissioned/new left alone) + exp-backoff flapping guard (alert@4,
cap@8). guest-attach records enrolled; eject records ejected. Non-hollow tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 17:49:25 +02:00
parent bc4f2b9168
commit 237b85f420
8 changed files with 590 additions and 35 deletions
+25 -3
View File
@@ -43,7 +43,7 @@ import (
// 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.26.0"
var version = "0.27.0"
func main() {
var (
@@ -329,10 +329,27 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
storageTrigger := make(chan struct{}, 1)
loop.SetTrigger(storageTrigger)
remounter := &gateRemounter{gate: gate, ops: hostOps, hostID: cfg.Hub.HostID, logger: logger}
// Drive intent store (slice 10 P3 self-heal): persisted, durable-id-keyed enroll/eject/decommission
// state. Gates the watchdog's self-heal re-mount to ENROLLED drives, and the local API records
// enroll/eject into it. Open failure → ungated legacy remount (logged) so the daemon still runs.
var intentReader storage.IntentReader
intentStore, ierr := storage.OpenIntentStore(filepath.Join(cfg.LocalAPI.StateDir, "drive-intents.json"))
if ierr != nil {
logger.Warn("storage: intent store unavailable — self-heal runs UNGATED (legacy remount-any)", "err", ierr)
intentStore = nil
} else {
intentReader = intentStore
}
watchdog := storage.NewWatchdog(storage.WatchdogOptions{
Targets: storage.NewCachingKnownTargets(observer, cfg.Storage.KnownRefresh()),
Liveness: storage.NewHostLiveness(hostReader, 0),
Remounter: remounter,
Intent: intentReader, // P3: only re-mount enrolled drives
OnAbsent: func(durableID string) { // P3: clear `ejected` on physical absence (replug rule)
if intentStore != nil {
_ = intentStore.OnAbsent(durableID)
}
},
Trigger: func() {
select {
case storageTrigger <- struct{}{}:
@@ -381,7 +398,11 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
// host still reports/reconciles). The leaf is generated+persisted once so its pin is stable.
localServers := 0
var localTokens *localapi.TokenStore
localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, hostOps, gate, collector, logger, &localTokens)
var intentRec localapi.IntentRecorder
if intentStore != nil { // avoid a typed-nil interface (would defeat the nil check)
intentRec = intentStore
}
localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, hostOps, gate, collector, intentRec, logger, &localTokens)
if localTokens != nil {
defer localTokens.Close()
}
@@ -532,7 +553,7 @@ func buildRestoreTestScheduler(cfg config.Config, px *proxmox.Client, engine *re
// leaf (stable fingerprint). Any failure DISABLES the server (returns nil) WITHOUT crashing the
// daemon — the host still reports/reconciles; only the controller channel is unavailable until
// fixed. The opened token store is returned via outTokens so the caller can Close it.
func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.Store, observer *storage.Observer, hostOps storage.HostOps, gate *reconcile.Gate, collector *hub.Collector, logger *slog.Logger, outTokens **localapi.TokenStore) *localapi.Server {
func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.Store, observer *storage.Observer, hostOps storage.HostOps, gate *reconcile.Gate, collector *hub.Collector, intent localapi.IntentRecorder, logger *slog.Logger, outTokens **localapi.TokenStore) *localapi.Server {
if !cfg.LocalAPI.Enabled() {
return nil
}
@@ -575,6 +596,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID},
Guests2: px,
GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest
Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal
// Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host +
// per-storage view to the customer's monitoring page (reuses the slice-4 collector).