Merge pull request '[E00-S02-T01] docker compose up -d starts DB + app' (#382) from feature/168 into main
CI / Frozen lockfile install (push) Successful in 56s

This commit was merged in pull request #382.
This commit is contained in:
2026-08-28 23:55:18 +00:00
4 changed files with 546 additions and 0 deletions
+18
View File
@@ -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
+52
View File
@@ -0,0 +1,52 @@
# 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.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.19.0-bookworm-slim 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.19.0 (bookworm-slim) + compiled output only ------
FROM node:24.19.0-bookworm-slim 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"]
+35
View File
@@ -0,0 +1,35 @@
# 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:
# 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}
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
+441
View File
@@ -0,0 +1,441 @@
/**
* 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.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
* 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: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)');
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\.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\.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,
/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:18-bookworm
environment:
POSTGRES_DB: eppp
ports:
- "5432:5432"
app:
build:
context: .
dockerfile: apps/server/Dockerfile
depends_on:
- db
`);
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');
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/);
});