package main import ( "context" "encoding/json" "strings" "testing" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-controller/internal/link" ) // mailOn is a composition of mesh-mail on ace at fingerprint c, from the given resources, each saying its module. func mailOn(t *testing.T, c byte, data bool, resources ...string) sdkdelivery.Composed { t.Helper() out := sdkdelivery.Composed{Node: "ace", Module: "mesh-mail", FP: sdkdelivery.SourcePrefix2 + strings.Repeat(string(c), 64), Data: data} for _, r := range resources { out.Resources = append(out.Resources, json.RawMessage(r)) } return out } const ( mailContainer = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:1111111111111111111111111111111111111111111111111111111111111111","volumes":["/var/lib/mesh-mail/spool:/spool"]}` mailContainer2 = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:2222222222222222222222222222222222222222222222222222222222222222","volumes":["/var/lib/mesh-mail/spool:/spool"]}` mailSpool = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}` mailSpoolMoved = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/srv/mesh-mail/spool"}` mailTools = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"a"}` mailTools2 = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"b"}` mailConfig = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"a"}` mailConfig2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}` ) // 1945/AC12: captured pairs of compositions are judged by ADR 0377 ยง3's tests: a changed container recreates; with no // container changed, a directory moved, a file within the data a container mounts changed, or the data declaration // changed moves data; a tools file nothing mounts changed is neither. func TestAMoveIsJudgedByWhatItRecreatesOrMoves(t *testing.T) { cases := []struct { name string from, to sdkdelivery.Composed want sdkdelivery.Judgement }{ {"a new image", mailOn(t, 'a', true, mailContainer, mailSpool, mailTools), mailOn(t, 'b', true, mailContainer2, mailSpool, mailTools), sdkdelivery.JudgedRecreates}, {"a container gone", mailOn(t, 'a', true, mailContainer, mailSpool), mailOn(t, 'b', true, mailSpool), sdkdelivery.JudgedRecreates}, {"its spool moved", mailOn(t, 'a', true, mailContainer, mailSpool), mailOn(t, 'b', true, mailContainer, mailSpoolMoved), sdkdelivery.JudgedMovesData}, {"a file in its spool", mailOn(t, 'a', true, mailContainer, mailSpool, mailConfig), mailOn(t, 'b', true, mailContainer, mailSpool, mailConfig2), sdkdelivery.JudgedMovesData}, {"its data declaration", mailOn(t, 'a', false, mailContainer, mailSpool), mailOn(t, 'b', true, mailContainer, mailSpool), sdkdelivery.JudgedMovesData}, {"its tools only", mailOn(t, 'a', true, mailContainer, mailSpool, mailTools), mailOn(t, 'b', true, mailContainer, mailSpool, mailTools2), sdkdelivery.JudgedNeither}, } for _, c := range cases { if got, words := judged(c.from, c.to); got != c.want || words == "" { t.Errorf("%s: judged %s (%q), want %s", c.name, got, words, c.want) } } } // 1945/AC12: a move is judged against the composition the node's have names: one the node never ran is neither; one // whose composition is gone cannot be judged and says why; and with A running, B held and C composed, C is judged // against A, and judged again when the have changes. func TestAMoveIsJudgedAgainstWhatTheNodeRuns(t *testing.T) { at, _, _, haves := aPlace(t, "ace") ctx := context.Background() a := mailOn(t, 'a', true, mailContainer, mailSpool, mailTools) b := mailOn(t, 'b', true, mailContainer2, mailSpool, mailTools) c := mailOn(t, 'c', true, mailContainer, mailSpool, mailTools2) write := func(comp sdkdelivery.Composed) (string, string) { key, _ := sdkdelivery.ComposedKey("ace", comp.Module, comp.FP) if _, err := writeComposed(ctx, at, key, comp); err != nil { t.Fatal(err) } d, _ := sdkdelivery.ComposedDigestOf(comp) return key, d } judgedNow := func(key string) sdkdelivery.ComposedDigest { e, err := at.digests.Get(ctx, key) if err != nil { t.Fatal(err) } v, err := sdkdelivery.DecodeComposedDigest(e.Value()) if err != nil { t.Fatal(err) } return v } keyA, digestA := write(a) if v := judgedNow(keyA); v.Judgement != sdkdelivery.JudgedNeither || v.Against != "" { t.Errorf("a module the node never ran: %+v", v) } if v := judgedNow(keyA); v.Policy != "roll" || v.PolicyFrom != "default" { t.Errorf("the policy beside the judgement: %+v", v) } haves["mesh-mail"] = sdkdelivery.Have{FP: a.FP, Composed: digestA, Phase: sdkdelivery.PhaseRunning, Health: sdkdelivery.HealthOK} keyB, _ := write(b) if v := judgedNow(keyB); v.Judgement != sdkdelivery.JudgedRecreates || v.Against != digestA { t.Errorf("B against A: %+v", v) } // B is held (the assigner writes no want for it); the node still runs A. keyC, digestC := write(c) if v := judgedNow(keyC); v.Judgement != sdkdelivery.JudgedNeither || v.Against != digestA { t.Errorf("C against A, B held: %+v", v) } // The have changes to C: every judgement of the module is written again, against what runs now (the have watch). haves["mesh-mail"] = sdkdelivery.Have{FP: c.FP, Composed: digestC, Phase: sdkdelivery.PhaseRunning, Health: sdkdelivery.HealthOK} if n, err := rejudge(ctx, at, "mesh-mail"); err != nil || n == 0 { t.Fatalf("rejudged %d (%v)", n, err) } if v := judgedNow(keyC); v.Against != digestC || v.Judgement != sdkdelivery.JudgedNeither { t.Errorf("C once the node runs it: %+v", v) } if v := judgedNow(keyB); v.Against != digestC || v.Judgement != sdkdelivery.JudgedRecreates { t.Errorf("B once the node runs C: %+v", v) } // A person sets the module's policy: written beside every composition of it. at.policy = func(string) (string, string, string) { return "record", "person", "its spool" } if _, err := rejudge(ctx, at, "mesh-mail"); err != nil { t.Fatal(err) } if v := judgedNow(keyB); v.Policy != "record" || v.PolicyFrom != "person" || v.PolicyWhy != "its spool" { t.Errorf("the person's policy beside B: %+v", v) } // A have naming a composition the buckets no longer hold. haves["mesh-mail"] = sdkdelivery.Have{FP: sdkdelivery.SourcePrefix2 + strings.Repeat("9", 64), Composed: "sha256:" + strings.Repeat("9", 64), Phase: sdkdelivery.PhaseRunning, Health: sdkdelivery.HealthOK} write(b) if v := judgedNow(keyB); v.Judgement != sdkdelivery.JudgedCannot || !strings.Contains(v.Words, "gone") { t.Errorf("against a composition that is gone: %+v", v) } } const ( busServer = `{"id":"nats.server","type":"container","module":"nats","name":"nats","image":"nats@sha256:4444444444444444444444444444444444444444444444444444444444444444"}` busServer2 = `{"id":"nats.server","type":"container","module":"nats","name":"nats","image":"nats@sha256:5555555555555555555555555555555555555555555555555555555555555555"}` busUsers = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users.conf","content":"users: [a]"}` busUsers2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users.conf","content":"users: [a, b]"}` ) // busOn is a composition of the bus on ace from the given resources. func busOn(t *testing.T, resources ...string) sdkdelivery.Composed { t.Helper() out := sdkdelivery.Composed{Node: "ace", Module: "nats", FP: sdkdelivery.SourcePrefix2 + strings.Repeat("b", 64)} for _, r := range resources { out.Resources = append(out.Resources, json.RawMessage(r)) } return out } // ADR 0540 clause 9: a have that names no composition, only a fingerprint (the switch: the node runs the declaration it // last applied), is judged against the module's slice of that declaration, read from the kept stream of declarations: // an equal slice is unchanged; a slice differing only in the bus's user list is that (round 1's A2, so no yes waits on it at the switch); a changed setting is judged as any move; no declaration to read cannot be judged. func TestAHaveWithNoCompositionIsJudgedAgainstTheLastDeclaration(t *testing.T) { ctx := context.Background() other := `{"id":"mesh-calendar.server","type":"container","module":"mesh-calendar","name":"calendar","image":"cal@sha256:3333333333333333333333333333333333333333333333333333333333333333"}` unsaid := `{"id":"mesh-mail.tools","type":"file","path":"/usr/lib/mesh-mail/tools.json","content":"a"}` declared := func(resources ...string) []byte { body, err := json.Marshal(map[string]any{"node": "ace", "resources": json.RawMessage("[" + strings.Join(resources, ",") + "]")}) if err != nil { t.Fatal(err) } signed, err := json.Marshal(link.Signed{Declaration: body, Signature: []byte("s")}) // as link.Envelope sent it if err != nil { t.Fatal(err) } return signed } cases := []struct { name string last []byte // the node's last declaration on the stream; nil when there is none to sdkdelivery.Composed want sdkdelivery.Judgement inWords string userList bool }{ {"an equal slice", declared(mailContainer, other, mailSpool, mailTools), mailOn(t, 'b', true, mailContainer, mailSpool, mailTools), sdkdelivery.JudgedUnchanged, "last applied", false}, {"a declared resource saying no module is not equal", declared(mailContainer, other, mailSpool, unsaid), mailOn(t, 'b', true, mailContainer, mailSpool, mailTools), sdkdelivery.JudgedNeither, "last applied", false}, {"a changed image", declared(mailContainer, other, mailSpool, mailTools), mailOn(t, 'b', true, mailContainer2, mailSpool, mailTools), sdkdelivery.JudgedRecreates, "last applied", false}, {"a changed setting in its spool", declared(mailContainer, mailSpool, mailConfig), mailOn(t, 'b', true, mailContainer, mailSpool, mailConfig2), sdkdelivery.JudgedMovesData, "last applied", false}, {"a changed tools file", declared(mailContainer, mailSpool, mailTools), mailOn(t, 'b', true, mailContainer, mailSpool, mailTools2), sdkdelivery.JudgedNeither, "last applied", false}, {"only the bus's user list", declared(busServer, busUsers, other), busOn(t, busServer, busUsers2), sdkdelivery.JudgedNeither, "only the bus's user list", true}, {"the bus's user list and its container", declared(busServer, busUsers), busOn(t, busServer2, busUsers2), sdkdelivery.JudgedRecreates, "last applied", false}, {"no declaration kept", nil, mailOn(t, 'b', true, mailContainer, mailSpool, mailTools), sdkdelivery.JudgedCannot, "names no composition", false}, } for _, c := range cases { at, _, _, haves := aPlace(t, "ace") last := c.last at.lastApplied = func(_ context.Context, module string) ([]json.RawMessage, string, bool, error) { if last == nil { return nil, "", false, nil } return sliceOfDeclaration(last, module) } haves[c.to.Module] = sdkdelivery.Have{FP: sdkdelivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: sdkdelivery.PhaseRunning, Health: sdkdelivery.HealthOK} key, _ := sdkdelivery.ComposedKey("ace", c.to.Module, c.to.FP) digest, err := sdkdelivery.ComposedDigestOf(c.to) if err != nil { t.Fatalf("%s: %v", c.name, err) } got, err := judgeMove(ctx, at, key, c.to, digest) if err != nil { t.Fatalf("%s: %v", c.name, err) } if got.UserListOnly != c.userList { t.Errorf("%s: user list only %v, want %v", c.name, got.UserListOnly, c.userList) } if got.Judgement != c.want || !strings.Contains(got.Words, c.inWords) { t.Errorf("%s: judged %s (%q), want %s", c.name, got.Judgement, got.Words, c.want) } if _, err := sdkdelivery.EncodeComposedDigest(got); err != nil { t.Errorf("%s: the judgement written is refused: %v", c.name, err) } } }