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

@@ -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", () => {