diff --git a/modules/build-agent/README.md b/modules/build-agent/README.md index fd3a32c3..e44dd4ee 100644 --- a/modules/build-agent/README.md +++ b/modules/build-agent/README.md @@ -7,9 +7,23 @@ machine that is off builds nothing and blocks nothing. The controller asks the r the outcome names the machine that built it. What a holding machine needs is what the builder always needed, said here once: a container runtime -(the socket is mounted), the artifact store and the package registry as provisions, a workspace, and -the bus credential. The code is `cmd/mesh-builder` in the mesh-controller repository, compiled from +(the socket is mounted), the artifact store as a provision, a workspace, and the bus credential. The code is `cmd/mesh-builder` in the mesh-controller repository, compiled from that repository's main (`build.artifacts[].context`); this module ships the packaging. Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the same thing; retire that once this is assigned where it was. + +## The package registry (novox/hq ADR 0336) + +The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement. +Genesis assigns the build agent before anything provides it, so the agent is assignable on a mesh with no +registry, and `mesh-controller.plan` and `node` list the requirement as unbound until the gitea module is +assigned; it then binds with no further act. + +Until it binds, the agent on genesis's node reads the `genesis-package-registry` own-secret, which the +installer seals to it at the packages step: the forge it raised, the port, the npm path, the account +`mesh-builder` and its password. Issued outside the mesh, so no other machine is given one. A real binding +wins over it, and each build's log says which the build resolved from. A build that needs the registry (a +`package` artifact, an image whose Dockerfile names `.npmrc`, a TypeScript artifact with an `@novox/` +dependency) on an agent with neither fails before building, naming the machine. Go artifacts and images +that do not name `.npmrc` build without it. `manifest_test.go` holds the manifest to this. diff --git a/modules/build-agent/go.mod b/modules/build-agent/go.mod new file mode 100644 index 00000000..8d3d54b0 --- /dev/null +++ b/modules/build-agent/go.mod @@ -0,0 +1,3 @@ +module build-agent + +go 1.22 diff --git a/modules/build-agent/manifest_test.go b/modules/build-agent/manifest_test.go new file mode 100644 index 00000000..163d2d77 --- /dev/null +++ b/modules/build-agent/manifest_test.go @@ -0,0 +1,117 @@ +// The build agent's manifest (novox/hq ADR 0336, issue 121). The build agent is assigned at genesis's +// step 13, before anything provides the package registry, so that requirement is optional +// (ADR 0330); until it binds, the agent on genesis's node reads the genesis document the installer +// seals to it at the packages step. The program is mesh-controller's cmd/mesh-builder; only the +// packaging lives here, and this is its test. +package buildagent + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +type manifest struct { + Requires []json.RawMessage `json:"requires"` + Binds map[string]string `json:"binds"` + Secrets map[string]string `json:"secrets"` + OwnSecrets map[string]json.RawMessage `json:"own-secrets"` + Resources []map[string]any `json:"resources"` +} + +func read(t *testing.T) manifest { + t.Helper() + raw, err := os.ReadFile("module.json") + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +// requirement is one entry of `requires`: its name, and whether it is optional. +func requirements(t *testing.T, m manifest) map[string]bool { + t.Helper() + out := map[string]bool{} + for _, raw := range m.Requires { + var name string + if json.Unmarshal(raw, &name) == nil { + out[name] = false + continue + } + var long struct { + Name string `json:"name"` + Optional bool `json:"optional"` + } + if err := json.Unmarshal(raw, &long); err != nil { + t.Fatalf("requires: %s: %v", raw, err) + } + out[long.Name] = long.Optional + } + return out +} + +func TestThePackageRegistryIsOptionalAndTheArtifactStoreStaysHard(t *testing.T) { + r := requirements(t, read(t)) + optional, has := r["npm-package-registry"] + if !has || !optional { + t.Errorf("npm-package-registry is not an optional requirement (ADR 0336 §1): %v", r) + } + if optional, has := r["artifact-store"]; !has || optional { + t.Errorf("artifact-store is not a hard requirement: %v", r) + } +} + +// The binding and the credential of the registry are delivered only while it is bound: ADR 0330 §6 +// composes neither for an absent requirement, so no file of the module may name them through a +// placeholder unless it is itself only-when-bound. +func TestTheRegistrysBindsAndSecretsAreOnlyWhenBound(t *testing.T) { + m := read(t) + if m.Binds["npm-package-registry"] == "" || m.Secrets["npm-package-registry"] == "" { + t.Fatalf("the agent is not told where the registry is once it binds: binds %v, secrets %v", m.Binds, m.Secrets) + } + for _, r := range m.Resources { + content, _ := r["content"].(string) + if strings.Contains(content, "${bound:npm-package-registry:") || strings.Contains(content, "${secret:npm-package-registry}") { + if r["only-when-bound"] != "npm-package-registry" { + t.Errorf("%v names the registry's binding and is delivered while it is absent", r["id"]) + } + } + } +} + +// The genesis document is an own-secret issued outside the mesh — by the installer at genesis's packages +// step (ADR 0336 §3) — so the mesh never makes one of its own on any other machine, and the agent is +// told where it lands. +func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) { + m := read(t) + raw, has := m.OwnSecrets["genesis-package-registry"] + if !has { + t.Fatalf("the build agent declares no genesis-package-registry own-secret: %v", m.OwnSecrets) + } + var own struct { + Path string `json:"path"` + IssuedBy string `json:"issued-by"` + } + if err := json.Unmarshal(raw, &own); err != nil || own.IssuedBy != "outside" { + t.Fatalf("the genesis document is not issued outside the mesh: %s (%v)", raw, err) + } + if !strings.HasPrefix(own.Path, "${dir:mesh-state}/") { + t.Fatalf("the genesis document is not where the container reads /run/mesh: %s", own.Path) + } + inside := "/run/mesh/" + strings.TrimPrefix(own.Path, "${dir:mesh-state}/") + for _, r := range m.Resources { + if r["id"] == "agent-env" { + content, _ := r["content"].(string) + if !strings.Contains(content, "MESH_GENESIS_PACKAGE_REGISTRY="+inside+"\n") { + t.Errorf("the agent is not told where the genesis document is (%s):\n%s", inside, content) + } + return + } + } + t.Fatal("the build agent has no environment file") +} diff --git a/modules/build-agent/module.json b/modules/build-agent/module.json index 51a9b547..c4b1d615 100644 --- a/modules/build-agent/module.json +++ b/modules/build-agent/module.json @@ -14,7 +14,10 @@ ], "requires": [ "artifact-store", - "npm-package-registry" + { + "name": "npm-package-registry", + "optional": true + } ], "binds": { "npm-package-registry": "${dir:mesh-state}/package-registry.json" @@ -23,7 +26,11 @@ "npm-package-registry": "${dir:mesh-state}/package-registry.secret" }, "own-secrets": { - "broker": "${dir:mesh-state}/broker" + "broker": "${dir:mesh-state}/broker", + "genesis-package-registry": { + "path": "${dir:mesh-state}/genesis-package-registry.json", + "issued-by": "outside" + } }, "data": { "own": [ @@ -52,7 +59,7 @@ "type": "file", "path": "${dir:mesh-state}/build-agent.env", "mode": "0600", - "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=${dir:workspace}\n" + "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n" }, { "id": "server",