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
+80 -1
View File
@@ -24,10 +24,11 @@ func (s *staticKnown) Known(context.Context) ([]KnownTarget, error) {
return s.targets, s.err
}
// mapLiveness is a settable per-target presence fake.
// mapLiveness is a settable per-target presence + device-presence fake.
type mapLiveness struct {
mu sync.Mutex
present map[string]bool
device map[string]bool // backing-device presence (re-mount trigger)
}
func (m *mapLiveness) set(name string, p bool) {
@@ -35,11 +36,24 @@ func (m *mapLiveness) set(name string, p bool) {
defer m.mu.Unlock()
m.present[name] = p
}
func (m *mapLiveness) setDevice(name string, p bool) {
m.mu.Lock()
defer m.mu.Unlock()
if m.device == nil {
m.device = map[string]bool{}
}
m.device[name] = p
}
func (m *mapLiveness) Present(_ context.Context, t KnownTarget) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.present[t.Name]
}
func (m *mapLiveness) DevicePresent(_ context.Context, t KnownTarget) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.device[t.Name]
}
// newTestWatchdog builds a watchdog with a manual clock and a trigger counter.
func newTestWatchdog(known KnownTargets, live TargetLiveness, debounce time.Duration) (*Watchdog, *int, *time.Time) {
@@ -129,6 +143,71 @@ func TestWatchdog_DebounceCoalescesFlaps(t *testing.T) {
}
}
// fakeRemounter records re-mount dispatches.
type fakeRemounter struct {
mu sync.Mutex
calls []string
}
func (r *fakeRemounter) Remount(_ context.Context, t KnownTarget) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, t.Name)
}
func (r *fakeRemounter) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.calls)
}
func TestWatchdog_ReMountOnDeviceReturn(t *testing.T) {
known := &staticKnown{targets: []KnownTarget{{Name: "usb", MountBacked: true, UUID: "1234-ABCD", MountPath: "/mnt/usb"}}}
live := &mapLiveness{present: map[string]bool{"usb": true}, device: map[string]bool{"usb": true}}
rem := &fakeRemounter{}
w, _, clock := newTestWatchdog(known, live, 30*time.Second)
w.remounter = rem
w.spawn = func(f func()) { f() } // run the dispatch synchronously for deterministic assertion
ctx := context.Background()
w.tick(ctx) // baseline: present
if rem.count() != 0 {
t.Fatalf("no re-mount at baseline, got %d", rem.count())
}
// Drop: device gone, unmounted. No re-mount (nothing to mount).
live.set("usb", false)
live.setDevice("usb", false)
w.tick(ctx)
if rem.count() != 0 {
t.Fatalf("no re-mount while device absent, got %d", rem.count())
}
// Device returns but still unmounted → re-mount dispatched.
live.setDevice("usb", true)
w.tick(ctx)
if rem.count() != 1 {
t.Fatalf("re-mount expected when device returns unmounted, got %d", rem.count())
}
// Still device-present-unmounted within the debounce window → rate-limited (no storm).
*clock = clock.Add(5 * time.Second)
w.tick(ctx)
if rem.count() != 1 {
t.Fatalf("re-mount must be rate-limited within debounce, got %d", rem.count())
}
// Successful mount (present=true) clears the rate-limit; a later cycle re-mounts again.
live.set("usb", true)
*clock = clock.Add(5 * time.Second)
w.tick(ctx) // present → clears lastRemount
live.set("usb", false) // drop again, device still present
*clock = clock.Add(5 * time.Second)
w.tick(ctx)
if rem.count() != 2 {
t.Fatalf("a fresh device cycle should re-mount again, got %d", rem.count())
}
}
func TestWatchdog_ReadErrorSkipsTick(t *testing.T) {
known := &staticKnown{err: errors.New("proxmox blip")}
live := &mapLiveness{present: map[string]bool{}}