Files
mesh-catalog/modules/mesh-build-image/cmd/mesh-build-image/build_test.go
T
jschoubben dcf32576a8
mesh/delivery merged: source.main moved to novox/mesh-catalog@eba4bab00aa7
mesh/check checking dcf32576a880 on eba4bab00aa7
mesh-build-image: build with the module's directory as context, so a Dockerfile in a subdirectory finds its COPY paths (issue 1703)
photos' admin-client, client and server Dockerfiles live in subdirectories and copy
paths from the module's root (admin-client/..., client-server/serve.mjs), as build-agent
built them and as the fingerprint reads the module's whole directory. Taking the
Dockerfile's own directory as context failed every such COPY with copier: stat
"/admin-client/package.json": no such file or directory, on shanks and on ace.

Quick fix (rejected): move photos' Dockerfiles to its repository root; it changes the
operator's photo application and leaves the trap for the next module.
Thorough fix (chosen): one context for every image, the module's directory; it never
leaves that directory, so the isolation of ADR 0487 section 2 holds.
2026-10-26 21:25:24 +01:00

327 lines
13 KiB
Go

package main
import (
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The fixture module (testdata/repos/image): a Dockerfile that adds a file to an upstream base the manifest pins in
// build.on, as a workspace's tree holds it.
func place(t *testing.T, m *Module, edit func(string) string) {
t.Helper()
src := filepath.Join("testdata", "repos", "image", "modules", "demo")
dst := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
for _, n := range []string{"Dockerfile", "hello.txt", "module.json.fixture"} {
raw, err := os.ReadFile(filepath.Join(src, n))
if err != nil {
t.Fatal(err)
}
if n == "module.json.fixture" {
n = "module.json"
raw = []byte(edit(string(raw)))
}
if err := os.WriteFile(filepath.Join(dst, n), raw, 0o644); err != nil {
t.Fatal(err)
}
}
}
func same(s string) string { return s }
func runBuild(t *testing.T, m *Module, states *keptStates, extra map[string]any) (BuildResult, delivery.Step) {
t.Helper()
args := map[string]any{"workspace": "ws1", "module": "modules/demo", "artifact": "server"}
for k, v := range extra {
args[k] = v
}
if _, err := tool(t, m, BuildTool)(args); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
var res BuildResult
if st.State == delivery.StepPassed {
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
}
return res, st
}
func setup(t *testing.T, edit func(string) string) (*Module, *fake, *keptStates) {
t.Helper()
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
place(t, m, edit)
return m, f, states
}
// 1703c/AC: the build runs buildah bud rootless: the store and the runtime state in the store directory, the
// Dockerfile of the module's own directory and that directory as the context, the target, the pinned base as a build
// argument; no mount, no credential file, a runtime directory of its own, and it answers the image's digest.
func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
t.Setenv("MESH_BUILD_CPU_SHARE", "50") // the composer's share (ADR 0553): buildah is given its threads
m, f, states := setup(t, same)
res, st := runBuild(t, m, states, nil)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
if res.Digest != imageDigest || res.Artifact != "server" || res.Pushed || res.Reference != "" {
t.Errorf("result = %+v, want the local digest and no push", res)
}
bud := f.called("bud")
if len(bud) != 1 {
t.Fatalf("buildah bud ran %d times", len(bud))
}
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
for _, want := range []string{
"--root " + m.Store + "/root", "--runroot " + m.Store + "/run", "--isolation oci", "--network pasta",
"--file " + dir + "/Dockerfile", "--target final",
"--build-arg DEMO_BASE=docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001",
} {
if !strings.Contains(bud[0].line, want) {
t.Errorf("bud line lacks %q:\n%s", want, bud[0].line)
}
}
if !strings.HasSuffix(bud[0].line, " "+dir) {
t.Errorf("the context is not the module's directory:\n%s", bud[0].line)
}
for _, c := range f.calls {
if c.name != "buildah" {
continue
}
for _, bad := range []string{" -v ", "--volume", "--mount", "--authfile", "--creds", "--cap-add", "--privileged", "--device"} {
if strings.Contains(" "+c.line+" ", bad) {
t.Errorf("%q carries %q", c.line, bad)
}
}
env := strings.Join(c.env, " ")
if strings.Contains(env, "REGISTRY_AUTH") || strings.Contains(env, "DOCKER_CONFIG") || strings.Contains(env, "MESH_") {
t.Errorf("the environment of %q carries a credential or the module's own: %s", c.line, env)
}
if !strings.Contains(env, "XDG_RUNTIME_DIR="+m.Store+"/xdg") {
t.Errorf("the environment of %q lacks the module's own runtime directory: %s", c.line, env)
}
if !strings.Contains(env, "GOMAXPROCS=") {
t.Errorf("the environment of %q gives buildah no thread limit under a share (ADR 0553): %s", c.line, env)
}
}
// The image is removed from the store once the digest is read, so the store does not grow with every build.
if len(f.called("rmi")) != 1 {
t.Errorf("rmi ran %d times, want the image removed once", len(f.called("rmi")))
}
}
// Issue 1703, 2026-10-26: a Dockerfile in a subdirectory of the module builds with the module's directory as its
// context, so its COPY of a path from the module's root (photos' admin-client/Dockerfile copies admin-client/... and
// client-server/serve.mjs) finds it; the Dockerfile's own directory as the context failed with `copier: stat
// "/admin-client/package.json": no such file or directory`.
func TestBuildContextIsTheModulesDirectoryForADockerfileInASubdirectory(t *testing.T) {
m, f, states := setup(t, func(s string) string {
return strings.Replace(s, `"from": "Dockerfile"`, `"from": "admin-client/Dockerfile"`, 1)
})
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
for name, body := range map[string]string{
"admin-client/Dockerfile": "ARG DEMO_BASE\nFROM ${DEMO_BASE} AS final\nCOPY admin-client/package.json client-server/serve.mjs /\n",
"admin-client/package.json": "{}\n",
"client-server/serve.mjs": "\n",
} {
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
_, st := runBuild(t, m, states, nil)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
bud := f.called("bud")
if len(bud) != 1 {
t.Fatalf("buildah bud ran %d times", len(bud))
}
if !strings.Contains(bud[0].line, "--file "+dir+"/admin-client/Dockerfile ") {
t.Errorf("the Dockerfile is not the subdirectory's:\n%s", bud[0].line)
}
if !strings.HasSuffix(bud[0].line, " "+dir) {
t.Errorf("the context is not the module's directory, so admin-client/ and client-server/ are out of reach:\n%s", bud[0].line)
}
}
// 1703c, issue 1703: a failing Dockerfile step fails the step, its reason saying buildah's error from stderr, never the
// last STEP line buildah writes on stdout; nothing is pushed.
func TestBuildFailsWithBuildahsError(t *testing.T) {
steps := "STEP 1/3: FROM docker.io/library/alpine\nSTEP 2/3: RUN apk add --no-cache ca-certificates\nfetch https://dl-cdn.alpinelinux.org/x\n"
long := strings.Repeat("x", 2*CauseBytes)
cases := map[string]struct {
ran forgekey.Ran
want []string
wantNot []string
}{
"buildah's RUN failure": {forgekey.Ran{Status: 1, Stdout: steps + "STEP 3/3: COPY . /app\n",
Stderr: "error running container: from /usr/bin/crun creating container for [/bin/sh -c apk add]: sd-bus call: Interactive authentication required.\nError: building at STEP \"RUN apk add --no-cache ca-certificates\": while running runtime: exit status 1\n"},
[]string{"exit status 1", "Interactive authentication required", `building at STEP "RUN apk add`}, []string{"STEP 3/3", "COPY"}},
"a failed RUN": {forgekey.Ran{Status: 1, Stdout: steps, Stderr: "Error: building at STEP \"RUN false\": exit status 1\n"},
[]string{`STEP "RUN false"`}, []string{"dl-cdn"}},
"a long stderr is bounded, its end kept": {forgekey.Ran{Status: 125, Stdout: steps, Stderr: "early noise\n" + long + "\nError: the end\n"},
[]string{"Error: the end"}, []string{"early noise", long}},
"nothing on stderr": {forgekey.Ran{Status: 2, Stdout: steps}, []string{"exit status 2", "nothing on stderr", "dl-cdn"}, nil},
"never ran": {forgekey.Ran{Err: "ENOENT"}, []string{"ENOENT", "wrote nothing"}, nil},
}
for name, c := range cases {
m, f, states := setup(t, same)
f.failBud = c.ran
_, st := runBuild(t, m, states, nil)
if st.State != delivery.StepFailed {
t.Errorf("%s: step = %+v, want failed", name, st)
continue
}
for _, w := range c.want {
if !strings.Contains(st.Reason, w) {
t.Errorf("%s: the reason %q lacks %q", name, st.Reason, w)
}
}
for _, w := range c.wantNot {
if strings.Contains(st.Reason, w) {
t.Errorf("%s: the reason %q carries %q", name, st.Reason, w)
}
}
if len(st.Reason) > CauseBytes+200 {
t.Errorf("%s: the reason is %d bytes, more than its bound", name, len(st.Reason))
}
if len(f.called("push")) != 0 {
t.Errorf("%s: a failed build pushed", name)
}
}
}
// 1703c: an image whose context is a repository, or whose build.on names another module's artifact, is refused
// (not failed), the reason saying why; nothing is built.
func TestBuildRefusesAnImageWithARepositoryContextOrAModuleBase(t *testing.T) {
cases := map[string]struct {
edit func(string) string
want []string
}{
"context": {func(s string) string {
return strings.Replace(s, `"target": "final"`, `"target": "final", "context": {"seat": "git", "repository": "novox/mesh-controller"}`, 1)
}, []string{"novox/mesh-controller", "module's own directory", "ADR 0487"}},
"module base": {func(s string) string {
return strings.Replace(s, `"image": "docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001"`, `"module": "mesh-sdk", "artifact": "lib"`, 1)
}, []string{"artifact lib of the module mesh-sdk", "DEMO_BASE"}},
}
for name, c := range cases {
m, f, states := setup(t, c.edit)
_, st := runBuild(t, m, states, nil)
if st.State != delivery.StepRefused {
t.Errorf("%s: step = %+v, want refused", name, st)
continue
}
for _, w := range c.want {
if !strings.Contains(st.Reason, w) {
t.Errorf("%s: the reason %q lacks %q", name, st.Reason, w)
}
}
if len(f.called("bud")) != 0 {
t.Errorf("%s: a refused image was built", name)
}
}
}
// The refusal is one small function: it knows the two forms and passes the rest.
func TestUnsupportedImageNamesTheTwoFormsAndPassesTheRest(t *testing.T) {
plain := catalogue.Manifest{Module: "x", Build: &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "A", Image: "i@sha256:aa"}}}}
if why := unsupportedImage(plain, catalogue.Artifact{Name: "a", Kind: "image"}); why != "" {
t.Errorf("a plain image was refused: %s", why)
}
if why := unsupportedImage(plain, catalogue.Artifact{Name: "a", Kind: "image", Context: &catalogue.ArtifactContext{Repository: "o/r"}}); why == "" {
t.Error("a repository context passed")
}
}
func TestBuildFailsForWhatIsNotAnImageOrNotThere(t *testing.T) {
for name, tc := range map[string]struct {
edit func(string) string
args map[string]any
}{
"not an image": {func(s string) string { return strings.Replace(s, `"kind": "image"`, `"kind": "archive"`, 1) }, nil},
"no artifact": {same, map[string]any{"artifact": "other"}},
"no Dockerfile": {func(s string) string {
return strings.Replace(s, `"from": "Dockerfile"`, `"from": "Missing"`, 1)
}, nil},
"leaves the module": {func(s string) string {
return strings.Replace(s, `"from": "Dockerfile"`, `"from": "../x/Dockerfile"`, 1)
}, nil},
"outside the workspace": {same, map[string]any{"module": "../.."}},
} {
m, f, states := setup(t, tc.edit)
_, st := runBuild(t, m, states, tc.args)
if st.State != delivery.StepFailed {
t.Errorf("%s: step = %+v, want failed", name, st)
}
if len(f.called("bud")) != 0 {
t.Errorf("%s: buildah bud ran", name)
}
}
}
// Issue 1751 (ADR 0514 §2, the review's K2): a manifest whose config value falls back to a predefined key builds. The
// builder holds no published list of the keys, so it reads the manifest for its inputs (ParseManifestForInputs), not
// as a judge of config values. The manifest is claude-code's real one (capturedClaudeCodeManifest) with the image fixture's
// base and artifact added to its build, since no image module of the catalogue falls back to a predefined key today.
func TestAManifestFallingBackToAPredefinedKeyBuilds(t *testing.T) {
raw := []byte(capturedClaudeCodeManifest)
if !strings.Contains(string(raw), `"predefined": "agent-managed-settings"`) {
t.Fatal("the captured manifest no longer falls back to a predefined key")
}
m, _, states := setup(t, func(fixture string) string {
var real, image map[string]json.RawMessage
if err := json.Unmarshal(raw, &real); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(fixture), &image); err != nil {
t.Fatal(err)
}
// The image fixture's base and artifact join claude-code's own artifacts (its archive of files/ stays).
var realBuild, imageBuild struct {
On json.RawMessage `json:"on,omitempty"`
Artifacts []json.RawMessage `json:"artifacts"`
}
if err := json.Unmarshal(real["build"], &realBuild); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(image["build"], &imageBuild); err != nil {
t.Fatal(err)
}
realBuild.On = imageBuild.On
realBuild.Artifacts = append(realBuild.Artifacts, imageBuild.Artifacts...)
b, err := json.Marshal(realBuild)
if err != nil {
t.Fatal(err)
}
real["build"] = b
out, err := json.Marshal(real)
if err != nil {
t.Fatal(err)
}
return string(out)
})
res, st := runBuild(t, m, states, nil)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
if res.Digest != imageDigest || res.Artifact != "server" {
t.Errorf("result = %+v", res)
}
}