From 8855735341b9a22b66311643c183a0eb1270b73d Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:44:25 +0100 Subject: [PATCH] mesh-delivery-assigner: the placement judge is the assigner's own, judging a proposal from placement alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0593 §1, §8: compose/judge moves back from mesh-sdk; every node answers a proposal, what a placement changes is read from its entries, and a node silent past the answer bound (60 s) is named and the proposal decided on the answers it has. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- .../cmd/mesh-delivery-assigner/asks.go | 161 -------- .../compose_integration_test.go | 179 --------- .../internal/assign/compose.go | 368 ------------------ .../internal/assign/compose_test.go | 169 -------- .../internal/assign/composer.go | 263 ------------- .../internal/assign/composer_test.go | 225 ----------- .../internal/judge/judge.go | 331 ++++++++++++++++ .../internal/judge/judge_test.go | 91 +++++ 8 files changed, 422 insertions(+), 1365 deletions(-) delete mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/asks.go delete mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/compose_integration_test.go delete mode 100644 modules/mesh-delivery-assigner/internal/assign/compose.go delete mode 100644 modules/mesh-delivery-assigner/internal/assign/compose_test.go delete mode 100644 modules/mesh-delivery-assigner/internal/assign/composer.go delete mode 100644 modules/mesh-delivery-assigner/internal/assign/composer_test.go create mode 100644 modules/mesh-delivery-assigner/internal/judge/judge.go create mode 100644 modules/mesh-delivery-assigner/internal/judge/judge_test.go diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/asks.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/asks.go deleted file mode 100644 index 4c4b394..0000000 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/asks.go +++ /dev/null @@ -1,161 +0,0 @@ -package main - -import ( - "context" - "sort" - "strings" - "sync" - "time" - - "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" - "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" - - "mesh-delivery-assigner/internal/assign" -) - -// AssignerModule is the asker the assigner's compose asks name. -const AssignerModule = "mesh-delivery-assigner" - -// composeAsks are the compose asks the assigner put on the Go build seat's check stream (novox/hq ADR 0482 clause 4, -// issues 1622 and 1623), by what each composes: one ask per key, put once and followed by a watch of its step key until -// it ends, which wakes a pass. No timer: an ask that waits in the stream waits (its backlog is the view's and the -// watchman's to show). A restart forgets them and asks again; the build module keeps a composer built for a commit, so a -// second ask builds nothing. -type composeAsks struct { - ctx context.Context - asker buildrun.Asker - wake func() - logf func(string, ...any) - now func() time.Time - - mu sync.Mutex - asks map[string]*composeAsk -} - -type composeAsk struct { - id string - cancel context.CancelFunc - ran *assign.Ran -} - -func newComposeAsks(ctx context.Context, asker buildrun.Asker, wake func(), logf func(string, ...any)) *composeAsks { - return &composeAsks{ctx: ctx, asker: asker, wake: wake, logf: logf, now: time.Now, asks: map[string]*composeAsk{}} -} - -// get is the end of the ask that composes key, or nil and its id while it runs. The first get of a key puts the ask. -// A job its layout refuses ends at once, failed with why: never silent. -func (a *composeAsks) get(key string, job delivery.ComposeJob) (*assign.Ran, string) { - a.mu.Lock() - defer a.mu.Unlock() - if k, ok := a.asks[key]; ok { - return k.ran, k.id - } - ask := delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: AssignerModule, - At: a.now(), Compose: &job} - ctx, cancel := context.WithCancel(a.ctx) - k := &composeAsk{id: ask.ID, cancel: cancel} - a.asks[key] = k - if err := a.asker.Put(ctx, ask); err != nil { - a.logf("the compose ask %s for %s could not be put: %v", ask.ID, key, err) - k.ran = &assign.Ran{State: delivery.StepFailed, Reason: "the compose ask could not be put on the Go build seat: " + err.Error()} - cancel() - return k.ran, k.id - } - go func() { - step, err := a.asker.Wait(ctx, ask) - if ctx.Err() != nil { - return - } - ran := assign.RanOf(step) - if err != nil { - ran = assign.Ran{State: delivery.StepFailed, Reason: "the compose ask's step could not be followed: " + err.Error()} - } - a.mu.Lock() - k.ran = &ran - a.mu.Unlock() - a.wake() - }() - return nil, k.id -} - -// sweep forgets, and stops following, every ask whose key no pass asked for this time: a candidate gone or moved on. -func (a *composeAsks) sweep(seen map[string]bool) { - a.mu.Lock() - defer a.mu.Unlock() - for key, k := range a.asks { - if !seen[key] { - k.cancel() - delete(a.asks, key) - } - } -} - -// composeAtHead is the composition of a group with a mesh-sdk member: the composer at its head and at each reader's -// mesh-sdk, asked of the Go build seat, and the verdict once every ask ended; until then the verdict waits, naming the -// asks. The keys it used are marked in seen. -func (p *passer) composeAtHead(ctx context.Context, m assign.Mesh, ch assign.Change, seen map[string]bool, began time.Time) delivery.CompositionVerdict { - waiting := func(words string) delivery.CompositionVerdict { - return delivery.CompositionVerdict{Group: ch.Group, Heads: ch.Heads, State: delivery.CompositionWaits, Words: words} - } - head, err := assign.HeadJob(m, ch) - if err != nil { - return delivery.CompositionVerdict{Group: ch.Group, Heads: ch.Heads, State: delivery.CompositionFail, SDK: "mesh-sdk " + ch.SDKHead, - Words: "the compose ask at the group's mesh-sdk head cannot be made: " + err.Error()} - } - var pending []string - key := "head." + ch.Group + "." + ch.Heads + "." + ch.SDKHead - seen[key] = true - headRan, id := p.asks.get(key, head) - if headRan == nil { - pending = append(pending, "mesh-sdk "+ch.SDKHead+" (ask "+id+")") - } - var readers []assign.Reader - var notJudged []string - for _, module := range sortedNames(assign.ReaderModules) { - repository := assign.ReaderModules[module] - var commits []string - member := false - if h := ch.Members[repository]; h != "" { - commits, member = []string{h}, true - } else { - commits = p.w.runningCommits(module) - } - if len(commits) == 0 { - notJudged = append(notJudged, "not judged whether "+module+" reads the group's manifests: the builder's log holds no done "+ - "trunk build of it, and the group holds no member of "+repository) - continue - } - for _, commit := range commits { - job, err := assign.ReaderJob(ch, module, repository, commit) - if err != nil { - readers = append(readers, assign.Reader{Module: module, Commit: commit, Member: member, - Ran: assign.Ran{State: delivery.StepFailed, Reason: err.Error()}}) - continue - } - rkey := "reader." + ch.Group + "." + ch.Heads + "." + module + "." + commit - seen[rkey] = true - ran, rid := p.asks.get(rkey, job) - if ran == nil { - pending = append(pending, module+" at "+commit+" (ask "+rid+")") - continue - } - readers = append(readers, assign.Reader{Module: module, Commit: commit, Member: member, Ran: *ran}) - } - } - if len(pending) > 0 { - return waiting("composing on the Go build seat with the mesh-sdk of " + strings.Join(pending, ", ")) - } - v := assign.FromComposer(m, ch, *headRan, readers, time.Since(began)) - v.Warnings = append(v.Warnings, notJudged...) - return v -} - -func sortedNames(m map[string]string) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/compose_integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/compose_integration_test.go deleted file mode 100644 index 580bfe3..0000000 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/compose_integration_test.go +++ /dev/null @@ -1,179 +0,0 @@ -//go:build integration - -package main - -import ( - "encoding/json" - "errors" - "strings" - "sync" - "testing" - "time" - - "github.com/nats-io/nats.go/jetstream" - - "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// holder is a test holder of the Go build seat: it takes every ask from the seat's check worker, as mesh-build-go does, -// and answers a compose ask by running mesh-sdk's composer over the request filled from the indexer's manifests (the -// linked composer standing for the one the build module builds at the asked commit), writing its step on its node's key. -type holder struct { - mu sync.Mutex - asks []delivery.StepAsk -} - -func (h *holder) taken() []delivery.StepAsk { - h.mu.Lock() - defer h.mu.Unlock() - return append([]delivery.StepAsk(nil), h.asks...) -} - -func (b *bed) hold(t *testing.T) *holder { - t.Helper() - seat, _ := delivery.AskStream(delivery.AskGo) - worker, _ := delivery.AskWorker(delivery.AskGo, delivery.AskCheck) - subject, _ := delivery.AskSubject(delivery.AskGo, delivery.AskCheck) - cons, err := b.js.CreateOrUpdateConsumer(b.ctx, seat, jetstream.ConsumerConfig{Durable: worker, FilterSubject: subject, AckPolicy: jetstream.AckExplicitPolicy}) - if err != nil { - t.Fatal(err) - } - steps := b.buckets[delivery.StepsBucket(goBuildModule)] - h := &holder{} - cc, err := cons.Consume(func(m jetstream.Msg) { - a, err := delivery.DecodeStepAsk(m.Data()) - if err != nil || a.Compose == nil { - t.Errorf("the seat was put an ask that is not a compose: %v %s", err, m.Data()) - m.Ack() - return - } - h.mu.Lock() - h.asks = append(h.asks, a) - h.mu.Unlock() - req, err := composer.Decode(a.Compose.Request) - if err == nil { - err = composer.Fill(&req, func(digest string) ([]byte, error) { - key, _ := delivery.ManifestsKey(digest) - e, err := b.buckets[delivery.ManifestsBucket].Get(b.ctx, key) - if err != nil { - return nil, err - } - man, err := delivery.DecodeManifest(e.Value()) - return []byte(man.JSON), err - }) - } - step := delivery.Step{ID: a.ID, Tool: "compose", Node: "ace", State: delivery.StepPassed, Started: time.Now().UTC(), Ended: time.Now().UTC()} - if err != nil { - step.State, step.Reason = delivery.StepFailed, err.Error() - } else if v, err := composer.Run(req); err != nil { - step.State, step.Reason = delivery.StepFailed, err.Error() - } else { - vr, _ := json.Marshal(v) - step.Result, _ = json.Marshal(delivery.ComposeAnswer{Built: true, Key: "composer-0123456789abcdef", Verdict: vr}) - } - raw, err := delivery.EncodeStep(step) - if err != nil { - t.Errorf("the step cannot be written: %v", err) - } - key, _ := delivery.AskStepKey("ace", a.ID) - if _, err := steps.Put(b.ctx, key, raw); err != nil { - t.Errorf("the step %s: %v", key, err) - } - m.Ack() - }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(cc.Stop) - return h -} - -// 1622/AC1-AC3 and 1623 on the bus: started whole, the assigner composes a group holding a mesh-sdk pull request by -// asking the Go build seat for the composer at the member's head, and for the composer at the mesh-sdk the running -// node-engine pins; it waits naming the asks while they run, writes the verdict from their answers naming the mesh-sdk the -// head's composer was built with, and asks nothing more on a later pass of the same heads. -func TestAGroupWithAnSDKMemberIsComposedByTheComposerAtItsHead(t *testing.T) { - b := startBed(t, "") - trunk := b.manifest(t, "web", `{"module":"web","version":"1"}`) - changed := b.manifest(t, "web", `{"module":"web","version":"2"}`) - mod, err := delivery.EncodeModule(delivery.Module{FP2: delivery.SourcePrefix2 + strings.Repeat("a", 64), Manifest: trunk, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) - if err != nil { - t.Fatal(err) - } - b.putJSON(t, delivery.ModulesBucket, "web", mod) - b.place(t, "g14", "web") - running := strings.Repeat("3", 40) - b.place(t, "novox", "mesh-host") - b.built(t, "mesh-host", "novox/mesh-host", running) - sdkHead := strings.Repeat("4", 40) - heads := []delivery.Head{{Repository: "novox/mesh-sdk", Number: 80, Head: sdkHead}, {Repository: "novox/web", Number: 7, Head: strings.Repeat("2", 40)}} - hash, _ := delivery.HeadsHash(heads) - pulls := []delivery.CandidatePull{{Head: heads[0]}, {Head: heads[1], - Modules: []string{"web"}, Fps: map[string]delivery.CandidateModule{"web": {FP2: delivery.SourcePrefix2 + strings.Repeat("b", 64), Manifest: changed}}}} - cand, err := delivery.EncodeCandidate(delivery.Candidate{Group: "feat/x", HeadsHash: hash, Pulls: pulls, FPSet: delivery.FingerprintSet(pulls)}) - if err != nil { - t.Fatal(err) - } - ckey, _ := delivery.CandidatesKey("feat/x") - b.putJSON(t, delivery.CandidatesBucket, ckey, cand) - b.start(t) - key, _ := delivery.CompositionKey("feat/x", hash) - // No holder takes the asks yet: the composition waits, naming them. - waits := b.verdict(t, key, func(v delivery.CompositionVerdict) bool { return v.State == delivery.CompositionWaits }) - if !strings.Contains(waits.Words, "mesh-sdk "+sdkHead) || !strings.Contains(waits.Words, "mesh-host at "+running) { - t.Fatalf("the waiting composition says %q, want the head's and the running node-engine's asks", waits.Words) - } - h := b.hold(t) - v := b.verdict(t, key, func(v delivery.CompositionVerdict) bool { return v.State != delivery.CompositionWaits }) - if v.State != delivery.CompositionPass || len(v.Machines) != 1 || v.Machines[0] != "g14" || v.SDK == "" { - t.Fatalf("composition = %+v, want a pass on g14", v) - } - asks := h.taken() - if len(asks) != 2 { - t.Fatalf("the seat was asked %d composes, want two (the head, the running node-engine)", len(asks)) - } - byRepo := map[string]delivery.StepAsk{} - for _, a := range asks { - byRepo[a.Compose.Fetch["repository"].(string)] = a - if a.Asker != AssignerModule || a.Kind != delivery.AskCheck || a.Language != delivery.AskGo { - t.Errorf("ask = %+v", a) - } - } - if a := byRepo["novox/mesh-sdk"]; a.Compose == nil || a.Compose.Fetch["commit"] != sdkHead || a.Compose.Dir != "go" { - t.Errorf("the head's ask = %+v", a.Compose) - } - if a := byRepo["novox/mesh-host"]; a.Compose == nil || a.Compose.Fetch["commit"] != running || a.Compose.Dir != delivery.RootPath { - t.Errorf("the running node-engine's ask = %+v", a.Compose) - } - // A later pass of the same heads (woken by any change) asks nothing more. - on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) - b.putJSON(t, delivery.PresenceBucket, "ace", on) - time.Sleep(500 * time.Millisecond) - if n := len(h.taken()); n != 2 { - t.Errorf("a later pass of the same heads asked again: %d asks", n) - } -} - -// verdict waits for the composition under key to satisfy ok, and answers it. -func (b *bed) verdict(t *testing.T, key string, ok func(delivery.CompositionVerdict) bool) delivery.CompositionVerdict { - t.Helper() - var last delivery.CompositionVerdict - for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { - e, err := b.buckets[delivery.AssignerCompositionsBucket].Get(b.ctx, key) - if errors.Is(err, jetstream.ErrKeyNotFound) { - continue - } - if err != nil { - t.Fatal(err) - } - if last, err = delivery.DecodeCompositionVerdict(e.Value()); err != nil { - t.Fatal(err) - } - if ok(last) { - return last - } - } - t.Fatalf("no composition under %s as wanted after 10 s; last %+v", key, last) - return last -} diff --git a/modules/mesh-delivery-assigner/internal/assign/compose.go b/modules/mesh-delivery-assigner/internal/assign/compose.go deleted file mode 100644 index 6c9eece..0000000 --- a/modules/mesh-delivery-assigner/internal/assign/compose.go +++ /dev/null @@ -1,368 +0,0 @@ -// Package assign is the assigner's judgement, as functions over what it read from the bus: the composition check of a -// change (novox/hq ADR 0446 clauses 1 to 5, ADR 0528 clause 5, issue 1297), the six rules of what each node should -// run (ADR 0429 §6, ADR 0528 clause 3) and the comparison with what the controller sends (ADR 0528 clause 4). It reads -// and writes nothing itself: the program hands it what it watched and writes what it answers. -package assign - -import ( - "encoding/json" - "fmt" - "slices" - "sort" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// SDKRepository is the repository of mesh-sdk: a group with a member there changes the composition itself. -const SDKRepository = "novox/mesh-sdk" - -// NotJudged names, in a composition's warnings, the failures of ADR 0446 clause 1 the assigner cannot judge from the -// bus, because what they need is only in the controller's facts snapshot, which is not on the bus yet (issue 1461; -// the design limit of R4g, issue 1297): said on every verdict, never passed silently. -const NotJudged = "not judged until the facts snapshot is on the bus (issue 1461): a consumer left out of a grant, a " + - "credential left bound elsewhere, a stored setting the changed definition cannot keep, and a new module the " + - "node-engine's validator would refuse" - -// Mesh is the mesh as the trunk holds it: every module's manifest by name, the manifests that cannot be read with -// why, and each node's open assignments. -type Mesh struct { - Manifests map[string]catalogue.Manifest - Unreadable map[string]string - Assigned map[string][]string - // Digests is every trunk module's manifest digest in the indexer's manifests bucket, readable or not: what a - // composer at another mesh-sdk reads (issue 1622). - Digests map[string]string - // Input is the rest of the mesh as the nodes compose from it, filled by mesh-sdk's frombus from the catalogue's - // placement and the nodes' records (novox/hq ADR 0547 §2, ADR 0549 §3, issue 1126), so the check and the nodes share - // one composer's input; nil before any placement is read, when the nodes and their assignments alone are composed. - Input *compose.Input - // InputUnread says why that input could not be read from the bus, when it could not: the check then composes the - // nodes and their assignments alone, and says so in its warnings. - InputUnread string - // Verdicts are the indexer's refusals of trunk manifests, by module, as their values carry them (novox/hq ADR 0578 - // §3): each such module is in Unreadable too, and the composer's input carries the verdict (§5). - Verdicts map[string]delivery.ManifestVerdict -} - -// Change is a group's change as the indexer's candidate holds it: the manifest of every module it moves or adds, those -// it cannot read and why, the modules it removes, every module defined twice with where, the heads of a mesh-sdk -// member (empty when none), and how many modules it rebuilds. -type Change struct { - Group, Heads string - Manifests map[string]catalogue.Manifest - Unreadable map[string]string - Removed []string - Twice map[string][]string - SDKHead string - Rebuilt int - // Digests is the manifest digest of every module the change moves or adds, readable by the assigner or not, and - // Members the head of each member by repository: what a composer at the group's mesh-sdk reads (issues 1622, 1623). - Digests map[string]string - Members map[string]string - // Held are the approved held values of the group's pull requests at the heads judged (mesh-config's `kept`, novox/hq ADR - // 0565 §5, ADR 0502 §4): each laid over its module's values on its node in the change's composition, and named. - Held []delivery.KeptValue -} - -// HeldOf are the held values kept for the pull requests the candidate names, each at the head it names: one of another -// pull request, or at another head, is never laid (ADR 0502 §4). kept is mesh-config's `kept` by key. -func HeldOf(kept map[string][]byte, heads map[string]string) []delivery.KeptValue { - var out []delivery.KeptValue - for _, raw := range kept { - var k delivery.KeptValue - if json.Unmarshal(raw, &k) != nil || k.Node == "" || k.Module == "" || k.Key == "" { - continue - } - if head, ok := heads[k.Pull]; ok && head == k.Head { - out = append(out, k) - } - } - sort.Slice(out, func(i, j int) bool { - a, b := out[i], out[j] - return a.Pull+a.Node+a.Module+a.Key < b.Pull+b.Node+b.Module+b.Key - }) - return out -} - -// layHeld lays each held value over its module's values on its node, as a layer of its own after the rest, and answers -// the words that name each; judged first against the head's declaration of its key (catalogue.ValueProblems), a value the -// declaration refuses, or a key it does not declare, is a failure. -func layHeld(in *compose.Input, held []delivery.KeptValue) (said, fails []string) { - for _, k := range held { - var v any - if err := json.Unmarshal(k.Value, &v); err != nil { - said = append(said, fmt.Sprintf("the held value %s of %s on %s for %s cannot be read, so it is not laid: %v", k.Key, k.Module, k.Node, k.Pull, err)) - continue - } - d, declared := in.Catalogue[k.Module].Settings[k.Key] - if !declared { - fails = append(fails, fmt.Sprintf("the held value %s of %s on %s for %s: the head declares no such key", k.Key, k.Module, k.Node, k.Pull)) - continue - } - if problems := catalogue.ValueProblems(d, v); len(problems) > 0 { - fails = append(fails, fmt.Sprintf("the held value %s of %s on %s for %s does not hold to the head's declaration: %s", k.Key, k.Module, - k.Node, k.Pull, strings.Join(problems, "; "))) - continue - } - at := slices.IndexFunc(in.Nodes, func(n compose.Node) bool { return n.Name == k.Node }) - if at < 0 { - said = append(said, fmt.Sprintf("the held value %s of %s for %s names %s, which runs no module of the mesh: not laid", k.Key, k.Module, k.Pull, k.Node)) - continue - } - if in.Nodes[at].Settings == nil { - in.Nodes[at].Settings = map[string][]catalogue.Layer{} - } - in.Nodes[at].Settings[k.Module] = append(in.Nodes[at].Settings[k.Module], catalogue.Layer{From: "held for " + k.Pull, Values: map[string]any{k.Key: v}}) - said = append(said, fmt.Sprintf("held value laid: %s of %s on %s, held for %s at %.12s, approved by %s (novox/hq ADR 0502 §4)", k.Key, k.Module, k.Node, k.Pull, k.Head, k.Approved)) - } - return said, fails -} - -// Touched is the machines a change touches (ADR 0446 clause 3): those running a module the change moves, adds or -// removes, those running a consumer of a provision such a module provides (before or after), and those running a -// provider of a provision such a module requires after the change. A module the change adds that is assigned nowhere -// touches no machine. A change of placement is no change of a repository (ADR 0567 §4): the seat -// mesh-placement judges it. -func Touched(m Mesh, c Change) []string { - moved := map[string]bool{} - for name := range c.Manifests { - moved[name] = true - } - for name := range c.Unreadable { - moved[name] = true - } - for _, name := range c.Removed { - moved[name] = true - } - provided, required := map[string]bool{}, map[string]bool{} - for name := range moved { - for _, man := range []catalogue.Manifest{m.Manifests[name], c.Manifests[name]} { - for _, o := range man.Provides { - provided[o.Name] = true - } - } - for _, r := range c.Manifests[name].Requires { - required[r] = true - } - } - var out []string - for node, modules := range m.Assigned { - for _, mod := range modules { - man := m.Manifests[mod] - if moved[mod] || slices.ContainsFunc(man.Requires, func(r string) bool { return provided[r] }) || - slices.ContainsFunc(man.Provides, func(o catalogue.Offer) bool { return required[o.Name] }) { - out = append(out, node) - break - } - } - } - sort.Strings(out) - return out -} - -// input is the composition's input for the mesh's catalogue and assignments: the nodes by name with what is assigned -// to them, and every seat the assigned modules claim as held there. What the bus does not carry (places on the -// private network, settings, credentials, licences) is left empty, so a trunk composition and a change's are made -// with the same gaps and compared (ADR 0446 clause 1: what was already broken is said and fails nothing). -func input(catalog map[string]catalogue.Manifest, assigned map[string][]string) compose.Input { - in := compose.Input{Catalogue: catalog} - nodes := make([]string, 0, len(assigned)) - for n := range assigned { - nodes = append(nodes, n) - } - sort.Strings(nodes) - for _, n := range nodes { - in.Nodes = append(in.Nodes, compose.Node{Name: n, Assigned: slices.Clone(assigned[n])}) - for _, mod := range assigned[n] { - for _, cl := range catalog[mod].Claims { - scope := cl.Scope - if seat, ok := catalogue.SeatNamed(cl.Name); ok && scope == "" { - scope = seat.Scope - } - in.Holdings = append(in.Holdings, catalogue.Held{Claim: cl.Name, Scope: scope, Node: n, Module: mod}) - } - } - } - return in -} - -// Compose is the composition check of change c over the mesh m, composed with the mesh-sdk version sdk: every touched -// machine resolved as the trunk holds it and as the change leaves it. It fails on a manifest of the change that cannot -// be read, a module a machine runs that the change removes, a module defined twice, and a machine that resolves on the -// trunk and not with the change; a machine that does not resolve on either is said and fails nothing. It warns past -// CompositionRebuildWarning modules rebuilt, says what it does not judge (NotJudged), and for a group with a mesh-sdk -// member says it composed with its own mesh-sdk, not the member's head (issue 1621). took is how long it took. -func Compose(m Mesh, c Change, sdk string, took time.Duration) delivery.CompositionVerdict { - v := delivery.CompositionVerdict{Group: c.Group, Heads: c.Heads, SDK: sdk, Took: took.Milliseconds()} - var fails []string - failAt := func(machine, words string) { - if v.Machine == "" && machine != "" { - v.Machine = machine - } - fails = append(fails, words) - } - for _, name := range sortedKeys(c.Unreadable) { - failAt("", fmt.Sprintf("the manifest of %s cannot be read: %s", name, c.Unreadable[name])) - } - for _, name := range sortedKeys(c.Twice) { - failAt("", fmt.Sprintf("%s is defined twice, in %s", name, strings.Join(c.Twice[name], " and "))) - } - machines := Touched(m, c) - v.Machines = machines - for _, node := range machines { - for _, name := range c.Removed { - if slices.Contains(m.Assigned[node], name) { - failAt(node, fmt.Sprintf("%s runs %s, which the change removes from its source", node, name)) - } - } - } - trunk := input(m.Manifests, m.Assigned) - changed := map[string]catalogue.Manifest{} - for name, man := range m.Manifests { - changed[name] = man - } - for name, man := range c.Manifests { - changed[name] = man - } - for _, name := range c.Removed { - delete(changed, name) - } - after := input(changed, m.Assigned) - laid, heldFails := layHeld(&after, c.Held) - v.Warnings = append(v.Warnings, laid...) - for _, f := range heldFails { - failAt("", f) - } - for _, node := range machines { - _, before := compose.Resolve(trunk, node) - _, now := compose.Resolve(after, node) - switch { - case now == nil: - case before != nil: - v.Warnings = append(v.Warnings, fmt.Sprintf("%s does not compose on the trunk either, so the change is not blamed: %v", node, before)) - default: - failAt(node, fmt.Sprintf("%s composes on the trunk and not with the change: %v", node, now)) - } - } - if c.SDKHead != "" { - v.Warnings = append(v.Warnings, fmt.Sprintf("composed with the assigner's own mesh-sdk %s, not the group's mesh-sdk head %s, "+ - "and a field the running controller cannot read is not judged (issues 1621, 1622, 1623)", sdk, c.SDKHead)) - } - return finish(v, m, c, machines, fails) -} - -// finish adds what every composition says beside its own judgement (more modules rebuilt than CompositionRebuildWarning, -// what is not judged, the machines not composed, a change that touches none) and its state from fails. -func finish(v delivery.CompositionVerdict, m Mesh, c Change, machines []string, fails []string) delivery.CompositionVerdict { - if c.Rebuilt > delivery.CompositionRebuildWarning { - v.Warnings = append(v.Warnings, fmt.Sprintf("the merge rebuilds %d modules, more than %d", c.Rebuilt, delivery.CompositionRebuildWarning)) - } - v.Warnings = append(v.Warnings, NotJudged) - if could := CouldRun(m, c, machines); len(could) > 0 { - v.Warnings = append(v.Warnings, "not judged until the facts snapshot is on the bus (issue 1461, ADR 0537 clause 1): "+ - "the machines that could run a moved or added module and are not assigned it: "+strings.Join(could, ", ")) - } - if grants := GrantsAlone(m, c, machines); len(grants) > 0 { - v.Warnings = append(v.Warnings, "not judged until the facts snapshot is on the bus (issue 1461, ADR 0537 clause 1): "+ - "the machines whose grants alone the change alters: "+strings.Join(grants, ", ")) - } - if len(machines) == 0 { - v.Warnings = append(v.Warnings, "the change touches no machine: no module it moves is assigned anywhere") - } - if len(fails) > 0 { - v.State, v.Words = delivery.CompositionFail, strings.Join(fails, "; ") - return v - } - v.State = delivery.CompositionPass - return v -} - -// CouldRun is, by name, every machine of the mesh not composed that is not assigned a module the change moves or adds -// (ADR 0537 clause 1): which of them could run it, the bus does not say (capabilities, settings and places are only in -// the facts snapshot), so each is named as not judged. -func CouldRun(m Mesh, c Change, composed []string) []string { - var out []string - for node, modules := range m.Assigned { - if slices.Contains(composed, node) { - continue - } - for name := range c.Manifests { - if !slices.Contains(modules, name) { - out = append(out, node) - break - } - } - } - sort.Strings(out) - return out -} - -// grantsOf is what of a manifest makes grants: its bus declarations and the provisions it requires and offers. -func grantsOf(man catalogue.Manifest) string { - var offers []string - for _, o := range man.Provides { - offers = append(offers, o.Name) - } - return fmt.Sprint(man.Requires, offers, man.Reads, man.State, man.Emits, man.Consumes, man.Invokes, man.Claims, man.Grants) -} - -// GrantsAlone is, by name, every machine not composed whose grants alone the change alters (ADR 0537 clause 1): for a -// moved module whose grants differ from the trunk's, the machines assigned the bus's holder (whose user list carries -// every module's bus grants) and those assigned a provider of a provision it requires, before or after. -func GrantsAlone(m Mesh, c Change, composed []string) []string { - provisions := map[string]bool{} - altered := false - for name, man := range c.Manifests { - before, had := m.Manifests[name] - if had && grantsOf(before) == grantsOf(man) { - continue - } - altered = true - for _, r := range append(slices.Clone(man.Requires), before.Requires...) { - provisions[r] = true - } - } - if !altered { - return nil - } - var out []string - for node, modules := range m.Assigned { - if slices.Contains(composed, node) { - continue - } - for _, mod := range modules { - man := m.Manifests[mod] - if man.ClaimsSeat("mesh-broker") || slices.ContainsFunc(man.Provides, func(o catalogue.Offer) bool { return provisions[o.Name] }) { - out = append(out, node) - break - } - } - } - sort.Strings(out) - return out -} - -// withOverlay is base with every manifest of over laid over it, a copy. -func withOverlay(base, over map[string]catalogue.Manifest) map[string]catalogue.Manifest { - out := make(map[string]catalogue.Manifest, len(base)+len(over)) - for name, man := range base { - out[name] = man - } - for name, man := range over { - out[name] = man - } - return out -} - -func sortedKeys[V any](m map[string]V) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/modules/mesh-delivery-assigner/internal/assign/compose_test.go b/modules/mesh-delivery-assigner/internal/assign/compose_test.go deleted file mode 100644 index 1e202f5..0000000 --- a/modules/mesh-delivery-assigner/internal/assign/compose_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package assign - -import ( - "encoding/json" - "slices" - "strconv" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -func meshOf() Mesh { - return Mesh{ - Manifests: map[string]catalogue.Manifest{ - "mail": {Module: "mail", Version: "1", Requires: []string{"database"}}, - "store": {Module: "store", Version: "1", Provides: []catalogue.Offer{{Name: "database"}}}, - "web": {Module: "web", Version: "1"}, - }, - Assigned: map[string][]string{"ace": {"mail", "store"}, "g14": {"web"}}, - } -} - -func hasWarning(v delivery.CompositionVerdict, part string) bool { - return slices.ContainsFunc(v.Warnings, func(w string) bool { return strings.Contains(w, part) }) -} - -// 1297/AC1 (its unit half): a change touching one module on one machine composes that machine alone, passes, names -// the machine and the mesh-sdk it composed with, and says what it did not judge. -func TestAChangeOfOneModuleComposesItsMachine(t *testing.T) { - c := Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"web": {Module: "web", Version: "2"}}} - v := Compose(meshOf(), c, "v0.1.14-x", 0) - if v.State != delivery.CompositionPass || !slices.Equal(v.Machines, []string{"g14"}) || v.SDK != "v0.1.14-x" { - t.Fatalf("composition = %+v, want a pass on g14 alone", v) - } - if !hasWarning(v, "issue 1461") { - t.Errorf("the verdict does not say what it did not judge: %v", v.Warnings) - } - if _, err := delivery.EncodeCompositionVerdict(v); err != nil { - t.Errorf("the verdict cannot be written: %v", err) - } - // 1953/D7 (ADR 0565 §5, ADR 0502 §4): the group's own pull request's held value at its head is laid and named, judged - // against the head's declaration; one of another pull request or another head is not laid. - web, err := catalogue.Decode([]byte(`{"module":"web","version":"2","config":{"port":{"type":"number","default":80,"description":"the port, a number","runtime":true}}}`)) - if err != nil { - t.Fatal(err) - } - kept := map[string][]byte{} - for i, k := range []delivery.KeptValue{ - {Pull: "novox/x#1", Head: "h1", Node: "g14", Module: "web", Key: "port", Value: json.RawMessage(`81`), Approved: "op"}, - {Pull: "novox/x#2", Head: "h2", Node: "g14", Module: "web", Key: "port", Value: json.RawMessage(`82`)}, - {Pull: "novox/x#1", Head: "old", Node: "g14", Module: "web", Key: "other", Value: json.RawMessage(`1`)}, - } { - kept[strconv.Itoa(i)], _ = json.Marshal(k) - } - held := HeldOf(kept, map[string]string{"novox/x#1": "h1"}) - if len(held) != 1 || string(held[0].Value) != "81" { - t.Fatalf("held = %+v, want #1's at its head alone", held) - } - c = Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"web": web}, Held: held} - if v := Compose(meshOf(), c, "x", 0); v.State != delivery.CompositionPass || !hasWarning(v, "held value laid: port of web on g14, held for novox/x#1") { - t.Errorf("composition = %+v", v) - } - c.Held[0].Value = json.RawMessage(`"eighty"`) - if v := Compose(meshOf(), c, "x", 0); v.State != delivery.CompositionFail || !strings.Contains(v.Words, "does not hold to the head's declaration") { - t.Errorf("a held value the declaration refuses: %+v", v) - } -} - -// 1297/AC2 and B3c: a change that removes a module a machine runs fails, naming the machine and the module; a machine that -// composes on the trunk and not with the change fails it, naming the machine and why (the consumer's machine is touched when -// its provider changes); what was already broken on the trunk is said and fails nothing. One table (three tests merged). -func TestAChangeThatBreaksAMachineFailsAndWhatWasBrokenFailsNothing(t *testing.T) { - broken := meshOf() - broken.Manifests["store"] = catalogue.Manifest{Module: "store", Version: "1"} - for _, c := range []struct { - name string - mesh Mesh - c Change - state string - must func(delivery.CompositionVerdict) bool - }{ - {"removing a module a machine runs", meshOf(), Change{Group: "g", Heads: "0123456789ab", Removed: []string{"web"}}, delivery.CompositionFail, - func(v delivery.CompositionVerdict) bool { - return v.Machine == "g14" && strings.Contains(v.Words, "g14 runs web") - }}, - {"a machine the change breaks", meshOf(), Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"store": {Module: "store", Version: "2"}}}, - delivery.CompositionFail, func(v delivery.CompositionVerdict) bool { - return v.Machine == "ace" && strings.Contains(v.Words, "database") - }}, - {"what was broken before", broken, Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"mail": {Module: "mail", Version: "2", Requires: []string{"database"}}}}, - delivery.CompositionPass, func(v delivery.CompositionVerdict) bool { return hasWarning(v, "on the trunk either") }}, - } { - if v := Compose(c.mesh, c.c, "sdk", 0); v.State != c.state || !c.must(v) { - t.Errorf("%s: composition = %+v", c.name, v) - } - } -} - -// 1297/B3c: an unreadable manifest and a module defined twice fail; more than twelve rebuilt modules warn. -func TestUnreadableTwiceAndWide(t *testing.T) { - c := Change{Group: "g", Heads: "0123456789ab", Unreadable: map[string]string{"web": "unknown field \"scope\""}, Rebuilt: 13} - v := Compose(meshOf(), c, "sdk", 0) - if v.State != delivery.CompositionFail || !strings.Contains(v.Words, "web cannot be read") || !hasWarning(v, "rebuilds 13 modules") { - t.Fatalf("composition = %+v", v) - } - c = Change{Group: "g", Heads: "0123456789ab", Twice: map[string][]string{"web": {"novox/a", "novox/b"}}, Rebuilt: 12} - v = Compose(meshOf(), c, "sdk", 0) - if v.State != delivery.CompositionFail || !strings.Contains(v.Words, "web is defined twice") || hasWarning(v, "rebuilds") { - t.Fatalf("composition = %+v", v) - } -} - -// 1622's criterion as R4g delivers it: a group with a mesh-sdk member is composed with the assigner's own mesh-sdk, and -// the verdict says so, naming the member's head it did not use; a group without one says nothing of it. -func TestAGroupWithAnSDKMemberSaysWhichSDKComposedIt(t *testing.T) { - c := Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"web": {Module: "web", Version: "2"}}, SDKHead: "f625d98713e4"} - v := Compose(meshOf(), c, "v0.1.14-own", 0) - if !hasWarning(v, "not the group's mesh-sdk head f625d98713e4") || !hasWarning(v, "v0.1.14-own") || !hasWarning(v, "1623") { - t.Errorf("the verdict does not say it composed with its own mesh-sdk: %v", v.Warnings) - } - c.SDKHead = "" - if v := Compose(meshOf(), c, "v0.1.14-own", 0); hasWarning(v, "mesh-sdk head") { - t.Errorf("a group without a mesh-sdk member says it has one: %v", v.Warnings) - } -} - -// 1297/B3a: the machines touched are those running a moved module or a consumer of what it provides; an added module -// assigned nowhere touches none, and the verdict says so. -func TestTouched(t *testing.T) { - m := meshOf() - if got := Touched(m, Change{Manifests: map[string]catalogue.Manifest{"store": m.Manifests["store"]}}); !slices.Equal(got, []string{"ace"}) { - t.Errorf("touched by store = %v", got) - } - m.Assigned["g14"] = append(m.Assigned["g14"], "mail") - if got := Touched(m, Change{Manifests: map[string]catalogue.Manifest{"store": m.Manifests["store"]}}); !slices.Equal(got, []string{"ace", "g14"}) { - t.Errorf("touched by store with a consumer on g14 = %v", got) - } - c := Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"new": {Module: "new", Version: "1"}}} - if v := Compose(m, c, "sdk", 0); len(v.Machines) != 0 || !hasWarning(v, "touches no machine") || v.State != delivery.CompositionPass { - t.Errorf("an added module assigned nowhere: %+v", v) - } -} - -// ADR 0537 clause 1: every verdict names, by machine, those that could run a moved or added module and are not assigned -// it, and those whose grants alone the change alters (the bus's machine, a provider of what the module requires), as not -// judged; shanks, providing what web now requires, is composed and so named in neither. A change that alters no grant -// names none of the second. -func TestTheMachinesNotJudgedAreNamed(t *testing.T) { - m := meshOf() - m.Manifests["bus"] = catalogue.Manifest{Module: "bus", Version: "1", Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: "mesh"}}} - m.Manifests["db"] = catalogue.Manifest{Module: "db", Version: "1", Provides: []catalogue.Offer{{Name: "cache"}}} - m.Assigned["novox"] = []string{"bus"} - m.Assigned["shanks"] = []string{"db"} - c := Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{ - "web": {Module: "web", Version: "2", Reads: []string{"mesh-controller.assignments"}, Requires: []string{"cache"}}}} - v := Compose(m, c, "sdk", 0) - if !hasWarning(v, "could run a moved or added module and are not assigned it: ace, novox") || !slices.Contains(v.Machines, "shanks") { - t.Errorf("the machines that could run web are not named: %v", v.Warnings) - } - if !hasWarning(v, "whose grants alone the change alters: novox") { - t.Errorf("the machines whose grants alone change are not named: %v", v.Warnings) - } - c.Manifests["web"] = catalogue.Manifest{Module: "web", Version: "3"} - if v := Compose(meshOf(), c, "sdk", 0); hasWarning(v, "grants alone") { - t.Errorf("a change altering no grant names machines whose grants change: %v", v.Warnings) - } -} diff --git a/modules/mesh-delivery-assigner/internal/assign/composer.go b/modules/mesh-delivery-assigner/internal/assign/composer.go deleted file mode 100644 index a4cedf8..0000000 --- a/modules/mesh-delivery-assigner/internal/assign/composer.go +++ /dev/null @@ -1,263 +0,0 @@ -package assign - -import ( - "encoding/json" - "fmt" - "slices" - "sort" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// The cases of a group's composition at its mesh-sdk head: the trunk as it is, and the trunk with the change. -const ( - CaseTrunk = "trunk" - CaseChange = "change" -) - -// SDKDir is the directory of mesh-sdk's Go module in its repository, where the composer is built at a head; a reader's -// composer is built at its repository's root, at the mesh-sdk its go.mod pins. -const SDKDir = "go" - -// The modules whose mesh-sdk reads the manifests a plan sends (ADR 0482 clause 5): the node-engine composes and applies -// them, the controller being gone (novox/hq ADR 0590, issue 1126). Each is judged at its repository. -var ReaderModules = map[string]string{"mesh-host": "novox/mesh-host"} - -// HeadJob is the compose job of a group with a mesh-sdk member (ADR 0482 clauses 3 and 4, issue 1622): the composer -// built at the member's head composes the machines the change touches, as the trunk holds them and as the change leaves -// them, every manifest named by its digest in the indexer's bucket, the seats held from the claims (the assigner's input). -func HeadJob(m Mesh, c Change) (delivery.ComposeJob, error) { - trunk, change := map[string]composer.Manifest{}, map[string]composer.Manifest{} - for name, digest := range m.Digests { - trunk[name] = composer.Manifest{Digest: digest} - change[name] = composer.Manifest{Digest: digest} - } - for name, digest := range c.Digests { - change[name] = composer.Manifest{Digest: digest} - } - for _, name := range c.Removed { - delete(change, name) - } - in, fromClaims := plainInput(m.Assigned), true - if m.Input != nil { - // The input the nodes compose from, without its catalogue: the cases carry the manifests (issue 1126). - in = *m.Input - in.Catalogue = nil - fromClaims = len(in.Holdings) == 0 - } - // Each trunk manifest the indexer refused goes in with its verdict, so the composer holds and names it (ADR 0578 §5); - // a manifest the change brings has no verdict of the indexer yet, and the checker's judge judges it (§2). - changeVerdicts := map[string]delivery.ManifestVerdict{} - for name, v := range m.Verdicts { - if _, changed := c.Digests[name]; !changed { - changeVerdicts[name] = v - } - } - changeCase := composer.Case{Name: CaseChange, Manifests: change, Verdicts: changeVerdicts} - req := composer.Request{V: composer.Version, Input: in, Depth: composer.DepthResolve, - HoldingsFromClaims: fromClaims, Machines: Touched(m, c), - Cases: []composer.Case{{Name: CaseTrunk, Manifests: trunk, Verdicts: m.Verdicts}, changeCase}} - return job(SDKRepository, c.SDKHead, SDKDir, req, "the mesh-sdk head of "+c.Group) -} - -// ReaderJob is the compose job that reads the manifests a group changes with the mesh-sdk a reader module pins at -// commit (ADR 0482 clause 5, issue 1623): no machine is composed, every changed manifest is only read. -func ReaderJob(c Change, module, repository, commit string) (delivery.ComposeJob, error) { - changed := map[string]composer.Manifest{} - for name, digest := range c.Digests { - changed[name] = composer.Manifest{Digest: digest} - } - req := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: CaseChange, Manifests: changed}}} - return job(repository, commit, delivery.RootPath, req, module+" at "+commit) -} - -func job(repository, commit, dir string, req composer.Request, reader string) (delivery.ComposeJob, error) { - raw, err := json.Marshal(req) - if err != nil { - return delivery.ComposeJob{}, err - } - return delivery.ComposeJob{Fetch: map[string]any{"repository": repository, "commit": commit}, Dir: dir, - Package: composer.Package, Request: raw, Reader: reader}, nil -} - -// plainInput is the composition's input without its catalogue: the nodes by name with what is assigned to them (the -// composer derives the seats held from the claims, as input does). -func plainInput(assigned map[string][]string) compose.Input { - var in compose.Input - nodes := make([]string, 0, len(assigned)) - for n := range assigned { - nodes = append(nodes, n) - } - sort.Strings(nodes) - for _, n := range nodes { - in.Nodes = append(in.Nodes, compose.Node{Name: n, Assigned: slices.Clone(assigned[n])}) - } - return in -} - -// Ran is one compose ask's end: its step's state and reason, and the composer's verdict when the step passed. -type Ran struct { - State string - Reason string - Verdict *composer.Verdict -} - -// RanOf reads a compose ask's end from its step value; a step that passed with an answer that cannot be read is a -// failure, named. -func RanOf(step delivery.Step) Ran { - r := Ran{State: step.State, Reason: step.Reason} - if step.State != delivery.StepPassed { - return r - } - var a delivery.ComposeAnswer - var v composer.Verdict - if err := json.Unmarshal(step.Result, &a); err != nil { - return Ran{State: delivery.StepFailed, Reason: "the compose step's answer cannot be read: " + err.Error()} - } - if err := json.Unmarshal(a.Verdict, &v); err != nil || v.V != composer.Version { - return Ran{State: delivery.StepFailed, Reason: fmt.Sprintf("the composer's verdict cannot be read (version %d): %v", v.V, err)} - } - r.Verdict = &v - return r -} - -func caseOf(v *composer.Verdict, name string) (composer.CaseVerdict, bool) { - if v == nil { - return composer.CaseVerdict{}, false - } - for _, c := range v.Cases { - if c.Name == name { - return c, true - } - } - return composer.CaseVerdict{}, false -} - -// Reader is one module whose mesh-sdk reads the group's manifests, at the commit judged: the group's member head when -// the group holds one in its repository, else a build its node runs. -type Reader struct { - Module, Commit string - Member bool - Ran Ran -} - -// FromComposer is the composition check of a group with a mesh-sdk member, judged from the composer built at its head -// (ADR 0482 clauses 3 to 5, issues 1622 and 1623): the same failures as Compose, read from the composer's two cases, the -// verdict naming the mesh-sdk the composer was built with; a trunk manifest the head's mesh-sdk cannot decode fails too -// (the group would deliver a reader that cannot read it). A reader that cannot read a changed manifest is a warning, -// never a failure: it decodes leniently and sets a field it does not know aside (novox/hq ADR 0578 §6, which changes -// ADR 0482 §5). -func FromComposer(m Mesh, c Change, head Ran, readers []Reader, took time.Duration) delivery.CompositionVerdict { - v := delivery.CompositionVerdict{Group: c.Group, Heads: c.Heads, SDK: "mesh-sdk " + c.SDKHead, Took: took.Milliseconds()} - machines := Touched(m, c) - v.Machines = machines - var fails []string - if head.State != delivery.StepPassed { - v.Words = fmt.Sprintf("the composer at mesh-sdk %s did not run: %s %s", c.SDKHead, head.State, head.Reason) - v.State = delivery.CompositionFail - return v - } - if head.Verdict.SDK != "" { - v.SDK = head.Verdict.SDK - } - trunk, okT := caseOf(head.Verdict, CaseTrunk) - change, okC := caseOf(head.Verdict, CaseChange) - if !okT || !okC { - v.State, v.Words = delivery.CompositionFail, "the composer's verdict holds no trunk or no change case" - return v - } - failAt := func(machine, words string) { - if v.Machine == "" && machine != "" { - v.Machine = machine - } - fails = append(fails, words) - } - for _, name := range sortedKeys(c.Digests) { - if r := change.Manifests[name]; !r.Accepted { - failAt("", fmt.Sprintf("the manifest of %s cannot be read by mesh-sdk %s: %s", name, c.SDKHead, r.Refused)) - } - } - for _, name := range sortedKeys(trunk.Manifests) { - if _, changed := c.Digests[name]; changed || slices.Contains(c.Removed, name) || trunk.Manifests[name].Accepted { - continue - } - if _, before := m.Unreadable[name]; before { - continue // already unreadable on the trunk: not the group's doing - } - failAt("", fmt.Sprintf("mesh-sdk %s refuses the trunk's manifest of %s, which the group does not change: %s", c.SDKHead, name, trunk.Manifests[name].Refused)) - } - for _, name := range sortedKeys(c.Twice) { - failAt("", fmt.Sprintf("%s is defined twice, in %s", name, strings.Join(c.Twice[name], " and "))) - } - for _, node := range machines { - for _, name := range c.Removed { - if slices.Contains(m.Assigned[node], name) { - failAt(node, fmt.Sprintf("%s runs %s, which the change removes from its source", node, name)) - } - } - before, now := trunk.Machines[node], change.Machines[node] - switch { - case now.Composes: - case !before.Composes: - v.Warnings = append(v.Warnings, fmt.Sprintf("%s does not compose on the trunk either, so the change is not blamed: %s", node, before.Reason)) - default: - failAt(node, fmt.Sprintf("%s composes on the trunk and not with the change: %s", node, now.Reason)) - } - } - readerFails, readerWarnings := judgeReaders(c, change, readers) - fails = append(fails, readerFails...) - v.Warnings = append(v.Warnings, readerWarnings...) - if m.InputUnread != "" { - v.Warnings = append(v.Warnings, "composed over the nodes and their assignments alone: the input the nodes compose from "+ - "cannot be read from the bus ("+m.InputUnread+")") - } - return finish(v, m, c, machines, fails) -} - -// NoComposer says whether a compose step failed because the mesh-sdk it was built at has no composer command (a mesh-sdk -// from before issue 1621): Go's words for a package its module does not hold. -func NoComposer(reason string) bool { - for _, words := range []string{"cannot find package", "no required module provides package", "is not in std", "cannot find module providing package", "does not contain package"} { - if strings.Contains(reason, words) { - return true - } - } - return false -} - -func judgeReaders(c Change, head composer.CaseVerdict, readers []Reader) (fails, warnings []string) { - for _, r := range readers { - at := r.Module + " at " + r.Commit - if r.Member { - at = r.Module + "'s member head " + r.Commit - } - if r.Ran.State != delivery.StepPassed { - if NoComposer(r.Ran.Reason) { - warnings = append(warnings, fmt.Sprintf("not judged whether %s reads the group's manifests: the mesh-sdk it pins has no "+ - "composer command (before issue 1621)", at)) - continue - } - fails = append(fails, fmt.Sprintf("the composer of the mesh-sdk %s pins did not run: %s %s", at, r.Ran.State, r.Ran.Reason)) - continue - } - read, ok := caseOf(r.Ran.Verdict, CaseChange) - if !ok { - fails = append(fails, fmt.Sprintf("the composer of the mesh-sdk %s pins answered no change case", at)) - continue - } - for _, name := range sortedKeys(c.Digests) { - if !head.Manifests[name].Accepted || read.Manifests[name].Accepted { - continue - } - // ADR 0578 §6: no longer a failure; the reader sets what it does not know aside, and the part that needs it takes - // the newer package (ADR 0573 §2). - warnings = append(warnings, fmt.Sprintf("the manifest of %s uses what %s (mesh-sdk %s) cannot read: %s; it reads the rest "+ - "once it takes mesh-sdk %s", name, at, r.Ran.Verdict.SDK, read.Manifests[name].Refused, c.SDKHead)) - } - } - return fails, warnings -} diff --git a/modules/mesh-delivery-assigner/internal/assign/composer_test.go b/modules/mesh-delivery-assigner/internal/assign/composer_test.go deleted file mode 100644 index 1985104..0000000 --- a/modules/mesh-delivery-assigner/internal/assign/composer_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package assign - -import ( - "encoding/json" - "slices" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -var testTime = time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC) - -const sdkHead = "1111111111111111111111111111111111111111" - -// raws are the manifests of meshOf as their repositories hold them, by digest, as the indexer keeps them; `web2` is web -// with a field `shares` that the assigner's own mesh-sdk does not read (the shape of issue 1593). -var raws = map[string]string{ - "d-mail": `{"module": "mail", "version": "1", "requires": ["postgres-database"]}`, - "d-store": `{"module": "store", "version": "1", "provides": [{"name": "postgres-database"}]}`, - "d-web": `{"module": "web", "version": "1"}`, - "d-web2": `{"module": "web", "version": "2", "shares": ["photos"]}`, -} - -// asTheHeadReads is how mesh-sdk at the group's head reads each manifest: as the assigner's own reads it, but for the -// field it adds, which it accepts. The head's composer is built and run by the Go build module (ADR 0482 clause 4); here -// its verdict is made by mesh-sdk's composer.Run over the same request, with `web2` read as the head reads it. -var asTheHeadReads = map[string]string{"d-web2": `{"module": "web", "version": "2"}`} - -func digestedMesh() Mesh { - m := meshOf() - m.Digests = map[string]string{"mail": "d-mail", "store": "d-store", "web": "d-web"} - return m -} - -// webShares is a group of mesh-sdk and mesh-catalog whose catalogue member gives web the field only the SDK head reads: -// the assigner's own reader refuses it (Unreadable), as the indexer's manifest is read in world.change. -func webShares(members map[string]string) Change { - // As an older reader refused it; the field set aside by the lenient decoder is named the same (ADR 0578 §6). - m, _ := catalogue.Decode([]byte(raws["d-web2"])) - return Change{Group: "g", Heads: "0123456789ab", SDKHead: sdkHead, Manifests: map[string]catalogue.Manifest{}, - Unreadable: map[string]string{"web": m.SetAside()}, Digests: map[string]string{"web": "d-web2"}, Members: members} -} - -// run is the composer's step for a job, as the build module answers it: the request filled from the indexer's digests -// (with read standing in for how that mesh-sdk reads a digest), run, and its verdict put in a passed step. -func run(t *testing.T, job delivery.ComposeJob, read map[string]string) Ran { - t.Helper() - req, err := composer.Decode(job.Request) - if err != nil { - t.Fatal(err) - } - if err := composer.Fill(&req, func(d string) ([]byte, error) { - if r, ok := read[d]; ok { - return []byte(r), nil - } - return []byte(raws[d]), nil - }); err != nil { - t.Fatal(err) - } - v, err := composer.Run(req) - if err != nil { - t.Fatal(err) - } - v.SDK = "(devel) " + job.Fetch["commit"].(string) - vr, _ := json.Marshal(v) - ans, _ := json.Marshal(delivery.ComposeAnswer{Built: true, Key: "composer-0", Verdict: vr}) - return RanOf(delivery.Step{State: delivery.StepPassed, Result: ans}) -} - -// 1622/AC1: a group holding a mesh-sdk pull request is composed by the composer built at the member's head: the job -// fetches mesh-sdk at that head, builds mesh-sdk's composer command in its Go module, and hands it the trunk and the -// change by digest, never the manifests' bytes, composing the machines the change touches. -func TestAGroupWithAnSDKMemberAsksTheComposerAtItsHead(t *testing.T) { - c := webShares(nil) - job, err := HeadJob(digestedMesh(), c) - if err != nil { - t.Fatal(err) - } - if job.Fetch["repository"] != SDKRepository || job.Fetch["commit"] != sdkHead || job.Dir != "go" || job.Package != composer.Package { - t.Fatalf("job = %+v", job) - } - ask := delivery.StepAsk{ID: "mh3k2x9qa1b2c5", Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-assigner", - At: testTime, Compose: &job} - if _, err := delivery.EncodeStepAsk(ask); err != nil { - t.Fatalf("the ask is refused by its layout: %v", err) - } - req, err := composer.Decode(job.Request) - if err != nil { - t.Fatal(err) - } - if len(req.Cases) != 2 || req.Cases[0].Name != CaseTrunk || req.Cases[1].Name != CaseChange { - t.Fatalf("cases = %+v", req.Cases) - } - if req.Cases[0].Manifests["web"].Digest != "d-web" || req.Cases[1].Manifests["web"].Digest != "d-web2" || len(req.Cases[1].Manifests["web"].Raw) != 0 { - t.Errorf("the cases do not name web by its trunk and its change digest: %+v", req.Cases) - } - if !slices.Equal(req.Machines, []string{"g14"}) || req.Depth != composer.DepthResolve || !req.HoldingsFromClaims { - t.Errorf("request = machines %v, depth %q, holdings from claims %v", req.Machines, req.Depth, req.HoldingsFromClaims) - } - - // 1126 (ADR 0547 §2, ADR 0549 §3): once the catalogue places the nodes, the check composes over the input the nodes - // compose from (frombus), its catalogue left to the cases, and the seats held as placement says. - m := digestedMesh() - m.Input = &compose.Input{Catalogue: map[string]catalogue.Manifest{"web": {Module: "web"}}, - Nodes: []compose.Node{{Name: "g14", Assigned: []string{"web"}, Place: compose.Place{Endpoint: "g14.example:51820"}}}, - Holdings: []catalogue.Held{{Claim: "web-seat", Scope: "mesh", Node: "g14", Module: "web"}}} - placedJob, err := HeadJob(m, c) - if err != nil { - t.Fatal(err) - } - placed, err := composer.Decode(placedJob.Request) - if err != nil { - t.Fatal(err) - } - if placed.Input.Catalogue != nil || len(placed.Input.Nodes) != 1 || placed.Input.Nodes[0].Place.Endpoint != "g14.example:51820" || - len(placed.Input.Holdings) != 1 || placed.HoldingsFromClaims { - t.Errorf("the placed check's request = %+v, holdings from claims %v", placed.Input, placed.HoldingsFromClaims) - } -} - -// 1622/AC2, AC3: a catalogue member using a field only the SDK head reads passes with the head's composer, and the -// verdict names the mesh-sdk the composer was built with; with the assigner's own mesh-sdk it fails. -func TestAFieldTheGroupAddsPassesWithTheHeadsComposer(t *testing.T) { - c := webShares(map[string]string{"novox/mesh-controller": "2222222222222222222222222222222222222222"}) - job, _ := HeadJob(digestedMesh(), c) - head := run(t, job, asTheHeadReads) - v := FromComposer(digestedMesh(), c, head, nil, 0) - if v.State != delivery.CompositionPass || v.SDK != "(devel) "+sdkHead || !slices.Equal(v.Machines, []string{"g14"}) { - t.Fatalf("with the head's composer: %+v", v) - } - if _, err := delivery.EncodeCompositionVerdict(v); err != nil { - t.Errorf("the verdict cannot be written: %v", err) - } - own := Compose(digestedMesh(), c, "v0.1.14-own", 0) - if own.State != delivery.CompositionFail || !strings.Contains(own.Words, "shares") { - t.Fatalf("with the assigner's own mesh-sdk: %+v, want a fail naming the field", own) - } -} - -// 1623/AC1, AC2 as ADR 0578 §6 changes them: the group of mesh-sdk and mesh-catalog adding and using a field, without a -// controller member, passes with a warning naming the field and mesh-controller, whose running build pins an older -// mesh-sdk that cannot read it: a reader sets a field it does not know aside, so it fails nothing. With a controller -// member pinned to the SDK head it passes with no such warning. -func TestAFieldTheRunningControllerCannotReadIsAWarning(t *testing.T) { - c := webShares(nil) - job, _ := HeadJob(digestedMesh(), c) - head := run(t, job, asTheHeadReads) - running := "3333333333333333333333333333333333333333" - rjob, err := ReaderJob(c, "mesh-controller", "novox/mesh-controller", running) - if err != nil { - t.Fatal(err) - } - if rjob.Fetch["commit"] != running || rjob.Dir != delivery.RootPath { - t.Fatalf("reader job = %+v", rjob) - } - // The running controller's mesh-sdk, older than the lenient decoder, refuses web2 for its field. - old := Ran{State: delivery.StepPassed, Verdict: &composer.Verdict{V: composer.Version, SDK: "v0.1.13", Cases: []composer.CaseVerdict{ - {Name: CaseChange, Manifests: map[string]composer.ManifestRead{"web": {Refused: `json: unknown field "shares"`}}}}}} - v := FromComposer(digestedMesh(), c, head, []Reader{{Module: "mesh-controller", Commit: running, Ran: old}}, 0) - if v.State != delivery.CompositionPass || !hasWarning(v, "shares") || !hasWarning(v, "mesh-controller at "+running) { - t.Fatalf("without a controller member: %+v, want a pass warning of the field and mesh-controller", v) - } - member := "2222222222222222222222222222222222222222" - pinned := run(t, rjob, asTheHeadReads) - v = FromComposer(digestedMesh(), c, head, []Reader{{Module: "mesh-controller", Commit: member, Member: true, Ran: pinned}}, 0) - if v.State != delivery.CompositionPass { - t.Fatalf("with a controller member pinned to the head: %+v, want a pass", v) - } -} - -// 1623: a reader whose mesh-sdk has no composer command (before issue 1621) is said, not failed; a reader's composer that -// failed for another cause fails the group, loud. -func TestAReaderWithNoComposerIsSaidAndAnotherFailureFails(t *testing.T) { - c := webShares(nil) - job, _ := HeadJob(digestedMesh(), c) - head := run(t, job, asTheHeadReads) - none := Ran{State: delivery.StepFailed, Reason: "go build: no required module provides package git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose"} - v := FromComposer(digestedMesh(), c, head, []Reader{{Module: "mesh-host", Commit: "abc", Ran: none}}, 0) - if v.State != delivery.CompositionPass || !hasWarning(v, "mesh-host at abc") || !hasWarning(v, "before issue 1621") { - t.Fatalf("a reader with no composer: %+v", v) - } - broke := Ran{State: delivery.StepFailed, Reason: "the fetch could not reach the forge"} - v = FromComposer(digestedMesh(), c, head, []Reader{{Module: "mesh-host", Commit: "abc", Ran: broke}}, 0) - if v.State != delivery.CompositionFail || !strings.Contains(v.Words, "could not reach the forge") { - t.Fatalf("a reader whose composer failed: %+v", v) - } -} - -// 1622: the head's composer that did not run fails the composition naming why, never passes it; a trunk manifest the -// head's mesh-sdk refuses fails it too, since the group would deliver a reader that refuses it. -func TestTheHeadsComposerFailingOrRefusingTheTrunkFails(t *testing.T) { - c := webShares(nil) - v := FromComposer(digestedMesh(), c, Ran{State: delivery.StepRefused, Reason: "the guard refuses it"}, nil, 0) - if v.State != delivery.CompositionFail || !strings.Contains(v.Words, "the guard refuses it") || !strings.Contains(v.Words, sdkHead) { - t.Fatalf("a head composer that did not run: %+v", v) - } - job, _ := HeadJob(digestedMesh(), c) - strict := map[string]string{"d-web2": asTheHeadReads["d-web2"], "d-mail": `{"module": "mail", "version": "1", "requires": "x"}`} - v = FromComposer(digestedMesh(), c, run(t, job, strict), nil, 0) - if v.State != delivery.CompositionFail || !strings.Contains(v.Words, "refuses the trunk's manifest of mail") { - t.Fatalf("a head that refuses a trunk manifest: %+v", v) - } - // ADR 0578 §5: a trunk manifest the indexer refused goes into the composer's input with its verdict, in both cases; - // the composer holds and names it, and the group is not blamed for it. - m := digestedMesh() - m.Verdicts = map[string]delivery.ManifestVerdict{"mail": {Module: "mail", State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}} - m.Unreadable = map[string]string{"mail": m.Verdicts["mail"].Why()} - job, _ = HeadJob(m, c) - var req composer.Request - if err := json.Unmarshal(job.Request, &req); err != nil || !req.Cases[0].Verdicts["mail"].Refused() || !req.Cases[1].Verdicts["mail"].Refused() { - t.Fatalf("the request carries no verdict: %+v %v", req.Cases, err) - } - head := run(t, job, asTheHeadReads) - if trunk, _ := caseOf(head.Verdict, CaseTrunk); !strings.Contains(trunk.Manifests["mail"].Refused, "refused the manifest of mail: it is wrong") { - t.Fatalf("the composer did not name the refusal: %+v", trunk.Manifests["mail"]) - } - if v = FromComposer(m, c, head, nil, 0); v.State != delivery.CompositionPass { - t.Fatalf("a refusal on the trunk blamed the group: %+v", v) - } -} diff --git a/modules/mesh-delivery-assigner/internal/judge/judge.go b/modules/mesh-delivery-assigner/internal/judge/judge.go new file mode 100644 index 0000000..c30b939 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/judge/judge.go @@ -0,0 +1,331 @@ +// Package judge is how the seat mesh-placement's holder judges a proposal (novox/hq ADR 0567 §3, §4, as ADR 0593 §8 +// changes them): what it changes on each node by its placement (Intended), every node answering it, and the verdict of +// their answers (JudgeAnswers). The assigner's own (ADR 0593 §1), moved back from mesh-sdk's compose/judge: the holder +// composes nothing, since every composition is its node's engine's, and a node answers through the candidates path +// with what it composed. It holds no bus client and no timer: the holder gives it what it read. +package judge + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strconv" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// AnswerBound is how long the holder waits for a node's answer after its candidate was written: the time to live of the +// node's pulse key (delivery.PulseAnswerKey), whose expiry names it as giving no answer, never passed. The same answer +// bound a check gives a node (ADR 0593 §8, §7: 60 seconds, delivery.ComposeAnswerBound). A variable so a test on the bus +// can shorten it. +var AnswerBound = delivery.ComposeAnswerBound + +// Intended is, by node, the modules whose placed entry the proposal changes there, and the nodes that answer it: every +// node (ADR 0593 §8, changing ADR 0567 §4's list of the nodes that must answer), sorted. current is the placement as it +// stands, laid the proposal's placements by node (nil removes the node's key) and nodes every node with a record. A +// node's set holds the modules the proposal adds, removes or whose placed entry differs on that node; a node with no +// key now gets an empty set (§7: the seed is answered as the same). What a node's compositions change beyond its own +// placement it says itself in its answer, marked in the brief as not computed. +func Intended(current map[string]delivery.Placement, laid map[string]*delivery.Placement, nodes []string) (intended map[string]map[string]bool, must []string) { + intended = map[string]map[string]bool{} + all := map[string]bool{} + for _, n := range nodes { + all[n] = true + } + for n := range laid { + all[n] = true + } + for node := range all { + must = append(must, node) + set := map[string]bool{} + intended[node] = set + before, placed := current[node] + after, changesKey := laid[node] + if !changesKey || !placed { + continue // untouched, or new to placement: every answer must be the same + } + var afterModules []delivery.PlacedModule + if after != nil { + afterModules = after.Modules + } + entries := map[string]delivery.PlacedModule{} + for _, m := range before.Modules { + entries[m.Module] = m + } + seen := map[string]bool{} + for _, m := range afterModules { + seen[m.Module] = true + if e, ok := entries[m.Module]; !ok || !reflect.DeepEqual(e, m) { + set[m.Module] = true + } + } + for name := range entries { + if !seen[name] { + set[name] = true + } + } + } + sort.Strings(must) + return intended, must +} + +// JudgeAnswers is the verdict of a proposal's answers (§4). answers is by node its HaveNode.Candidates entry for the +// proposal (nil when it has none) and revisions by node the revision of its candidate key: an answer counts when it +// names the proposal and that revision. It fails on a refusal by a node that must answer; a node of Must with no answer +// that counts is waited for while its pulse key stands, and once it has ended (pulsing false: the bound's expiry, heard +// on the bus) is named as giving no answer and the proposal decided on the answers it has (ADR 0593 §8). +// A difference fails nothing: one Intended computed is listed under Intended, one it did not under Unintended, both +// shown in the ask's brief. The seed alone fails on any difference. +func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer, + revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict { + v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: map[string][]string{}, + Answers: map[string]delivery.CandidateAnswer{}} + for node, set := range intended { + for m := range set { + v.Intended[node] = append(v.Intended[node], m) + } + sort.Strings(v.Intended[node]) + } + must := p.Must + if len(must) == 0 { + must = p.Nodes() + } + for _, node := range must { + a := answers[node] + if a == nil || a.Proposal != p.ID || a.Revision == 0 || a.Revision != revisions[node] { + v.Silent = append(v.Silent, node) + continue + } + v.Answers[node] = *a + if a.Refused != "" { + v.Fails = append(v.Fails, fmt.Sprintf("%s cannot compose the proposal: %s", node, a.Refused)) + continue + } + for _, r := range a.Refusals { + v.Fails = append(v.Fails, fmt.Sprintf("%s refuses the proposal for %s: %s", node, r.Module, r.Why)) + } + want := intended[node] + differs := func(module, words string) { + if p.Seed { + // The seed carries what its nodes found, each named in the brief for the operator who approves it. + v.Carried[node] = append(v.Carried[node], words) + return + } + if !want[module] { + v.Unintended[node] = append(v.Unintended[node], module) + } + } + for _, d := range a.Differs { + differs(d.Module, differenceWords(d)) + } + for _, d := range a.Added { + differs(d.Module, "would run "+d.Module) + } + for _, m := range a.Removed { + differs(m, "would stop running "+m) + } + sort.Strings(v.Unintended[node]) + } + if len(v.Intended) == 0 { + v.Intended = nil + } + if len(v.Unintended) == 0 { + v.Unintended = nil + } + if len(v.Carried) == 0 { + v.Carried = nil + } + if len(v.Answers) == 0 { + v.Answers = nil + } + var waiting, gone []string + for _, node := range v.Silent { + named := node + if online != nil && !online[node] { + named += " (offline)" + } + if pulsing[node] { + waiting = append(waiting, named) + } else { + gone = append(gone, named) + } + } + if len(waiting) > 0 { + v.Notes = append(v.Notes, fmt.Sprintf("waits for the answers of %s, each for %s from its candidate's write", + strings.Join(waiting, ", "), AnswerBound)) + } + if len(gone) > 0 { + // ADR 0593 §8: decided on the answers it has; that node's own composition judges again when it applies (§9). + v.Notes = append(v.Notes, fmt.Sprintf("no answer from %s within %s of its candidate's write: decided on the answers "+ + "it has, and that node's own composition judges again when it applies", strings.Join(gone, ", "), AnswerBound)) + } + switch { + case len(v.Fails) > 0: + v.State = delivery.VerdictFail + case len(waiting) > 0: + v.State = delivery.VerdictWaits + default: + v.State = delivery.VerdictPass + } + return v +} + +// Brief is the verdict as the ask's brief shows it, by node (§5): what the proposal changes and what each node answered +// beside it, marked, and what stays held as the node holds it today, with the proposal's why. +func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { + var b strings.Builder + fmt.Fprintf(&b, "%s (proposed by %s)", p.Why, p.By) + nodes := map[string]bool{} + for n := range v.Intended { + nodes[n] = true + } + for n := range v.Unintended { + nodes[n] = true + } + for _, n := range p.Nodes() { + nodes[n] = true + } + for n, a := range v.Answers { + if len(a.Held) > 0 { + nodes[n] = true + } + } + for n := range v.Carried { + nodes[n] = true + } + names := make([]string, 0, len(nodes)) + for n := range nodes { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + fmt.Fprintf(&b, "; %s:", n) + if m := v.Intended[n]; len(m) > 0 { + fmt.Fprintf(&b, " changes %s", strings.Join(m, ", ")) + } else { + b.WriteString(" composes the same") + } + if m := v.Unintended[n]; len(m) > 0 { + fmt.Fprintf(&b, "; also differs, not computed: %s", strings.Join(m, ", ")) + } + if m := v.Carried[n]; len(m) > 0 { + fmt.Fprintf(&b, "; the seed carries: %s", strings.Join(m, "; ")) + } + if held := v.Answers[n].Held; len(held) > 0 { + var names []string + for _, h := range held { + today := h.Today + if today == "" { + today = "held" + } + names = append(names, h.Module+" ("+today+")") + } + fmt.Fprintf(&b, "; stays as today: %s", strings.Join(names, ", ")) + } + } + return b.String() +} + +// differenceWords names one module's difference: the module, the first field that differs in it with its value as it +// runs and as the seed composes it, each cut to about 60 characters ("(value cut)" where a body shown is cut before the +// field), and how many fields more; or the resource and why when no body is shown. +func differenceWords(d delivery.ModuleDiffers) string { + for _, b := range d.Bodies { + if len(b.Paths) == 0 { + continue + } + path := b.Paths[0] + words := fmt.Sprintf("%s: %s %s → %s", d.Module, fieldName(b.Running, b.Composed, path), + valueAt(b.Running, path), valueAt(b.Composed, path)) + if n := len(b.Paths) - 1; n > 0 { + words += fmt.Sprintf(" (and %d more fields)", n) + } + return words + } + if d.Resource != "" { + return fmt.Sprintf("%s: %s (%s)", d.Module, d.Resource, d.Why) + } + return fmt.Sprintf("%s: %s", d.Module, d.Why) +} + +// shownValue bounds a value named in a brief. +const shownValue = 60 + +// walk follows a differing path (delivery.DifferingPaths: keys joined by dots, a key may hold dots, and a JSON value +// written in a string is read as JSON) through a body, answering the keys it passed and the value at its end. +func walk(body, path string) (keys []string, value any, ok bool) { + var v any + if json.Unmarshal([]byte(body), &v) != nil { + return nil, nil, false + } + rest := path + for rest != "" { + if s, isString := v.(string); isString { + var inner any + if json.Unmarshal([]byte(s), &inner) != nil { + return nil, nil, false + } + v = inner + } + switch t := v.(type) { + case map[string]any: + best := "" + for k := range t { + if (rest == k || strings.HasPrefix(rest, k+".")) && len(k) > len(best) { + best = k + } + } + if best == "" { + return nil, nil, false + } + keys, v, rest = append(keys, best), t[best], strings.TrimPrefix(strings.TrimPrefix(rest, best), ".") + case []any: + seg, after, _ := strings.Cut(rest, ".") + n, err := strconv.Atoi(seg) + if err != nil || n < 0 || n >= len(t) { + return nil, nil, false + } + keys, v, rest = append(keys, seg), t[n], after + default: + return nil, nil, false + } + } + return keys, v, true +} + +// valueAt is the value at path in a shown body, cut to shownValue, or why it is not shown. +func valueAt(body, path string) string { + if body == "" { + return "(none)" + } + _, v, ok := walk(body, path) + if !ok { + return "(value cut)" + } + s, isString := v.(string) + if !isString { + raw, _ := json.Marshal(v) + s = string(raw) + } + if r := []rune(s); len(r) > shownValue { + s = string(r[:shownValue-1]) + "…" + } + return s +} + +// fieldName is the differing field as a person reads it: its parent's name and its own, `PARENT[key]`, from whichever +// body reaches it; the path's end when neither does. +func fieldName(running, composed, path string) string { + for _, body := range []string{running, composed} { + if keys, _, ok := walk(body, path); ok && len(keys) > 0 { + if len(keys) == 1 { + return keys[0] + } + return keys[len(keys)-2] + "[" + keys[len(keys)-1] + "]" + } + } + return path +} diff --git a/modules/mesh-delivery-assigner/internal/judge/judge_test.go b/modules/mesh-delivery-assigner/internal/judge/judge_test.go new file mode 100644 index 0000000..3a3060c --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/judge/judge_test.go @@ -0,0 +1,91 @@ +package judge + +import ( + "slices" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// ADR 0567 §3, §4 (rows moved from the assigner's check of a pull request's placement, ADR 0559 §5): what a proposal +// changes by node, and the verdict of the answers: differences shown and failing nothing, a refusal and a silent node +// failing, a later answer judging again, the seed failing on any difference. +func TestJudge(t *testing.T) { + current := map[string]delivery.Placement{"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}} + laid := map[string]*delivery.Placement{ + "anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail", Ports: map[string]int{"25": 2525}}, {Module: "web"}}}, + "laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: "web"}}}} + // ADR 0593 §8: every node answers; what the placement changes is read from the placement alone. + got, must := Intended(current, laid, []string{"anchor", "laptop", "server"}) + if !got["anchor"]["mail"] || !got["anchor"]["web"] || len(got["laptop"]) != 0 || len(got["server"]) != 0 || + !slices.Equal(must, []string{"anchor", "laptop", "server"}) { + t.Errorf("intended = %v, must %v: want anchor's mail and web, nothing elsewhere, every node answering", got, must) + } + + at := time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC) + p := delivery.PlacementProposal{ID: "p-one", By: "op", Why: "move web", Written: at, Base: map[string]uint64{"anchor": 3, "laptop": 0}, + Must: []string{"anchor", "laptop"}} + revisions := map[string]uint64{"anchor": 7, "laptop": 8} + intended := map[string]map[string]bool{"anchor": {"web": true}} + same := func(rev uint64) *delivery.CandidateAnswer { + return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateSame, Same: []string{"mail"}} + } + differs := func(rev uint64, module string) *delivery.CandidateAnswer { + return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateDiffers, + Differs: []delivery.ModuleDiffers{{Module: module, Why: "the port differs"}}} + } + for _, row := range []struct { + name string + seed bool + answers map[string]*delivery.CandidateAnswer + pulsing map[string]bool + state string + words string + marked string + }{ + {"all the same", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, + {"a module added where intended", false, map[string]*delivery.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: delivery.CandidateDiffers, + Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, + {"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, delivery.VerdictPass, "", "mail"}, + {"the seed carries a difference, named", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, + Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{Module: "node-tools", Resource: "node-tools.runtime", Why: "the resource differs", + Bodies: []delivery.ResourceBodies{{ID: "node-tools.runtime", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf", "env.MESH_TOOL_MODULES"}, + Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`, + Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}","MESH_TOOL_MODULES":"… (cut at 8192 bytes)`}}}}}}, nil, delivery.VerdictPass, "", ""}, + {"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused, + Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""}, + {"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""}, + {"an answer of an earlier revision, past its bound, is no answer and decided without it", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, delivery.VerdictPass, "no answer from anchor", ""}, + {"a module held as today passes, shown in the brief", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, + Verdict: delivery.CandidateSame, Held: []delivery.ModuleRefused{{Module: "spiegel", Why: "a required value is unset"}}}}, nil, delivery.VerdictPass, "", ""}, + {"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, + } { + q := p + q.Seed = row.seed + v := JudgeAnswers(q, intended, row.answers, revisions, map[string]bool{"anchor": true}, row.pulsing, at) + all := strings.Join(append(append([]string{}, v.Fails...), v.Notes...), "; ") + if v.State != row.state || !strings.Contains(all, row.words) { + t.Errorf("%s: %s, %q", row.name, v.State, all) + } + if row.seed && row.state == delivery.VerdictPass && !strings.Contains(Brief(q, v), "the seed carries: node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → (value cut) (and 1 more fields)") { + t.Errorf("%s: the brief does not name it: %s", row.name, Brief(q, v)) + } + if row.seed && row.state == delivery.VerdictPass { + whole := row.answers["laptop"].Differs[0] + whole.Bodies = []delivery.ResourceBodies{{ID: "r", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf"}, + Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`, + Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}"}}`}} + if got := differenceWords(whole); got != "node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → systemd-resolved (node-resolver)" { + t.Errorf("the difference is named %q", got) + } + } + if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays as today: spiegel (held)") { + t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v)) + } + if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) { + t.Errorf("%s: the difference is not marked: %+v, %s", row.name, v.Unintended, Brief(q, v)) + } + } +}