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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user