From 3dd80f91fe39caeab955b7deb5b0ad0c61c52a4f Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 00:32:15 +0000 Subject: [PATCH 1/2] feat: persist the database volume across restart/recreate (E00-S02-T04) Mount the named `db-data` volume at PostgreSQL's data directory (/var/lib/postgresql/data) on the db service and declare it in the top-level volumes map, so the database survives `docker compose restart` and `docker compose down` + `up -d` (recreate). Reset with `docker compose down -v` per the issue rollback note. Header comments in compose.yaml and the server Dockerfile updated: T04 is no longer out of scope; T05/T06 remain. --- apps/server/Dockerfile | 5 +++-- compose.yaml | 23 ++++++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 5b9498f..4cc2d96 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -8,8 +8,9 @@ # server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port # 3000, so the app container stays up and the health endpoint succeeds. The # Fastify 5 application shell (and the real HTTP API) lands in a later story; -# volume persistence (T04), non-root/read-only hardening (T05) and multi-arch -# targets remain later E00-S02 tasks — all out of scope here. +# DB volume persistence (T04) is a Compose-level concern (see compose.yaml — +# this image is unchanged), while non-root/read-only hardening (T05/T06) and +# multi-arch targets remain later E00-S02 tasks — all out of scope here. # # Image base: node:24.19.0-bookworm-slim (glibc Debian) per Technology-Stack # §5.4 — argon2 is a native dependency and musl/Alpine causes native-module diff --git a/compose.yaml b/compose.yaml index 3def6eb..ebcdf89 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,4 @@ -# EPPP Docker Compose baseline — [E00-S02-T01/T02/T03] +# EPPP Docker Compose baseline — [E00-S02-T01/T02/T03/T04] # # `docker compose up -d` starts both the database (PostgreSQL) and the # application (@personal-blog/server). Rollback: `docker compose down`. @@ -12,8 +12,14 @@ # `{"status":"ok"}`) on port 3000, so the app container stays up and the # health endpoint succeeds once the stack is running. # -# Explicitly out of scope for T01/T02/T03 (land in later E00-S02 tasks): -# - DB volume persistence (T04) +# Database volume persistence (T04): the `db` service mounts the named volume +# `db-data` at PostgreSQL's data directory (`/var/lib/postgresql/data`), so +# the database survives `docker compose restart` (restart) and +# `docker compose down` + `docker compose up -d` (recreate, which discards the +# container filesystem). Reset the data with `docker compose down -v`. +# +# Explicitly out of scope for T01..T04 (land in later E00-S02 tasks): +# - non-root execution (T05), read-only root filesystem (T06) # # All values have defaults so `docker compose up -d` works from a clean clone # without a .env file (a committed .env.example template lands in E00-S04). @@ -29,6 +35,11 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-eppp} ports: - "${POSTGRES_PORT:-5432}:5432" + # T04: persist the database in the named `db-data` volume (PostgreSQL data + # directory), so data survives `docker compose restart` and `docker + # compose down` + `up -d` (recreate). `docker compose down -v` resets it. + volumes: + - db-data:/var/lib/postgresql/data # Health gate for the app service (T02): probe the same credentials the db # service was created with. `$$` defers interpolation to the container, so # POSTGRES_USER/POSTGRES_DB overrides apply to the probe too. @@ -52,3 +63,9 @@ services: depends_on: db: condition: service_healthy + +# Named volumes shared across `docker compose` lifecycles. `db-data` (T04) +# holds the PostgreSQL data directory and is preserved across restart and +# recreate; `docker compose down -v` removes it to reset the database. +volumes: + db-data: -- 2.54.0 From b51af02d0670c75af446c95b732251120d0d64d8 Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 00:32:15 +0000 Subject: [PATCH 2/2] test: lock in DB volume persistence criteria (E00-S02-T04) compose-config: assert the db service mounts the named db-data volume at the PostgreSQL data directory and the top-level volumes map declares it; non-vacuous mutation probes (missing mount, missing volume declaration, wrong mount target all fail); Docker-gated real-stack probe writes a fixture row and asserts it survives `docker compose restart` (restart) and `docker compose down` + `up -d` (recreate), cleaned up with `docker compose down -v`. --- tests/compose-config.test.mjs | 195 +++++++++++++++++++++++++++++++++- 1 file changed, 192 insertions(+), 3 deletions(-) diff --git a/tests/compose-config.test.mjs b/tests/compose-config.test.mjs index e72ff19..8a62ae3 100644 --- a/tests/compose-config.test.mjs +++ b/tests/compose-config.test.mjs @@ -1,7 +1,7 @@ /** - * Docker Compose baseline test — locks in the [E00-S02-T01/T02/T03] `docker - * compose up -d` DB + app baseline, the PostgreSQL health gate and the app - * health endpoint for the workspace. + * Docker Compose baseline test — locks in the [E00-S02-T01/T02/T03/T04] + * `docker compose up -d` DB + app baseline, the PostgreSQL health gate, the + * app health endpoint and the DB volume persistence for the workspace. * * Acceptance criteria covered (each test fails without the committed state): * - "docker compose up -d starts the database" → the committed @@ -31,6 +31,16 @@ * depends on `db` with `condition: service_healthy`, so Compose only * starts the application once PostgreSQL reports healthy (the real-stack * probe asserts the `db` container is healthy when Docker reports it). + * - "database volume persists across restart" and "database volume + * persists across recreate" → the committed `compose.yaml` declares a + * named `db-data` volume and mounts it at PostgreSQL's data directory + * (`/var/lib/postgresql/data`), so the database files survive + * `docker compose restart` (same containers) and `docker compose down` → + * `docker compose up -d` (containers recreated — the container + * filesystem is discarded, so only a named volume carries the data). + * When Docker is available, the real-stack probe writes a fixture row, + * then asserts it survives both operations; data is reset with + * `docker compose down -v` (the issue's rollback note). * * Run: `node --test tests/compose-config.test.mjs` * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) @@ -249,6 +259,33 @@ function assertDbHealthcheck(compose) { ); } +/** + * Asserts the committed compose.yaml makes the database volume persist: the + * `db` service mounts the named `db-data` volume at PostgreSQL's data + * directory (`/var/lib/postgresql/data`) and the top-level `volumes` map + * declares that volume. That is what lets the database survive `docker + * compose restart` (containers restarted in place) and `docker compose down` + * → `docker compose up -d` (containers recreated — the container filesystem + * is discarded, so only a named volume carries the data across). Removing + * either half breaks the criterion (see the mutation probes below). + */ +function assertDbVolume(compose) { + const db = compose.services?.db; + assert.ok(db, 'compose.yaml must declare a "db" service'); + assert.ok( + Array.isArray(db.volumes) && + db.volumes.includes('db-data:/var/lib/postgresql/data'), + "the \"db\" service must mount the named db-data volume at PostgreSQL's data directory " + + '(a "db-data:/var/lib/postgresql/data" entry) so the database survives restart and recreate', + ); + const volumes = compose.volumes ?? {}; + assert.ok( + Object.prototype.hasOwnProperty.call(volumes, 'db-data'), + 'compose.yaml must declare the named "db-data" volume (top-level "volumes: db-data:") ' + + 'so the mount target exists and is preserved across recreate', + ); +} + // --------------------------------------------------------------------------- // Docker probe helpers (integration tests skip cleanly without Docker) // --------------------------------------------------------------------------- @@ -303,6 +340,29 @@ function field(container, ...names) { return undefined; } +/** + * Polls `docker compose ps` until the db container reports healthy (or the + * deadline passes). Used by the T04 persistence probe after `up`, `restart` + * and `down` + `up` so fixture queries never race a still-booting database. + */ +function waitForDbHealthy(deadlineMs = 60_000) { + const deadline = Date.now() + deadlineMs; + let last = ''; + while (Date.now() < deadline) { + const ps = run('docker', ['compose', 'ps', '--format', 'json'], { + cwd: REPO_ROOT, + timeout: 15_000, + }); + if (ps.status === 0) { + last = ps.stdout; + const db = parsePsJson(ps.stdout).find((c) => field(c, 'Service', 'service') === 'db'); + if (db && /healthy/i.test(String(field(db, 'Health', 'health') ?? ''))) return; + } + run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // db still booting — retry + } + throw new Error(`the db container did not become healthy within ${deadlineMs}ms (last ps: "${last.trim()}")`); +} + const DOCKER_COMPOSE = dockerComposeAvailable(); const DOCKER_DAEMON = dockerDaemonAvailable(); @@ -342,6 +402,10 @@ test('the app waits for the database before starting (depends_on db with conditi ); }); +test('the database volume persists across restart and recreate (db mounts the named db-data volume)', () => { + assertDbVolume(parseYaml(read(COMPOSE_PATH))); +}); + test('the application image is defined by a committed multi-stage Dockerfile', () => { assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`); const dockerfile = read(DOCKERFILE_PATH); @@ -478,6 +542,101 @@ test('docker compose up -d starts the database and application containers', { sk } }); +test('a database fixture survives docker compose restart and recreate (named db-data volume)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, () => { + // T04 acceptance criteria: the database volume persists across restart and + // across recreate. The probe writes a fixture row through the running db + // service, then asserts it is still there after `docker compose restart` + // (containers restarted in place) and after `docker compose down` + + // `docker compose up -d` (containers **recreated** — the container + // filesystem is discarded, so only the named `db-data` volume can carry + // the data across). `docker compose down -v` (the issue's rollback note) + // cleans up in `finally` so runs stay isolated. Credentials use the + // committed defaults, the same ones the app's DATABASE_URL hardcodes. + const execPsql = (args) => + run('docker', ['compose', 'exec', '-T', 'db', 'psql', '-U', 'eppp', '-d', 'eppp', ...args], { + cwd: REPO_ROOT, + timeout: 60_000, + }); + const createFixture = () => + execPsql([ + '-v', 'ON_ERROR_STOP=1', '-c', + "CREATE TABLE t04_persist_probe (note text); INSERT INTO t04_persist_probe VALUES ('t04-fixture');", + ]); + const countFixture = () => + execPsql(['-tA', '-c', 'SELECT count(*) FROM t04_persist_probe;']); + + const up = run('docker', ['compose', 'up', '-d'], { cwd: REPO_ROOT }); + assert.equal( + up.status, + 0, + `"docker compose up -d" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(), + ); + try { + waitForDbHealthy(); + + const created = createFixture(); + assert.equal( + created.status, + 0, + `fixture creation via psql must succeed:\n${(created.stdout || '')}\n${(created.stderr || '')}`.trim(), + ); + + // Acceptance 1 — "database volume persists across restart": `docker + // compose restart` stops and starts the same containers; the fixture + // must still be queryable afterwards. + const restart = run('docker', ['compose', 'restart'], { cwd: REPO_ROOT, timeout: 120_000 }); + assert.equal( + restart.status, + 0, + `"docker compose restart" must exit 0:\n${(restart.stdout || '')}\n${(restart.stderr || '')}`.trim(), + ); + waitForDbHealthy(); + const afterRestart = countFixture(); + assert.equal( + afterRestart.status, + 0, + `fixture query after restart must succeed:\n${(afterRestart.stdout || '')}\n${(afterRestart.stderr || '')}`.trim(), + ); + assert.match( + afterRestart.stdout.trim(), + /^1$/m, + `the database fixture must survive "docker compose restart" (volume persists across restart; got: "${afterRestart.stdout.trim()}")`, + ); + + // Acceptance 2 — "database volume persists across recreate": `docker + // compose down` (without -v, so named volumes survive) removes the + // containers, then `docker compose up -d` recreates them from scratch — + // the only thing that can carry the fixture across is the named volume. + const down = run('docker', ['compose', 'down'], { cwd: REPO_ROOT, timeout: 120_000 }); + assert.equal( + down.status, + 0, + `"docker compose down" must exit 0:\n${(down.stdout || '')}\n${(down.stderr || '')}`.trim(), + ); + const upAgain = run('docker', ['compose', 'up', '-d'], { cwd: REPO_ROOT, timeout: 120_000 }); + assert.equal( + upAgain.status, + 0, + `"docker compose up -d" after down must exit 0:\n${(upAgain.stdout || '')}\n${(upAgain.stderr || '')}`.trim(), + ); + waitForDbHealthy(); + const afterRecreate = countFixture(); + assert.equal( + afterRecreate.status, + 0, + `fixture query after recreate must succeed:\n${(afterRecreate.stdout || '')}\n${(afterRecreate.stderr || '')}`.trim(), + ); + assert.match( + afterRecreate.stdout.trim(), + /^1$/m, + `the database fixture must survive "docker compose down" + "docker compose up -d" (volume persists across recreate; got: "${afterRecreate.stdout.trim()}")`, + ); + } finally { + // Rollback note from the issue: `docker compose down -v` resets the data. + run('docker', ['compose', 'down', '-v'], { cwd: REPO_ROOT, timeout: 120_000 }); + } +}); + // --------------------------------------------------------------------------- // Non-vacuous probes — the assertions above really do fail on violations // --------------------------------------------------------------------------- @@ -563,3 +722,33 @@ test('a Dockerfile without the runtime entrypoint fails the image criterion (mut }; assert.throws(asserts, /compiled entrypoint/); }); + +test('removing the db volume mount makes the persistence criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const withoutMount = text.replace( + ' volumes:\n - db-data:/var/lib/postgresql/data\n', + '', + ); + assert.notEqual(withoutMount, text, 'the mutation must actually remove the db volume mount'); + const compose = parseYaml(withoutMount); + assert.throws(() => assertDbVolume(compose), /db-data/); +}); + +test('removing the named db-data volume declaration makes the persistence criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const withoutVolume = text.replace('volumes:\n db-data:\n', ''); + assert.notEqual(withoutVolume, text, 'the mutation must actually remove the top-level volumes declaration'); + const compose = parseYaml(withoutVolume); + assert.throws(() => assertDbVolume(compose), /db-data/); +}); + +test('mounting the volume at the wrong path fails the persistence criterion (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const wrongPath = text.replace( + 'db-data:/var/lib/postgresql/data', + 'db-data:/var/lib/postgresql', + ); + assert.notEqual(wrongPath, text, 'the mutation must actually change the mount target'); + const compose = parseYaml(wrongPath); + assert.throws(() => assertDbVolume(compose), /data directory/); +}); -- 2.54.0