Files
Codeman/test/docker-entrypoint.test.ts
T
DevvynandClaude Sonnet 5 9ba90a674a chore(docker): add Update-Codeman.sh for scripted major-update rebuilds
docker/README.md and docs/docker-self-update.md both already point operators
at "stop the stack, rebuild, restart" for anything the in-app updater refuses
to apply (a changed server.Dockerfile, a changed docker-compose.yaml, or a
new required .env key) — but that was a manual, hand-typed procedure with no
script of its own, unlike every other start/update path this deployment has.

docker/Update-Codeman.sh scripts it: `docker compose down`, then an
unconditional `docker compose build --no-cache` (a major update should be
certain of what actually ships, not reuse whatever layers happened to be
cached), then hands off to the existing Start-Codeman.sh for the same
careful PUID/PGID, override-file and fingerprint handling every other start
already goes through — rather than reimplementing any of that by hand and
risking it drifting out of step.

An optional --volumes/-v flag also removes the codeman-node-modules/
codeman-dist named volumes, the scripted form of the "Resetting the build
artefacts" procedure docs/docker-self-update.md already documents by hand.
Safe: those two are the only named volumes this stack declares; application
data and case workspaces are host bind mounts, never touched by
`docker compose down` either way.

Docs updated: a "Major updates" section in docker/README.md, and a pointer
from docs/docker-self-update.md's existing "Resetting the build artefacts"
troubleshooting entry.

