diff --git a/go/delivery/delivery_test.go b/go/delivery/delivery_test.go index f455eb3..9e6b68c 100644 --- a/go/delivery/delivery_test.go +++ b/go/delivery/delivery_test.go @@ -127,6 +127,7 @@ func testPulseKey(t *testing.T) { // 1131/AC4: each value encodes, decodes equal, and carries "v":1. func TestRoundTrip(t *testing.T) { + t.Run("phase and health refused", testPhaseHealthRefused) hasV1 := func(t *testing.T, b []byte) { t.Helper() var m map[string]any @@ -343,7 +344,7 @@ func TestUnknownVersion(t *testing.T) { } // 1131/AC6: a phase or health outside the lists is refused on encode and decode. -func TestPhaseHealthRefused(t *testing.T) { +func testPhaseHealthRefused(t *testing.T) { for _, p := range []Phase{PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed} { for _, h := range []Health{HealthOK, HealthFailed, HealthWaits, HealthUnknown} { if _, err := EncodeHave(Have{Phase: p, Health: h}); err != nil { diff --git a/go/delivery/judge.go b/go/delivery/judge.go new file mode 100644 index 0000000..f030401 --- /dev/null +++ b/go/delivery/judge.go @@ -0,0 +1,229 @@ +package delivery + +import ( + "bytes" + "encoding/json" + "path" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// The judgement of a move (ADR 0377 §3), as a pure function over two compositions of one module, so the node agent that +// composes its own modules judges what it would apply (novox/hq ADR 0547 §3; issue 1126's S8). Moved from +// mesh-controller cmd/mesh-controller/composed_judge.go at origin/main (the controller keeps its copy, unchanged, until +// it loses the lease, ADR 0547 §1): judged, onlyTheUserList and sameResources, with their helpers, are its code. +// +// ADR 0377 §3 over two compositions of one module: **recreates** when a container resource is changed, added or +// removed; **moves data** when, with no container changed, a directory or file resource is changed, added or removed +// that a container of the module mounts or that holds or lies within one of its directories (where a module keeps its +// data), when its `data` declaration changes, or, for a module that declares data, when one of its long-running +// processes changes; **neither** otherwise, and for a module the node has never run. + +// Judged is one move's judgement: what it does, its words, and whether only the bus's user list changes (ADR 0539 §7: +// a file change and a reload of the bus, no move of it, never asked). +type Judged struct { + Judgement Judgement + Words string + UserListOnly bool +} + +// JudgeMove judges moving a node's module from the composition it runs to the one composed for it. Running is nil when the +// node runs no composition of the module. A composition equal to the one running (resource for resource, each +// canonical) is unchanged: no move, and no ask (ADR 0547 §3). +func JudgeMove(running *Composed, composed Composed) Judged { + if composed.Module == "" { + return Judged{Judgement: JudgedNeither, Words: "the node's own composition"} + } + if running == nil { + return Judged{Judgement: JudgedNeither, Words: "the node runs no composition of " + composed.Module} + } + if running.Data == composed.Data && SameComposedResources(*running, composed) { + return Judged{Judgement: JudgedUnchanged, Words: "equal to the composition the node runs"} + } + if onlyTheUserList(*running, composed) { + return Judged{Judgement: JudgedNeither, Words: "only the bus's user list changes", UserListOnly: true} + } + j, words := judged(*running, composed) + return Judged{Judgement: j, Words: words} +} + +// busUsersSuffix ends the id of the bus module's user list as the composer writes it: `.bus-users`. +const busUsersSuffix = ".bus-users" + +// onlyTheUserList says two compositions of a module differ, and only in its file of the bus's user list. +func onlyTheUserList(from, to Composed) bool { + if from.Data != to.Data { + return false + } + before, after := judgedResourcesByID(from), judgedResourcesByID(to) + if len(before) != len(after) { + return false + } + changed := false + for id, now := range after { + was, ok := before[id] + if !ok { + return false + } + if bytes.Equal(was.raw, now.raw) { + continue + } + if !strings.HasSuffix(id, busUsersSuffix) || now.kind != string(declaration.TypeFile) { + return false + } + changed = true + } + return changed +} + +// judged applies ADR 0377 §3 to two compositions of one module. +func judged(from, to Composed) (Judgement, string) { + before, after := judgedResourcesByID(from), judgedResourcesByID(to) + if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 { + return JudgedRecreates, "it recreates " + strings.Join(ids, ", ") + } + if from.Data != to.Data { + return JudgedMovesData, "its data declaration changes" + } + places := dataPlaces(before, after) + held := func(kind string) bool { + return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile) + } + var moved []string + for _, id := range changedOf(before, after, held) { + r := before[id] + if r == nil { + r = after[id] + } + if withinPlaces(r.path, places) { + moved = append(moved, id) + } + } + if to.Data || from.Data { + moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...) + } + if len(moved) > 0 { + sort.Strings(moved) + return JudgedMovesData, "it changes where its data is kept: " + strings.Join(moved, ", ") + } + return JudgedNeither, "it recreates no container and moves no data" +} + +// judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount, +// and its canonical bytes. +type judgedResource struct { + kind string + path string + mounts []string + raw []byte +} + +func judgedResourcesByID(c Composed) map[string]*judgedResource { + out := map[string]*judgedResource{} + for _, raw := range c.Resources { + var head struct { + ID string `json:"id"` + Type string `json:"type"` + Path string `json:"path"` + // A container's volumes, `:[:]`. + Volumes []string `json:"volumes"` + } + if err := json.Unmarshal(raw, &head); err != nil { + continue + } + r := &judgedResource{kind: head.Type, path: head.Path, raw: compactResource(raw)} + for _, v := range head.Volumes { + source, _, _ := strings.Cut(v, ":") + r.mounts = append(r.mounts, source) + } + out[head.ID] = r + } + return out +} + +// compactResource is a resource's bytes canonical: its keys sorted, its spacing dropped, its numbers as written, so two +// compositions that say the same compare equal however each was written. +func compactResource(raw []byte) []byte { + var v any + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if err := d.Decode(&v); err != nil { + return raw + } + out, err := json.Marshal(v) + if err != nil { + return raw + } + return out +} + +// changedOf are the ids of the resources of the kinds counted that are changed, added or removed, sorted. +func changedOf(before, after map[string]*judgedResource, counted func(kind string) bool) []string { + var out []string + for id, now := range after { + if !counted(now.kind) { + continue + } + if was, ok := before[id]; !ok || !bytes.Equal(was.raw, now.raw) { + out = append(out, id) + } + } + for id, was := range before { + if _, ok := after[id]; !ok && counted(was.kind) { + out = append(out, id) + } + } + sort.Strings(out) + return out +} + +// dataPlaces are the paths where the module keeps what a move must not lose: what its containers mount, and its +// directories, before and after. +func dataPlaces(before, after map[string]*judgedResource) []string { + var out []string + for _, rs := range []map[string]*judgedResource{before, after} { + for _, r := range rs { + if r.kind == string(declaration.TypeContainer) { + out = append(out, r.mounts...) + } + if r.kind == string(declaration.TypeDirectory) && r.path != "" { + out = append(out, r.path) + } + } + } + return out +} + +// withinPlaces says p is one of places, or lies within one, or holds one. +func withinPlaces(p string, places []string) bool { + if p == "" { + return false + } + p = path.Clean(p) + for _, place := range places { + if place == "" || !strings.HasPrefix(place, "/") { + continue + } + place = path.Clean(place) + if p == place || strings.HasPrefix(p, place+"/") || strings.HasPrefix(place, p+"/") { + return true + } + } + return false +} + +// SameComposedResources says two compositions of a module hold the same resources in the same order, each compared +// canonical. +func SameComposedResources(a, b Composed) bool { + if len(a.Resources) != len(b.Resources) { + return false + } + for i := range a.Resources { + if !bytes.Equal(compactResource(a.Resources[i]), compactResource(b.Resources[i])) { + return false + } + } + return true +} diff --git a/go/delivery/judge_test.go b/go/delivery/judge_test.go new file mode 100644 index 0000000..4e61fd5 --- /dev/null +++ b/go/delivery/judge_test.go @@ -0,0 +1,68 @@ +package delivery + +import ( + "encoding/json" + "strings" + "testing" +) + +func composedOf(module string, c byte, data bool, resources ...string) Composed { + out := Composed{Node: "ace", Module: module, FP: SourcePrefix2 + strings.Repeat(string(c), 64), Data: data} + for _, r := range resources { + out.Resources = append(out.Resources, json.RawMessage(r)) + } + return out +} + +// The pairs mesh-controller's composed_judge_test.go captured (issue 1945 AC12), and the bus's user list, judged as the +// controller judges them (ADR 0377 §3, ADR 0539 §7), by the node agent's function (ADR 0547 §3, issue 1126 S8). +func TestJudgeMove(t *testing.T) { + const ( + container = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:1111111111111111111111111111111111111111111111111111111111111111","volumes":["/var/lib/mesh-mail/spool:/spool"]}` + container2 = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:2222222222222222222222222222222222222222222222222222222222222222","volumes":["/var/lib/mesh-mail/spool:/spool"]}` + spool = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}` + spoolMoved = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/srv/mesh-mail/spool"}` + tools = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"a"}` + tools2 = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"b"}` + config = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"a"}` + config2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}` + process = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/a"}` + process2 = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/b"}` + users = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"a"}` + users2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"b"}` + natsConf = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"a"}` + natsConf2 = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"b"}` + ) + mail := func(c byte, data bool, rs ...string) *Composed { + m := composedOf("mesh-mail", c, data, rs...) + return &m + } + bus := func(rs ...string) *Composed { m := composedOf("nats", 'b', false, rs...); return &m } + for _, c := range []struct { + name string + from, to *Composed + want Judgement + userList bool + }{ + {"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false}, + {"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false}, + {"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false}, + {"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false}, + {"its data declaration", mail('a', false, container, spool), mail('b', true, container, spool), JudgedMovesData, false}, + {"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false}, + {"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false}, + {"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false}, + {"never ran", nil, mail('b', true, container), JudgedNeither, false}, + {"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false}, + {"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true}, + {"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false}, + } { + got := JudgeMove(c.from, *c.to) + if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" { + t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList) + } + } + if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither { + t.Errorf("the node's own composition: %+v", got) + } +}