From 9fee1f1e7581e74484bc6c852fb526732a87380f Mon Sep 17 00:00:00 2001 From: tnagorra Date: Thu, 16 Jul 2026 07:53:20 +0545 Subject: [PATCH 1/3] feat(images): add Node 22 managed toolchain Add an operator-managed node22 base image (digest-pinned node:22-alpine + bash/git/build-base/python3 + corepack with yarn 1.22.22 and pnpm 10.34.5 pre-activated for offline resolution under readOnlyRootFilesystem), selectable via spec.pipeline.nodeVersion: 22. Wire it through every consumer the image-set drift guard enforces: images/Makefile (build target + pinned base digest), the CI docker_build matrix, the release-body image loop, the chart's operator.nodeImages values, the rendered helm snapshots, and the check-images drift-test fixture. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 4 +- .../toggle-web-baker/snapshots/console.yaml | 4 +- .../toggle-web-baker/snapshots/defaults.yaml | 4 +- .../toggle-web-baker/snapshots/git-auth.yaml | 4 +- .../snapshots/monitoring.yaml | 4 +- .../toggle-web-baker/snapshots/no-crd.yaml | 4 +- deploy/helm/toggle-web-baker/values.yaml | 4 ++ images/Makefile | 7 ++- images/node22/Dockerfile | 47 +++++++++++++++++++ images/node22/README.md | 20 ++++++++ images/test/check_images_test.sh | 2 +- 12 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 images/node22/Dockerfile create mode 100644 images/node22/README.md 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=$? From 64d1ec8ee080858fe2fb1e307bbe14f1f4243e4f Mon Sep 17 00:00:00 2001 From: tnagorra Date: Thu, 16 Jul 2026 07:54:28 +0545 Subject: [PATCH 2/3] fix(operator): resolve pnpm node_modules cwd-locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm builds set npm_config_modules_dir=/cache/node_modules, relocating node_modules onto the cache PVC. But `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". Drop npm_config_modules_dir and keep only the content-addressable store on the cache PVC (npm_config_store_dir). node_modules now lands on the per-run /work emptyDir for pnpm too (same as yarn), shared across the pod's init containers, so cwd-relative bin resolution works in every phase while the store still gives cross-run install speedup. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/controller/buildpod.go | 37 +++++++++++++++---------- internal/controller/buildpod_test.go | 41 ++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/internal/controller/buildpod.go b/internal/controller/buildpod.go index 05d3a4d..2ceee88 100644 --- a/internal/controller/buildpod.go +++ b/internal/controller/buildpod.go @@ -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)...) diff --git a/internal/controller/buildpod_test.go b/internal/controller/buildpod_test.go index 45990bc..8a84bfe 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,37 @@ 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") + } +} + // Requirement 4: hardened securityContext + single-pod invariants. func TestBuildJob_HardenedSecurity(t *testing.T) { app := baseApp() From 7354bcd0bb107d859a91b43d3c5a70407d9e25c6 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Thu, 16 Jul 2026 07:55:30 +0545 Subject: [PATCH 3/3] feat(operator): mount dataCache RW in build + copier to support incremental builds The build phase mounted the dataCache read-only, so an app that persists an incremental-build working tree there (e.g. astro-incremental via a `build -> /data` symlink) failed `mkdir build/incremental` (EROFS/ENOENT). Mount the dataCache RW in the build phase so the build can write its cache and prior output back for reuse across runs, and mount it RO on the copier so it can follow a symlinked outputDir (build/out -> /data/out) when publishing the bundle. This does not widen the credential boundary: that invariant is about the fetch script never writing secrets into the dataCache (readable by build's untrusted deps), which RW-vs-RO in build does not change. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/controller/buildpod.go | 20 ++++++++++++++----- internal/controller/buildpod_test.go | 30 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/internal/controller/buildpod.go b/internal/controller/buildpod.go index 2ceee88..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" @@ -356,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", @@ -376,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" @@ -392,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 8a84bfe..732b6b8 100644 --- a/internal/controller/buildpod_test.go +++ b/internal/controller/buildpod_test.go @@ -182,6 +182,36 @@ func TestBuildJob_FetchCanRunInstalledDeps(t *testing.T) { } } +// 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()