From dcf32576a88071eadf7f7f13f37f62d7bfc0dc01 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 21:25:24 +0100 Subject: [PATCH] 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. --- modules/mesh-build-image/README.md | 5 ++- .../cmd/mesh-build-image/build.go | 18 +++++--- .../cmd/mesh-build-image/build_test.go | 41 ++++++++++++++++++- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/modules/mesh-build-image/README.md b/modules/mesh-build-image/README.md index a6c3b62e..e27eaa30 100644 --- a/modules/mesh-build-image/README.md +++ b/modules/mesh-build-image/README.md @@ -24,8 +24,9 @@ The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can re - `build_image_fetch` fetches a commit over SSH into a workspace (a base commit makes the merge preview, as the other build modules' fetch). Long: answers a step id and ends on its key in `steps`. -- `build_image_build` builds one image artifact: `buildah bud` with the Dockerfile of the module's directory as file - and context, `--target`, and the images `build.on` pins as `--build-arg`. Answers the image's digest. With +- `build_image_build` builds one image artifact: `buildah bud` with the Dockerfile `from` names (in the module's + directory or a subdirectory of it) as file and the module's own directory as context, so a COPY names paths from the + module's root (issue 1703), `--target`, and the images `build.on` pins as `--build-arg`. Answers the image's digest. With `publish: true` (a merged trunk commit only) the module's own process then pushes it to the registry under `/:` (the first 12 hex digits of the digest unless the call gives one), waiting out a registry held still, and the key carries the digest the registry serves and the reference pinned by it. Without diff --git a/modules/mesh-build-image/cmd/mesh-build-image/build.go b/modules/mesh-build-image/cmd/mesh-build-image/build.go index c3e1d0e4..323cf3a5 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/build.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/build.go @@ -55,18 +55,18 @@ func (m *Module) buildTool() StepTool { } // unsupportedImage says why this module does not build the artifact, or "" when it does. It is the one place that -// knows which image forms are out: ADR 0487 §1 takes the Dockerfile's directory as the build context and the base +// knows which image forms are out: ADR 0487 §1 builds from the module's own directory (the build context) and the base // from the Dockerfile or an image pinned in `build.on`, so a context that is another repository and a base that is // another module's artifact are not built (the lead's decision for R4c-9, which retires them with build-agent at R4g). func unsupportedImage(manifest catalogue.Manifest, a catalogue.Artifact) string { if a.Context != nil { - return fmt.Sprintf("the image %s of %s builds with the repository %s as its context; mesh-build-image takes the Dockerfile's directory as the build context (ADR 0487 §1) and does not build a whole repository", + return fmt.Sprintf("the image %s of %s builds with the repository %s as its context; mesh-build-image takes the module's own directory as the build context (ADR 0487 §1) and does not build another repository", a.Name, manifest.Module, a.Context.Repository) } if manifest.Build != nil { for _, on := range manifest.Build.On { if on.Module != "" { - return fmt.Sprintf("the image %s of %s stands on the artifact %s of the module %s through the build argument %s; mesh-build-image builds only from the Dockerfile's directory and an image pinned in build.on (ADR 0487 §1)", + return fmt.Sprintf("the image %s of %s stands on the artifact %s of the module %s through the build argument %s; mesh-build-image builds only from the module's own directory and an image pinned in build.on (ADR 0487 §1)", a.Name, manifest.Module, on.Artifact, on.Module, on.Arg) } } @@ -183,8 +183,14 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { return m.push(ctx, workspace, manifest.Module, local, res, tag) } -// dockerfileOf is the Dockerfile an artifact's `from` names and the build context: the directory it lies in. It must -// be a regular file inside the module's own directory (checks/imageplace holds the catalogue to the same). +// dockerfileOf is the Dockerfile an artifact's `from` names and the build context: the module's own directory, the +// one its manifest lies in, whichever subdirectory the Dockerfile sits in. `from` is a path relative to that +// directory, and so is every COPY of the Dockerfile: a recipe in a subdirectory (photos' admin-client/Dockerfile +// copies admin-client/... and client-server/serve.mjs) builds as build-agent built it, and as the fingerprint reads +// the module's whole directory as its build source. Taking the Dockerfile's own directory instead failed every such +// COPY with `copier: stat ... no such file or directory` (issue 1703, 2026-10-26). The context never leaves the module's +// directory, so a module's build reads no other module's files. The Dockerfile must be a regular file inside that +// directory (checks/imageplace holds the catalogue to the same). func dockerfileOf(moduleDir, from string) (dockerfile, contextDir string, err error) { clean := filepath.Clean(filepath.FromSlash(strings.TrimSpace(from))) if from == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { @@ -195,7 +201,7 @@ func dockerfileOf(moduleDir, from string) (dockerfile, contextDir string, err er if err != nil || !info.Mode().IsRegular() { return "", "", fmt.Errorf("%q is not a file in the module's directory", from) } - return dockerfile, filepath.Dir(dockerfile), nil + return dockerfile, moduleDir, nil } // localName names the image in buildah's store: one name for each artifact of a workspace. diff --git a/modules/mesh-build-image/cmd/mesh-build-image/build_test.go b/modules/mesh-build-image/cmd/mesh-build-image/build_test.go index 3bb92b3a..a8c866ac 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/build_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/build_test.go @@ -94,7 +94,7 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) { } } if !strings.HasSuffix(bud[0].line, " "+dir) { - t.Errorf("the context is not the Dockerfile's directory:\n%s", bud[0].line) + t.Errorf("the context is not the module's directory:\n%s", bud[0].line) } for _, c := range f.calls { if c.name != "buildah" { @@ -122,6 +122,43 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) { } } +// 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) { @@ -178,7 +215,7 @@ func TestBuildRefusesAnImageWithARepositoryContextOrAModuleBase(t *testing.T) { }{ "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", "Dockerfile's directory", "ADR 0487"}}, + }, []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"}},