diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 985a0c309..c9406d15a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 53f34f46c..05fef0ebe 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -245,12 +245,23 @@ jobs: rm trivy-install.sh trivy --version - # Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built - # image fails the job. For a finding without an upstream fix - # (e.g. a base-image CVE Wolfi has not patched yet), add the CVE - # id to a `.trivyignore` file at the repository root — Trivy reads - # it automatically — one CVE per line, each with a comment naming - # the tracking issue and the re-check condition. Never suppress a - # finding that has a fixed version available; bump instead. + # Every HIGH/CRITICAL finding in the built image, fixed or not, as a + # table in the job log. Reporting only: a Debian base carries + # advisories the distribution has not fixed yet (or marked as not + # worth a fix), and nothing this repository changes can remove those. + # The list is here so they stay visible and move to the gate below + # the day a fix is published. + - name: Trivy container scan (all findings) + run: trivy image --format table --severity CRITICAL,HIGH --exit-code 0 healthlog:scan + + # Blocking since v1.15.20 — a HIGH/CRITICAL finding with a published + # fix fails the job. `--ignore-unfixed` leaves out only the findings + # listed above that have no fixed version yet; a fix that reaches the + # Debian archive, the Node image or a dependency turns this red until + # it is taken (rebuild, bump the base digest, or bump the package). + # `.trivyignore` at the repository root (Trivy reads it automatically) + # is for a finding with a fix that cannot reach this image, one CVE + # per line with a comment naming what it covers and when to remove + # it. Never suppress a finding whose fix applies here; take it. - name: Trivy container scan - run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan + run: trivy image --format table --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 healthlog:scan diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index d03f4baa3..dfdad5f61 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -6,7 +6,7 @@ name: Trivy Scheduled Scan # - `security.yml` → `dependency-audit` runs `pnpm audit --prod` on every # push to `main` and once a day, blocking on HIGH/CRITICAL. # - `security.yml` → `docker-security` builds the image on a push to `main` -# and runs `trivy image` against it, blocking on HIGH/CRITICAL. +# and runs `trivy image` against it, blocking on HIGH/CRITICAL with a fix. # # Both are change-triggered, and both answer "is this change safe". Neither # answers "is what we shipped last week still safe", because an advisory @@ -162,3 +162,6 @@ jobs: format: table severity: "CRITICAL" exit-code: "1" + # Same rule as the blocking gate in security.yml: a finding with a + # published fix fails, one without is in the SARIF report above. + ignore-unfixed: true diff --git a/CHANGELOG.md b/CHANGELOG.md index b5e7c15e4..95d0a94b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,16 +51,20 @@ ### Changed -- **The image runs on Wolfi instead of Alpine.** The runtime is glibc now, - so the native page renderer, password hashing and the Prisma engine run - their glibc builds. The published arm64 image renders PDF pages on a - Raspberry Pi 4 again, where the Alpine build of the renderer needs CPU - features an ARMv8.0 core lacks. The compressed image is a little smaller - than before. A health check that still calls `wget`, from a compose - file copied before this release or a platform template, keeps working: - the image carries a small `wget` that answers health checks and nothing - else. The current `docker-compose.yml` probes with Node directly, and - that is the one to switch to. (#1311) +- **The image runs on the official Node 22 image (Debian slim) instead of + Alpine.** The runtime is glibc now, so the native page renderer, + password hashing and the Prisma engine run their glibc builds. The + published arm64 image renders PDF pages on a Raspberry Pi 4 again, where + the Alpine build of the renderer needs CPU features an ARMv8.0 core + lacks. Node security releases now reach the image with the official + Node image, usually within days. The image carries no package manager: + npm, npx, corepack and yarn are removed. `pg_dump` and `pg_restore` are + PostgreSQL 18.6, the bundled database's version; `psql` is no longer in + the app image, so run it in the `db` container. A health check that + still calls `wget`, from a compose file copied before this release or a + platform template, keeps working: the image carries a small `wget` that + answers health checks and nothing else. The current `docker-compose.yml` + probes with Node directly, and that is the one to switch to. (#1311) - **The app connects to the database with less power.** On Postgres 17 or later, with a database user that may create roles (the bundled compose file's user may), the app and the worker now connect as a role of their diff --git a/Dockerfile b/Dockerfile index 04585fd95..8736ac053 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index f605cbc78..4a1a137ee 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The App Store listing carries a small price, which exists to cover Apple's yearl ## Tech stack -Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage glibc Docker image (Debian build, Wolfi runtime). The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml). +Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Docker image on the official Node 22 Debian slim base. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml). ## Documentation diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index c602b9c12..fd7570fd2 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -10,7 +10,8 @@ already installed, longer if a TLS-fronted public hostname is in scope - **Docker 24+ with Compose v2.** The bundled `docker-compose.yml` pulls a multi-arch image from GitHub Container Registry (linux/amd64 and linux/arm64), so a Raspberry Pi 4 or 5 or an x86 VPS all work - unchanged. The image runs on glibc (a Wolfi base). Document thumbnails and + unchanged. The image is built on the official Node 22 image (Debian + slim, glibc). Document thumbnails and scanned-PDF rendering use a native renderer that runs on every arm64 board, the Pi 4 included, and on x86-64 CPUs with AVX2; on an older x86 CPU (Celeron/Atom-class NAS chips) it switches itself off and those two diff --git a/next.config.ts b/next.config.ts index a4813cdab..25c5c3724 100644 --- a/next.config.ts +++ b/next.config.ts @@ -327,13 +327,11 @@ const nextConfig: NextConfig = { // image-only-wire provider (codex/OAuth) can read it — the ambient // auto-read path. `@napi-rs/canvas` loads a native `.node` binary that the // Turbopack NFT tracer resolves dynamically and therefore misses, so pin - // the JS loader AND the two musl prebuilts (the Alpine runtime image is - // amd64 + arm64) into the standalone image explicitly. pnpm keeps them - // unhoisted under `.pnpm`. No cairo/pango apk is needed — the prebuilt - // `.node` is self-contained. + // the JS loader into the standalone image explicitly. The glibc prebuilt + // the Debian runtime image loads is placed by the Dockerfile's canvas + // hoist step, which `canvas-binary-hoist-guard.test.ts` pins. No system + // cairo/pango is needed: the prebuilt `.node` is self-contained. "./node_modules/.pnpm/@napi-rs+canvas@*/node_modules/@napi-rs/canvas/**", - "./node_modules/.pnpm/@napi-rs+canvas-linux-x64-musl@*/node_modules/@napi-rs/canvas-linux-x64-musl/**", - "./node_modules/.pnpm/@napi-rs+canvas-linux-arm64-musl@*/node_modules/@napi-rs/canvas-linux-arm64-musl/**", ], }, // v1.4.34 — silence the Turbopack NFT trace warnings emitted diff --git a/scripts/__tests__/release-workflows.test.ts b/scripts/__tests__/release-workflows.test.ts index ae4eb4b0f..9730d3c40 100644 --- a/scripts/__tests__/release-workflows.test.ts +++ b/scripts/__tests__/release-workflows.test.ts @@ -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( diff --git a/src/__tests__/canvas-binary-hoist-guard.test.ts b/src/__tests__/canvas-binary-hoist-guard.test.ts index ca985e095..63c72eda8 100644 --- a/src/__tests__/canvas-binary-hoist-guard.test.ts +++ b/src/__tests__/canvas-binary-hoist-guard.test.ts @@ -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 diff --git a/src/__tests__/runtime-package-pins.test.ts b/src/__tests__/runtime-package-pins.test.ts index b032090c3..24b0610d7 100644 --- a/src/__tests__/runtime-package-pins.test.ts +++ b/src/__tests__/runtime-package-pins.test.ts @@ -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/", + ); }); }); diff --git a/src/lib/documents/native-canvas-support.ts b/src/lib/documents/native-canvas-support.ts index 1cdb407af..1c4882460 100644 --- a/src/lib/documents/native-canvas-support.ts +++ b/src/lib/documents/native-canvas-support.ts @@ -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 diff --git a/src/lib/geo/geolite2-fetch.ts b/src/lib/geo/geolite2-fetch.ts index c520781dd..bb78cb605 100644 --- a/src/lib/geo/geolite2-fetch.ts +++ b/src/lib/geo/geolite2-fetch.ts @@ -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. */