F9: auto-re-assert enrolled guest data-drive binds on agent startup

The in-guest bind (pct set -mpN) is config state that a destroy+re-provision drops, and
nothing restored it — so a re-provisioned guest came up with its enrolled HDD unattached
(the live-drive F9 finding). New GuestBindStore persists, per guest, the durable-ids of
enrolled drives (recorded at guest-attach); ReassertGuestBinds runs on agent startup (the
host's bring-up/reconcile trigger) and re-adds any bind a guest is MISSING — but ONLY when
the durable-id still resolves to a present, mounted drive (a swapped/absent drive is never
auto-bound) and the guest lacks it (idempotent). The re-added bind activates on the guest's
next reboot, like the enroll flow. Wired in main.go (store opened beside drive-intents.json;
ReassertGuestBinds called before the local API serves).

Tests: restores a missing bind with no manual call (the operator's real-trigger proof);
skips absent/swapped durable-id; no-op when already bound; store survives reopen (restart).
This commit is contained in:
2026-06-14 15:07:37 +02:00
parent a2a76e7624
commit 4cd1d024e9
5 changed files with 331 additions and 3 deletions
+14 -2
View File
@@ -340,6 +340,13 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
} else {
intentReader = intentStore
}
// F9: per-guest enrolled-bind record, replayed by ReassertGuestBinds at startup to restore a
// guest data-drive bind that a re-provision dropped (durable-id-keyed; absent/swapped drives skipped).
guestBindStore, gbErr := localapi.OpenGuestBindStore(filepath.Join(intentStateDir, "guest-binds.json"))
if gbErr != nil {
logger.Warn("storage: guest-bind store unavailable — startup bind re-assert disabled (F9)", "err", gbErr)
guestBindStore = nil
}
watchdog := storage.NewWatchdog(storage.WatchdogOptions{
Targets: storage.NewCachingKnownTargets(observer, cfg.Storage.KnownRefresh()),
Liveness: storage.NewHostLiveness(hostReader, 0),
@@ -424,7 +431,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec}, cfg.Hub.HostID, logger)
loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner))
localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, hostOps, gate, collector, intentRec, logger, &localTokens)
localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, hostOps, gate, collector, intentRec, guestBindStore, logger, &localTokens)
if localTokens != nil {
defer localTokens.Close()
}
@@ -462,6 +469,10 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
go func() { errc <- pbsLoop.Run(ctx) }()
if localSrv != nil {
localServers = 1
// F9: on startup (the host's bring-up/reconcile trigger), re-assert any enrolled guest data-drive
// bind that a re-provision dropped — before serving, so the drive is back in the guest config
// (activates on the guest's next reboot). On-durable-id-match; absent/swapped drives are skipped.
localSrv.ReassertGuestBinds(ctx)
go func() { errc <- localSrv.Run(ctx) }()
}
if lanLoop != nil {
@@ -576,7 +587,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, intent localapi.IntentRecorder, 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, guestBinds *localapi.GuestBindStore, logger *slog.Logger, outTokens **localapi.TokenStore) *localapi.Server {
if !cfg.LocalAPI.Enabled() {
return nil
}
@@ -620,6 +631,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
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
GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert
// 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).