diff --git a/.gitignore b/.gitignore index 952eee66..6f3d049b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,6 @@ modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image /modules/mesh-build-go/mesh-build-go /modules/mesh-delivery-gate/mesh-delivery-gate modules/mesh-config/cmd/mesh-config/mesh-config -checks/placement/placement # Built by their modules' build step (issue 1577), never committed. modules/forticlient/cmd/forticlient-tools/forticlient-tools diff --git a/checks/noplacement/go.mod b/checks/noplacement/go.mod new file mode 100644 index 00000000..a09ac5ec --- /dev/null +++ b/checks/noplacement/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/noplacement + +go 1.22 diff --git a/checks/noplacement/main.go b/checks/noplacement/main.go new file mode 100644 index 00000000..3de240c4 --- /dev/null +++ b/checks/noplacement/main.go @@ -0,0 +1,99 @@ +// Command noplacement refuses what the repository path of placement left behind (novox/hq ADR 0567 §10, §12). +// +// Placement, which module runs on which node, is bus state held by the seat mesh-placement; no file of the +// catalogue says it, and no manifest reads it from the delivery indexer, which no longer keeps it. It refuses: +// +// 1. a `placement` directory (or file) at the root of the catalogue; +// +// 2. a `particulars` file at the root (the mesh's node names come from the checker's run, §16); +// +// 3. a manifest naming a state, stream, event or bucket of the indexer's placement (`mesh-delivery-indexer.placement…` +// or `mesh-delivery-indexer_placement…`) in any string value, such as `reads`. +// +// go run . +// +// Exits 1 on a finding. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Prefixes are the names of the indexer's placement parts; a manifest string starting with one names them. +var Prefixes = []string{"mesh-delivery-indexer.placement", "mesh-delivery-indexer_placement"} + +// Findings is every refusal in the catalogue at root, sorted. +func Findings(root string) ([]string, error) { + var out []string + if _, err := os.Lstat(filepath.Join(root, "placement")); err == nil { + out = append(out, "placement: the repository holds no placement; it is bus state of the seat mesh-placement (novox/hq ADR 0567 §10)") + } + if _, err := os.Lstat(filepath.Join(root, "particulars")); err == nil { + out = append(out, "particulars: the repository lists no node names; the checker hands them to the check (novox/hq ADR 0567 §16)") + } + manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + sort.Strings(manifests) + for _, file := range manifests { + raw, err := os.ReadFile(file) + if err != nil { + return nil, err + } + var doc any + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("%s: %w", file, err) + } + module := filepath.Base(filepath.Dir(file)) + for _, s := range named(doc, nil) { + out = append(out, fmt.Sprintf("%s: names %q, which the indexer no longer keeps; placement is the seat mesh-placement's (novox/hq ADR 0567 §12)", module, s)) + } + } + return out, nil +} + +// named collects the strings of a decoded manifest that start with one of the Prefixes. +func named(v any, into []string) []string { + switch v := v.(type) { + case string: + for _, p := range Prefixes { + if strings.HasPrefix(v, p) { + return append(into, v) + } + } + case []any: + for _, e := range v { + into = named(e, into) + } + case map[string]any: + for _, e := range v { + into = named(e, into) + } + } + return into +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: noplacement ") + os.Exit(2) + } + found, err := Findings(os.Args[1]) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + for _, f := range found { + fmt.Println(f) + } + if len(found) > 0 { + os.Exit(1) + } + fmt.Println("no placement directory, and no manifest reads the indexer's placement") +} diff --git a/checks/noplacement/main_test.go b/checks/noplacement/main_test.go new file mode 100644 index 00000000..0ffc320f --- /dev/null +++ b/checks/noplacement/main_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFindingsRefuseWhatThePlacementPathLeft(t *testing.T) { + for _, row := range []struct { + name string + files map[string]string + want string // empty: clean + }{ + {"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-controller.assignments"]}`}, ""}, + {"a placement directory", map[string]string{"placement/ace.json": `{}`}, "placement: the repository holds no placement"}, + {"a particulars file", map[string]string{"particulars": "ace\n"}, "particulars: the repository lists no node names"}, + {"a read of the indexer's placement", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-indexer.placement"]}`}, `a: names "mesh-delivery-indexer.placement"`}, + {"a read of its candidates", map[string]string{"modules/b/module.json": `{"module":"b","reads":["mesh-delivery-indexer.placement-candidates"]}`}, "placement-candidates"}, + {"its event", map[string]string{"modules/c/module.json": `{"module":"c","on":[{"event":"mesh-delivery-indexer.placement-changed"}]}`}, "placement-changed"}, + {"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-placement.placement"]}`}, ""}, + } { + t.Run(row.name, func(t *testing.T) { + root := t.TempDir() + for path, text := range row.files { + p := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + got, err := Findings(root) + if err != nil { + t.Fatal(err) + } + if all := strings.Join(got, "\n"); (row.want == "") != (all == "") || !strings.Contains(all, row.want) { + t.Fatalf("findings = %q, want one containing %q", all, row.want) + } + }) + } +} + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2). +func TestTheCatalogueHoldsNoPlacementOfTheRepositoryPath(t *testing.T) { + got, err := Findings(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + for _, f := range got { + t.Error(f) + } +} diff --git a/checks/noplacement/reads-outside b/checks/noplacement/reads-outside new file mode 100644 index 00000000..2fc76de8 --- /dev/null +++ b/checks/noplacement/reads-outside @@ -0,0 +1,6 @@ +# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole; +# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept +# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing. +# the root, for a placement directory, and every manifest, for a read of the indexer's placement states. +?placement +modules/*/module.json diff --git a/checks/particulars/main.go b/checks/particulars/main.go index 5203f1f6..e2f6d32e 100644 --- a/checks/particulars/main.go +++ b/checks/particulars/main.go @@ -2,8 +2,9 @@ // issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or // fixture of a covered module names a node of this one. // -// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place -// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched +// The names are the mesh's own, not the repository's: the checker hands them in the environment value +// MESH_NODE_NAMES, comma-separated, from the node records it reads (novox/hq ADR 0567 §16). A run with none fails +// loudly, since it would pass on anything. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched // whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word. // The forge is not a node name: its host (git.novox.be) and the organisation path (novox/) are blanked // before a line is read. @@ -34,14 +35,28 @@ func main() { if len(os.Args) > 1 { root = os.Args[1] } - os.Exit(check(root)) + os.Exit(check(root, namesFromEnv())) +} + +// NamesEnv is the environment value that carries the mesh's node names (mesh-sdk delivery.NodeNamesEnv). +const NamesEnv = "MESH_NODE_NAMES" + +// namesFromEnv is the names NamesEnv holds, comma-separated, trimmed, empty entries left out. +func namesFromEnv() []string { + var out []string + for _, n := range strings.Split(os.Getenv(NamesEnv), ",") { + if n = strings.TrimSpace(n); n != "" { + out = append(out, n) + } + } + return out } // check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable. -func check(root string) int { - names, err := readList(filepath.Join(root, "particulars")) - if err == nil && len(names) == 0 { - err = fmt.Errorf("particulars lists no name — the check would pass on anything") +func check(root string, names []string) int { + var err error + if len(names) == 0 { + err = fmt.Errorf("no node names (%s is empty) — the check would pass on anything", NamesEnv) } var covered []string if err == nil { diff --git a/checks/particulars/main_test.go b/checks/particulars/main_test.go index f348a703..0395aadc 100644 --- a/checks/particulars/main_test.go +++ b/checks/particulars/main_test.go @@ -74,26 +74,24 @@ func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) { } } -func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) { +func TestCheckReturnsOneOnAFindingAndTwoOnNoNames(t *testing.T) { root := t.TempDir() write := func(p, s string) { p = filepath.Join(root, p) os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte(s), 0o644) } - write("particulars", "ace\n") write("checks/particulars/covered", "kernel\n") write("modules/kernel/a", "clean\n") - if got := check(root); got != 0 { + if got := check(root, []string{"ace"}); got != 0 { t.Errorf("clean = %d, want 0", got) } write("modules/kernel/a", "on ace\n") - if got := check(root); got != 1 { + if got := check(root, []string{"ace"}); got != 1 { t.Errorf("finding = %d, want 1", got) } - os.Remove(filepath.Join(root, "particulars")) - if got := check(root); got != 2 { - t.Errorf("no list = %d, want 2", got) + if got := check(root, nil); got != 2 { + t.Errorf("no names = %d, want 2", got) } } @@ -117,7 +115,10 @@ func TestOnlyCoveredModulesAreRead(t *testing.T) { // The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no node name of this mesh in a covered module. func TestTheCoveredModulesNameNoNode(t *testing.T) { - if code := check("../.."); code != 0 { + if _, set := os.LookupEnv(NamesEnv); !set { + t.Skip("ADR 0567 §16: this checker hands no node names (before D14 is delivered); the skip goes once it is") + } + if code := check("../..", namesFromEnv()); code != 0 { t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code) } } diff --git a/checks/particulars/reads-outside b/checks/particulars/reads-outside index c2a9b667..bcc81106 100644 --- a/checks/particulars/reads-outside +++ b/checks/particulars/reads-outside @@ -1,6 +1,5 @@ # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole; # ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept # until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing. -# check("../.."): the root's list and every file of the modules it names. -particulars +# check("../.."): every file of the modules the covered list names. The node names come from the run (MESH_NODE_NAMES). modules diff --git a/checks/placement/go.mod b/checks/placement/go.mod deleted file mode 100644 index 950a8a23..00000000 --- a/checks/placement/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module checks/placement - -go 1.26.0 - -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f diff --git a/checks/placement/go.sum b/checks/placement/go.sum deleted file mode 100644 index dacf1a77..00000000 --- a/checks/placement/go.sum +++ /dev/null @@ -1,18 +0,0 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117 h1:88CNZOoQgu7YPYKW16qkWT8Gw9Ucg07aTrIImA2nYUk= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f h1:AKzV973UTWe0OYFoIjgGu0f+B26xh8QsAHzCpsTIjM8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f h1:mimAGERguQmCGYVNdRTrFcZ1dhpU6GWo+Q3EDhHOrVk= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= -github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= -github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= -github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= -github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= -github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/checks/placement/main.go b/checks/placement/main.go deleted file mode 100644 index 659edcc6..00000000 --- a/checks/placement/main.go +++ /dev/null @@ -1,80 +0,0 @@ -// Command placement is the catalogue's check of its placement files (novox/hq ADR 0549 §1, ADR 0555; issue 1126): every -// `placement/.json` reads as a placement of the node its name says, and the files together are refused, every -// finding named at once, for two modules of one node on one host port, a pin, binding or holding naming a provider not -// placed where it says, a seat held once for the mesh held twice, two nodes with one address on the private network, or -// more than one hub (mesh-sdk delivery.CheckPlacements). -// -// A module the catalogue does not hold and a node not enrolled are the composition check's to refuse: placement names -// modules of every repository the indexer reads and the nodes the mesh enrolled, neither of which this tree holds. -// -// go run . -// -// Exits 1 on a finding. -package main - -import ( - "fmt" - "os" - "path/filepath" - "sort" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Check reads every placement file under root and answers each finding; none is a catalogue whose placement holds. A -// catalogue with no placement directory has none to check. -func Check(root string) ([]string, error) { - dir := filepath.Join(root, delivery.PlacementDir) - entries, err := os.ReadDir(dir) - if os.IsNotExist(err) { - return nil, nil - } - if err != nil { - return nil, err - } - var found []string - placements := map[string]delivery.Placement{} - for _, e := range entries { - rel := delivery.PlacementDir + "/" + e.Name() - node, ok := delivery.PlacementFileNode(rel) - if e.IsDir() || !ok { - found = append(found, fmt.Sprintf("%s is not a placement file: the directory holds only .json, one per node", rel)) - continue - } - raw, err := os.ReadFile(filepath.Join(dir, e.Name())) - if err != nil { - return nil, err - } - p, err := delivery.DecodePlacement(raw) - if err != nil { - found = append(found, fmt.Sprintf("%s does not read as a placement: %v", rel, err)) - continue - } - if p.Revision != "" { - found = append(found, fmt.Sprintf("%s names a revision, which only the indexer writes into the key", rel)) - } - placements[node] = p - } - all := func(string) bool { return true } - found = append(found, delivery.CheckPlacements(placements, all, all)...) - sort.Strings(found) - return found, nil -} - -func main() { - if len(os.Args) != 2 { - fmt.Fprintln(os.Stderr, "usage: placement ") - os.Exit(2) - } - found, err := Check(os.Args[1]) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(2) - } - for _, f := range found { - fmt.Println(f) - } - if len(found) > 0 { - os.Exit(1) - } -} diff --git a/checks/placement/main_test.go b/checks/placement/main_test.go deleted file mode 100644 index 88e45aca..00000000 --- a/checks/placement/main_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// The catalogue's placement files as the merge check reads them (ADR 0549 §1, ADR 0555): the real tree holds every -// rule, and a tree that breaks them is refused with every finding named at once. -func TestPlacementFiles(t *testing.T) { - found, err := Check("../..") - if err != nil { - t.Fatal(err) - } - for _, f := range found { - t.Errorf("%s (novox/hq ADR 0549 §1)", f) - } - - root := t.TempDir() - write := func(name, body string) { - t.Helper() - if err := os.MkdirAll(filepath.Join(root, "placement"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "placement", name), []byte(body), 0o644); err != nil { - t.Fatal(err) - } - } - if found, err := Check(root); err != nil || len(found) != 0 { - t.Fatalf("a catalogue with no placement directory: %v, %v", found, err) - } - write("ace.json", `{"v":1,"node":"ace","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"a","ports":{"80":8080}},{"module":"b","ports":{"81":8080}}]}`) - write("g14.json", `{"v":1,"node":"g14","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"c","bindings":{"pg":{"node":"ace","module":"pg"}}}]}`) - write("novox.json", `{"v":1,"node":"shanks","modules":[]}`) - write("bad.json", `{"v":1,"node":"bad","modules":{}}`) - write("dated.json", `{"v":1,"node":"dated","revision":"abc","modules":[]}`) - write("notes.md", "x") - found, err = Check(root) - if err != nil { - t.Fatal(err) - } - for _, want := range []string{"a and b are both published on host port 8080", "bound to ace/pg", "both have the address 10.77.0.1", - "ace, g14 are each placed as it", "placement file of novox names the node", "bad.json does not read", "dated.json names a revision", - "notes.md is not a placement file"} { - hit := false - for _, f := range found { - hit = hit || strings.Contains(f, want) - } - if !hit { - t.Errorf("no finding says %q; found %v", want, found) - } - } -} diff --git a/checks/placement/reads-outside b/checks/placement/reads-outside deleted file mode 100644 index f6b2d871..00000000 --- a/checks/placement/reads-outside +++ /dev/null @@ -1 +0,0 @@ -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/gitea/client.ts b/modules/gitea/client.ts index 8c022545..14ced5b1 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -560,7 +560,7 @@ export class GiteaClient { * **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages * run past a bound): the call fails naming the repository and the commit, never answering the directories it did * see, since a directory left out would be taken for one without a module. Reads only. */ - async commitTrees(owner: string, repo: string, sha: string, paths?: Record, listed?: string[]): Promise<{ root: string; trees: Record; paths?: Record>; listed?: Record> }> { + async commitTrees(owner: string, repo: string, sha: string, paths?: Record): Promise<{ root: string; trees: Record; paths?: Record> }> { const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`; const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`); const entries: any[] = []; @@ -587,23 +587,7 @@ export class GiteaClient { if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`); trees[dir] = tree; } - // **Listed directories** (novox/hq ADR 0549 §2, issue 1126): each asked directory's files directly under it, by name - // to blob id, from the same listing: how the forge bridge finds the catalogue's placement files, one per node, at a - // merge. A directory the commit lacks lists nothing (the catalogue before its first placement file). - let files: Record> | undefined; - if (listed !== undefined) { - files = {}; - for (const dir of listed) { - files[dir] = {}; - const prefix = dir === "" ? "" : `${dir}/`; - for (const e of entries) { - if (e?.type !== "blob" || typeof e.path !== "string" || !e.path.startsWith(prefix)) continue; - const name = e.path.slice(prefix.length); - if (name !== "" && !name.includes("/")) files[dir][name] = String(e.sha); - } - } - } - if (paths === undefined) return files === undefined ? { root, trees } : { root, trees, listed: files }; + if (paths === undefined) return { root, trees }; // **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a // directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails // naming both, and nothing is answered in part. @@ -618,7 +602,7 @@ export class GiteaClient { named[dir][p] = id; } } - return files === undefined ? { root, trees, paths: named } : { root, trees, paths: named, listed: files }; + return { root, trees, paths: named }; } /** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */ diff --git a/modules/gitea/test/commit-trees.test.ts b/modules/gitea/test/commit-trees.test.ts index f1ae6b7e..0c36f409 100644 --- a/modules/gitea/test/commit-trees.test.ts +++ b/modules/gitea/test/commit-trees.test.ts @@ -183,15 +183,6 @@ test("named paths: each asked path's git object id at the commit, a file's or a }); assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") }); assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered"); - // Listed directories (ADR 0549 §2, issue 1126): the files directly under each, not deeper; a missing one lists none. - const listed = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", "", "placement"]); - assert.deepEqual(listed.listed, { - "modules/a": { "main.go": h("6"), "module.json": h("5") }, - "modules/b": {}, - "": { "README.md": h("1"), "module.json": h("2") }, - placement: {}, - }); - assert.equal((await client.commitTrees("novox", "hq", COMMIT)).listed, undefined, "no directory asked, none listed"); } finally { server.close(); } @@ -218,12 +209,7 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); try { const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!; - assert.ok("paths" in tool.input && "listed" in tool.input, "the tool names the arguments"); - const listedAnswer: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: ["modules/d"] } as any); - assert.deepEqual(listedAnswer.listed, { "modules/d": { "notes.md": h("b") } }); - for (const bad of ["modules/d", ["../x"], ["/abs"], [1]]) { - await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: bad } as any), /listed/, JSON.stringify(bad)); - } + assert.ok("paths" in tool.input, "the tool names the arguments"); const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any); assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } }); const before = asked.length; diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index e1029d65..4d1a94db 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -79,17 +79,6 @@ function namedPaths(raw: unknown): Record | undefined { return out; } -/** The `listed` argument of gitea_commit_trees: [directory], each plain (no `.` or `..` segment, not absolute, the root - * the empty name); anything else is refused before the forge is asked. Absent is absent. */ -function listedDirs(raw: unknown): string[] | undefined { - if (raw === undefined || raw === null) return undefined; - if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("listed is [directory, ...]"); - for (const d of raw as string[]) { - if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`listed: ${JSON.stringify(d)} is not a plain relative directory`); - } - return raw as string[]; -} - export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] { return namesChecked(allGiteaTools(gitea, admin, source)); } @@ -733,20 +722,19 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined }, { name: "gitea_commit_trees", - description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. With listed, [directory], the answer also carries listed, {directory: {name: git blob id}} of the files directly under each (the catalog's placement files, novox/hq ADR 0549 §2). A listing the forge cut is refused, never answered in part. Reads only.", + description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, sha: { type: "string", description: "the commit, its full 40-hex sha" }, paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" }, - listed: { type: "array", items: { type: "string" }, description: "optional, [directory]: the files directly under each, {directory: {name: git blob id}}, are answered under listed; a directory the commit lacks lists none" }, }, run: async (args) => { const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); const sha = String(args.sha ?? "").trim(); if (!owner || !repo) throw new Error("name the repository: owner and repo"); if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha"); - return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths), listedDirs(args.listed)); + return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths)); }, }, { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks.go b/modules/mesh-build-go/cmd/mesh-build-go/asks.go index eb95b65f..2c796c1c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/asks.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks.go @@ -87,13 +87,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep var watch func(string, func(stdio.ProvisionChange) error) error // And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice // gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity. - // And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes + // And the node's build hours (a node may build only in a window): outside them it takes // nothing new. var cores func() float64 var hours func() (*buildstep.BuildWindow, string) if capacity == nil { hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say) - capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) + capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) watch = buildstep.WatchConfigServed cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go b/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go index 20f6d788..d9efe887 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "regexp" "slices" "strings" "testing" @@ -126,7 +127,8 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) { raw, _ := os.ReadFile(moduleDir + "/module.json") var doc struct { Config map[string]struct { - Default json.RawMessage `json:"default"` + Default json.RawMessage `json:"default"` + Pattern, Description string } `json:"config"` } _ = json.Unmarshal(raw, &doc) @@ -144,10 +146,20 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) { if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" { t.Errorf("the settings template holds build hours %v (%s)", w, fault) } - var capacity string + // ADR 0567 §15: build-hours names no node (one window, or empty for any hour) and capacity defaults to 0. + window := regexp.MustCompile("^(?:" + doc.Config[buildstep.BuildHoursSetting].Pattern + ")$") + for value, ok := range map[string]bool{"": true, "18:00-06:00": true, "g14=18:00-06:00": false, "18:00": false} { + if window.MatchString(value) != ok { + t.Errorf("build-hours pattern on %q: accepted %v, want %v", value, !ok, ok) + } + } + if strings.Contains(doc.Config[buildstep.BuildHoursSetting].Description, "=") { + t.Errorf("the build-hours description names a node pair") + } + var capacity float64 _ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity) - if capacity != buildstep.DefaultCapacities { - t.Errorf("the capacity's default = %q, want %q", capacity, buildstep.DefaultCapacities) + if capacity != 0 { + t.Errorf("the capacity's default = %g, want 0", capacity) } settings := resource(t, m, "settings") if settings["path"] != "${dir:state}/settings.json" { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/cpu.go b/modules/mesh-build-go/cmd/mesh-build-go/cpu.go index c1c00903..afff4ac3 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/cpu.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/cpu.go @@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits { case m.Capacity != nil: slots = m.Capacity() case m.Settings != "": - slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)() + slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)() } return buildstep.NodeLimits(slots) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/digest.go b/modules/mesh-build-go/cmd/mesh-build-go/digest.go index 0e0133e2..c3494d08 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/digest.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/digest.go @@ -37,6 +37,10 @@ const DigestVersion = "6" // depends on the siblings' commits. const besideVariable = "MESH_CHECK_BESIDE" +// namesVariable is the same for the mesh's node names: a package whose tests reach a file naming it depends on the node +// list the ask carries, so its digest holds the sorted names and a new node misses a kept verdict (ADR 0567 §15). +const namesVariable = "MESH_NODE_NAMES" + // ReadsOutsideMarker is the name of a file in a package's directory that says its tests read files outside it (a // repository's documents, a sibling directory), which the Go toolchain does not list (issue 1935). It lists, one per // line, what those tests read, as paths from the repository's root; a path may be a glob (filepath.Match), and a @@ -164,6 +168,8 @@ func (m *Module) digestTool() StepTool { "workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"}, "level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"}, "runner": runnerProperty, + delivery.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "optional, the names of the mesh's nodes: a package whose tests read " + delivery.NodeNamesEnv + " has them in its digest"}, }, "required": []string{"workspace", "level"}, }, @@ -178,6 +184,10 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, err } + names, err := nodeNamesArg(args[delivery.NodeNamesArg]) + if err != nil { + return nil, err + } if runner == RunnerWorkspace { if err := m.workspaceThere(workspace); err != nil { return nil, err @@ -191,7 +201,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) { default: return nil, fmt.Errorf("the level %q is neither unit nor integration", level) } - a, judged, err := m.asCandidate(ctx, levelSpec{Step: stepDigest, Level: level, Workspace: workspace}) + a, judged, err := m.asCandidate(ctx, levelSpec{Step: stepDigest, Level: level, Workspace: workspace, NodeNames: names}) if err != nil { return nil, err } @@ -201,7 +211,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) { a.Digest.JudgedBy = judged return *a.Digest, nil } - res, err := m.digestHere(ctx, workspace, level) + res, err := m.digestHere(ctx, workspace, level, names) if err != nil { return nil, err } @@ -210,7 +220,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) { } // digestHere is the digest step as this program runs it. -func (m *Module) digestHere(ctx context.Context, workspace, level string) (DigestResult, error) { +func (m *Module) digestHere(ctx context.Context, workspace, level string, names []string) (DigestResult, error) { if !workspaceOK.MatchString(workspace) { return DigestResult{}, fmt.Errorf("%q is not a workspace id", workspace) } @@ -241,6 +251,14 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string) (Diges besideHeader = append(besideHeader, "beside "+b) } + // The node names, sorted, as a header line for a package that reads them. + sortedNames := append([]string{}, names...) + sort.Strings(sortedNames) + var namesHeader []string + if len(sortedNames) > 0 { + namesHeader = []string{"node-names " + strings.Join(sortedNames, ",")} + } + dirs, err := m.goModDirs(ctx, tree) if err != nil { return DigestResult{}, err @@ -251,7 +269,7 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string) (Diges if err != nil { return DigestResult{}, err } - if err := digestModule(&res, pkgs, tree, header, besideHeader, level == LevelIntegration); err != nil { + if err := digestModule(&res, pkgs, tree, header, besideHeader, namesHeader, level == LevelIntegration); err != nil { return DigestResult{}, err } } @@ -302,7 +320,7 @@ func parsePackages(out string) (map[string]*goPackage, error) { // digestModule adds to res the digest of each root package of one go.mod directory. A root is a package of the main // module that is listed, not only a dependency, and not a variant of another. -func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside []string, needs bool) error { +func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside, names []string, needs bool) error { roots := []string{} for path, p := range pkgs { if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main { @@ -349,7 +367,7 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he res.neverKept[path] = why continue } - d, err := packageDigest(root, testMain, pkgs, tree, header, beside, declared) + d, err := packageDigest(root, testMain, pkgs, tree, header, beside, names, declared) if err != nil { return fmt.Errorf("%s: %w", path, err) } @@ -361,9 +379,9 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he // packageDigest is the digest of one package: a hash over a sorted text of the header, the files of every package its // tests reach in the module's own tree (by their content, under their path in the tree), the version and checksum of // every module outside it, and the package's own test data. The beside lines (the siblings' commits) join the header -// only when a file the tests reach names MESH_CHECK_BESIDE. A package of the standard library is the Go version's. +// only when a file the tests reach names MESH_CHECK_BESIDE, and the node names likewise for MESH_NODE_NAMES. A package of the standard library is the Go version's. // The lines a package's ReadsOutsideMarker declares join them (declaredReads). -func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside, declared []string) (string, error) { +func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside, names, declared []string) (string, error) { reached := map[string]*goPackage{root.ImportPath: root} for _, dep := range testMain.Deps { if p := pkgs[dep]; p != nil { @@ -371,7 +389,7 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s } } lines := append([]string{}, declared...) - readsBeside := false + readsBeside, readsNames := false, false for _, p := range reached { if p.Standard || p.Module == nil { continue @@ -390,9 +408,10 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s return "", err } lines = append(lines, l) - if !readsBeside { - if raw, err := os.ReadFile(filepath.Join(p.Dir, f)); err == nil && bytes.Contains(raw, []byte(besideVariable)) { - readsBeside = true + if !readsBeside || !readsNames { + if raw, err := os.ReadFile(filepath.Join(p.Dir, f)); err == nil { + readsBeside = readsBeside || bytes.Contains(raw, []byte(besideVariable)) + readsNames = readsNames || bytes.Contains(raw, []byte(namesVariable)) } } } @@ -424,6 +443,9 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s if readsBeside { header = append(append([]string{}, header...), beside...) } + if readsNames { + header = append(append([]string{}, header...), names...) + } h := sha256.New() for _, l := range append(append([]string{}, header...), lines...) { h.Write([]byte(l + "\n")) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go index a6dac3a4..0a449589 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go @@ -172,8 +172,9 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) { root := &goPackage{ImportPath: "x/r", Dir: dir, Module: &goModule{Path: "x", Main: true}, GoFiles: []string{"r.go"}, TestGoFiles: []string{"r.go"}} testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}} pkgs[root.ImportPath] = root + var names []string digest := func() string { - d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil, nil) + d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil, names, nil) if err != nil { t.Fatal(err) } @@ -188,6 +189,20 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) { if d3 := digest(); d3 == d1 { t.Errorf("a new version left the digest as it was") } + // A different node list misses the cache only for a package whose tests read MESH_NODE_NAMES (ADR 0567 §15). + names = []string{"node-names a"} + d4 := digest() + names = []string{"node-names a", "node-names b"} + if d5 := digest(); d5 != d4 { + t.Errorf("a package that does not read the node names changed with them") + } + os.WriteFile(filepath.Join(dir, "r.go"), []byte("package r\n// "+namesVariable+"\n"), 0o644) + names = []string{"node-names a"} + d6 := digest() + names = []string{"node-names a", "node-names b"} + if d7 := digest(); d7 == d6 { + t.Errorf("a new node left the digest of a package that reads the node names as it was") + } } // Issue 1935, 1936: a package whose reads-outside marker lists nothing it can trust is never kept: its digest is one no diff --git a/modules/mesh-build-go/cmd/mesh-build-go/integration.go b/modules/mesh-build-go/cmd/mesh-build-go/integration.go index 704dab50..62b4d0d1 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/integration.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/integration.go @@ -72,7 +72,7 @@ func (m *Module) integrationHere(ctx context.Context, workspace, tree string, pa return nil, fmt.Errorf("the integration level's bus: %w", err) } defer stopBus() - env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace)...) + env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace, nil)...) // Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run // below finds them built. Whether they compile is for the run to say; its failure is not read here. // Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory. diff --git a/modules/mesh-build-go/cmd/mesh-build-go/level.go b/modules/mesh-build-go/cmd/mesh-build-go/level.go index 2b6ee2b8..99a40937 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/level.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/level.go @@ -43,6 +43,7 @@ type levelSpec struct { Run string `json:"run,omitempty"` Setup time.Duration `json:"setup"` Bound time.Duration `json:"bound"` + NodeNames []string `json:"node-names,omitempty"` } // levelAnswer is the one line the process prints: the level's result or the digest, or why it could not run. @@ -221,14 +222,14 @@ func (m *Module) answer(ctx context.Context, spec levelSpec) levelAnswer { tree := filepath.Join(m.Work, spec.Workspace, "tree") switch spec.Step { case stepDigest: - d, err := m.digestHere(ctx, spec.Workspace, spec.Level) + d, err := m.digestHere(ctx, spec.Workspace, spec.Level, spec.NodeNames) if err != nil { return levelAnswer{Error: err.Error()} } return levelAnswer{Digest: &d} case stepUnit: res, err := m.runLevel(ctx, tree, levelOpts{workspace: spec.Workspace, level: LevelUnit, packages: spec.Packages, run: spec.Run, - bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(spec.Workspace)}) + bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(spec.Workspace, spec.NodeNames)}) if err != nil { return levelAnswer{Error: err.Error()} } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go b/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go index 25a50c9d..afb36256 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go @@ -2,7 +2,10 @@ package main import ( "slices" + "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Issue 1309: a test that runs a step in bubblewrap is proof only where it runs. Where a build module judges (it hands @@ -29,8 +32,12 @@ func TestAMissingToolSkipsExceptBubblewrapOnABuildNode(t *testing.T) { // The levels a build module judges hand the tests the build node mark, with the siblings they read. func TestEveryLevelHandsTheTestsTheBuildNodeMark(t *testing.T) { m := &Module{Work: t.TempDir()} - if env := m.checkEnv("ws1"); !slices.Contains(env, BuildNodeEnv+"=1") { - t.Errorf("env %v lacks %s=1", env, BuildNodeEnv) + if env := m.checkEnv("ws1", nil); !slices.Contains(env, BuildNodeEnv+"=1") || slices.ContainsFunc(env, func(e string) bool { return strings.HasPrefix(e, delivery.NodeNamesEnv) }) { + t.Errorf("env %v lacks %s=1, or names nodes when none were asked", env, BuildNodeEnv) + } + // And the mesh's node names, when the ask carried them (ADR 0567 §12). + if env := m.checkEnv("ws1", []string{"a", "b"}); !slices.Contains(env, delivery.NodeNamesEnv+"=a,b") { + t.Errorf("env %v lacks the node names", env) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test.go b/modules/mesh-build-go/cmd/mesh-build-go/test.go index 6491c900..d165124c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test.go @@ -16,6 +16,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -38,7 +39,9 @@ func (m *Module) testTool() StepTool { "level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"}, "packages": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "package patterns of each module, ./... when none"}, - "run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"}, + "run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"}, + delivery.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "optional, the names of the mesh's nodes: the tests get them comma-separated in " + delivery.NodeNamesEnv + " (a check that keeps node names out of the repository reads them there)"}, "runner": runnerProperty, }, "required": []string{"workspace", "level"}, @@ -78,12 +81,16 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, err } + names, err := nodeNamesArg(args[delivery.NodeNamesArg]) + if err != nil { + return nil, err + } var res *TestResult switch { case runner == RunnerWorkspace && (level == LevelUnit || level == LevelIntegration): - return m.testAsCandidate(ctx, workspace, level, packages, pattern) + return m.testAsCandidate(ctx, workspace, level, packages, pattern, names) case level == LevelUnit: - res, err = m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(workspace)}) + res, err = m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(workspace, names)}) case level == LevelIntegration: res, err = m.integration(ctx, workspace, tree, packages, pattern) default: @@ -98,14 +105,14 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { // testAsCandidate runs a level as the candidate's program (ADR 0535 §1): the integration level is refused first as the // installed program would refuse it (no postgres), then the whole level runs in the candidate's one shut-off process. -func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string) (*TestResult, error) { - spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound()} +func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string, names []string) (*TestResult, error) { + spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound(), NodeNames: names} if level == LevelIntegration { if _, err := m.checkPostgres(ctx); err != nil { return nil, err // a *buildstep.Refusal: this level only } b := m.integrationBounds() - spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run} + spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run, NodeNames: names} } a, judged, err := m.asCandidate(ctx, spec) if err != nil { @@ -132,9 +139,35 @@ func (m *Module) besideEnv(workspace string) []string { // and present, so a test that needs it fails where it is missing instead of skipping (issue 1309). const BuildNodeEnv = "MESH_BUILD_NODE" -// checkEnv is what every level hands the tests it runs: the build node mark and the siblings (besideEnv). -func (m *Module) checkEnv(workspace string) []string { - return append([]string{BuildNodeEnv + "=1"}, m.besideEnv(workspace)...) +// checkEnv is what every level hands the tests it runs: the build node mark, the siblings (besideEnv) and, when the +// ask carried them, the mesh's node names (delivery.NodeNamesEnv, novox/hq ADR 0567 §12). +func (m *Module) checkEnv(workspace string, names []string) []string { + env := append([]string{BuildNodeEnv + "=1"}, m.besideEnv(workspace)...) + if len(names) > 0 { + env = append(env, delivery.NodeNamesEnv+"="+strings.Join(names, ",")) + } + return env +} + +// nodeNamesArg reads the node-names argument: a list of names, absent when none. +func nodeNamesArg(raw any) ([]string, error) { + switch v := raw.(type) { + case nil: + return nil, nil + case []string: + return v, nil + case []any: + var out []string + for _, e := range v { + n, ok := e.(string) + if !ok || n == "" || strings.ContainsAny(n, ", \n") { + return nil, fmt.Errorf("%s is a list of node names, not %v", delivery.NodeNamesArg, e) + } + out = append(out, n) + } + return out, nil + } + return nil, fmt.Errorf("%s is a list of node names", delivery.NodeNamesArg) } // packagesArg reads the packages argument: a list of package patterns, as []any when it came over the bus as JSON and diff --git a/modules/mesh-build-go/files/settings.json b/modules/mesh-build-go/files/settings.json index 56bb28d4..3b65dd1c 100644 --- a/modules/mesh-build-go/files/settings.json +++ b/modules/mesh-build-go/files/settings.json @@ -1,5 +1,5 @@ { - "capacity": "shanks=3,ace=2,g14=0,novox=0", + "capacity": 0, "cores-per-step": 6, "build-hours": "" } diff --git a/modules/mesh-build-go/go.mod b/modules/mesh-build-go/go.mod index 7fbb32cf..c9c7530e 100644 --- a/modules/mesh-build-go/go.mod +++ b/modules/mesh-build-go/go.mod @@ -3,7 +3,7 @@ module meshbuildgo go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 github.com/distribution/distribution/v3 v3.1.2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/modules/mesh-build-go/go.sum b/modules/mesh-build-go/go.sum index da91c098..3c317d26 100644 --- a/modules/mesh-build-go/go.sum +++ b/modules/mesh-build-go/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1 h1:9qyUcrvlklxDRBUCBqGH1L3oximOk554IfaPrTMgaTA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index 7526d1a0..88fbba9b 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -25,15 +25,15 @@ ], "config": { "capacity": { - "type": "string", - "description": "How many asks of the Go build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", - "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", - "default": "shanks=3,ace=2,g14=0,novox=0" + "type": "number", + "description": "How many asks of the Go build seat this module runs at once on this node (novox/hq ADR 0525 §5); 0 pulls nothing. It is set per node in the mesh's configuration; a node with no value builds nothing.", + "minimum": 0, + "default": 0 }, "build-hours": { "type": "string", - "description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.", - "pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*", + "description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.", + "pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]", "default": "" }, "cores-per-step": { diff --git a/modules/mesh-build-image/cmd/mesh-build-image/asks.go b/modules/mesh-build-image/cmd/mesh-build-image/asks.go index ee4edf75..204123d6 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/asks.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/asks.go @@ -43,13 +43,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep var watch func(string, func(stdio.ProvisionChange) error) error // And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice // gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity. - // And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes + // And the node's build hours (a node may build only in a window): outside them it takes // nothing new. var cores func() float64 var hours func() (*buildstep.BuildWindow, string) if capacity == nil { hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say) - capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) + capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) watch = buildstep.WatchConfigServed cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskImage], m.Say) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/cpu.go b/modules/mesh-build-image/cmd/mesh-build-image/cpu.go index 91f1d314..be28cc43 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/cpu.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/cpu.go @@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits { case m.Capacity != nil: slots = m.Capacity() case m.Settings != "": - slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)() + slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)() } return buildstep.NodeLimits(slots) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go index 9b424a22..b6da5a0a 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "os" "path/filepath" + "regexp" "slices" "strings" "testing" @@ -121,6 +123,30 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes if strings.TrimSpace(string(tpl)) != "${seat:mesh-artifact-store:reach}" { t.Errorf("registry template = %q", tpl) } + raw, _ = os.ReadFile(filepath.Join(moduleDir, "module.json")) + var doc struct { + Config map[string]struct { + Default json.RawMessage `json:"default"` + Pattern, Description string + } `json:"config"` + } + _ = json.Unmarshal(raw, &doc) + var capacity float64 = -1 + _ = json.Unmarshal(doc.Config["capacity"].Default, &capacity) + if capacity != 0 { + t.Errorf("the capacity's default = %g, want 0", capacity) + } + // ADR 0567 §15: build-hours names no node (one window, or empty for any hour) and capacity defaults to 0. + window := regexp.MustCompile("^(?:" + doc.Config["build-hours"].Pattern + ")$") + for value, ok := range map[string]bool{"": true, "18:00-06:00": true, "g14=18:00-06:00": false, "18:00": false} { + if window.MatchString(value) != ok { + t.Errorf("build-hours pattern on %q: accepted %v, want %v", value, !ok, ok) + } + } + if strings.Contains(doc.Config["build-hours"].Description, "=") { + t.Errorf("the build-hours description names a node pair") + } + } // Every tool the manifest names is served, and every tool served is named. diff --git a/modules/mesh-build-image/files/settings.json b/modules/mesh-build-image/files/settings.json index 73ee4fa8..4a445c34 100644 --- a/modules/mesh-build-image/files/settings.json +++ b/modules/mesh-build-image/files/settings.json @@ -1,5 +1,5 @@ { - "capacity": "shanks=3,ace=2,g14=0,novox=0", + "capacity": 0, "cores-per-step": 2, "build-hours": "" } diff --git a/modules/mesh-build-image/go.mod b/modules/mesh-build-image/go.mod index ce45001d..55ed1f70 100644 --- a/modules/mesh-build-image/go.mod +++ b/modules/mesh-build-image/go.mod @@ -3,7 +3,7 @@ module meshbuildimage go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028115016-1618837ea9fe + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-build-image/go.sum b/modules/mesh-build-image/go.sum index a0df3cb5..6245202d 100644 --- a/modules/mesh-build-image/go.sum +++ b/modules/mesh-build-image/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028115016-1618837ea9fe h1:QDWi8k7zxaRPnJ+V/izoy6A19hZt1LTLl3uksZSlqOg= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028115016-1618837ea9fe/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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-build-image/module.json b/modules/mesh-build-image/module.json index c83b9d88..54bc2fb0 100644 --- a/modules/mesh-build-image/module.json +++ b/modules/mesh-build-image/module.json @@ -20,15 +20,15 @@ ], "config": { "capacity": { - "type": "string", - "description": "How many asks of the image build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", - "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", - "default": "shanks=3,ace=2,g14=0,novox=0" + "type": "number", + "description": "How many asks of the image build seat this module runs at once on this node (novox/hq ADR 0525 §5); 0 pulls nothing. It is set per node in the mesh's configuration; a node with no value builds nothing.", + "minimum": 0, + "default": 0 }, "build-hours": { "type": "string", - "description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.", - "pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*", + "description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.", + "pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]", "default": "" }, "cores-per-step": { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks.go b/modules/mesh-build-node/cmd/mesh-build-node/asks.go index 607755e7..2e8b26cf 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/asks.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks.go @@ -51,13 +51,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep var watch func(string, func(stdio.ProvisionChange) error) error // And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice // gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity. - // And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes + // And the node's build hours (a node may build only in a window): outside them it takes // nothing new. var cores func() float64 var hours func() (*buildstep.BuildWindow, string) if capacity == nil { hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say) - capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) + capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) watch = buildstep.WatchConfigServed cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskNode], m.Say) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go b/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go index 8ae9a43d..75a03f2e 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "regexp" "strings" "testing" "time" @@ -84,7 +85,8 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) { raw, _ := os.ReadFile(moduleDir + "/module.json") var doc struct { Config map[string]struct { - Default json.RawMessage `json:"default"` + Default json.RawMessage `json:"default"` + Pattern, Description string } `json:"config"` } _ = json.Unmarshal(raw, &doc) @@ -102,10 +104,20 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) { if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" { t.Errorf("the settings template holds build hours %v (%s)", w, fault) } - var capacity string + // ADR 0567 §15: build-hours names no node (one window, or empty for any hour) and capacity defaults to 0. + window := regexp.MustCompile("^(?:" + doc.Config[buildstep.BuildHoursSetting].Pattern + ")$") + for value, ok := range map[string]bool{"": true, "18:00-06:00": true, "g14=18:00-06:00": false, "18:00": false} { + if window.MatchString(value) != ok { + t.Errorf("build-hours pattern on %q: accepted %v, want %v", value, !ok, ok) + } + } + if strings.Contains(doc.Config[buildstep.BuildHoursSetting].Description, "=") { + t.Errorf("the build-hours description names a node pair") + } + var capacity float64 _ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity) - if capacity != buildstep.DefaultCapacities { - t.Errorf("the capacity's default = %q", capacity) + if capacity != 0 { + t.Errorf("the capacity's default = %g, want 0", capacity) } if resource(t, m, "settings")["path"] != "${dir:state}/settings.json" { t.Error("no settings file") diff --git a/modules/mesh-build-node/cmd/mesh-build-node/cpu.go b/modules/mesh-build-node/cmd/mesh-build-node/cpu.go index 5936b615..432e0747 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/cpu.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/cpu.go @@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits { case m.Capacity != nil: slots = m.Capacity() case m.Settings != "": - slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)() + slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)() } return buildstep.NodeLimits(slots) } diff --git a/modules/mesh-build-node/files/settings.json b/modules/mesh-build-node/files/settings.json index 73ee4fa8..4a445c34 100644 --- a/modules/mesh-build-node/files/settings.json +++ b/modules/mesh-build-node/files/settings.json @@ -1,5 +1,5 @@ { - "capacity": "shanks=3,ace=2,g14=0,novox=0", + "capacity": 0, "cores-per-step": 2, "build-hours": "" } diff --git a/modules/mesh-build-node/go.mod b/modules/mesh-build-node/go.mod index d07b767c..35588b71 100644 --- a/modules/mesh-build-node/go.mod +++ b/modules/mesh-build-node/go.mod @@ -3,7 +3,7 @@ module meshbuildnode go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 github.com/distribution/distribution/v3 v3.1.2 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-build-node/go.sum b/modules/mesh-build-node/go.sum index ff7ca7db..46efa949 100644 --- a/modules/mesh-build-node/go.sum +++ b/modules/mesh-build-node/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1 h1:9qyUcrvlklxDRBUCBqGH1L3oximOk554IfaPrTMgaTA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index baf8b291..25f8611b 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -21,15 +21,15 @@ ], "config": { "capacity": { - "type": "string", - "description": "How many asks of the Node build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", - "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", - "default": "shanks=3,ace=2,g14=0,novox=0" + "type": "number", + "description": "How many asks of the Node build seat this module runs at once on this node (novox/hq ADR 0525 §5); 0 pulls nothing. It is set per node in the mesh's configuration; a node with no value builds nothing.", + "minimum": 0, + "default": 0 }, "build-hours": { "type": "string", - "description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.", - "pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*", + "description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.", + "pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]", "default": "" }, "cores-per-step": { diff --git a/modules/mesh-config/cmd/mesh-config/resolve.go b/modules/mesh-config/cmd/mesh-config/resolve.go index ccf35335..b57bf3dc 100644 --- a/modules/mesh-config/cmd/mesh-config/resolve.go +++ b/modules/mesh-config/cmd/mesh-config/resolve.go @@ -21,9 +21,9 @@ const ( ProposalsState = "proposals" AssignmentsRead = "mesh-controller.assignments" - // PlacementRead is the indexer's placement, read from the catalogue at each merge (novox/hq ADR 0549 §2, §3): each + // PlacementRead is the seat mesh-placement's state, held by the assigner (novox/hq ADR 0567 §1, §8): each // node's key `placement.` with the modules placed there. - PlacementRead = "mesh-delivery-indexer.placement" + PlacementRead = "mesh-delivery-assigner.placement" DeclaredConfigRead = "mesh-controller.declared-config" PredefinedRead = "mesh-controller.predefined" diff --git a/modules/mesh-config/cmd/mesh-config/sources.go b/modules/mesh-config/cmd/mesh-config/sources.go index 36adb3c8..6cd7e88d 100644 --- a/modules/mesh-config/cmd/mesh-config/sources.go +++ b/modules/mesh-config/cmd/mesh-config/sources.go @@ -27,9 +27,9 @@ func (c *Config) sources() (Sources, error) { }); err != nil { return s, fmt.Errorf("the assignments cannot be read: %w", err) } - // And every module the catalog's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both + // And every module the seat's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both // sources stand (ADR 0549 §5), so a pair either names is resolved, and none loses its values while the two differ. - // Placement that cannot be read yet (the indexer's state not on the bus) leaves the assignments alone, said. + // Placement that cannot be read yet (the seat's state not on the bus) leaves the assignments alone, said. standing := map[string]bool{} for _, a := range s.Assignments { standing[a.Node+"."+a.Module] = true @@ -57,7 +57,7 @@ func (c *Config) sources() (Sources, error) { } return nil }); err != nil { - problems = append(problems, fmt.Sprintf("the catalog's placement cannot be read, so the assignments alone are resolved: %v", err)) + problems = append(problems, fmt.Sprintf("the seat's placement cannot be read, so the assignments alone are resolved: %v", err)) } if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error { m, err := ParseModuleDeclarations(raw) diff --git a/modules/mesh-config/go.mod b/modules/mesh-config/go.mod index c027e91b..6a0791d7 100644 --- a/modules/mesh-config/go.mod +++ b/modules/mesh-config/go.mod @@ -2,4 +2,4 @@ module mesh-config go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021075504-7bf4a8053a49 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 diff --git a/modules/mesh-config/go.sum b/modules/mesh-config/go.sum index b21c9645..8c9ba7ca 100644 --- a/modules/mesh-config/go.sum +++ b/modules/mesh-config/go.sum @@ -1,4 +1,2 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021075504-7bf4a8053a49 h1:h1hE+H1TzmeWNFP3EnfhUXTZq2gyiyeND32Tsg83ygY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021075504-7bf4a8053a49/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/modules/mesh-config/module.json b/modules/mesh-config/module.json index d6b5bdd0..ca7e0a2f 100644 --- a/modules/mesh-config/module.json +++ b/modules/mesh-config/module.json @@ -29,7 +29,7 @@ ], "reads": [ "mesh-controller.assignments", - "mesh-delivery-indexer.placement", + "mesh-delivery-assigner.placement", "mesh-controller.declared-config", "mesh-controller.predefined" ], diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go index 90a06d57..e53a7c0f 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go @@ -59,7 +59,7 @@ type checkerVerdict struct { Words string `json:"words,omitempty"` } `json:"failed,omitzero"` // Composition is what the check of the machines' composition made of it (mesh-sdk delivery.Composition): its state - // not-run says the composition, and with it a placement change's answers from its nodes, was not judged. + // not-run says the composition was not judged. Composition *struct { State string `json:"state"` Words string `json:"words,omitempty"` @@ -341,7 +341,7 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid { if ok { switch v.State { case checkerPass: - return checkSaid{Key: checkSaidPass, Words: "passed its check" + notComposed(v, members)} + return checkSaid{Key: checkSaidPass, Words: "passed its check" + notComposed(v)} case checkerRunning: words := "its check has no verdict yet: it is running" if since, held := h.runningSince[hash]; held && !since.IsZero() { @@ -384,20 +384,13 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid { return checkSaid{Key: checkSaidNone, Words: "its check has no verdict yet"} } -// notComposed is what a passing verdict adds when its composition was not judged (mesh-sdk delivery.CompositionNotRun): a -// placement change's pass then holds no node's answer to its placement (ADR 0559 §5), which the ask says. Empty otherwise. -func notComposed(v checkerVerdict, members []*Delivery) string { +// notComposed is what a passing verdict adds when its composition was not judged (mesh-sdk delivery.CompositionNotRun). +// Empty otherwise. +func notComposed(v checkerVerdict) string { if v.Composition == nil || v.Composition.State != sdk.CompositionNotRun { return "" } why := firstOf(oneLine(v.Composition.Words), "it was not run") - for _, m := range members { - for _, p := range m.Paths { - if strings.HasPrefix(p, "placement/") && strings.HasSuffix(p, ".json") { - return ", but its composition was not judged (" + why + "), so no node answered its placement" - } - } - } return ", but its composition was not judged (" + why + ")" } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go index d998ea80..0f038c3f 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go @@ -465,7 +465,7 @@ const waitsCheckBeforeAsking = "waits its check before asking the operator's App // has no verdict (the operator, 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request"). // A pass or a failure is asked (a failure in its words: the operator may still merge it), and so is a check that has not // answered within askCheckBound of the heads becoming ready, which the ask says. For a group the check is the one of all its -// heads together; for a placement change it ends only once every node it places answered (ADR 0559 §5). Under the lock. +// heads together. Under the lock. func (h *Holder) verdictWaits(d *Delivery) string { members := h.askMembers(d) c := h.checkAt(members) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go index 4f85d309..a02db03b 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go @@ -75,12 +75,11 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) { // past which it is asked anyway, saying so, and asked again in new words once the check passes. The verdict is the // checker's verdicts bucket value as captured on the bus (mesh-delivery-checker, `.`). for _, row := range []struct { - name, state, failed, then, path string - gated bool - notComposed bool - after time.Duration - asks, open int - want, not []string + name, state, failed, then string + gated bool + after time.Duration + asks, open int + want, not []string }{ {name: "gated, passed", gated: true, state: "pass", asks: 1, want: []string{"app#7 Fix it passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, @@ -100,9 +99,6 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) { want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running (since 15:00 CEST); asked anyway at 16:00 CEST"}}, {name: "ungated, passed, its verdict gone past its bound", state: "pass", after: askCheckBound, then: "gone", asks: 1, open: -1, want: []string{waitsCheckBeforeAsking + "its check has no verdict yet; asked anyway at 16:00 CEST"}}, - // A placement change's pass with its composition not judged holds no node's answer, and the ask says so (ADR 0559 §5). - {name: "ungated, placement passed uncomposed", state: "pass", path: "placement/node-a.json", notComposed: true, asks: 1, - want: []string{"passed its check, but its composition was not judged (the assigner is not running), so no node answered its placement"}}, {name: "ungated, failed", state: "fail", failed: "TestThing: wanted 1, got 2", asks: 1, want: []string{"its check failed: TestThing: wanted 1, got 2", "although its check failed"}, not: []string{"passed", "anyway"}}, } { @@ -112,7 +108,7 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) { ungated(v) } v.op.auto = false - p := pr("novox/app", 7, readyHead, "feat/x", firstOf(row.path, "src/a.go")) + p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go") p.Title = "Fix it" v.h.PullUpdated(p) head := headOf("novox/app", 7, readyHead) @@ -121,11 +117,7 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) { } v.settleAll() if !row.gated && row.state != "" { - var more map[string]any - if row.notComposed { - more = map[string]any{"composition": map[string]string{"state": "not-run", "words": "the assigner is not running"}} - } - checkerSaysWith(v, "g", row.state, row.failed, more, head) + checkerSaysWith(v, "g", row.state, row.failed, nil, head) v.settleAll() } if row.after > 0 { diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index fc69fd43..57a6c550 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-gate go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 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-gate/go.sum b/modules/mesh-delivery-gate/go.sum index f65149d1..6245202d 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,9 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61 h1:/c6ncICWjq55nBf8TlDM0ROe/PuH+qZIdnuVbtg8UuM= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f h1:AKzV973UTWe0OYFoIjgGu0f+B26xh8QsAHzCpsTIjM8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f h1:mimAGERguQmCGYVNdRTrFcZ1dhpU6GWo+Q3EDhHOrVk= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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-vault/cmd/mesh-vault/move_test.go b/modules/mesh-vault/cmd/mesh-vault/move_test.go index ab13d194..4da010aa 100644 --- a/modules/mesh-vault/cmd/mesh-vault/move_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/move_test.go @@ -437,10 +437,10 @@ func TestANodeEngineHandsTheVaultWhatRunsOnItsOwnMachine(t *testing.T) { later -= time.Hour // own_drift reads it from the node's latest shadow answer, mesh-wide; the id as a node runs it, its module's name // first (captured from novox's shadow answer, 2026-10-28: only-office.needs-jwt). - answer := delivery.CandidateAnswer{Head: "h", Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{ + answer := delivery.CandidateAnswer{Proposal: "pro", Revision: 1, Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{ Module: "postgres", Why: "the resource differs", Bodies: []delivery.ResourceBodies{{ID: "postgres.needs-superuser-password", Composed: `{"sealed":"` + delivery.MaskedAnotherValue + `"}`, Running: `{"sealed":"` + delivery.MaskedAnotherValue + `"}`}}}}} - have, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": answer}, + have, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"pro": answer}, Composes: delivery.ComposesController}) if err != nil { t.Fatal(err) @@ -453,7 +453,7 @@ func TestANodeEngineHandsTheVaultWhatRunsOnItsOwnMachine(t *testing.T) { } if len(drift.Secrets) != 1 || drift.Secrets[0].Secret != "own/anchor/postgres/superuser-password" || drift.Secrets[0].Generation != 2 || drift.Secrets[0].Previous != 1 || drift.Secrets[0].Takeable || - len(drift.Nodes) != 1 || drift.Nodes[0].ValuesDiffer["g"] != 1 || drift.Nodes[0].Composes != delivery.ComposesController { + len(drift.Nodes) != 1 || drift.Nodes[0].ValuesDiffer["pro"] != 1 || drift.Nodes[0].Composes != delivery.ComposesController { t.Errorf("own_drift: %+v", drift) } } diff --git a/modules/mesh-vault/go.mod b/modules/mesh-vault/go.mod index 27efc746..34a8552a 100644 --- a/modules/mesh-vault/go.mod +++ b/modules/mesh-vault/go.mod @@ -3,7 +3,7 @@ module mesh-vault go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/modules/mesh-vault/go.sum b/modules/mesh-vault/go.sum index a460d122..6245202d 100644 --- a/modules/mesh-vault/go.sum +++ b/modules/mesh-vault/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 h1:eSwObLdxs3jxPrlcwdaMo9hzIDTUz2ogWw8cjX8To7I= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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/particulars b/particulars deleted file mode 100644 index b2ab98b1..00000000 --- a/particulars +++ /dev/null @@ -1,5 +0,0 @@ -# This mesh's node names (novox/hq ADR 0507 §9, issue 1838): the one place they may be written. -novox -ace -shanks -g14