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
}