diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2cb857975..985a0c309 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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"] diff --git a/.github/workflows/compose-db-upgrade.yml b/.github/workflows/compose-db-upgrade.yml new file mode 100644 index 000000000..edc86e0cb --- /dev/null +++ b/.github/workflows/compose-db-upgrade.yml @@ -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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 165aca665..0640e5420 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/.github/workflows/post-publish-verify.yml b/.github/workflows/post-publish-verify.yml index 4f4bdf70f..d22ef58f0 100644 --- a/.github/workflows/post-publish-verify.yml +++ b/.github/workflows/post-publish-verify.yml @@ -56,7 +56,7 @@ jobs: services: postgres: - image: postgres:16-alpine + image: postgres:18-alpine env: POSTGRES_USER: healthlog POSTGRES_PASSWORD: healthlog diff --git a/.github/workflows/test-nightly.yml b/.github/workflows/test-nightly.yml index 88c1e0a27..eddede148 100644 --- a/.github/workflows/test-nightly.yml +++ b/.github/workflows/test-nightly.yml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 01d9e0cd4..cdfbfeea6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/contributing/benchmarks.md b/docs/contributing/benchmarks.md index aa10a1d73..9ceb4b7a0 100644 --- a/docs/contributing/benchmarks.md +++ b/docs/contributing/benchmarks.md @@ -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 ``` diff --git a/scripts/compose-db-upgrade-check.sh b/scripts/compose-db-upgrade-check.sh new file mode 100755 index 000000000..080002b99 --- /dev/null +++ b/scripts/compose-db-upgrade-check.sh @@ -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" </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" diff --git a/src/__tests__/compose-db-upgrade-guard.test.ts b/src/__tests__/compose-db-upgrade-guard.test.ts new file mode 100644 index 000000000..c4f892a10 --- /dev/null +++ b/src/__tests__/compose-db-upgrade-guard.test.ts @@ -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; + command?: string[]; +}; + +const compose = parse( + readFileSync(resolve(__dirname, "../../docker-compose.yml"), "utf8"), +) as { services: Record; volumes: Record }; +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"); + }); +}); diff --git a/src/__tests__/database-pin-is-watched.test.ts b/src/__tests__/database-pin-is-watched.test.ts index 09c740e0b..84d2d4cea 100644 --- a/src/__tests__/database-pin-is-watched.test.ts +++ b/src/__tests__/database-pin-is-watched.test.ts @@ -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", () => { diff --git a/tests/integration/global-setup.ts b/tests/integration/global-setup.ts index ac412f296..a440e9410 100644 --- a/tests/integration/global-setup.ts +++ b/tests/integration/global-setup.ts @@ -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;