Files
mesh-controller/internal/builder/standing_on_test.go
T
jschoubben e81f352979 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.
2026-09-21 20:48:00 +02:00

136 lines
5.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: 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)
}
}