mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge branch 'fix/v147-debian-slim' into release/v1.47.0
This commit is contained in:
commit
3149805592
13 files changed
+295
-164
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+14
-10
@@ -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
|
||||
|
||||
+144
-95
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
@@ -224,8 +224,8 @@ describe("release container inputs", () => {
|
||||
const dockerfile = readRepoFile("Dockerfile");
|
||||
const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? [];
|
||||
|
||||
// deps and builder; the runner is Wolfi (canvas-binary-hoist-guard).
|
||||
expect(fromLines).toHaveLength(2);
|
||||
// deps, builder, the prisma-cli install and the runner.
|
||||
expect(fromLines).toHaveLength(4);
|
||||
expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual(
|
||||
new Set([
|
||||
"FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f",
|
||||
@@ -244,11 +244,14 @@ describe("release container inputs", () => {
|
||||
expect(
|
||||
dockerfile.match(/corepack prepare pnpm@11\.15\.1 --activate/g),
|
||||
).toHaveLength(2);
|
||||
// The runner installs npm for the Prisma CLI step only, removes it in the
|
||||
// same layer, and fails the build if any package manager is left.
|
||||
expect(dockerfile).toContain("apk del --no-cache npm");
|
||||
// The Prisma CLI is installed in its own stage and copied over; the
|
||||
// runner removes the base image's package managers and fails the build
|
||||
// if any is left (runtime-package-pins holds the rest).
|
||||
expect(dockerfile).toContain(
|
||||
"sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v pnpm'",
|
||||
"COPY --from=prisma-cli /opt/prisma-cli /opt/prisma-cli",
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
"sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v yarn && ! command -v pnpm'",
|
||||
);
|
||||
expect(dockerfile).toContain("tsx@4.23.1");
|
||||
expect(dockerfile).toContain(
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("canvas binary hoist", () => {
|
||||
expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/);
|
||||
});
|
||||
|
||||
it("installs on Debian and runs on Wolfi, both glibc", () => {
|
||||
it("installs and runs on the same Debian glibc image", () => {
|
||||
const froms = DOCKERFILE.match(/^FROM \S+ AS \S+$/gm) ?? [];
|
||||
expect(froms).toEqual([
|
||||
expect.stringMatching(
|
||||
@@ -71,7 +71,13 @@ describe("canvas binary hoist", () => {
|
||||
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS builder$/,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^FROM cgr\.dev\/chainguard\/wolfi-base:latest@sha256:[0-9a-f]{64} AS runner$/,
|
||||
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS prisma-cli$/,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^FROM postgres:18\.\d+-trixie@sha256:[0-9a-f]{64} AS pgclient$/,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS runner$/,
|
||||
),
|
||||
]);
|
||||
// The native packages pnpm picked in the builder must load in the
|
||||
|
||||
@@ -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/",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user