feat: PostgreSQL health gates app start (E00-S02-T02)
CI / Frozen lockfile install (pull_request) Successful in 47s
CI / Frozen lockfile install (pull_request) Successful in 47s
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
|
# `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
@@ -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, '');
|
||||||
|
|||||||
Reference in New Issue
Block a user