Install the build agent at genesis, the module that builds since ADR 0190 (hq issue 596)
Genesis's builder step read modules/builder from the catalogue, which retired it on 2026-10-03 for the build agent (node-build-agent on every machine), so no genesis got past step 13. The step now installs build-agent from the catalogue, puts the builder image it carried and published where the manifest names the image it builds, and issues its bus account as any module's.
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuilderModule is the module that lets a mesh produce anything at all.
|
// BuilderModule is the module that lets a mesh produce anything at all: the build agent, which holds
|
||||||
const BuilderModule = "builder"
|
// 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.
|
// BuilderRepository is what its image is called in this mesh's own registry.
|
||||||
const BuilderRepository = "mesh-builder"
|
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)
|
manifest, err := readManifest(o.Catalogue, BuilderModule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, fmt.Errorf("%w\n"+
|
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)
|
"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 {
|
if err != nil {
|
||||||
return out, err
|
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
|
// 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
|
// the one after it. A build agent that arrives without its credential starts, finds nothing it
|
||||||
// read, and waits — which looks exactly like a builder with no work.
|
// may read, and waits — which looks exactly like an agent with no work. The same act as any
|
||||||
account := o.Node + "-" + BuilderModule
|
// module's account (`module issue`), which a re-run repeats harmlessly.
|
||||||
if _, err := control.tell(ctx, "builder", "issue", account, "--node", o.Node); err != nil {
|
if _, err := control.tell(ctx, "module", "issue", BuilderModule, "--node", o.Node); err != nil {
|
||||||
// Said and carried on. An account that already exists is the ordinary case on a re-run,
|
return out, fmt.Errorf("the build agent has no account on the bus, so it can take no work: %w", err)
|
||||||
// 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")
|
|
||||||
}
|
}
|
||||||
|
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)
|
out.Installed.Pushed, err = pushNode(ctx, o, control, say)
|
||||||
return out, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package bootstrap
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -44,15 +45,15 @@ func TestTheBuildersPackageRegistryPortIsASettingAndNotTheManifest(t *testing.T)
|
|||||||
[]byte(theBuildersBinding), quietly); err != nil {
|
[]byte(theBuildersBinding), quietly); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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
|
// 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.
|
// 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)
|
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 {
|
if set < 0 || push < 0 || set > push {
|
||||||
t.Fatalf("the port was not set before the push that raises the builder: %v", c.told)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user