Merge pull request '[E00-S02-T04] DB volume persists across restart/recreate' (#385) from feature/171 into main
CI / Frozen lockfile install (push) Successful in 59s
CI / Frozen lockfile install (push) Successful in 59s
This commit was merged in pull request #385.
This commit is contained in:
@@ -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
|
||||
|
||||
+20
-3
@@ -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:
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user