diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 256a2a6..91cd6fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,7 @@ jobs: - { name: clock, context: images/clock, file: images/clock/Dockerfile } - { name: shim, context: images/shim, file: images/shim/Dockerfile } - { name: node18, context: images/node18, file: images/node18/Dockerfile, content_tag: true } + - { name: node22, context: images/node22, file: images/node22/Dockerfile, content_tag: true } - { name: node24, context: images/node24, file: images/node24/Dockerfile, content_tag: true } steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9e3ab4..622cc4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,9 +77,9 @@ jobs: # Tool images ship at the release tag; node images ship at a content # hash of their Dockerfile (images/content-tag.sh), so list each at # the tag it is actually published under. - for img in operator console clone copier du cleanup clock shim node18 node24; do + for img in operator console clone copier du cleanup clock shim node18 node22 node24; do case "$img" in - node18 | node24) tag="$(bash images/content-tag.sh "$img")" ;; + node18 | node22 | node24) tag="$(bash images/content-tag.sh "$img")" ;; *) tag="${{ github.ref_name }}" ;; esac echo "- \`ghcr.io/toggle-corp/toggle-web-baker-$img:$tag\`" diff --git a/deploy/helm/toggle-web-baker/snapshots/console.yaml b/deploy/helm/toggle-web-baker/snapshots/console.yaml index 9e869b5..e9f4bec 100644 --- a/deploy/helm/toggle-web-baker/snapshots/console.yaml +++ b/deploy/helm/toggle-web-baker/snapshots/console.yaml @@ -64,7 +64,7 @@ data: clock: "ghcr.io/toggle-corp/toggle-web-baker-clock:0.1.1" shim: "ghcr.io/toggle-corp/toggle-web-baker-shim:0.1.1" nginx: "docker.io/nginxinc/nginx-unprivileged:1.27-alpine" - nodeImages: {"18":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node18:18-436d743e3c1c","runAsUser":1000},"24":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node24:24-cddf3de62036","runAsUser":1000}} + nodeImages: {"18":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node18:18-436d743e3c1c","runAsUser":1000},"22":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node22:22-85c87694aa15","runAsUser":1000},"24":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node24:24-cddf3de62036","runAsUser":1000}} defaultSetupCommands: {"pnpm":["pnpm","install","--frozen-lockfile"],"yarn":["yarn","install","--frozen-lockfile"]} phaseResources: cpu: @@ -1824,7 +1824,7 @@ spec: metadata: annotations: # Roll the operator pod whenever the config ConfigMap content changes. - checksum/config: 6e2048b45f97f17109329c486ed68a91eaeabece1fdf92518d3ed76c94fb0e47 + checksum/config: 8f954e32befb33bb8723f59e345f5775032b6a8f807b1c5d11c4b1e64cd48179 labels: app.kubernetes.io/name: toggle-web-baker app.kubernetes.io/instance: release-name diff --git a/deploy/helm/toggle-web-baker/snapshots/defaults.yaml b/deploy/helm/toggle-web-baker/snapshots/defaults.yaml index ddcadec..32c31cd 100644 --- a/deploy/helm/toggle-web-baker/snapshots/defaults.yaml +++ b/deploy/helm/toggle-web-baker/snapshots/defaults.yaml @@ -50,7 +50,7 @@ data: clock: "ghcr.io/toggle-corp/toggle-web-baker-clock:0.1.1" shim: "ghcr.io/toggle-corp/toggle-web-baker-shim:0.1.1" nginx: "docker.io/nginxinc/nginx-unprivileged:1.27-alpine" - nodeImages: {"18":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node18:18-436d743e3c1c","runAsUser":1000},"24":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node24:24-cddf3de62036","runAsUser":1000}} + nodeImages: {"18":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node18:18-436d743e3c1c","runAsUser":1000},"22":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node22:22-85c87694aa15","runAsUser":1000},"24":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node24:24-cddf3de62036","runAsUser":1000}} defaultSetupCommands: {"pnpm":["pnpm","install","--frozen-lockfile"],"yarn":["yarn","install","--frozen-lockfile"]} phaseResources: cpu: @@ -1565,7 +1565,7 @@ spec: metadata: annotations: # Roll the operator pod whenever the config ConfigMap content changes. - checksum/config: 19e73f5ec2d171b77a7cd7378ff680ca43b7bda04a2b7a5d6abd80e6d256007d + checksum/config: 5a399fc96a00d17f1e4f48cda3bbb188733b1f65a47ae96814d00f6561ddf2ac labels: app.kubernetes.io/name: toggle-web-baker app.kubernetes.io/instance: release-name diff --git a/deploy/helm/toggle-web-baker/snapshots/git-auth.yaml b/deploy/helm/toggle-web-baker/snapshots/git-auth.yaml index 2182f86..be31ad0 100644 --- a/deploy/helm/toggle-web-baker/snapshots/git-auth.yaml +++ b/deploy/helm/toggle-web-baker/snapshots/git-auth.yaml @@ -73,7 +73,7 @@ data: clock: "ghcr.io/toggle-corp/toggle-web-baker-clock:0.1.1" shim: "ghcr.io/toggle-corp/toggle-web-baker-shim:0.1.1" nginx: "docker.io/nginxinc/nginx-unprivileged:1.27-alpine" - nodeImages: {"18":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node18:18-436d743e3c1c","runAsUser":1000},"24":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node24:24-cddf3de62036","runAsUser":1000}} + nodeImages: {"18":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node18:18-436d743e3c1c","runAsUser":1000},"22":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node22:22-85c87694aa15","runAsUser":1000},"24":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node24:24-cddf3de62036","runAsUser":1000}} defaultSetupCommands: {"pnpm":["pnpm","install","--frozen-lockfile"],"yarn":["yarn","install","--frozen-lockfile"]} phaseResources: cpu: @@ -1588,7 +1588,7 @@ spec: metadata: annotations: # Roll the operator pod whenever the config ConfigMap content changes. - checksum/config: e7fe5477af01b461ba41a411918a9bdb01fa31a96f9b7c0dcb78a1d7a5172a3d + checksum/config: 19906f02c67c80fa8143b84f0b52347b0f518e6a3c6af799981890e3d9313be8 labels: app.kubernetes.io/name: toggle-web-baker app.kubernetes.io/instance: release-name diff --git a/deploy/helm/toggle-web-baker/snapshots/monitoring.yaml b/deploy/helm/toggle-web-baker/snapshots/monitoring.yaml index 89fb13f..b829017 100644 --- a/deploy/helm/toggle-web-baker/snapshots/monitoring.yaml +++ b/deploy/helm/toggle-web-baker/snapshots/monitoring.yaml @@ -50,7 +50,7 @@ data: clock: "ghcr.io/toggle-corp/toggle-web-baker-clock:0.1.1" shim: "ghcr.io/toggle-corp/toggle-web-baker-shim:0.1.1" nginx: "docker.io/nginxinc/nginx-unprivileged:1.27-alpine" - nodeImages: {"18":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node18:18-436d743e3c1c","runAsUser":1000},"24":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node24:24-cddf3de62036","runAsUser":1000}} + nodeImages: {"18":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node18:18-436d743e3c1c","runAsUser":1000},"22":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node22:22-85c87694aa15","runAsUser":1000},"24":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node24:24-cddf3de62036","runAsUser":1000}} defaultSetupCommands: {"pnpm":["pnpm","install","--frozen-lockfile"],"yarn":["yarn","install","--frozen-lockfile"]} phaseResources: cpu: @@ -1595,7 +1595,7 @@ spec: metadata: annotations: # Roll the operator pod whenever the config ConfigMap content changes. - checksum/config: 19e73f5ec2d171b77a7cd7378ff680ca43b7bda04a2b7a5d6abd80e6d256007d + checksum/config: 5a399fc96a00d17f1e4f48cda3bbb188733b1f65a47ae96814d00f6561ddf2ac labels: app.kubernetes.io/name: toggle-web-baker app.kubernetes.io/instance: release-name diff --git a/deploy/helm/toggle-web-baker/snapshots/no-crd.yaml b/deploy/helm/toggle-web-baker/snapshots/no-crd.yaml index 28e3952..c00758f 100644 --- a/deploy/helm/toggle-web-baker/snapshots/no-crd.yaml +++ b/deploy/helm/toggle-web-baker/snapshots/no-crd.yaml @@ -48,7 +48,7 @@ data: clock: "ghcr.io/toggle-corp/toggle-web-baker-clock:0.1.1" shim: "ghcr.io/toggle-corp/toggle-web-baker-shim:0.1.1" nginx: "docker.io/nginxinc/nginx-unprivileged:1.27-alpine" - nodeImages: {"18":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node18:18-436d743e3c1c","runAsUser":1000},"24":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node24:24-cddf3de62036","runAsUser":1000}} + nodeImages: {"18":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node18:18-436d743e3c1c","runAsUser":1000},"22":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node22:22-85c87694aa15","runAsUser":1000},"24":{"image":"ghcr.io/toggle-corp/toggle-web-baker-node24:24-cddf3de62036","runAsUser":1000}} defaultSetupCommands: {"pnpm":["pnpm","install","--frozen-lockfile"],"yarn":["yarn","install","--frozen-lockfile"]} phaseResources: cpu: @@ -325,7 +325,7 @@ spec: metadata: annotations: # Roll the operator pod whenever the config ConfigMap content changes. - checksum/config: 6e2048b45f97f17109329c486ed68a91eaeabece1fdf92518d3ed76c94fb0e47 + checksum/config: 8f954e32befb33bb8723f59e345f5775032b6a8f807b1c5d11c4b1e64cd48179 labels: app.kubernetes.io/name: toggle-web-baker app.kubernetes.io/instance: release-name diff --git a/deploy/helm/toggle-web-baker/values.yaml b/deploy/helm/toggle-web-baker/values.yaml index 07646c4..4b034a7 100644 --- a/deploy/helm/toggle-web-baker/values.yaml +++ b/deploy/helm/toggle-web-baker/values.yaml @@ -52,6 +52,10 @@ operator: repository: ghcr.io/toggle-corp/toggle-web-baker-node18 tag: "18-436d743e3c1c" runAsUser: 1000 + "22": + repository: ghcr.io/toggle-corp/toggle-web-baker-node22 + tag: "22-85c87694aa15" + runAsUser: 1000 "24": repository: ghcr.io/toggle-corp/toggle-web-baker-node24 tag: "24-cddf3de62036" diff --git a/images/Makefile b/images/Makefile index 9339f18..cff07f2 100644 --- a/images/Makefile +++ b/images/Makefile @@ -17,7 +17,7 @@ REGISTRY ?= ghcr.io/toggle-corp/toggle-web-baker TAG ?= dev -IMAGES := clone copier du cleanup clock shim node18 node24 +IMAGES := clone copier du cleanup clock shim node18 node22 node24 # ---- pinned base image digests (mirror of the FROM lines) ------------------- CLONE_BASE := alpine/git@sha256:c0280cf9572316299b08544065d3bf35db65043d5e3963982ec50647d2746e26 @@ -26,6 +26,7 @@ DU_BASE := alpine@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491 CLEANUP_BASE := node@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 CLOCK_BASE := alpine@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d NODE18_BASE := node@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e +NODE22_BASE := node@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 NODE24_BASE := node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd .PHONY: build $(IMAGES) test digests @@ -55,6 +56,9 @@ shim: node18: docker build -t $(REGISTRY)-node18:$(TAG) ./node18 +node22: + docker build -t $(REGISTRY)-node22:$(TAG) ./node22 + node24: docker build -t $(REGISTRY)-node24:$(TAG) ./node24 @@ -75,4 +79,5 @@ digests: @echo "cleanup: $(CLEANUP_BASE)" @echo "clock: $(CLOCK_BASE)" @echo "node18: $(NODE18_BASE)" + @echo "node22: $(NODE22_BASE)" @echo "node24: $(NODE24_BASE)" diff --git a/images/node22/Dockerfile b/images/node22/Dockerfile new file mode 100644 index 0000000..39f7d03 --- /dev/null +++ b/images/node22/Dockerfile @@ -0,0 +1,47 @@ +# node22 -- Node.js 22 base for user frontend builds. +# +# Trusted, platform-owned base image the operator runs for a user build when +# spec.nodeVersion selects node 22. The official node:22-alpine already ships a +# numeric `node` user (UID 1000), so no user is created here. Adds bash (the +# phase entrypoints are bash) and git (submodule/lockfile flows), and enables +# corepack so BOTH the yarn and pnpm shims work. Pinned by digest. +FROM node@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 + +# corepack's per-manager version cache. The operator runs this image with +# readOnlyRootFilesystem and injects HOME=/work (a writable emptyDir), so +# corepack's default (~/.cache under HOME) would be an EMPTY dir at runtime -- +# corepack would then try to DOWNLOAD a package manager (and mkdir there), +# which fails with EROFS on the read-only root. Pinning COREPACK_HOME to a fixed, +# image-baked path decouples the cache from HOME: the versions pre-activated +# below are found offline and read-only. +ENV COREPACK_HOME=/opt/corepack +# corepack's default, when a project has no packageManager field, is to WRITE one +# into package.json. The operator now runs clone under the build phase's UID, so +# the checkout is WRITABLE by this toolchain (enabling in-tree codegen) — meaning +# that auto-pin would silently SUCCEED, mutating the user's committed manifest +# with a non-reproducible packageManager pin. Disable it: the toolchain may write +# BUILD ARTEFACTS into the tree, but must not rewrite the user's source manifest. +ENV COREPACK_ENABLE_AUTO_PIN=0 + +# bash for the phase entrypoints; git for repo/submodule and lockfile flows; +# build-base + python3 for node-gyp (native addons some deps compile on install -- +# this is the "anything else from cimg/node" part: cimg bundles build tools). +# corepack enable installs the yarn/pnpm shims into /usr/local/bin, REPLACING the +# bundled yarn binary with a shim -- so yarn too must be pre-activated or it will +# try to download at runtime. Pre-activate pnpm 10.x (supports node 22) + classic +# yarn into COREPACK_HOME (as root, world-readable) so both resolve offline at +# runtime regardless of the injected HOME. +# +# The pre-activated pnpm version must MATCH what target apps pin via their +# package.json `packageManager` field: corepack honours that pin EXACTLY and, on a +# mismatch, tries to DOWNLOAD the pinned version into COREPACK_HOME -> EROFS under +# the operator's readOnlyRootFilesystem. 10.34.5 tracks the current mapswipe-website +# pin (its e2e is node22's primary real-app validation); bump this when apps move. +RUN apk add --no-cache bash git build-base python3 \ + && corepack enable \ + && corepack prepare yarn@1.22.22 --activate \ + && corepack prepare pnpm@10.34.5 --activate + +# The node:alpine base already provides the numeric `node` user (UID 1000); run +# as it for sane local runs. The operator overrides runAsUser at runtime anyway. +USER node diff --git a/images/node22/README.md b/images/node22/README.md new file mode 100644 index 0000000..dd5ba75 --- /dev/null +++ b/images/node22/README.md @@ -0,0 +1,20 @@ +# node22 + +Node.js 22 base image for user frontend builds. + +## Contract + +| | | +|---|---| +| **Runs as** | build-pod init/main container, `USER node` (UID 1000) | +| **Base** | `node:22-alpine` pinned by digest | +| **Selected via** | `spec.pipeline.nodeVersion` on the App (picks node 22) | +| **Adds** | `bash` (phase entrypoints are bash), `git` (repo/submodule and lockfile flows), `build-base` + `python3` (node-gyp native addons), `corepack` with **yarn 1.22.22 + pnpm 10.34.5** pre-activated | + +The official `node:22-alpine` already provides a numeric `node` user (UID 1000), +so no user is created; the image ends with `USER node` for sane local runs. The +operator overrides `runAsUser` at runtime. + +`COREPACK_HOME` is baked to a fixed path with yarn+pnpm pre-activated, so both +package managers resolve **offline** under the operator's `readOnlyRootFilesystem` ++ injected `HOME=/work` — no runtime download. diff --git a/images/test/check_images_test.sh b/images/test/check_images_test.sh index 5a53bf1..349d639 100755 --- a/images/test/check_images_test.sh +++ b/images/test/check_images_test.sh @@ -55,7 +55,7 @@ case "$out" in *"clock"*) ok "missing-in-matrix: mentions clock" ;; *) no "missi # ---- 4. missing-in-release-body: drop node24 from the for-loop list -------- rel_no_node24="$TMP/release-no-node24.yml" -sed 's/for img in operator console clone copier du cleanup clock shim node18 node24;/for img in operator console clone copier du cleanup clock shim node18;/' \ +sed 's/for img in operator console clone copier du cleanup clock shim node18 node22 node24;/for img in operator console clone copier du cleanup clock shim node18 node22;/' \ "$REPO/.github/workflows/release.yml" >"$rel_no_node24" rc=0 out="$(RELEASE_YML="$rel_no_node24" bash "$CHECK" 2>&1)" || rc=$? diff --git a/internal/controller/buildpod.go b/internal/controller/buildpod.go index 05d3a4d..ced2d02 100644 --- a/internal/controller/buildpod.go +++ b/internal/controller/buildpod.go @@ -17,7 +17,7 @@ import ( // Volume + mount paths shared across the build pod. const ( workMountPath = "/work" // checkout + node_modules + build output (writable) - cacheMountPath = "/cache" // package-manager cache (and pnpm store / node_modules on pnpm) + cacheMountPath = "/cache" // package-manager cache (pnpm content-addressable store) dataMountPath = "/data" // dataCache PVC (fetched data) outputMountPath = "/output" @@ -188,11 +188,10 @@ func (r *AppReconciler) resolvePhase(app *bakerv1alpha1.App, phase bakerv1alpha1 return domain.ResolvePhase(phase.Image, phase.RunAsUser, app.Spec.Pipeline.NodeVersion, r.Config.NodeImages, r.Config.Images.Clone) } -// buildVolumesAndMounts returns the pod volumes plus the cache/work mounts, -// BRANCHING on packageManager. yarn: node_modules live on a per-run emptyDir -// (work), cache PVC holds only the yarn cache. pnpm: the pnpm store AND -// node_modules both live on the cache PVC (mounted RW), so the build phase -// mounts cache RW in both cases. +// buildVolumesAndMounts returns the pod volumes plus the cache/work mounts. +// node_modules ALWAYS lives on the per-run /work emptyDir (both managers); the +// cache PVC holds only the reusable cache/store (yarn: download cache; pnpm: the +// content-addressable store). Both mount cache RW so install can populate it. func buildVolumesAndMounts(app *bakerv1alpha1.App) (volumes []corev1.Volume, cacheMount corev1.VolumeMount) { volumes = []corev1.Volume{ // Per-run scratch: checkout + (yarn) node_modules + build output. @@ -220,14 +219,20 @@ func commonMounts() []corev1.VolumeMount { } } -// pkgManagerEnv returns the env that points the package manager at the cache -// volume, branching on the package manager. +// pkgManagerEnv returns the env that points the package manager's CACHE at the +// cache PVC, branching on the package manager. node_modules ALWAYS lands on the +// per-run /work emptyDir (the WorkingDir default) for BOTH managers — the cache +// PVC holds only the reusable, cross-run cache/store. // -// pnpm: the content-addressable store AND node_modules must live on the SAME -// filesystem (the cache PVC) so pnpm's hard-links work and persist across runs. -// pnpm honors npm_config_* keys, so npm_config_store_dir / npm_config_modules_dir -// place both on the cache PVC. (The previous NODE_MODULES_DIR key is bogus — no -// tool reads it.) +// pnpm: only the content-addressable STORE goes on the cache PVC +// (npm_config_store_dir); node_modules is left at its cwd default (/work). +// Relocating node_modules onto the cache PVC via npm_config_modules_dir was +// BROKEN: `pnpm exec`/`pnpm run` resolve a command's bin from /node_modules/.bin +// (cwd-relative), NOT from modules-dir, so any phase running an installed tool +// (e.g. `pnpm exec graphql-codegen` in a fetch codegen step) failed +// "command not found". The store still gives cross-run install speedup; keeping +// node_modules cwd-local is what makes bin resolution work. (The even older +// NODE_MODULES_DIR key was bogus — no tool reads it.) // // yarn: node_modules live on the per-run emptyDir (work volume, set by // WorkingDir); the cache PVC holds only the yarn download cache. @@ -236,8 +241,6 @@ func pkgManagerEnv(app *bakerv1alpha1.App) []corev1.EnvVar { case bakerv1alpha1.PackageManagerPnpm: return []corev1.EnvVar{ {Name: "npm_config_store_dir", Value: cacheMountPath + "/pnpm-store"}, - // node_modules on the cache PVC (same FS as the store ⇒ hard-links work). - {Name: "npm_config_modules_dir", Value: cacheMountPath + "/node_modules"}, } default: // yarn return []corev1.EnvVar{ @@ -331,7 +334,11 @@ func (r *AppReconciler) BuildJob(app *bakerv1alpha1.App, token string, gitCred g SecurityContext: resolvedSecurityContext(setupR), } - // fetch: the ONLY container that receives secrets. Writes to /data. + // fetch: the ONLY container that receives secrets. Writes to /data. It runs + // installed tools (codegen, data scripts) via node_modules/.bin on the shared + // /work emptyDir — populated by setup, visible here because /work is one + // emptyDir shared across the pod's init containers. No cache mount needed: the + // PM cache/store is only consumed at install time, not at exec time. fetchEnv := append([]corev1.EnvVar{}, toEnvVars(app.Spec.Pipeline.Phases.Fetch.Env)...) fetchEnv = append(fetchEnv, envMapVars(app.Spec.Pipeline.Phases.Fetch.EnvMap)...) fetchEnv = append(fetchEnv, toSecretEnvVars(app.Spec.Pipeline.Phases.Fetch.Secrets)...) @@ -349,13 +356,18 @@ func (r *AppReconciler) BuildJob(app *bakerv1alpha1.App, token string, gitCred g } // build: public build.env + NODE_OPTIONS etc. Mounts cache RW (both PMs) and - // data RO. NEVER mounts the output PVC. + // dataCache RW. NEVER mounts the output PVC. dataCache is RW here (not just in + // fetch) so the build can persist its own artefacts across runs — e.g. an + // incremental-build cache dir (astro-incremental) the build writes each run. + // This does NOT widen the credential boundary: that invariant is about the + // fetch script never WRITING secrets into dataCache (readability by build's + // untrusted deps), which RW-vs-RO in build does not change. buildEnv := append([]corev1.EnvVar{}, pmEnv...) buildEnv = append(buildEnv, toEnvVars(app.Spec.Pipeline.Phases.Build.Env)...) buildEnv = append(buildEnv, envMapVars(app.Spec.Pipeline.Phases.Build.EnvMap)...) buildMounts := append(append([]corev1.VolumeMount{}, base...), cacheMount, - corev1.VolumeMount{Name: volData, MountPath: dataMountPath, ReadOnly: true}, + corev1.VolumeMount{Name: volData, MountPath: dataMountPath}, shimMount) build := corev1.Container{ Name: "build", @@ -369,7 +381,11 @@ func (r *AppReconciler) BuildJob(app *bakerv1alpha1.App, token string, gitCred g } // copier (MAIN): the only container mounting the output PVC. Publishes the - // built bundle and emits build-derived status as a termination message. + // built bundle and emits build-derived status as a termination message. Also + // mounts the dataCache RO: an app may place its outputDir behind a symlink into + // the dataCache (e.g. `build -> /data` to persist an incremental build's output + // across runs), so the copier must be able to follow that symlink to read + // OUTPUT_DIR. RO — the copier only reads the source and writes the output PVC. outputDir := app.Spec.Pipeline.Phases.Build.OutputDir if outputDir == "" { outputDir = "dist" @@ -385,7 +401,8 @@ func (r *AppReconciler) BuildJob(app *bakerv1alpha1.App, token string, gitCred g {Name: "KEEP_RELEASES", Value: fmt.Sprintf("%d", app.Spec.KeepReleases)}, }, VolumeMounts: append(append([]corev1.VolumeMount{}, base...), - corev1.VolumeMount{Name: volOutput, MountPath: outputMountPath}), + corev1.VolumeMount{Name: volOutput, MountPath: outputMountPath}, + corev1.VolumeMount{Name: volData, MountPath: dataMountPath, ReadOnly: true}), TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, SecurityContext: hardenedSecurityContext(), } diff --git a/internal/controller/buildpod_test.go b/internal/controller/buildpod_test.go index 45990bc..732b6b8 100644 --- a/internal/controller/buildpod_test.go +++ b/internal/controller/buildpod_test.go @@ -115,8 +115,14 @@ func TestBuildJob_YarnVsPnpmVolumeLayout(t *testing.T) { pnpm.Spec.Pipeline.PackageManager = bakerv1alpha1.PackageManagerPnpm pjob := r.BuildJob(pnpm, "t", gitCredentialDecision{}) pbuild := containerByName(pjob.Spec.Template.Spec.InitContainers, "build") - if !hasEnv(pbuild.Env, "npm_config_store_dir") || !hasEnv(pbuild.Env, "npm_config_modules_dir") { - t.Fatalf("pnpm build must set npm_config_store_dir and npm_config_modules_dir (store+node_modules on cache PVC)") + if !hasEnv(pbuild.Env, "npm_config_store_dir") { + t.Fatalf("pnpm build must set npm_config_store_dir (content-addressable store on the cache PVC)") + } + // node_modules must stay cwd-local (/work), NOT be relocated onto the cache PVC: + // pnpm exec/run resolve bins from /node_modules/.bin, so npm_config_modules_dir + // would break `pnpm exec` in any phase (regression: graphql-codegen "not found"). + if hasEnv(pbuild.Env, "npm_config_modules_dir") { + t.Fatalf("pnpm build must NOT set npm_config_modules_dir (breaks cwd-relative pnpm exec/run bin resolution)") } // pnpm must NOT use the bogus NODE_MODULES_DIR key, and yarn must not set pnpm env. if hasEnv(pbuild.Env, "NODE_MODULES_DIR") { @@ -145,6 +151,67 @@ func TestBuildJob_YarnVsPnpmVolumeLayout(t *testing.T) { } } +// Regression: any phase (fetch especially) must be able to run tools installed +// by setup. node_modules lives on the shared /work emptyDir for BOTH managers, +// so setup's install is visible to fetch/build without relocating node_modules +// onto the cache PVC. Relocating it (npm_config_modules_dir) broke pnpm's +// cwd-relative exec/run bin lookup — a fetch `pnpm exec graphql-codegen` failed +// "command not found". Guards: no phase sets modules_dir, and fetch mounts /work +// (deps) + /data, but needs no cache mount at exec time. +func TestBuildJob_FetchCanRunInstalledDeps(t *testing.T) { + r := reconcilerForPod() + + pnpm := baseApp() + pnpm.Spec.Pipeline.PackageManager = bakerv1alpha1.PackageManagerPnpm + pjob := r.BuildJob(pnpm, "t", gitCredentialDecision{}) + for _, name := range []string{"setup", "fetch", "build"} { + c := containerByName(pjob.Spec.Template.Spec.InitContainers, name) + if c == nil { + continue // setup is conditional; only assert on phases that exist + } + if hasEnv(c.Env, "npm_config_modules_dir") { + t.Fatalf("pnpm %s must NOT relocate node_modules (breaks cwd-relative pnpm exec/run)", name) + } + } + pfetch := containerByName(pjob.Spec.Template.Spec.InitContainers, "fetch") + if mountByName(pfetch.VolumeMounts, volWork) == nil { + t.Fatalf("fetch must mount /work (holds node_modules/.bin from setup)") + } + if mountByName(pfetch.VolumeMounts, volData) == nil { + t.Fatalf("fetch must mount the data PVC") + } +} + +// The build phase mounts the dataCache RW (not just fetch): lets the build +// persist its own artefacts across runs (e.g. an incremental-build cache the +// build writes each run). fetch also mounts it RW. +func TestBuildJob_BuildMountsDataCacheRW(t *testing.T) { + r := reconcilerForPod() + job := r.BuildJob(baseApp(), "t", gitCredentialDecision{}) + for _, name := range []string{"fetch", "build"} { + c := containerByName(job.Spec.Template.Spec.InitContainers, name) + if c == nil { + t.Fatalf("%s container must exist", name) + } + m := mountByName(c.VolumeMounts, volData) + if m == nil { + t.Fatalf("%s must mount the dataCache PVC", name) + } + if m.ReadOnly { + t.Fatalf("%s must mount the dataCache RW (got ReadOnly), so the build can write its cache", name) + } + } + // The copier (MAIN container) must also mount the dataCache so it can follow an + // outputDir that lives behind a symlink into it (e.g. build -> /data). RO is fine. + copier := containerByName(job.Spec.Template.Spec.Containers, "copier") + if copier == nil { + t.Fatalf("copier container must exist") + } + if mountByName(copier.VolumeMounts, volData) == nil { + t.Fatalf("copier must mount the dataCache (to read a symlinked outputDir)") + } +} + // Requirement 4: hardened securityContext + single-pod invariants. func TestBuildJob_HardenedSecurity(t *testing.T) { app := baseApp()