Merge pull request 'Install the build agent at genesis, the module that builds since ADR 0190 (issue 596)' (#101) from fix/596-genesis-installs-the-build-agent into main
This commit was merged in pull request #101.
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