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:
@@ -124,6 +124,36 @@ func TestDecommission_Effects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecommission_DeletesGuestMount is the C1-fix regression: decommission must `--delete` the guest
|
||||
// mountpoint slot that binds the drive, so its now-missing source can't brick the next boot. The guest
|
||||
// has two binds (bootstrap mp9 + the data drive mp1); only mp1 (the one targeting /mnt/bulk) may be
|
||||
// detached.
|
||||
//
|
||||
// COMPANION GUARD: the pre-fix handler (the B3 bug) never called DetachBind → detachCount()==0 → this
|
||||
// test FAILS on it. A trivial impl deleting the WRONG/first slot is caught by the slot==mp1 assertion.
|
||||
func TestDecommission_DeletesGuestMount(t *testing.T) {
|
||||
d := &fakeDiskOps{}
|
||||
intent := newFakeIntent()
|
||||
intent.SetEnrolled("uuid:usb-1")
|
||||
ga := &fakeGuestAttacher{}
|
||||
srv := decommServer(t, d, userDataAndProtected(), fakeGuestList{}, intent, tempBindStore(t), ga, map[int]map[string]string{
|
||||
8200: {
|
||||
"mp9": "/var/lib/.../bootstrap,mp=/etc/felhom-bootstrap,ro=1",
|
||||
"mp1": "/mnt/bulk/felhom-data,mp=/mnt/bulk",
|
||||
},
|
||||
})
|
||||
w := do(t, srv.Handler(), "POST", "/disks/decommission", "A", `{"where":"/mnt/bulk"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("decommission: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if ga.detachCount() != 1 {
|
||||
t.Fatalf("DetachBind called %d times, want 1 (C1 fix: the dead mp must be deleted)", ga.detachCount())
|
||||
}
|
||||
if got := ga.detaches[0]; got.vmid != 8200 || got.slot != "mp1" {
|
||||
t.Fatalf("DetachBind(vmid=%d, slot=%q), want (8200, mp1) — wrong slot deleted", got.vmid, got.slot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReassertGuestBinds_SkipsDecommissioned is the load-bearing F9-reconnect invariant: a
|
||||
// decommissioned-but-present drive still recorded in the bind store must NOT auto-rebind on agent
|
||||
// restart. Companion: with intent=enrolled the SAME setup DOES rebind — proving the intent gate is
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -389,7 +389,11 @@ type fakeGuestAttacher struct {
|
||||
vmid int
|
||||
slot, where string
|
||||
}
|
||||
reboots []int
|
||||
reboots []int
|
||||
detaches []struct {
|
||||
vmid int
|
||||
slot string
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, where string) error {
|
||||
@@ -403,6 +407,21 @@ func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, wh
|
||||
}
|
||||
func (f *fakeGuestAttacher) count() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.calls) }
|
||||
|
||||
func (f *fakeGuestAttacher) DetachBind(_ context.Context, vmid int, mountKey string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.detaches = append(f.detaches, struct {
|
||||
vmid int
|
||||
slot string
|
||||
}{vmid, mountKey})
|
||||
return nil
|
||||
}
|
||||
func (f *fakeGuestAttacher) detachCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.detaches)
|
||||
}
|
||||
|
||||
func (f *fakeGuestAttacher) RebootGuest(_ context.Context, vmid int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -70,6 +70,21 @@ func (b *GuestBinder) AttachBind(ctx context.Context, vmid int, mountKey, where
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetachBind removes a mountpoint bind from the guest config (`pct set <vmid> --delete <mpN>`). This is
|
||||
// the decommission/eject counterpart to AttachBind and the C1 FIX: a drive whose bind is removed here
|
||||
// leaves NO dead `mpN` whose now-missing source would brick the guest on its next reboot (the B3
|
||||
// critical bug, where decommission unmounted the drive but never deleted the bind). It runs on a RUNNING
|
||||
// guest — a plain config edit, NOT a start — so it takes no start lock and cannot deadlock (unlike a
|
||||
// pre-start `--delete`, which is why the boot-time net uses placeholders instead). The live in-guest
|
||||
// mount lingers until the next reboot; the caller unmounts the host source separately.
|
||||
func (b *GuestBinder) DetachBind(ctx context.Context, vmid int, mountKey string) error {
|
||||
if err := b.run(ctx, "pct", "set", strconv.Itoa(vmid), "--delete", mountKey); err != nil {
|
||||
return fmt.Errorf("guest-detach: pct set %d --delete %s: %w", vmid, mountKey, err)
|
||||
}
|
||||
b.logger.Info("guest-detach: mountpoint bind removed from guest config", "vmid", vmid, "slot", mountKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebootGuest reboots the guest (graceful shutdown + start) so persisted-but-inactive mountpoint
|
||||
// binds activate (slice 10 P2: the host-side live inject is blocked on an unprivileged guest, so a
|
||||
// drive enrolled into a RUNNING guest activates only at the next boot — this is the user-triggered
|
||||
|
||||
Reference in New Issue
Block a user