v0.32.0: self-serve decommission endpoint + intent-aware re-assert (B2a)

POST /disks/decommission mirrors eject (withGuest, user-data role gate) — no
operator signature, non-destructive (never formats): sets IntentDecommissioned,
prunes the GuestBindStore entry, unmounts. ReassertGuestBinds is now intent-aware
(skip non-enrolled) so a decommissioned-but-present drive never auto-rebinds on
agent restart — the load-bearing F9-reconnect fix. GuestBindStore.Remove added.
Operator-signed DecommissionExecutor + classify untouched. Non-hollow tests incl.
the intent-aware reassert companion (mutation-proven to fail on intent-blind code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 19:55:17 +02:00
parent 8e6d00a57f
commit f43697c881
6 changed files with 397 additions and 49 deletions
+74 -7
View File
@@ -71,12 +71,16 @@ type GuestAttacher interface {
RebootGuest(ctx context.Context, vmid int) error
}
// IntentRecorder persists drive enroll/eject INTENT (slice 10 P3 self-heal), keyed by durable-id, so
// the watchdog reconciles only enrolled drives and respects an official eject. Satisfied by
// *storage.IntentStore. Optional — when nil, the local API records no intent (self-heal is ungated).
// IntentRecorder persists drive enroll/eject/decommission INTENT (slice 10 P3 self-heal), keyed by
// durable-id, so the watchdog reconciles only enrolled drives and respects an official eject /
// permanent decommission. Get lets the startup re-assert be intent-aware (B2 — skip non-enrolled).
// Satisfied by *storage.IntentStore. Optional — when nil, the local API records no intent (self-heal
// is ungated and the re-assert falls back to legacy bind-all behavior).
type IntentRecorder interface {
SetEnrolled(durableID string) error
SetEjected(durableID string) error
SetDecommissioned(durableID string) error
Get(durableID string) storage.DriveIntent
}
// ---- handlers ---------------------------------------------------------------------------
@@ -88,14 +92,14 @@ type DiskInfo struct {
State string `json:"state"` // attached | disconnected
BackingDevice string `json:"backing_device"` // /dev/sdb1, … ("" for network/lvm)
MountPath string `json:"mount_path"`
Class string `json:"class"` // fast | slow | ""
Class string `json:"class"` // fast | slow | ""
// Role is the agent's AUTHORITATIVE protection tier (system | backup | user-data), derived from
// the agent's own storage view + host topology — never from the controller. The controller drives
// the UI from it: system/backup get a lock badge and NO destructive controls; user-data is
// customer-manageable. Defense in depth — the agent re-enforces role at wipe time regardless.
Role string `json:"role"`
DataBearing bool `json:"data_bearing"` // agent device-inspection verdict (UI hint)
DataReason string `json:"data_reason,omitempty"`
Role string `json:"role"`
DataBearing bool `json:"data_bearing"` // agent device-inspection verdict (UI hint)
DataReason string `json:"data_reason,omitempty"`
// Capacity (from the agent's storage view) — for the controller's capacity bar. 0 when unknown.
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
@@ -246,6 +250,58 @@ func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid in
writeOK(w, map[string]any{"vmid": vmid, "ejected": req.Where, "dependent_guests": dependents})
}
// handleDiskDecommission is the SELF-SERVE, NON-DESTRUCTIVE permanent removal of a user-data drive
// (B2). It mirrors handleDiskEject EXACTLY — withGuest self-scoping, scopedFromBody, and the same
// user-data ROLE GATE (a system/backup mount is refused 403; fail-safe-to-protected on ambiguity) so
// a compromised controller can't decommission protected storage. Unlike the operator-signed
// DecommissionExecutor it needs no signature: it is the customer's own drive. It records a PERMANENT
// decommission intent (the self-heal watchdog + the intent-aware re-assert never auto-mount/re-bind it
// again), prunes the guest-bind record (hygiene), and unmounts so the drive is physically removable.
// It NEVER calls any format/mkfs path — the data stays on the drive.
func (s *Server) handleDiskDecommission(w http.ResponseWriter, r *http.Request, vmid int) {
if s.disks == nil {
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
return
}
var req ejectRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
if strings.TrimSpace(req.Where) == "" {
writeErr(w, http.StatusBadRequest, "where (mountpoint) is required")
return
}
// ROLE GATE (same as eject): user-data only. The agent classifies from its OWN view, never the
// caller's claim; an unresolvable mount fails safe to protected → refused.
if role := s.roleForMountPath(r.Context(), req.Where); role != storage.RoleUserData {
s.logger.Warn("local-api: protected — decommission refused by role",
"vmid", vmid, "where", req.Where, "role", role)
writeErr(w, http.StatusForbidden, "mount is system/backup-protected — decommission refused (role: "+string(role)+")")
return
}
dependents := s.dependentGuests(r.Context(), req.Where)
// Resolve the durable-id BEFORE unmounting (it still resolves while mounted) for the bind prune.
id := s.durableIDForMount(r.Context(), req.Where)
// Record the PERMANENT decommission intent first (self-heal never re-mounts it again).
s.recordIntent(r.Context(), req.Where, "decommissioned")
// Hygiene: drop the guest-bind record so the startup re-assert carries no stale id.
if id != "" && s.guestBinds != nil {
if err := s.guestBinds.Remove(vmid, id); err != nil {
s.logger.Warn("local-api: guest-bind remove failed", "vmid", vmid, "durable_id", id, "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)
writeErr(w, http.StatusBadRequest, "decommission failed: "+err.Error())
return
}
writeOK(w, map[string]any{"vmid": vmid, "decommissioned": req.Where, "dependent_guests": dependents})
}
type guestAttachRequest struct {
VMID int `json:"vmid"`
Where string `json:"where"` // the host mount path of the enrolled drive (e.g. /mnt/felhom-usb)
@@ -670,6 +726,15 @@ func (s *Server) ReassertGuestBinds(ctx context.Context) {
}
}
for _, id := range ids {
// Intent-aware (B2, the load-bearing correctness fix): NEVER re-bind a drive that is not
// currently `enrolled` — an ejected or decommissioned drive must not auto-rebind into the
// guest on agent restart, even if it is still host-mounted. Covers both the self-serve and
// the operator-signed decommission paths (both land on IntentDecommissioned). A nil intent
// store falls back to legacy bind-all (ungated), matching the watchdog's nil-intent rule.
if s.intent != nil && s.intent.Get(id) != storage.IntentEnrolled {
s.logger.Warn("F9 re-assert: skipping non-enrolled drive (intent-gated)", "vmid", vmid, "durable_id", id, "intent", string(s.intent.Get(id)))
continue
}
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)
@@ -727,6 +792,8 @@ func (s *Server) recordIntent(ctx context.Context, where, action string) {
err = s.intent.SetEnrolled(id)
case "ejected":
err = s.intent.SetEjected(id)
case "decommissioned":
err = s.intent.SetDecommissioned(id)
}
if err != nil {
s.logger.Warn("local-api: intent record failed", "where", where, "action", action, "durable_id", id, "err", err)