agent v0.33.0: C1 net — pre-start self-heal hook + decommission mp-delete

Pre-start PVE hookscript (internal/guesthook) creates host-root placeholders for
absent bind-mount sources so the guest always boots (fail-closed); decommission
now pct set --delete's the dead mp (GuestBinder.DetachBind) so a missing source
can't brick the next reboot (B3 C1 bug). Non-hollow tests + companions. Installed
+ registered per-guest by the provision back-half. Transitional ahead of the
intermediary-mount re-architecture which makes C1 structural.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 16:11:10 +02:00
parent 2a4affc3a8
commit 44cdf82631
11 changed files with 490 additions and 3 deletions
+35
View File
@@ -68,6 +68,9 @@ type GuestLister interface {
// host-side live inject is blocked on unprivileged guests). Satisfied by *GuestBinder.
type GuestAttacher interface {
AttachBind(ctx context.Context, vmid int, mountKey, where string) error
// DetachBind removes a mountpoint slot from the guest config (the decommission C1 fix — a removed
// bind can't brick the next boot with a missing source). Runs on the running guest (no start lock).
DetachBind(ctx context.Context, vmid int, mountKey string) error
RebootGuest(ctx context.Context, vmid int) error
}
@@ -293,6 +296,18 @@ func (s *Server) handleDiskDecommission(w http.ResponseWriter, r *http.Request,
s.logger.Warn("local-api: guest-bind remove failed", "vmid", vmid, "durable_id", id, "err", err)
}
}
// C1 FIX (B3 critical bug): delete the guest mountpoint bind for this drive so its now-missing
// source can't brick the guest on the NEXT reboot. The old decommission unmounted but left the
// `mpN` in the config → pre-start mount failure → all apps down. Runs on the running guest (config
// edit, no start lock → no deadlock). Best-effort: a missing slot is already clean.
if s.guestAttach != nil {
if slot := s.guestSlotForPath(r.Context(), vmid, req.Where); slot != "" {
if err := s.guestAttach.DetachBind(r.Context(), vmid, slot); err != nil {
s.logger.Warn("local-api: guest-detach failed — mp left in config (reboot may brick until reconciled)",
"vmid", vmid, "slot", slot, "where", req.Where, "err", err)
}
}
}
// Unmount (mirror eject) — benign, data preserved. NEVER format/mkfs here.
if err := s.disks.Unmount(r.Context(), req.Where); err != nil {
s.logger.Error("local-api: disk decommission", "vmid", vmid, "where", req.Where, "err", err)
@@ -672,6 +687,26 @@ func (s *Server) guestBoundPaths(ctx context.Context, vmid int) map[string]bool
return out
}
// guestSlotForPath returns the mountpoint slot (mpN) whose bind targets the guest path `where`, or ""
// if none. Used by decommission/eject to find the slot to `--delete` (the C1 fix). Best-effort: a
// config-read error yields "" (nothing to detach — the safe direction).
func (s *Server) guestSlotForPath(ctx context.Context, vmid int, where string) string {
if s.guests == nil {
return ""
}
cfg, err := s.guests.GuestConfig(ctx, vmid)
if err != nil {
s.logger.Warn("local-api: slot-for-path — could not read guest config", "vmid", vmid, "where", where, "err", err)
return ""
}
for slot, spec := range cfg.MountPoints() {
if _, mp, _ := parseMount(spec); mp == where {
return slot
}
}
return ""
}
// 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) {