237b85f420
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>
92 lines
2.8 KiB
Go
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))
|
|
}
|
|
}
|
|
}
|