feat: docker compose up -d starts DB + app (E00-S02-T01)
CI / Frozen lockfile install (pull_request) Successful in 43s
CI / Frozen lockfile install (pull_request) Successful in 43s
This commit is contained in:
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user