[E00-S02-T06] App root filesystem read-only except mounts/tmpfs #387

Merged
kpcto merged 2 commits from feature/173 into main 2026-08-29 01:00:34 +00:00
3 changed files with 362 additions and 7 deletions
+5 -4
View File
@@ -8,10 +8,11 @@
# server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port # server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port
# 3000, so the app container stays up and the health endpoint succeeds. The # 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; # 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 — # DB volume persistence (T04) and read-only root filesystem (T06) are
# this image is unchanged), while read-only root filesystem (T06) and # Compose-level concerns (see compose.yaml — the `db-data` volume mount and the
# multi-arch build targets (T07) remain later E00-S02 tasks — all out of scope # app service's `read_only: true` + `/tmp` tmpfs; this image is unchanged),
# here. Since T05 the runtime stage drops root privileges (runs as the # 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's non-root `node` user).
# #
# Image base: node:24.19.0-bookworm-slim (glibc Debian) per Technology-Stack # Image base: node:24.19.0-bookworm-slim (glibc Debian) per Technology-Stack
+18 -3
View File
@@ -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 # `docker compose up -d` starts both the database (PostgreSQL) and the
# application (@personal-blog/server). Rollback: `docker compose down`. # 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 # 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. # `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): the `app` service sets `read_only: true`, so
# - read-only root filesystem (T06), multi-arch build targets (T07) # 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 # 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). # without a .env file (a committed .env.example template lands in E00-S04).
@@ -68,6 +75,14 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy 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) # Named volumes shared across `docker compose` lifecycles. `db-data` (T04)
# holds the PostgreSQL data directory and is preserved across restart and # holds the PostgreSQL data directory and is preserved across restart and
+339
View File
@@ -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/);
});