Three faults in one path. A merge rebuilt every module built from the repository, so one change in a repository holding twenty-six of them meant twenty-six builds. A merge into a repository a module only *packages* source from rebuilt nothing — two modules are built from the control plane's own repository and neither had ever been rebuilt when it moved — because the manifest the mesh keeps carries no build section, so a build now says which repositories it read and the mesh keeps that beside what it stood on. And a module handed over by hand could record a repository with no directory inside it, which is a module nothing can ever rebuild (novox/hq 04-ISSUES/131, /132). A change inside no module's own directory is a change to what they share, and everything built from that repository is rebuilt: rebuilding too much is the safe direction, because the fault this whole path exists for is a mesh that believes it is current and is not.
203 lines
8.5 KiB
Go
203 lines
8.5 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)
|
|
}
|
|
}
|
|
|
|
// What a build was handed as its bases is what it stood on — recorded, so a changed base knows what
|
|
// to rebuild (novox/hq 04-ISSUES/131). A recipe reads the base from an argument, so nothing else
|
|
// could know.
|
|
func TestTheBasesABuildWasHandedAreWhatItStoodOn(t *testing.T) {
|
|
manifest := catalogue.Manifest{
|
|
Module: "gitea",
|
|
Build: &catalogue.Build{
|
|
On: []catalogue.BuildsOn{
|
|
{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"},
|
|
{Arg: "BUILD_BASE", Module: "mesh-tools", Artifact: "build"},
|
|
},
|
|
Artifacts: []catalogue.Artifact{{Name: "runtime", Kind: catalogue.ArtifactImage, From: "Dockerfile"}},
|
|
},
|
|
}
|
|
held := map[string]string{
|
|
"mesh-tools/runtime": "127.0.0.1:5000/mesh-tools/runtime@sha256:" + strings.Repeat("a", 64),
|
|
"mesh-tools/build": "127.0.0.1:5000/mesh-tools/build@sha256:" + strings.Repeat("b", 64),
|
|
}
|
|
_, resolved, err := standingOn(context.Background(), manifest, held, noMirror)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := against(t.TempDir(), manifest, resolved)
|
|
if len(got) != 2 || got[0] != held["mesh-tools/build"] || got[1] != held["mesh-tools/runtime"] {
|
|
t.Fatalf("the bases the build was handed were not what it stood on: %v", got)
|
|
}
|
|
}
|
|
|
|
// What a build read besides its module's own repository is the second repository its recipes name,
|
|
// each once: a module that packages source living elsewhere is affected when that source moves.
|
|
func TestWhatABuildReadIsTheRepositoriesItsRecipesName(t *testing.T) {
|
|
elsewhere := catalogue.ArtifactContext{Repository: "http://forge.internal:20000/novox/mesh-controller.git", Ref: "main"}
|
|
manifest := catalogue.Manifest{
|
|
Module: "builder",
|
|
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
|
{Name: "server", Kind: catalogue.ArtifactImage, From: "Dockerfile", Context: &elsewhere},
|
|
{Name: "tools", Kind: catalogue.ArtifactImage, From: "Dockerfile", Context: &elsewhere},
|
|
{Name: "config", Kind: catalogue.ArtifactArchive, From: "etc"},
|
|
}},
|
|
}
|
|
read := readBy(manifest)
|
|
if len(read) != 1 || read[0] != elsewhere {
|
|
t.Fatalf("the repositories this build read are %+v", read)
|
|
}
|
|
if readBy(catalogue.Manifest{Module: "gitea", Build: &catalogue.Build{}}) != nil {
|
|
t.Fatal("a module whose recipes name no other repository read one")
|
|
}
|
|
}
|