From 3be575818dc2b7ba0052fe26ef7882534482b25d Mon Sep 17 00:00:00 2001 From: implementer Date: Fri, 28 Aug 2026 23:35:05 +0000 Subject: [PATCH 1/2] feat: docker compose up -d starts DB + app (E00-S02-T01) --- .dockerignore | 18 ++ apps/server/Dockerfile | 48 ++++ compose.yaml | 33 +++ tests/compose-config.test.mjs | 440 ++++++++++++++++++++++++++++++++++ 4 files changed, 539 insertions(+) create mode 100644 .dockerignore create mode 100644 apps/server/Dockerfile create mode 100644 compose.yaml create mode 100644 tests/compose-config.test.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..95e1211 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# VCS +.git +.gitignore + +# Dependencies +node_modules +.pnpm-store + +# Build output +dist +coverage + +# Local environment files (a committed .env.example lands in E00-S04) +.env +.env.* + +# Logs +*.log diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 0000000..733c9b7 --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 + +# @personal-blog/server — EPPP public server application image. +# +# [E00-S02-T01] baseline: builds the workspace server package with the pinned +# toolchain (Node 24 + pnpm 11.23.0, frozen lockfile) and runs the compiled +# entrypoint. The server is still a bootstrap placeholder (its module loads and +# exits cleanly); the Fastify 5 application shell that turns it into a serving +# process lands in a later story, and the health gate (T02), health endpoint +# (T03), volume persistence (T04), non-root/read-only hardening and multi-arch +# targets are later E00-S02 tasks — all out of scope here. + +# --- build stage: install the frozen workspace and compile the server -------- +FROM node:24-alpine AS build +WORKDIR /app + +# Enable the pinned pnpm (11.23.0, via packageManager in the root package.json) +# with Corepack, which ships with the Node image. +RUN corepack enable + +# Copy only the manifests needed for resolution first, so source edits do not +# invalidate the dependency layer, then install against the committed lockfile +# (the same `--frozen-lockfile` path CI and developers use). Every workspace +# package manifest is copied so the in-image workspace matches the lockfile +# importers exactly (apps/server, packages/core, extensions/example). +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/server/package.json apps/server/package.json +COPY packages/core/package.json packages/core/package.json +COPY extensions/example/package.json extensions/example/package.json +RUN pnpm install --frozen-lockfile + +# Compile the server package (tsc -p apps/server/tsconfig.json -> dist/). +COPY apps/server apps/server +RUN pnpm --filter @personal-blog/server build + +# --- runtime stage: Node 24 + compiled output only ---------------------------- +FROM node:24-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production + +# The workspace install (devDependencies included — image-size pruning is a +# later E00-S02 concern) plus the compiled server output and manifest. +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/apps/server/dist ./apps/server/dist +COPY --from=build /app/apps/server/package.json ./apps/server/package.json + +EXPOSE 3000 +CMD ["node", "apps/server/dist/index.js"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..b4a3d3e --- /dev/null +++ b/compose.yaml @@ -0,0 +1,33 @@ +# EPPP Docker Compose baseline — [E00-S02-T01] +# +# `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) +# - application health endpoint (T03) +# - DB volume persistence (T04) +# +# 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). + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-eppp} + POSTGRES_USER: ${POSTGRES_USER:-eppp} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-eppp} + ports: + - "${POSTGRES_PORT:-5432}:5432" + + app: + build: + context: . + dockerfile: apps/server/Dockerfile + environment: + DATABASE_URL: postgres://eppp:eppp@db:5432/eppp + ports: + - "${APP_PORT:-3000}:3000" + depends_on: + - db diff --git a/tests/compose-config.test.mjs b/tests/compose-config.test.mjs new file mode 100644 index 0000000..55e6c63 --- /dev/null +++ b/tests/compose-config.test.mjs @@ -0,0 +1,440 @@ +/** + * Docker Compose baseline test — locks in the [E00-S02-T01] `docker compose + * up -d` DB + app baseline for the workspace. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "docker compose up -d starts the database" → the committed + * `compose.yaml` declares a `db` service backed by a PostgreSQL image + * with the credentials the app expects and a published default port, so + * `docker compose up -d` creates and starts the database container. When + * 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. + * - "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 + 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` + * container is created and starts cleanly (exit 0 when the placeholder + * process exits). + * + * Run: `node --test tests/compose-config.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The committed Compose file and app image definition under test. */ +const COMPOSE_PATH = 'compose.yaml'; +const DOCKERFILE_PATH = 'apps/server/Dockerfile'; +const DOCKERIGNORE_PATH = '.dockerignore'; + +// --------------------------------------------------------------------------- +// Minimal block-YAML parser (no dependencies, lockfile untouched) +// --------------------------------------------------------------------------- +// +// Covers exactly the subset the committed compose.yaml uses: nested block +// mappings, block sequences of scalars, plain / single-quoted / double-quoted +// scalars and `#` comments. Anything else (flow collections, anchors/aliases, +// `|`/`>` block scalars, merge keys) is rejected loudly so the parser can +// never silently misread the structure it locks in. + +/** Drops a `#` comment that is not inside a quoted scalar. */ +function stripComment(line) { + let quote = null; + for (let i = 0; i < line.length; i += 1) { + const ch = line[i]; + if (quote) { + if (ch === quote) quote = null; + } else if (ch === "'" || ch === '"') { + quote = ch; + } else if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) { + return line.slice(0, i).trimEnd(); + } + } + return line.trimEnd(); +} + +/** Unquotes a plain / single-quoted / double-quoted scalar. */ +function scalarValue(raw) { + if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1); + if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) { + return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + return raw; +} + +/** + * Parses the supported block-YAML subset into plain JS objects/arrays. + * Throws on any construct the committed file does not use. + */ +function parseYaml(text) { + const lines = []; + for (const raw of text.split(/\r?\n/)) { + const expanded = raw.replace(/\t/g, ' '); + if (!expanded.trim() || expanded.trim().startsWith('#')) continue; + const indent = expanded.length - expanded.trimStart().length; + const content = stripComment(expanded.trimStart()).trim(); + if (!content) continue; + lines.push({ indent, content }); + } + + let pos = 0; + + const parseBlock = (indent) => { + const node = {}; + while (pos < lines.length && lines[pos].indent >= indent) { + if (lines[pos].indent > indent) { + throw new Error(`unexpected indentation at "${lines[pos].content}"`); + } + const { content } = lines[pos]; + const match = /^([^:#][^:]*):(?:\s+(.*))?$/.exec(content); + if (!match) { + throw new Error(`expected "key: value", got "${content}"`); + } + const key = scalarValue(match[1].trim()); + const rest = match[2] === undefined ? undefined : match[2].trim(); + pos += 1; + if (rest === undefined || rest === '') { + if (pos < lines.length && lines[pos].indent > indent) { + if (lines[pos].content.startsWith('-')) { + node[key] = parseSequence(lines[pos].indent); + } else { + node[key] = parseBlock(lines[pos].indent); + } + } else { + node[key] = null; + } + } else { + node[key] = scalarValue(rest); + } + } + return node; + }; + + const parseSequence = (indent) => { + const items = []; + while (pos < lines.length && lines[pos].indent >= indent) { + if (lines[pos].indent > indent) { + throw new Error(`unexpected indentation in sequence at "${lines[pos].content}"`); + } + const { content } = lines[pos]; + if (!content.startsWith('-')) break; + const rest = content.slice(1).trim(); + if (!rest) throw new Error(`empty sequence item at "-"`); + items.push(scalarValue(rest)); + pos += 1; + } + return items; + }; + + if (lines.length === 0) return {}; + return parseBlock(0); +} + +// --------------------------------------------------------------------------- +// Compose structure assertions (shared by the tests and the mutation probes) +// --------------------------------------------------------------------------- + +/** + * Asserts the committed compose.yaml declares a `db` service that + * `docker compose up -d` can start: a PostgreSQL image, the credentials the + * app expects, and a published default port. + */ +function assertDbService(compose) { + assert.ok(compose.services, 'compose.yaml must declare a top-level "services" map'); + const db = compose.services.db; + assert.ok(db, 'compose.yaml must declare a "db" service (docker compose up -d starts the database)'); + assert.equal( + db.image, + 'postgres:16-alpine', + 'the "db" service must use the committed PostgreSQL image (postgres:16-alpine)', + ); + const env = db.environment ?? {}; + assert.equal(env.POSTGRES_DB, '${POSTGRES_DB:-eppp}', 'db must set POSTGRES_DB (with a default)'); + assert.equal(env.POSTGRES_USER, '${POSTGRES_USER:-eppp}', 'db must set POSTGRES_USER (with a default)'); + assert.ok( + typeof env.POSTGRES_PASSWORD === 'string' && env.POSTGRES_PASSWORD.length > 0, + 'db must set POSTGRES_PASSWORD (with a default)', + ); + assert.ok( + Array.isArray(db.ports) && db.ports.some((p) => p.endsWith(':5432')), + 'db must publish the PostgreSQL port (a mapping ending in ":5432")', + ); +} + +/** + * 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). + */ +function assertAppService(compose) { + const app = compose.services?.app; + assert.ok(app, 'compose.yaml must declare an "app" service (docker compose up -d starts the application)'); + assert.equal( + app.build?.context, + '.', + 'the "app" service must build from the repository root context (build.context: ".")', + ); + assert.equal( + app.build?.dockerfile, + DOCKERFILE_PATH, + `the "app" service must build the committed ${DOCKERFILE_PATH} image`, + ); + const env = app.environment ?? {}; + assert.ok( + typeof env.DATABASE_URL === 'string' && /@db:5432\//.test(env.DATABASE_URL), + 'app must set DATABASE_URL pointing at the db service host (postgres://…@db:5432/…)', + ); + assert.ok( + Array.isArray(app.ports) && app.ports.some((p) => p.endsWith(':3000')), + 'app must publish the application port (a mapping ending in ":3000")', + ); + 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', + ); +} + +// --------------------------------------------------------------------------- +// Docker probe helpers (integration tests skip cleanly without Docker) +// --------------------------------------------------------------------------- + +function run(cmd, args, opts = {}) { + return spawnSync(cmd, args, { + encoding: 'utf8', + timeout: 600_000, + ...opts, + }); +} + +/** True when the `docker` CLI with the Compose plugin is on PATH. */ +function dockerComposeAvailable() { + try { + return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0; + } catch { + return false; + } +} + +/** True when a reachable Docker daemon exists. */ +function dockerDaemonAvailable() { + try { + return run('docker', ['info'], { timeout: 15_000 }).status === 0; + } catch { + return false; + } +} + +/** Parses `docker compose ps --format json` (JSON array or one object per line). */ +function parsePsJson(stdout) { + const text = String(stdout).trim(); + if (!text) return []; + try { + const parsed = JSON.parse(text); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); + } +} + +/** Tolerant field lookup across compose ps JSON shapes. */ +function field(container, ...names) { + for (const name of names) { + if (container[name] !== undefined) return container[name]; + } + return undefined; +} + +const DOCKER_COMPOSE = dockerComposeAvailable(); +const DOCKER_DAEMON = dockerDaemonAvailable(); + +// --------------------------------------------------------------------------- +// Tests — the committed state that makes `docker compose up -d` work +// --------------------------------------------------------------------------- + +test('compose.yaml exists, parses, and declares exactly the db and app services', () => { + assert.ok(existsSync(path.join(REPO_ROOT, COMPOSE_PATH)), `committed ${COMPOSE_PATH} must exist`); + const compose = parseYaml(read(COMPOSE_PATH)); + assert.deepEqual( + Object.keys(compose.services ?? {}).sort(), + ['app', 'db'], + 'compose.yaml must declare exactly the "db" and "app" services at T01', + ); +}); + +test('the database service is defined so "docker compose up -d" starts the database', () => { + assertDbService(parseYaml(read(COMPOSE_PATH))); +}); + +test('the application service is defined so "docker compose up -d" starts the application', () => { + assertAppService(parseYaml(read(COMPOSE_PATH))); +}); + +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); + assert.match( + dockerfile, + /FROM node:24-alpine AS build/, + 'the Dockerfile must build on the committed Node 24 line (FROM node:24-alpine AS build)', + ); + assert.match( + dockerfile, + /FROM node:24-alpine AS runtime/, + 'the Dockerfile must ship a slim Node 24 runtime stage (FROM node:24-alpine AS runtime)', + ); + assert.match( + dockerfile, + /pnpm install --frozen-lockfile/, + 'the Dockerfile must install with the frozen lockfile (reproducible builds)', + ); + assert.match( + dockerfile, + /pnpm --filter @personal-blog\/server build/, + 'the Dockerfile must compile the server package (pnpm --filter @personal-blog/server build)', + ); + assert.match( + dockerfile, + /node\b[^\n]*apps\/server\/dist\/index\.js/, + 'the Dockerfile runtime stage must run the compiled server entrypoint (node apps/server/dist/index.js)', + ); +}); + +test('the build context excludes local artifacts and environment files', () => { + assert.ok( + existsSync(path.join(REPO_ROOT, DOCKERIGNORE_PATH)), + `committed ${DOCKERIGNORE_PATH} must exist so local artifacts stay out of the build context`, + ); + const patterns = read(DOCKERIGNORE_PATH) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + for (const required of ['node_modules', 'dist', '.env', '.git']) { + assert.ok( + patterns.includes(required), + `.dockerignore must exclude "${required}" (got: ${patterns.join(', ')})`, + ); + } +}); + +// --------------------------------------------------------------------------- +// Real-stack probes — run the actual acceptance command when Docker is present +// --------------------------------------------------------------------------- + +test('the committed compose.yaml validates against the Compose spec (docker compose config)', { skip: !DOCKER_COMPOSE }, () => { + const result = run('docker', ['compose', 'config', '--quiet'], { cwd: REPO_ROOT }); + assert.equal( + result.status, + 0, + `"docker compose config" must exit 0 for the committed ${COMPOSE_PATH}:\n` + + `${(result.stdout || '')}\n${(result.stderr || '')}`.trim(), + ); +}); + +test('docker compose up -d starts the database and application containers', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, (t) => { + 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 { + const ps = run('docker', ['compose', 'ps', '-a', '--format', 'json'], { cwd: REPO_ROOT }); + assert.equal(ps.status, 0, `"docker compose ps" must exit 0:\n${(ps.stderr || ps.stdout || '').trim()}`); + const containers = parsePsJson(ps.stdout); + + const db = containers.find((c) => field(c, 'Service', 'service') === 'db'); + assert.ok(db, 'docker compose up -d must create the "db" container'); + const dbState = String(field(db, 'State', 'state') ?? ''); + assert.match( + dbState, + /running|up/i, + `the "db" container must be running after "docker compose up -d" (state: "${dbState}")`, + ); + + const app = containers.find((c) => field(c, 'Service', 'service') === 'app'); + assert.ok(app, 'docker compose up -d must create the "app" container'); + const appState = String(field(app, 'State', 'state') ?? ''); + const appExit = Number(field(app, 'ExitCode', 'exit_code', 'exitCode')); + if (/exited/i.test(appState)) { + assert.equal( + appExit, + 0, + `the "app" container exited non-zero (exit ${appExit}) — the server entrypoint must start cleanly`, + ); + } else { + assert.match(appState, /running|up/i, `the "app" container must start after "docker compose up -d" (state: "${appState}")`); + } + } finally { + run('docker', ['compose', 'down'], { cwd: REPO_ROOT }); + } +}); + +// --------------------------------------------------------------------------- +// Non-vacuous probes — the assertions above really do fail on violations +// --------------------------------------------------------------------------- + +test('the YAML parser reads the committed structure (non-vacuous parser probe)', () => { + const parsed = parseYaml(` +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: eppp + ports: + - "5432:5432" + app: + build: + context: . + dockerfile: apps/server/Dockerfile + depends_on: + - db +`); + assert.equal(parsed.services.db.image, 'postgres:16-alpine'); + assert.equal(parsed.services.db.environment.POSTGRES_DB, 'eppp'); + assert.deepEqual(parsed.services.db.ports, ['5432:5432']); + assert.equal(parsed.services.app.build.dockerfile, 'apps/server/Dockerfile'); + assert.deepEqual(parsed.services.app.depends_on, ['db']); +}); + +test('removing the db service makes the database criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const withoutDb = text.replace(/^ db:\n(?: .*\n?)*/m, ''); + assert.notEqual(withoutDb, text, 'the mutation must actually remove the db service block'); + const compose = parseYaml(withoutDb); + assert.throws(() => assertDbService(compose), /"db" service/); +}); + +test('removing the app service makes the application criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const withoutApp = text.replace(/^ app:\n(?: .*\n?)*/m, ''); + assert.notEqual(withoutApp, text, 'the mutation must actually remove the app service block'); + const compose = parseYaml(withoutApp); + assert.throws(() => assertAppService(compose), /"app" service/); +}); + +test('a Dockerfile without the runtime entrypoint fails the image criterion (mutation probe)', () => { + const dockerfile = read(DOCKERFILE_PATH); + const withoutCmd = dockerfile.replace(/CMD \[[^\]]*\]/g, ''); + assert.notEqual(withoutCmd, dockerfile, 'the mutation must actually remove the CMD'); + const asserts = () => { + assert.match(withoutCmd, /node\s+apps\/server\/dist\/index\.js/, 'must run the compiled entrypoint'); + }; + assert.throws(asserts, /compiled entrypoint/); +}); From 3bbea68e6ce86067dbac179650720ead04daca07 Mon Sep 17 00:00:00 2001 From: implementer Date: Fri, 28 Aug 2026 23:45:18 +0000 Subject: [PATCH 2/2] fix: align app/db image bases with documented stack (E00-S02-T01) Switch the app image from node:24-alpine to node:24.19.0-bookworm-slim in both build and runtime stages (Technology-Stack 5.4: glibc Debian base required because argon2 is a native dependency; musl/Alpine causes native-module build surprises), and the db image from postgres:16-alpine to postgres:18-bookworm (Technology-Stack 5.2/5.4/6.2 + golden tuple 7 pin PostgreSQL 18.6; 18-bookworm is the 18.x line on Debian bookworm). Update tests/compose-config.test.mjs so the committed assertions lock in the corrected image bases (db image, Dockerfile build/runtime stages, parser probe). --- apps/server/Dockerfile | 12 ++++++++---- compose.yaml | 4 +++- tests/compose-config.test.mjs | 21 +++++++++++---------- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 733c9b7..3b2c97c 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -3,15 +3,19 @@ # @personal-blog/server — EPPP public server application image. # # [E00-S02-T01] baseline: builds the workspace server package with the pinned -# toolchain (Node 24 + pnpm 11.23.0, frozen lockfile) and runs the compiled +# toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the compiled # entrypoint. The server is still a bootstrap placeholder (its module loads and # exits cleanly); the Fastify 5 application shell that turns it into a serving # process lands in a later story, and the health gate (T02), health endpoint # (T03), volume persistence (T04), non-root/read-only hardening and multi-arch # targets are later E00-S02 tasks — all out of scope here. +# +# 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 +# build surprises, so the image must stay on a glibc base. # --- build stage: install the frozen workspace and compile the server -------- -FROM node:24-alpine AS build +FROM node:24.19.0-bookworm-slim AS build WORKDIR /app # Enable the pinned pnpm (11.23.0, via packageManager in the root package.json) @@ -33,8 +37,8 @@ RUN pnpm install --frozen-lockfile COPY apps/server apps/server RUN pnpm --filter @personal-blog/server build -# --- runtime stage: Node 24 + compiled output only ---------------------------- -FROM node:24-alpine AS runtime +# --- runtime stage: Node 24.19.0 (bookworm-slim) + compiled output only ------ +FROM node:24.19.0-bookworm-slim AS runtime WORKDIR /app ENV NODE_ENV=production diff --git a/compose.yaml b/compose.yaml index b4a3d3e..327c055 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,7 +13,9 @@ services: db: - image: postgres:16-alpine + # PostgreSQL 18 on Debian bookworm — the documented runtime target + # (Technology-Stack §5.2/§5.4/§6.2, golden tuple §7; no Alpine drift). + image: postgres:18-bookworm environment: POSTGRES_DB: ${POSTGRES_DB:-eppp} POSTGRES_USER: ${POSTGRES_USER:-eppp} diff --git a/tests/compose-config.test.mjs b/tests/compose-config.test.mjs index 55e6c63..10f8cc9 100644 --- a/tests/compose-config.test.mjs +++ b/tests/compose-config.test.mjs @@ -13,8 +13,9 @@ * container is up. * - "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 + frozen pnpm install → - * `tsc` build of `@personal-blog/server` → `node apps/server/dist/index.js`), + * `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` * container is created and starts cleanly (exit 0 when the placeholder @@ -158,8 +159,8 @@ function assertDbService(compose) { assert.ok(db, 'compose.yaml must declare a "db" service (docker compose up -d starts the database)'); assert.equal( db.image, - 'postgres:16-alpine', - 'the "db" service must use the committed PostgreSQL image (postgres:16-alpine)', + 'postgres:18-bookworm', + 'the "db" service must use the committed PostgreSQL 18 image (postgres:18-bookworm)', ); const env = db.environment ?? {}; assert.equal(env.POSTGRES_DB, '${POSTGRES_DB:-eppp}', 'db must set POSTGRES_DB (with a default)'); @@ -291,13 +292,13 @@ test('the application image is defined by a committed multi-stage Dockerfile', ( const dockerfile = read(DOCKERFILE_PATH); assert.match( dockerfile, - /FROM node:24-alpine AS build/, - 'the Dockerfile must build on the committed Node 24 line (FROM node:24-alpine AS build)', + /FROM node:24\.19\.0-bookworm-slim AS build/, + 'the Dockerfile must build on the committed Node 24.19.0 bookworm-slim base (FROM node:24.19.0-bookworm-slim AS build)', ); assert.match( dockerfile, - /FROM node:24-alpine AS runtime/, - 'the Dockerfile must ship a slim Node 24 runtime stage (FROM node:24-alpine AS runtime)', + /FROM node:24\.19\.0-bookworm-slim AS runtime/, + 'the Dockerfile must ship a Node 24.19.0 bookworm-slim runtime stage (FROM node:24.19.0-bookworm-slim AS runtime)', ); assert.match( dockerfile, @@ -394,7 +395,7 @@ test('the YAML parser reads the committed structure (non-vacuous parser probe)', const parsed = parseYaml(` services: db: - image: postgres:16-alpine + image: postgres:18-bookworm environment: POSTGRES_DB: eppp ports: @@ -406,7 +407,7 @@ services: depends_on: - db `); - assert.equal(parsed.services.db.image, 'postgres:16-alpine'); + 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.equal(parsed.services.app.build.dockerfile, 'apps/server/Dockerfile');