254 lines
12 KiB
Go
254 lines
12 KiB
Go
package assign
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"git.novox.be/novox/mesh-sdk/go/health"
|
|
)
|
|
|
|
// 1297/B9 (the assigner's half): the ask about a held fingerprint is a valid ask of the router and of mesh-sdk's layout at
|
|
// its key, of a kind the view may answer.
|
|
func TestTheHoldAskIsValid(t *testing.T) {
|
|
key, err := delivery.HoldAskKey("mail", fNew)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now()
|
|
a := HoldAsk(key, "mail", fNew, "ace", "health failed, phase running: connection refused", 0, now)
|
|
if err := a.Ask.Check(now); err != nil {
|
|
t.Errorf("the router refuses the ask: %v", err)
|
|
}
|
|
if _, err := delivery.EncodeAssignerAsk(key, a); err != nil {
|
|
t.Errorf("the ask cannot be written: %v", err)
|
|
}
|
|
// 1906/H3: the ask names the machine whose check failed and the hold's words.
|
|
if !strings.Contains(a.Ask.Explanation, "on ace") || !strings.Contains(a.Ask.Explanation, "connection refused") {
|
|
t.Errorf("the ask does not name the node and the hold's words: %q", a.Ask.Explanation)
|
|
}
|
|
// 1906/H2: an ask written again after a lift is another asking, under an id of its own.
|
|
again := HoldAsk(key, "mail", fNew, "ace", "x", 7, now)
|
|
if again.Asking != 7 || again.Ask.ID != delivery.AskingID(key, 7) || again.Ask.ID == a.Ask.ID {
|
|
t.Errorf("the asking after a lift = %d %s", again.Asking, again.Ask.ID)
|
|
}
|
|
if _, err := delivery.EncodeAssignerAsk(key, again); err != nil {
|
|
t.Errorf("the ask asked again cannot be written: %v", err)
|
|
}
|
|
if _, err := delivery.ViewAnswerKey(key); err != nil {
|
|
t.Errorf("the view may not answer it: %v", err)
|
|
}
|
|
}
|
|
|
|
// 1906/D3 (ADR 0539 §19): the ask about a data module held on a node is a valid ask of the router and of mesh-sdk's layout at
|
|
// its restore key, of a kind the view may answer; it offers restoring (losing the newer writes) and leaving it stopped.
|
|
func TestTheRestoreAskIsValid(t *testing.T) {
|
|
key, err := delivery.RestoreAskKey("ace", "mail", fNew)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now()
|
|
a := RestoreAsk(key, "ace", "mail", fNew, "connection refused", now)
|
|
if err := a.Ask.Check(now); err != nil {
|
|
t.Errorf("the router refuses the ask: %v", err)
|
|
}
|
|
if _, err := delivery.EncodeAssignerAsk(key, a); err != nil {
|
|
t.Errorf("the ask cannot be written: %v", err)
|
|
}
|
|
if _, err := delivery.ViewAnswerKey(key); err != nil {
|
|
t.Errorf("the view may not answer it: %v", err)
|
|
}
|
|
var ids []string
|
|
for _, o := range a.Ask.Options {
|
|
ids = append(ids, o.ID)
|
|
}
|
|
if len(ids) != 2 || ids[0] != OptionRestore || ids[1] != OptionLeaveStopped {
|
|
t.Errorf("options = %v", ids)
|
|
}
|
|
if !strings.Contains(a.Ask.Explanation, "connection refused") || !strings.Contains(a.Ask.Explanation, "ace") {
|
|
t.Errorf("the ask does not name the node and the check's words: %q", a.Ask.Explanation)
|
|
}
|
|
}
|
|
|
|
// 1906/D3: the condition for a held fingerprint names the module, the node and the check's words, under the assigner's own
|
|
// name (ADR 0437 clause 9), and is cleared under the same key.
|
|
func TestTheAssignersConditions(t *testing.T) {
|
|
t.Run("the held condition names module, node and words", heldConditionNamesModuleNodeAndWords)
|
|
t.Run("a lifted hold clears its condition saying why", liftedHoldClearsItsConditionSayingWhy)
|
|
t.Run("1952/WM6: each said condition is a finding, its clear deletes it, said straight only until the take-over", saidConditionsAreFindings)
|
|
}
|
|
|
|
func heldConditionNamesModuleNodeAndWords(t *testing.T) {
|
|
c := HeldCondition("mail", fNew, "ace", "connection refused", true, true)
|
|
if _, err := asks.EncodeCondition(c); err != nil {
|
|
t.Fatalf("the condition is refused: %v", err)
|
|
}
|
|
if c.State != asks.StateRaised || c.Subject.ID != "mail" || c.Subject.Machine != "ace" || !strings.Contains(c.Explanation, "connection refused") || !strings.Contains(c.Key, "mail") {
|
|
t.Errorf("condition = %+v", c)
|
|
}
|
|
if got := HeldCondition("mail", fNew, "", "", false, false); got.State != asks.StateCleared || got.Key != c.Key {
|
|
t.Errorf("cleared = %+v", got)
|
|
}
|
|
}
|
|
|
|
// 1906/H2: a hold lifted by itself clears the held condition under its key, saying why.
|
|
func liftedHoldClearsItsConditionSayingWhy(t *testing.T) {
|
|
c := LiftedCondition("mail", fNew)
|
|
if _, err := asks.EncodeCondition(c); err != nil {
|
|
t.Fatalf("the condition is refused: %v", err)
|
|
}
|
|
if c.State != asks.StateCleared || c.Key != HeldCondition("mail", fNew, "ace", "x", true, false).Key || !strings.Contains(c.Resolved, "lifted its hold by itself") {
|
|
t.Errorf("condition = %+v", c)
|
|
}
|
|
}
|
|
|
|
// 1952/WM6 (ADR 0564 §2, issue 1952 note 16): every condition the assigner says is a finding in its own bucket, under
|
|
// `<scope>.<id>.<kind>`, written at the raise and deleted at the clear; it is said straight to the router while the take-over
|
|
// key follows the controller, and only as a finding from the take-over.
|
|
func saidConditionsAreFindings(t *testing.T) {
|
|
at := time.Date(2026, 10, 29, 21, 0, 0, 0, time.UTC)
|
|
for _, c := range []struct {
|
|
raised, cleared asks.Condition
|
|
key string
|
|
}{
|
|
{HeldCondition("mail", fNew, "ace", "refused", true, true), LiftedCondition("mail", fNew), "module.mail." + strings.TrimPrefix(fNew, delivery.SourcePrefix2) + ".held"},
|
|
{NodeFailedCondition("ace", "mail", fNew, NodeFailure{Words: "x", Others: []string{"g14"}}, true), NodeFailedCondition("ace", "mail", fNew, NodeFailure{}, false), "module.ace.mail.failed-alone"},
|
|
{NeitherCondition("ace", "mail", Holding{Rule: 3, Words: "w"}, true), NeitherCondition("ace", "mail", Holding{}, false), "module.ace.mail.neither"},
|
|
{PlacementCondition(Pair{Node: "ace", Module: "mail", Only: OnlyAssignments}, true), PlacementCondition(Pair{Node: "ace", Module: "mail"}, false), "module.ace.mail.placement"},
|
|
{SeatWaitsCondition([]string{"placement"}), SeatWaitsCondition(nil), "module.mesh-delivery-assigner.seat-states"},
|
|
{BackupCondition("ace", "mail", fNew, "disk full", true), BackupCondition("ace", "mail", fNew, "", false), "module.ace.mail." + strings.TrimPrefix(fNew, delivery.SourcePrefix2) + ".backup"},
|
|
} {
|
|
puts, deletes, said := map[string]health.Finding{}, []string{}, 0
|
|
keeper := false
|
|
f := Findings{Put: func(k string, v []byte) error {
|
|
got, err := health.DecodeFinding(v)
|
|
puts[k] = got
|
|
return err
|
|
}, Delete: func(k string) error { deletes = append(deletes, k); return nil }, ToKeeper: func() bool { return keeper }, Now: func() time.Time { return at }}
|
|
say := f.Say(func(asks.Condition) error { said++; return nil })
|
|
if err := say(c.raised); err != nil {
|
|
t.Fatalf("%s: %v", c.key, err)
|
|
}
|
|
got, ok := puts[c.key]
|
|
if !ok || got.Severity != health.Severity(c.raised.Severity) || got.Headline != c.raised.Headline || got.Observer != delivery.AssignerModule ||
|
|
got.Subject.Machine != c.raised.Subject.Machine || said != 1 {
|
|
t.Fatalf("%s: finding %+v (written %v), said %d", c.key, got, puts, said)
|
|
}
|
|
keeper = true
|
|
if err := say(c.cleared); err != nil || !slices.Equal(deletes, []string{c.key}) || said != 1 {
|
|
t.Fatalf("%s: deleted %v, said %d after the take-over (%v)", c.key, deletes, said, err)
|
|
}
|
|
}
|
|
if _, _, _, err := FindingOf(asks.Condition{Key: "another.x", State: asks.StateRaised}, at); err == nil {
|
|
t.Error("a condition not the assigner's is a finding")
|
|
}
|
|
}
|
|
|
|
// 1906/D4: the record ask and the backup ask are valid asks of the router and of mesh-sdk's layout at their keys, answerable in
|
|
// the view, and name the move and the node.
|
|
func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
|
|
now := time.Now()
|
|
rkey, err := delivery.RecordAskKey("ace", "mail", "sha256:"+strings.Repeat("b", 64))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := RecordAsk(rkey, "ace", "mail", fNew, RecordNeed{Node: "ace", Digest: "sha256:" + strings.Repeat("b", 64), Judgement: delivery.JudgedCannot, Words: "the composition it runs is gone"}, now)
|
|
bkey, err := delivery.BackupAskKey("ace", "mail", fNew)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dgst := "sha256:" + strings.Repeat("b", 64)
|
|
b := BackupAsk(bkey, "ace", "mail", fNew, dgst, "no space left", 0, now)
|
|
for key, a := range map[string]delivery.AssignerAsk{rkey: r, bkey: b} {
|
|
if err := a.Ask.Check(now); err != nil {
|
|
t.Errorf("%s: the router refuses the ask: %v", key, err)
|
|
}
|
|
if _, err := delivery.EncodeAssignerAsk(key, a); err != nil {
|
|
t.Errorf("%s: cannot be written: %v", key, err)
|
|
}
|
|
if _, err := delivery.ViewAnswerKey(key); err != nil {
|
|
t.Errorf("%s: the view may not answer it: %v", key, err)
|
|
}
|
|
}
|
|
if !strings.Contains(r.Ask.Explanation, "gone") || r.Ask.Options[0].ID != OptionYes {
|
|
t.Errorf("the record ask = %+v", r.Ask)
|
|
}
|
|
if !strings.Contains(b.Ask.Explanation, "no space left") || b.Ask.Options[0].ID != delivery.BackupAgain || b.Ask.Options[1].ID != delivery.BackupSkip {
|
|
t.Errorf("the backup ask = %+v", b.Ask)
|
|
}
|
|
// the id binds the ask to the composition and to its number (review round 1, items 3 and 4)
|
|
if again := BackupAsk(bkey, "ace", "mail", fNew, dgst, "x", NextBackupAsking(0), now); again.Ask.ID == b.Ask.ID {
|
|
t.Errorf("asked again under the same id")
|
|
}
|
|
if NextBackupAsking(-1) != 0 || NextBackupAsking(0) != 2 || NextBackupAsking(2) != 3 {
|
|
t.Errorf("the numbers of the askings: %d %d %d", NextBackupAsking(-1), NextBackupAsking(0), NextBackupAsking(2))
|
|
}
|
|
if BackupAskDigest(b) != dgst {
|
|
t.Errorf("the ask names composition %q, want %q", BackupAskDigest(b), dgst)
|
|
}
|
|
if BackupAnswerWords("again", 0) == BackupAnswerWords("again", 1) {
|
|
t.Errorf("two answers of the same option are one want value")
|
|
}
|
|
c := BackupCondition("ace", "mail", fNew, "no space left", true)
|
|
if _, err := asks.EncodeCondition(c); err != nil {
|
|
t.Errorf("the backup condition is refused: %v", err)
|
|
}
|
|
// ADR 0546 clauses 7 and 8: a put's ask is valid at the record ask's key; for the bus its yes approves and binds the act,
|
|
// so the view cannot answer it, and it lasts no longer than an approving ask may.
|
|
put := delivery.Put{FP: fNew, Why: "the bus's user list", Hold: true}
|
|
d := delivery.ComposedDigest{Digest: dgst, Judgement: delivery.JudgedCannot, Words: "the have names no composition"}
|
|
for _, bus := range []bool{false, true} {
|
|
a, err := PutAsk(rkey, "ace", "mail", put, d, bus, now)
|
|
if err != nil {
|
|
t.Fatalf("bus %v: %v", bus, err)
|
|
}
|
|
if err := a.Ask.Check(now); err != nil {
|
|
t.Errorf("bus %v: the router refuses the put's ask: %v", bus, err)
|
|
}
|
|
if _, err := delivery.EncodeAssignerAsk(rkey, a); err != nil {
|
|
t.Errorf("bus %v: the put's ask cannot be written: %v", bus, err)
|
|
}
|
|
viewed := delivery.ViewAnswerFits(delivery.ViewAnswer{Asking: a.Ask.ID, Option: OptionYes}, a)
|
|
yes := a.Ask.Options[0]
|
|
switch {
|
|
case !strings.Contains(a.Ask.Explanation, "the bus's user list"):
|
|
t.Errorf("bus %v: the put's ask does not say why: %s", bus, a.Ask.Explanation)
|
|
case bus && (yes.Level != asks.Approve || yes.Binds == "" || viewed == nil || a.Ask.Expires.After(now.Add(asks.ApproveLasts))):
|
|
t.Errorf("bus: the yes is %+v, the view's answer %v, expiring %s", yes, viewed, a.Ask.Expires)
|
|
case !bus && (yes.Level != asks.Acknowledge || viewed != nil):
|
|
t.Errorf("a recorded module: the yes is %+v, the view's answer %v", yes, viewed)
|
|
}
|
|
later := put
|
|
later.At = now.Add(time.Minute)
|
|
b, _ := PutAsk(rkey, "ace", "mail", later, d, bus, now)
|
|
if b.Ask.ID == a.Ask.ID || b.Asking != PutAsking(later) {
|
|
t.Errorf("bus %v: a later put's ask is asking %d under id %q, the earlier's %q", bus, b.Asking, b.Ask.ID, a.Ask.ID)
|
|
}
|
|
if err := yes.Performs(PutAct("ace", "mail", fNew, dgst)); err != nil {
|
|
t.Errorf("bus %v: the yes does not perform the act it bound: %v", bus, err)
|
|
}
|
|
if err := yes.Performs(PutAct("ace", "mail", fNew, dig(fOld))); bus && err == nil {
|
|
t.Errorf("bus: the yes performs another composition's act")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1945/AC21: the condition of a module on neither list is urgent, names the module, the node and the rule holding its want back,
|
|
// under the assigner's own name keyed by node and module, and is cleared under the same key.
|
|
func TestTheNeitherConditionNamesModuleNodeAndRule(t *testing.T) {
|
|
c := NeitherCondition("g14", "i3status-rust", Holding{Rule: delivery.RuleProviders, Words: "no provider of x11-display runs healthy on g14 or at its canary"}, true)
|
|
if _, err := asks.EncodeCondition(c); err != nil {
|
|
t.Fatalf("the condition is refused: %v", err)
|
|
}
|
|
if c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent || c.Subject.ID != "i3status-rust" || c.Subject.Machine != "g14" ||
|
|
!strings.Contains(c.Explanation, "x11-display") || !strings.Contains(c.Explanation, "rule 5") || c.Key != "mesh-delivery-assigner.neither.g14.i3status-rust" {
|
|
t.Errorf("condition = %+v", c)
|
|
}
|
|
if got := NeitherCondition("g14", "i3status-rust", Holding{}, false); got.State != asks.StateCleared || got.Key != c.Key {
|
|
t.Errorf("cleared = %+v", got)
|
|
}
|
|
}
|