From 7753c27042e0dfa0dfec6091b4b8aa9ad6c76e76 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:49:50 +0000 Subject: [PATCH 1/6] Make dev-tools, lab and the two build modules require the toolchain provisions instead of declaring the packages (issue 1573) --- modules/dev-tools/module.json | 19 ++++--------------- modules/lab/module.json | 14 +++----------- modules/mesh-build-go/module.json | 8 ++------ modules/mesh-build-node/module.json | 13 ++----------- 4 files changed, 11 insertions(+), 43 deletions(-) diff --git a/modules/dev-tools/module.json b/modules/dev-tools/module.json index 8ee6fd1a..094f8336 100644 --- a/modules/dev-tools/module.json +++ b/modules/dev-tools/module.json @@ -4,12 +4,11 @@ "capabilities": [ "package-manager" ], + "requires": [ + "go-toolchain", + "node-toolchain" + ], "resources": [ - { - "id": "go", - "type": "package", - "package": "go" - }, { "id": "c-compiler", "type": "package", @@ -20,16 +19,6 @@ "type": "package", "package": "make" }, - { - "id": "nodejs", - "type": "package", - "package": "nodejs" - }, - { - "id": "npm", - "type": "package", - "package": "npm" - }, { "id": "python", "type": "package", diff --git a/modules/lab/module.json b/modules/lab/module.json index e23963df..ef82b87c 100644 --- a/modules/lab/module.json +++ b/modules/lab/module.json @@ -6,7 +6,9 @@ "virtualisation" ], "requires": [ - "npm-package-registry" + "npm-package-registry", + "go-toolchain", + "node-toolchain" ], "contributes": { "npm-package-registry": { @@ -84,16 +86,6 @@ "type": "package", "package": "iproute2" }, - { - "id": "npm", - "type": "package", - "package": "npm" - }, - { - "id": "go", - "type": "package", - "package": "go" - }, { "id": "incus", "type": "package", diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index bb7f65de..74f4d97c 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -27,7 +27,8 @@ } ], "requires": [ - "forge-account" + "forge-account", + "go-toolchain" ], "own-secrets": { "broker": "${dir:state}/broker" @@ -105,11 +106,6 @@ "mode": "0644", "template": "files/gitconfig" }, - { - "id": "go", - "type": "package", - "package": "go" - }, { "id": "git", "type": "package", diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index 6c06d5e2..a11cb96d 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -29,7 +29,8 @@ ], "requires": [ "forge-account", - "npm-package-registry" + "npm-package-registry", + "node-toolchain" ], "contributes": { "npm-package-registry": { @@ -126,16 +127,6 @@ "artifact": "toolchain", "unknown": "program" }, - { - "id": "nodejs", - "type": "package", - "package": "nodejs" - }, - { - "id": "npm", - "type": "package", - "package": "npm" - }, { "id": "git", "type": "package", From c4cd7a1b16252eca10b0df54217a20465b094084 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:50:14 +0000 Subject: [PATCH 2/6] Check that only the go and nodejs modules declare the toolchain packages (issue 1573) --- checks/toolchains/go.mod | 3 + checks/toolchains/main.go | 104 +++++++++++++++++++++++++++++++++ checks/toolchains/main_test.go | 64 ++++++++++++++++++++ modules/dev-tools/README.md | 6 +- modules/lab/README.md | 2 +- 5 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 checks/toolchains/go.mod create mode 100644 checks/toolchains/main.go create mode 100644 checks/toolchains/main_test.go diff --git a/checks/toolchains/go.mod b/checks/toolchains/go.mod new file mode 100644 index 00000000..63d6ec97 --- /dev/null +++ b/checks/toolchains/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/toolchains + +go 1.22 diff --git a/checks/toolchains/main.go b/checks/toolchains/main.go new file mode 100644 index 00000000..8954a5c6 --- /dev/null +++ b/checks/toolchains/main.go @@ -0,0 +1,104 @@ +// Command toolchains refuses any manifest but the owner's declaring a language's toolchain package (novox/hq +// ADR 0477 clause 4, issue 1573): the package go only in modules/go, and nodejs and npm only in modules/nodejs. +// Everything that compiles or runs that language requires the owner's provision instead (go-toolchain, +// node-toolchain), so the package is declared once, and a node without the owner refuses the module that +// needs it at resolution rather than failing at its first build. The owners must declare their packages. +// +// go run . +// +// Exits 1 on a finding. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// Owners names, for each toolchain package, the one module that declares it. +var Owners = map[string]string{ + "go": "go", + "nodejs": "nodejs", + "npm": "nodejs", +} + +// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare +// the Package it owns. +type Finding struct { + Module, Package, Owner string + Missing bool +} + +// Check reads every manifest under root/modules and answers what it refuses. +func Check(root string) ([]Finding, error) { + manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + sort.Strings(manifests) + var out []Finding + declared := map[string]map[string]bool{} // module -> packages + for _, path := range manifests { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var m struct { + Module string `json:"module"` + Resources []struct { + Type string `json:"type"` + Package string `json:"package"` + } `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + declared[m.Module] = map[string]bool{} + for _, r := range m.Resources { + if r.Type != "package" { + continue + } + declared[m.Module][r.Package] = true + if owner, ok := Owners[r.Package]; ok && owner != m.Module { + out = append(out, Finding{Module: m.Module, Package: r.Package, Owner: owner}) + } + } + } + var pkgs []string + for p := range Owners { + pkgs = append(pkgs, p) + } + sort.Strings(pkgs) + for _, p := range pkgs { + if !declared[Owners[p]][p] { + out = append(out, Finding{Module: Owners[p], Package: p, Owner: Owners[p], Missing: true}) + } + } + return out, nil +} + +func main() { + root := "." + if len(os.Args) > 1 { + root = os.Args[1] + } + found, err := Check(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + for _, f := range found { + if f.Missing { + fmt.Printf("%s does not declare the package %s it owns (novox/hq ADR 0477 clause 4)\n", f.Owner, f.Package) + continue + } + fmt.Printf("%s declares the package %s: only %s may; require its provision instead (novox/hq ADR 0477 clause 4)\n", + f.Module, f.Package, f.Owner) + } + if len(found) > 0 { + os.Exit(1) + } + fmt.Println("toolchains: go, nodejs and npm are declared only by their owners") +} diff --git a/checks/toolchains/main_test.go b/checks/toolchains/main_test.go new file mode 100644 index 00000000..b80cd58e --- /dev/null +++ b/checks/toolchains/main_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func catalogue(t *testing.T, manifests map[string]string) string { + t.Helper() + root := t.TempDir() + for name, body := range manifests { + dir := filepath.Join(root, "modules", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) { + root := catalogue(t, map[string]string{ + "go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`, + "nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`, + "lab": `{"module":"lab","resources":[{"id":"git","type":"package","package":"git"},{"id":"go","type":"package","package":"go"}]}`, + "builder": `{"module":"builder","resources":[{"id":"n","type":"package","package":"nodejs"},{"id":"dir","type":"directory","path":"/go"}]}`, + }) + got, err := Check(root) + if err != nil { + t.Fatal(err) + } + want := []Finding{{Module: "builder", Package: "nodejs", Owner: "nodejs"}, {Module: "lab", Package: "go", Owner: "go"}} + if !reflect.DeepEqual(got, want) { + t.Errorf("found %v, want %v", got, want) + } +} + +func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) { + root := catalogue(t, map[string]string{ + "go": `{"module":"go","resources":[]}`, + }) + got, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Errorf("an owner missing its packages gave %v, want three findings (go, nodejs, npm)", got) + } +} + +// The catalogue as it stands: this is the check itself, run here too. +func TestTheCatalogueGivesEachToolchainPackageToItsOwnerAlone(t *testing.T) { + got, err := Check("../..") + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Errorf("the catalogue declares a toolchain package outside its owner: %v", got) + } +} diff --git a/modules/dev-tools/README.md b/modules/dev-tools/README.md index 35017a93..64eb8d9a 100644 --- a/modules/dev-tools/README.md +++ b/modules/dev-tools/README.md @@ -5,16 +5,16 @@ machine: an agent working on a change builds it and runs its tests there, with t pull request. The lab is the QA step after that: it tests the work in a sandbox, through its seat at the mesh scope, and is not where development happens. -Assign it to every node. +Assign it to every node, with the `go` and `nodejs` modules: it requires their provisions `go-toolchain` and `node-toolchain` (novox/hq ADR 0477 clause 4). ## Owns | what | package | class (ADR 0182) | |---|---|---| -| the Go toolchain, for `go build` and `go test` in every Go module | `go` | package | +| the Go toolchain, for `go build` and `go test` in every Go module | the `go` module's package `go` | package | | a C compiler, for `go test -race` and cgo | `gcc` | package | | make | `make` | package | -| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | `nodejs`, `npm` | package | +| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | the `nodejs` module's packages `nodejs`, `npm` | package | | Python, for the scripts some checks run | `python` | package | | git and jq | `git`, `jq` | package | diff --git a/modules/lab/README.md b/modules/lab/README.md index 17db440e..6ce0f8d5 100644 --- a/modules/lab/README.md +++ b/modules/lab/README.md @@ -1,6 +1,6 @@ # lab -The `lab` module of the mesh catalogue: installs the package `git`; installs the package `make`; installs the package `python`; installs the package `file`; installs the package `iproute2`; installs the package `npm`; installs the package `go`; installs the package `incus`. +The `lab` module of the mesh catalogue: installs the package `git`; installs the package `make`; installs the package `python`; installs the package `file`; installs the package `iproute2`; installs the package `incus`; requires `go-toolchain` and `node-toolchain`, so the `go` module and the `nodejs` module are assigned to a machine running it (novox/hq ADR 0477 clause 4). ## Files it does not know From 9d1f15260340a752d38ce66338b7f3bf9888c735 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:50:46 +0000 Subject: [PATCH 3/6] Wire the toolchains check into the merge check (issue 1573) --- merge-check.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/merge-check.sh b/merge-check.sh index 45d4a1ec..d3aacbf6 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -53,6 +53,8 @@ set -eu (cd checks/words && go test -count=1 . && go run . ../..) +# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs. +(cd checks/toolchains && go test -count=1 . && go run . ../..) # The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it. (cd checks/agenthome && go test -count=1 . && go run . ../..) # The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it. From bda75011a13ba6728ef1db4ff418d7834755eb72 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:50:55 +0000 Subject: [PATCH 4/6] Say in the build modules' READMEs where the packages come from (issue 1573) --- modules/mesh-build-go/README.md | 4 ++-- modules/mesh-build-node/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/mesh-build-go/README.md b/modules/mesh-build-go/README.md index 9bf8b620..88bd48a9 100644 --- a/modules/mesh-build-go/README.md +++ b/modules/mesh-build-go/README.md @@ -2,8 +2,8 @@ Design 47, release R4c-3 (novox/hq ADR 0405, ADR 0466 §1, ADR 0469, ADR 0470). The module that builds and checks Go code on a build machine, as an account of its own (`runs-as`, no login shell, home at mode 0700), with the -toolchain installed as the packages it declares (`go`, `git`, `openssh`, `postgresql`) and the capability -`user-namespaces`. It claims no seat: it is assigned to the build machines (ADR 0469 §6) and runs dark until the +toolchain installed as the packages it declares (`git`, `openssh`, `postgresql`; `go` comes from the `go` module, which this module requires as `go-toolchain`) and the capability +`user-namespaces`. It claims no seat: it is assigned to the build machines (ADR 0469 clause 6) and runs dark until the builder uses it. ## The recipe diff --git a/modules/mesh-build-node/README.md b/modules/mesh-build-node/README.md index 556cb1ee..9ade36fc 100644 --- a/modules/mesh-build-node/README.md +++ b/modules/mesh-build-node/README.md @@ -2,8 +2,8 @@ Design 47, release R4c-3 (novox/hq ADR 0405, ADR 0466 §1, ADR 0469 §1 and §4). The module that builds and checks TypeScript code on a build machine, as an account of its own (`runs-as`, no login shell, home at mode 0700), with the -packages it declares (`nodejs`, `npm`, `git`, `openssh`) and the capability `user-namespaces`. The bundle is Go -(`cmd/mesh-build-node`). It claims no seat: it is assigned to the build machines (ADR 0469 §6) and runs dark until the +packages it declares (`git`, `openssh`; `nodejs` and `npm` come from the `nodejs` module, which this module requires as `node-toolchain`) and the capability `user-namespaces`. The bundle is Go +(`cmd/mesh-build-node`). It claims no seat: it is assigned to the build machines (ADR 0469 clause 6) and runs dark until the builder uses it. Its forge key and ssh files are the same as mesh-build-go's (issue 1484), filled on the node by the provisioner's client (issue 1537). From d8d153295ab20de38bdf3af0917595bb2a6d9df2 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:51:22 +0000 Subject: [PATCH 5/6] Say in the go and nodejs READMEs that they are now the only declarers (issue 1573) --- modules/go/README.md | 15 ++++++--------- modules/nodejs/README.md | 16 +++++++--------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/modules/go/README.md b/modules/go/README.md index 9d0bd841..c84f9ff2 100644 --- a/modules/go/README.md +++ b/modules/go/README.md @@ -1,14 +1,11 @@ # go -The Go toolchain as a module (novox/hq ADR 0477 §4, issue 1573). +The Go toolchain as a module (novox/hq ADR 0477 clause 4, issue 1573). -- **Declares the package `go`.** Today dev-tools, lab and mesh-build-go declare it too, which the resolver allows - (ADR 0303); once they require `go-toolchain` instead (issue 1573's second step), this is the only module that - declares it, and `checks/toolchains` refuses another. -- **Provides `go-toolchain` with the machine's reach**: a module that requires it composes only on a machine where - this module is assigned. It is never pulled in for whatever asked, so `go` is assigned to a node on purpose. - Nothing requires it yet; dev-tools, lab and mesh-build-go will, in the second step. +- **The only module that declares the package `go`.** `checks/toolchains` refuses any other that does. +- **Provides `go-toolchain` with the machine's reach**: dev-tools, lab and mesh-build-go require it, so each + composes only on a machine where this module is assigned. It is never pulled in for whatever asked, so `go` is + assigned to a node on purpose, and a node without it refuses those modules at resolution. - Holds no long-running resource, so it adds nothing to `health-undeclared`. -The node-engine never uninstalls a package, so when the users stop declaring `go`, nothing leaves a node that has -this module. +The node-engine never uninstalls a package, so moving the declaration here removed nothing from a node. diff --git a/modules/nodejs/README.md b/modules/nodejs/README.md index e8241558..c71fb9b1 100644 --- a/modules/nodejs/README.md +++ b/modules/nodejs/README.md @@ -1,14 +1,12 @@ # nodejs -Node.js and npm as a module (novox/hq ADR 0477 §4, issue 1573). +Node.js and npm as a module (novox/hq ADR 0477 clause 4, issue 1573). -- **Declares the packages `nodejs` and `npm`.** Today dev-tools, lab and mesh-build-node declare some of them too, - which the resolver allows (ADR 0303); once they require `node-toolchain` instead (issue 1573's second step), this - is the only module that declares them, and `checks/toolchains` refuses another. -- **Provides `node-toolchain` with the machine's reach**: a module that requires it composes only on a machine where - this module is assigned. It is never pulled in for whatever asked, so `nodejs` is assigned to a node on purpose. - Nothing requires it yet; dev-tools, lab and mesh-build-node will, in the second step. +- **The only module that declares the packages `nodejs` and `npm`.** `checks/toolchains` refuses any other that + does. +- **Provides `node-toolchain` with the machine's reach**: dev-tools, lab and mesh-build-node require it, so each + composes only on a machine where this module is assigned. It is never pulled in for whatever asked, so `nodejs` + is assigned to a node on purpose, and a node without it refuses those modules at resolution. - Holds no long-running resource, so it adds nothing to `health-undeclared`. -The node-engine never uninstalls a package, so when the users stop declaring `nodejs` and `npm`, nothing leaves a -node that has this module. +The node-engine never uninstalls a package, so moving the declarations here removed nothing from a node. From 36259f3406f7f6fbda4087a9d0419908e4a3d4ab Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:53:48 +0000 Subject: [PATCH 6/6] Update the build modules' manifest tests for the toolchain provisions (issue 1573) --- modules/mesh-build-go/cmd/mesh-build-go/forge_test.go | 4 ++-- modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go | 2 +- modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go index 27a0cc76..ac9e1666 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go @@ -143,8 +143,8 @@ func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) { // template in the files/ archive (novox/hq ADR 0349 §2). func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) { m := readForgeManifest(t) - if len(m.Requires) != 1 || m.Requires[0] != "forge-account" { - t.Errorf("requires = %v, want [forge-account]", m.Requires) + if len(m.Requires) != 2 || m.Requires[0] != "forge-account" || m.Requires[1] != "go-toolchain" { + t.Errorf("requires = %v, want [forge-account go-toolchain]", m.Requires) } keeps := false for _, s := range m.State { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go index e1436f55..99355848 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go @@ -64,7 +64,7 @@ func TestManifestIsValidAndDeclaresTheAccountTheCapabilityAndThePackages(t *test } } slices.Sort(packages) - if want := []string{"git", "go", "openssh", "postgresql"}; !slices.Equal(packages, want) { + if want := []string{"git", "openssh", "postgresql"}; !slices.Equal(packages, want) { t.Errorf("packages = %v, want %v", packages, want) } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go index 8fb06a2d..efc359e9 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go @@ -66,7 +66,7 @@ func TestManifestIsValidAndDeclaresTheAccountTheCapabilityAndThePackages(t *test } } slices.Sort(packages) - if want := []string{"git", "nodejs", "npm", "openssh"}; !slices.Equal(packages, want) { + if want := []string{"git", "openssh"}; !slices.Equal(packages, want) { t.Errorf("packages = %v, want %v", packages, want) } } @@ -134,6 +134,9 @@ func TestTheModuleCarriesItsToolchainLockAndTheManifestPlacesIt(t *testing.T) { t.Errorf("the module carries no toolchain/%s: %v", name, err) } } + if !slices.Contains(m.Requires, "node-toolchain") { + t.Errorf("requires = %v lack node-toolchain, which provides the nodejs and npm packages (issue 1573)", m.Requires) + } if !slices.Contains(m.Requires, "npm-package-registry") { t.Errorf("requires = %v lack npm-package-registry, which the install resolves @novox from", m.Requires) }