mesh/delivery-group group fix/r6-node-composition held: a member is held: novox/mesh-sdk@10b6e202320f (merged without a passing check: only a person decides wh…
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/check checking 48aaecf556c5 on 650a06ceab1d | novox/mesh-delivery#39 in group fix/r6-node-composition
383 lines
16 KiB
Go
383 lines
16 KiB
Go
package view
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
const (
|
|
fpOld = "src2:1111111111111111111111111111111111111111111111111111111111111111"
|
|
fpNew = "src2:2222222222222222222222222222222222222222222222222222222222222222"
|
|
)
|
|
|
|
func wantE(t *testing.T, node, module string, w delivery.Want) Entry {
|
|
t.Helper()
|
|
key, err := delivery.WantKey(node, module)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !w.Removed {
|
|
w.Composed = "sha256:" + strings.Repeat("c", 64)
|
|
}
|
|
v, err := delivery.EncodeWant(w)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Entry{Bucket: delivery.AssignerWantsBucket, Key: key, Value: v, At: t0}
|
|
}
|
|
|
|
func haveFP(t *testing.T, node, module, fp string, health delivery.Health) Entry {
|
|
t.Helper()
|
|
b, err := delivery.EncodeHave(delivery.Have{FP: fp, Phase: delivery.PhaseRunning, Health: health})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, err := delivery.HaveKey(node, module)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Entry{Bucket: delivery.HaveBucket, Key: key, Value: b, At: t0}
|
|
}
|
|
|
|
func moduleE(t *testing.T, module, fp string) Entry {
|
|
t.Helper()
|
|
v, err := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: strings.Repeat("a", 64), Repository: "novox/" + module, Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Entry{Bucket: delivery.ModulesBucket, Key: module, Value: v, At: t0}
|
|
}
|
|
|
|
func sourceE(t *testing.T, revision uint64) Entry {
|
|
t.Helper()
|
|
v, err := delivery.EncodeSource(delivery.Source{Group: "g", Heads: map[string]string{"novox/web": strings.Repeat("1", 40)}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Entry{Bucket: delivery.SourceBucket, Key: delivery.SourceMainKey, Value: v, At: t0, Revision: revision}
|
|
}
|
|
|
|
// 1906/D7 AC7: every want is a row beside the have of its node, with where it stands.
|
|
func TestEveryWantIsARowAgainstItsHave(t *testing.T) {
|
|
m := New()
|
|
fill(m,
|
|
wantE(t, "ace", "web", delivery.Want{FP: fpNew}), haveFP(t, "ace", "web", fpNew, delivery.HealthOK),
|
|
wantE(t, "ace", "mail", delivery.Want{FP: fpNew}), haveFP(t, "ace", "mail", fpOld, delivery.HealthOK),
|
|
wantE(t, "g14", "web", delivery.Want{FP: fpNew}), haveFP(t, "g14", "web", fpNew, delivery.HealthFailed),
|
|
wantE(t, "g14", "mail", delivery.Want{FP: fpOld, Newest: fpNew, Rule: delivery.RuleCanary, Words: "the canary ace does not run it healthy yet"}),
|
|
wantE(t, "g14", "old", delivery.Want{Removed: true}),
|
|
wantE(t, "novox", "web", delivery.Want{FP: fpNew}),
|
|
)
|
|
got := map[string]WantRow{}
|
|
for _, r := range m.Snapshot().Wants {
|
|
got[r.Node+"."+r.Module] = r
|
|
}
|
|
for key, state := range map[string]string{
|
|
"ace.web": WantApplied, "ace.mail": WantPending, "g14.web": WantFailed, "g14.mail": WantHeldBack, "g14.old": WantRemoved, "novox.web": WantPending,
|
|
} {
|
|
if got[key].State != state {
|
|
t.Errorf("%s = %+v, want state %s", key, got[key], state)
|
|
}
|
|
}
|
|
if r := got["g14.mail"]; r.Rule != delivery.RuleCanary || r.Newest != fpNew || r.Words == "" {
|
|
t.Errorf("the held-back row keeps its rule and words: %+v", r)
|
|
}
|
|
if r := got["ace.mail"]; r.Want != fpNew || r.Have != fpOld {
|
|
t.Errorf("a pending row shows both: %+v", r)
|
|
}
|
|
// a want taken away leaves the list
|
|
fill(m, Entry{Bucket: delivery.AssignerWantsBucket, Key: "want.novox.web", Deleted: true})
|
|
if n := len(m.Snapshot().Wants); n != 5 {
|
|
t.Errorf("wants = %d after a delete, want 5", n)
|
|
}
|
|
}
|
|
|
|
// 1906/D7 AC7: a source revision is delivered when every want equals its module's fingerprint and every have equals its
|
|
// want, healthy; one failed have shows it not delivered and names the module.
|
|
func TestASourceRevisionIsDerivedFromWantsAndHaves(t *testing.T) {
|
|
m := New()
|
|
fill(m, sourceE(t, 14355), moduleE(t, "web", fpNew), moduleE(t, "mail", fpNew),
|
|
assign("ace", "web", "b-1", ""), assign("g14", "web", "b-1", ""),
|
|
wantE(t, "ace", "web", delivery.Want{FP: fpNew}), haveFP(t, "ace", "web", fpNew, delivery.HealthOK),
|
|
wantE(t, "g14", "web", delivery.Want{FP: fpNew}), haveFP(t, "g14", "web", fpNew, delivery.HealthOK),
|
|
wantE(t, "ace", "mail", delivery.Want{FP: fpNew}), haveFP(t, "ace", "mail", fpNew, delivery.HealthOK))
|
|
s := m.Snapshot().Source
|
|
if s == nil || s.Revision != 14355 || s.State != SourceDelivered || len(s.Waiting) != 0 {
|
|
t.Fatalf("source = %+v, want revision 14355 delivered", s)
|
|
}
|
|
fill(m, haveFP(t, "g14", "web", fpNew, delivery.HealthFailed))
|
|
s = m.Snapshot().Source
|
|
if s.State != SourceNotDelivered || len(s.Waiting) != 1 || !strings.Contains(s.Waiting[0], "web") || !strings.Contains(s.Waiting[0], "g14") {
|
|
t.Errorf("a failed have: %+v", s)
|
|
}
|
|
fill(m, haveFP(t, "g14", "web", fpNew, delivery.HealthOK), wantE(t, "ace", "mail", delivery.Want{FP: fpOld}))
|
|
s = m.Snapshot().Source
|
|
if s.State != SourceNotDelivered || !strings.Contains(strings.Join(s.Waiting, ";"), "mail") {
|
|
t.Errorf("a want that is not the module's fingerprint: %+v", s)
|
|
}
|
|
fill(m, wantE(t, "ace", "mail", delivery.Want{FP: fpNew}), assign("novox", "web", "b-1", ""))
|
|
s = m.Snapshot().Source
|
|
if s.State != SourceNotDelivered || !strings.Contains(strings.Join(s.Waiting, ";"), "novox") {
|
|
t.Errorf("a node assigned the module with no want: %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestNoSourceRevisionHeldMeansNoStateIsDerived(t *testing.T) {
|
|
if s := New().Snapshot().Source; s != nil {
|
|
t.Errorf("source = %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestTheWantsPageSaysWantsAgainstHaves(t *testing.T) {
|
|
m := New()
|
|
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}), haveFP(t, "ace", "web", fpOld, delivery.HealthOK))
|
|
text := wantText(m.Snapshot())
|
|
if len(text) != 1 || text[0][0] != "ace" || text[0][1] != "web" || text[0][2] != fpNew || text[0][3] != fpOld || text[0][4] != WantPending || text[0][5] != "none" {
|
|
t.Errorf("text = %v", text)
|
|
}
|
|
}
|
|
|
|
// 1906/D7 (ADR 0539 §14): the controller's judgement of the move a want names is shown beside it, with its words.
|
|
func TestTheJudgementOfTheMoveIsShownBesideTheWant(t *testing.T) {
|
|
m := New()
|
|
key, err := delivery.ComposedKey("ace", "web", fpNew)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("c", 64), Judgement: delivery.JudgedCannot, Words: "the composition it runs is gone"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}), Entry{Bucket: delivery.ControllerComposedBucket, Key: key, Value: v, At: t0})
|
|
rows := m.Snapshot().Wants
|
|
if len(rows) != 1 || rows[0].Judgement != string(delivery.JudgedCannot) || !strings.Contains(rows[0].Judged, "gone") {
|
|
t.Fatalf("rows = %+v", rows)
|
|
}
|
|
text := wantText(m.Snapshot())
|
|
if len(text[0]) != 7 || text[0][5] != "cannot-judge" {
|
|
t.Errorf("text = %v", text)
|
|
}
|
|
}
|
|
|
|
// 1906/D9 (asked by the lead for issue 1946): the view says, once for each source revision, that it is delivered: the event the
|
|
// issue tracker derives "fix delivered" from, in place of the gate's states that go in release 6 (ADR 0539 §17).
|
|
func TestTheViewSaysOnceThatASourceRevisionIsDelivered(t *testing.T) {
|
|
m := New()
|
|
var said []DeliveredEvent
|
|
m.SetDeliveredEvent(func(e DeliveredEvent) { said = append(said, e) })
|
|
fill(m, sourceE(t, 14355), moduleE(t, "web", fpNew), assign("ace", "web", "b-1", ""),
|
|
wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
|
|
if len(said) != 0 {
|
|
t.Fatalf("said %v before any have", said)
|
|
}
|
|
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthFailed))
|
|
if len(said) != 0 {
|
|
t.Fatalf("said %v for a failed have", said)
|
|
}
|
|
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
|
|
if len(said) != 1 || said[0].Revision != 14355 || said[0].Group != "g" || said[0].Heads["novox/web"] == "" {
|
|
t.Fatalf("said = %+v, want revision 14355 once with its group and heads", said)
|
|
}
|
|
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK), wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
|
|
if len(said) != 1 {
|
|
t.Errorf("said again for the same revision: %v", said)
|
|
}
|
|
// a newer revision is delivered when its wants and haves are; a revision that goes bad and good again is not said twice
|
|
fill(m, sourceE(t, 14356))
|
|
if len(said) != 2 || said[1].Revision != 14356 {
|
|
t.Errorf("the next revision: %v", said)
|
|
}
|
|
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthFailed), haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
|
|
if len(said) != 2 {
|
|
t.Errorf("said again after a have failed and recovered: %v", said)
|
|
}
|
|
}
|
|
|
|
// Review round 1 (view notes): the controller no longer writes the build of an assignment (C6), so a row reads the want and the
|
|
// have: the row shows the fingerprint wanted and waits while the node runs another.
|
|
func TestARowReadsTheWantWhenTheAssignmentNamesNoBuild(t *testing.T) {
|
|
m := New()
|
|
fill(m, assign("ace", "web", "", ""), wantE(t, "ace", "web", delivery.Want{FP: fpNew}), haveFP(t, "ace", "web", fpOld, delivery.HealthOK))
|
|
r := find(t, m.Snapshot(), "ace", "web")
|
|
if r.Assigned != fpNew || r.Have != fpOld || !strings.Contains(r.Waits, "has not applied") {
|
|
t.Fatalf("row = %+v, want it to wait for the want's fingerprint", r)
|
|
}
|
|
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
|
|
if r := find(t, m.Snapshot(), "ace", "web"); r.Waits != "" || r.Have != fpNew {
|
|
t.Errorf("row = %+v, want no wait once the have names the want's fingerprint", r)
|
|
}
|
|
}
|
|
|
|
// Review round 1 (view notes, issue 1150): a module whose name holds a dot has its have under the escaped key; the row finds it,
|
|
// and a have nothing is assigned to keeps the module's name.
|
|
func TestADottedModuleNameIsFoundAndKept(t *testing.T) {
|
|
m := New()
|
|
fill(m, assign("ace", "novox.be", "", ""), wantE(t, "ace", "novox.be", delivery.Want{FP: fpNew}), haveFP(t, "ace", "novox.be", fpNew, delivery.HealthOK),
|
|
haveFP(t, "ace", "old.name", fpNew, delivery.HealthOK))
|
|
s := m.Snapshot()
|
|
if r := find(t, s, "ace", "novox.be"); r.Have != fpNew || r.Waits != "" {
|
|
t.Errorf("the dotted module's row = %+v", r)
|
|
}
|
|
if r := find(t, s, "ace", "old.name"); r.Status != StatusLeftBehind {
|
|
t.Errorf("the left-behind dotted module = %+v", r)
|
|
}
|
|
if rows := s.Wants; len(rows) != 1 || rows[0].Module != "novox.be" || rows[0].State != WantApplied {
|
|
t.Errorf("wants = %+v", rows)
|
|
}
|
|
}
|
|
|
|
// 1906/D10 (asked by the lead for the messenger, ADR 0308): with no plans to say that a module is in motion, the view says it: `moving`
|
|
// when a module's want on a node differs from its have, `settled` when the have reaches the want running and healthy, `stuck` when it
|
|
// is held or failed or the watchman says it stopped moving; each once for a node, a module and a composition.
|
|
func motionLog(m *Model) *[]MotionEvent {
|
|
var said []MotionEvent
|
|
m.SetMotionEvent(func(e MotionEvent) { said = append(said, e) })
|
|
for _, b := range []string{delivery.AssignerWantsBucket, delivery.HaveBucket, delivery.StuckBucket} {
|
|
m.Initial(b)
|
|
}
|
|
return &said
|
|
}
|
|
|
|
func types(said []MotionEvent) string {
|
|
var out []string
|
|
for _, e := range said {
|
|
out = append(out, e.Type)
|
|
}
|
|
return strings.Join(out, ",")
|
|
}
|
|
|
|
func TestAModuleInMotionIsSaidMovingThenSettledOnceEach(t *testing.T) {
|
|
m := New()
|
|
said := motionLog(m)
|
|
fill(m, haveFP(t, "ace", "web", fpOld, delivery.HealthOK))
|
|
if len(*said) != 0 {
|
|
t.Fatalf("said %v for a have with no want", types(*said))
|
|
}
|
|
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
|
|
if types(*said) != "moving" {
|
|
t.Fatalf("said %q, want moving", types(*said))
|
|
}
|
|
mv := (*said)[0]
|
|
if mv.Body.Node != "ace" || mv.Body.Module != "web" || mv.Body.FromFP != fpOld || mv.Body.ToFP != fpNew || mv.Body.Composed != "sha256:"+strings.Repeat("c", 64) ||
|
|
mv.Body.Since.IsZero() || mv.Body.At.IsZero() || mv.Body.BoundSeconds != 1800 || mv.ID != "moving.ace.web."+strings.Repeat("c", 64) {
|
|
t.Errorf("moving = %+v", mv)
|
|
}
|
|
fill(m, haveFP(t, "ace", "web", fpOld, delivery.HealthOK), wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
|
|
if types(*said) != "moving" {
|
|
t.Errorf("said %q after the same state again", types(*said))
|
|
}
|
|
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
|
|
if types(*said) != "moving,settled" || (*said)[1].Body.Outcome != "running" {
|
|
t.Errorf("said %q, want settled with its outcome", types(*said))
|
|
}
|
|
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
|
|
if types(*said) != "moving,settled" {
|
|
t.Errorf("settled said twice: %q", types(*said))
|
|
}
|
|
}
|
|
|
|
func TestAModuleHeldOrFailedIsSaidStuckWithItsWords(t *testing.T) {
|
|
m := New()
|
|
said := motionLog(m)
|
|
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
|
|
b, _ := delivery.EncodeHave(delivery.Have{FP: fpOld, Phase: delivery.PhaseHeld, Health: delivery.HealthWaits, Detail: "no space left"})
|
|
fill(m, Entry{Bucket: delivery.HaveBucket, Key: "ace.web", Value: b, At: t0})
|
|
if types(*said) != "moving,stuck" || (*said)[1].Body.Outcome != "stopped" || (*said)[1].Body.Words != "no space left" {
|
|
t.Fatalf("said %v: %+v", types(*said), *said)
|
|
}
|
|
fill(m, Entry{Bucket: delivery.HaveBucket, Key: "ace.web", Value: b, At: t0})
|
|
if types(*said) != "moving,stuck" {
|
|
t.Errorf("stuck said twice: %q", types(*said))
|
|
}
|
|
// failed on the wanted fingerprint
|
|
m2 := New()
|
|
said2 := motionLog(m2)
|
|
fill(m2, wantE(t, "g14", "web", delivery.Want{FP: fpNew}), haveFP(t, "g14", "web", fpNew, delivery.HealthFailed))
|
|
if types(*said2) != "moving,stuck" || (*said2)[1].Body.Outcome != "failed" {
|
|
t.Errorf("failed: said %q", types(*said2))
|
|
}
|
|
}
|
|
|
|
func TestAWantTheWatchmanSaysStoppedMovingIsSaidStuckPastItsBound(t *testing.T) {
|
|
m := New()
|
|
said := motionLog(m)
|
|
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}), haveFP(t, "ace", "web", fpOld, delivery.HealthOK))
|
|
sk, _ := delivery.StuckKey(delivery.AssignerWantsBucket, "want.ace.web")
|
|
v, err := delivery.EncodeStuck(delivery.Stuck{Bucket: delivery.AssignerWantsBucket, Key: "want.ace.web", Waited: "the node agent of ace to run web", Since: t0})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fill(m, Entry{Bucket: delivery.StuckBucket, Key: sk, Value: v, At: t0})
|
|
if types(*said) != "moving,stuck" || (*said)[1].Body.Outcome != "stopped" {
|
|
t.Errorf("said %q: %+v", types(*said), *said)
|
|
}
|
|
}
|
|
|
|
func TestANodeThatSettlesWithoutBeingSeenMovingSaysNothing(t *testing.T) {
|
|
m := New()
|
|
said := motionLog(m)
|
|
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK), wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
|
|
if len(*said) != 0 {
|
|
t.Errorf("said %v for a module that never moved", types(*said))
|
|
}
|
|
}
|
|
|
|
// Nothing is said of a module in motion before the buckets have been read: a want read ahead of its have is not a node behind. What is
|
|
// in motion when they have been read is said then, and a module that settles after is said settled.
|
|
func TestMotionIsSaidOnlyOnceTheBucketsHaveBeenRead(t *testing.T) {
|
|
m := New()
|
|
var said []MotionEvent
|
|
m.SetMotionEvent(func(e MotionEvent) { said = append(said, e) })
|
|
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}), wantE(t, "ace", "mail", delivery.Want{FP: fpNew}))
|
|
m.Initial(delivery.AssignerWantsBucket)
|
|
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK), haveFP(t, "ace", "mail", fpOld, delivery.HealthOK))
|
|
m.Initial(delivery.HaveBucket)
|
|
if len(said) != 0 {
|
|
t.Fatalf("said %v before the stuck keys were read", types(said))
|
|
}
|
|
m.Initial(delivery.StuckBucket)
|
|
if len(said) != 1 || said[0].Type != MotionMoving || said[0].Body.Module != "mail" {
|
|
t.Fatalf("said %v: %+v, want only mail moving (web is already there)", types(said), said)
|
|
}
|
|
fill(m, haveFP(t, "ace", "mail", fpNew, delivery.HealthOK))
|
|
if types(said) != "moving,settled" {
|
|
t.Errorf("said %q", types(said))
|
|
}
|
|
}
|
|
|
|
// assigner_wants lists a node's own want (want.<node>.node, ADR 0539 clause 6) among the wants, and never as unreadable:
|
|
// on 2026-10-25 every node's own want was listed unreadable, though its value was what the assigner writes (issue 1945).
|
|
func TestTheWantsListANodesOwnWant(t *testing.T) {
|
|
own, err := delivery.WantNodeKey("ace")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
value, err := delivery.EncodeNodeWant(delivery.NodeWant{Composed: "sha256:" + strings.Repeat("d", 64)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
moduleKey, _ := delivery.WantKey("ace", "web")
|
|
moduleValue, _ := delivery.EncodeWant(delivery.Want{FP: fpNew, Composed: "sha256:" + strings.Repeat("c", 64)})
|
|
state := map[string][]byte{own: value, moduleKey: moduleValue}
|
|
r := Reader{WantsState: StateRead{
|
|
Keys: func() ([]string, error) { return []string{own, moduleKey}, nil },
|
|
Get: func(k string) (json.RawMessage, error) { return state[k], nil },
|
|
}}
|
|
got, err := r.Wants("", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Unreadable) != 0 || got.Wants[own] == nil || got.Wants[moduleKey] == nil {
|
|
t.Fatalf("the wants: %v, unreadable %v", got.Wants, got.Unreadable)
|
|
}
|
|
if got, _ := r.Wants("g14", ""); len(got.Wants) != 0 {
|
|
t.Errorf("another node's wants: %v", got.Wants)
|
|
}
|
|
if got, _ := r.Wants("ace", "web"); got.Wants[own] != nil {
|
|
t.Errorf("the node's own want was listed under the module web")
|
|
}
|
|
}
|