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
+70 -4
View File
@@ -92,7 +92,7 @@ func TestObserve_BuildsTargetsFromProxmoxAndHostReads(t *testing.T) {
removable: map[string]bool{"/dev/sdb1": true, "/dev/mapper/pve-root": false},
}
got, err := NewObserver(api, host, quietLogger()).Observe(context.Background())
got, err := NewObserver(api, host, nil, quietLogger()).Observe(context.Background())
if err != nil {
t.Fatalf("Observe: %v", err)
}
@@ -156,6 +156,72 @@ func TestObserve_BuildsTargetsFromProxmoxAndHostReads(t *testing.T) {
}
}
// fakeHostOps fills SMART + thin-pool metadata for the enrichment test.
type fakeHostOps struct {
smartByDevice map[string]hub.SmartSummary
metaByPool map[string]float64 // "vg/pool" -> fraction
smartDevices []string // records which devices SMART was called on
}
func (f *fakeHostOps) EnsureMount(context.Context, MountSpec) error { return nil }
func (f *fakeHostOps) Unmount(context.Context, string) error { return nil }
func (f *fakeHostOps) SMART(_ context.Context, device string) (hub.SmartSummary, error) {
f.smartDevices = append(f.smartDevices, device)
if s, ok := f.smartByDevice[device]; ok {
return s, nil
}
return hub.SmartSummary{Health: hub.SmartUnknown}, nil
}
func (f *fakeHostOps) ThinPoolMetadata(_ context.Context, vg, pool string) (float64, bool) {
v, ok := f.metaByPool[vg+"/"+pool]
return v, ok
}
func TestObserve_EnrichesSMARTAndThinPoolMetadata(t *testing.T) {
api := &fakeStorageAPI{
node: "demo-felhom",
cluster: []proxmox.Storage{
{Storage: "local-lvm", Type: "lvmthin", VGName: "pve", ThinPool: "data"},
{Storage: "usb-backup", Type: "dir", Path: "/mnt/usb-backup"},
},
nodeSt: []proxmox.Storage{
{Storage: "local-lvm", Type: "lvmthin", Active: 1, UsedFraction: 0.4},
{Storage: "usb-backup", Type: "dir", Path: "/mnt/usb-backup", Active: 1},
},
}
host := &fakeHostReader{
mounts: []Mount{{Device: "/dev/sdb1", MountPoint: "/mnt/usb-backup", FSType: "ext4"}},
uuids: map[string]string{"/dev/sdb1": "1111-2222"},
exists: map[string]bool{"/dev/sdb1": true},
removable: map[string]bool{"/dev/sdb1": true},
}
ops := &fakeHostOps{
smartByDevice: map[string]hub.SmartSummary{"/dev/sdb": {Health: hub.SmartPassed}},
metaByPool: map[string]float64{"pve/data": 0.12},
}
got, err := NewObserver(api, host, ops, quietLogger()).Observe(context.Background())
if err != nil {
t.Fatal(err)
}
m := byName(got)
// SMART runs on the WHOLE disk (/dev/sdb), not the partition (/dev/sdb1).
if len(ops.smartDevices) != 1 || ops.smartDevices[0] != "/dev/sdb" {
t.Errorf("SMART should target the whole disk /dev/sdb, got %v", ops.smartDevices)
}
if m["usb-backup"].Smart.Health != hub.SmartPassed {
t.Errorf("usb SMART not enriched: %+v", m["usb-backup"].Smart)
}
// lvmthin metadata fill (Phase B) is now populated.
lvm := m["local-lvm"]
if lvm.ThinPool == nil || lvm.ThinPool.MetadataUsedFraction == nil {
t.Fatalf("lvmthin metadata fill not enriched: %+v", lvm.ThinPool)
}
if *lvm.ThinPool.MetadataUsedFraction != 0.12 {
t.Errorf("metadata fraction = %v, want 0.12", *lvm.ThinPool.MetadataUsedFraction)
}
}
func TestObserve_USBUnpluggedIsDisconnected(t *testing.T) {
api := &fakeStorageAPI{
node: "demo-felhom",
@@ -170,7 +236,7 @@ func TestObserve_USBUnpluggedIsDisconnected(t *testing.T) {
host := &fakeHostReader{
mounts: []Mount{{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"}}, // no /mnt/usb-backup
}
got, err := NewObserver(api, host, quietLogger()).Observe(context.Background())
got, err := NewObserver(api, host, nil, quietLogger()).Observe(context.Background())
if err != nil {
t.Fatal(err)
}
@@ -187,7 +253,7 @@ func TestObserve_USBUnpluggedIsDisconnected(t *testing.T) {
func TestObserve_ProxmoxErrorIsFatalForStorage(t *testing.T) {
api := &fakeStorageAPI{node: "n", listErr: context.DeadlineExceeded}
if _, err := NewObserver(api, &fakeHostReader{}, quietLogger()).Observe(context.Background()); err == nil {
if _, err := NewObserver(api, &fakeHostReader{}, nil, quietLogger()).Observe(context.Background()); err == nil {
t.Fatal("a Proxmox read error must surface (the collector then omits storage this cycle)")
}
}
@@ -199,7 +265,7 @@ func TestObserve_MountReadFailureDegradesNotFatal(t *testing.T) {
nodeSt: []proxmox.Storage{{Storage: "local-lvm", Type: "lvmthin", Active: 1, UsedFraction: 0.1}},
}
host := &fakeHostReader{mountsErr: io.ErrUnexpectedEOF}
got, err := NewObserver(api, host, quietLogger()).Observe(context.Background())
got, err := NewObserver(api, host, nil, quietLogger()).Observe(context.Background())
if err != nil {
t.Fatalf("a host mount-read failure must degrade, not fail: %v", err)
}