Files
felhom-agent/internal/storage/watchdog_test.go
T
admin 77b4f21450 v0.5.1: live-validation prep — fix unmounted-dir durable_id mis-id + watchdog UUID memory
Surfaced preparing the live USB validation on demo-felhom:
- observe.go: an unmounted removable dir-storage no longer falls through to the ROOT fs
  for its backing device/UUID — durable_id was becoming uuid:<root-uuid> (a DR mis-id that
  would re-attach the wrong disk). Now derived only from the target's own mountpoint;
  unmounted → no device + stable store:<name> durable_id. Removed containingMountDevice.
- watchdog.go: remember the fs-UUID observed while attached and backfill it onto the
  re-mount target, so re-mount works even if the known-set cache refreshed mid-drop
  (doc 03 §7 "sourced from the existing definition").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:01:40 +02:00

361 lines
11 KiB
Go

package storage
import (
"context"
"errors"
"net"
"sync"
"testing"
"time"
)
// staticKnown is a settable KnownTargets fake.
type staticKnown struct {
mu sync.Mutex
targets []KnownTarget
err error
calls int
}
func (s *staticKnown) Known(context.Context) ([]KnownTarget, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls++
return s.targets, s.err
}
// 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) {
m.mu.Lock()
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) {
var fires int
clock := time.Unix(1_700_000_000, 0).UTC()
w := NewWatchdog(WatchdogOptions{
Targets: known,
Liveness: live,
Trigger: func() { fires++ },
Interval: time.Second,
Debounce: debounce,
Logger: quietLogger(),
})
w.now = func() time.Time { return clock }
return w, &fires, &clock
}
func TestWatchdog_BaselineThenDropTriggers(t *testing.T) {
known := &staticKnown{targets: []KnownTarget{{Name: "usb", MountBacked: true}}}
live := &mapLiveness{present: map[string]bool{"usb": true}}
w, fires, _ := newTestWatchdog(known, live, 30*time.Second)
ctx := context.Background()
w.tick(ctx) // baseline: present, no trigger
if *fires != 0 {
t.Fatalf("baseline tick must not trigger, fires=%d", *fires)
}
live.set("usb", false) // drop
w.tick(ctx)
if *fires != 1 {
t.Fatalf("a known target drop must trigger an out-of-band report, fires=%d", *fires)
}
}
func TestWatchdog_NeverAttachedNotFlagged(t *testing.T) {
// A defined-but-absent target (never seen present) must not be flagged on its absence.
known := &staticKnown{targets: []KnownTarget{{Name: "usb", MountBacked: true}}}
live := &mapLiveness{present: map[string]bool{"usb": false}}
w, fires, _ := newTestWatchdog(known, live, 30*time.Second)
ctx := context.Background()
w.tick(ctx) // baseline absent
w.tick(ctx) // still absent
if *fires != 0 {
t.Fatalf("a never-attached target must not trigger, fires=%d", *fires)
}
// Now it appears (reconnect) → that IS a transition worth reporting.
live.set("usb", true)
w.tick(ctx)
if *fires != 1 {
t.Fatalf("attach transition should trigger, fires=%d", *fires)
}
}
func TestWatchdog_DebounceCoalescesFlaps(t *testing.T) {
known := &staticKnown{targets: []KnownTarget{{Name: "usb", MountBacked: true}}}
live := &mapLiveness{present: map[string]bool{"usb": true}}
w, fires, clock := newTestWatchdog(known, live, 30*time.Second)
ctx := context.Background()
w.tick(ctx) // baseline present
// First drop fires immediately (leading edge).
live.set("usb", false)
w.tick(ctx)
if *fires != 1 {
t.Fatalf("first drop should fire, fires=%d", *fires)
}
// Flap within the debounce window: re-attach then drop again — suppressed (pending).
*clock = clock.Add(5 * time.Second)
live.set("usb", true)
w.tick(ctx)
*clock = clock.Add(5 * time.Second)
live.set("usb", false)
w.tick(ctx)
if *fires != 1 {
t.Fatalf("flaps within the debounce window must be coalesced, fires=%d", *fires)
}
// After the window elapses, the pending change fires (trailing edge), even with no new
// transition this tick.
*clock = clock.Add(30 * time.Second)
w.tick(ctx)
if *fires != 2 {
t.Fatalf("a pending change must fire once the window elapses, fires=%d", *fires)
}
}
// fakeRemounter records re-mount dispatches.
type fakeRemounter struct {
mu sync.Mutex
calls []string
uuids []string
}
func (r *fakeRemounter) Remount(_ context.Context, t KnownTarget) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, t.Name)
r.uuids = append(r.uuids, t.UUID)
}
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_ReMountUsesRememberedUUID(t *testing.T) {
// The re-mount key must survive the known-set cache losing the UUID mid-drop (an
// unmounted dir-storage can't resolve its own UUID). The watchdog remembers the UUID
// observed while attached and backfills it onto the re-mount target.
known := &staticKnown{targets: []KnownTarget{{Name: "usb", MountBacked: true, UUID: "277a2179", MountPath: "/mnt/usb"}}}
live := &mapLiveness{present: map[string]bool{"usb": true}, device: map[string]bool{"usb": true}}
rem := &fakeRemounter{}
w, _, _ := newTestWatchdog(known, live, 30*time.Second)
w.remounter = rem
w.spawn = func(f func()) { f() }
ctx := context.Background()
w.tick(ctx) // baseline attached → remembers UUID 277a2179
// Cache refreshes during the drop and loses the UUID (the unmounted observe can't
// resolve it); device still present, unmounted.
known.targets = []KnownTarget{{Name: "usb", MountBacked: true, UUID: "", MountPath: "/mnt/usb"}}
live.set("usb", false)
w.tick(ctx)
if rem.count() != 1 {
t.Fatalf("expected one re-mount, got %d", rem.count())
}
if rem.uuids[0] != "277a2179" {
t.Fatalf("re-mount must use the remembered UUID, got %q", rem.uuids[0])
}
}
func TestWatchdog_ReadErrorSkipsTick(t *testing.T) {
known := &staticKnown{err: errors.New("proxmox blip")}
live := &mapLiveness{present: map[string]bool{}}
w, fires, _ := newTestWatchdog(known, live, time.Second)
w.tick(context.Background())
if *fires != 0 {
t.Fatalf("a known-target read error must not trigger, fires=%d", *fires)
}
}
func TestWatchdog_RunBaselinesAndStops(t *testing.T) {
// Smoke test of the goroutine wiring under -race: Run establishes a baseline and exits
// cleanly on ctx cancellation.
known := &staticKnown{targets: []KnownTarget{{Name: "usb", MountBacked: true}}}
live := &mapLiveness{present: map[string]bool{"usb": true}}
w, _, _ := newTestWatchdog(known, live, time.Second)
w.now = func() time.Time { return time.Now().UTC() }
w.interval = 5 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- w.Run(ctx) }()
time.Sleep(30 * time.Millisecond)
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("Run returned %v, want nil on cancel", err)
}
case <-time.After(time.Second):
t.Fatal("watchdog did not stop on cancel")
}
}
func TestCachingKnownTargets_RefreshesOnTTL(t *testing.T) {
src := &staticKnown{targets: []KnownTarget{{Name: "a"}}}
clock := time.Unix(1_700_000_000, 0).UTC()
c := NewCachingKnownTargets(src, 60*time.Second)
c.now = func() time.Time { return clock }
ctx := context.Background()
if _, err := c.Known(ctx); err != nil {
t.Fatal(err)
}
if _, err := c.Known(ctx); err != nil { // within TTL → cached
t.Fatal(err)
}
if src.calls != 1 {
t.Fatalf("within TTL the source must be hit once, calls=%d", src.calls)
}
clock = clock.Add(61 * time.Second) // past TTL
if _, err := c.Known(ctx); err != nil {
t.Fatal(err)
}
if src.calls != 2 {
t.Fatalf("past TTL the source must refresh, calls=%d", src.calls)
}
}
func TestCachingKnownTargets_ServesStaleOnError(t *testing.T) {
src := &staticKnown{targets: []KnownTarget{{Name: "a"}}}
clock := time.Unix(1_700_000_000, 0).UTC()
c := NewCachingKnownTargets(src, 1*time.Second)
c.now = func() time.Time { return clock }
ctx := context.Background()
if _, err := c.Known(ctx); err != nil { // prime the cache
t.Fatal(err)
}
clock = clock.Add(2 * time.Second)
src.mu.Lock()
src.err = errors.New("blip")
src.mu.Unlock()
got, err := c.Known(ctx)
if err != nil {
t.Fatalf("a transient error must serve stale, got err=%v", err)
}
if len(got) != 1 || got[0].Name != "a" {
t.Fatalf("stale set not served: %+v", got)
}
}
func TestHostLiveness_MountBackedPresence(t *testing.T) {
host := &fakeHostReader{
mounts: []Mount{{Device: "/dev/sdb1", MountPoint: "/mnt/usb", FSType: "ext4"}},
exists: map[string]bool{"/dev/sdb1": true},
}
hl := NewHostLiveness(host, time.Second)
tgt := KnownTarget{Name: "usb", MountBacked: true, MountPath: "/mnt/usb", BackingDevice: "/dev/sdb1"}
if !hl.Present(context.Background(), tgt) {
t.Error("mounted device should be present")
}
// Unmount it: no exact mount entry → absent.
host.mounts = []Mount{{Device: "/dev/mapper/root", MountPoint: "/", FSType: "ext4"}}
if hl.Present(context.Background(), tgt) {
t.Error("unmounted device should be absent")
}
}
func TestHostLiveness_NetworkDial(t *testing.T) {
hl := NewHostLiveness(&fakeHostReader{}, time.Second)
var dialed string
hl.dial = func(network, addr string, _ time.Duration) (net.Conn, error) {
dialed = addr
return nil, errors.New("refused")
}
tgt := KnownTarget{Name: "nfs", Network: true, ReachEndpoint: "10.0.0.5:2049"}
if hl.Present(context.Background(), tgt) {
t.Error("a refused dial should report not-present")
}
if dialed != "10.0.0.5:2049" {
t.Errorf("dialed %q, want 10.0.0.5:2049", dialed)
}
// No endpoint to probe → don't false-alarm (the slow cycle uses the active flag).
if !hl.Present(context.Background(), KnownTarget{Name: "x", Network: true}) {
t.Error("network target without endpoint must not be flagged down")
}
}