[E00-S02-T04] DB volume persists across restart/recreate #385
@@ -8,8 +8,9 @@
|
|||||||
# server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port
|
# server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port
|
||||||
# 3000, so the app container stays up and the health endpoint succeeds. The
|
# 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;
|
# 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
|
# DB volume persistence (T04) is a Compose-level concern (see compose.yaml —
|
||||||
# targets remain later E00-S02 tasks — all out of scope here.
|
# 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
|
# 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
|
# §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
|
# `docker compose up -d` starts both the database (PostgreSQL) and the
|
||||||
# application (@personal-blog/server). Rollback: `docker compose down`.
|
# 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
|
# `{"status":"ok"}`) on port 3000, so the app container stays up and the
|
||||||
# health endpoint succeeds once the stack is running.
|
# health endpoint succeeds once the stack is running.
|
||||||
#
|
#
|
||||||
# Explicitly out of scope for T01/T02/T03 (land in later E00-S02 tasks):
|
# Database volume persistence (T04): the `db` service mounts the named volume
|
||||||
# - DB volume persistence (T04)
|
# `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
|
# 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).
|
# without a .env file (a committed .env.example template lands in E00-S04).
|
||||||
@@ -29,6 +35,11 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-eppp}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-eppp}
|
||||||
ports:
|
ports:
|
||||||
- "${POSTGRES_PORT:-5432}:5432"
|
- "${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
|
# Health gate for the app service (T02): probe the same credentials the db
|
||||||
# service was created with. `$$` defers interpolation to the container, so
|
# service was created with. `$$` defers interpolation to the container, so
|
||||||
# POSTGRES_USER/POSTGRES_DB overrides apply to the probe too.
|
# POSTGRES_USER/POSTGRES_DB overrides apply to the probe too.
|
||||||
@@ -52,3 +63,9 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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
|
* Docker Compose baseline test — locks in the [E00-S02-T01/T02/T03/T04]
|
||||||
* compose up -d` DB + app baseline, the PostgreSQL health gate and the app
|
* `docker compose up -d` DB + app baseline, the PostgreSQL health gate, the
|
||||||
* health endpoint for the workspace.
|
* app health endpoint and the DB volume persistence for the workspace.
|
||||||
*
|
*
|
||||||
* Acceptance criteria covered (each test fails without the committed state):
|
* Acceptance criteria covered (each test fails without the committed state):
|
||||||
* - "docker compose up -d starts the database" → the committed
|
* - "docker compose up -d starts the database" → the committed
|
||||||
@@ -31,6 +31,16 @@
|
|||||||
* depends on `db` with `condition: service_healthy`, so Compose only
|
* depends on `db` with `condition: service_healthy`, so Compose only
|
||||||
* starts the application once PostgreSQL reports healthy (the real-stack
|
* starts the application once PostgreSQL reports healthy (the real-stack
|
||||||
* probe asserts the `db` container is healthy when Docker reports it).
|
* 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`
|
* Run: `node --test tests/compose-config.test.mjs`
|
||||||
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
* (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)
|
// Docker probe helpers (integration tests skip cleanly without Docker)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -303,6 +340,29 @@ function field(container, ...names) {
|
|||||||
return undefined;
|
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_COMPOSE = dockerComposeAvailable();
|
||||||
const DOCKER_DAEMON = dockerDaemonAvailable();
|
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', () => {
|
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`);
|
assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`);
|
||||||
const dockerfile = read(DOCKERFILE_PATH);
|
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
|
// 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/);
|
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