From 840e56eddb903fcbf6234b9dc8f2a586bae56869 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 03:43:49 +0200 Subject: [PATCH 1/9] Fail the node-engine's check when the controller's trunk is unknown, so its step-nine test never skips (issue 1295) --- .../mesh-delivery-checker/internal/check/beside.go | 4 ++++ .../internal/check/pins_test.go | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/modules/mesh-delivery-checker/internal/check/beside.go b/modules/mesh-delivery-checker/internal/check/beside.go index 2453319..ebe0df5 100644 --- a/modules/mesh-delivery-checker/internal/check/beside.go +++ b/modules/mesh-delivery-checker/internal/check/beside.go @@ -83,6 +83,10 @@ func (j *Examiner) besideFor(ctx context.Context, c *Case, p Pull) ([]BesideRef, out = append(out, BesideRef{Repository: repo, Commit: member.Head, Ref: refOf(member.Number)}) } else if head := heads[repo]; head != "" { out = append(out, BesideRef{Repository: repo, Commit: head}) + } else if repo == controllerRepo && p.Repository == nodeEngineRepo { + // The node-engine's step-nine test judges the controller's own manifest and fails without it (issue 1295): a + // checker that cannot place it says so, where placing none left that test to skip. + return nil, fmt.Errorf("the trunk head of %s is not known, so the controller the tests of %s#%d read cannot be placed beside them", repo, p.Repository, p.Number) } } } diff --git a/modules/mesh-delivery-checker/internal/check/pins_test.go b/modules/mesh-delivery-checker/internal/check/pins_test.go index c9c2bb1..d1083be 100644 --- a/modules/mesh-delivery-checker/internal/check/pins_test.go +++ b/modules/mesh-delivery-checker/internal/check/pins_test.go @@ -346,3 +346,15 @@ func TestAMeshLabHeadGetsACheck(t *testing.T) { t.Fatalf("mesh-lab was not fetched: %+v", fetched) } } + +// 1295: the node-engine's step-nine test fails without the controller beside it, so a checker that knows no trunk head of the +// controller fails the check at the placing, loudly, and does not place none. +func TestTheNodeEnginesCheckFailsWhereTheControllersTrunkIsNotKnown(t *testing.T) { + const host = "novox/mesh-host" + b := newBench(t) + b.ex.Trunks = trunksOf(t, map[string]string{host: trunkA, catalogueRepository: trunkS, SDKRepository: trunkS}) + v := b.judge(t, hostCase(t, pullOf(host, 5, head1))).Verdict + if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, "mesh-controller") || !strings.Contains(v.Failed.Words, "cannot be placed") { + t.Fatalf("verdict = %+v", v) + } +} From c58342786f4c0e47a3909a0ae5b7976fda7b46db Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 03:57:51 +0200 Subject: [PATCH 2/9] Judge a group with a mesh-sdk member from the composer at its head, and fail a field its readers cannot read (issues 1622, 1623) --- modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 2 + .../internal/assign/compose.go | 19 +- .../internal/assign/composer.go | 241 ++++++++++++++++++ .../internal/assign/composer_test.go | 184 +++++++++++++ 5 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 modules/mesh-delivery-assigner/internal/assign/composer.go create mode 100644 modules/mesh-delivery-assigner/internal/assign/composer_test.go diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 1408845..7beb07e 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 2a3e9f3..e524e7f 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce h1:9pNnygyhuYIpVs0HPzgheGLNjPXigGGQpnFTp8lw+X4= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-assigner/internal/assign/compose.go b/modules/mesh-delivery-assigner/internal/assign/compose.go index d609c87..b1f26e4 100644 --- a/modules/mesh-delivery-assigner/internal/assign/compose.go +++ b/modules/mesh-delivery-assigner/internal/assign/compose.go @@ -32,6 +32,9 @@ 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 } // Change is a group's change as the indexer's candidate holds it: the manifest of every module it moves or adds, those @@ -45,6 +48,10 @@ type Change struct { 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 } // Touched is the machines a change touches (ADR 0446 clause 3): those running a module the change moves, adds or @@ -167,13 +174,19 @@ func Compose(m Mesh, c Change, sdk string, took time.Duration) delivery.Composit failAt(node, fmt.Sprintf("%s composes on the trunk and not with the change: %v", node, now)) } } - if c.Rebuilt > delivery.CompositionRebuildWarning { - v.Warnings = append(v.Warnings, fmt.Sprintf("the merge rebuilds %d modules, more than %d", c.Rebuilt, delivery.CompositionRebuildWarning)) - } 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): "+ diff --git a/modules/mesh-delivery-assigner/internal/assign/composer.go b/modules/mesh-delivery-assigner/internal/assign/composer.go new file mode 100644 index 0000000..b8ccb02 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/composer.go @@ -0,0 +1,241 @@ +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 controller composes and sends +// them, the node-engine applies them. Each is judged at its repository. +var ReaderModules = map[string]string{"mesh-controller": "novox/mesh-controller", "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) + } + req := composer.Request{V: composer.Version, Input: plainInput(m.Assigned), Depth: composer.DepthResolve, + HoldingsFromClaims: true, Machines: Touched(m, c), + Cases: []composer.Case{{Name: CaseTrunk, Manifests: trunk}, {Name: CaseChange, Manifests: change}}} + 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 refuses fails too (the +// group would deliver a reader that refuses it); and each reader that refuses a changed manifest the head reads fails +// it, naming the module and its words, unless the reader's mesh-sdk has no composer to ask (before issue 1621), which is +// said and judged nothing. +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...) + 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 + } + fails = append(fails, fmt.Sprintf("the manifest of %s uses what %s (mesh-sdk %s) cannot read: %s; the group must move %s onto "+ + "mesh-sdk %s", name, at, r.Ran.Verdict.SDK, read.Manifests[name].Refused, r.Module, 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 new file mode 100644 index 0000000..4b709c8 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/composer_test.go @@ -0,0 +1,184 @@ +package assign + +import ( + "encoding/json" + "slices" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "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 { + _, err := catalogue.ParseManifest([]byte(raws["d-web2"])) + return Change{Group: "g", Heads: "0123456789ab", SDKHead: sdkHead, Manifests: map[string]catalogue.Manifest{}, + Unreadable: map[string]string{"web": err.Error()}, 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) + } +} + +// 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: the group of mesh-sdk and mesh-catalog adding and using a field, without a controller member, fails +// naming the field and mesh-controller, whose running build pins an older mesh-sdk; with a controller member pinned to +// the SDK head (its composer reads what the head reads) it passes. +func TestAFieldTheRunningControllerCannotReadFailsTheGroup(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) + } + old := run(t, rjob, nil) // the running controller's mesh-sdk reads web2 as the assigner's own does: refused + v := FromComposer(digestedMesh(), c, head, []Reader{{Module: "mesh-controller", Commit: running, Ran: old}}, 0) + if v.State != delivery.CompositionFail || !strings.Contains(v.Words, "shares") || !strings.Contains(v.Words, "move mesh-controller onto mesh-sdk "+sdkHead) { + t.Fatalf("without a controller member: %+v, want a fail naming 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", "gone": true}`} + 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) + } +} From 93427c08e74737acb0c0afdabed6a28320aa6625 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 03:57:51 +0200 Subject: [PATCH 3/9] Ask the Go build seat for the composer at a group's mesh-sdk head and at its readers' mesh-sdk, and follow each step (issues 1622, 1623) --- .../cmd/mesh-delivery-assigner/asks.go | 161 ++++++++++++++++ .../compose_integration_test.go | 180 ++++++++++++++++++ .../integration_test.go | 13 ++ .../cmd/mesh-delivery-assigner/main.go | 11 +- .../cmd/mesh-delivery-assigner/pass.go | 12 +- .../cmd/mesh-delivery-assigner/world.go | 36 +++- .../mesh-delivery-assigner/manifest_test.go | 6 +- modules/mesh-delivery-assigner/module.json | 4 + 8 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/asks.go create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/compose_integration_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 new file mode 100644 index 0000000..6fbe713 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/asks.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "sort" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" + + "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(ctx, kvGetter(p.kvs[delivery.BuildsBucket]), module) + } + if len(commits) == 0 { + notJudged = append(notJudged, "not judged whether "+module+" reads the group's manifests: no build of it is assigned with a "+ + "commit on record, 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 new file mode 100644 index 0000000..8bc0662 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/compose_integration_test.go @@ -0,0 +1,180 @@ +//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" + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// 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 +// controller 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.putJSON(t, provider.AssignmentsBucket, "g14.web", []byte(`{"node":"g14","module":"web","build":"build-1"}`)) + running := strings.Repeat("3", 40) + b.putJSON(t, provider.AssignmentsBucket, "novox.mesh-controller", []byte(`{"node":"novox","module":"mesh-controller","build":"c-1"}`)) + b.putJSON(t, delivery.BuildsBucket, delivery.BuildKey("mesh-controller", "c-1"), []byte(`{"id":"c-1","repository":"novox/mesh-controller","commit":"`+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-controller at "+running) { + t.Fatalf("the waiting composition says %q, want the head's and the running controller'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 controller)", 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-controller"]; a.Compose == nil || a.Compose.Fetch["commit"] != running || a.Compose.Dir != delivery.RootPath { + t.Errorf("the running controller'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/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index f04d60c..d017c8d 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -73,6 +73,12 @@ func startBed(t *testing.T, leaveOut string) *bed { if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil { t.Fatal(err) } + // The Go build seat's work queue, as the broker derives it from the seat's accepted kinds (delivery.AskStream). + seat, _ := delivery.AskStream(delivery.AskGo) + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: seat, Subjects: []string{"mesh.seat.node-build-go.accept.>"}, + Retention: jetstream.WorkQueuePolicy}); err != nil { + t.Fatal(err) + } return b } @@ -213,6 +219,13 @@ func TestAChangeIsComposedOnTheBus(t *testing.T) { if took := time.Since(began); took > 5*time.Second { t.Errorf("the composition took %s", took) } + // 1622/AC3: a group without a mesh-sdk member builds no composer: no ask is put on the Go build seat. + seat, _ := delivery.AskStream(delivery.AskGo) + if st, err := b.js.Stream(b.ctx, seat); err != nil { + t.Fatal(err) + } else if info, err := st.Info(b.ctx); err != nil || info.State.Msgs != 0 { + t.Errorf("a group without a mesh-sdk member put %d asks on the Go build seat (%v)", info.State.Msgs, err) + } } // get waits for a key of one of the bed's buckets and answers its value. diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go index 8900bd8..0ca3642 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -22,6 +22,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/provider" ) @@ -47,8 +48,13 @@ var readBuckets = []string{ delivery.ModulesBucket, delivery.PresenceBucket, delivery.HaveBucket, + delivery.StepsBucket(goBuildModule), } +// goBuildModule is the Go build module, which takes the assigner's compose asks on its seat's check stream and writes +// their steps in its steps state (novox/hq ADR 0482 clause 4). +const goBuildModule = "mesh-build-go" + // ownBuckets are the assigner's own states, as its manifest declares them. var ownBuckets = []string{delivery.AssignerAsksBucket, delivery.AssignerCompositionsBucket, delivery.AssignerDifferencesBucket, delivery.AssignerWantsBucket} @@ -124,7 +130,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro logger.Printf("what the assigner keeps could not be said on the bus, so its readers are not granted until it is: %v", err) } logger.Printf("started on the account %s, reading %d buckets", account.User, len(kvs)) - return follow(ctx, kvs, logger) + return follow(ctx, js, kvs, logger) } // watched are the buckets whose every value a pass reads; the manifests are read by digest when a pass needs one, and @@ -145,7 +151,7 @@ var watched = []string{ // follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after // each change; changes that come during a pass make one more. It returns when ctx ends or a watch ends. -func follow(ctx context.Context, kvs buckets, logger *log.Logger) error { +func follow(ctx context.Context, js jetstream.JetStream, kvs buckets, logger *log.Logger) error { w := newWorld() dirty := make(chan struct{}, 1) wake := func() { @@ -210,6 +216,7 @@ func follow(ctx context.Context, kvs buckets, logger *log.Logger) error { } } p := &passer{kvs: kvs, w: w, logf: logger.Printf} + p.asks = newComposeAsks(ctx, buildrun.Asker{Publisher: buildrun.JetStreamAsks{JS: js}, Waiter: buildrun.JetStreamAsks{JS: js}}, wake, logger.Printf) wake() for { select { diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index a626f95..c5855fd 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -19,6 +19,7 @@ type passer struct { w *world logf func(string, ...any) sdk string + asks *composeAsks } func (p *passer) pass(ctx context.Context) { @@ -162,6 +163,10 @@ func (p *passer) compositions(ctx context.Context) { written := p.w.read(delivery.AssignerCompositionsBucket) trunk := p.w.trunk() var mesh *assign.Mesh + seen := map[string]bool{} + if p.asks != nil { + defer p.asks.sweep(seen) + } for key, value := range p.w.read(delivery.CandidatesBucket) { c, err := delivery.DecodeCandidate(value) if err != nil { @@ -183,7 +188,12 @@ func (p *passer) compositions(ctx context.Context) { m := p.w.mesh(ctx, kvGetter(p.kvs[delivery.ManifestsBucket])) mesh = &m } - v = assign.Compose(*mesh, ch, p.sdk, time.Since(began)) + if ch.SDKHead != "" && p.asks != nil { + // A group with a mesh-sdk member is composed by the composer built at its head (ADR 0482 clauses 3 to 5). + v = p.composeAtHead(ctx, *mesh, ch, seen, began) + } else { + v = assign.Compose(*mesh, ch, p.sdk, time.Since(began)) + } } if before, err := delivery.DecodeCompositionVerdict(written[out]); err == nil { before.Took, before.V = v.Took, 0 diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index fcd91d8..2ea9b7c 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -193,12 +193,13 @@ func (w *world) trunk() map[string]delivery.Module { // mesh is the mesh as the trunk holds it, for the composition check. func (w *world) mesh(ctx context.Context, manifests getter) assign.Mesh { - m := assign.Mesh{Manifests: map[string]catalogue.Manifest{}, Unreadable: map[string]string{}} + m := assign.Mesh{Manifests: map[string]catalogue.Manifest{}, Unreadable: map[string]string{}, Digests: map[string]string{}} m.Assigned, _ = w.assigned() for name, mod := range w.trunk() { if mod.Manifest == "" { continue } + m.Digests[name] = mod.Manifest p := w.manifest(ctx, manifests, mod.Manifest) if p.err != "" { m.Unreadable[name] = p.err @@ -211,12 +212,14 @@ func (w *world) mesh(ctx context.Context, manifests getter) assign.Mesh { // change is a candidate's change for the composition check, or why the composition waits for it. func (w *world) change(ctx context.Context, manifests getter, c delivery.Candidate, trunk map[string]delivery.Module) (assign.Change, string) { - ch := assign.Change{Group: c.Group, Heads: c.HeadsHash, Manifests: map[string]catalogue.Manifest{}, Unreadable: map[string]string{}, Twice: map[string][]string{}} + ch := assign.Change{Group: c.Group, Heads: c.HeadsHash, Manifests: map[string]catalogue.Manifest{}, Unreadable: map[string]string{}, + Twice: map[string][]string{}, Digests: map[string]string{}, Members: map[string]string{}} definedIn := map[string]map[string]bool{} for _, p := range c.Pulls { if p.Repository == assign.SDKRepository { ch.SDKHead = p.Head.Head } + ch.Members[p.Repository] = p.Head.Head for _, name := range p.Modules { fp, ok := p.Fps[name] if !ok || fp.Manifest == "" { @@ -233,6 +236,7 @@ func (w *world) change(ctx context.Context, manifests getter, c delivery.Candida if strings.HasPrefix(got.err, "the indexer holds no manifest") { return ch, got.err } + ch.Digests[name] = fp.Manifest if got.err != "" { ch.Unreadable[name] = got.err continue @@ -368,3 +372,31 @@ func (w *world) sentFP(ctx context.Context, get getter, module, build string) st w.mu.Unlock() return rec.SourceFingerprint } + +// runningCommits is, of every open assignment of module, the commit of the build it names, read from the controller's +// build record (`mesh-controller_builds` `.build.`), distinct and sorted: the commits a reader of the +// manifests runs at (novox/hq ADR 0482 clause 5, issue 1623). A build whose record names no full commit is left out. +func (w *world) runningCommits(ctx context.Context, get getter, module string) []string { + _, all := w.assigned() + seen := map[string]bool{} + for _, a := range all { + if a.Module != module || a.Build == "" { + continue + } + value, err := get(ctx, delivery.BuildKey(module, a.Build)) + if err != nil { + continue + } + var rec delivery.BuildRecord + if json.Unmarshal(value, &rec) != nil || len(rec.Commit) != 40 { + continue + } + seen[rec.Commit] = true + } + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + sort.Strings(out) + return out +} diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index 03b879a..db46536 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -32,7 +32,7 @@ func TestManifest(t *testing.T) { } reads := slices.Clone(m.Reads) slices.Sort(reads) - want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-controller.grants", + want := []string{"mesh-build-go.steps", "mesh-controller.assignments", "mesh-controller.builds", "mesh-controller.grants", "mesh-delivery-builder.builds", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-watchman.presence", "node-engine.have"} if !reflect.DeepEqual(reads, want) { @@ -51,6 +51,10 @@ func TestManifest(t *testing.T) { if m.SecretsOwner != "mesh-delivery-assigner" { t.Errorf("secrets-owner = %q", m.SecretsOwner) } + // 1622: the assigner puts its compose asks on the Go build seat, as the checker puts its checks. + if !slices.Equal(m.Uses, []string{"node-build-go"}) { + t.Errorf("uses = %v, want exactly node-build-go", m.Uses) + } if len(m.Claims) != 0 || len(m.Tools) != 0 || len(m.Invokes) != 0 { t.Errorf("the assigner claims, serves or calls something: claims %v, tools %v, invokes %v", m.Claims, m.Tools, m.Invokes) } diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 418e8fb..b07feba 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -3,6 +3,7 @@ "version": "1", "slug": "assigner", "reads": [ + "mesh-build-go.steps", "mesh-controller.assignments", "mesh-controller.builds", "mesh-controller.grants", @@ -13,6 +14,9 @@ "mesh-delivery-watchman.presence", "node-engine.have" ], + "uses": [ + "node-build-go" + ], "state": [ { "name": "asks", "history": 5 }, { "name": "compositions", "history": 5 }, From c256d732f4ca9939f784b7cf707fccd6707fa9fc Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 04:14:33 +0200 Subject: [PATCH 4/9] Pin the assigner to mesh-sdk's head that reads a composer request from a file (issue 1622) --- modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 7beb07e..892c1d3 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index e524e7f..8bc73fe 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce h1:9pNnygyhuYIpVs0HPzgheGLNjPXigGGQpnFTp8lw+X4= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721 h1:N//dDGmry1F3HKeBTWd+z/SfK+wb8bUUMJF0OlgR0/g= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= From 4702c6bd776610b15d588f9437ea9dd2a0b1fe54 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 04:28:15 +0200 Subject: [PATCH 5/9] Pin to the finish group's mesh-sdk head dd0d156b, so every member names one SDK (issue 1622) --- modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 892c1d3..0b357b1 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024022512-dd0d156b9edc github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 8bc73fe..9779a79 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce h1:9pNnygyh git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721 h1:N//dDGmry1F3HKeBTWd+z/SfK+wb8bUUMJF0OlgR0/g= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024022512-dd0d156b9edc h1:MSywVviXmT3bR2pQLJLHErUGK8wCme8jEspS6x/j/Vo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024022512-dd0d156b9edc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= From 2259d98c5b34ff8280752cd6a79c90956298897c Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 04:39:03 +0200 Subject: [PATCH 6/9] Mark the packages whose tests read files outside their directory, found by the review (issue 1935) --- modules/mesh-delivery-builder/reads-outside | 1 + modules/mesh-delivery-checker/reads-outside | 1 + modules/mesh-delivery-forge-bridge/reads-outside | 1 + modules/mesh-delivery-indexer/reads-outside | 1 + modules/mesh-delivery-teller/reads-outside | 1 + modules/mesh-delivery-watchman/reads-outside | 1 + 6 files changed, 6 insertions(+) create mode 100644 modules/mesh-delivery-builder/reads-outside create mode 100644 modules/mesh-delivery-checker/reads-outside create mode 100644 modules/mesh-delivery-forge-bridge/reads-outside create mode 100644 modules/mesh-delivery-indexer/reads-outside create mode 100644 modules/mesh-delivery-teller/reads-outside create mode 100644 modules/mesh-delivery-watchman/reads-outside diff --git a/modules/mesh-delivery-builder/reads-outside b/modules/mesh-delivery-builder/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/modules/mesh-delivery-builder/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/modules/mesh-delivery-checker/reads-outside b/modules/mesh-delivery-checker/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/modules/mesh-delivery-checker/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/modules/mesh-delivery-forge-bridge/reads-outside b/modules/mesh-delivery-forge-bridge/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/modules/mesh-delivery-forge-bridge/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/modules/mesh-delivery-indexer/reads-outside b/modules/mesh-delivery-indexer/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/modules/mesh-delivery-indexer/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/modules/mesh-delivery-teller/reads-outside b/modules/mesh-delivery-teller/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/modules/mesh-delivery-teller/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/modules/mesh-delivery-watchman/reads-outside b/modules/mesh-delivery-watchman/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/modules/mesh-delivery-watchman/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). From e78bbfbdee81a9c452579267a33ff523a6c5d230 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 13:28:42 +0200 Subject: [PATCH 7/9] Pin the assigner to the group's mesh-sdk head after main's merge, so it composes with the SDK the group delivers --- modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 0b357b1..dac347a 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024022512-dd0d156b9edc + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024112544-bc0c25e51052 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 9779a79..39a02b1 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,11 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce h1:9pNnygyhuYIpVs0HPzgheGLNjPXigGGQpnFTp8lw+X4= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721 h1:N//dDGmry1F3HKeBTWd+z/SfK+wb8bUUMJF0OlgR0/g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024022512-dd0d156b9edc h1:MSywVviXmT3bR2pQLJLHErUGK8wCme8jEspS6x/j/Vo= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024022512-dd0d156b9edc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024112544-bc0c25e51052 h1:iPXa9g9QtywV2DHdtTFF4jHae5aKgCv+lCYdilRbp5k= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024112544-bc0c25e51052/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= From f5fd74380cc458a96ba5f8d2f0c0d78fa4bf0d0f Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 14:12:10 +0200 Subject: [PATCH 8/9] Pin to the group's new mesh-sdk head 19c1d359, whose composer names the reader's pinned SDK instead of a bare (devel) (ADR 0482 clause 5) --- modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index dac347a..ae9ac5c 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024112544-bc0c25e51052 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024121120-19c1d35939bc github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 39a02b1..efe02e3 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024112544-bc0c25e51052 h1:iPXa9g9QtywV2DHdtTFF4jHae5aKgCv+lCYdilRbp5k= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024112544-bc0c25e51052/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024121120-19c1d35939bc h1:aOONENGflAq/LJPQAcSKrv/EnFBfpNq0tixTirQitn8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024121120-19c1d35939bc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= From 1d4dcdf41a2228377b5c195bc1d333913ce55cbd Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 14:57:36 +0200 Subject: [PATCH 9/9] =?UTF-8?q?Read=20a=20change's=20manifest=20as=20the?= =?UTF-8?q?=20indexer=20reads=20it,=20so=20a=20fallback=20to=20a=20predefi?= =?UTF-8?q?ned=20key=20is=20not=20unreadable=20for=20the=20catalogue's=20p?= =?UTF-8?q?redefined.json=20the=20assigner=20never=20holds=20(ADR=200348?= =?UTF-8?q?=20=C2=A77,=20issue=201751);=20pin=20mesh-sdk=20bfc3883a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catalogue-835bf22b-xterm.module.json | 236 ++++++++++++++++++ .../cmd/mesh-delivery-assigner/world.go | 4 +- .../cmd/mesh-delivery-assigner/world_test.go | 23 ++ modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 4 +- 5 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/testdata/catalogue-835bf22b-xterm.module.json diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/testdata/catalogue-835bf22b-xterm.module.json b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/testdata/catalogue-835bf22b-xterm.module.json new file mode 100644 index 0000000..b65be8e --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/testdata/catalogue-835bf22b-xterm.module.json @@ -0,0 +1,236 @@ +{ + "module": "xterm", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-terminal-emulator", + "scope": "node", + "serves": [ + "open" + ] + } + ], + "tools": [ + "xterm_font", + "xterm_colours" + ], + "config": { + "face": { + "type": "string", + "description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).", + "pattern": "[A-Za-z0-9 ._-]{1,64}", + "default": "JetBrainsMono Nerd Font" + }, + "size": { + "type": "number", + "description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.", + "minimum": 6, + "maximum": 72, + "predefined": "font-size", + "default": 12 + }, + "save-lines": { + "type": "number", + "description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.", + "minimum": 0, + "maximum": 1000000, + "default": 10000 + }, + "term-name": { + "type": "string", + "description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.", + "pattern": "[A-Za-z0-9._-]{1,64}", + "default": "xterm-256color" + }, + "render-font": { + "type": "boolean", + "description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.", + "default": true + }, + "bold-mode": { + "type": "boolean", + "description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.", + "default": false + }, + "always-bold-mode": { + "type": "boolean", + "description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.", + "default": false + }, + "allow-bold-fonts": { + "type": "boolean", + "description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.", + "default": true + }, + "background": { + "type": "string", + "description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#000000" + }, + "foreground": { + "type": "string", + "description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#a8a8a8" + }, + "color0": { + "type": "string", + "description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#000000" + }, + "color1": { + "type": "string", + "description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#9e1828" + }, + "color2": { + "type": "string", + "description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#008800" + }, + "color3": { + "type": "string", + "description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#d2bb4b" + }, + "color4": { + "type": "string", + "description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#414171" + }, + "color5": { + "type": "string", + "description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#963c59" + }, + "color6": { + "type": "string", + "description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#418179" + }, + "color7": { + "type": "string", + "description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#bebebe" + }, + "color8": { + "type": "string", + "description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#666666" + }, + "color9": { + "type": "string", + "description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#bc5766" + }, + "color10": { + "type": "string", + "description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#61a171" + }, + "color11": { + "type": "string", + "description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#e7db52" + }, + "color12": { + "type": "string", + "description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#5085af" + }, + "color13": { + "type": "string", + "description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#a97a99" + }, + "color14": { + "type": "string", + "description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#6ba4a4" + }, + "color15": { + "type": "string", + "description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#ffffff" + }, + "copy-binding": { + "type": "string", + "description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.", + "pattern": "[^:\\\\\\n]+", + "default": "Ctrl Shift ~Alt C" + }, + "paste-binding": { + "type": "string", + "description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.", + "pattern": "[^:\\\\\\n]+", + "default": "Ctrl Shift ~Alt V" + } + }, + "items": [ + { + "collection": "environment", + "name": "terminal", + "attributes": { + "kind": "variable", + "name": "TERMINAL" + }, + "template": "files/env-terminal" + }, + { + "collection": "shell:xresources:normal", + "name": "xresources-normal", + "template": "files/xresources-normal" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "xterm" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/xterm-tools", + "binary": "xterm-tools", + "loads": [ + "xterm-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index 2ea9b7c..fb49934 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -148,7 +148,9 @@ func (w *world) manifest(ctx context.Context, get getter, digest string) parsed m, err := delivery.DecodeManifest(value) if err != nil { p = parsed{err: err.Error()} - } else if man, err := catalogue.ParseManifest([]byte(m.JSON)); err != nil { + } else if man, err := catalogue.ParseManifestForInputs([]byte(m.JSON)); err != nil { + // Read as the indexer reads it (issue 1751): a value falling back to a predefined key is judged by the merge gate + // in the catalogue, against the predefined.json at its root that this process never holds (ADR 0348 §7). p = parsed{module: m.Module, err: err.Error()} } else { p = parsed{module: m.Module, manifest: man} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go index ee3a8e3..5211c7a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "os" "slices" "strings" "testing" @@ -88,3 +89,25 @@ func TestAChangeWaitsForItsManifests(t *testing.T) { t.Errorf("an unreadable manifest: waits %q, change %+v", waits, ch) } } + +// A manifest of the change whose config value falls back to a predefined key (novox/hq ADR 0348 §7) is read, not +// unreadable for the catalogue's predefined.json the assigner never holds: the merge gate judges the fallback in the +// catalogue, and the indexer reads it the same way (catalogue.ParseManifestForInputs, issue 1751). A group whose +// mesh-catalog member touched pacman and xterm failed its composition on this (group check 18df81380db3). The manifest is +// xterm's module.json from mesh-catalog at 835bf22b. +func TestAManifestFallingBackToAPredefinedKeyIsReadable(t *testing.T) { + raw, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"predefined"`) { + t.Fatal("the fixture names no predefined key") + } + f := fakeManifests{} + xterm := f.add(t, "xterm", string(raw)) + ch, waits := newWorld().change(context.Background(), f.get, delivery.Candidate{Group: "g", HeadsHash: "0123456789ab", + Pulls: []delivery.CandidatePull{pull("novox/mesh-catalog", 1, map[string]delivery.CandidateModule{"xterm": {FP2: fp('b'), Manifest: xterm}})}}, nil) + if waits != "" || ch.Unreadable["xterm"] != "" || ch.Manifests["xterm"].Module != "xterm" { + t.Errorf("xterm: waits %q, unreadable %q, want read", waits, ch.Unreadable["xterm"]) + } +} diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index ae9ac5c..6d197f7 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024121120-19c1d35939bc + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024125710-bfc3883af980 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index efe02e3..0688b07 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024121120-19c1d35939bc h1:aOONENGflAq/LJPQAcSKrv/EnFBfpNq0tixTirQitn8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024121120-19c1d35939bc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024125710-bfc3883af980 h1:ZEEeYibEPD1rngOAqbODzRY5rUStGC46OpyQwtNCYbs= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024125710-bfc3883af980/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=