agent v0.36.1: decommission keeps raw mounted (re-enrollable)

The decommission unmounted the raw /mnt/<name>, orphaning a non-removable drive
so re-enroll bound an empty dir. Now DetachDrive only (bind under parent); raw
stays mounted so re-enroll re-binds. Test: raw NOT unmounted + DetachDrive called.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:06:23 +02:00
parent 05be509e6e
commit 281c7b7e3f
4 changed files with 28 additions and 12 deletions
+14 -5
View File
@@ -92,15 +92,21 @@ func TestDecommission_RoleGated(t *testing.T) {
}
// TestDecommission_Effects: a user-data decommission sets IntentDecommissioned, removes the
// GuestBindStore entry, and unmounts — assert ALL THREE (not just no-error). NEVER formats.
// GuestBindStore entry, and DETACHES the bind under the parent — but (intermediary model) does NOT
// unmount the RAW host mount, so a one-click re-enroll (H3) can re-bind it. NEVER formats.
//
// COMPANION GUARD: the pre-fix decommission unmounted the raw drive (`d.unmountCalls`), which orphaned a
// non-removable drive so re-enroll bound an empty dir. The "raw NOT unmounted" + "DetachDrive called"
// assertions below fail that impl.
func TestDecommission_Effects(t *testing.T) {
d := &fakeDiskOps{}
intent := newFakeIntent()
intent.SetEnrolled("uuid:usb-1") // currently enrolled
gb := tempBindStore(t)
_ = gb.Record(8200, "uuid:usb-1")
ga := &fakeGuestAttacher{}
srv := decommServer(t, d, userDataAndProtected(), fakeGuestList{}, intent, gb, &fakeGuestAttacher{}, nil)
srv := decommServer(t, d, userDataAndProtected(), fakeGuestList{}, intent, gb, ga, nil)
w := do(t, srv.Handler(), "POST", "/disks/decommission", "A", `{"where":"/mnt/bulk"}`)
if w.Code != http.StatusOK {
t.Fatalf("decommission user-data: got %d want 200 (%s)", w.Code, w.Body.String())
@@ -113,11 +119,14 @@ func TestDecommission_Effects(t *testing.T) {
if ids := gb.Guests()[8200]; len(ids) != 0 {
t.Errorf("guest-bind not removed: %v", ids)
}
// 3) unmounted, never formatted
// 3) the bind under the parent is DETACHED, the RAW is NOT unmounted, and never formatted.
if len(ga.detachDrives) != 1 || ga.detachDrives[0] != "/mnt/bulk" {
t.Errorf("DetachDrive calls = %v, want [/mnt/bulk]", ga.detachDrives)
}
d.mu.Lock()
defer d.mu.Unlock()
if len(d.unmountCalls) != 1 || d.unmountCalls[0] != "/mnt/bulk" {
t.Errorf("Unmount calls = %v, want [/mnt/bulk]", d.unmountCalls)
if len(d.unmountCalls) != 0 {
t.Errorf("raw drive must NOT be unmounted on decommission (re-enrollable); got %v", d.unmountCalls)
}
if len(d.formatCalls) != 0 {
t.Errorf("decommission must NEVER format; formatCalls = %v", d.formatCalls)
+5 -6
View File
@@ -360,12 +360,11 @@ func (s *Server) handleDiskDecommission(w http.ResponseWriter, r *http.Request,
}
}
}
// 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)
writeErr(w, http.StatusBadRequest, "decommission failed: "+err.Error())
return
}
// Intermediary model: decommission is a LOGICAL retire (data stays, re-enrollable). It DetachDrive'd
// the bind under the parent above (the drive is no longer visible to the guest); it does NOT unmount
// the RAW /mnt/<name> host mount — that would orphan the drive (a non-removable SATA drive doesn't get
// re-plugged), so a one-click re-enroll (H3) could not re-bind it. The soft decommission marker blocks
// scheduling; physical removal is the separate "remove from system" action. NEVER format/mkfs here.
writeOK(w, map[string]any{"vmid": vmid, "decommissioned": req.Where, "dependent_guests": dependents})
}