398 lines
18 KiB
Go
398 lines
18 KiB
Go
package assign
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
func fpOf(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
|
|
|
var (
|
|
fNew = fpOf('b')
|
|
fOld = fpOf('a')
|
|
)
|
|
|
|
// ready is a module mail at fNew on ace and g14 for which every rule holds: built, not held, granted, its provider
|
|
// store healthy on ace and the canary ace running fNew healthy.
|
|
func ready() (Module, Nodes) {
|
|
m := Module{Name: "mail", FP: fNew, Built: true, Granted: true, Composed: composedEverywhere, Nodes: []string{"g14", "ace"},
|
|
Manifest: catalogue.Manifest{Module: "mail", Requires: []string{"database"}}}
|
|
n := Nodes{
|
|
Online: map[string]bool{"ace": true, "g14": true},
|
|
Haves: map[string]map[string]delivery.Have{
|
|
"ace": {"mail": {FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, "store": {FP: fpOf('s'), Phase: delivery.PhaseRunning, Health: delivery.HealthOK}},
|
|
"g14": {"mail": {FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}},
|
|
},
|
|
Providers: map[string]map[string][]string{"database": {"store": {"ace"}}},
|
|
}
|
|
return m, n
|
|
}
|
|
|
|
// dig is the digest of the composition at a fingerprint in these tests: the fingerprint's last character repeated.
|
|
func dig(fp string) string { return "sha256:" + strings.Repeat(fp[len(fp)-1:], 64) }
|
|
|
|
// composedEverywhere is a Module.Composed with a composition for every node at every fingerprint, judged neither.
|
|
func composedEverywhere(_, fp string) (delivery.ComposedDigest, bool) {
|
|
return delivery.ComposedDigest{Digest: dig(fp), Judgement: delivery.JudgedNeither}, true
|
|
}
|
|
|
|
func wantedOld() map[string]delivery.Want {
|
|
return map[string]delivery.Want{"ace": {FP: fOld, Composed: dig(fOld)}, "g14": {FP: fOld, Composed: dig(fOld)}}
|
|
}
|
|
|
|
// 1297/B4: with every rule holding, both nodes' wants move to the new fingerprint (the canary ace runs it healthy, so
|
|
// g14 follows at once: step 0, ADR 0528 clause 3).
|
|
func TestEveryRuleHoldsSoTheWantsMove(t *testing.T) {
|
|
m, n := ready()
|
|
out := Wants(m, n, wantedOld(), nil)
|
|
for _, node := range []string{"ace", "g14"} {
|
|
if out.Wants[node].FP != fNew || out.Wants[node].Rule != 0 || out.Wants[node].Composed != dig(fNew) {
|
|
t.Errorf("%s's want = %+v, want %s", node, out.Wants[node], fNew)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1297/B4, one test per rule: given every rule but N holds, no want moves to F, and the rule N is named.
|
|
func TestEachRuleHoldsTheWantBack(t *testing.T) {
|
|
for rule, breakIt := range map[int]func(*Module, *Nodes){
|
|
delivery.RuleBuildDone: func(m *Module, _ *Nodes) { m.Built = false },
|
|
delivery.RuleNotHeld: func(m *Module, _ *Nodes) { m.Held = true },
|
|
delivery.RuleCanary: func(_ *Module, n *Nodes) {
|
|
n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}
|
|
},
|
|
delivery.RuleProviders: func(_ *Module, n *Nodes) {
|
|
n.Haves["ace"]["store"] = delivery.Have{FP: fpOf('s'), Phase: delivery.PhaseRunning, Health: delivery.HealthWaits}
|
|
},
|
|
delivery.RuleGrant: func(m *Module, _ *Nodes) { m.Granted = false },
|
|
delivery.RuleComposed: func(m *Module, _ *Nodes) {
|
|
m.Composed = func(string, string) (delivery.ComposedDigest, bool) { return delivery.ComposedDigest{}, false }
|
|
},
|
|
} {
|
|
m, n := ready()
|
|
breakIt(&m, &n)
|
|
out := Wants(m, n, wantedOld(), nil)
|
|
if w, ok := out.Wants["g14"]; ok && w.FP == fNew {
|
|
t.Errorf("rule %d broken: g14's want moved to F", rule)
|
|
}
|
|
if got := out.Rules["g14"].Rule; got != rule {
|
|
t.Errorf("rule %d broken: g14 is held back by rule %d (%s)", rule, got, out.Rules["g14"].Words)
|
|
}
|
|
if w := out.Wants["g14"]; w.FP != "" && (w.FP != fOld || w.Newest != fNew || w.Rule != rule) {
|
|
t.Errorf("rule %d broken: g14's want is %+v", rule, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1297/B4, ADR 0435 §3: a canary that is offline is passed over: the next node by name is the canary, and moves first.
|
|
func TestAnOfflineCanaryIsPassedOver(t *testing.T) {
|
|
m, n := ready()
|
|
n.Online["ace"] = false
|
|
n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}
|
|
n.Providers["database"]["store"] = []string{"ace", "g14"}
|
|
n.Haves["g14"]["store"] = delivery.Have{FP: fpOf('s'), Phase: delivery.PhaseRunning, Health: delivery.HealthOK}
|
|
if c := Canary(m.Nodes, n.Online); c != "g14" {
|
|
t.Fatalf("canary = %q, want g14", c)
|
|
}
|
|
out := Wants(m, n, wantedOld(), nil)
|
|
if out.Wants["g14"].FP != fNew {
|
|
t.Errorf("the canary g14's want = %+v", out.Wants["g14"])
|
|
}
|
|
if out.Rules["ace"].Rule != delivery.RuleCanary {
|
|
t.Errorf("ace, behind the canary g14, is held back by %+v", out.Rules["ace"])
|
|
}
|
|
}
|
|
|
|
// 1297/B4: a failed health check of F holds F and sets the nodes that want it back to their last healthy fingerprint.
|
|
func TestAFailedHealthHoldsAndSetsBack(t *testing.T) {
|
|
m, n := ready()
|
|
n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"}
|
|
out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}, "g14": {FP: fOld, Composed: dig(fOld)}}, map[string]string{"ace": fOld})
|
|
if out.Held == nil || out.Held.Node != "ace" || out.Held.Words != "connection refused" {
|
|
t.Fatalf("held = %+v", out.Held)
|
|
}
|
|
if w := out.Wants["ace"]; w.FP != fOld || w.Composed != dig(fOld) || w.Newest != fNew || w.Rule != delivery.RuleNotHeld {
|
|
t.Errorf("ace's want = %+v, want set back to the last healthy fingerprint", w)
|
|
}
|
|
if w, ok := out.Wants["g14"]; ok && w.FP == fNew {
|
|
t.Errorf("g14 moved to a held fingerprint: %+v", w)
|
|
}
|
|
}
|
|
|
|
// 1297/B4: a module holding data is not set back; its want stays and says why.
|
|
func TestADataModuleIsNotSetBack(t *testing.T) {
|
|
m, n := ready()
|
|
m.Manifest.Data = &catalogue.Data{Own: []catalogue.DataItem{{ID: "mail", Class: catalogue.ClassIrreplaceable}}}
|
|
n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed}
|
|
out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fNew}}, map[string]string{"ace": fOld})
|
|
if out.Held == nil {
|
|
t.Fatal("F was not held")
|
|
}
|
|
if w := out.Wants["ace"]; w.FP != fNew || !strings.Contains(w.Words, "holds data") {
|
|
t.Errorf("ace's want = %+v, want it left at F, saying it holds data", w)
|
|
}
|
|
// 1906/D3: and the node is to be asked whether to restore it
|
|
if !slices.Equal(out.Restore, []string{"ace"}) {
|
|
t.Errorf("restore asks = %v, want ace", out.Restore)
|
|
}
|
|
}
|
|
|
|
// 1906/D3: a module without data is set back and asked about nothing.
|
|
func TestAModuleWithoutDataIsNotAskedToRestore(t *testing.T) {
|
|
m, n := ready()
|
|
n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed}
|
|
out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fNew}}, map[string]string{"ace": fOld})
|
|
if len(out.Restore) != 0 {
|
|
t.Errorf("restore asks = %v", out.Restore)
|
|
}
|
|
}
|
|
|
|
// 1297/B4, ADR 0408 §4: a node that leaves the module's nodes gets its want written removed, never deleted; a want that
|
|
// is already as the rules give it is not written again.
|
|
func TestALeavingNodeIsRemovedAndNothingIsWrittenTwice(t *testing.T) {
|
|
m, n := ready()
|
|
m.Nodes = []string{"ace"}
|
|
out := Wants(m, n, map[string]delivery.Want{"ace": {V: 1, FP: fNew, Composed: dig(fNew)}, "g14": {FP: fOld, Composed: dig(fOld)}}, nil)
|
|
if w := out.Wants["g14"]; !w.Removed {
|
|
t.Errorf("g14's want = %+v, want removed", w)
|
|
}
|
|
if _, ok := out.Wants["ace"]; ok {
|
|
t.Errorf("ace's want, unchanged, was written again: %+v", out.Wants["ace"])
|
|
}
|
|
}
|
|
|
|
// 1906/D2 (ADR 0539 §6): the seventh rule. A want waits for the composition of the module for that node at F, and names its
|
|
// digest once it exists; a node with no want before still gets none until then.
|
|
func TestAWantWaitsForItsCompositionAndNamesItsDigest(t *testing.T) {
|
|
m, n := ready()
|
|
have := map[string]bool{}
|
|
m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) {
|
|
if !have[node+"|"+fp] {
|
|
return delivery.ComposedDigest{}, false
|
|
}
|
|
return composedEverywhere(node, fp)
|
|
}
|
|
out := Wants(m, n, wantedOld(), nil)
|
|
for node, w := range out.Wants {
|
|
if w.FP == fNew {
|
|
t.Errorf("%s's want moved to F before its composition exists: %+v", node, w)
|
|
}
|
|
}
|
|
if r := out.Rules["ace"]; r.Rule != delivery.RuleComposed || !strings.Contains(r.Words, "ace") {
|
|
t.Errorf("ace is held back by %+v, want rule 7 naming the node", r)
|
|
}
|
|
// and a node that never had a want gets none
|
|
if out := Wants(m, n, nil, nil); len(out.Wants) != 0 {
|
|
t.Errorf("wants without a composition: %+v", out.Wants)
|
|
}
|
|
have["ace|"+fNew] = true
|
|
out = Wants(m, n, wantedOld(), nil)
|
|
if w := out.Wants["ace"]; w.FP != fNew || w.Composed != dig(fNew) {
|
|
t.Errorf("ace's want = %+v, want F naming the composition's digest", w)
|
|
}
|
|
if w, ok := out.Wants["g14"]; ok && w.FP == fNew {
|
|
t.Errorf("g14, whose composition does not exist, moved: %+v", w)
|
|
}
|
|
}
|
|
|
|
// 1906/D2 (ADR 0539 §5): a new revision of the composition at the same fingerprint reaches the node only through a new want,
|
|
// by the same rules: written when they hold, and not while one does not.
|
|
func TestARevisionAtTheSameFingerprintIsAWantByTheSameRules(t *testing.T) {
|
|
m, n := ready()
|
|
revised := "sha256:" + strings.Repeat("9", 64)
|
|
m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) {
|
|
return delivery.ComposedDigest{Digest: revised, Judgement: delivery.JudgedNeither}, true
|
|
}
|
|
running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}, "g14": {FP: fNew, Composed: dig(fNew)}}
|
|
out := Wants(m, n, running, nil)
|
|
for _, node := range []string{"ace", "g14"} {
|
|
if w := out.Wants[node]; w.FP != fNew || w.Composed != revised {
|
|
t.Errorf("%s's want = %+v, want F naming the revised digest", node, w)
|
|
}
|
|
}
|
|
m.Granted = false
|
|
out = Wants(m, n, running, nil)
|
|
if w, ok := out.Wants["ace"]; ok && w.Composed == revised {
|
|
t.Errorf("a revision was written while a rule holds: %+v", w)
|
|
}
|
|
}
|
|
|
|
// 1906/D2: a set-back names the composition of the fingerprint it sets back to, and when that is gone the want stays and says so.
|
|
func TestASetBackNamesTheOlderCompositionOrStays(t *testing.T) {
|
|
m, n := ready()
|
|
n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "x"}
|
|
running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}}
|
|
m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) {
|
|
if fp == fOld {
|
|
return delivery.ComposedDigest{}, false
|
|
}
|
|
return composedEverywhere(node, fp)
|
|
}
|
|
out := Wants(m, n, running, map[string]string{"ace": fOld})
|
|
if w := out.Wants["ace"]; w.FP == fOld {
|
|
t.Errorf("set back to a fingerprint whose composition is gone: %+v", w)
|
|
}
|
|
m.Composed = composedEverywhere
|
|
out = Wants(m, n, running, map[string]string{"ace": fOld})
|
|
if w := out.Wants["ace"]; w.FP != fOld || w.Composed != dig(fOld) {
|
|
t.Errorf("set-back want = %+v", w)
|
|
}
|
|
}
|
|
|
|
// movingTo is a module ready to move, whose composition at F is judged j against the one it runs, and whose digest is dig(F).
|
|
func movingTo(j delivery.Judgement) (Module, Nodes, map[string]delivery.Want) {
|
|
m, n := ready()
|
|
m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) {
|
|
d := delivery.ComposedDigest{Digest: dig(fp), Judgement: j}
|
|
if j != delivery.JudgedNeither {
|
|
d.Against, d.Words = dig(fOld), "judged"
|
|
}
|
|
return d, true
|
|
}
|
|
return m, n, wantedOld()
|
|
}
|
|
|
|
func recording(m *Module) {
|
|
m.Manifest.Upgrade = &catalogue.UpgradePolicy{Policy: catalogue.PolicyRecord, Why: "a person takes each build"}
|
|
}
|
|
|
|
// 1906/D4 (ADR 0539 §14): a module of policy record whose move is judged to recreate a container, to move data, or cannot be
|
|
// judged, is not written until its ask is answered yes; the same at a new revision of the same fingerprint.
|
|
func TestARecordModuleWaitsForItsAskWhenTheMoveIsNotJudgedNeither(t *testing.T) {
|
|
for _, j := range []delivery.Judgement{delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot} {
|
|
m, n, wanted := movingTo(j)
|
|
recording(&m)
|
|
out := Wants(m, n, wanted, nil)
|
|
if w, ok := out.Wants["ace"]; ok {
|
|
t.Errorf("%s: ace's want was written without the ask's yes: %+v", j, w)
|
|
}
|
|
var ace *RecordNeed
|
|
for i := range out.Record {
|
|
if out.Record[i].Node == "ace" {
|
|
ace = &out.Record[i]
|
|
}
|
|
}
|
|
if ace == nil || ace.Digest != dig(fNew) || ace.Judgement != j {
|
|
t.Errorf("%s: the asks = %+v, want one for ace naming the digest and the judgement", j, out.Record)
|
|
}
|
|
// a new revision at the same fingerprint is asked too
|
|
same := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fOld)}}
|
|
if out := Wants(m, n, same, nil); len(out.Record) == 0 || len(out.Wants) != 0 && out.Wants["ace"].Composed == dig(fNew) {
|
|
t.Errorf("%s: a new revision at the same fingerprint: %+v", j, out)
|
|
}
|
|
m.Allowed = func(node, digest string) bool { return node == "ace" && digest == dig(fNew) }
|
|
out = Wants(m, n, wanted, nil)
|
|
if w := out.Wants["ace"]; w.FP != fNew || w.Composed != dig(fNew) {
|
|
t.Errorf("%s: after the yes ace's want = %+v", j, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1906/D4: a move judged neither is written at once, whatever the policy; so is a module the node never ran; and a module
|
|
// that rolls (not record) is never asked.
|
|
func TestAMoveJudgedNeitherOrNeverRunOrNotRecordedIsWrittenAtOnce(t *testing.T) {
|
|
m, n, wanted := movingTo(delivery.JudgedNeither)
|
|
recording(&m)
|
|
if out := Wants(m, n, wanted, nil); out.Wants["ace"].FP != fNew || len(out.Record) != 0 {
|
|
t.Errorf("neither: %+v", out)
|
|
}
|
|
m, n, _ = movingTo(delivery.JudgedRecreates)
|
|
recording(&m)
|
|
if out := Wants(m, n, nil, nil); out.Wants["ace"].FP != fNew || len(out.Record) != 0 {
|
|
t.Errorf("a node that never ran it: %+v", out)
|
|
}
|
|
m, n, wanted = movingTo(delivery.JudgedRecreates)
|
|
if out := Wants(m, n, wanted, nil); out.Wants["ace"].FP != fNew || len(out.Record) != 0 {
|
|
t.Errorf("a rolled module: %+v", out)
|
|
}
|
|
}
|
|
|
|
// 1906/D4 (ADR 0539 §7, §15): the bus module's new fingerprint is never written by the assigner, but a revision of its
|
|
// composition at the same fingerprint that only changes the user list (judged neither) is written at once.
|
|
func TestTheBusIsNotMovedButItsUserListRevisionIsWrittenAtOnce(t *testing.T) {
|
|
m, n, _ := movingTo(delivery.JudgedNeither)
|
|
m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}}
|
|
if out := Wants(m, n, wantedOld(), nil); out.Wants["ace"].FP == fNew {
|
|
t.Errorf("the bus moved to a new fingerprint: %+v", out.Wants["ace"])
|
|
}
|
|
running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fOld)}, "g14": {FP: fNew, Composed: dig(fOld)}}
|
|
out := Wants(m, n, running, nil)
|
|
if w := out.Wants["ace"]; w.FP != fNew || w.Composed != dig(fNew) {
|
|
t.Errorf("a user-list revision of the bus: ace's want = %+v", w)
|
|
}
|
|
if len(out.Record) != 0 {
|
|
t.Errorf("a user-list revision was asked: %+v", out.Record)
|
|
}
|
|
}
|
|
|
|
// 1906/D4 (ADR 0539 §16): the answer to a failed backup stays in the want while its target stays.
|
|
func TestTheBackupAnswerStaysInTheWantWhileItsTargetStays(t *testing.T) {
|
|
m, n := ready()
|
|
running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew), Backup: delivery.BackupSkip}}
|
|
if w, ok := Wants(m, n, running, nil).Wants["ace"]; ok && w.Backup == "" {
|
|
t.Errorf("the answer was erased: %+v", w)
|
|
}
|
|
}
|
|
|
|
// 1906/D4 (ADR 0539 §19): a hold released by the operator is not held again by a health check that failed before, and the
|
|
// wants move by the rules again.
|
|
func TestAReleasedHoldIsNotHeldAgain(t *testing.T) {
|
|
m, n := ready()
|
|
n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "x"}
|
|
m.Released = true
|
|
out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fOld, Composed: dig(fOld), Newest: fNew, Rule: delivery.RuleNotHeld}}, map[string]string{"ace": fOld})
|
|
if out.Held != nil {
|
|
t.Errorf("a released fingerprint was held again: %+v", out.Held)
|
|
}
|
|
if w := out.Wants["ace"]; w.FP != fNew || w.Composed != dig(fNew) || w.Rule != 0 {
|
|
t.Errorf("ace's want = %+v, want the held fingerprint written again", w)
|
|
}
|
|
}
|
|
|
|
// 1906/D4: a data module whose restore ask the operator answered "restore" is set back like any other, losing the newer writes;
|
|
// answered "leave stopped" (or not answered) it stays.
|
|
func TestARestoreAnswerSetsADataModuleBack(t *testing.T) {
|
|
m, n := ready()
|
|
m.Manifest.Data = &catalogue.Data{Own: []catalogue.DataItem{{ID: "mail", Class: catalogue.ClassIrreplaceable}}}
|
|
n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed}
|
|
running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}}
|
|
m.Restored = func(node string) bool { return false }
|
|
if w, ok := Wants(m, n, running, map[string]string{"ace": fOld}).Wants["ace"]; ok && w.FP == fOld {
|
|
t.Errorf("set back without the operator's restore: %+v", w)
|
|
}
|
|
m.Restored = func(node string) bool { return node == "ace" }
|
|
if w := Wants(m, n, running, map[string]string{"ace": fOld}).Wants["ace"]; w.FP != fOld || w.Composed != dig(fOld) {
|
|
t.Errorf("after restore ace's want = %+v", w)
|
|
}
|
|
}
|
|
|
|
// 1906/D4 (ADR 0539 §14): the policy the controller holds beside the composition decides, the operator's own choice included;
|
|
// a composition with none (made by hand) falls back to what the manifest derives.
|
|
func TestTheRecordPolicyIsTheOneTheControllerHolds(t *testing.T) {
|
|
m, n, wanted := movingTo(delivery.JudgedMovesData)
|
|
with := func(policy, from string) {
|
|
inner := m.Composed
|
|
m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) {
|
|
d, ok := inner(node, fp)
|
|
d.Policy, d.PolicyFrom = policy, from
|
|
return d, ok
|
|
}
|
|
}
|
|
with(delivery.PolicyRecord, "person") // a roll module the operator set to record
|
|
if out := Wants(m, n, wanted, nil); len(out.Record) == 0 || out.Wants["ace"].FP == fNew {
|
|
t.Errorf("the operator's record policy was not honoured: %+v", out)
|
|
}
|
|
recording(&m) // the manifest says record, the operator chose roll
|
|
with("roll", "person")
|
|
if out := Wants(m, n, wanted, nil); len(out.Record) != 0 || out.Wants["ace"].FP != fNew {
|
|
t.Errorf("the operator's roll policy was not honoured: %+v", out)
|
|
}
|
|
with("", "") // made by hand: the manifest decides
|
|
if out := Wants(m, n, wanted, nil); len(out.Record) == 0 {
|
|
t.Errorf("a hand-made composition fell through the manifest's record policy: %+v", out)
|
|
}
|
|
}
|