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>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user