[E00-S02-T04] DB volume persists across restart/recreate #385

Merged
kpcto merged 2 commits from feature/171 into main 2026-08-29 00:35:08 +00:00
Showing only changes of commit b51af02d06 - Show all commits
+192 -3
View File
@@ -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/);
});