mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #465 from opticon454/chore/docker-major-update-script
chore(docker): add Update-Codeman.sh for scripted major-update rebuilds # Conflicts: # docker/README.md
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { readFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -33,6 +33,7 @@ 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[] {
|
||||
@@ -174,6 +175,281 @@ describe('Start-Codeman.sh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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('force-rebuilds with --no-cache BEFORE stopping the stack, THEN hands off to Start-Codeman.sh via `bash`', () => {
|
||||
const build = updateScript.indexOf('"${compose_command[@]}" build --no-cache');
|
||||
const down = updateScript.indexOf('"${compose_command[@]}" down');
|
||||
const handoff = updateScript.indexOf('exec bash "$script_dir/Start-Codeman.sh"');
|
||||
expect(build).toBeGreaterThan(-1);
|
||||
expect(down).toBeGreaterThan(build);
|
||||
expect(handoff).toBeGreaterThan(down);
|
||||
// A bare `exec "$script_dir/Start-Codeman.sh"` fails EACCES — Start-Codeman.sh
|
||||
// is committed non-executable (100644), same as this script.
|
||||
expect(updateScript).not.toMatch(/exec "\$script_dir\/Start-Codeman\.sh"/);
|
||||
});
|
||||
|
||||
it('resolves the collision guard BEFORE the --no-cache build and the down, not after', () => {
|
||||
// This script's own build/down run before the handoff to Start-Codeman.sh,
|
||||
// so its copy of the guard has to be early here too - Start-Codeman.sh's
|
||||
// copy alone would only catch the collision after this script's own
|
||||
// destructive calls already ran.
|
||||
const projectName = updateScript.indexOf('project_name=$(');
|
||||
const guard = updateScript.indexOf('other_working_dir=$(');
|
||||
const build = updateScript.indexOf('"${compose_command[@]}" build --no-cache');
|
||||
const down = updateScript.indexOf('"${compose_command[@]}" down');
|
||||
expect(projectName).toBeGreaterThan(-1);
|
||||
expect(guard).toBeGreaterThan(projectName);
|
||||
expect(guard).toBeLessThan(build);
|
||||
expect(guard).toBeLessThan(down);
|
||||
expect(updateScript).toMatch(/label=com\.docker\.compose\.project=\$project_name/);
|
||||
expect(updateScript).toMatch(/\{\{\.Label "com\.docker\.compose\.project\.working_dir"\}\}/);
|
||||
expect(updateScript).toMatch(/grep -v -F -x -- "\$script_dir"/);
|
||||
});
|
||||
|
||||
it('clears the named volumes by DEFAULT; --keep-volumes opts out to a plain `down`', () => {
|
||||
expect(updateScript).toMatch(/--keep-volumes\)\s*\n\s*keep_volumes=1/);
|
||||
expect(updateScript).toMatch(/"\$\{compose_command\[@\]\}" down --volumes/);
|
||||
// The keep_volumes branch must stay a plain `down` — merging the two would
|
||||
// silently start wiping the build-artefact volumes even when asked not to.
|
||||
expect(updateScript).toMatch(
|
||||
/if \[\[ "\$keep_volumes" == '1' \]\]; then\s*\n\s*"\$\{compose_command\[@\]\}" down\s*\n\s*else/
|
||||
);
|
||||
});
|
||||
|
||||
it('--help/-h prints usage and exits 0, rather than falling into the unrecognised-argument branch', () => {
|
||||
expect(updateScript).toMatch(/--help \| -h\)\s*\n\s*printf 'Usage:/);
|
||||
const helpBlock = updateScript.slice(updateScript.indexOf('--help | -h)'), updateScript.indexOf('*)'));
|
||||
expect(helpBlock).toMatch(/exit 0/);
|
||||
});
|
||||
|
||||
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 normalise = (s: string) => s.replace(/\r\n/g, '\n');
|
||||
const overrideBlock = (script: string) =>
|
||||
normalise(script.slice(script.indexOf('override_yml='), script.indexOf('compose_command=(docker compose')));
|
||||
expect(overrideBlock(updateScript)).toBe(overrideBlock(startScript));
|
||||
});
|
||||
|
||||
it('derives PUID/PGID from the SAME owner_of() helper Start-Codeman.sh uses, so the --no-cache build gets the right build args', () => {
|
||||
const normalise = (s: string) => s.replace(/\r\n/g, '\n');
|
||||
const ownerOfBlock = (script: string) => {
|
||||
const start = script.indexOf('owner_of() {');
|
||||
const end = script.indexOf('\n}', start) + '\n}'.length;
|
||||
return normalise(script.slice(start, end));
|
||||
};
|
||||
expect(ownerOfBlock(updateScript)).toBe(ownerOfBlock(startScript));
|
||||
expect(updateScript).toMatch(/export PUID=\$\{owner_ids%%:\*\}/);
|
||||
expect(updateScript).toMatch(/export PGID=\$\{owner_ids##\*:\}/);
|
||||
// The build must come AFTER PUID/PGID are resolved and exported, or Compose
|
||||
// falls back to its own default of 1000:1000 for the build args.
|
||||
const puidExport = updateScript.indexOf('export PUID=');
|
||||
const build = updateScript.indexOf('"${compose_command[@]}" build --no-cache');
|
||||
expect(puidExport).toBeGreaterThan(-1);
|
||||
expect(build).toBeGreaterThan(puidExport);
|
||||
});
|
||||
|
||||
describe('end-to-end smoke test (a stub `docker` on PATH, logging every invocation)', () => {
|
||||
/**
|
||||
* Reproduces the exact scenario the review on PR #465 caught by hand: a bare
|
||||
* `exec` of a non-executable script exits 126 with no further `docker` calls
|
||||
* at all. Runs the REAL Update-Codeman.sh against a synthetic deployment,
|
||||
* asserting the actual command sequence a shell would issue — string-matching
|
||||
* the source (the tests above) cannot tell a working `exec bash "…"` apart
|
||||
* from a silently-broken bare `exec "…"` the way actually running it can.
|
||||
*
|
||||
* The harness intentionally does NOT create a real Unix socket for
|
||||
* DOCKER_SOCKET (net.createServer().listen(path) is unreliable off Linux —
|
||||
* measured EACCES on this Windows sandbox even outside any container). So the
|
||||
* handoff to Start-Codeman.sh is real and fully exercises this script's own
|
||||
* build/down/handoff sequence, but Start-Codeman.sh's OWN socket check is
|
||||
* expected to then fail — which is itself the proof the handoff worked: a
|
||||
* process that failed to exec would never reach a Start-Codeman.sh-only error
|
||||
* message, and would exit 126, not 1.
|
||||
*/
|
||||
// Windows join()/mkdtempSync() paths carry backslashes, which the stub
|
||||
// `docker`'s naive `source "$envfile"` (a shortcut for `docker compose
|
||||
// config --environment`'s own real parsing, which handles this fine) reads
|
||||
// as bash ESCAPE characters and silently drops — `C:\Users\x` becomes
|
||||
// `C:Usersx`. Forward slashes are accepted by git-bash/MSYS on Windows and
|
||||
// by every POSIX shell, so normalising once here sidesteps a harness
|
||||
// artifact that has nothing to do with the scripts under test.
|
||||
const posix = (p: string) => p.replace(/\\/g, '/');
|
||||
|
||||
function runSmokeTest(
|
||||
args: string[],
|
||||
extraEnv: Record<string, string> = {}
|
||||
): { status: number; stderr: string; log: string[] } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-update-smoke-'));
|
||||
try {
|
||||
const dockerDir = join(dir, 'docker');
|
||||
mkdirSync(dockerDir);
|
||||
writeFileSync(join(dockerDir, 'Update-Codeman.sh'), updateScript);
|
||||
writeFileSync(join(dockerDir, 'Start-Codeman.sh'), startScript);
|
||||
writeFileSync(join(dockerDir, 'docker-compose.yaml'), compose);
|
||||
|
||||
const appdataPath = join(dir, 'appdata');
|
||||
const casesPath = join(dir, 'cases');
|
||||
const socketPath = join(dir, 'docker.sock'); // deliberately NOT a real socket — see above
|
||||
mkdirSync(appdataPath);
|
||||
mkdirSync(casesPath);
|
||||
writeFileSync(socketPath, '');
|
||||
|
||||
writeFileSync(
|
||||
join(dockerDir, '.env'),
|
||||
[
|
||||
`CODEMAN_APPDATA_PATH=${posix(appdataPath)}`,
|
||||
`CODEMAN_CASES_PATH=${posix(casesPath)}`,
|
||||
`DOCKER_SOCKET=${posix(socketPath)}`,
|
||||
'CODEMAN_RUNTIME_USER=codeman',
|
||||
'CODEMAN_PORT=3000',
|
||||
'CODEMAN_HOST=127.0.0.1',
|
||||
'CODEMAN_PASSWORD=x',
|
||||
'CODEMAN_USERNAME=admin',
|
||||
'GEMINI_API_KEY=',
|
||||
'CODEMAN_DOCKER_BRIDGE_HOOKS=',
|
||||
'CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=',
|
||||
'TZ=UTC',
|
||||
'CODEMAN_IMAGE=codeman:test',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
// A stub `docker` that only understands the two `compose config` shapes
|
||||
// both scripts actually issue, and logs every invocation verbatim —
|
||||
// written and chmod+x'd from WITHIN one bash invocation (not
|
||||
// fs.chmodSync, whose Win32 backing does not reliably set the bit this
|
||||
// MSYS bash's own PATH lookup honours — measured, differs from a plain
|
||||
// `chmod +x` issued by bash itself).
|
||||
const binDir = join(dir, 'bin');
|
||||
mkdirSync(binDir);
|
||||
const stub = [
|
||||
'#!/usr/bin/env bash',
|
||||
'echo "docker $*" >> "$CMDLOG"',
|
||||
'if [[ "$1" == "compose" ]]; then',
|
||||
' shift',
|
||||
' prev=""',
|
||||
' envfile=""',
|
||||
' for a in "$@"; do',
|
||||
' if [[ "$prev" == "--env-file" ]]; then envfile="$a"; fi',
|
||||
' prev="$a"',
|
||||
' done',
|
||||
' if [[ " $* " == *" config "* && " $* " == *" --environment "* ]]; then',
|
||||
' source "$envfile"',
|
||||
' echo "CODEMAN_APPDATA_PATH=$CODEMAN_APPDATA_PATH"',
|
||||
' echo "CODEMAN_CASES_PATH=$CODEMAN_CASES_PATH"',
|
||||
' echo "DOCKER_SOCKET=$DOCKER_SOCKET"',
|
||||
' exit 0',
|
||||
' fi',
|
||||
' if [[ " $* " == *" config "* && " $* " == *" --format json "* ]]; then',
|
||||
// Real `docker compose config --format json` pretty-prints, so
|
||||
// `"name"` starts its OWN line rather than sharing one with `{` -
|
||||
// the sed extraction both scripts use anchors on that, and a
|
||||
// compact one-liner here would silently resolve project_name to
|
||||
// empty, exercising neither script's guard the way real Compose
|
||||
// output does.
|
||||
' printf \'{\\n "name": "codeman"\\n}\\n\'',
|
||||
' exit 0',
|
||||
' fi',
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Mirrors the guard's own `docker ps -a --filter ... --format
|
||||
// '{{.Label "com.docker.compose.project.working_dir"}}'` call.
|
||||
// Empty by default (no collision) so the existing smoke tests above
|
||||
// see no output here and proceed exactly as before; a test that
|
||||
// wants to exercise the guard itself sets STUB_PS_WORKING_DIR.
|
||||
'if [[ "$1" == "ps" && -n "${STUB_PS_WORKING_DIR:-}" ]]; then',
|
||||
' echo "$STUB_PS_WORKING_DIR"',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'exit 0',
|
||||
].join('\n');
|
||||
const stubPath = join(binDir, 'docker');
|
||||
writeFileSync(stubPath, stub);
|
||||
execFileSync('bash', ['-c', `chmod +x '${stubPath}'`]);
|
||||
|
||||
const logPath = join(dir, 'cmdlog.txt');
|
||||
writeFileSync(logPath, '');
|
||||
|
||||
let status = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('bash', [join(dockerDir, 'Update-Codeman.sh'), ...args], {
|
||||
env: { ...process.env, PATH: `${binDir}:${process.env.PATH}`, CMDLOG: logPath, ...extraEnv },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as { status?: number; stderr?: string };
|
||||
status = e.status ?? 1;
|
||||
stderr = e.stderr ?? '';
|
||||
}
|
||||
|
||||
const log = readFileSync(logPath, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim());
|
||||
return { status, stderr, log };
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it('default: build --no-cache, THEN down --volumes, THEN the handoff genuinely runs Start-Codeman.sh', () => {
|
||||
const { status, stderr, log } = runSmokeTest([]);
|
||||
|
||||
const buildIdx = log.findIndex((l) => l.includes('build --no-cache'));
|
||||
const downIdx = log.findIndex((l) => l.includes(' down --volumes') || l.endsWith(' down'));
|
||||
expect(buildIdx).toBeGreaterThan(-1);
|
||||
expect(downIdx).toBeGreaterThan(buildIdx);
|
||||
expect(log[downIdx]).toContain('down --volumes');
|
||||
|
||||
// Proof the handoff really executed Start-Codeman.sh rather than dying
|
||||
// with EACCES right after printing "Handing off...": more `docker`
|
||||
// invocations appear AFTER the down, which only Start-Codeman.sh's own
|
||||
// config-resolution lines would produce.
|
||||
const configCallsAfterDown = log.slice(downIdx + 1).filter((l) => l.includes('config'));
|
||||
expect(configCallsAfterDown.length).toBeGreaterThan(0);
|
||||
|
||||
// A working handoff fails HONESTLY at Start-Codeman.sh's own socket
|
||||
// check (this harness deliberately supplies no real Unix socket) — never
|
||||
// with an EACCES/126 from a broken `exec`.
|
||||
expect(status).toBe(1);
|
||||
expect(stderr).toMatch(/DOCKER_SOCKET is not a Unix socket/);
|
||||
expect(stderr).not.toMatch(/permission denied/i);
|
||||
});
|
||||
|
||||
it('--keep-volumes: a plain `down`, with no --volumes flag', () => {
|
||||
const { log } = runSmokeTest(['--keep-volumes']);
|
||||
const downLine = log.find((l) => / down(\s|$)/.test(l));
|
||||
expect(downLine).toBeDefined();
|
||||
expect(downLine).not.toContain('--volumes');
|
||||
});
|
||||
|
||||
it('refuses BEFORE the --no-cache build when the resolved project belongs to a different checkout', () => {
|
||||
// The whole reason this guard lives here rather than only in
|
||||
// Start-Codeman.sh: this script's own build/down run before the handoff
|
||||
// ever reaches that script's copy of the same check.
|
||||
const { status, stderr, log } = runSmokeTest([], { STUB_PS_WORKING_DIR: '/some/other/checkout/docker' });
|
||||
expect(status).toBe(1);
|
||||
expect(stderr).toMatch(/already in use by a DIFFERENT checkout/);
|
||||
expect(stderr).toContain('/some/other/checkout/docker');
|
||||
expect(log.some((l) => l.includes('build --no-cache'))).toBe(false);
|
||||
expect(log.some((l) => / down(\s|$)/.test(l))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('git_head_commit resolves every ref layout a checkout can have', () => {
|
||||
let base: string;
|
||||
const git = (cwd: string, ...args: string[]) =>
|
||||
|
||||
Reference in New Issue
Block a user