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
+35
View File
@@ -47,6 +47,41 @@ The reported `StorageTarget` shape is a cross-repo contract duplicated in `felho
`internal/hub/testdata/host-report.golden.json` is byte-identical with the hub's copy and a
bidirectional key-set test guards drift.
### The privileged `HostOps` surface (slice 5 Phase B)
The write side — the one place the agent steps outside its Proxmox API token into OS-root —
is isolated behind the `HostOps` seam (`hostops.go`): production `SudoHostOps` shells out via
a narrow **sudoers allowlist** (`configs/felhom-agent.sudoers`) with **fixed argument vectors
and no shell**; tests use a fake (no real root in the suite).
- **Persistent mounts** are **systemd `.mount` units keyed by fs-UUID**
(`What=/dev/disk/by-uuid/<UUID>`, enabled so they survive reboot) — not raw fstab or a
transient `mount`. Benign re-mount is idempotent; **detach** (stop+disable) is destructive
and routes through the gate.
- **Every argument is validated before any command is constructed** (`validate.go`): UUIDs
against a strict hex regex, mount paths confined + traversal-checked, SMART devices
whitelisted to raw disks, LVM names charset-checked. The adversarial matrix in
`validate_test.go` proves a hostile UUID / path / device is refused with **zero** exec.
- **SMART** (`smart.go`) fills `StorageTarget.smart` via `smartctl -a -j` — SATA *and* NVMe
attribute sets, degrading to `UNKNOWN` for devices that expose no SMART (e.g. a USB bridge).
**`lvs`** fills the lvmthin thin-pool **metadata** fill (metadata exhaustion corrupts a pool
like data exhaustion).
- The **watchdog** gains a benign **re-mount response**: when a known mount-backed target's
device returns unmounted, it dispatches (off the poll path) a by-UUID re-mount, routed
through the gate as benign. The **disk-grow executor** (`pct resize`, **grow-only**) lands
in `internal/reconcile` as a benign action; **destructive storage ops** (detach/wipe/
data-losing-resize) construct a `ClassStorageWipe`/`ClassDecommission` intent bound to the
storage **target identity** and go through the slice-4 gate (built + tested, inert live).
### `--selftest=storage` (live storage harness)
Runs standalone on the Proxmox host (no hub needed):
- bare: an **observe pass** printing the full `StorageTarget` table incl. the SMART summary
and thin-pool data+metadata fill.
- `-watch <dur>` (e.g. `--selftest=storage -watch 3m`): runs the watchdog verbose for the
window with the **re-mount response live**, so an operator can physically cycle a drive and
watch detect → report → re-mount in the logs.
## The `proxmox` package — model
Two backends, one fixed routing policy (the fence is structural — `Client` never shells out,