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
CI / Frozen lockfile install (push) Successful in 41s
This commit was merged in pull request #383.
This commit is contained in:
+20
-4
@@ -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
|
||||
# application (@personal-blog/server). Rollback: `docker compose down`.
|
||||
#
|
||||
# Explicitly out of scope for T01 (land in later E00-S02 tasks):
|
||||
# - PostgreSQL health gate (T02)
|
||||
# PostgreSQL health gate (T02): the `db` service carries a `pg_isready`
|
||||
# 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)
|
||||
# - DB volume persistence (T04)
|
||||
#
|
||||
@@ -22,6 +26,15 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-eppp}
|
||||
ports:
|
||||
- "${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:
|
||||
build:
|
||||
@@ -31,5 +44,8 @@ services:
|
||||
DATABASE_URL: postgres://eppp:eppp@db:5432/eppp
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
# T02: start only once the database reports healthy (service_healthy), so
|
||||
# the application waits for PostgreSQL before starting.
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
+105
-10
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Docker Compose baseline test — locks in the [E00-S02-T01] `docker compose
|
||||
* up -d` DB + app baseline for the workspace.
|
||||
* Docker Compose baseline test — locks in the [E00-S02-T01/T02] `docker
|
||||
* compose up -d` DB + app baseline and the PostgreSQL health gate for the
|
||||
* workspace.
|
||||
*
|
||||
* Acceptance criteria covered (each test fails without the committed state):
|
||||
* - "docker compose up -d starts the database" → the committed
|
||||
@@ -10,16 +11,25 @@
|
||||
* a Docker daemon + Compose plugin are available (CI/dev machines), the
|
||||
* suite additionally runs the real stack (`docker compose up -d` →
|
||||
* `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
|
||||
* `compose.yaml` declares an `app` service built from the committed
|
||||
* `apps/server/Dockerfile` (multi-stage: Node 24.19.0 bookworm-slim +
|
||||
* frozen pnpm install → `tsc` build of `@personal-blog/server` →
|
||||
* `node apps/server/dist/index.js`),
|
||||
* with a published default port and `depends_on: db` so the application
|
||||
* starts after the database. The real-stack probe asserts the `app`
|
||||
* with a published default port. The real-stack probe asserts the `app`
|
||||
* container is created and starts cleanly (exit 0 when the placeholder
|
||||
* 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`
|
||||
* (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
|
||||
* `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) {
|
||||
const app = compose.services?.app;
|
||||
@@ -202,9 +213,38 @@ function assertAppService(compose) {
|
||||
Array.isArray(app.ports) && app.ports.some((p) => p.endsWith(':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(
|
||||
Array.isArray(app.depends_on) && app.depends_on.includes('db'),
|
||||
'app must declare depends_on: [db] so the application starts after the database',
|
||||
(hc.test ?? '').includes('$${POSTGRES_USER}') && (hc.test ?? '').includes('$${POSTGRES_DB}'),
|
||||
'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)));
|
||||
});
|
||||
|
||||
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', () => {
|
||||
assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`);
|
||||
const dockerfile = read(DOCKERFILE_PATH);
|
||||
@@ -368,6 +422,14 @@ test('docker compose up -d starts the database and application containers', { sk
|
||||
/running|up/i,
|
||||
`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');
|
||||
assert.ok(app, 'docker compose up -d must create the "app" container');
|
||||
@@ -400,18 +462,25 @@ services:
|
||||
POSTGRES_DB: eppp
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \$\${POSTGRES_USER} -d \$\${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
retries: 5
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/server/Dockerfile
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
condition: service_healthy
|
||||
`);
|
||||
assert.equal(parsed.services.db.image, 'postgres:18-bookworm');
|
||||
assert.equal(parsed.services.db.environment.POSTGRES_DB, 'eppp');
|
||||
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.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)', () => {
|
||||
@@ -430,6 +499,32 @@ test('removing the app service makes the application criterion fail (mutation pr
|
||||
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)', () => {
|
||||
const dockerfile = read(DOCKERFILE_PATH);
|
||||
const withoutCmd = dockerfile.replace(/CMD \[[^\]]*\]/g, '');
|
||||
|
||||
Reference in New Issue
Block a user