extroonie.com/scripts/test-deploy.mjs

274 lines
11 KiB
JavaScript
Raw Permalink Normal View History

2026-09-21 12:31:51 -07:00
import assert from 'node:assert/strict';
import {
mkdtempSync,
mkdirSync,
copyFileSync,
writeFileSync,
readFileSync,
existsSync,
rmSync,
statSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const root = mkdtempSync(join(tmpdir(), 'extroonie-deploy-'));
try {
const bin = join(root, 'bin');
mkdirSync(bin);
// Git is real; only Docker is simulated.
writeFileSync(
join(bin, 'docker'),
`#!/bin/sh
set -eu
snapshot=''
if [ "\${1:-}" = compose ] && [ "\${2:-}" = --project-directory ]; then
snapshot=$5
shift 5
set -- compose "$@"
fi
printf '%s %s [%s]\\n' "\${SITE_IMAGE:-unset}" "$*" "$snapshot" >> "$DEPLOY_LOG"
case "$*" in
'compose version'|'info') exit 0 ;;
'compose ps -q site')
case "$snapshot" in
*candidate.yaml) echo current-container ;;
*current.yaml) echo restored-container ;;
*) [ "$SCENARIO" = first-deploy ] || echo previous-container ;;
esac ;;
'compose config --images site') echo extroonie:local ;;
'compose config --hash site')
test "$SITE_IMAGE" = extroonie:previous
printf 'site hash-%s\\n' "$(cat compose.yaml)" ;;
'compose config site') printf 'name: test\\nservices:\\n site:\\n image: %s\\n environment:\\n RELEASE: %s\\n' "$SITE_IMAGE" "$(cat compose.yaml)" ;;
'compose port site 8080') echo "127.0.0.1:\${SITE_PORT:-8080}" ;;
'inspect --format {{.Image}} previous-container') echo sha256:previous ;;
'inspect --format {{.Config.Image}} previous-container') echo extroonie:previous ;;
'inspect --format {{index .Config.Labels "com.docker.compose.config-hash"}} previous-container')
[ "$SCENARIO" = local-missing-hash ] || echo hash-old-config ;;
'compose build --pull site')
grep -q "RELEASE: $EXPECTED_CONFIG" "$snapshot"
if [ "$SCENARIO" = local-dirty ]; then
test "$(cat local.txt)" = 'keep me'
test "$(cat staged.txt)" = 'staged change'
test "$(cat unstaged.txt)" = 'unstaged change'
test ! -f removed.txt
fi
[ "$SCENARIO" != build-failure ] ;;
'compose up '*)
case "$snapshot" in
*previous.yaml)
grep -q 'image: sha256:previous' "$snapshot"
grep -q 'RELEASE: old-config' "$snapshot"
[ "$SCENARIO" != rollback-failure ] ;;
*candidate.yaml)
case "$SCENARIO" in health-failure|cached-rollback|rollback-failure|local-health-failure) exit 1 ;; esac ;;
*) exit 2 ;;
esac ;;
'compose logs '*) echo 'health check failed' ;;
'image inspect '*) echo sha256:current ;;
'image tag sha256:current extroonie:local'|'image tag sha256:previous extroonie:local') exit 0 ;;
'image ls '*) printf 'extroonie:current sha256:current\\nextroonie:previous sha256:previous\\nextroonie:old sha256:old\\nunrelated:old sha256:unrelated\\n' ;;
'image rm extroonie:old') exit 0 ;;
*) echo "Unexpected Docker command: $*" >&2; exit 2 ;;
esac
`,
{ mode: 0o755 },
);
function git(cwd, ...args) {
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@example.test',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@example.test',
},
});
assert.equal(result.status, 0, result.stderr);
return result.stdout.trim();
}
for (const scenario of [
'success',
'first-deploy',
'build-failure',
'health-failure',
'cached-rollback',
'rollback-failure',
'dirty-pull',
'diverged',
'local-clean',
'local-dirty',
'local-detached',
'local-unborn',
'local-health-failure',
'local-unsaved-config',
'local-stale-config',
'local-missing-hash',
]) {
const pull = !scenario.startsWith('local-');
const folder = join(root, scenario);
const remote = join(folder, 'remote.git');
const seed = join(folder, 'seed');
const checkout = join(folder, 'checkout');
mkdirSync(folder);
git(folder, 'init', '--bare', remote);
git(folder, 'init', '-b', 'main', seed);
copyFileSync(resolve('.gitignore'), join(seed, '.gitignore'));
copyFileSync(resolve('deploy.sh'), join(seed, 'deploy.sh'));
writeFileSync(join(seed, 'compose.yaml'), 'old-config\n');
for (const file of ['staged.txt', 'unstaged.txt', 'removed.txt']) writeFileSync(join(seed, file), 'original\n');
git(seed, 'add', '.');
git(seed, 'commit', '-m', 'Initial');
git(seed, 'remote', 'add', 'origin', remote);
git(seed, 'push', '-u', 'origin', 'main');
git(folder, 'clone', '-b', 'main', remote, checkout);
assert.equal(git(checkout, 'ls-files', 'deploy.sh'), 'deploy.sh');
const state = join(checkout, '.git/extroonie-deploy');
if (['cached-rollback', 'local-dirty', 'local-health-failure', 'local-stale-config'].includes(scenario)) {
mkdirSync(state);
writeFileSync(
join(state, 'container'),
scenario === 'local-stale-config' ? 'other-container\n' : 'previous-container\n',
);
writeFileSync(join(state, 'current.yaml'), 'image: sha256:previous\nRELEASE: old-config\n');
}
if (scenario === 'cached-rollback') {
writeFileSync(join(checkout, 'compose.yaml'), 'failed-config\n');
git(checkout, 'add', '.');
git(checkout, 'commit', '-m', 'Earlier failed deployment');
git(checkout, 'push');
git(seed, 'pull', '--ff-only');
}
writeFileSync(join(seed, 'update.txt'), 'new release\n');
writeFileSync(join(seed, 'compose.yaml'), 'new-config\n');
git(seed, 'add', '.');
git(seed, 'commit', '-m', 'Update');
git(seed, 'push');
if (scenario === 'dirty-pull') {
writeFileSync(join(checkout, 'local.txt'), 'keep me');
git(checkout, 'config', 'status.showUntrackedFiles', 'no');
}
if (scenario === 'local-dirty') {
writeFileSync(join(checkout, 'local.txt'), 'keep me');
writeFileSync(join(checkout, 'staged.txt'), 'staged change');
git(checkout, 'add', 'staged.txt');
writeFileSync(join(checkout, 'unstaged.txt'), 'unstaged change');
rmSync(join(checkout, 'removed.txt'));
}
if (['local-dirty', 'local-health-failure', 'local-unsaved-config', 'local-stale-config'].includes(scenario))
writeFileSync(join(checkout, 'compose.yaml'), 'new-config\n');
if (scenario === 'local-detached') git(checkout, 'checkout', '--detach');
if (scenario === 'local-unborn') {
rmSync(join(checkout, '.git'), { recursive: true });
git(checkout, 'init', '-b', 'main');
}
if (scenario === 'diverged') {
writeFileSync(join(checkout, 'local.txt'), 'local commit');
git(checkout, 'add', '.');
git(checkout, 'commit', '-m', 'Local change');
}
const log = join(folder, 'docker.log');
const beforeStatus = git(checkout, 'status', '--porcelain', '--untracked-files=normal');
const beforeHead = git(checkout, 'rev-parse', '--revs-only', 'HEAD');
const expectedConfig =
pull || ['local-dirty', 'local-health-failure'].includes(scenario) ? 'new-config' : 'old-config';
const run = spawnSync('sh', ['./deploy.sh', ...(pull ? ['--pull'] : [])], {
cwd: checkout,
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
DEPLOY_LOG: log,
SCENARIO: scenario,
EXPECTED_CONFIG: expectedConfig,
},
});
const calls = readFileSync(log, 'utf8');
if (
['success', 'first-deploy', 'local-clean', 'local-dirty', 'local-detached', 'local-unborn'].includes(
scenario,
)
)
assert.equal(run.status, 0, run.stdout + run.stderr);
else assert.notEqual(run.status, 0, 'Expected deployment failure');
assert(!existsSync(join(checkout, '.git/extroonie-deploy.lock')), 'Lock was not released');
if (scenario === 'dirty-pull' || scenario === 'diverged') {
assert(!calls.includes('compose build'), 'Built despite unsafe checkout');
assert(existsSync(join(checkout, 'local.txt')), 'Local work lost');
continue;
}
if (pull) assert(existsSync(join(checkout, 'update.txt')), 'Remote changes were not pulled');
else {
assert(!existsSync(join(checkout, 'update.txt')), 'Local deployment pulled remote changes');
assert.equal(
git(checkout, 'status', '--porcelain', '--untracked-files=normal'),
beforeStatus,
'Local work or staging changed',
);
assert.equal(
git(checkout, 'rev-parse', '--revs-only', 'HEAD'),
beforeHead,
'Local deployment changed HEAD',
);
if (scenario === 'local-dirty' || scenario === 'local-unborn')
assert.match(
calls,
/extroonie:[\w-]+-dirty-\d+-\d+ compose build/,
'Uncommitted build lacks a dirty marker',
);
}
if (['local-unsaved-config', 'local-stale-config', 'local-missing-hash'].includes(scenario)) {
assert.match(run.stderr, /No saved configuration matches the running container/);
assert(!calls.includes('compose build'), 'Built without a verified rollback configuration');
assert(!calls.includes('compose up'), 'Replaced the running container without a recovery configuration');
assert(!calls.includes('image rm'), 'Removed images without a recovery configuration');
if (scenario === 'local-stale-config') {
assert(readFileSync(join(state, 'current.yaml'), 'utf8').includes('RELEASE: old-config'));
assert.equal(readFileSync(join(state, 'container'), 'utf8'), 'other-container\n');
} else assert(!existsSync(join(state, 'current.yaml')), 'Saved unverified settings for recovery');
}
if (scenario === 'build-failure') {
assert(!calls.includes('compose up'), 'Stopped serving after build failure');
assert(
readFileSync(join(state, 'current.yaml'), 'utf8').includes('RELEASE: old-config'),
'Initial rollback configuration lost after failed build',
);
}
if (['health-failure', 'cached-rollback', 'rollback-failure', 'local-health-failure'].includes(scenario)) {
assert(calls.includes('previous.yaml]'), 'Rollback not attempted');
assert(!calls.includes('image rm'), 'Cleanup ran after failed deployment');
const recovery = readFileSync(join(state, 'current.yaml'), 'utf8');
assert(recovery.includes('RELEASE: old-config'), 'Recovery configuration overwritten');
assert(recovery.includes('image: sha256:previous'), 'Recovery image lost');
if (scenario !== 'rollback-failure') {
assert(calls.includes('image tag sha256:previous extroonie:local'), 'Stable tag was not restored');
assert.equal(readFileSync(join(state, 'container'), 'utf8').trim(), 'restored-container');
}
}
if (scenario === 'success') {
assert(calls.indexOf('compose build') < calls.indexOf('compose up'));
assert(calls.indexOf('compose up') < calls.indexOf('image rm'));
assert(calls.includes('image rm extroonie:old'));
assert(!calls.includes('image rm extroonie:previous'));
assert(!calls.includes('image rm unrelated'));
assert(calls.includes('image tag sha256:current extroonie:local'), 'Default Compose image would be stale');
assert(readFileSync(join(state, 'current.yaml'), 'utf8').includes('RELEASE: new-config'));
assert.equal(readFileSync(join(state, 'container'), 'utf8').trim(), 'current-container');
assert.equal(
statSync(join(state, 'current.yaml')).mode & 0o777,
0o600,
'Snapshot may contain private configuration',
);
}
}
console.log(
'Passed: simulated working-file deployments, opt-in pulls, image tagging, verified rollback configuration, scoped cleanup, and real Git work preservation.',
);
} finally {
rmSync(root, { recursive: true, force: true });
}