An undeclared COPY --from is refused; an undeclared FROM is said, not yet refused

The mesh's own images start FROM a public base — the control plane's, the builder's,
the tool runtime's — and refusing those refuses genesis. They declare their bases
next; until then the base is named every build, with the remedy.
This commit is contained in:
2026-09-21 20:48:00 +02:00
parent 6f6e1244d4
commit e81f352979
2 changed files with 32 additions and 16 deletions
+22 -10
View File
@@ -387,13 +387,23 @@ func one(ctx context.Context, run Runner, publish Publisher,
declared[strings.SplitN(args[i+1], "=", 2)[0]] = true
}
}
if fetches := undeclaredFetches(string(recipe), declared); len(fetches) > 0 {
bases, copies := undeclaredFetches(string(recipe), declared)
if len(copies) > 0 {
return catalogue.Built{}, fmt.Errorf(
"%s: the recipe %s fetches %s, which the manifest does not declare. A build "+
"%s: the recipe %s copies out of %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, ", "))
module, a.From, strings.Join(copies, ", "))
}
if len(bases) > 0 {
// Said, not yet refused: the mesh's own images start FROM a public base — the control
// plane's, the builder's, the tool runtime's — and refusing those refuses genesis.
// They declare their bases next; until then a base fetched on its own is named here,
// with the remedy, every build.
say("recipe", "UNDECLARED base(s) %s in %s — declare each under build.on as "+
"{arg, image@sha256:…} and read it from that argument (novox/hq ADR 0097)",
strings.Join(bases, ", "), a.From)
}
invocation := append([]string{"build", "-f", a.From, "-t", local}, args...)
if a.Target != "" {
@@ -788,12 +798,12 @@ 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 {
// nor one of its own stages nor `scratch`, in two lists: the bases it starts `FROM`, and the images
// it `COPY --from`s out of — a vendor's tool, the case novox/hq 04-ISSUES/064 is about.
func undeclaredFetches(recipe string, declared map[string]bool) (bases, copies []string) {
stages := map[string]bool{}
var out []string
seen := map[string]bool{}
var out *[]string
note := func(ref string) {
ref = strings.TrimSpace(ref)
switch {
@@ -807,7 +817,7 @@ func undeclaredFetches(recipe string, declared map[string]bool) []string {
if !declared[name] {
if !seen[ref] {
seen[ref] = true
out = append(out, ref+" (a build argument the manifest does not declare)")
*out = append(*out, ref+" (a build argument the manifest does not declare)")
}
}
return
@@ -818,7 +828,7 @@ func undeclaredFetches(recipe string, declared map[string]bool) []string {
}
if !seen[ref] {
seen[ref] = true
out = append(out, ref)
*out = append(*out, ref)
}
}
for _, raw := range strings.Split(recipe, "\n") {
@@ -830,6 +840,7 @@ func undeclaredFetches(recipe string, declared map[string]bool) []string {
switch strings.ToUpper(fields[0]) {
case "FROM":
// FROM [--platform=…] <ref> [AS <name>]
out = &bases
var ref string
for i := 1; i < len(fields); i++ {
if strings.HasPrefix(fields[i], "--") {
@@ -843,6 +854,7 @@ func undeclaredFetches(recipe string, declared map[string]bool) []string {
}
note(ref)
case "COPY", "ADD":
out = &copies
for _, f := range fields[1:] {
if strings.HasPrefix(f, "--from=") {
note(strings.TrimPrefix(f, "--from="))
@@ -850,5 +862,5 @@ func undeclaredFetches(recipe string, declared map[string]bool) []string {
}
}
}
return out
return bases, copies
}
+10 -6
View File
@@ -120,12 +120,16 @@ 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)
bases, copies := undeclaredFetches(recipe, map[string]bool{"RUNTIME_BASE": true})
if strings.Join(copies, "|") != "${MC_BASE} (a build argument the manifest does not declare)|vendor/tool:latest" {
t.Fatalf("copies out of undeclared images: %v", copies)
}
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)
// A base fetched on its own is named apart: the mesh's own images still start FROM one, so
// it is said rather than refused until they declare theirs.
if strings.Join(bases, "|") != "golang:1.25-alpine" {
t.Fatalf("undeclared bases: %v", bases)
}
if _, copies := undeclaredFetches(recipe, map[string]bool{"RUNTIME_BASE": true, "MC_BASE": true}); len(copies) != 1 {
t.Fatalf("declared arguments are not fetches: %v", copies)
}
}