Merge pull request '[E00-S02-T02] PostgreSQL health gates app start' (#383) from feature/169 into main
CI / Frozen lockfile install (push) Successful in 41s

This commit was merged in pull request #383.
This commit is contained in:
2026-08-29 00:07:31 +00:00
2 changed files with 125 additions and 14 deletions
+20 -4
View File
@@ -1,10 +1,14 @@
# EPPP Docker Compose baseline — [E00-S02-T01] # EPPP Docker Compose baseline — [E00-S02-T01/T02]
# #
# `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`.
# #
# Explicitly out of scope for T01 (land in later E00-S02 tasks): # PostgreSQL health gate (T02): the `db` service carries a `pg_isready`
# - PostgreSQL health gate (T02) # healthcheck and the `app` service depends on it with
# `condition: service_healthy`, so the application does not start until the
# database is accepting connections.
#
# Explicitly out of scope for T01/T02 (land in later E00-S02 tasks):
# - application health endpoint (T03) # - application health endpoint (T03)
# - DB volume persistence (T04) # - DB volume persistence (T04)
# #
@@ -22,6 +26,15 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-eppp} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-eppp}
ports: ports:
- "${POSTGRES_PORT:-5432}:5432" - "${POSTGRES_PORT:-5432}:5432"
# 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.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
app: app:
build: build:
@@ -31,5 +44,8 @@ services:
DATABASE_URL: postgres://eppp:eppp@db:5432/eppp DATABASE_URL: postgres://eppp:eppp@db:5432/eppp
ports: ports:
- "${APP_PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
# T02: start only once the database reports healthy (service_healthy), so
# the application waits for PostgreSQL before starting.
depends_on: depends_on:
- db db:
condition: service_healthy
+105 -10
View File
@@ -1,6 +1,7 @@
/** /**
* Docker Compose baseline test — locks in the [E00-S02-T01] `docker compose * Docker Compose baseline test — locks in the [E00-S02-T01/T02] `docker
* up -d` DB + app baseline for the workspace. * compose up -d` DB + app baseline and the PostgreSQL health gate 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
@@ -10,16 +11,25 @@
* a Docker daemon + Compose plugin are available (CI/dev machines), the * a Docker daemon + Compose plugin are available (CI/dev machines), the
* suite additionally runs the real stack (`docker compose up -d` → * suite additionally runs the real stack (`docker compose up -d` →
* `docker compose ps` → `docker compose down`) and asserts the `db` * `docker compose ps` → `docker compose down`) and asserts the `db`
* container is up. * container is up (and healthy).
* - "docker compose up -d starts the application" → the committed * - "docker compose up -d starts the application" → the committed
* `compose.yaml` declares an `app` service built from the committed * `compose.yaml` declares an `app` service built from the committed
* `apps/server/Dockerfile` (multi-stage: Node 24.19.0 bookworm-slim + * `apps/server/Dockerfile` (multi-stage: Node 24.19.0 bookworm-slim +
* frozen pnpm install → `tsc` build of `@personal-blog/server` → * frozen pnpm install → `tsc` build of `@personal-blog/server` →
* `node apps/server/dist/index.js`), * `node apps/server/dist/index.js`),
* with a published default port and `depends_on: db` so the application * with a published default port. The real-stack probe asserts the `app`
* starts after the database. The real-stack probe asserts the `app`
* container is created and starts cleanly (exit 0 when the placeholder * container is created and starts cleanly (exit 0 when the placeholder
* process exits). * process exits).
* - "PostgreSQL health check gates application start" → the committed
* `compose.yaml` declares a `healthcheck` on the `db` service that probes
* readiness with `pg_isready` against the same credentials the database
* was created with (`$$`-escaped so the container env applies), with an
* interval/retries so a still-booting database is re-probed rather than
* failed instantly.
* - "app waits for the database before starting" → the `app` service
* 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).
* *
* 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.)
@@ -178,7 +188,8 @@ function assertDbService(compose) {
/** /**
* Asserts the committed compose.yaml declares an `app` service that * Asserts the committed compose.yaml declares an `app` service that
* `docker compose up -d` can start: built from the committed Dockerfile, * `docker compose up -d` can start: built from the committed Dockerfile,
* pointed at the db service, and started after it (depends_on). * pointed at the db service, and started only after the database is healthy
* (depends_on with condition: service_healthy).
*/ */
function assertAppService(compose) { function assertAppService(compose) {
const app = compose.services?.app; const app = compose.services?.app;
@@ -202,9 +213,38 @@ function assertAppService(compose) {
Array.isArray(app.ports) && app.ports.some((p) => p.endsWith(':3000')), Array.isArray(app.ports) && app.ports.some((p) => p.endsWith(':3000')),
'app must publish the application port (a mapping ending in ":3000")', 'app must publish the application port (a mapping ending in ":3000")',
); );
assert.equal(
app.depends_on?.db?.condition,
'service_healthy',
'app must depend on db with condition: service_healthy so it waits for the database before starting',
);
}
/**
* Asserts the committed compose.yaml gates the app on PostgreSQL health: the
* `db` service declares a `pg_isready` healthcheck against the credentials it
* was created with, with an interval and retries so a booting database is
* re-probed instead of failed instantly.
*/
function assertDbHealthcheck(compose) {
const db = compose.services?.db;
assert.ok(db, 'compose.yaml must declare a "db" service');
const hc = db.healthcheck;
assert.ok(hc, 'the "db" service must declare a healthcheck (PostgreSQL health check gates application start)');
assert.match(
hc.test ?? '',
/pg_isready/,
'the db healthcheck must probe readiness with pg_isready',
);
assert.ok( assert.ok(
Array.isArray(app.depends_on) && app.depends_on.includes('db'), (hc.test ?? '').includes('$${POSTGRES_USER}') && (hc.test ?? '').includes('$${POSTGRES_DB}'),
'app must declare depends_on: [db] so the application starts after the database', 'the db healthcheck must probe the same credentials the database was created with ' +
'($${POSTGRES_USER}/$${POSTGRES_DB}, $$-escaped so the container env applies)',
);
assert.ok(hc.interval, 'the db healthcheck must declare an interval so readiness is re-probed');
assert.ok(
Number(hc.retries) >= 1,
'the db healthcheck must declare retries so a booting database is not failed instantly',
); );
} }
@@ -287,6 +327,20 @@ test('the application service is defined so "docker compose up -d" starts the ap
assertAppService(parseYaml(read(COMPOSE_PATH))); assertAppService(parseYaml(read(COMPOSE_PATH)));
}); });
test('PostgreSQL health check gates application start (db declares a pg_isready healthcheck)', () => {
assertDbHealthcheck(parseYaml(read(COMPOSE_PATH)));
});
test('the app waits for the database before starting (depends_on db with condition service_healthy)', () => {
const app = parseYaml(read(COMPOSE_PATH)).services?.app;
assert.ok(app, 'compose.yaml must declare an "app" service');
assert.equal(
app.depends_on?.db?.condition,
'service_healthy',
'app must depend on db with condition: service_healthy so it starts only after PostgreSQL is healthy',
);
});
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);
@@ -368,6 +422,14 @@ test('docker compose up -d starts the database and application containers', { sk
/running|up/i, /running|up/i,
`the "db" container must be running after "docker compose up -d" (state: "${dbState}")`, `the "db" container must be running after "docker compose up -d" (state: "${dbState}")`,
); );
const dbHealth = String(field(db, 'Health', 'health') ?? '');
if (dbHealth) {
assert.match(
dbHealth,
/healthy/i,
`the "db" container must be healthy before the app starts (health: "${dbHealth}")`,
);
}
const app = containers.find((c) => field(c, 'Service', 'service') === 'app'); const app = containers.find((c) => field(c, 'Service', 'service') === 'app');
assert.ok(app, 'docker compose up -d must create the "app" container'); assert.ok(app, 'docker compose up -d must create the "app" container');
@@ -400,18 +462,25 @@ services:
POSTGRES_DB: eppp POSTGRES_DB: eppp
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \$\${POSTGRES_USER} -d \$\${POSTGRES_DB}"]
interval: 5s
retries: 5
app: app:
build: build:
context: . context: .
dockerfile: apps/server/Dockerfile dockerfile: apps/server/Dockerfile
depends_on: depends_on:
- db db:
condition: service_healthy
`); `);
assert.equal(parsed.services.db.image, 'postgres:18-bookworm'); assert.equal(parsed.services.db.image, 'postgres:18-bookworm');
assert.equal(parsed.services.db.environment.POSTGRES_DB, 'eppp'); assert.equal(parsed.services.db.environment.POSTGRES_DB, 'eppp');
assert.deepEqual(parsed.services.db.ports, ['5432:5432']); assert.deepEqual(parsed.services.db.ports, ['5432:5432']);
assert.match(parsed.services.db.healthcheck.test, /pg_isready/);
assert.equal(parsed.services.db.healthcheck.retries, '5');
assert.equal(parsed.services.app.build.dockerfile, 'apps/server/Dockerfile'); assert.equal(parsed.services.app.build.dockerfile, 'apps/server/Dockerfile');
assert.deepEqual(parsed.services.app.depends_on, ['db']); assert.equal(parsed.services.app.depends_on.db.condition, 'service_healthy');
}); });
test('removing the db service makes the database criterion fail (mutation probe)', () => { test('removing the db service makes the database criterion fail (mutation probe)', () => {
@@ -430,6 +499,32 @@ test('removing the app service makes the application criterion fail (mutation pr
assert.throws(() => assertAppService(compose), /"app" service/); assert.throws(() => assertAppService(compose), /"app" service/);
}); });
test('removing the db healthcheck makes the health-gate criterion fail (mutation probe)', () => {
const text = read(COMPOSE_PATH);
const withoutHealthcheck = text.replace(/^ healthcheck:\n(?: .*\n?)*/m, '');
assert.notEqual(withoutHealthcheck, text, 'the mutation must actually remove the db healthcheck block');
const compose = parseYaml(withoutHealthcheck);
assert.throws(() => assertDbHealthcheck(compose), /healthcheck/);
});
test('reverting depends_on to a plain list breaks the healthy-gate criterion (mutation probe)', () => {
const text = read(COMPOSE_PATH);
const withoutGate = text.replace(
/^ depends_on:\n db:\n condition: service_healthy\n/m,
' depends_on:\n - db\n',
);
assert.notEqual(withoutGate, text, 'the mutation must actually replace the healthy-conditioned depends_on');
const compose = parseYaml(withoutGate);
assert.throws(() => {
const app = compose.services?.app;
assert.equal(
app?.depends_on?.db?.condition,
'service_healthy',
'app must depend on db with condition: service_healthy',
);
}, /service_healthy/);
});
test('a Dockerfile without the runtime entrypoint fails the image criterion (mutation probe)', () => { test('a Dockerfile without the runtime entrypoint fails the image criterion (mutation probe)', () => {
const dockerfile = read(DOCKERFILE_PATH); const dockerfile = read(DOCKERFILE_PATH);
const withoutCmd = dockerfile.replace(/CMD \[[^\]]*\]/g, ''); const withoutCmd = dockerfile.replace(/CMD \[[^\]]*\]/g, '');