diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index a42ccb8..8b99809 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -8,10 +8,11 @@ # server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port # 3000, so the app container stays up and the health endpoint succeeds. The # Fastify 5 application shell (and the real HTTP API) lands in a later story; -# DB volume persistence (T04) is a Compose-level concern (see compose.yaml — -# this image is unchanged), while read-only root filesystem (T06) and -# multi-arch build targets (T07) remain later E00-S02 tasks — all out of scope -# here. Since T05 the runtime stage drops root privileges (runs as the +# DB volume persistence (T04) and read-only root filesystem (T06) are +# Compose-level concerns (see compose.yaml — the `db-data` volume mount and the +# app service's `read_only: true` + `/tmp` tmpfs; this image is unchanged), +# while multi-arch build targets (T07) remains a later E00-S02 task — out of +# scope here. Since T05 the runtime stage drops root privileges (runs as the # image's non-root `node` user). # # Image base: node:24.19.0-bookworm-slim (glibc Debian) per Technology-Stack diff --git a/compose.yaml b/compose.yaml index 4cb33c8..a703515 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,4 @@ -# EPPP Docker Compose baseline — [E00-S02-T01..T05] +# EPPP Docker Compose baseline — [E00-S02-T01..T06] # # `docker compose up -d` starts both the database (PostgreSQL) and the # application (@personal-blog/server). Rollback: `docker compose down`. @@ -23,8 +23,15 @@ # so the app container does not run with root privileges. No Compose-level # `user:` override is needed: the image's USER is inherited by the container. # -# Explicitly out of scope for T01..T05 (land in later E00-S02 tasks): -# - read-only root filesystem (T06), multi-arch build targets (T07) +# Read-only root filesystem (T06): the `app` service sets `read_only: true`, so +# the container's root filesystem is mounted read-only — a write anywhere on it +# is denied. Writable paths are limited to declared mounts and tmpfs: the app +# declares a `tmpfs` at `/tmp` and no writable volume/bind mounts, so `/tmp` is +# the only writable path. Rollback: drop `read_only`/`tmpfs` from the `app` +# service. +# +# Explicitly out of scope for T01..T06 (land in later E00-S02 tasks): +# - multi-arch build targets (T07), secrets not embedded (T08) # # 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). @@ -68,6 +75,14 @@ services: depends_on: db: condition: service_healthy + # T06: read-only root filesystem — the container's root filesystem is + # mounted read-only (`read_only: true`), so writes are denied everywhere + # except the declared mounts/tmpfs below. The app writes nothing else, so + # the only writable path is the declared `tmpfs` at `/tmp` (no writable + # volumes or bind mounts on this service). + read_only: true + tmpfs: + - /tmp # Named volumes shared across `docker compose` lifecycles. `db-data` (T04) # holds the PostgreSQL data directory and is preserved across restart and diff --git a/tests/readonly-rootfs.test.mjs b/tests/readonly-rootfs.test.mjs new file mode 100644 index 0000000..fdce83d --- /dev/null +++ b/tests/readonly-rootfs.test.mjs @@ -0,0 +1,339 @@ +/** + * App read-only root filesystem test — locks in the [E00-S02-T06] read-only + * app container root filesystem for the workspace. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "app root filesystem is read-only" → the committed `compose.yaml` `app` + * service declares `read_only: true`, so the container root filesystem is + * mounted read-only and any write to it is denied. A static assertion + * requires the flag; mutation probes removing it (or flipping it to + * false) fail; the Docker-gated real-stack probe additionally proves it + * on a live container — `docker inspect` reports `ReadonlyRootfs: true` + * and `touch /…` on the root filesystem is denied. + * - "writable paths are limited to declared mounts and tmpfs" → the `app` + * service declares its writable paths explicitly: a `tmpfs` at `/tmp` and + * no writable volume/bind mounts (any mount on the app service must be + * read-only, `:ro`), so with the root filesystem read-only the declared + * `/tmp` tmpfs is the only writable path. Static assertions require the + * `tmpfs` entry and reject writable mounts (mutation probes cover both + * directions); the real-stack probe writes to `/tmp` (succeeds — the + * declared tmpfs is writable) and re-checks the health endpoint answers + * HTTP 200 as a regression guard (read-only + tmpfs must not break + * startup). + * + * Run: `node --test tests/readonly-rootfs.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 under test. */ +const COMPOSE_PATH = 'compose.yaml'; + +// --------------------------------------------------------------------------- +// Compose structure helpers (block-scoped to the app service) +// --------------------------------------------------------------------------- + +/** + * Extracts the committed `app` service block from compose.yaml — every line + * from the `app:` key (indent 2) to the next line with a smaller indent. All + * T06 assertions are scoped to this block so a `read_only`/`tmpfs` elsewhere + * (e.g. on the `db` service) can never satisfy them. + */ +function appServiceBlock(composeText) { + const lines = composeText.split(/\r?\n/); + const start = lines.findIndex((line) => /^ app:\s*$/.test(line)); + assert.notEqual(start, -1, 'compose.yaml must declare an "app" service (indent-2 "app:" key)'); + const block = []; + for (let i = start + 1; i < lines.length && /^ /.test(lines[i]); i += 1) { + block.push(lines[i]); + } + return block.join('\n'); +} + +/** + * Asserts the app service declares `read_only: true`, so the container root + * filesystem is mounted read-only ("app root filesystem is read-only"). + */ +function assertReadOnlyRootfs(composeText) { + const block = appServiceBlock(composeText); + assert.match( + block, + /^ read_only:\s*true\s*$/m, + 'the "app" service must declare "read_only: true" so the container root filesystem is read-only', + ); +} + +/** + * Asserts the app service declares its writable paths explicitly: a `tmpfs` + * mount at `/tmp` ("writable paths are limited to declared mounts and + * tmpfs"). With the root filesystem read-only, the declared tmpfs is what the + * app is allowed to write to. + */ +function assertWritableTmpfs(composeText) { + const block = appServiceBlock(composeText); + assert.match( + block, + /^ tmpfs:\s*$/m, + 'the "app" service must declare a tmpfs (writable paths are limited to declared mounts and tmpfs)', + ); + assert.match( + block, + /^ - \/tmp\s*$/m, + 'the "app" service must declare a tmpfs at "/tmp" (the app\'s writable temp path)', + ); +} + +/** + * Asserts the app service declares no *writable* volume/bind mounts: any + * mount on the app service must be read-only (`:ro`), so the declared tmpfs + * is the only writable path. The committed state has no mounts at all. + */ +function assertNoWritableMounts(composeText) { + const block = appServiceBlock(composeText); + const lines = block.split('\n'); + let inVolumes = false; + for (const line of lines) { + if (/^ volumes:\s*$/.test(line)) { + inVolumes = true; + } else if (inVolumes) { + if (/^ /.test(line)) { + assert.match( + line, + /:ro\s*$/, + `a writable mount ("${line.trim()}") on the app service would extend writable paths beyond the declared tmpfs — app mounts must be read-only (:ro)`, + ); + } else { + inVolumes = false; + } + } + } +} + +// --------------------------------------------------------------------------- +// 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; + } +} + +const DOCKER_COMPOSE = dockerComposeAvailable(); +const DOCKER_DAEMON = dockerDaemonAvailable(); + +// --------------------------------------------------------------------------- +// Tests — the committed state that makes the app root filesystem read-only +// --------------------------------------------------------------------------- + +test('compose.yaml exists and the app service declares a read-only root filesystem (read_only: true)', () => { + assert.ok(existsSync(path.join(REPO_ROOT, COMPOSE_PATH)), `committed ${COMPOSE_PATH} must exist`); + assertReadOnlyRootfs(read(COMPOSE_PATH)); +}); + +test('the app service declares a writable tmpfs at /tmp (writable paths are declared)', () => { + assertWritableTmpfs(read(COMPOSE_PATH)); +}); + +test('the app service declares no writable volume/bind mounts (writable paths are limited to the declared tmpfs)', () => { + assertNoWritableMounts(read(COMPOSE_PATH)); +}); + +// --------------------------------------------------------------------------- +// Real-stack probe — run the acceptance sequence when Docker is present +// --------------------------------------------------------------------------- + +test('the running app container has a read-only root filesystem and a writable declared tmpfs (real-stack probe)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, () => { + 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 { + // The app starts only after the db health gate, so exec may fail while + // the stack is still booting — poll until the app accepts exec. + let ready = false; + for (let attempt = 0; attempt < 60 && !ready; attempt += 1) { + const probe = run('docker', ['compose', 'exec', '-T', 'app', 'sh', '-c', 'true'], { + cwd: REPO_ROOT, + timeout: 15_000, + }); + if (probe.status === 0) ready = true; + else run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // still booting — retry + } + assert.ok(ready, 'the app container must accept "docker compose exec" once the stack is up'); + + // Acceptance 1 — "app root filesystem is read-only": Docker reports the + // container was created with a read-only root filesystem, and a write to + // the root filesystem is denied. + const containerId = run('docker', ['compose', 'ps', '-q', 'app'], { cwd: REPO_ROOT, timeout: 15_000 }); + assert.equal( + containerId.status, + 0, + `"docker compose ps -q app" must exit 0:\n${(containerId.stderr || containerId.stdout || '').trim()}`, + ); + const id = containerId.stdout.trim().split('\n')[0]; + assert.ok(id, '"docker compose ps -q app" must return the app container id'); + const inspect = run('docker', ['inspect', '--format', '{{.HostConfig.ReadonlyRootfs}}', id], { + timeout: 15_000, + }); + assert.equal( + inspect.status, + 0, + `"docker inspect" must succeed:\n${(inspect.stdout || '')}\n${(inspect.stderr || '')}`.trim(), + ); + assert.equal( + inspect.stdout.trim(), + 'true', + `the app container must be created with a read-only root filesystem (HostConfig.ReadonlyRootfs must be true; got "${inspect.stdout.trim()}")`, + ); + + const writeRoot = run('docker', ['compose', 'exec', '-T', 'app', 'sh', '-c', + 'if touch /t06-rootfs-probe 2>/dev/null; then echo WROTE; else echo DENIED; fi', + ], { cwd: REPO_ROOT, timeout: 15_000 }); + assert.equal( + writeRoot.status, + 0, + `the root-filesystem write probe must run:\n${(writeRoot.stdout || '')}\n${(writeRoot.stderr || '')}`.trim(), + ); + assert.match( + writeRoot.stdout, + /DENIED/, + `a write to the app root filesystem must be denied (read-only root filesystem; got "${writeRoot.stdout.trim()}")`, + ); + + // Acceptance 2 — "writable paths are limited to declared mounts and + // tmpfs": the declared /tmp tmpfs is writable. + const writeTmp = run('docker', ['compose', 'exec', '-T', 'app', 'sh', '-c', + 'touch /tmp/t06-tmpfs-probe && echo WROTE && rm /tmp/t06-tmpfs-probe', + ], { cwd: REPO_ROOT, timeout: 15_000 }); + assert.equal( + writeTmp.status, + 0, + `the tmpfs write probe must succeed:\n${(writeTmp.stdout || '')}\n${(writeTmp.stderr || '')}`.trim(), + ); + assert.match( + writeTmp.stdout, + /WROTE/, + `the declared /tmp tmpfs must be writable (writable paths are limited to declared mounts and tmpfs; got "${writeTmp.stdout.trim()}")`, + ); + + // Regression guard: the read-only rootfs + tmpfs must not break startup — + // the health endpoint still answers HTTP 200 with a healthy body. + let healthOutput = ''; + let healthOk = false; + for (let attempt = 0; attempt < 30 && !healthOk; attempt += 1) { + const probe = run('docker', ['compose', 'exec', '-T', 'app', 'node', '-e', ` + fetch('http://127.0.0.1:3000/health') + .then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : 1); }) + .catch(() => process.exit(2)); + `], { cwd: REPO_ROOT, timeout: 15_000 }); + const output = String(probe.stdout ?? '') + String(probe.stderr ?? ''); + if (probe.status === 0) { + healthOk = true; + healthOutput = output; + } else if (probe.status === 1) { + healthOutput = output; // answered but not 2xx — fail fast + break; + } else { + run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // app still starting — retry + } + } + assert.ok( + healthOk, + `GET /health must answer 2xx inside the app container once the stack is up (last probe: "${healthOutput.trim()}")`, + ); + assert.match(healthOutput, /200/, `GET /health must return HTTP 200 (got: "${healthOutput.trim()}")`); + assert.match( + healthOutput, + /"status":"ok"/, + `GET /health must report a healthy application (got: "${healthOutput.trim()}")`, + ); + } finally { + run('docker', ['compose', 'down'], { cwd: REPO_ROOT }); + } +}); + +// --------------------------------------------------------------------------- +// Non-vacuous probes — the assertions above really do fail on violations +// --------------------------------------------------------------------------- + +test('removing read_only makes the read-only criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const mutated = text.replace(/^ read_only: true\n/m, ''); + assert.notEqual(mutated, text, 'the mutation must actually remove the read_only line'); + assert.throws(() => assertReadOnlyRootfs(mutated), /read_only/); +}); + +test('switching read_only to false makes the read-only criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const mutated = text.replace(/^ read_only: true\n/m, ' read_only: false\n'); + assert.notEqual(mutated, text, 'the mutation must actually flip read_only to false'); + assert.throws(() => assertReadOnlyRootfs(mutated), /read_only/); +}); + +test('removing the /tmp tmpfs makes the writable-path criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const mutated = text.replace(/^ - \/tmp\n/m, ''); + assert.notEqual(mutated, text, 'the mutation must actually remove the /tmp tmpfs entry'); + assert.throws(() => assertWritableTmpfs(mutated), /tmpfs/); +}); + +test('removing the whole tmpfs block makes the writable-path criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const mutated = text.replace(/^ tmpfs:\n - \/tmp\n/m, ''); + assert.notEqual(mutated, text, 'the mutation must actually remove the tmpfs block'); + assert.throws(() => assertWritableTmpfs(mutated), /tmpfs/); +}); + +test('adding a writable volume mount makes the writable-path criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const mutated = text.replace( + /^ tmpfs:\n/m, + ' volumes:\n - app-data:/data\n tmpfs:\n', + ); + assert.notEqual(mutated, text, 'the mutation must actually add a writable mount to the app service'); + assert.throws(() => assertNoWritableMounts(mutated), /writable mount/); +}); + +test('a read_only flag on the db service cannot satisfy the app criterion (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const mutated = text.replace(/^ read_only: true\n/m, ''); + const withDbOnly = mutated.replace( + /^ # T04: persist the database in the named `db-data` volume/m, + ' read_only: true\n # T04: persist the database in the named `db-data` volume', + ); + assert.notEqual(withDbOnly, text, 'the mutation must move read_only onto the db service only'); + assert.throws(() => assertReadOnlyRootfs(withDbOnly), /read_only/); +});