From 377e0d0d3affb51bc595de2bc277e72155e7aea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 21:18:44 +0100 Subject: [PATCH] build: copy pg_dump and pg_restore from the PostgreSQL image postgresql-client-18 from the PGDG repository brought Perl and Debian's client wrapper scripts into the runner for two binaries the app calls by name. A pgclient stage on postgres:18.6-trixie, pinned by digest, now hands over pg_dump, pg_restore and the libpq they link; the Debian libraries libpq needs (Kerberos, LDAP, SASL) still come from apt so the upgrade and the image scan see them. The runner registers libpq with the loader and fails the build when either binary has an unresolved library; the major 18 version checks stay. The client minor follows the bundled database's: a guard holds the two pins together, and the Dockerfile's Dependabot watch now ignores a PostgreSQL major the same way the compose watch does. --- .github/dependabot.yml | 7 ++ Dockerfile | 77 +++++++++---------- .../canvas-binary-hoist-guard.test.ts | 14 +++- src/__tests__/database-pin-is-watched.test.ts | 26 +++++++ 4 files changed, 82 insertions(+), 42 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 985a0c309..037570419 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -64,6 +64,13 @@ 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. + - dependency-name: "postgres" + update-types: ["version-update:semver-major"] # The `docker` ecosystem above reads the Dockerfile only. The bundled # database lives in `docker-compose.yml`, which GitHub treats as its own # ecosystem, so without this block a new PostgreSQL minor release never diff --git a/Dockerfile b/Dockerfile index 21dc3d305..7adac7ffb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,7 +76,23 @@ 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 ───────────────────────────── +# ── Stage 3: PostgreSQL client tools ─────────────────────── +# `pg_dump` and `pg_restore` of major 18, and the libpq they link, taken from +# the official PostgreSQL image built on the same Debian release. Installing +# `postgresql-client-18` instead pulls Perl and Debian's client wrapper +# scripts into the runner for two binaries the app calls by name. Pinned to +# the same minor as the bundled database in docker-compose.yml; move the two +# together. 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 4: Production runner ───────────────────────────── FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS runner # `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware # timestamp formatting) resolve to the actual offset instead of silently @@ -88,50 +104,33 @@ FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab # CVE-2026-14456 in openssl was the first instance the container scan # caught between base-image rebuilds). # -# `postgresql-client-18` 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. Debian trixie itself -# ships major 17, so the client comes from the PostgreSQL project's own apt -# repository (PGDG). Its signing key is fetched over HTTPS and must match the -# pinned SHA-256 before apt trusts it; the pin is the key with fingerprint -# B97B 0AFC AA1A 47F0 44F2 44A0 7FCC 7D46 ACCC 4CF8. If PGDG ever republishes -# the key file, check that fingerprint again before moving the pin. `node` -# fetches it, so no curl or wget lands in the image for this one download. -# `ca-certificates` is what apt needs for the HTTPS repository. +# `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. Debian trixie ships major 17, so 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. +# +# `ca-certificates` is the system trust store the OpenSSL-linked tools use +# (the Prisma schema engine, libpq) for a TLS connection to the database. RUN set -eux; \ - export PGDG_ASC_SHA256=0144068502a1eddd2a0280ede10ef607d1ec592ce819940991203941564e8e76; \ export DEBIAN_FRONTEND=noninteractive; \ apt-get update; \ apt-get upgrade -y --no-install-recommends; \ - apt-get install -y --no-install-recommends ca-certificates tzdata; \ - node -e " \ - const { createHash } = require('node:crypto'); \ - const { writeFileSync } = require('node:fs'); \ - fetch('https://www.postgresql.org/media/keys/ACCC4CF8.asc', { signal: AbortSignal.timeout(30000) }) \ - .then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) \ - .then((b) => { \ - const key = Buffer.from(b); \ - const sum = createHash('sha256').update(key).digest('hex'); \ - if (sum !== process.env.PGDG_ASC_SHA256) throw new Error('PGDG key sha256 ' + sum); \ - writeFileSync('/usr/share/keyrings/pgdg.asc', key); \ - }) \ - .catch((e) => { console.error(e); process.exit(1); });"; \ - printf '%s\n' \ - 'Types: deb' \ - 'URIs: https://apt.postgresql.org/pub/repos/apt' \ - 'Suites: trixie-pgdg' \ - 'Components: main' \ - 'Signed-By: /usr/share/keyrings/pgdg.asc' \ - > /etc/apt/sources.list.d/pgdg.sources; \ - apt-get update; \ - apt-get install -y --no-install-recommends postgresql-client-18; \ + 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/* -# Debian installs each major's client under its own directory and puts a -# version-picking Perl wrapper on PATH. Name the directory, so `pg_dump` -# is the major 18 binary by construction, not by what the wrapper picks. +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 WORKDIR /app diff --git a/src/__tests__/canvas-binary-hoist-guard.test.ts b/src/__tests__/canvas-binary-hoist-guard.test.ts index 56735461b..092be8a34 100644 --- a/src/__tests__/canvas-binary-hoist-guard.test.ts +++ b/src/__tests__/canvas-binary-hoist-guard.test.ts @@ -61,12 +61,20 @@ describe("canvas binary hoist", () => { expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/); }); - it("builds every stage on the glibc Debian base", () => { + it("builds every Node stage on the glibc Debian base", () => { const froms = DOCKERFILE.match(/^FROM \S+/gm) ?? []; - expect(froms.length).toBeGreaterThanOrEqual(3); - for (const from of froms) { + const node = froms.filter((from) => from.startsWith("FROM node:")); + expect(node).toHaveLength(3); + for (const from of node) { expect(from).toMatch(/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64}$/); } + // The only other stage supplies the PostgreSQL client tools, built on + // the same Debian release so its binaries find their libraries. + expect(froms.filter((from) => !from.startsWith("FROM node:"))).toEqual([ + expect.stringMatching( + /^FROM postgres:18\.\d+-trixie@sha256:[0-9a-f]{64}$/, + ), + ]); }); it("fails the build when the copied binary is another version", () => { diff --git a/src/__tests__/database-pin-is-watched.test.ts b/src/__tests__/database-pin-is-watched.test.ts index 84d2d4cea..a9350eafd 100644 --- a/src/__tests__/database-pin-is-watched.test.ts +++ b/src/__tests__/database-pin-is-watched.test.ts @@ -40,6 +40,32 @@ describe("the bundled database pin", () => { } }); + it("ships pg_dump of the same minor the bundled database runs", () => { + const db = /^\s*image:\s*postgres:(\d+\.\d+)-alpine$/m.exec( + read("docker-compose.yml"), + ); + const client = + /^FROM postgres:(\d+\.\d+)-trixie@sha256:[0-9a-f]{64} AS pgclient$/m.exec( + read("Dockerfile"), + ); + expect(db, "the compose file pins the database").not.toBeNull(); + expect(client, "the Dockerfile has a pinned pgclient stage").not.toBeNull(); + // The two pins are one decision; a Dependabot PR moves them separately. + expect(client![1]).toBe(db![1]); + }); + + it("keeps a major bump of the client tools out of the Dockerfile watch too", () => { + const dependabot = read(".github/dependabot.yml"); + const docker = + /package-ecosystem:\s*"docker"\n([\s\S]*?)(?=\n\s*- package-ecosystem:)/.exec( + dependabot, + ); + expect(docker, "a docker ecosystem entry").not.toBeNull(); + expect(docker![1]).toMatch( + /dependency-name:\s*"postgres"[\s\S]{0,200}version-update:semver-major/, + ); + }); + it("lets a major version through only as a decision, never as an update PR", () => { const dependabot = read(".github/dependabot.yml"); // The first run of the compose ecosystem proposed 16.15 -> 18.6. A