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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-03 21:18:44 +01:00
1 parent d8743e9111
commit 377e0d0d3a
4 files changed
+82 -42

No files matched your search

+7
View File
@@ -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
+38 -39
View File
@@ -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
@@ -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", () => {
@@ -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