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
+82 -1
View File
@@ -286,6 +286,7 @@ func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, v
for key, spec := range mounts {
if _, mp, _ := parseMount(spec); mp == where {
s.recordIntent(r.Context(), where, "enrolled")
s.recordGuestBind(r.Context(), vmid, where)
s.logger.Info("local-api: guest-attach idempotent (already bound)", "vmid", vmid, "where", where, "slot", key)
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": key, "already": true})
return
@@ -301,8 +302,10 @@ func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, v
writeErr(w, http.StatusBadGateway, "guest-attach failed: "+err.Error())
return
}
// Record the drive as ENROLLED so the self-heal watchdog will reconcile it (P3).
// Record the drive as ENROLLED so the self-heal watchdog will reconcile it (P3), and persist the
// per-guest bind so the startup re-assert can restore it after a re-provision (F9).
s.recordIntent(r.Context(), where, "enrolled")
s.recordGuestBind(r.Context(), vmid, where)
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": slot})
}
@@ -561,6 +564,84 @@ func (s *Server) guestBoundPaths(ctx context.Context, vmid int) map[string]bool
return out
}
// recordGuestBind persists that the drive at `where` (by its durable-id) is enrolled into `vmid`, so the
// startup re-assert (ReassertGuestBinds) can restore the bind after a re-provision (F9). Best-effort.
func (s *Server) recordGuestBind(ctx context.Context, vmid int, where string) {
if s.guestBinds == nil {
return
}
id := s.durableIDForMount(ctx, where)
if id == "" {
s.logger.Warn("local-api: guest-bind not recorded — durable-id unresolved", "vmid", vmid, "where", where)
return
}
if err := s.guestBinds.Record(vmid, id); err != nil {
s.logger.Warn("local-api: guest-bind record failed", "vmid", vmid, "where", where, "durable_id", id, "err", err)
return
}
s.logger.Info("local-api: guest-bind recorded for startup re-assert", "vmid", vmid, "where", where, "durable_id", id)
}
// ReassertGuestBinds re-adds, on agent startup (the host's bring-up/reconcile trigger), any enrolled
// user-data drive bind a guest is MISSING from its config (F9 — a re-provision drops the mp, and nothing
// previously restored it). For each recorded (vmid, durable-id): only when the durable-id STILL resolves
// to a present, mounted drive AND the guest lacks the bind, it re-runs AttachBind. "On durable-id match"
// — a swapped or absent drive is never auto-bound. The re-added bind is config state; it activates on the
// guest's next reboot (logged), exactly like the enroll flow. Safe to call repeatedly (idempotent).
func (s *Server) ReassertGuestBinds(ctx context.Context) {
if s.guestBinds == nil || s.guestAttach == nil || s.guests == nil {
return
}
// durable-id -> current host mount path (present drives only), from the agent's own storage view.
mountByDurable := map[string]string{}
if targets, err := s.storage.Observe(ctx); err == nil {
for _, t := range targets {
if t.DurableID != "" && t.MountPath != "" {
mountByDurable[t.DurableID] = t.MountPath
}
}
} else {
s.logger.Warn("F9 re-assert: storage view unavailable — skipping", "err", err)
return
}
for vmid, ids := range s.guestBinds.Guests() {
cfg, err := s.guests.GuestConfig(ctx, vmid)
if err != nil {
s.logger.Warn("F9 re-assert: skip guest (config read failed)", "vmid", vmid, "err", err)
continue
}
mounts := cfg.MountPoints()
boundPaths := map[string]bool{}
for _, spec := range mounts {
if _, mp, _ := parseMount(spec); mp != "" {
boundPaths[mp] = true
}
}
for _, id := range ids {
where, present := mountByDurable[id]
if !present {
s.logger.Warn("F9 re-assert: enrolled drive not present (durable-id absent) — skipping", "vmid", vmid, "durable_id", id)
continue
}
if boundPaths[where] {
continue // already bound — nothing to re-assert
}
slot, ok := freeMountSlot(mounts)
if !ok {
s.logger.Warn("F9 re-assert: no free mountpoint slot on guest", "vmid", vmid, "where", where)
continue
}
if err := s.guestAttach.AttachBind(ctx, vmid, slot, where); err != nil {
s.logger.Error("F9 re-assert: AttachBind failed", "vmid", vmid, "where", where, "slot", slot, "err", err)
continue
}
mounts[slot] = where // reserve the slot so a second enrolled drive takes the next one
s.logger.Warn("F9 re-assert: re-attached enrolled drive into guest config (reboot to activate)",
"vmid", vmid, "where", where, "slot", slot, "durable_id", id)
}
}
}
// durableIDForMount resolves the durable-id of the storage mounted at `where` (from the agent's own
// storage view) — the key the intent store records enroll/eject against. "" if not resolvable.
func (s *Server) durableIDForMount(ctx context.Context, where string) string {