feat: distroless non-root runtime image + compiled migration entrypoint (workspace#026) - #6300
feat: distroless non-root runtime image + compiled migration entrypoint (workspace#026)#6300valentinyanakiev wants to merge 8 commits into
Conversation
workspace#026-distroless-runtime-images (US2, R-3, FR-004)
The TypeORM CLI DataSource used by migration:run/revert/show resolved its
`migrations` glob relative to the process CWD (src/migrations/*.{ts,js}).
Run from a compiled dist/ tree — as the distroless runtime image must —
that glob silently matches zero files: migration:run exits 0 having done
nothing, and every downstream deploy that carries a schema change is
silently bricked.
Anchor the glob to __dirname instead, so the identical config resolves
src/migrations/*.ts under ts-node (developer flow, unaffected) and
dist/migrations/*.js when compiled (the distroless image's execution
mode). Add a mechanical guard asserting every migration file imports only
typeorm/node builtins (path-aliased imports would break the plain-Node
CLI path); all current migrations already comply.
Pre-existing pnpm lint failure unrelated to this feature, encountered while bringing the exit-gate pass green for workspace#026-distroless-runtime-images.
workspace#026-distroless-runtime-images (US1, R-2, FR-001, FR-002, FR-003, SC-001, SC-002)
Replace the single-stage node:22.21.1-alpine image (ships pnpm, ts-node,
full sources, dev dependencies, and a /wait binary into production) with
a three-stage build:
1. builder — node:22.21.1-bookworm-slim (glibc, matches the repo's
Volta pin), full deps, compiles TypeScript to dist/.
2. proddeps — the SAME glibc base, a fresh `pnpm install --prod`. Never
copied from an Alpine/musl layer: sharp's prebuilt native binaries
are ABI-specific to the libc they were resolved against, and a musl
artifact silently fails to load on glibc.
3. runtime — gcr.io/distroless/nodejs22-debian12:nonroot, digest-pinned.
No shell, no package manager, no /wait, no src/, no dev deps. Runs
as UID 65532. CMD ["dist/main.js"] (the distroless node entrypoint).
Dropping /wait is verified consumer-safe: no Kubernetes manifest invokes
the image's own /wait — the server Deployment's readiness gating uses a
separate groundnuty/k8s-wait-for initContainer.
Supporting package.json changes, both required for the --prod install to
actually be production-only:
- Move ts-node and tsconfig-paths from dependencies to devDependencies.
They were miscategorized as production dependencies despite being used
only by dev/CI-time CLI scripts (migration:*, schema:*, start-nodemon);
nothing in src/ requires either at runtime. Left in `dependencies`,
`pnpm install --prod` would ship both into the "distroless" image.
- Guard the `prepare` script (`husky || true`): husky is a devDependency,
so a --prod install has no `husky` binary at all and the bare `husky`
prepare script hard-failed the install.
- Add `pnpm.supportedArchitectures.libc: ["glibc"]` so pnpm never
resolves musl-variant optional dependencies (e.g. sharp's musl
binaries) for this project going forward — encodes the "never a musl
artifact in the Debian runtime" constraint at the package-manager
level, not just via the build's stage topology.
.dockerignore: added the three-stage build's actual context exclusions
(test/, docs/, specs/, scripts/, tooling configs, generated schema
files) alongside the pre-existing generic template entries.
Measured: 176.7MB compressed vs a 317.6MB alkemio/server:latest
baseline — 44.35% smaller (SC-001, ≥40% required). Verified via
.docker/distroless-image-smoke.sh: non-root, no shell/pnpm/wait
reachable, no src/ or ts-node/pnpm in node_modules, sharp loads
(glibc-matched, US1-AS1/AS2).
workspace#026-distroless-runtime-images (US1, US2, US5, R-2, R-3, FR-005, FR-015, SC-001, SC-006) Two executable, persisted regression checks (contract checks for server-runtime-image / server-migration-entrypoint): - .docker/distroless-image-smoke.sh <image> — asserts UID 65532, CMD ["dist/main.js"], no shell/apk/apt/pnpm/wait executable, no src/ tree, no ts-node/pnpm in node_modules, sharp loads (native glibc sentinel), and enforces the SC-001 ≥40% size-reduction budget. Emits IMAGE_DIGEST=/IMAGE_SIZE_BYTES= for evidence-bundle consumption. - .docker/distroless-migration-smoke.sh <image> — spins up a throwaway postgres:17.5 container, runs the compiled TypeORM CLI migration path with zero shell involvement, and asserts the migrations_typeorm row count matches the compiled dist/migrations/*.js file count (the regression this guards against: a CWD-relative glob run from dist/ would match zero files and "succeed" having silently done nothing), then asserts migration:show reports zero pending migrations. Also enforces the SC-006 <5-minute budget. Baseline size (317,579,664 bytes compressed, alkemio/server:latest amd64) is documented inline in distroless-image-smoke.sh: the exact SHA tag pinned in dev-orchestration's base manifest (9b6a81e) wasn't resolvable from this worktree (404 on Docker Hub; dev-orchestration is a separate repo/wave not in this slice's scope), so the equivalent `latest` tag was used instead. Re-measure against the true pinned baseline during forge-verify if it becomes available.
…point workspace#026-distroless-runtime-images (US5, FR-005) Describes the three-stage build, what was removed and why (/wait, pnpm, ts-node/tsconfig-paths), the compiled migration invocation that dev-orchestration (and infra-ops at release time, R-8) must use, and how to run the persisted smoke scripts locally.
Reduce trivy HIGH/CRITICAL findings on alkemio/server:026-distroless-local
from 68 to 8 via pnpm.overrides + direct dependency bumps: sharp 0.35.3,
path-to-regexp 8.4.2 (+ express's bundled 0.1.13), multer 2.2.0, picomatch
2.3.2, undici 6.27.0, validator 13.15.35, ws (three bundlers) 7.5.13/8.21.1,
@apollo/server 4.13.0, axios 1.18.1 (unified), minimatch 3.1.5/5.1.9,
brace-expansion 1.1.16/2.1.2, fast-uri 3.1.4, form-data 3.0.5/4.0.6,
immutable 4.3.9, js-yaml 4.3.0, jws 3.2.3, linkify-it 5.0.2, lodash 4.18.1.
Also fixes a pre-existing top-level package.json "overrides" block being
silently inert under pnpm 10.17.1 (only pnpm.overrides is read) by moving
fs-capacitor/jsonwebtoken/tough-cookie/@nestjs/schematics>typescript pins
into pnpm.overrides, where they now actually apply tree-wide.
Excludes erroneously-materialized -musl optional native binaries (sharp's
libvips, @napi-rs/canvas, @swc/core) that the broader lockfile resolution
exposed, keeping the image within the SC-001 40% size-reduction floor.
Evidence: .docker/verification/{sbom.cdx.json,sizes.md,trivy-before-fix.json,
trivy-after-fix.json,US5-AS1-cve-remediation.md}. Remaining 8 findings
(libssl3 x6, typeorm x1, @nestjs/microservices x1) addressed in the next
commit.
…h (US5-AS1) Closes the remaining 8 fixable HIGH/CRITICAL trivy findings down to 2 verified-unreachable residual risks, satisfying the FR-015/US5-AS1/SC-004 zero-fixable-HIGH/CRITICAL gate: - libssl3 (1 CRITICAL + 5 HIGH, CVE-2026-31789/28387/28388/28389/28390/45447): migrate the Dockerfile's builder+runtime base pair from Debian 12 (node:22.21.1-bookworm-slim / gcr.io/distroless/nodejs22-debian12:nonroot) to Debian 13 (node:22.21.1-trixie-slim / gcr.io/distroless/nodejs22-debian13:nonroot). Debian 12 is frozen upstream at the currently-pinned patch level and Google's distroless project no longer builds fresh Debian-12 variants, so no future Debian-12 rebuild would ever clear this. This supersedes spec chief-decision C-1 (which pinned Debian 12 because the story named it verbatim) — the spec's own hard CVE gate takes precedence once a live scan proved no Debian-12 fix path exists. Same glibc family as Debian 12, so the sharp/native-module gate (R-2) is unaffected. Rebuilt + re-verified: both smoke gates green, size reduction 43.72% (still clears the 40% SC-001 floor). - typeorm CVE-2025-60542 (GHSA-q2pj-6v73-8rgj, SQL injection via repository.save/update): investigation confirms this is MySQL/mysql2- driver-only (missing `stringifyObjects: true` on the mysql2 connection options). This codebase is postgres-only (every DataSource hardcodes `type: 'postgres'`; mysql2 is not an installed dependency), so the vulnerable code path is unreachable. Rather than rebase the custom CTI fork (unsafe — no verified newer commit exists, per the prior escalation), ports the exact upstream fix (typeorm commit d57fe3b) as a local `pnpm patch` (patches/typeorm@0.3.13.patch, wired via package.json:pnpm.patchedDependencies) for defense-in-depth. Verified present in the built image. Trivy will still report this by version string (its matching is version-based, blind to patch overlays) — recorded as residual risk pending a real fork rebase once a safe upstream commit exists. - @nestjs/microservices CVE-2026-40879: unchanged disposition (documented residual risk — Transport.TCP/JsonSocket path confirmed unreachable, only Transport.RMQ is used; fixing requires a NestJS 10->11 major bump, disproportionate blast radius for an unreachable transport). Evidence updated: .docker/verification/{sizes.md,US5-AS1-cve-remediation.md, sbom.cdx.json,trivy-after-fix.json} now reflect the final 2-residual-risk posture. Full gate pass (install/lint/test/build/docker build) and both persisted smoke scripts (distroless-image-smoke.sh, distroless-migration-smoke.sh) re-verified green against the rebuilt image. Note: the shared evidence bundle at specs/026-distroless-runtime-images/forge/verification/ (workspace root) is intentionally NOT written from this worktree — implementer charter confines file writes to the worktree; promoting this evidence into the shared bundle is left to the orchestrator/a human.
…ve machine scan output to the workspace evidence bundle The ~4MB sbom/trivy JSONs live in the workspace spec's forge/verification/evidence/server/ (review advisories spec-server-2, qual-server-1). Human-readable US5-AS1-cve-remediation.md and sizes.md stay in-repo. workspace#026-distroless-runtime-images
📊 PR Metrics SummaryTitle: feat: distroless non-root runtime image + compiled migration entrypoint (workspace#026)
Flags
Thresholds{
"critical_loc": 200,
"simple_loc": 100,
"file_count": 10
} |
WalkthroughChangesDistroless runtime migration
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Operator
participant MigrationSmokeScript
participant DistrolessImage
participant PostgreSQL
Operator->>MigrationSmokeScript: provide image tag
MigrationSmokeScript->>PostgreSQL: start container and wait for readiness
MigrationSmokeScript->>DistrolessImage: run migration:run
DistrolessImage->>PostgreSQL: apply compiled migrations
MigrationSmokeScript->>PostgreSQL: count applied migrations
MigrationSmokeScript->>DistrolessImage: count compiled migration files
MigrationSmokeScript->>DistrolessImage: run migration:show
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
Schema Diff Summary: No blocking changes
Baseline branch: develop |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.docker/distroless-migration-smoke.sh:
- Around line 59-61: Replace the predictable PID-derived temporary paths in
.docker/distroless-migration-smoke.sh lines 59-61 and
.docker/distroless-image-smoke.sh lines 63-68 with paths allocated atomically
via mktemp. Ensure each script stores and uses its allocated log or probe path,
and removes that exact file through the existing cleanup paths.
In @.docker/verification/US5-AS1-cve-remediation.md:
- Around line 102-110: Revise the CVE explanation to avoid claiming the
vulnerability is neutralized based solely on the grep result. Either add a
focused runtime assertion proving the MySQL driver path uses merged connection
options containing stringifyObjects: true, or change “neutralizes the real
vulnerable behavior” to state only that the patched source is present.
In `@docs/PublishingImages.md`:
- Around line 13-30: Update the production image documentation in the
three-stage Dockerfile description: change the builder/proddeps base from
node:22.21.1-bookworm-slim to node:22.21.1-trixie-slim and the runtime base from
distroless nodejs22-debian12 to nodejs22-debian13:nonroot. Keep the existing
digest-pinning and stage descriptions unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: cc969dec-9682-4972-9fa7-f5c41dfae234
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (12)
.docker/distroless-image-smoke.sh.docker/distroless-migration-smoke.sh.docker/verification/US5-AS1-cve-remediation.md.docker/verification/sizes.md.dockerignoreDockerfiledocs/PublishingImages.mdpackage.jsonpatches/typeorm@0.3.13.patchsrc/config/typeorm.cli.config.run.spec.tssrc/config/typeorm.cli.config.run.tssrc/domain/access/role-set/role.set.resolver.mutations.membership.spec.ts
💤 Files with no reviewable changes (1)
- src/domain/access/role-set/role.set.resolver.mutations.membership.spec.ts
| > /tmp/migration-run.$$.log 2>&1 \ | ||
| || { cat /tmp/migration-run.$$.log; rm -f /tmp/migration-run.$$.log; fail "migration:run (compiled, no shell) exited non-zero"; } | ||
| rm -f /tmp/migration-run.$$.log |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Use atomically created temporary files in both smoke scripts. PID-derived /tmp paths are predictable and can be pre-created or symlinked. Use mktemp and remove the allocated file through each script’s cleanup path.
.docker/distroless-migration-smoke.sh#L59-L61: replacemigration-run.$$.logwith amktemp-created log path..docker/distroless-image-smoke.sh#L63-L68: replaceshell-probe.$$with amktemp-created probe path.
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] 59-59: Building a temp file path in a world-writable directory from the PID ($$) or `` is predictable and racy: an attacker can pre-create or guess the name and win a symlink/race attack. Use mktemp (e.g. `f=$(mktemp)` or `f=$(mktemp /tmp/myapp.XXXXXX)`) so the kernel atomically creates a unique, unpredictable file.
Context: /tmp/migration-run.$$.log
Note: [CWE-377] Insecure Temporary File.
(tmp-file-pid-name-bash)
📍 Affects 2 files
.docker/distroless-migration-smoke.sh#L59-L61(this comment).docker/distroless-image-smoke.sh#L63-L68
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.docker/distroless-migration-smoke.sh around lines 59 - 61, Replace the
predictable PID-derived temporary paths in .docker/distroless-migration-smoke.sh
lines 59-61 and .docker/distroless-image-smoke.sh lines 63-68 with paths
allocated atomically via mktemp. Ensure each script stores and uses its
allocated log or probe path, and removes that exact file through the existing
cleanup paths.
Source: Linters/SAST tools
| **Why trivy still reports this finding after the patch**: trivy's npm/yarn/ | ||
| pnpm vulnerability matching is version-string-based (reads the installed | ||
| `typeorm` package's declared version, `0.3.13`, against the advisory's affected | ||
| range `< 0.3.26`) — it has no visibility into a `pnpm patch` overlay's actual | ||
| code changes. The patch neutralizes the real vulnerable *behavior* (verified: | ||
| `grep 'stringifyObjects: true' node_modules/typeorm/driver/mysql/MysqlDriver.js` | ||
| inside the built image returns a match) but cannot change what version string | ||
| the scanner reads, since that requires the fork itself to publish a build | ||
| whose `package.json.version` is bumped past `0.3.26` — which requires exactly |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the referenced document with line numbers.
file=".docker/verification/US5-AS1-cve-remediation.md"
wc -l "$file"
sed -n '90,160p' "$file" | cat -nRepository: alkem-io/server
Length of output: 3857
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file=".docker/verification/US5-AS1-cve-remediation.md"
echo "== lines 100-110 =="
sed -n '100,110p' "$file" | cat -n
echo
echo "== lines 138-145 =="
sed -n '138,145p' "$file" | cat -n
echo
echo "== search for runtime assertion / createConnectionOptions / stringifyObjects =="
rg -n "createConnectionOptions|stringifyObjects|MysqlDriver" .docker/verification src . 2>/dev/null || trueRepository: alkem-io/server
Length of output: 3211
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file="patches/typeorm@0.3.13.patch"
wc -l "$file"
sed -n '1,80p' "$file" | cat -nRepository: alkem-io/server
Length of output: 1774
Strengthen the CVE wording. The grep only shows the patched source is present in the image; it doesn’t prove the MySQL driver path is exercised or that the merged connection options still contain stringifyObjects: true at runtime. Add a focused runtime assertion, or soften “neutralizes” to “patched source is present.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.docker/verification/US5-AS1-cve-remediation.md around lines 102 - 110,
Revise the CVE explanation to avoid claiming the vulnerability is neutralized
based solely on the grep result. Either add a focused runtime assertion proving
the MySQL driver path uses merged connection options containing
stringifyObjects: true, or change “neutralizes the real vulnerable behavior” to
state only that the patched source is present.
| Since `workspace#026-distroless-runtime-images`, the production image is built by a | ||
| three-stage `Dockerfile`: | ||
|
|
||
| 1. **`builder`** — `node:22.21.1-bookworm-slim` (glibc/Debian 12, matches the repo's | ||
| Volta pin). Installs the full dependency set (incl. dev) and compiles TypeScript | ||
| (`pnpm run build` → `dist/`). | ||
| 2. **`proddeps`** — the **same glibc base**, a fresh `pnpm install --frozen-lockfile --prod`. | ||
| This stage is never skipped or copied from an Alpine/musl layer: native modules | ||
| (`sharp`) must be installed on the exact glibc runtime they'll execute on, or | ||
| they silently fail to load (musl artifacts are ABI-incompatible with glibc). | ||
| `package.json`'s `pnpm.supportedArchitectures.libc: ["glibc"]` reinforces this by | ||
| preventing musl-variant optional dependencies from ever being resolved. | ||
| 3. **`runtime`** — `gcr.io/distroless/nodejs22-debian12:nonroot`, digest-pinned. No | ||
| shell, no package manager, no `/wait` binary, no `src/` sources, no dev | ||
| dependencies. Runs as UID 65532 (`nonroot`). Entrypoint is the distroless `node` | ||
| binary; `CMD ["dist/main.js"]`. | ||
|
|
||
| Both base image tags are pinned by digest in the `Dockerfile` header comment. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Update the documented base images to Debian 13.
The Dockerfile now uses node:22.21.1-trixie-slim and gcr.io/distroless/nodejs22-debian13:nonroot, but this section still directs operators to the superseded Bookworm/Debian 12 pair. Update Lines 16 and 25 to match the digest-pinned runtime.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/PublishingImages.md` around lines 13 - 30, Update the production image
documentation in the three-stage Dockerfile description: change the
builder/proddeps base from node:22.21.1-bookworm-slim to
node:22.21.1-trixie-slim and the runtime base from distroless nodejs22-debian12
to nodejs22-debian13:nonroot. Keep the existing digest-pinning and stage
descriptions unchanged.
Source: Path instructions
Implements the server slice of workspace#026-distroless-runtime-images (story: alkem-io/infrastructure-operations#2153, epic infra-ops#2059).
What
node:22.21.1-trixie-slimbuilder → prod-deps stage → digest-pinnedgcr.io/distroless/nodejs22-debian13:nonrootruntime. UID 65532, no shell/pkg-manager/ts-node/src/dev-deps//wait.ENV PATHincludes/nodejs/binso barecommand:overrides also resolve.src/via ts-node AND compileddist/migrations/*.js); migrations run in-image vianode ./node_modules/typeorm/cli.js migration:run --dataSource dist/config/migration.config.js..docker/distroless-image-smoke.sh,.docker/distroless-migration-smoke.sh. Human-readable CVE/size evidence in.docker/verification/; full SBOM/scan JSONs live in the workspace spec evidence bundle.Evidence (live on k8s-hetzner-sandbox, ephemeral namespace)
/health/live|ready200; GraphQL serving; full rollback drill old↔new proven.Release coupling (R-8 — release-blocking follow-up)
infrastructure-operations (prod/acc) still runs pnpm-based migration CronJob + auth-reset worker against
alkemio/serverrelease tags. The first release shipping this image MUST bundle the equivalent infra-ops manifest updates (tracked in the workspace spec + Release NN story scope).Spec:
agents-hq/specs/026-distroless-runtime-images/(C-1 amended 2026-07-24: Debian 13 pair ratified — FR-015 zero-fixable-CVE gate over the story's literal debian12 naming).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation