feat(db): move the bundled database to Postgres 18

The compose file runs Postgres 18. An install from v1.45 or earlier keeps
its Postgres 16 data in `pgdata`, which 18 cannot open, so a one-shot
`db-upgrade` service moves it on the first start: it starts Postgres 16 on
the old volume inside its own container, streams a dump into 18 in one
transaction, compares every table's row count, gathers planner statistics
and marks the old volume as moved. The app waits for it to succeed.

The old volume is never changed apart from the marker, so the way back is
the previous image and volume lines. A move cut short leaves a "moving"
marker and runs again from scratch; an emptied new volume after a finished
move is refused rather than refilled with the old state.

scripts/compose-db-upgrade-check.sh plays all of it against real
containers from the v1.45.0 compose file, and runs in CI on every change
to the compose file. Pull requests run the integration suite and e2e on
Postgres 18; the nightly integration run keeps 16 covered for installs
that bring their own database.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 11:18:14 +01:00
1 parent 6b3c1cc433
commit d4d91994e8
11 files changed
+445 -24

No files matched your search

+3 -1
View File
@@ -79,6 +79,8 @@ updates:
# The first run of this ecosystem proposed 16.15 -> 18.6, which would
# have handed self-hosters a database their data directory cannot be
# read by. Minor releases still arrive as PRs; a major is a deliberate
# project decision with a migration guide behind it.
# project decision with a migration guide behind it. The ignore also
# keeps `db-upgrade` on 16: it exists to read Postgres 16 data, and a
# bump to 18 would leave it unable to start on the volume it moves.
- dependency-name: "postgres"
update-types: ["version-update:semver-major"]
+51
View File
@@ -0,0 +1,51 @@
name: Compose database move
# Plays the Postgres 16 -> 18 move in docker-compose.yml against real
# containers: a v1.45 database with every migration applied, then the current
# compose file's `db-upgrade` on its first start, a second start, a move cut
# short, an emptied new volume and a new install. The service runs once on an
# operator's host and has no second chance there, so every change to the
# compose file or to the check runs it here first.
# `scripts/compose-db-upgrade-check.sh` is the same check, runnable locally.
on:
pull_request:
branches: [main]
paths:
- docker-compose.yml
- scripts/compose-db-upgrade-check.sh
- .github/workflows/compose-db-upgrade.yml
push:
branches: [main]
paths:
- docker-compose.yml
- scripts/compose-db-upgrade-check.sh
- .github/workflows/compose-db-upgrade.yml
workflow_dispatch:
permissions:
contents: read
jobs:
move:
name: Postgres 16 -> 18 move
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The check reads the v1.45 compose file from its tag.
- name: Fetch the v1.45.0 tag
run: git fetch --no-tags --depth=1 origin tag v1.45.0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Move check
run: bash scripts/compose-db-upgrade-check.sh
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
name: e2e shard ${{ matrix.shard }}/3
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
@@ -158,7 +158,7 @@ jobs:
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
+5 -1
View File
@@ -84,7 +84,7 @@ jobs:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: integration shard ${{ matrix.shard }}/4 (shuffled)
name: integration shard ${{ matrix.shard }}/4 (shuffled, Postgres 16)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -110,6 +110,10 @@ jobs:
shell: bash
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# Pull requests run the suite on Postgres 18, the compose default.
# Installs that bring their own database may still be on 16, so the
# nightly run keeps that major covered without doubling every PR.
INTEGRATION_PG_IMAGE: "postgres:16-alpine"
# Four times each property's own case count; a property's timeout
# grows by the same factor (`propertyTimeout`).
FC_RUNS_FACTOR: "4"
+149 -12
View File
@@ -1,4 +1,4 @@
# HealthLog — two-container stack (app + Postgres 16).
# HealthLog — two-container stack (app + Postgres 18).
#
# First run (clean host):
# cp .env.example .env
@@ -14,9 +14,11 @@
# and login silently fails. This is the single most common self-host snag.
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
#
# Persistent state lives in exactly one named volume (`pgdata`, Postgres'
# data dir). The app container is stateless, so a backup of the database is
# a backup of everything. The optional GeoLite2 databases are either baked
# Persistent state lives in exactly one named volume (`postgres-data`,
# Postgres' data). The app container is stateless, so a backup of the database
# is a backup of everything. `pgdata` is where Postgres 16 kept the data up to
# v1.45; on an install that has it, `db-upgrade` moves it into Postgres 18 once
# and then leaves it alone as the way back (docs/ops/postgres-major-upgrade.md). The optional GeoLite2 databases are either baked
# into the image at build time or mounted read-only from the host and
# pointed at with GEOLITE2_DIR — either way they are replaceable, not state.
@@ -489,6 +491,8 @@ services:
depends_on:
db:
condition: service_healthy
db-upgrade:
condition: service_completed_successfully
restart: unless-stopped
# Bounded log rotation: the default json-file driver keeps every line
# forever, which is exactly what fills the disk on the small NAS /
@@ -562,6 +566,8 @@ services:
# depends_on:
# db:
# condition: service_healthy
# db-upgrade:
# condition: service_completed_successfully
# restart: unless-stopped
# logging:
# driver: json-file
@@ -572,13 +578,11 @@ services:
# stop_grace_period: 45s
db:
# Pinned to the concrete minor instead of the floating `16-alpine`
# Pinned to the concrete minor instead of the floating `18-alpine`
# alias so a database update is always a deliberate, reviewable
# change (at pin time `16-alpine` resolved to this same digest, so
# existing deployments see no image change). Minor releases share
# the on-disk format — bumping this pin is safe without a dump /
# restore; a major-version bump (17+) is not, see
# docs/ops/postgres-major-upgrade.md.
# change. Minor releases share the on-disk format, so bumping this pin
# is safe without a dump / restore; a major version is not, which is
# what `db-upgrade` below and docs/ops/postgres-major-upgrade.md are for.
#
# The pin only works as long as something watches it: PostgreSQL
# publishes minor releases with security and data-corruption fixes and
@@ -588,7 +592,7 @@ services:
# `.github/dependabot.yml` now raises a PR for each new minor;
# `src/__tests__/database-pin-is-watched.test.ts` keeps both halves
# (concrete pin, active watch) from being dropped.
image: postgres:16.15-alpine
image: postgres:18.6-alpine
container_name: healthlog-db
# Loads pg_stat_statements so slow queries can be found from measurements
# instead of guesses. Loading the library only collects statistics; the
@@ -618,8 +622,12 @@ services:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_DB: healthlog
# From 18 on, the image keeps its data under /var/lib/postgresql/18/docker
# and expects the volume one level up, so the next major can sit beside
# this one in the same volume. Mounting it at .../data, as 16 had it,
# makes the container refuse to start.
volumes:
- pgdata:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U healthlog"]
interval: 5s
@@ -640,5 +648,134 @@ services:
max-size: "10m"
max-file: "3"
# Moves a Postgres 16 database from v1.45 or earlier into Postgres 18, once,
# on the first start after the update. A major version cannot read the data
# directory of an older one, so changing the image line alone would leave
# the database refusing to start.
#
# On every start it decides in a second and exits:
# - no `pgdata` data (a new install): nothing to do;
# - Postgres 18 already holds the database: nothing to do;
# - otherwise it starts Postgres 16 inside this container on the old
# volume, streams a dump into the new database in one transaction,
# compares the row count of every table, gathers planner statistics and
# marks the old volume as moved. `app` starts only after it succeeded.
# A move cut short (a restart, a full disk) leaves a "moving" marker, and
# the next start empties the new database and runs it again from scratch.
#
# The old volume is read, never changed, apart from that marker file, so
# going back is the previous image and volume lines. If anything fails, the
# new database stays empty, `app` does not start, and the log says why.
# It costs one small image pull; a later release removes the service.
db-upgrade:
image: postgres:16.15-alpine
container_name: healthlog-db-upgrade
# Runs once per `compose up` and exits. "no" also keeps hosts such as
# Coolify from reporting the exited container as a failed service.
restart: "no"
security_opt:
- no-new-privileges:true
environment:
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
volumes:
- pgdata:/var/lib/postgresql/data
# Read-only, only to check there is room for the copy before starting.
- postgres-data:/var/lib/postgresql/next:ro
depends_on:
db:
condition: service_healthy
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# `$$` is how compose spells a literal `$`.
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -eu
set -o pipefail
log() { echo "db-upgrade: $$*"; }
OLD=/var/lib/postgresql/data
MARK="$$OLD/HEALTHLOG_MOVED_TO_POSTGRES_18"
MOVING="$$OLD/HEALTHLOG_MOVING_TO_POSTGRES_18"
export PGPASSWORD="$$POSTGRES_PASSWORD"
new() { psql -h db -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
old() { psql -h /tmp -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
empty_new() {
for S in $$(new "SELECT quote_ident(nspname) FROM pg_namespace WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'"); do
new "DROP SCHEMA $$S CASCADE"
done
new "CREATE SCHEMA public"
}
if [ ! -s "$$OLD/PG_VERSION" ]; then
log "no Postgres 16 database to move, nothing to do"
exit 0
fi
USER_TABLES="FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"
TABLES=$$(new "SELECT count(*) $$USER_TABLES")
if [ -e "$$MARK" ]; then
if [ "$$TABLES" != 0 ]; then
log "the Postgres 16 database was moved on $$(cat "$$MARK"), nothing to do"
exit 0
fi
log "the Postgres 16 database was moved on $$(cat "$$MARK"), but the Postgres 18 database is empty now."
log "Not importing the old state again by itself. See docs/ops/postgres-major-upgrade.md, \"Starting over\"."
exit 1
fi
if [ -e "$$MOVING" ]; then
log "an earlier move did not finish; emptying the Postgres 18 database and starting it again"
empty_new
elif [ "$$TABLES" != 0 ]; then
log "the Postgres 18 database already holds data, leaving both as they are"
exit 0
fi
VERSION=$$(cat "$$OLD/PG_VERSION")
if [ "$$VERSION" != 16 ]; then
log "the old data directory is Postgres $$VERSION, this step moves Postgres 16 only."
log "See docs/ops/postgres-major-upgrade.md to move it by hand."
exit 1
fi
NEED=$$(du -sk "$$OLD" | cut -f1)
FREE=$$(df -Pk /var/lib/postgresql/next | awk 'NR == 2 { print $$4 }')
if [ "$$FREE" -le "$$NEED" ]; then
log "the move needs about $$((NEED / 1024)) MiB free for the new database, the disk has $$((FREE / 1024)) MiB."
log "Nothing was changed. Free some space and start the stack again."
exit 1
fi
trap 'CODE=$$?; [ "$$CODE" = 0 ] || log "the move stopped (exit $$CODE). The old volume is untouched; the next start begins it again."' EXIT
date -u +%Y-%m-%dT%H:%M:%SZ > "$$MOVING"
log "moving the Postgres 16 database ($$((NEED / 1024)) MiB on disk) into Postgres 18"
su-exec postgres postgres -D "$$OLD" -c listen_addresses= -c unix_socket_directories=/tmp &
PID=$$!
i=0
until pg_isready -h /tmp -U healthlog -q; do
kill -0 "$$PID" 2>/dev/null || { log "the Postgres 16 server did not start, see the lines above"; exit 1; }
i=$$((i + 1))
[ "$$i" -lt 600 ] || { log "the Postgres 16 server did not get ready in ten minutes"; exit 1; }
sleep 1
done
pg_dump -h /tmp -U healthlog -Fc healthlog |
pg_restore -h db -U healthlog -d healthlog --no-owner --single-transaction --exit-on-error
COUNTED=0
for T in $$(old "SELECT quote_ident(schemaname) || '.' || quote_ident(tablename) $$USER_TABLES ORDER BY 1"); do
A=$$(old "SELECT count(*) FROM $$T")
B=$$(new "SELECT count(*) FROM $$T")
if [ "$$A" != "$$B" ]; then
log "table $$T has $$A rows in Postgres 16 but $$B in Postgres 18. Emptying the new database again."
log "Please report this with the lines above."
empty_new
exit 1
fi
COUNTED=$$((COUNTED + 1))
done
log "all $$COUNTED tables arrived with the same row counts"
vacuumdb -h db -U healthlog -d healthlog --analyze-in-stages --quiet
kill -INT "$$PID"
wait "$$PID" || true
mv "$$MOVING" "$$MARK"
log "done. The old volume stays as it was, as a way back; see docs/ops/postgres-major-upgrade.md"
volumes:
postgres-data:
pgdata:
+1 -1
View File
@@ -14,7 +14,7 @@ Never point the seed or the benchmark at a database that holds real data. They w
```sh
docker run -d --name hl-bench-db --shm-size=256m \
-e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=healthlog_bench \
-p 55432:5432 postgres:16.15-alpine \
-p 55432:5432 postgres:18.6-alpine \
postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=top
```
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Plays the Postgres 16 -> 18 move in docker-compose.yml end to end, against
# real containers, the way an install from v1.45 meets it on its first update.
#
# 1. Start the database exactly as the v1.45 compose file had it, apply every
# migration and add rows in a second schema.
# 2. Start the current compose file's `db-upgrade` and check the move: exit 0,
# Postgres 18, the same rows and the same migration history.
# 3. Start it again: nothing to do.
# 4. Pretend the move was cut short after the restore: the next start must
# empty the new database and run the move again.
# 5. Empty the new volume: it must refuse to import the old state again,
# and move it once more after the marker is removed as the guide says.
# 6. A new install with no old volume: nothing to do.
#
# Needs Docker and the repo's node_modules (for the migrations). Run it from
# anywhere in the repo: `bash scripts/compose-db-upgrade-check.sh`.
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
# The last release whose compose file ran Postgres 16.
OLD_REF=${OLD_REF:-v1.45.0}
PROJECT=hl-db-upgrade-check
PORT=${CHECK_DB_PORT:-55416}
WORK=$(mktemp -d)
export PRISMA_HIDE_UPDATE_MESSAGE=1
export POSTGRES_PASSWORD
POSTGRES_PASSWORD=$(openssl rand -hex 16)
# The app service is never started here, but compose reads its environment.
export ENCRYPTION_KEY API_TOKEN_HMAC_KEY
ENCRYPTION_KEY=$(openssl rand -hex 32)
API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)
git -C "$ROOT" show "$OLD_REF:docker-compose.yml" >"$WORK/old.yml"
cat >"$WORK/old-port.yml" <<EOF
services:
db:
ports:
- "127.0.0.1:$PORT:5432"
EOF
old() { docker compose -p "$PROJECT" -f "$WORK/old.yml" -f "$WORK/old-port.yml" "$@"; }
new() { docker compose -p "$PROJECT" -f "$ROOT/docker-compose.yml" "$@"; }
down_all() {
new down -v --remove-orphans >/dev/null 2>&1 || true
old down -v --remove-orphans >/dev/null 2>&1 || true
}
trap 'down_all; rm -rf "$WORK"' EXIT
fail() {
echo "FAIL: $*" >&2
docker logs healthlog-db-upgrade 2>&1 | tail -40 >&2 || true
exit 1
}
step() { echo "== $*"; }
sql() { docker exec healthlog-db psql -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$1"; }
# Starts db-upgrade (and the database it waits for), waits for it to exit and
# prints its exit code.
run_upgrade() {
new up -d db-upgrade >/dev/null 2>&1 || true
docker wait healthlog-db-upgrade
}
upgrade_log() { docker logs healthlog-db-upgrade 2>&1 | grep '^db-upgrade:' || true; }
down_all
step "a v1.45 database on Postgres 16"
old up -d --wait db >/dev/null 2>&1
DATABASE_URL="postgresql://healthlog:$POSTGRES_PASSWORD@127.0.0.1:$PORT/healthlog?schema=public" \
node "$ROOT/node_modules/prisma/build/index.js" migrate deploy --schema "$ROOT/prisma/schema.prisma" >/dev/null
old exec -T db psql -U healthlog -d healthlog -v ON_ERROR_STOP=1 -q <<'SQL'
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE SCHEMA check_probe;
CREATE TABLE check_probe.rows AS
SELECT g AS id, md5(g::text) AS value FROM generate_series(1, 50000) AS g;
SQL
probe() {
"$@" psql -U healthlog -d healthlog -Atqc \
"SELECT md5(string_agg(value, ',' ORDER BY id)) FROM check_probe.rows"
}
migrations() {
"$@" psql -U healthlog -d healthlog -Atqc "SELECT count(*) FROM _prisma_migrations"
}
PROBE=$(probe old exec -T db)
MIGRATIONS=$(migrations old exec -T db)
[ "$MIGRATIONS" -gt 0 ] || fail "the migrations did not apply to the old database"
old stop db >/dev/null 2>&1
old rm -f db >/dev/null 2>&1
step "first start of the current compose file moves it"
CODE=$(run_upgrade)
upgrade_log
[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE on the move"
upgrade_log | grep -q 'tables arrived with the same row counts' || fail "the move did not report its row-count check"
VERSION=$(sql "SHOW server_version_num")
[ "$VERSION" -ge 180000 ] || fail "the database runs $VERSION, expected Postgres 18"
[ "$(probe docker exec healthlog-db)" = "$PROBE" ] || fail "the probe rows differ after the move"
[ "$(migrations docker exec healthlog-db)" = "$MIGRATIONS" ] || fail "the migration history differs after the move"
[ "$(sql "SELECT count(*) FROM pg_stats WHERE schemaname = 'check_probe'")" -gt 0 ] ||
fail "the moved tables have no planner statistics"
step "second start: nothing to do"
CODE=$(run_upgrade)
[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE on a start after the move"
upgrade_log | tail -1 | grep -q 'nothing to do' || fail "the second start did not say there was nothing to do"
step "a move cut short after the restore runs again from scratch"
new down >/dev/null 2>&1
docker run --rm -v "${PROJECT}_pgdata:/old" postgres:16.15-alpine \
mv /old/HEALTHLOG_MOVED_TO_POSTGRES_18 /old/HEALTHLOG_MOVING_TO_POSTGRES_18
CODE=$(run_upgrade)
[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE when resuming a cut-short move"
upgrade_log | grep -q 'did not finish' || fail "the resumed move did not say it starts again"
[ "$(probe docker exec healthlog-db)" = "$PROBE" ] || fail "the probe rows differ after the resumed move"
step "an emptied Postgres 18 volume is not refilled from the old state"
new down >/dev/null 2>&1
docker volume rm "${PROJECT}_postgres-data" >/dev/null
CODE=$(run_upgrade)
[ "$CODE" = 1 ] || fail "db-upgrade exited $CODE with the moved marker and an empty database, expected 1"
upgrade_log | grep -q 'Not importing the old state again' || fail "the refusal did not explain itself"
step "starting over as the guide says brings the moved state back"
new run --rm --entrypoint rm db-upgrade /var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18 >/dev/null 2>&1
CODE=$(run_upgrade)
[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE after the marker was removed"
[ "$(probe docker exec healthlog-db)" = "$PROBE" ] || fail "the probe rows differ after starting over"
step "a new install: nothing to do"
new down -v >/dev/null 2>&1
CODE=$(run_upgrade)
[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE on a new install"
upgrade_log | tail -1 | grep -q 'no Postgres 16 database to move' || fail "the new install did not say there was nothing to move"
echo "OK: the Postgres 16 -> 18 move behaves in all six cases"
@@ -0,0 +1,87 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
/**
* The wiring around `db-upgrade` in docker-compose.yml, the one-time move of a
* Postgres 16 database from v1.45 or earlier into Postgres 18.
*
* What the move does is proven against real containers by
* `scripts/compose-db-upgrade-check.sh` (the "Compose database move"
* workflow). That check starts `db-upgrade` by name, so it cannot see the
* part that protects an operator's data on a normal `docker compose up`: the
* app must wait for the move to succeed, or it starts on an empty Postgres 18
* database and the instance looks wiped. This file freezes that and the
* volume layout the move depends on.
*/
type Service = {
image?: string;
restart?: string;
volumes?: string[];
depends_on?: Record<string, { condition?: string }>;
command?: string[];
};
const compose = parse(
readFileSync(resolve(__dirname, "../../docker-compose.yml"), "utf8"),
) as { services: Record<string, Service>; volumes: Record<string, unknown> };
const { app, db } = compose.services;
const upgrade = compose.services["db-upgrade"];
function major(image: string | undefined): number {
const match = image?.match(/^postgres:(\d+)\./);
if (!match) throw new Error(`not a pinned postgres image: ${image}`);
return Number(match[1]);
}
describe("the Postgres 16 -> 18 move in docker-compose.yml", () => {
it("starts the app only after the move succeeded", () => {
expect(app.depends_on?.["db-upgrade"]?.condition).toBe(
"service_completed_successfully",
);
expect(app.depends_on?.db?.condition).toBe("service_healthy");
});
it("moves into a running database", () => {
expect(upgrade.depends_on?.db?.condition).toBe("service_healthy");
});
it("reads the old data with the major that wrote it", () => {
expect(major(upgrade.image)).toBe(16);
expect(major(db.image)).toBe(18);
expect(upgrade.volumes).toContain("pgdata:/var/lib/postgresql/data");
});
it("keeps the new data where the Postgres 18 image expects it", () => {
// Mounted at .../data, as 16 had it, the 18 image refuses to start.
expect(db.volumes).toEqual(["postgres-data:/var/lib/postgresql"]);
expect(Object.keys(compose.volumes).sort()).toEqual([
"pgdata",
"postgres-data",
]);
});
it("runs once per start instead of restarting", () => {
// A restart policy would rerun a failed move in a loop; "no" also keeps
// Coolify from counting the exited container as a failed service.
expect(upgrade.restart).toBe("no");
});
it("never fills a database that already holds data", () => {
const script = upgrade.command?.join("\n") ?? "";
// The checks run before the restore, in this order.
const order = [
"PG_VERSION",
"$$MARK",
"$$MOVING",
'elif [ "$$TABLES" != 0 ]',
"pg_restore",
].map((needle) => script.indexOf(needle));
expect(order.every((index) => index >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
expect(script).toContain("--single-transaction");
});
});
@@ -31,9 +31,13 @@ describe("the bundled database pin", () => {
...read("docker-compose.yml").matchAll(/^\s*image:\s*(postgres:\S+)/gm),
].map((match) => match[1]);
expect(images.length).toBe(1);
// `postgres:16-alpine` or `postgres:16` would match a moving target.
expect(images[0]).toMatch(/^postgres:\d+\.\d+-alpine$/);
// The database itself and `db-upgrade`, which reads the Postgres 16 data
// of an install from v1.45 or earlier.
expect(images.length).toBe(2);
for (const image of images) {
// `postgres:18-alpine` or `postgres:18` would match a moving target.
expect(image).toMatch(/^postgres:\d+\.\d+-alpine$/);
}
});
it("lets a major version through only as a decision, never as an update PR", () => {
+4 -3
View File
@@ -44,10 +44,11 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PROJECT_ROOT = resolve(__dirname, "..", "..");
// The compose file ships Postgres 16; INTEGRATION_PG_IMAGE runs the same
// suite against another major (the TrueNAS app defaults to 18).
// The compose file ships Postgres 18; INTEGRATION_PG_IMAGE runs the same
// suite against another major. The nightly run uses it for Postgres 16, the
// oldest major a self-host on its own database may still run.
const POSTGRES_IMAGE =
process.env.INTEGRATION_PG_IMAGE?.trim() || "postgres:16-alpine";
process.env.INTEGRATION_PG_IMAGE?.trim() || "postgres:18-alpine";
let started: StartedPostgreSqlContainer | null = null;