v0.5.0: slice 5 Phase B — the host-root surface (mounts + SMART + grow + destructive gate)

The privileged write surface, isolated behind a narrow, arg-validated, adversarially-
tested seam (HostOps), the same discipline as the slice-4 gate. Completes slice 5.

- internal/storage: HostOps seam + SudoHostOps (systemd .mount units by fs-UUID, detach,
  SMART, lvs) via sudoers allowlist + fixed arg vectors, no shell; NoopHostOps fallback.
- validate.go: strict UUID/mount-path/device/LVM validators + in-process systemd-escape.
  Headline test: adversarial matrix (metacharacters/traversal/malformed) refused with
  zero exec.
- smart.go: smartctl SATA + NVMe parse, UNKNOWN-degrade; lvs thin-pool metadata fill.
- observer enrichment (Observe only): fills smart + thin-pool metadata.
- watchdog: benign re-mount response off the poll path (DevicePresent probe, rate-limited).
- reconcile: ActionResize (benign, grow-only) + proxmox.ResizeLXC; destructive storage ops
  (ClassStorageWipe/Decommission) through the slice-4 gate, target-scoped; built+tested,
  inert live.
- --selftest=storage [-watch] live harness; configs/felhom-agent.sudoers; privileged.* knobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:53:38 +02:00
parent 27b68f043b
commit 9d6e49236c
25 changed files with 2074 additions and 182 deletions
+31 -2
View File
@@ -20,8 +20,18 @@ const (
// ActionSetConfig applies benign config changes (cores/memory/description) in one
// PUT (proxmox VM.Config.*). May return synchronously (empty UPID) — slice-4 proven.
ActionSetConfig ActionKind = "set_config"
// ActionResize GROWS the rootfs (proxmox `pct resize`, async). Grow-only — the planner
// emits it only when desired DiskBytes > actual; a shrink is data-losing and is refused
// (never silently applied as a grow). Slice 5 Phase B; unfed live until slice 10.
ActionResize ActionKind = "resize"
)
// growRoundMiB rounds a positive byte delta UP to whole MiB for the Proxmox `+<n>M` grow
// size (Proxmox resizes in whole units; rounding up never under-provisions the desired size).
func growRoundMiB(deltaBytes int64) int64 {
return (deltaBytes + bytesPerMiB - 1) / bytesPerMiB
}
// Action is one minimal mutation the engine will dispatch onto the per-guest queue.
// In Phase A every Action is benign by construction (only the benign kinds exist).
// Phase B's classifier/gate sits in front of the executor and may tag an action
@@ -99,8 +109,27 @@ func Plan(desired DesiredState, actual ActualState, norm FieldNormalizers) []Act
params["memory"] = strconv.FormatInt(want, 10)
reasons = append(reasons, fmt.Sprintf("memory %dMiB->%dMiB", a.MemoryMiB, want))
}
// DiskBytes is intentionally NOT reconciled here (rootfs grow is
// `pct resize`, grow-only and separate — a later slice).
}
// Rootfs GROW (slice 5 Phase B) — a separate async op from the config PUT, so
// its own Action. GROW-ONLY: emit a resize only when desired > actual. A shrink
// (desired < actual) is data-losing and is REFUSED here — we never silently
// clamp it to a grow; it is simply not planned (a deliberate shrink would have
// to come as a signed destructive op, slice 10). DiskBytes==0 means unmanaged.
if d.Spec != nil && d.Spec.DiskBytes > 0 && a.DiskBytes > 0 {
switch {
case d.Spec.DiskBytes > a.DiskBytes:
deltaMiB := growRoundMiB(d.Spec.DiskBytes - a.DiskBytes)
actions = append(actions, Action{
VMID: vmid,
Kind: ActionResize,
Params: map[string]string{"disk": "rootfs", "size": fmt.Sprintf("+%dM", deltaMiB)},
Reason: fmt.Sprintf("disk grow %dB->%dB (+%dMiB)", a.DiskBytes, d.Spec.DiskBytes, deltaMiB),
})
case d.Spec.DiskBytes < a.DiskBytes:
// Shrink refused by omission: emit NO action (a data-losing shrink is a
// signed destructive op, slice 10 — never a benign reconcile grow). The
// executor also guards (size must start with '+'). See the resize note above.
}
}
if d.Description != nil && !norm.Equal("description", *d.Description, a.Description) {
params["description"] = *d.Description