Files
mesh-controller/internal/builder/standing_on_test.go
jschoubben b3486270d1 The control plane's recipe starts FROM the base its manifest declares, and an undeclared base is refused
The mesh's own images declare theirs now, so the refusal ADR 0097 deferred is live.
The builder's own image and the examples take arguments with defaults; make builds
them, not the mesh.
2026-09-21 22:16:10 +02:00

154 lines
6.3 KiB
Go

package builder
import (
"context"
"fmt"
"strings"
"testing"
"github.com/novox/mesh-controller/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(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")
}
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(context.Background(), manifest, held, noMirror)
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(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)
}
}
// 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(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
`
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)
}
// A base fetched on its own is named apart, and refused like a copy (ADR 0097).
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)
}
}
// Continued lines are one instruction, heredoc bodies are not instructions, and a RUN --mount reaches
// for an image as a COPY --from does (review C4, C5).
func TestARecipeIsReadAsInstructions(t *testing.T) {
recipe := "ARG RUNTIME_BASE\n" +
"FROM ${RUNTIME_BASE} \\\n AS build\n" +
"COPY \\\n --from=docker.io/vendor/one:latest /a /a\n" +
"COPY --from=build /out /out\n" +
"COPY <<EOF /app/x.py\nfrom os import path\nEOF\n" +
"RUN --mount=type=bind,from=docker.io/vendor/two:1,target=/t cp /t/x /x\n" +
"FROM scratch\n"
bases, copies := undeclaredFetches(recipe, map[string]bool{"RUNTIME_BASE": true})
if strings.Join(copies, "|") != "docker.io/vendor/one:latest|docker.io/vendor/two:1" {
t.Fatalf("copies: %v", copies)
}
if len(bases) != 0 {
t.Fatalf("a heredoc line or a continued stage was read as a base: %v", bases)
}
}