Skip to content

Commit e619501

Browse files
fix(db): keep the PostgreSQL volume name stable across major upgrades
Reverts the postgresql-data-v18 rename from #1627 before it ships in a release. A PostgreSQL 18 container pointed at PostgreSQL 17 data fails loudly (the image entrypoint refuses to start), which is strictly safer than the rename's silent healthy-but-empty stack; stable volume names are the Compose norm, and a version-suffixed name forces a rename at every future major. The upgrade drill now rehearses the stable-name flow (dump, destroy the volume, fresh PostgreSQL 18 init, restore) and proves the loud-refusal property; the operator runbook and ADR 0038 are amended to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bbf92f1 commit e619501

8 files changed

Lines changed: 157 additions & 43 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hephaestus": minor
3+
---
4+
5+
**Operators:** the PostgreSQL Compose volume keeps its stable `postgresql-data` name across the 17 → 18 upgrade instead of moving to a version-suffixed `postgresql-data-v18`. This corrects the PostgreSQL 18 qualification shipping in this same release, so no deployed instance ever sees the `-v18` name. The upgrade is dump, remove the volume, restore into the freshly initialized PostgreSQL 18 cluster; a PostgreSQL 18 container started against un-migrated PostgreSQL 17 data refuses to start instead of coming up empty.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#### 🔴 The PostgreSQL upgrade keeps the stable volume name — rely on the verified dump, not a retained volume
2+
3+
**Affected**: operators performing the PostgreSQL 17 → 18 migration this release requires. This
4+
entry corrects the "Restore PostgreSQL 17 data into PostgreSQL 18" entry above, which predates it.
5+
6+
**Before**: this release was drafted to start PostgreSQL 18 on a new `postgresql-data-v18` volume,
7+
leaving the PostgreSQL 17 volume in place as the rollback path.
8+
9+
**After**: the Compose volume keeps its stable `postgresql-data` name. The documented upgrade
10+
verifies the dump, removes the PostgreSQL 17 volume, and lets PostgreSQL 18 initialize a fresh
11+
cluster under the same name. Starting the new release without migrating is safe: a PostgreSQL 18
12+
container attached to PostgreSQL 17 data refuses to start rather than coming up healthy and empty.
13+
14+
**Migration**: follow the current
15+
[Backup & Restore](https://docs.hephaestus.build/admin/backup-restore#postgresql-17-to-18)
16+
procedure. Where the entry above says to keep the PostgreSQL 17 volume until acceptance checks
17+
pass, keep the verified dump (with an off-host copy) instead — the old volume is removed during
18+
the upgrade, so the dump is the rollback artifact.

docker/compose.app.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ services:
449449
- postgres
450450
restart: unless-stopped
451451
volumes:
452-
- postgresql-data-v18:/var/lib/postgresql
452+
- postgresql-data:/var/lib/postgresql
453453
healthcheck:
454454
test:
455455
- CMD-SHELL
@@ -472,5 +472,5 @@ networks:
472472
driver: bridge
473473

474474
volumes:
475-
postgresql-data-v18:
475+
postgresql-data:
476476
git-repos:

docker/preview/compose.app.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ services:
2222
POSTGRES_USER: hephaestus
2323
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
2424
volumes:
25-
- postgres-data-v18:/var/lib/postgresql
25+
- postgres-data:/var/lib/postgresql
2626
security_opt:
2727
- no-new-privileges:true
2828
cap_drop:
@@ -376,4 +376,4 @@ networks:
376376
name: shared-network
377377

378378
volumes:
379-
postgres-data-v18:
379+
postgres-data:

docs/admin/backup-restore.mdx

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ holds bounded evidence copies that may become irreproducible after upstream cont
1111

1212
| Data | Where | Priority |
1313
| --- | --- | --- |
14-
| PostgreSQL volume | Compose-managed volume mounted by `postgres` | **Everything.** Accounts, workspaces, observations, feedback, encrypted provider tokens, JWT signing keys. |
14+
| PostgreSQL database | Compose-managed `postgresql-data` volume mounted by `postgres` | **Everything.** Accounts, workspaces, observations, feedback, encrypted provider tokens, JWT signing keys. |
1515
| `.env` | `/opt/hephaestus/docker/self-host/.env` | **Equal priority.** Its encryption keys protect values inside the database backup. Without them, provider credentials and signing material are unreadable. |
1616
| TLS certificates | `./letsencrypt/` | Optional — Let's Encrypt re-issues on first boot (rate limits permitting). |
1717
| Context Fabric | `git-repos` volume | Back up when replayable evidence is required. Skipping it discards repository clones and bounded replay/CAS content; durable job provenance remains in PostgreSQL, but the exact source bytes may be unrecoverable. |
@@ -57,13 +57,22 @@ timestamp.
5757

5858
## PostgreSQL 17 to 18
5959

60-
PostgreSQL major-version data directories are not binary compatible. The PostgreSQL 18 image stores
61-
`PGDATA` under `/var/lib/postgresql/18/docker`.
62-
Do **not** point the PostgreSQL 18 container at the old PostgreSQL 17 volume and do not delete the
63-
old volume until the restored instance passes verification.
60+
PostgreSQL major-version data directories are not binary compatible, and the PostgreSQL 18 image
61+
stores `PGDATA` under `/var/lib/postgresql/18/docker`. The Compose volume keeps its stable
62+
`postgresql-data` name across the upgrade, so the move is: dump, destroy the volume, let
63+
PostgreSQL 18 initialize a fresh empty cluster under the same name, restore.
6464

65-
From the PostgreSQL 17 release, stop application writes, make a logical dump, and record the exact
66-
Compose volume name:
65+
Skipping the procedure fails loudly, not silently: a PostgreSQL 18 container attached to the volume
66+
still holding PostgreSQL 17 data refuses to start (the entrypoint reports the old data and exits),
67+
so nothing is lost by starting the new release too early — the container just stays down until the
68+
steps below are completed.
69+
70+
**Step (c) destroys the PostgreSQL 17 data.** From that point on, the dump is the only copy. Do not
71+
remove the volume before the dump has passed `pg_restore --list`, and copy the dump (and its
72+
checksum) off the host first.
73+
74+
**(a)** From the running PostgreSQL 17 release, stop application writes and take a verified
75+
custom-format dump:
6776

6877
```bash
6978
cd /opt/hephaestus/docker/self-host
@@ -73,44 +82,72 @@ dc exec -T postgres pg_dump -U root -Fc hephaestus > /var/tmp/hephaestus-pg17.du
7382
test -s /var/tmp/hephaestus-pg17.dump
7483
dc exec -T postgres pg_restore --list < /var/tmp/hephaestus-pg17.dump >/dev/null
7584
sha256sum /var/tmp/hephaestus-pg17.dump > /var/tmp/hephaestus-pg17.dump.sha256
76-
PG17_VOLUME=$(docker inspect "$(dc ps -q postgres)" \
77-
--format '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}')
78-
test -n "$PG17_VOLUME"
79-
dc down
80-
printf 'Preserved PostgreSQL 17 volume: %s\n' "$PG17_VOLUME"
8185
```
8286

83-
Check out the new release and prepare its signed release lock as described in [Upgrades](./install#upgrades).
84-
The supported self-host stack creates no custom roles or tablespaces; operators who added either must
85-
migrate those cluster-global objects separately. Restore into the new PostgreSQL 18 volume:
87+
Copy `/var/tmp/hephaestus-pg17.dump` and its `.sha256` off the host now.
88+
89+
**(b)** Stop and remove the database container:
90+
91+
```bash
92+
dc down postgres
93+
```
94+
95+
**(c)** Remove the PostgreSQL 17 volume. Docker prefixes the Compose project name, so the exact
96+
name is `<project>_postgresql-data` — list the volumes and use the one ending in
97+
`_postgresql-data`:
98+
99+
```bash
100+
docker volume ls --format '{{.Name}}' | grep '_postgresql-data$'
101+
docker volume rm <project>_postgresql-data
102+
```
103+
104+
This deletes the PostgreSQL 17 cluster permanently.
105+
106+
**(d)** Check out the new release and prepare its signed release lock as described in
107+
[Upgrades](./install#upgrades), then start the database alone. PostgreSQL 18 initializes a fresh,
108+
empty cluster in the recreated volume:
86109

87110
```bash
88111
dc() { docker compose --env-file .env --env-file release-lock.env "$@"; }
89112
dc up -d postgres
90113
until dc exec -T postgres pg_isready -U root -d hephaestus; do sleep 2; done
91114
test "$(dc exec -T postgres psql -U root -d hephaestus -Atc \
92115
"SELECT current_setting('server_version_num')::int / 10000 || ':' || (to_regclass('public.databasechangelog') IS NULL)")" = "18:true"
116+
```
117+
118+
**(e)** Restore the dump. The supported self-host stack creates no custom roles or tablespaces;
119+
operators who added either must migrate those cluster-global objects separately:
120+
121+
```bash
93122
sha256sum -c /var/tmp/hephaestus-pg17.dump.sha256
94123
dc exec -T postgres dropdb -U root hephaestus
95124
dc exec -T postgres createdb -U root hephaestus
96125
dc exec -T postgres pg_restore -U root -d hephaestus --no-owner --no-acl \
97126
--single-transaction < /var/tmp/hephaestus-pg17.dump
127+
```
128+
129+
**(f)** Update the pg_partman extension to the version bundled with the new image, then start the
130+
stack:
131+
132+
```bash
98133
dc exec -T postgres psql -U root -d hephaestus -v ON_ERROR_STOP=1 \
99134
-c 'ALTER EXTENSION pg_partman UPDATE'
100135
dc up -d --wait
101136
```
102137

103-
Verify application health, sign in, and inspect recent workspace activity. Then verify the database
104-
major, extension, and partition policy:
138+
**(g)** Verify application health, sign in, and inspect recent workspace activity. Then verify the
139+
database major, extension, partition policy, and that row counts in the tables you know best match
140+
the pre-upgrade state:
105141

106142
```bash
107143
dc exec -T postgres psql -U root -d hephaestus -Atc \
108144
"SELECT current_setting('server_version_num')::int / 10000 = 18; SELECT extversion = '5.5.0' FROM pg_extension WHERE extname = 'pg_partman'; SELECT count(*) = 1 FROM partman.part_config WHERE parent_table = 'public.auth_event';"
109145
```
110146

111-
Keep the dump and PostgreSQL 17 volume until acceptance checks pass and backup policy permits their
112-
deletion. Rollback means stopping PostgreSQL 18 and starting the prior release against the preserved
113-
volume; never run the prior application against the PostgreSQL 18 database.
147+
Keep the dump until acceptance checks pass and backup policy permits its deletion. Rollback means
148+
checking out the prior release, removing the volume again, starting its PostgreSQL 17 container
149+
against the recreated empty volume, and restoring the same dump; never run the prior application
150+
against the PostgreSQL 18 database.
114151

115152
### After a point-in-time restore
116153

docs/decisions/0038-postgresql-18-release-baseline.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Status
44

5-
Accepted
5+
Accepted (amended 2026-08-31 — see the update below)
66

77
## Context
88

@@ -33,3 +33,27 @@ PostgreSQL 18. Preview environments create a fresh PostgreSQL 18 cluster.
3333
- [PostgreSQL major-upgrade documentation](https://www.postgresql.org/docs/18/upgrading.html)
3434
- [Official image `PGDATA` contract](https://github.com/docker-library/docs/blob/master/postgres/README.md#pgdata)
3535
- [pg_partman 5.5 release notes](https://github.com/pgpartman/pg_partman/releases/tag/v5.5.0)
36+
37+
## Update — 2026-08-31 (PR #1673)
38+
39+
One point of the decision is reversed before it ships in a release: the self-hosted Compose volume
40+
keeps its stable `postgresql-data` name (`postgres-data` in the preview stack) instead of moving to
41+
a version-suffixed `postgresql-data-v18`. Everything else above stands — PostgreSQL 18 as the
42+
qualified baseline, the `/var/lib/postgresql` mount, and the dump-and-restore upgrade.
43+
44+
What the rename bought was a retained PostgreSQL 17 cluster for rollback. What it cost:
45+
46+
- The residual risk flagged at review — with a renamed volume, an operator who upgrades without
47+
migrating gets a stack that comes up **healthy but empty**, because Compose silently creates the
48+
new volume. With the stable name the same mistake fails loudly: the 18+ image's entrypoint
49+
detects the PostgreSQL 17 data in the volume and refuses to start (major-version data directories
50+
are incompatible with the server), so nothing is silently lost and the operator is pointed back
51+
at the documented migration.
52+
- Stable volume names are the Compose norm; a version-suffixed name forces a rename — and a fresh
53+
round of this reasoning — at every future major.
54+
55+
Rollback safety now rests on the verified dump rather than a retained volume:
56+
`docs/admin/backup-restore.mdx` § "PostgreSQL 17 to 18" requires `pg_restore --list` and an
57+
off-host copy before the volume is removed. `scripts/postgres-major-upgrade-test.ts` rehearses the
58+
stable-name flow (dump → destroy volume → fresh PostgreSQL 18 initialization → restore) and proves
59+
the loud-refusal property.

docs/decisions/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ do, its home is the Admin Guide (`docs/admin/`) and the runbook links to it.
7676
| [0035](0035-pull-request-previews-are-label-gated.md) | Pull request previews are label-gated and driven from the default branch | Accepted |
7777
| [0036](0036-agent-runtime-runs-on-node-24.md) | Agent runtime runs on Node.js 24 with bounded resources | Accepted |
7878
| [0037](0037-node-24-and-pnpm-12-are-the-javascript-toolchain.md) | Node.js 24 and pnpm 12 are the JavaScript toolchain | Accepted |
79-
| [0038](0038-postgresql-18-release-baseline.md) | PostgreSQL 18 is the qualified release baseline | Accepted |
79+
| [0038](0038-postgresql-18-release-baseline.md) | PostgreSQL 18 is the qualified release baseline | Accepted (amended 2026-08-31 #1673 — the Compose volume keeps its stable `postgresql-data` name) |
8080
| [0039](0039-git-and-postgresql-own-evidence.md) | Git owns repository evidence; PostgreSQL owns captured payloads and references | Accepted |
8181

8282
Template: [0000-template.md](0000-template.md).

scripts/postgres-major-upgrade-test.ts

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1+
// Rehearses the operator upgrade documented in docs/admin/backup-restore.mdx § PostgreSQL 17 to 18:
2+
// dump the PostgreSQL 17 cluster, destroy the volume, recreate it under the same stable name, and
3+
// restore into a fresh PostgreSQL 18 cluster. It also proves the safety property ADR 0038's
4+
// amendment leans on — PostgreSQL 18 refuses to start against PostgreSQL 17 data instead of coming
5+
// up healthy and empty.
16
import { spawnSync } from "node:child_process";
27
import { randomUUID } from "node:crypto";
38

49
const id = `postgres-upgrade-${randomUUID().slice(0, 8)}`;
510
const source = `${id}-17`;
611
const target = `${id}-18`;
7-
const sourceVolume = `${source}-data`;
8-
const targetVolume = `${target}-data`;
12+
// One volume, reused across the major upgrade — the same stable `postgresql-data` name the shipped
13+
// compose files declare (Compose prefixes it with the project name).
14+
const volume = `${id}_postgresql-data`;
915

1016
function run(command: string, args: string[], input?: Buffer): string {
1117
const result = spawnSync(command, args, { encoding: "utf8", input, maxBuffer: 64 * 1024 * 1024 });
@@ -54,7 +60,7 @@ function wait(container: string): void {
5460
throw new Error(`${container} did not become ready`);
5561
}
5662

57-
function start(container: string, volume: string, mount: string, image: string): number {
63+
function start(container: string, dataVolume: string, mount: string, image: string): number {
5864
docker(
5965
"run",
6066
"-d",
@@ -63,7 +69,7 @@ function start(container: string, volume: string, mount: string, image: string):
6369
"-p",
6470
"127.0.0.1::5432",
6571
"-v",
66-
`${volume}:${mount}`,
72+
`${dataVolume}:${mount}`,
6773
"-e",
6874
"POSTGRES_DB=hephaestus",
6975
"-e",
@@ -96,10 +102,9 @@ try {
96102
"docker/postgres",
97103
]);
98104
run("docker", ["build", "-t", `${id}:18`, "docker/postgres"]);
99-
docker("volume", "create", sourceVolume);
100-
docker("volume", "create", targetVolume);
105+
docker("volume", "create", volume);
101106

102-
const sourcePort = start(source, sourceVolume, "/var/lib/postgresql/data", `${id}:17`);
107+
const sourcePort = start(source, volume, "/var/lib/postgresql/data", `${id}:17`);
103108
if (sql(source, "SHOW server_version_num").slice(0, 2) !== "17")
104109
throw new Error("source is not PostgreSQL 17");
105110

@@ -145,9 +150,41 @@ try {
145150
input: dump,
146151
});
147152
if (listing.status !== 0) throw new Error("source dump is unreadable");
148-
docker("stop", source);
153+
docker("rm", "-f", source);
149154

150-
start(target, targetVolume, "/var/lib/postgresql", `${id}:18`);
155+
// The safety property behind keeping the volume name stable: an operator who upgrades without
156+
// completing the dump-and-restore gets a container that refuses to start, not a silently empty
157+
// database. The 18+ entrypoint detects the foreign PG_VERSION and exits before initdb.
158+
const refusal = spawnSync(
159+
"docker",
160+
[
161+
"run",
162+
"--rm",
163+
"-v",
164+
`${volume}:/var/lib/postgresql`,
165+
"-e",
166+
"POSTGRES_DB=hephaestus",
167+
"-e",
168+
"POSTGRES_USER=root",
169+
"-e",
170+
"POSTGRES_PASSWORD=root",
171+
`${id}:18`,
172+
],
173+
{ encoding: "utf8", timeout: 120_000, maxBuffer: 64 * 1024 * 1024 },
174+
);
175+
if (refusal.status === 0 || refusal.status === null) {
176+
throw new Error("PostgreSQL 18 did not refuse the PostgreSQL 17 data");
177+
}
178+
if (!refusal.stderr.includes("PostgreSQL data")) {
179+
throw new Error(`PostgreSQL 18 failed for an unexpected reason:\n${refusal.stderr}`);
180+
}
181+
182+
// The operator's destructive step: the PostgreSQL 17 volume is removed and recreated under the
183+
// same name, so from here on the verified dump is the only copy of the data.
184+
docker("volume", "rm", volume);
185+
docker("volume", "create", volume);
186+
187+
start(target, volume, "/var/lib/postgresql", `${id}:18`);
151188
docker("exec", target, "dropdb", "-U", "root", "hephaestus");
152189
docker("exec", target, "createdb", "-U", "root", "hephaestus");
153190
const restore = spawnSync(
@@ -193,15 +230,8 @@ try {
193230
) !== "t"
194231
)
195232
throw new Error("auth_event partitions were not restored");
196-
197-
docker("stop", target);
198-
docker("start", source);
199-
wait(source);
200-
if (sql(source, "SELECT value FROM upgrade_qualification WHERE id=1") !== "preserved")
201-
throw new Error("PostgreSQL 17 rollback volume is unreadable");
202233
} finally {
203234
for (const container of [source, target]) spawnSync("docker", ["rm", "-f", container]);
204-
for (const volume of [sourceVolume, targetVolume])
205-
spawnSync("docker", ["volume", "rm", "-f", volume]);
235+
spawnSync("docker", ["volume", "rm", "-f", volume]);
206236
for (const image of [`${id}:17`, `${id}:18`]) spawnSync("docker", ["rmi", "-f", image]);
207237
}

0 commit comments

Comments
 (0)