mesh/delivery merged: source.main moved to novox/mesh-catalog@eba4bab00aa7
mesh/check checking dcf32576a880 on eba4bab00aa7
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.
327 lines
13 KiB
Go
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)
|
|
}
|
|
}
|