diff --git a/internal/bootstrap/builder.go b/internal/bootstrap/builder.go index b86c1b1..3c650c0 100644 --- a/internal/bootstrap/builder.go +++ b/internal/bootstrap/builder.go @@ -1,13 +1,16 @@ package bootstrap import ( + "bytes" "context" + "encoding/json" "fmt" - "strings" ) -// BuilderModule is the module that lets a mesh produce anything at all. -const BuilderModule = "builder" +// BuilderModule is the module that lets a mesh produce anything at all: the build agent, which holds +// the node seat `node-build-agent` (novox/hq ADR 0190). The one-holder `builder` module it replaced +// left the catalogue on 2026-10-03, and genesis went on asking for it (issue 596). +const BuilderModule = "build-agent" // BuilderRepository is what its image is called in this mesh's own registry. const BuilderRepository = "mesh-builder" @@ -50,10 +53,10 @@ func InstallBuilder(ctx context.Context, o Options, d Deps, control controlPlane manifest, err := readManifest(o.Catalogue, BuilderModule) if err != nil { return out, fmt.Errorf("%w\n"+ - "This is the manifest that makes the builder an ordinary module. Without it the mesh "+ + "This is the manifest that makes the build agent an ordinary module. Without it the mesh "+ "has the image and no way to run it, so nothing can be built here", err) } - pinned, places, err := pinPlaceholder(manifest, published.Reference, BuilderModule) + pinned, places, err := pinTheCarriedBuilder(manifest, published.Reference) if err != nil { return out, err } @@ -66,21 +69,72 @@ func InstallBuilder(ctx context.Context, o Options, d Deps, control controlPlane } // Before the push, so the account is in the declaration the machine receives rather than in - // the one after it. A builder that arrives without its credential starts, finds nothing it may - // read, and waits — which looks exactly like a builder with no work. - account := o.Node + "-" + BuilderModule - if _, err := control.tell(ctx, "builder", "issue", account, "--node", o.Node); err != nil { - // Said and carried on. An account that already exists is the ordinary case on a re-run, - // and the push below is what makes either state true on the machine. - if !strings.Contains(err.Error(), "already") { - return out, fmt.Errorf("the builder has no broker account, so it can take no work: %w", err) - } - say(" broker account " + account + " — already issued") - } else { - out.Account = true - say(" broker account " + account + ", scoped to what it consumes and emits") + // the one after it. A build agent that arrives without its credential starts, finds nothing it + // may read, and waits — which looks exactly like an agent with no work. The same act as any + // module's account (`module issue`), which a re-run repeats harmlessly. + if _, err := control.tell(ctx, "module", "issue", BuilderModule, "--node", o.Node); err != nil { + return out, fmt.Errorf("the build agent has no account on the bus, so it can take no work: %w", err) } + out.Account = true + say(" bus account " + BuilderModule + " on " + o.Node + ", scoped to what it consumes and emits") out.Installed.Pushed, err = pushNode(ctx, o, control, say) return out, err } + +// pinTheCarriedBuilder names the image this installer carried and published in the build agent's +// manifest, in place of the artifact the mesh would build for it. +// +// **The one module genesis cannot have built** (novox/hq ADR 0073): the build agent is what builds, +// and at this step the mesh has no build agent. The catalogue's manifest names its server container by +// the artifact it builds (`"artifact": "server"`, an image built from mesh-controller's +// cmd/mesh-builder); the image this installer carries is that build, made from the same program. So +// every container built from that image artifact runs the published reference instead, and the rest +// of the manifest — its build, its seat, its requirements — is the catalogue's as written. The mesh +// rebuilds it from the catalogue like any module from then on. +// +// A manifest still carrying the old placeholder digest is pinned the old way. One naming neither an +// image artifact nor a placeholder is refused: there would be nothing to put the carried image in. +func pinTheCarriedBuilder(manifest []byte, reference string) ([]byte, int, error) { + if bytes.Contains(manifest, []byte(placeholderDigest)) { + return pinPlaceholder(manifest, reference, BuilderModule) + } + var m map[string]any + if err := json.Unmarshal(manifest, &m); err != nil { + return nil, 0, fmt.Errorf("the %s module's manifest is not readable: %w", BuilderModule, err) + } + images := map[string]bool{} + if build, ok := m["build"].(map[string]any); ok { + artifacts, _ := build["artifacts"].([]any) + for _, a := range artifacts { + if art, ok := a.(map[string]any); ok && art["kind"] == "image" { + if name, ok := art["name"].(string); ok { + images[name] = true + } + } + } + } + places := 0 + resources, _ := m["resources"].([]any) + for _, r := range resources { + res, ok := r.(map[string]any) + if !ok || res["type"] != "container" { + continue + } + if name, ok := res["artifact"].(string); ok && images[name] { + delete(res, "artifact") + res["image"] = reference + places++ + } + } + if places == 0 { + return nil, 0, fmt.Errorf( + "the %s module's manifest runs no container from an image it builds, so there is nowhere to "+ + "put the builder this installer carried and published (%s)", BuilderModule, reference) + } + pinned, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, 0, err + } + return append(pinned, '\n'), places, nil +} diff --git a/internal/bootstrap/builder_test.go b/internal/bootstrap/builder_test.go index 26368e9..18feaf2 100644 --- a/internal/bootstrap/builder_test.go +++ b/internal/bootstrap/builder_test.go @@ -2,6 +2,7 @@ package bootstrap import ( "context" + "encoding/json" "os" "path/filepath" "strings" @@ -44,15 +45,15 @@ func TestTheBuildersPackageRegistryPortIsASettingAndNotTheManifest(t *testing.T) []byte(theBuildersBinding), quietly); err != nil { t.Fatal(err) } - if got := c.settings["builder-settings.json"]; got != `{"serves":{"port":3100}}` { + if got := c.settings[BuilderModule+"-settings.json"]; got != `{"serves":{"port":3100}}` { t.Errorf("the builder was told %q about the package registry's port", got) } // The manifest reaches the mesh as the catalogue wrote it. A port rewritten into it here is a // port the next registration from the catalogue silently takes back. - if got := c.settings["builder-module.json"]; got != theBuildersBinding { + if got := c.settings[BuilderModule+"-module.json"]; got != theBuildersBinding { t.Errorf("the installer changed the builder's manifest:\n%s", got) } - set, push := c.index("settings set builder"), c.index("push anchor") + set, push := c.index("settings set "+BuilderModule), c.index("push anchor") if set < 0 || push < 0 || set > push { t.Fatalf("the port was not set before the push that raises the builder: %v", c.told) } @@ -167,3 +168,73 @@ func TestAForgeManifestTheCatalogueDoesNotHaveIsNamed(t *testing.T) { t.Errorf("a missing forge manifest was not explained: %v", err) } } + +// theCataloguesModule is the catalogue's own manifest of a module, never a copy: the clone beside a merge +// check (MESH_CHECK_BESIDE), where it must be; else a checkout beside this one; else the test skips aloud. +func theCataloguesModule(t *testing.T, module string) []byte { + t.Helper() + inside := filepath.Join("mesh-catalog", "modules", module, "module.json") + if root := os.Getenv("MESH_CHECK_BESIDE"); root != "" { + raw, err := os.ReadFile(filepath.Join(root, inside)) + if err != nil { + t.Fatalf("the catalogue beside this check (MESH_CHECK_BESIDE=%s) has no %s: %v", root, inside, err) + } + return raw + } + raw, err := os.ReadFile(filepath.Join("..", "..", "..", inside)) + if err != nil { + t.Skipf("NOT JUDGED: no catalogue beside this checkout and no MESH_CHECK_BESIDE; the merge check judges it") + } + return raw +} + +// **Genesis installs the build agent the catalogue holds** (novox/hq issue 596, ADR 0190): the +// one-holder `builder` left the catalogue on 2026-10-03, and genesis stopped at step 13 asking for it. +// The catalogue's own manifest is read, and the image the installer carried is put where the manifest +// names the image it builds. +func TestGenesisInstallsTheCataloguesBuildAgent(t *testing.T) { + manifest := theCataloguesModule(t, BuilderModule) + const reference = "127.0.0.1:5000/mesh-builder@sha256:" + "4444444444444444444444444444444444444444444444444444444444444444" + pinned, places, err := pinTheCarriedBuilder(manifest, reference) + if err != nil { + t.Fatal(err) + } + var m struct { + Module string `json:"module"` + Claims []struct { + Name string `json:"name"` + } `json:"claims"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(pinned, &m); err != nil { + t.Fatal(err) + } + if m.Module != BuilderModule || len(m.Claims) == 0 || m.Claims[0].Name != "node-build-agent" { + t.Fatalf("genesis installs %q claiming %v; the build agent holds node-build-agent (ADR 0190)", m.Module, m.Claims) + } + running := 0 + for _, r := range m.Resources { + if r["type"] != "container" { + continue + } + if _, still := r["artifact"]; still { + t.Errorf("the container %v still names an artifact nothing has built yet", r["name"]) + } + if r["image"] == reference { + running++ + } + } + if places != 1 || running != 1 { + t.Errorf("the carried builder runs in %d container(s) (%d place(s) said); the build agent runs one", running, places) + } +} + +// A manifest that runs nothing from an image it builds has nowhere to put the carried builder, and is +// refused rather than registered running nothing. +func TestABuildAgentManifestWithNowhereForTheCarriedBuilderIsRefused(t *testing.T) { + _, _, err := pinTheCarriedBuilder([]byte(`{"module":"build-agent","resources":[{"id":"server","type":"container","name":"x","artifact":"server"}],"build":{"artifacts":[{"name":"server","kind":"bundle"}]}}`), + "127.0.0.1:5000/mesh-builder@sha256:"+strings.Repeat("4", 64)) + if err == nil || !strings.Contains(err.Error(), "nowhere to put") { + t.Fatalf("a manifest with no image artifact was pinned: %v", err) + } +}