A build declares the vendor image it stands on, and a recipe fetches nothing undeclared

build.on takes {arg, image@sha256:…} beside {arg, module, artifact}: the image is
copied into the mesh's registry before the build (ADR 0096) and the recipe reads the
copy from the argument. A FROM or COPY --from naming a registry image the manifest
did not declare is refused before the build, naming it and the remedy; stages,
declared arguments and scratch are not fetches (novox/hq 04-ISSUES/064, ADR 0097).
This commit is contained in:
2026-09-21 20:45:36 +02:00
parent 412599de9a
commit 6f6e1244d4
3 changed files with 201 additions and 9 deletions
+127 -2
View File
@@ -14,6 +14,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -149,7 +150,20 @@ func Build(ctx context.Context, run Runner, publish Publisher,
// 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)
// An image published elsewhere that the build stands on is copied into the mesh's own
// registry first, like an upstream artifact (ADR 0096), and the recipe is handed the copy.
// Genesis has nowhere to copy to and pulls it into this machine's store instead.
mirror := func(ctx context.Context, from, repository string) (string, error) {
if m, can := publish.(Mirrorer); can {
say("bases", "copying %s into the mesh's registry", from)
return m.MirrorImage(ctx, from, repository)
}
if _, err := run(ctx, tree, "docker", "pull", from); err != nil {
return "", fmt.Errorf("cannot fetch %s: %w", from, err)
}
return from, nil
}
args, err := standingOn(ctx, manifest, held, mirror)
if err != nil {
say("bases", "UNMET: %v", err)
return Result{}, err
@@ -358,6 +372,29 @@ func one(ctx context.Context, run Runner, publish Publisher,
local := fmt.Sprintf("%s-%s:%s", module, a.Name, short(commit))
// 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.
// **A recipe fetches nothing the manifest did not declare** (novox/hq 04-ISSUES/064). A FROM
// or a COPY --from naming a registry image that is not a declared base is a build that
// reaches a public registry on its own — and works when that registry answers, which is
// sometimes. Refused here, in front of the person who can declare it, not inside a build
// that fails with "pull access denied" for a reason that is not the mesh's.
recipe, err := os.ReadFile(filepath.Join(tree, a.From))
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: cannot read the recipe %s: %w", module, a.From, err)
}
declared := map[string]bool{}
for i := 0; i+1 < len(args); i += 2 {
if args[i] == "--build-arg" {
declared[strings.SplitN(args[i+1], "=", 2)[0]] = true
}
}
if fetches := undeclaredFetches(string(recipe), declared); len(fetches) > 0 {
return catalogue.Built{}, fmt.Errorf(
"%s: the recipe %s fetches %s, which the manifest does not declare. A build "+
"reaching a public registry on its own works only when that registry answers; "+
"declare it under build.on as {\"arg\": \"<NAME>\", \"image\": \"<image>@sha256:…\"} "+
"and read it from that argument (novox/hq ADR 0097)",
module, a.From, strings.Join(fetches, ", "))
}
invocation := append([]string{"build", "-f", a.From, "-t", local}, args...)
if a.Target != "" {
invocation = append(invocation, "--target", a.Target)
@@ -578,7 +615,8 @@ var _ io.Writer = (*stringWriter)(nil)
// 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) {
func standingOn(ctx context.Context, manifest catalogue.Manifest, held map[string]string,
mirror func(ctx context.Context, from, repository string) (string, error)) ([]string, error) {
if manifest.Build == nil || len(manifest.Build.On) == 0 {
return nil, nil
}
@@ -587,6 +625,27 @@ func standingOn(manifest catalogue.Manifest, held map[string]string) ([]string,
var args []string
for _, base := range on {
if base.Image != "" {
// A vendor's image, declared (novox/hq 04-ISSUES/064, ADR 0097). Pinned, because a tag
// is what somebody else can move; copied into the mesh's registry, because a build
// that reaches a public registry on its own is a build that works sometimes.
if base.Arg == "" || base.Module != "" || base.Artifact != "" {
return nil, fmt.Errorf(
"%s stands on the image %s, and a base is either a module's artifact or an "+
"image — never both — read from one build argument", manifest.Module, base.Image)
}
if !strings.Contains(base.Image, "@sha256:") {
return nil, fmt.Errorf(
"%s stands on the image %q, which is not pinned by digest. A tag is what "+
"somebody else can move; name it as <image>@sha256:…", manifest.Module, base.Image)
}
reference, err := mirror(ctx, base.Image, manifest.Module+"/on-"+strings.ToLower(base.Arg))
if err != nil {
return nil, fmt.Errorf("%s stands on %s: %w", manifest.Module, base.Image, err)
}
args = append(args, "--build-arg", base.Arg+"="+reference)
continue
}
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 "+
@@ -727,3 +786,69 @@ func sourcesFor(entrypoints []string, out string) []string {
func timeNow() time.Time { return time.Now() }
func since(t time.Time) string { return time.Since(t).Round(time.Millisecond).String() }
// undeclaredFetches is every image a recipe reaches for that is neither a declared build argument
// nor one of its own stages nor `scratch`: a `FROM` or a `COPY --from` naming somebody else's
// registry directly.
func undeclaredFetches(recipe string, declared map[string]bool) []string {
stages := map[string]bool{}
var out []string
seen := map[string]bool{}
note := func(ref string) {
ref = strings.TrimSpace(ref)
switch {
case ref == "" || ref == "scratch" || stages[strings.ToLower(ref)]:
return
case strings.HasPrefix(ref, "$"):
name := strings.Trim(strings.TrimPrefix(ref, "$"), "{}")
if cut := strings.IndexAny(name, ":-"); cut >= 0 {
name = name[:cut]
}
if !declared[name] {
if !seen[ref] {
seen[ref] = true
out = append(out, ref+" (a build argument the manifest does not declare)")
}
}
return
}
// A stage referenced by number (COPY --from=0) is its own recipe's.
if _, err := strconv.Atoi(ref); err == nil {
return
}
if !seen[ref] {
seen[ref] = true
out = append(out, ref)
}
}
for _, raw := range strings.Split(recipe, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
switch strings.ToUpper(fields[0]) {
case "FROM":
// FROM [--platform=…] <ref> [AS <name>]
var ref string
for i := 1; i < len(fields); i++ {
if strings.HasPrefix(fields[i], "--") {
continue
}
ref = fields[i]
if i+2 < len(fields) && strings.EqualFold(fields[i+1], "AS") {
stages[strings.ToLower(fields[i+2])] = true
}
break
}
note(ref)
case "COPY", "ADD":
for _, f := range fields[1:] {
if strings.HasPrefix(f, "--from=") {
note(strings.TrimPrefix(f, "--from="))
}
}
}
}
return out
}
+65 -4
View File
@@ -1,6 +1,8 @@
package builder
import (
"context"
"fmt"
"strings"
"testing"
@@ -20,7 +22,7 @@ func TestABaseTheMeshHasNotBuiltIsRefused(t *testing.T) {
On: []catalogue.BuildsOn{{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"}},
},
}
_, err := standingOn(manifest, map[string]string{})
_, err := standingOn(context.Background(), manifest, map[string]string{}, noMirror)
if err == nil {
t.Fatal("a base nothing has built was accepted; the build would have failed on its first line")
}
@@ -40,7 +42,7 @@ func TestABaseTheMeshHoldsBecomesABuildArgument(t *testing.T) {
},
}
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)
args, err := standingOn(context.Background(), manifest, held, noMirror)
if err != nil {
t.Fatalf("a base this mesh holds was refused: %v", err)
}
@@ -52,7 +54,7 @@ func TestABaseTheMeshHoldsBecomesABuildArgument(t *testing.T) {
// 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)
args, err := standingOn(context.Background(), catalogue.Manifest{Module: "hello-web", Build: &catalogue.Build{}}, nil, noMirror)
if err != nil || args != nil {
t.Fatalf("a module naming no base produced %v, %v", args, err)
}
@@ -64,7 +66,66 @@ func TestAnIncompleteBaseIsRefused(t *testing.T) {
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 {
if _, err := standingOn(context.Background(), manifest, map[string]string{"mesh-tools/runtime": "x"}, noMirror); err == nil {
t.Fatal("a base with no build argument was accepted; nothing would have read it")
}
}
// noMirror is a mirror for tests whose bases are all the mesh's own.
func noMirror(context.Context, string, string) (string, error) {
return "", fmt.Errorf("nothing to copy in this test")
}
// A build may stand on an image published elsewhere, declared and pinned (novox/hq 04-ISSUES/064,
// ADR 0097): it is copied into the mesh's registry first and the recipe is handed the copy.
func TestADeclaredVendorImageIsCopiedInAndHandedToTheRecipe(t *testing.T) {
manifest := catalogue.Manifest{
Module: "minio",
Build: &catalogue.Build{
On: []catalogue.BuildsOn{{Arg: "MC_BASE", Image: "quay.io/minio/mc@sha256:" + strings.Repeat("c", 64)}},
},
}
var asked []string
args, err := standingOn(context.Background(), manifest, nil, func(_ context.Context, from, repository string) (string, error) {
asked = append(asked, from+" -> "+repository)
return "127.0.0.1:5000/" + repository + "@sha256:" + strings.Repeat("d", 64), nil
})
if err != nil {
t.Fatal(err)
}
if len(asked) != 1 || asked[0] != "quay.io/minio/mc@sha256:"+strings.Repeat("c", 64)+" -> minio/on-mc_base" {
t.Fatalf("the image was not copied under the module's repository: %v", asked)
}
if strings.Join(args, " ") != "--build-arg MC_BASE=127.0.0.1:5000/minio/on-mc_base@sha256:"+strings.Repeat("d", 64) {
t.Fatalf("the recipe was not handed the copy: %v", args)
}
// Unpinned, it is refused: a tag is what somebody else can move.
manifest.Build.On[0].Image = "quay.io/minio/mc:latest"
if _, err := standingOn(context.Background(), manifest, nil, noMirror); err == nil || !strings.Contains(err.Error(), "not pinned") {
t.Fatalf("an unpinned vendor image was accepted: %v", err)
}
}
// A recipe reaching for an image the manifest did not declare is named, and its own stages,
// declared arguments and scratch are not.
func TestARecipeFetchingWhatTheManifestDidNotDeclareIsNamed(t *testing.T) {
recipe := `
ARG RUNTIME_BASE
ARG MC_BASE
FROM ${RUNTIME_BASE} AS build
COPY --from=${MC_BASE} /usr/bin/mc /usr/local/bin/mc
COPY --from=build /out /out
COPY --from=0 /x /x
FROM scratch
COPY --from=vendor/tool:latest /tool /tool
FROM golang:1.25-alpine AS go
`
got := undeclaredFetches(recipe, map[string]bool{"RUNTIME_BASE": true})
want := []string{"${MC_BASE} (a build argument the manifest does not declare)", "vendor/tool:latest", "golang:1.25-alpine"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("got %v, want %v", got, want)
}
if got := undeclaredFetches(recipe, map[string]bool{"RUNTIME_BASE": true, "MC_BASE": true}); len(got) != 2 {
t.Fatalf("declared arguments are not fetches: %v", got)
}
}