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 <