diff --git a/compose.yaml b/compose.yaml index 327c055..bad9a35 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/tests/compose-config.test.mjs b/tests/compose-config.test.mjs index 10f8cc9..a4cca8a 100644 --- a/tests/compose-config.test.mjs +++ b/tests/compose-config.test.mjs @@ -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, '');