From cfe28164958624dc6b8c800e5cf60b281f6bab63 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 23:53:22 +0200 Subject: [PATCH] A module names the module its build stands on, not a copy of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fingerprint written into a recipe names one particular copy of the base — the copy on whichever machine the person typing it was using. On any other mesh that copy has never existed, so the build stops on its first line with a message about an image nobody can look up. Three modules in the catalogue were in exactly that state, and the line each of them replaced was equally dead. A module now names the module and artifact instead, and the mesh answers with what it holds. The builder is still a thing that clones, builds and answers: the answer travels with the question, because only the mesh knows what it has. A base the mesh has not built is refused before anything is built, naming which module has to exist first. --- cmd/mesh-builder/main.go | 2 +- cmd/mesh-builder/once.go | 33 ++++++++++++- cmd/mesh-control/build.go | 25 ++++++++++ internal/builder/builder.go | 54 +++++++++++++++++++-- internal/builder/builder_test.go | 20 ++++---- internal/builder/standing_on_test.go | 70 ++++++++++++++++++++++++++++ internal/catalogue/manifest.go | 25 ++++++++++ internal/inventory/builds.go | 44 +++++++++++++++++ internal/link/build.go | 12 +++++ 9 files changed, 269 insertions(+), 16 deletions(-) create mode 100644 internal/builder/standing_on_test.go diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index 5f5fad5..2289726 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -184,7 +184,7 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis fmt.Println() built, err := builder.Build(ctx, builder.Command, publisher, - request.Repository, request.Path, request.Ref, workspace) + request.Repository, request.Path, request.Ref, workspace, request.Held) if err != nil { // A failure is a result. A build that fails and says nothing is indistinguishable from a // builder that is not running, and those want completely different responses. diff --git a/cmd/mesh-builder/once.go b/cmd/mesh-builder/once.go index 8a98042..2be3bdb 100644 --- a/cmd/mesh-builder/once.go +++ b/cmd/mesh-builder/once.go @@ -7,6 +7,7 @@ import ( "flag" "fmt" "os" + "strings" "github.com/novox/mesh-control/internal/builder" ) @@ -34,6 +35,8 @@ func buildOnce(ctx context.Context, args []string) error { registry := set.String("registry", "", "host:port to publish to. Without it the artifacts stay in this machine's container runtime, which is the genesis case") workspace := set.String("workspace", "", "where to clone and build (default: a temporary directory)") + on := multiple(set, "on", + "a base this build stands on, as /=. Repeatable") positionals, err := parseAround(set, args) if err != nil { return err @@ -48,6 +51,22 @@ func buildOnce(ctx context.Context, args []string) error { where = os.TempDir() + "/mesh-builder-once" } + // What this build may stand on, said on the command line because there is no mesh to ask. + // + // **This is the genesis case and it is meant to be awkward.** On a running mesh the control + // plane answers this, because only it knows what this mesh holds. Here nothing has been built + // yet, so whoever runs this says what to use — and for the control plane, which is the one + // module raised before anything else exists, the answer is ordinarily nothing at all. + bases := map[string]string{} + for _, pair := range *on { + key, reference, found := strings.Cut(pair, "=") + if !found || key == "" || reference == "" { + return fmt.Errorf( + "--on takes /=, and %q is not that", pair) + } + bases[key] = reference + } + // Local unless told otherwise, because the moment this exists for has nowhere to publish. A // default pointing at a registry would mean genesis failing at a push to something that is not // there yet, one step away from the thing that could explain it. @@ -65,7 +84,7 @@ func buildOnce(ctx context.Context, args []string) error { } fmt.Fprintln(os.Stderr) - built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where) + built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where, bases) if buildErr != nil { return buildErr } @@ -132,3 +151,15 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) { rest = rest[1:] } } + +// multiple is a flag that may be given more than once. +type repeated []string + +func (r *repeated) String() string { return strings.Join(*r, ", ") } +func (r *repeated) Set(v string) error { *r = append(*r, v); return nil } + +func multiple(set *flag.FlagSet, name, usage string) *[]string { + var values repeated + set.Var(&values, name, usage) + return (*[]string)(&values) +} diff --git a/cmd/mesh-control/build.go b/cmd/mesh-control/build.go index 76b7103..dc72584 100644 --- a/cmd/mesh-control/build.go +++ b/cmd/mesh-control/build.go @@ -8,6 +8,7 @@ import ( "errors" "flag" "fmt" + "os" "strings" "time" @@ -341,6 +342,7 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat Repository: repository, Path: path, Ref: ref, + Held: heldBy(ctx), } fmt.Printf("asked for %s", request.Repository) if path != "" { @@ -417,6 +419,7 @@ func buildAndShow(ctx context.Context, repository, path, ref string, wait time.D result, err := link.RequestBuild(ctx, server.Channel(), link.BuildRequest{ ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()), Repository: repository, Path: path, Ref: ref, + Held: heldBy(ctx), }, wait) if err != nil { return err @@ -460,3 +463,25 @@ type answers struct { // a mesh whose hub is that node has no hub. network string } + +// heldBy is every artifact this mesh has built, for a build that may need one as its base. +// +// **A failure here is not a failure to build.** A module that names no base does not need this at +// all, and one that does gets a refusal naming exactly what is missing — which is a better sentence +// than a build command refusing to start because a query did not run. So the store not opening is +// reported and the build goes ahead without it. +func heldBy(ctx context.Context) map[string]string { + open, err := openStores(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "could not read what this mesh has built, so a module naming a "+ + "base will be told that base is missing: %v\n", err) + return nil + } + defer open.Close() + held, err := open.inventory.Held(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "could not read what this mesh has built: %v\n", err) + return nil + } + return held +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go index ee5e9f4..55150ab 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -67,7 +67,7 @@ type Result struct { // archive failed would otherwise leave half of itself in the store under a digest the mesh never // records — reachable, unreferenced, and indistinguishable from something in use. func Build(ctx context.Context, run Runner, publish Publisher, - repository, path, ref, workspace string) (Result, error) { + repository, path, ref, workspace string, held map[string]string) (Result, error) { // Made rather than required. A builder that fails because the directory it was told to work // in does not exist is a builder that needs a setup step nobody documented. @@ -117,12 +117,19 @@ func Build(ctx context.Context, run Runner, publish Publisher, var built []catalogue.Built if manifest.Build != nil { + // What this module said it stands on, answered with what this mesh actually holds. Done + // before anything is built, so a missing base is refused in front of the person who can + // fix it rather than inside a build that stops on its own first line. + args, err := standingOn(manifest, held) + if err != nil { + return Result{}, err + } artifacts := append([]catalogue.Artifact{}, manifest.Build.Artifacts...) // Ordered, so two builds of one commit do the same work in the same sequence and their // logs can be compared. sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) for _, a := range artifacts { - made, err := one(ctx, run, publish, manifest.Module, within, commit, a) + made, err := one(ctx, run, publish, manifest.Module, within, commit, a, args) if err != nil { return Result{}, err } @@ -208,7 +215,7 @@ func against(within string, manifest catalogue.Manifest) []string { const ManifestName = "module.json" func one(ctx context.Context, run Runner, publish Publisher, - module, tree, commit string, a catalogue.Artifact) (catalogue.Built, error) { + module, tree, commit string, a catalogue.Artifact, args []string) (catalogue.Built, error) { switch a.Kind { case catalogue.ArtifactUpstream: @@ -229,7 +236,11 @@ func one(ctx context.Context, run Runner, publish Publisher, // release and a commit is what was actually built. The mesh pins the digest anyway; this // is only so a person looking at the build node can tell what is there. local := fmt.Sprintf("%s-%s:%s", module, a.Name, short(commit)) - if _, err := run(ctx, tree, "docker", "build", "-f", a.From, "-t", local, "."); err != nil { + // The bases this module named, resolved to what this mesh holds. A recipe reads them as + // build arguments, so a module says which module it stands on and never which copy. + invocation := append([]string{"build", "-f", a.From, "-t", local}, args...) + invocation = append(invocation, ".") + if _, err := run(ctx, tree, "docker", invocation...); err != nil { return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err) } reference, err := publish.PublishImage(ctx, local, module+"/"+a.Name) @@ -357,3 +368,38 @@ func Command(ctx context.Context, dir, name string, args ...string) (string, err } var _ io.Writer = (*stringWriter)(nil) + +// standingOn turns the bases a module named into build arguments for what this mesh holds. +// +// **Refused rather than defaulted** (novox/hq issue 044). A module naming a base the mesh has not +// built cannot be built here yet, and the useful sentence names which module is missing — not the +// one a container runtime produces when a recipe's first line refers to an image nobody has. +// +// The order is fixed so two builds of one commit invoke the same command. +func standingOn(manifest catalogue.Manifest, held map[string]string) ([]string, error) { + if manifest.Build == nil || len(manifest.Build.On) == 0 { + return nil, nil + } + on := append([]catalogue.BuildsOn{}, manifest.Build.On...) + sort.Slice(on, func(i, j int) bool { return on[i].Arg < on[j].Arg }) + + var args []string + for _, base := range on { + if base.Arg == "" || base.Module == "" || base.Artifact == "" { + return nil, fmt.Errorf( + "%s says its build stands on something, and does not say all of what: a base "+ + "needs the module, the artifact, and the build argument the recipe reads it "+ + "from", manifest.Module) + } + key := base.Module + "/" + base.Artifact + reference, has := held[key] + if !has { + return nil, fmt.Errorf( + "%s builds on %s, and this mesh has not built it. Build %s first — every module "+ + "in this toolchain stands on it, so it is the thing to have before anything "+ + "else", manifest.Module, key, base.Module) + } + args = append(args, "--build-arg", base.Arg+"="+reference) + } + return args, nil +} diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go index 6ef8ea2..7f1c2c3 100644 --- a/internal/builder/builder_test.go +++ b/internal/builder/builder_test.go @@ -108,7 +108,7 @@ func TestABuildProducesAManifestThePinsAreIn(t *testing.T) { r, workspace := aRepository(t, withBoth, map[string]string{ "Dockerfile": "FROM scratch", "files/theme.conf": "dark", }) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace) + got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil) if err != nil { t.Fatal(err) } @@ -134,7 +134,7 @@ func TestTwoBuildsOfOneCommitProduceOneDigest(t *testing.T) { }) // A year apart, so a packer carrying timestamps cannot accidentally agree. r.stamped = time.Date(2020+i, time.March, 3, 4, 5, 6, 0, time.UTC) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace) + got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil) if err != nil { t.Fatal(err) } @@ -154,7 +154,7 @@ func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) { // unreferenced, and indistinguishable from something in use. r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch"}) // `files` is missing, so packing the archive fails — after the image would have been pushed. - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace) + _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil) if err == nil { t.Fatal("a build with a missing input succeeded") } @@ -166,7 +166,7 @@ func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) { func TestARepositoryWithNoManifestSaysSo(t *testing.T) { workspace := t.TempDir() r := &recorded{contents: map[string]string{"README.md": "nothing to see"}} - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace) + _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil) if err == nil { t.Fatal("a repository with nothing saying what it is was built") } @@ -179,7 +179,7 @@ func TestAModuleThatBuildsNothingStillProducesAManifest(t *testing.T) { // Most of what a person installs is configuration. r, workspace := aRepository(t, `{"module":"shell","version":"1","resources":[ {"id":"rc","type":"file","path":"/etc/zsh/zshrc","content":"setopt"}]}`, nil) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace) + got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace, nil) if err != nil { t.Fatal(err) } @@ -209,7 +209,7 @@ func TestTheTreeIsFreshEveryTime(t *testing.T) { if err := os.WriteFile(leftover, []byte("stale"), 0o644); err != nil { t.Fatal(err) } - if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace); err != nil { + if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil); err != nil { t.Fatal(err) } if _, err := os.Stat(leftover); err == nil { @@ -222,7 +222,7 @@ func TestABuildThatCannotPushFails(t *testing.T) { "Dockerfile": "FROM scratch", "files/a": "b", }) r.failPush = true - if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace); err == nil { + if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil); err == nil { t.Fatal("a build that could publish nothing reported success") } } @@ -236,7 +236,7 @@ func TestAnUpstreamImageIsMirroredRatherThanBuilt(t *testing.T) { "resources":[{"id":"db","type":"container","name":"mesh-postgres","artifact":"store"}]}` r, workspace := aRepository(t, mirrors, nil) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace) + got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace, nil) if err != nil { t.Fatal(err) } @@ -302,7 +302,7 @@ func TestAModuleIsBuiltFromItsPathWithinTheRepository(t *testing.T) { "modules/other/" + ManifestName: `{"module":"other","version":"1"}`, }} got, err := Build(context.Background(), r.run, r, - "https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir()) + "https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir(), nil) if err != nil { t.Fatal(err) } @@ -321,7 +321,7 @@ func TestAPathThatLeavesTheRepositoryIsRefused(t *testing.T) { for _, escaping := range []string{"../../etc", "/etc"} { r := &recorded{contents: map[string]string{ManifestName: withBoth}} _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/x.git", escaping, "", t.TempDir()) + "https://forge.invalid/x.git", escaping, "", t.TempDir(), nil) if err == nil { t.Fatalf("%q was accepted as a module's path", escaping) } diff --git a/internal/builder/standing_on_test.go b/internal/builder/standing_on_test.go new file mode 100644 index 0000000..b4d3e1f --- /dev/null +++ b/internal/builder/standing_on_test.go @@ -0,0 +1,70 @@ +package builder + +import ( + "strings" + "testing" + + "github.com/novox/mesh-control/internal/catalogue" +) + +// A module naming a base the mesh has not built is refused, and the refusal names what is missing. +// +// **This is the whole point of naming a base rather than pinning one** (novox/hq issue 044). A +// recipe with a fingerprint typed into it fails inside a container runtime, on its first line, with +// a message about an image nobody can look up. This fails before anything is built, saying which +// module has to exist first. +func TestABaseTheMeshHasNotBuiltIsRefused(t *testing.T) { + manifest := catalogue.Manifest{ + Module: "postgres", + Build: &catalogue.Build{ + On: []catalogue.BuildsOn{{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"}}, + }, + } + _, err := standingOn(manifest, map[string]string{}) + if err == nil { + t.Fatal("a base nothing has built was accepted; the build would have failed on its first line") + } + for _, want := range []string{"mesh-tools", "postgres"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } +} + +// And one the mesh holds becomes the argument the recipe reads it from. +func TestABaseTheMeshHoldsBecomesABuildArgument(t *testing.T) { + manifest := catalogue.Manifest{ + Module: "postgres", + Build: &catalogue.Build{ + On: []catalogue.BuildsOn{{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"}}, + }, + } + held := map[string]string{"mesh-tools/runtime": "127.0.0.1:5000/mesh-tools/runtime@sha256:" + strings.Repeat("a", 64)} + args, err := standingOn(manifest, held) + if err != nil { + t.Fatalf("a base this mesh holds was refused: %v", err) + } + want := []string{"--build-arg", "RUNTIME_BASE=" + held["mesh-tools/runtime"]} + if len(args) != len(want) || args[0] != want[0] || args[1] != want[1] { + t.Fatalf("the build was invoked with %v, not %v", args, want) + } +} + +// A module naming no base asks for nothing, which is most modules. +func TestAModuleNamingNoBaseAddsNoArguments(t *testing.T) { + args, err := standingOn(catalogue.Manifest{Module: "hello-web", Build: &catalogue.Build{}}, nil) + if err != nil || args != nil { + t.Fatalf("a module naming no base produced %v, %v", args, err) + } +} + +// Half a base is refused rather than half-applied. +func TestAnIncompleteBaseIsRefused(t *testing.T) { + manifest := catalogue.Manifest{ + Module: "postgres", + Build: &catalogue.Build{On: []catalogue.BuildsOn{{Module: "mesh-tools", Artifact: "runtime"}}}, + } + if _, err := standingOn(manifest, map[string]string{"mesh-tools/runtime": "x"}); err == nil { + t.Fatal("a base with no build argument was accepted; nothing would have read it") + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 7a767d6..0a6d581 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -344,6 +344,31 @@ type Build struct { // Artifacts are what the source produces, each named so a resource can refer to it before // anybody knows its digest. Artifacts []Artifact `json:"artifacts,omitempty"` + // On is what this module's own build stands on: another module's artifact, named rather than + // pinned. + // + // **A module may not write down which copy of its base to use** (novox/hq issue 044). Every + // module in a scripted toolchain is compiled inside one shared image, and a fingerprint typed + // into a recipe names one particular copy of it — the copy on whichever machine the person + // typing was using. On any other mesh that copy has never existed, so the build stops on its + // first line. Naming the module instead lets the mesh answer with the copy *this* mesh has, + // which is the only one it can fetch. + // + // It does not make the build edge declared. What this says is where to start; what the build + // was actually built against is still read back out of the build itself (ADR 0009), and the + // two can disagree — a recipe that names a base and then bakes in a second one is exactly the + // drift that reading it back catches. + On []BuildsOn `json:"on,omitempty"` +} + +// BuildsOn is one base a build needs, and the name the recipe knows it by. +type BuildsOn struct { + // Arg is the build argument the recipe reads it from. + Arg string `json:"arg"` + // Module is whose artifact it is. + Module string `json:"module"` + // Artifact is which of that module's artifacts, by its own name for it. + Artifact string `json:"artifact"` } // Artifact is one thing built from a module's source. diff --git a/internal/inventory/builds.go b/internal/inventory/builds.go index 83fe1d7..9990993 100644 --- a/internal/inventory/builds.go +++ b/internal/inventory/builds.go @@ -100,3 +100,47 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui } return out, rows.Err() } + +// Held is every artifact this mesh has built, keyed "/". +// +// **The newest successful build of each module wins**, which is the same rule the rest of the mesh +// uses for what a module currently is. A module rebuilt to something broken and then rebuilt again +// is at the second one; a module whose last build failed is at the last one that worked, because a +// failure published nothing and the thing it published before is still what exists. +// +// Only successes, and only builds that knew what they were building: a build that failed before it +// could read a manifest has no module to be the artifact of. +func (i *Inventory) Held(ctx context.Context) (map[string]string, error) { + rows, err := i.store.Pool().Query(ctx, + `select distinct on (module) module, made + from build + where module is not null and module <> '' and failed = '' + order by module, at desc`) + if err != nil { + return nil, err + } + defer rows.Close() + + held := map[string]string{} + for rows.Next() { + var module string + var raw []byte + if err := rows.Scan(&module, &raw); err != nil { + return nil, err + } + var made []Artifact + if err := json.Unmarshal(raw, &made); err != nil { + // Skipped rather than fatal. One unreadable build record should not stop every other + // module's base from being answerable — and the build that needs this one will say + // plainly that it is missing. + continue + } + for _, artifact := range made { + if artifact.Name == "" || artifact.Reference == "" { + continue + } + held[module+"/"+artifact.Name] = artifact.Reference + } + } + return held, rows.Err() +} diff --git a/internal/link/build.go b/internal/link/build.go index 64ecd96..2ebe602 100644 --- a/internal/link/build.go +++ b/internal/link/build.go @@ -51,6 +51,18 @@ type BuildRequest struct { // repository root, which is the ordinary case; a repository holding several modules names // each by its own directory. Path string `json:"path,omitempty"` + // Held is every artifact this mesh has built, keyed "/". + // + // **Sent with the asking rather than fetched by the builder** (novox/hq issue 044). A module + // says which module's artifact its build stands on; only the mesh knows which copy of that + // artifact *this* mesh holds, and the builder is deliberately a thing that clones, runs a + // build and answers — giving it a way to ask the mesh questions would make it something else. + // So the answer travels with the question. + // + // It is everything rather than only what this module needs, because what this module needs is + // written in a manifest the mesh has not read: it is inside the repository, and reading it is + // the build's first act. + Held map[string]string `json:"held,omitempty"` } // BuildResult is what a builder says back.