Merge pull request '[E00-S02-T06] App root filesystem read-only except mounts/tmpfs' (#387) from feature/173 into main
CI / Frozen lockfile install (push) Successful in 46s
CI / Frozen lockfile install (push) Successful in 46s
This commit was merged in pull request #387.
This commit is contained in:
@@ -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
|
||||
|
||||
+18
-3
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user