From e81f35297938e9a886a204dc9c2be29d1d9145fc Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 21 Sep 2026 20:48:00 +0200 Subject: [PATCH] An undeclared COPY --from is refused; an undeclared FROM is said, not yet refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/builder/builder.go | 32 +++++++++++++++++++--------- internal/builder/standing_on_test.go | 16 ++++++++------ 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 640fbf3..a592c52 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -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\": \"\", \"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=…] [AS ] + 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 } diff --git a/internal/builder/standing_on_test.go b/internal/builder/standing_on_test.go index d3622f9..ee9cc0c 100644 --- a/internal/builder/standing_on_test.go +++ b/internal/builder/standing_on_test.go @@ -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) } }