v0.5.0-rc1: slice 5 Phase A — storage observe/report + watchdog (read-only, live)
Fill the slice-3 storage_targets stub and add the fast-poll storage watchdog. Read-only this phase; the host-root surface (mounts/SMART/grow/destructive gate) is Phase B. Hub-owned desired manifest is slice 10, so reconcile against it is built-but-unfed. - internal/storage: StorageTarget wire contract, durable_id derivation per type, HostReader seam (procfs/sysfs, root-free), Observer (storage_targets from ListStorage/NodeStorage + host reads, lvmthin thin-pool fill), and the watchdog (third daemon goroutine; debounced out-of-band report on a known target's attach/disconnect transition). - proxmox.Storage: additive parse-only config fields (durable_id sources). - collector StorageObserver seam; Loop.SetTrigger out-of-band report; daemon runs the watchdog as a third goroutine; StorageConfig knobs. - cross-repo golden kept byte-identical with felhom.eu/hub; bidirectional key-set test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
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 fake.
|
||||
type mapLiveness struct {
|
||||
mu sync.Mutex
|
||||
present map[string]bool
|
||||
}
|
||||
|
||||
func (m *mapLiveness) set(name string, p bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.present[name] = p
|
||||
}
|
||||
func (m *mapLiveness) Present(_ context.Context, t KnownTarget) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.present[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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user