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:
@@ -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{}}
|
||||
|
||||
Reference in New Issue
Block a user