Files
admin 237b85f420 agent v0.27.0: slice 10 P3 — self-heal watchdog reconcile + 4-state intent model
IntentStore (durable-id-keyed: new/enrolled/ejected/decommissioned, OnAbsent
replug rule). Watchdog re-mounts only enrolled drives (out-of-band unmount heals;
ejected/decommissioned/new left alone) + exp-backoff flapping guard (alert@4,
cap@8). guest-attach records enrolled; eject records ejected. Non-hollow tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 17:49:25 +02:00

92 lines
2.8 KiB
Go

package storage
import (
"path/filepath"
"testing"
)
func TestIntentStore_StatesAndPersistence(t *testing.T) {
path := filepath.Join(t.TempDir(), "intent.json")
s, err := OpenIntentStore(path)
if err != nil {
t.Fatalf("open: %v", err)
}
// new: absent → IntentNew
if s.Get("uuid:A") != IntentNew {
t.Fatalf("absent should be new, got %q", s.Get("uuid:A"))
}
if err := s.SetEnrolled("uuid:A"); err != nil {
t.Fatal(err)
}
if err := s.SetEjected("uuid:B"); err != nil {
t.Fatal(err)
}
if err := s.SetDecommissioned("uuid:C"); err != nil {
t.Fatal(err)
}
// reload → states persisted
s2, err := OpenIntentStore(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if s2.Get("uuid:A") != IntentEnrolled || s2.Get("uuid:B") != IntentEjected || s2.Get("uuid:C") != IntentDecommissioned {
t.Fatalf("states not persisted: A=%q B=%q C=%q", s2.Get("uuid:A"), s2.Get("uuid:B"), s2.Get("uuid:C"))
}
}
func TestIntentStore_OnAbsent_ReplyRule(t *testing.T) {
s, _ := OpenIntentStore(filepath.Join(t.TempDir(), "i.json"))
_ = s.SetEjected("uuid:E")
_ = s.SetDecommissioned("uuid:D")
_ = s.SetEnrolled("uuid:N")
// ejected → absent → enrolled (so a replug auto-mounts)
if err := s.OnAbsent("uuid:E"); err != nil {
t.Fatal(err)
}
if s.Get("uuid:E") != IntentEnrolled {
t.Fatalf("ejected→absent should clear to enrolled, got %q", s.Get("uuid:E"))
}
// decommissioned survives absent
_ = s.OnAbsent("uuid:D")
if s.Get("uuid:D") != IntentDecommissioned {
t.Fatalf("decommissioned must survive absent, got %q", s.Get("uuid:D"))
}
// enrolled unchanged by absent
_ = s.OnAbsent("uuid:N")
if s.Get("uuid:N") != IntentEnrolled {
t.Fatalf("enrolled should stay enrolled, got %q", s.Get("uuid:N"))
}
}
func TestIntentStore_EmptyDurableIDRefused(t *testing.T) {
s, _ := OpenIntentStore(filepath.Join(t.TempDir(), "i.json"))
if err := s.SetEnrolled(""); err == nil {
t.Fatal("recording intent for an empty durable-id must be refused")
}
if s.Get("") != IntentNew {
t.Fatal("empty durable-id must read as new")
}
}
// ReconcilePermitted is the gate the reconciler uses: only an `enrolled` drive (intent-none) is
// auto-mounted; new/ejected/decommissioned are not. This documents the decision table.
func TestIntentStore_ReconcileGate(t *testing.T) {
s, _ := OpenIntentStore(filepath.Join(t.TempDir(), "i.json"))
_ = s.SetEnrolled("uuid:enr")
_ = s.SetEjected("uuid:ej")
_ = s.SetDecommissioned("uuid:dec")
cases := map[string]bool{
"uuid:enr": true, // enrolled → reconcile
"uuid:ej": false, // ejected → leave alone
"uuid:dec": false, // decommissioned → never
"uuid:new": false, // new → never auto-mount
}
for id, want := range cases {
got := s.Get(id) == IntentEnrolled
if got != want {
t.Errorf("reconcile-permitted(%s): got %v want %v (intent %q)", id, got, want, s.Get(id))
}
}
}