Tests: extended test/docker-entrypoint.test.ts (the existing home for
Start-Codeman.sh's own static checks) with a bash -n parse check, the
down-before-build-before-handoff ordering, the --volumes flag's effect,
unrecognised-argument handling, and byte-for-byte agreement with
Start-Codeman.sh's own override-file resolution logic (so `down` here and
`up` there can never target different Compose files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-21 13:57:54 +08:00

311 lines
13 KiB
TypeScript

/**
* @fileoverview Static and fixture checks for the Docker Compose deployment's
* privilege handling: `docker/entrypoint.sh` starts as root, corrects bind-mount
* ownership and drops to PUID:PGID, which only works while three files agree.
*
* 1. The capabilities `docker-compose.yaml` adds back on top of `cap_drop: ALL`
* must be exactly what the entrypoint and `init: true` need. This is the
* drift that shipped once already: the `USER` instruction became a root
* entrypoint, tini stayed root while the server became PUID, and with no
* CAP_KILL every `docker compose down` ended in tini failing to forward
* SIGTERM and the server being SIGKILLed. The list is derived here from what
* the scripts actually do, not copied.
* 2. The runtime-owned CLI prefix must never sit ahead of the system
* directories on the PATH the root entrypoint resolves commands through: a
* planted `setpriv` in a PUID-writable prefix ran as uid 0 (measured with a
* minimal image of the same shape).
* 3. `Start-Codeman.sh` derives PUID/PGID BEFORE it creates
* `CODEMAN_CASES_PATH`, so the directory it creates has the owner the
* container will accept, and its `git_head_commit` helper (a pure function
* over `.git`) resolves the three ref layouts a checkout can have.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync, mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const ROOT = process.cwd();
const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf-8');
const compose = read('docker/docker-compose.yaml');
const entrypoint = read('docker/entrypoint.sh');
const dockerfile = read('docker/server.Dockerfile');
const startScript = read('docker/Start-Codeman.sh');
const updateScript = read('docker/Update-Codeman.sh');
/** The `- NAME` entries under `cap_add:` (the block ends at the next key at the same indent). */
function composeCapAdd(text: string): string[] {
const m = text.match(/^(\s*)cap_add:\n((?:\1\s+.*\n)*)/m);
if (!m) return [];
return m[2]
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith('- '))
.map((l) => l.slice(2).trim())
.sort();
}
/**
* What the deployment needs, derived from the scripts. Each rule names the
* line that needs it, so a capability cannot be added or removed here without
* the reason changing too.
*/
function requiredCaps(): string[] {
const caps = new Set<string>();
if (/\bchown\b/.test(entrypoint)) {
// chown of a root-owned bind source, and traversing trees root cannot
// otherwise read on a mount with restrictive modes.
caps.add('CHOWN');
caps.add('DAC_OVERRIDE');
}
if (/setpriv .*--reuid/.test(entrypoint)) caps.add('SETUID');
if (/setpriv .*--(regid|groups|clear-groups)/.test(entrypoint)) caps.add('SETGID');
const dropsUid = /setpriv .*--reuid/.test(entrypoint);
if (/^\s*init:\s*true\s*$/m.test(compose) && dropsUid) {
// tini is PID 1 and stays root; signalling the PUID server needs CAP_KILL.
caps.add('KILL');
}
return [...caps].sort();
}
describe('docker-compose.yaml cap_add covers what entrypoint.sh and init:true need', () => {
it('the compose file adds back exactly the derived capability set', () => {
expect(composeCapAdd(compose)).toEqual(requiredCaps());
});
it('cap_drop: ALL is still the baseline', () => {
expect(compose).toMatch(/^\s*cap_drop:\n\s*- ALL\s*$/m);
});
it("the entrypoint's own diagnosis names the same list, so a missing cap gets a one-line fix", () => {
const m = entrypoint.match(/^required_caps='([^']+)'/m);
expect(m, 'entrypoint.sh must declare required_caps').not.toBeNull();
const named = m![1]
.split(',')
.map((c) => c.trim())
.sort();
expect(named).toEqual(composeCapAdd(compose));
});
it('the user-facing docs quote the same cap_add list', () => {
for (const rel of ['docker/README.md', 'CLAUDE.md']) {
const text = read(rel);
const quoted = [...text.matchAll(/cap_add: \[([^\]]+)\]/g)].map((m) =>
m[1]
.split(',')
.map((c) => c.trim())
.sort()
);
expect(quoted.length, `${rel} should quote the cap_add list at least once`).toBeGreaterThan(0);
for (const list of quoted) expect(list, rel).toEqual(composeCapAdd(compose));
}
});
});
describe('the runtime-owned CLI prefix never shadows root commands', () => {
it('server.Dockerfile appends /opt/codeman-cli/bin to PATH rather than prepending it', () => {
const pathLines = dockerfile.split('\n').filter((l) => /^ENV PATH=/.test(l));
expect(pathLines.length).toBeGreaterThan(0);
for (const line of pathLines) {
expect(line, 'a writable prefix ahead of $PATH lets a planted setpriv run as root').not.toMatch(
/^ENV PATH=\/opt\/codeman-cli/
);
}
expect(pathLines).toContain('ENV PATH=$PATH:/opt/codeman-cli/bin');
});
it('entrypoint.sh pins PATH to the system directories before its first command', () => {
const lines = entrypoint.split('\n');
const pinIdx = lines.findIndex((l) => l === 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin');
expect(pinIdx, 'the PATH pin must exist').toBeGreaterThan(-1);
const firstToolIdx = lines.findIndex((l) => !l.trim().startsWith('#') && /\b(setpriv|chown|stat)\b/.test(l));
expect(firstToolIdx).toBeGreaterThan(pinIdx);
// The only thing allowed before the pin is the `user:` short-circuit.
const before = lines
.slice(0, pinIdx)
.filter((l) => l.trim() && !l.trim().startsWith('#') && !/^(set -eu|runtime_path=\$PATH)$/.test(l.trim()));
expect(before).toEqual(['if [ "$(id -u)" -ne 0 ]; then', ' exec "$@"', 'fi']);
});
it("entrypoint.sh hands the image's full PATH back to the server at the drop", () => {
expect(entrypoint).toMatch(/exec setpriv [^\n]*\\\n\s*env PATH="\$runtime_path" "\$@"/);
});
it('entrypoint.sh no longer passes --bounding-set (a silent no-op without CAP_SETPCAP)', () => {
const code = entrypoint
.split('\n')
.filter((l) => !l.trim().startsWith('#'))
.join('\n');
expect(code).not.toMatch(/--bounding-set/);
expect(composeCapAdd(compose)).not.toContain('SETPCAP');
});
});
describe('Start-Codeman.sh', () => {
it('parses under bash -n', () => {
execFileSync('bash', ['-n', join(ROOT, 'docker/Start-Codeman.sh')]);
execFileSync('sh', ['-n', join(ROOT, 'docker/entrypoint.sh')]);
});
it('derives PUID/PGID before creating CODEMAN_CASES_PATH, so the new directory gets that owner', () => {
const puid = startScript.indexOf('export PUID=');
const mkdirCases = startScript.indexOf('mkdir -p -- "$cases_path"');
expect(puid).toBeGreaterThan(-1);
expect(mkdirCases).toBeGreaterThan(puid);
expect(startScript).toMatch(/chown -- "\$PUID:\$PGID" "\$cases_path"/);
});
it('builds before taking the stack down, and writes the source marker only after a refresh', () => {
const build = startScript.indexOf('"${compose_command[@]}" build');
const down = startScript.indexOf('"${compose_command[@]}" down');
const marker = startScript.indexOf('>"$source_state_file.tmp"');
expect(build).toBeGreaterThan(-1);
expect(down).toBeGreaterThan(build);
expect(marker).toBeGreaterThan(down);
expect(startScript).toMatch(/if \[\[ "\$refreshed" == '1' \]\]; then\n\s*printf '\{\\n {2}"headCommit"/);
// A failed volume removal must not abort under set -e with the stack down.
expect(startScript).not.toMatch(/\[\[ -n "\$volume_name" \]\] && docker volume rm/);
expect(startScript).toMatch(/&& ! docker volume rm -- "\$volume_name"; then/);
});
it('falls back to `down --volumes` when the Compose project name cannot be resolved', () => {
expect(startScript).toMatch(/if \[\[ -z "\$project_name" \]\]; then[\s\S]*down --volumes/);
});
});
describe('Update-Codeman.sh (the scripted major-update path — docker/README.md "Major updates")', () => {
it('parses under bash -n', () => {
execFileSync('bash', ['-n', join(ROOT, 'docker/Update-Codeman.sh')]);
});
it('is executable, like every other script this deployment runs directly', () => {
// Windows checkouts (this repo is developed on both) do not carry a real
// execute bit, so this only meaningfully asserts on POSIX — matching how
// docker/README.md documents running it (`bash docker/Update-Codeman.sh`,
// not `./docker/Update-Codeman.sh`) either way.
if (process.platform === 'win32') return;
const mode = statSync(join(ROOT, 'docker/Update-Codeman.sh')).mode;
expect(mode & 0o111).not.toBe(0);
});
it('stops the stack, THEN force-rebuilds with --no-cache, THEN hands off to Start-Codeman.sh', () => {
const down = updateScript.indexOf('"${compose_command[@]}" down');
const build = updateScript.indexOf('"${compose_command[@]}" build --no-cache');
const handoff = updateScript.indexOf('exec "$script_dir/Start-Codeman.sh"');
expect(down).toBeGreaterThan(-1);
expect(build).toBeGreaterThan(down);
expect(handoff).toBeGreaterThan(build);
});
it('--volumes (or -v) removes the named volumes on the way down; the default path does not', () => {
expect(updateScript).toMatch(/--volumes \| -v\)\s*\n\s*remove_volumes=1/);
expect(updateScript).toMatch(/"\$\{compose_command\[@\]\}" down --volumes/);
// The unconditional call further down (the else branch) must stay a plain
// `down` — accidentally merging the two branches would silently start
// wiping the build-artefact volumes on every major update, not just when
// the flag is passed.
expect(updateScript).toMatch(/else\s*\n\s*"\$\{compose_command\[@\]\}" down\s*\n\s*fi/);
});
it('rejects an unrecognised argument rather than silently ignoring it', () => {
expect(updateScript).toMatch(/Error: unrecognised argument/);
expect(updateScript).toMatch(/exit 1/);
});
it('resolves the override file exactly like Start-Codeman.sh, so `down` and `up` never target different Compose files', () => {
// \r stripped before comparing: git's autocrlf normalises the COMMITTED blob to LF
// either way, but a Windows checkout can have already converted one file's line
// endings on disk and not the other's (e.g. Start-Codeman.sh checked out before this
// script existed), which would fail a raw byte comparison for a reason that has
// nothing to do with the two scripts actually agreeing.
const overrideBlock = (script: string) =>
script
.slice(script.indexOf('override_yml='), script.indexOf('compose_command=(docker compose'))
.replace(/\r\n/g, '\n');
expect(overrideBlock(updateScript)).toBe(overrideBlock(startScript));
});
});
describe('git_head_commit resolves every ref layout a checkout can have', () => {
let base: string;
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, {
cwd,
encoding: 'utf-8',
env: {
...process.env,
GIT_AUTHOR_NAME: 't',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 't',
GIT_COMMITTER_EMAIL: 't@example.com',
},
}).trim();
/** Runs the function exactly as the script defines it, extracted by its own delimiters. */
const headCommit = (repo: string): { out: string; status: number } => {
const script = [`eval "$(sed -n '/^git_head_commit() {/,/^}/p' "$1")"`, 'git_head_commit "$2"'].join('\n');
try {
const out = execFileSync('bash', ['-c', script, '_', join(ROOT, 'docker/Start-Codeman.sh'), repo], {
encoding: 'utf-8',
});
return { out: out.trim(), status: 0 };
} catch (err) {
const e = err as { stdout?: string; status?: number };
return { out: (e.stdout ?? '').trim(), status: e.status ?? 1 };
}
};
const makeRepo = (name: string): string => {
const dir = join(base, name);
git(base, 'init', '-q', '-b', 'master', dir);
writeFileSync(join(dir, 'f'), 'x');
git(dir, 'add', 'f');
git(dir, 'commit', '-q', '-m', 'one');
return dir;
};
beforeAll(() => {
base = mkdtempSync(join(tmpdir(), 'codeman-head-commit-'));
});
afterAll(() => {
rmSync(base, { recursive: true, force: true });
});
it('symbolic ref with a loose ref file', () => {
const dir = makeRepo('loose');
expect(headCommit(dir)).toEqual({ out: git(dir, 'rev-parse', 'HEAD'), status: 0 });
});
it('detached HEAD', () => {
const dir = makeRepo('detached');
const sha = git(dir, 'rev-parse', 'HEAD');
git(dir, 'checkout', '-q', '--detach', sha);
expect(headCommit(dir)).toEqual({ out: sha, status: 0 });
});
it('packed refs after gc', () => {
const dir = makeRepo('packed');
const sha = git(dir, 'rev-parse', 'HEAD');
git(dir, 'pack-refs', '--all');
expect(readFileSync(join(dir, '.git/packed-refs'), 'utf-8')).toContain('refs/heads/master');
expect(headCommit(dir)).toEqual({ out: sha, status: 0 });
});
it('a linked worktree (.git is a file) resolves nothing rather than something wrong', () => {
const dir = makeRepo('main');
const wt = join(base, 'wt');
git(dir, 'worktree', 'add', '-q', wt);
const result = headCommit(wt);
expect(result.out).toBe('');
expect(result.status).not.toBe(0);
});
it('a directory that is not a checkout fails', () => {
const result = headCommit(base);
expect(result.out).toBe('');
expect(result.status).not.toBe(0);
});
});