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
+13 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -164,6 +165,15 @@ func (e *Engine) execute(ctx context.Context, act Action) error {
upid, err = e.api.Stop(ctx, act.VMID)
case ActionSetConfig:
upid, err = e.api.SetConfig(ctx, act.VMID, act.Params)
case ActionResize:
// Defensive grow-only guard at the executor: a resize size MUST be a "+<n>" grow.
// The planner only ever emits grows, but never let a shrink reach Proxmox here.
disk, size := act.Params["disk"], act.Params["size"]
if !strings.HasPrefix(size, "+") {
err = fmt.Errorf("reconcile: refusing non-grow resize size %q (data-losing shrink is a signed op)", size)
} else {
upid, err = e.api.ResizeLXC(ctx, act.VMID, disk, size)
}
default:
err = fmt.Errorf("reconcile: unknown action kind %q", act.Kind)
}
@@ -208,7 +218,9 @@ func (e *Engine) readActual(ctx context.Context) (ActualState, error) {
}
guests := make(map[int]ActualGuest, len(lxc))
for _, g := range lxc {
a := ActualGuest{VMID: g.VMID, Run: normRun(g.Status)}
// MaxDisk (bytes) comes from the list entry and is reliable independent of the
// per-guest config read — it is the actual side of the grow comparison.
a := ActualGuest{VMID: g.VMID, Run: normRun(g.Status), DiskBytes: g.MaxDisk}
cfg, err := e.api.GuestConfig(ctx, g.VMID)
if err != nil {
e.logger.Warn("reconcile: GuestConfig failed; spec unknown (run-state kept)",