mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'feat/v147-wolfi' into release/v1.47.0
This commit is contained in:
commit
a503c8855d
12 files changed
+184
-89
No files matched your search
@@ -714,11 +714,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+89
-33
@@ -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.<triple>.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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user