build: run the image on the official Node 22 Debian slim image

The runner moves from Wolfi to node:22-trixie-slim, the same image and
digest as the dependency and build stages. Wolfi's free repository keeps
only its newest Node stream current, so nodejs-22 stayed at 22.23.2 and
missed two releases with HIGH fixes; the official image carries 22.23.4.

The runner removes npm, npx, corepack and yarn before anything else and
fails the build if one is left. The Prisma CLI install moves to its own
prisma-cli stage on the same base and is copied over, so npm never runs
in the runner. pg_dump and pg_restore 18.6 come from a pgclient stage on
postgres:18.6-trixie, pinned by digest, with the libpq they link; the
Kerberos, LDAP and SASL libraries come from apt. The PGDG package would
bring Perl and the wrapper scripts, and its repository drops a pinned
minor once the next one is out.

The native ABI and glibc check between builder and runner stays as a
guard. runtime-package-pins now holds every stage to a digest, the
runner to the builder's image, the runner free of package managers, and
the pgclient minor to the bundled database's. Dependabot ignores a
PostgreSQL major in the Dockerfile as it does in the compose file.
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 13:13:56 +01:00
1 parent de47cb7b31
commit b6b693717c
7 files changed
+251 -137

No files matched your search

+9
View File
@@ -64,6 +64,15 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 3
ignore:
# The Dockerfile's `pgclient` stage carries pg_dump and pg_restore of
# the bundled database's major. A major bump there is part of the same
# migration decision as the database itself, and the runner's paths and
# version checks name major 18, so it arrives by hand, not as a PR. Its
# minor moves with the database's in docker-compose.yml
# (runtime-package-pins.test.ts fails while the two differ).
- dependency-name: "postgres"
update-types: ["version-update:semver-major"]
# The `docker` ecosystem above reads the Dockerfile only. The bundled
# database lives in `docker-compose.yml`, which GitHub treats as its own
# ecosystem, so without this block a new PostgreSQL minor release never
+144 -95
View File
@@ -6,12 +6,15 @@
# Raspberry Pi 4's Cortex-A72; the glibc arm64 build does not use those
# instructions (#1311).
#
# The dependency and build stages run on Debian (node:22-trixie-slim), where
# pnpm installs the `-gnu` native packages. The runner is Wolfi: glibc, and
# a package set small enough that the image scan reports what the app ships
# rather than a general-purpose distribution's base system. A glibc binary
# runs on the glibc it was linked against or a newer one; the runner checks
# both that and the Node module ABI against the builder at build time.
# Every Node stage, the runner included, is the official node:22-trixie-slim
# image, pinned by its multi-arch index digest. Node security releases reach
# that image within days of their publication, which no distribution package
# of an older Node stream matched. The runner removes the package managers
# the image ships; the one npm install the runtime needs happens in the
# `prisma-cli` stage and is copied over. pg_dump and pg_restore come from the
# official PostgreSQL image in the `pgclient` stage. The runner still checks
# the Node module ABI and the glibc against the builder at build time, so a
# future change to one of the stages cannot pair them wrongly.
# ── Stage 1: Dependencies ──────────────────────────────────
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS deps
@@ -85,42 +88,133 @@ RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version"
RUN node scripts/verify-message-catalogs.mjs --dir messages \
--config src/lib/i18n/config.ts --write /app/.message-locales
# ── Stage 3: Production runner ─────────────────────────────
FROM cgr.dev/chainguard/wolfi-base:latest@sha256:629d9acaceda7746cd9293365eb0d8e8fabf74e91830b05470cfbee20b53f447 AS runner
# `nodejs-22` is the runtime; the builder's Node 22 and this one share the
# module ABI, which the check below holds. `tzdata` is required so
# Europe/Berlin schedules (pg-boss cron, locale-aware timestamp formatting)
# resolve to the actual offset instead of silently falling back to UTC.
# ── Stage 3: Migration CLI ─────────────────────────────────
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. This is the
# only npm install the runtime needs; it runs here, on the runner's own base,
# and the runner copies `/opt/prisma-cli` over, so npm never reaches the
# runtime image.
#
# `apk upgrade` pulls in package security fixes the pinned base digest has
# not absorbed yet (the digest pin stays for build reproducibility; fixes
# from the Wolfi repository override it deliberately — CVE-2026-14456 in
# openssl was the first instance the container scan caught between
# base-image rebuilds).
# The `allowScripts` field is for npm 12 and later, which runs no dependency
# install scripts unless package.json lists the package there. Three packages
# here have one: `@prisma/engines` downloads the schema engine `migrate
# deploy` runs, `prisma` checks its environment, and `esbuild` (under tsx)
# checks its binary. The npm in this image still runs them without the field;
# keeping it means a later npm major cannot drop the schema engine quietly.
# Either way the runner asks for the engine file after the copy.
#
# `postgresql-18-client` carries `pg_dump` and `pg_restore` for the nightly
# database dump, the pre-migration dump in the entrypoint and the
# `db-restore` service (docs/ops/disaster-recovery.md). Major 18 matches the
# bundled database; a client dumps servers of its own major and older, so it
# also serves an install still on its own Postgres 16.
# This install belongs to npm, not pnpm, so the `overrides` block in
# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app
# tree still arrives here at whatever version Prisma asks for. That is not
# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves
# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH,
# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit
# whose dependency audit over the app tree was green. Two installs, two
# override mechanisms, one of them set. `overrides` is npm's own mechanism and
# `npm pkg set` writes it into the package.json that `npm init -y` just
# created, before anything resolves.
#
# busybox, part of the base, is the shell the entrypoint runs under and
# carries `tar`/`gzip` for the GeoLite2 extraction and `addgroup`/`adduser`.
# Measured by rebuilding this exact install both ways: without the field
# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version`
# still reports 7.8.0. Keep the range in step with the entry in
# `pnpm-workspace.yaml` — they are one decision applied to two package
# managers, and a fix that lands in only one of them is the defect above.
#
# The two packages the app runs on are pinned to an exact package version,
# so a rebuild of the same commit runs the same Node and the same pg_dump;
# `apk upgrade` cannot move a pinned package, and moves only the libraries
# under them. No updater reads these pins. What moves them is the image
# scan: Wolfi records the fixed package version for every advisory, so a
# pinned package with a published fix turns the HIGH/CRITICAL gate red and
# names the version to pin. To bump, take the newest from
# `apk policy nodejs-22` / `apk policy postgresql-18-client` in the pinned
# base. `src/__tests__/runtime-package-pins.test.ts` holds the pin format,
# and the client's minor to the bundled database's in docker-compose.yml.
# tzdata stays unpinned on purpose: a zone rule change is a fix to take.
RUN apk add --no-cache nodejs-22=22.23.2-r1 tzdata postgresql-18-client=18.6-r7 && \
apk upgrade --no-cache && \
mkdir -p /usr/local/bin
# The same class recurred on 2026-09-02: the image scan reported
# `@hono/node-server@1.19.11` (GHSA-frvp-7c67-39w9, CVE-2026-39406) and
# `valibot@1.2.0` (CVE-2026-59952) under this path, both reached through
# `prisma -> @prisma/dev`, while the app tree carried 1.19.17 and a pin the
# npm install never reads. Rebuilding this install both ways confirms it:
# without the two fields below npm resolves 1.19.11 and 1.2.0, with them it
# resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0.
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS prisma-cli
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm pkg set 'allowScripts.@prisma/engines=true' 'allowScripts.prisma=true' 'allowScripts.esbuild=true' --json && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.9 --no-fund --no-audit
# ── Stage 4: PostgreSQL client ─────────────────────────────
# pg_dump and pg_restore of the bundled database's major, taken from the
# official PostgreSQL image (Debian trixie, the runner's own release; its
# binaries are the PGDG builds) together with the libpq they link. Debian
# trixie itself ships major 17. The PGDG apt package was the other route: it
# brings Perl and Debian's version-picking wrapper scripts for two binaries
# the app calls by name, and its repository drops a minor once the next one
# is out, so a pinned package version stops installing. A digest-pinned
# image stays buildable, and Dependabot proposes its next minor the way it
# does for the bundled database. The libpq file is found through the loader
# rather than by an architecture path, so the step is the same on amd64 and
# arm64.
FROM postgres:18.6-trixie@sha256:3a4264520e121176619f54ff867d12774bc81e95e7b84db58d63bccdb6144b81 AS pgclient
RUN set -eu; \
mkdir -p /out/bin /out/lib; \
cp /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore /out/bin/; \
LIBPQ="$(ldd /usr/lib/postgresql/18/bin/pg_dump | awk '$1 == "libpq.so.5" { print $3 }')"; \
[ -n "$LIBPQ" ] || { echo "ERROR: pg_dump links no libpq.so.5"; exit 1; }; \
cp -L "$LIBPQ" /out/lib/libpq.so.5
# ── Stage 5: Production runner ─────────────────────────────
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS runner
# The package managers the base ships (npm, npx, corepack, yarn) go first.
# Nothing at runtime installs a package, and the bundled npm carried the only
# HIGH findings with a fix the image scan reported. Removed in a later layer,
# their files stay in the base layer's bytes but leave the image's file
# system, which is what runs and what the scan reads.
#
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
# timestamp formatting) resolve to the actual offset instead of silently
# falling back to UTC; the slim base ships without it. tzdata stays unpinned
# on purpose: a zone rule change is a fix to take.
#
# `apt-get upgrade` pulls in OS-package security fixes the pinned base digest
# has not absorbed yet (the digest pin stays for build reproducibility;
# security patches from the Debian archive override it deliberately —
# CVE-2026-14456 in openssl was the first instance the container scan caught
# between base-image rebuilds).
#
# `pg_dump` and `pg_restore` serve the nightly database dump, the
# pre-migration dump in the entrypoint and the `db-restore` service
# (docs/ops/disaster-recovery.md). Major 18 matches the bundled database; a
# client dumps servers of its own major and older, so it also serves an
# install still on its own Postgres 16. The two binaries and their libpq come
# from the `pgclient` stage above; the Debian libraries libpq links
# (Kerberos, LDAP, SASL) come from apt below, so the build-time upgrade and
# the image scan see them. The copied binaries are outside dpkg: the scan
# does not see them and `apt-get upgrade` does not patch them, so a
# PostgreSQL client fix arrives by moving the `pgclient` pin.
# `src/__tests__/runtime-package-pins.test.ts` holds that pin to the bundled
# database's minor in docker-compose.yml.
#
# `ca-certificates` is the system trust store the OpenSSL-linked tools use
# (the Prisma schema engine, libpq) for a TLS connection to the database.
# `tar` and `gzip` for the GeoLite2 extraction and `sh` (dash) for the
# entrypoint are part of the Debian base.
RUN set -eux; \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-v*; \
sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v yarn && ! command -v pnpm'; \
export DEBIAN_FRONTEND=noninteractive; \
apt-get update; \
apt-get upgrade -y --no-install-recommends; \
apt-get install -y --no-install-recommends \
ca-certificates tzdata libgssapi-krb5-2 libldap2 libsasl2-2; \
apt-get clean; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /root/.npm /root/.cache
COPY --from=pgclient /out/bin/ /usr/lib/postgresql/18/bin/
COPY --from=pgclient /out/lib/libpq.so.5 /usr/lib/postgresql/18/lib/libpq.so.5
RUN echo /usr/lib/postgresql/18/lib > /etc/ld.so.conf.d/postgresql-18.conf && \
ldconfig && \
! ldd /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore | grep -q "not found"
ENV PATH=/usr/lib/postgresql/18/bin:$PATH
# The builder and the runner start from the same image, so the two agree
# today. The check stays: it fails the build the day one of them moves to
# another base or another Node major without the other.
COPY --from=builder /app/.native-abi /tmp/.native-abi
RUN set -e; \
read -r BUILD_ABI BUILD_GLIBC BUILD_NODE < /tmp/.native-abi; \
@@ -161,8 +255,9 @@ ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ARG NEXT_PUBLIC_APP_BUILD_SHA
ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
RUN addgroup -S -g 1001 nodejs && \
adduser -S -u 1001 -G nodejs -h /home/nextjs -s /sbin/nologin nextjs
RUN groupadd --system --gid 1001 nodejs && \
useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs \
--shell /usr/sbin/nologin nextjs
# Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a
# fresh named volume mounted here starts with that owner: Docker fills an
@@ -325,59 +420,13 @@ RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \
pg_dump --version | grep -q "(PostgreSQL) 18\." && \
pg_restore --version | grep -q "(PostgreSQL) 18\."
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
# config loads dotenv from /app, so expose the isolated copy there. npm is
# installed for this one step and removed in the same layer, with its cache,
# so no package manager is left in the runtime surface.
#
# Wolfi's npm is major 12, which runs no dependency install scripts unless
# package.json lists the package under `allowScripts`. Three packages here
# have one: `@prisma/engines` downloads the schema engine `migrate deploy`
# runs, `prisma` checks its environment, and `esbuild` (under tsx) checks
# its binary. Without the field the install succeeds and ships no schema
# engine, so the check after this step asks the CLI for it.
#
# This install belongs to npm, not pnpm, so the `overrides` block in
# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app
# tree still arrives here at whatever version Prisma asks for. That is not
# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves
# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH,
# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit
# whose dependency audit over the app tree was green. Two installs, two
# override mechanisms, one of them set. `overrides` is npm's own mechanism and
# `npm pkg set` writes it into the package.json that `npm init -y` just
# created, before anything resolves.
#
# Measured by rebuilding this exact install both ways: without the field
# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version`
# still reports 7.8.0. Keep the range in step with the entry in
# `pnpm-workspace.yaml` — they are one decision applied to two package
# managers, and a fix that lands in only one of them is the defect above.
#
# The same class recurred on 2026-09-02: the image scan reported
# `@hono/node-server@1.19.11` (GHSA-frvp-7c67-39w9, CVE-2026-39406) and
# `valibot@1.2.0` (CVE-2026-59952) under this path, both reached through
# `prisma -> @prisma/dev`, while the app tree carried 1.19.17 and a pin the
# npm install never reads. Rebuilding this install both ways confirms it:
# without the two fields below npm resolves 1.19.11 and 1.2.0, with them it
# resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0.
RUN apk add --no-cache npm && \
mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm pkg set 'allowScripts.@prisma/engines=true' 'allowScripts.prisma=true' 'allowScripts.esbuild=true' --json && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.9 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
# The migration CLI from the `prisma-cli` stage. The Prisma config loads
# dotenv from /app, so expose the isolated copy there. Root-owned: the app
# user can run it but not rewrite it.
COPY --from=prisma-cli /opt/prisma-cli /opt/prisma-cli
RUN ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
apk del --no-cache npm && \
rm -rf /root/.npm /root/.cache && \
sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v pnpm'
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma
# The schema engine the entrypoint's `migrate deploy` runs is in place.
# Look for the file before asking the CLI: a CLI that finds none downloads
@@ -407,7 +456,7 @@ RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 |
# user so the runtime worker can WRITE it: a self-hoster who sets
# MAXMIND_LICENSE_KEY on the published image has the databases fetched into
# this directory at runtime (src/lib/geo/geolite2-fetch.ts) with no rebuild or
# manual mount. `tar`/`gzip` for the extraction come with busybox. A
# manual mount. `tar`/`gzip` for the extraction come with the base. A
# read-only bind mount at this path (the bring-your-own-database route) still
# works — the runtime fetch just fails soft and the mounted files stand.
RUN mkdir -p /opt/geolite2 && chown nextjs:nodejs /opt/geolite2
@@ -416,7 +465,7 @@ COPY --chown=nextjs:nodejs assets/geolite2/ /opt/geolite2/
# `wget` for health checks written outside the image. A compose file copied
# from this repository up to v1.46.6 and the TrueNAS catalog app both call
# `wget --spider` against /api/health, and their check replaces the
# HEALTHCHECK below; the Wolfi base has no wget, so without this every such
# HEALTHCHECK below; the slim base has no wget, so without this every such
# container stayed unhealthy. `scripts/wget-shim.mjs` understands the flags
# those checks use, exits like wget, and writes nowhere but stdout. The
# image's own HEALTHCHECK and the bundled compose file use the Node probe.
@@ -431,7 +480,7 @@ RUN printf '#!/bin/sh\nexec node /usr/local/lib/healthlog/wget-shim.mjs "$@"\n'
# Declared this late in the stage on purpose: the value differs on
# every CI run, and every instruction after an ARG shares its cache
# fate. Down here the only layers it invalidates are the cheap
# entrypoint COPY/chmod below; the npm-install layers above stay
# entrypoint COPY/chmod below; the layers above stay
# cache-warm across rebuilds of the same release.
ARG NEXT_PUBLIC_APP_BUILT_AT
ENV NEXT_PUBLIC_APP_BUILT_AT=$NEXT_PUBLIC_APP_BUILT_AT
+9 -6
View File
@@ -224,8 +224,8 @@ describe("release container inputs", () => {
const dockerfile = readRepoFile("Dockerfile");
const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? [];
// deps and builder; the runner is Wolfi (canvas-binary-hoist-guard).
expect(fromLines).toHaveLength(2);
// deps, builder, the prisma-cli install and the runner.
expect(fromLines).toHaveLength(4);
expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual(
new Set([
"FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f",
@@ -244,11 +244,14 @@ describe("release container inputs", () => {
expect(
dockerfile.match(/corepack prepare pnpm@11\.15\.1 --activate/g),
).toHaveLength(2);
// The runner installs npm for the Prisma CLI step only, removes it in the
// same layer, and fails the build if any package manager is left.
expect(dockerfile).toContain("apk del --no-cache npm");
// The Prisma CLI is installed in its own stage and copied over; the
// runner removes the base image's package managers and fails the build
// if any is left (runtime-package-pins holds the rest).
expect(dockerfile).toContain(
"sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v pnpm'",
"COPY --from=prisma-cli /opt/prisma-cli /opt/prisma-cli",
);
expect(dockerfile).toContain(
"sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v yarn && ! command -v pnpm'",
);
expect(dockerfile).toContain("tsx@4.23.1");
expect(dockerfile).toContain(
@@ -61,7 +61,7 @@ describe("canvas binary hoist", () => {
expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/);
});
it("installs on Debian and runs on Wolfi, both glibc", () => {
it("installs and runs on the same Debian glibc image", () => {
const froms = DOCKERFILE.match(/^FROM \S+ AS \S+$/gm) ?? [];
expect(froms).toEqual([
expect.stringMatching(
@@ -71,7 +71,13 @@ describe("canvas binary hoist", () => {
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS builder$/,
),
expect.stringMatching(
/^FROM cgr\.dev\/chainguard\/wolfi-base:latest@sha256:[0-9a-f]{64} AS runner$/,
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS prisma-cli$/,
),
expect.stringMatching(
/^FROM postgres:18\.\d+-trixie@sha256:[0-9a-f]{64} AS pgclient$/,
),
expect.stringMatching(
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS runner$/,
),
]);
// The native packages pnpm picked in the builder must load in the
+78 -31
View File
@@ -1,14 +1,15 @@
/**
* The runtime packages the image installs from apk are pinned, and the
* PostgreSQL client moves with the bundled database.
* The runtime image is pinned by image digest, carries no package manager,
* and its PostgreSQL client moves with the bundled database.
*
* The runner installs Node and the PostgreSQL client from the Wolfi
* repository. Unpinned, a rebuild of the same commit could ship another
* Node or another pg_dump than the one the release was checked with. No
* updater reads an apk pin; the image scan is what flags a pinned package
* once Wolfi publishes a fix (the Dockerfile comment explains the bump), so
* this file only holds the pins' shape and their agreement with the rest of
* the repository.
* Node comes from the official node:22-trixie-slim image and pg_dump /
* pg_restore from the official PostgreSQL image, both by digest. Without
* the digest a rebuild of the same commit could ship another Node or
* another pg_dump than the one the release was checked with. Dependabot's
* `docker` ecosystem proposes the next digest or minor; the image scan
* names a fix the pinned base has not absorbed. This file holds the pins'
* shape, the runner's lack of npm, and the client's agreement with the
* bundled database.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -19,38 +20,84 @@ const ROOT = process.cwd();
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8");
function runner(): string {
const start = DOCKERFILE.search(/^FROM .* AS runner$/m);
expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0);
return DOCKERFILE.slice(start);
const DIGEST = /@sha256:[0-9a-f]{64}$/;
function froms(): { image: string; stage: string }[] {
return [...DOCKERFILE.matchAll(/^FROM (\S+) AS (\S+)$/gm)].map((m) => ({
image: m[1]!,
stage: m[2]!,
}));
}
function apkAdds(): string[] {
return [...runner().matchAll(/apk add --no-cache ([^&\n\\]+)/g)].flatMap(
(m) => m[1]!.trim().split(/\s+/),
);
function stage(name: string): string {
const start = DOCKERFILE.search(new RegExp(`^FROM .* AS ${name}$`, "m"));
expect(start, `the Dockerfile has a ${name} stage`).toBeGreaterThan(0);
const next = DOCKERFILE.slice(start + 1).search(/^FROM /m);
return next < 0
? DOCKERFILE.slice(start)
: DOCKERFILE.slice(start, start + 1 + next);
}
/** The runner's instructions, comments dropped. */
function runnerInstructions(): string {
return stage("runner")
.split("\n")
.filter((line) => !line.trimStart().startsWith("#"))
.join("\n");
}
describe("runtime package pins", () => {
it("pins nodejs-22 to one exact Node 22 package version", () => {
const pins = apkAdds().filter((p) => p.startsWith("nodejs"));
expect(pins).toHaveLength(1);
expect(pins[0]).toMatch(/^nodejs-22=22\.\d+\.\d+-r\d+$/);
it("pins every stage's base image by digest", () => {
const all = froms();
// Every FROM names a stage, so none escapes the list above.
expect(DOCKERFILE.match(/^FROM /gm)).toHaveLength(all.length);
for (const { image, stage: name } of all) {
expect(image, `${name} pins its base by digest`).toMatch(DIGEST);
}
});
it("pins postgresql-18-client to the bundled database's minor", () => {
const pins = apkAdds().filter((p) => p.startsWith("postgresql"));
expect(pins).toHaveLength(1);
const client = /^postgresql-18-client=(18\.\d+)-r\d+$/.exec(pins[0]!);
expect(client, `an exact pin, not ${pins[0]}`).not.toBeNull();
it("runs on the builder's own official Node 22 image", () => {
const byStage = new Map(froms().map((f) => [f.stage, f.image]));
const runner = byStage.get("runner");
expect(runner).toMatch(/^node:22-trixie-slim@sha256:[0-9a-f]{64}$/);
// Same image as the stage the native packages were installed in.
expect(runner).toBe(byStage.get("builder"));
});
it("removes npm, npx, corepack and yarn from the runner and runs none of them", () => {
const runner = runnerInstructions();
for (const path of [
"/usr/local/lib/node_modules/npm",
"/usr/local/lib/node_modules/corepack",
"/usr/local/bin/npm",
"/usr/local/bin/npx",
"/usr/local/bin/corepack",
"/usr/local/bin/yarn",
]) {
expect(runner).toContain(path);
}
expect(runner).toContain(
"sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v yarn && ! command -v pnpm'",
);
// Nothing in the runner installs a package manager or runs one.
expect(runner).not.toMatch(/\b(npm|npx|corepack|yarn|pnpm) [a-z]/);
expect(runner).not.toMatch(/apt-get install[^;&]*\b(npm|nodejs)\b/);
expect(runner).not.toMatch(/\bapk\b/);
});
it("takes the PostgreSQL client from the bundled database's minor", () => {
const pg = froms().find((f) => f.stage === "pgclient");
expect(pg, "the Dockerfile has a pgclient stage").toBeDefined();
const client = /^postgres:(18\.\d+)-trixie@sha256:[0-9a-f]{64}$/.exec(
pg!.image,
);
expect(client, `a pinned major 18 image, not ${pg!.image}`).not.toBeNull();
const db = /^\s*image:\s*postgres:(\d+\.\d+)-alpine$/m.exec(COMPOSE);
expect(db, "the compose file pins the database").not.toBeNull();
// One decision: a database minor bump moves the client with it.
expect(client![1]).toBe(db![1]);
});
it("leaves no other package floating except tzdata and the build-only npm", () => {
const unpinned = apkAdds().filter((p) => !p.includes("="));
expect(new Set(unpinned)).toEqual(new Set(["tzdata", "npm"]));
expect(stage("runner")).toContain(
"COPY --from=pgclient /out/bin/ /usr/lib/postgresql/18/bin/",
);
});
});
+1 -1
View File
@@ -9,7 +9,7 @@
* restart loop (live incident: a Synology DS1520+ self-host, exit code 132
* ~18 s after every boot once the thumbnail backfill touched Skia).
*
* The published image runs on glibc (Wolfi) and loads the glibc build. Under an
* The published image runs on glibc (Debian) and loads the glibc build. Under an
* emulated AVX2-less Atom core that build drew a canvas and rendered
* a PDF page to JPEG where the musl build died, but it still carries AVX2 code
* and not every path it can take was exercised, so the x64 gate stays closed
+2 -2
View File
@@ -23,11 +23,11 @@
*
* Extraction is pure Node: `zlib.gunzipSync` + a minimal in-process tar reader
* (`extractMmdbFromTarGz`), NOT a shell-out to `tar`. The runtime image does
* carry busybox `tar`+`gzip`, but this module is reachable from the worker-boot
* carry `tar` and `gzip`, but this module is reachable from the worker-boot
* import chain (`instrumentation.ts` → `reminder-worker` → this file), and a
* `child_process` import there makes Next's file tracer give up and trace the
* whole project into the standalone output. `node:zlib` keeps the trace bounded,
* removes the busybox runtime dependency entirely, and is trivially unit-testable
* removes the dependency on those tools entirely, and is trivially unit-testable
* — the MaxMind tarball nests a single `.mmdb` under one date-stamped directory,
* a shape a ~40-line reader handles without a dependency.
*/