diff --git a/.env.production.example b/.env.production.example index 9c6ad5dbe..77412bac1 100644 --- a/.env.production.example +++ b/.env.production.example @@ -714,11 +714,12 @@ API_TOKEN_HMAC_KEY="" # ENVIRONMENT_AIR_QUALITY_DISABLED="1" # ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ── -# The bundled renderer needs a recent CPU: AVX2 on x86-64, and ARMv8.2 features -# (LSE atomics, RCpc) on arm64. On older CPUs (Celeron/Atom-class NAS chips, the -# Raspberry Pi 4) it is disabled automatically and those features degrade -# cleanly (no thumbnails; scanned PDFs read as text only). Override only if -# auto-detection misfires: +# On x86-64 the renderer is enabled only on CPUs with AVX2. On older chips +# (Celeron/Atom-class NAS CPUs) it is disabled automatically and those features +# degrade cleanly (no thumbnails; scanned PDFs read as text only). On arm64 the +# published image renders on every 64-bit core, the Raspberry Pi 4 included. +# Only a musl (Alpine) build of your own needs ARMv8.2 (LSE atomics, RCpc) and +# is gated the same way. Override only if auto-detection misfires: # off — force-disable the renderer # on — force-enable (crashes PDF processing if the CPU truly lacks them) # NATIVE_CANVAS="off" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 406816ccc..53f34f46c 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -247,7 +247,7 @@ jobs: # 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 Alpine has not patched yet), add the CVE + # (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 diff --git a/.trivyignore b/.trivyignore index 34a98f1b7..d8ad25576 100644 --- a/.trivyignore +++ b/.trivyignore @@ -3,25 +3,6 @@ # can apply; never suppress one with a fixed version available — bump instead. # Each entry names what it covers and the condition to re-check / remove it. -# CVE-2026-33671 (picomatch ReDoS, HIGH) — flagged only in the npm CLI's -# OWN bundled copy that ships inside the Node base image -# (usr/local/lib/node_modules/npm/node_modules/picomatch, v4.0.3), used at -# image-build time by npm, not by our runtime. Our application dependency -# tree pins picomatch >= 4.0.4 via a pnpm override, so the running app is -# not affected and the path is not reachable at runtime. Re-check when the -# Node base image ships an npm build whose bundled picomatch is >= 4.0.4, -# then drop this entry. -CVE-2026-33671 - -# CVE-2026-48815 (sigstore certificate-OID verification, HIGH) — flagged only -# in the npm CLI's OWN bundled copy inside the Node base image -# (usr/local/lib/node_modules/npm/node_modules/sigstore, v3.1.0). npm uses -# sigstore for publish provenance at image-build time only; it is not in our -# application dependency tree at all (`pnpm why sigstore` is empty) and is -# never reachable at runtime. Re-check when the Node base image ships an npm -# build whose bundled sigstore is >= 4.1.1, then drop this entry. -CVE-2026-48815 - # CVE-2026-85393 (node-forge RSA PKCS#1 v1.5 signature forgery, HIGH; GitHub # advisory GHSA-86w9-cpqp-85rv) — node-forge <= 1.4.0 arrives only through # @parse/node-apn, which uses it in its certificate-based credential path diff --git a/Dockerfile b/Dockerfile index 7c03b74e2..ad7a8e70a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,20 @@ +# ── Base images ──────────────────────────────────────────── +# glibc, not musl, in every stage. The native modules the runtime loads (the +# @napi-rs/canvas Skia renderer, @node-rs/argon2, the Prisma schema engine) +# then run their glibc builds. The arm64 musl Skia build needs ARMv8.2 (LSE +# atomics, RCpc loads) and dies with SIGILL on an ARMv8.0 core such as the +# 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. + # ── Stage 1: Dependencies ────────────────────────────────── -FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps +FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS deps RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app @@ -11,7 +26,7 @@ COPY scripts/prepare.mjs scripts/prepare.mjs RUN pnpm install --frozen-lockfile --prod=false # ── Stage 2: Build ───────────────────────────────────────── -FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder +FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS builder RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app @@ -57,6 +72,10 @@ RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build # name; see the note there for why guessing was wrong. RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \ > /app/.pdfjs-version +# The Node module ABI and the glibc the native packages were installed for; +# the runner refuses to build on an older glibc or another module ABI. +RUN node -p "process.versions.modules + ' ' + process.report.getReport().header.glibcVersionRuntime + ' ' + process.version" \ + > /app/.native-abi # The same for @napi-rs/canvas, whose native binary must match its JS loader. RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \ > /app/.canvas-version @@ -67,23 +86,39 @@ RUN node scripts/verify-message-catalogs.mjs --dir messages \ --config src/lib/i18n/config.ts --write /app/.message-locales # ── Stage 3: Production runner ───────────────────────────── -FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner -# `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 on Alpine images that ship without it. +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. # -# `apk 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 Alpine repo override it deliberately — -# CVE-2026-14456 in openssl was the first instance the container scan -# caught between base-image rebuilds). +# `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). # -# `postgresql18-client` carries `pg_dump` and `pg_restore` for the nightly +# `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. -RUN apk add --no-cache tzdata postgresql18-client && apk upgrade --no-cache +# +# busybox, part of the base, is the shell the entrypoint runs under and +# carries `tar`/`gzip` for the GeoLite2 extraction and `addgroup`/`adduser`. +RUN apk add --no-cache nodejs-22 tzdata postgresql-18-client && \ + apk upgrade --no-cache && \ + mkdir -p /usr/local/bin +COPY --from=builder /app/.native-abi /tmp/.native-abi +RUN set -e; \ + read -r BUILD_ABI BUILD_GLIBC BUILD_NODE < /tmp/.native-abi; \ + rm -f /tmp/.native-abi; \ + RUN_ABI="$(node -p process.versions.modules)"; \ + RUN_GLIBC="$(node -p 'process.report.getReport().header.glibcVersionRuntime')"; \ + [ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI $RUN_ABI, builder $BUILD_ABI"; exit 1; }; \ + [ "$(printf '%s\n%s\n' "$BUILD_GLIBC" "$RUN_GLIBC" | sort -V | head -1)" = "$BUILD_GLIBC" ] || \ + { echo "ERROR: runner glibc $RUN_GLIBC is older than the builder's $BUILD_GLIBC"; exit 1; }; \ + echo "native ABI $RUN_ABI (builder $BUILD_NODE, runner $(node -v)); glibc $BUILD_GLIBC -> $RUN_GLIBC" WORKDIR /app ENV NODE_ENV=production @@ -114,8 +149,8 @@ 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 --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nextjs +RUN addgroup -S -g 1001 nodejs && \ + adduser -S -u 1001 -G nodejs -h /home/nextjs -s /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 @@ -163,8 +198,8 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')" # HOISTED to the top level there (verified: without it pdfjs logs "Cannot load # @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError); # (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH -# the canvas package and the musl binary package to /app/node_modules/@napi-rs -# via symlink into the .pnpm store, and drop the prebuilt .node into the canvas +# the canvas package and the glibc (`-gnu`) binary package to +# /app/node_modules/@napi-rs via symlink into the .pnpm store, and drop the prebuilt .node into the canvas # dir: the loader tries that local file (`require('./skia..node')`) # before the platform package, so it decides which binary runs. # @@ -181,24 +216,24 @@ RUN set -e; \ CANVAS_VERSION="$(cat /tmp/.canvas-version)"; \ rm -f /tmp/.canvas-version; \ CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/node_modules/@napi-rs/canvas"; \ - MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-musl@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-musl" 2>/dev/null | head -1)"; \ - if [ ! -d "$CANVAS_DIR" ] || [ -z "$MUSL_DIR" ]; then \ - echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its musl binary did not reach the traced image."; \ + GNU_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-gnu@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-gnu" 2>/dev/null | head -1)"; \ + if [ ! -d "$CANVAS_DIR" ] || [ -z "$GNU_DIR" ]; then \ + echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its glibc binary did not reach the traced image."; \ echo "Present instead:"; \ find /app/node_modules/.pnpm -maxdepth 1 -name '@napi-rs+canvas*' 2>/dev/null; \ exit 1; \ fi; \ - NODE_BIN="$(find "$MUSL_DIR" -maxdepth 1 -name 'skia.linux-*-musl.node' | head -1)"; \ - [ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $MUSL_DIR"; exit 1; }; \ + NODE_BIN="$(find "$GNU_DIR" -maxdepth 1 -name 'skia.linux-*-gnu.node' | head -1)"; \ + [ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $GNU_DIR"; exit 1; }; \ mkdir -p /app/node_modules/@napi-rs; \ ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \ - ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \ + ln -sfn "$GNU_DIR" "/app/node_modules/@napi-rs/$(basename "$GNU_DIR")"; \ cp "$NODE_BIN" "$CANVAS_DIR/"; \ chown -R nextjs:nodejs /app/node_modules/@napi-rs; \ - BIN_VERSION="$(node -p "require('$MUSL_DIR/package.json').version")"; \ + BIN_VERSION="$(node -p "require('$GNU_DIR/package.json').version")"; \ [ "$BIN_VERSION" = "$CANVAS_VERSION" ] || { echo "ERROR: canvas binary is $BIN_VERSION, loader is $CANVAS_VERSION"; exit 1; }; \ cmp -s "$NODE_BIN" "$CANVAS_DIR/$(basename "$NODE_BIN")" || { echo "ERROR: the canvas dir holds a different binary"; exit 1; }; \ - echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $MUSL_DIR)" + echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $GNU_DIR)" # pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render # path breaks in the standalone image), so the server does a runtime bare @@ -280,8 +315,16 @@ RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \ # 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 before -# stripping npm/Corepack and their caches from the runtime surface. +# 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 @@ -307,19 +350,29 @@ RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \ # 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 mkdir -p /opt/prisma-cli && \ +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 && \ ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \ ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \ - rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \ - rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx + 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' + +# 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 +# one, which would hide the missing install script here and fail offline. +RUN ls /opt/prisma-cli/node_modules/@prisma/engines/schema-engine-* >/dev/null && \ + node /opt/prisma-cli/node_modules/prisma/build/index.js --version 2>&1 | \ + grep -q "Schema Engine *: schema-engine-cli [0-9a-f]* (at " # Same proof for the intake-repair script under the launcher it is documented # with: its `pg` import resolves, so it reaches its own DATABASE_URL check. @@ -342,7 +395,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 ship in the busybox base. A +# manual mount. `tar`/`gzip` for the extraction come with busybox. 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 @@ -371,9 +424,12 @@ ENV HOSTNAME="0.0.0.0" # migration waiting out a held lock retries for up to about eight minutes. # Five retries: a pool saturated for a minute during a boot storm makes the # probe's 2 s database check miss once or twice without marking the container -# unhealthy (docker-compose.yml explains the numbers). +# unhealthy (docker-compose.yml explains the numbers). The probe is Node's own +# fetch: it needs nothing beyond the runtime, the same probe works on any +# base, and no HTTP client has to be installed for one line. A non-2xx +# answer, a refused connection and the 4 s abort all exit 1. HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \ - CMD wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1 + CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"] ENTRYPOINT ["./docker-entrypoint.sh"] CMD ["node", "server.js"] diff --git a/README.md b/README.md index 4c880ab87..f605cbc78 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 Alpine Docker image. 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 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). ## Documentation diff --git a/docker-compose.yml b/docker-compose.yml index f10142dc2..a19bb63ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -562,8 +562,10 @@ services: healthcheck: test: [ - "CMD-SHELL", - "wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1", + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))", ] interval: 30s timeout: 5s @@ -572,7 +574,7 @@ services: # past that, so a boot storm that saturates the pool for a minute after # the start period (backfills, a restore, another tenant's import) costs # a probe or two, not the container's health and the proxy's routing. - # wget's own 4 s timeout ends the request inside Docker's 5 s, so a + # The probe's own 4 s abort ends the request inside Docker's 5 s, so a # stalled connection reads as one clean miss rather than a killed probe. # A database that stays away for 2.5 minutes is a real outage and shows. retries: 5 diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 2575600c4..c602b9c12 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -9,7 +9,14 @@ 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 5 or an x86 VPS both work unchanged. + 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 + 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 + features fall back to text only. `NATIVE_CANVAS=off` or `on` in `.env` + overrides that detection (`on` crashes PDF processing on a CPU that truly + lacks the instructions). - **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data volume grows with measurement history; the Apple Health import worker can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 3bc36d23b..01d225033 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -140,7 +140,7 @@ changes nothing the web container relies on. ## Healthchecks -- `app` (web) healthcheck: `wget /api/health` every 30s. +- `app` (web) healthcheck: a `node -e` fetch of `/api/health` every 30s. - `app-worker` does not expose a port; its liveness is tracked in-memory inside the process (`src/lib/jobs/worker-status.ts`) and read back by `/api/admin/status`. In a web/worker split this means diff --git a/scripts/__tests__/release-workflows.test.ts b/scripts/__tests__/release-workflows.test.ts index c402102b8..ae4eb4b0f 100644 --- a/scripts/__tests__/release-workflows.test.ts +++ b/scripts/__tests__/release-workflows.test.ts @@ -224,10 +224,11 @@ describe("release container inputs", () => { const dockerfile = readRepoFile("Dockerfile"); const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? []; - expect(fromLines).toHaveLength(3); + // deps and builder; the runner is Wolfi (canvas-binary-hoist-guard). + expect(fromLines).toHaveLength(2); expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual( new Set([ - "FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2", + "FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f", ]), ); }); @@ -243,8 +244,11 @@ 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"); expect(dockerfile).toContain( - "rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack", + "sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! 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 45bcc3d85..ca985e095 100644 --- a/src/__tests__/canvas-binary-hoist-guard.test.ts +++ b/src/__tests__/canvas-binary-hoist-guard.test.ts @@ -6,7 +6,7 @@ * * The pnpm store held two canvas versions: the one in package.json and an * older one that a second, older pdfjs-dist pulled in (gone since #1277). - * The runner stage picked the musl binary with `find | head -1`, got the + * The runner stage picked the platform binary with `find | head -1`, got the * old one, and copied it into the new loader's directory, where the loader * looks first. PDF rendering still worked, so nothing looked wrong; * `loadImage` called an `Image.decode` the old binary lacks, and every @@ -43,7 +43,7 @@ describe("canvas binary hoist", () => { ); }); - it("hoists the loader and the musl binary of that version only", () => { + it("hoists the loader and the glibc binary of that version only", () => { const runner = stage("runner"); expect(runner).toContain( "COPY --from=builder /app/.canvas-version /tmp/.canvas-version", @@ -51,12 +51,38 @@ describe("canvas binary hoist", () => { expect(runner).toContain( 'CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/', ); - expect(runner).toContain("canvas-linux-*-musl@${CANVAS_VERSION}/"); + // The image is Debian: the glibc (`-gnu`) build is the one that runs, + // and the musl build, which needs ARMv8.2 on arm64 (#1311), is not. + expect(runner).toContain("canvas-linux-*-gnu@${CANVAS_VERSION}/"); + expect(runner).toContain("-name 'skia.linux-*-gnu.node'"); + expect(runner).not.toMatch(/-musl/); // A version-blind pick is the defect: any `canvas…@*` glob decides by // readdir order which of the store's copies wins. expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/); }); + it("installs on Debian and runs on Wolfi, both glibc", () => { + const froms = DOCKERFILE.match(/^FROM \S+ AS \S+$/gm) ?? []; + expect(froms).toEqual([ + expect.stringMatching( + /^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS deps$/, + ), + expect.stringMatching( + /^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$/, + ), + ]); + // The native packages pnpm picked in the builder must load in the + // runner: same module ABI, and a glibc no older than the builder's. + expect(stage("builder")).toContain("> /app/.native-abi"); + expect(stage("runner")).toContain( + '[ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI', + ); + expect(stage("runner")).toContain("is older than the builder's"); + }); + it("fails the build when the copied binary is another version", () => { const runner = stage("runner"); expect(runner).toMatch( diff --git a/src/__tests__/healthcheck-boot-storm.test.ts b/src/__tests__/healthcheck-boot-storm.test.ts index e91b7401b..6a780da5b 100644 --- a/src/__tests__/healthcheck-boot-storm.test.ts +++ b/src/__tests__/healthcheck-boot-storm.test.ts @@ -79,10 +79,21 @@ function imageCheck(): Check { }; } -function wgetTimeout(command: string): number { - const m = /wget[^|]*--timeout=(\d+)/.exec(command); - if (!m) throw new Error(`no wget timeout in: ${command}`); - return Number(m[1]); +/** + * The probe is Node's fetch, aborted by `AbortSignal.timeout(ms)`. The + * image carries no wget or curl, so a probe that names one would fail every + * time and mark a healthy container unhealthy. + */ +function probeTimeout(command: string): number { + expect(command, "the probe needs a binary the image has").not.toMatch( + /\b(wget|curl)\b/, + ); + const m = + /fetch\('http:\/\/127\.0\.0\.1:3000\/api\/health',\{signal:AbortSignal\.timeout\((\d+)\)\}\)/.exec( + command, + ); + if (!m) throw new Error(`no aborting /api/health fetch in: ${command}`); + return Number(m[1]) / 1000; } describe.each([ @@ -96,9 +107,9 @@ describe.each([ it("ends the request inside Docker's timeout, after the database probe's", () => { const check = read(); - const wget = wgetTimeout(check.command); - expect(wget).toBeGreaterThan(probeTimeoutSeconds); - expect(wget).toBeLessThan(check.timeout); + const abort = probeTimeout(check.command); + expect(abort).toBeGreaterThan(probeTimeoutSeconds); + expect(abort).toBeLessThan(check.timeout); }); }); diff --git a/src/lib/documents/native-canvas-support.ts b/src/lib/documents/native-canvas-support.ts index 88f496254..1cdb407af 100644 --- a/src/lib/documents/native-canvas-support.ts +++ b/src/lib/documents/native-canvas-support.ts @@ -1,13 +1,19 @@ /** * v1.28.27 — CPU-feature gate for the native canvas renderer. * - * The bundled `@napi-rs/canvas` (Skia) x64 builds use AVX2 instructions. On an + * The `@napi-rs/canvas` (Skia) x64 builds carry AVX2 instructions. On an * x86-64 host WITHOUT AVX2 — Celeron/Atom-class NAS CPUs (Goldmont Plus and - * older) are the common self-hosting case — the first canvas call dies with - * SIGILL (`trap invalid opcode … in skia.linux-x64-musl.node`), which kills the - * whole Node process and puts the container into an infinite restart loop - * (live incident: a Synology DS1520+ self-host, exit code 132 ~18 s after - * every boot once the thumbnail backfill touched Skia). + * older) are the common self-hosting case — the musl build died on the first + * canvas call with SIGILL (`trap invalid opcode … in skia.linux-x64-musl.node`), + * which kills the whole Node process and puts the container into an infinite + * 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 + * 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 + * without the flag. `NATIVE_CANVAS=on` is the way to try it on such a host. * * A SIGILL cannot be caught in-process, so the only safe posture is to never * load the module on an unsupported CPU. Every canvas consumer (document @@ -15,12 +21,13 @@ * the gate closed those features degrade cleanly (no thumbnails, PDFs read as * text/native only) while the rest of the app runs normally. * - * The arm64 musl build (the one the published arm64 image runs) is compiled - * for ARMv8.2 and later: it uses LSE atomics (`ldaddal`, `casal`) and RCpc - * loads (`ldapr`) throughout, with no runtime fallback, and dies with SIGILL - * as soon as the module loads on an ARMv8.0 core such as the Raspberry Pi 4's - * Cortex-A72. The glibc arm64 build uses neither, so a glibc host is left - * alone. + * The arm64 musl build is compiled for ARMv8.2 and later: it uses LSE atomics + * (`ldaddal`, `casal`) and RCpc loads (`ldapr`) throughout, with no runtime + * fallback, and dies with SIGILL as soon as the module loads on an ARMv8.0 + * core such as the Raspberry Pi 4's Cortex-A72 (#1311). The glibc arm64 build + * uses neither and runs there, which is why the published image moved off + * musl. The musl branch stays for a host that runs the app on musl itself, + * from source on Alpine or from an image built on it. * * Detection reads `/proc/cpuinfo` once on Linux: x64 requires the `avx2` * flag, arm64 under musl requires the `atomics` and `lrcpc` features. Every