mirror of
https://github.com/Extroonie/extroonie.com.git
synced 2026-10-12 17:48:06 -07:00
190 lines
7.4 KiB
JavaScript
190 lines
7.4 KiB
JavaScript
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 (including the remote and fast-forward). 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 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 ;;
|
|
'compose build --pull site')
|
|
grep -q 'RELEASE: new-config' "$snapshot"
|
|
[ "$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) 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',
|
|
'diverged',
|
|
]) {
|
|
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);
|
|
writeFileSync(join(seed, '.gitignore'), '/deploy.sh\n');
|
|
writeFileSync(join(seed, 'compose.yaml'), 'old-config\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);
|
|
copyFileSync(resolve('deploy.sh'), join(checkout, 'deploy.sh'));
|
|
assert.equal(git(checkout, 'check-ignore', 'deploy.sh'), 'deploy.sh');
|
|
const state = join(checkout, '.git/extroonie-deploy');
|
|
if (scenario === 'cached-rollback') {
|
|
mkdirSync(state);
|
|
writeFileSync(join(state, 'container'), 'previous-container\n');
|
|
writeFileSync(join(state, 'current.yaml'), 'image: sha256:previous\nRELEASE: old-config\n');
|
|
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') writeFileSync(join(checkout, 'local.txt'), 'keep me');
|
|
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 run = spawnSync('sh', ['./deploy.sh'], {
|
|
cwd: checkout,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${bin}:${process.env.PATH}`,
|
|
DEPLOY_LOG: log,
|
|
SCENARIO: scenario,
|
|
},
|
|
});
|
|
const calls = readFileSync(log, 'utf8');
|
|
if (['success', 'first-deploy'].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' || scenario === 'diverged') {
|
|
assert(!calls.includes('compose build'), 'Built despite unsafe checkout');
|
|
assert(existsSync(join(checkout, 'local.txt')), 'Local work lost');
|
|
continue;
|
|
}
|
|
assert(existsSync(join(checkout, 'update.txt')), 'Remote changes were not pulled');
|
|
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'].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 first deployment, stable image tagging, saved-config rollback, repeated/failed rollback, scoped cleanup, and real Git safety checks.',
|
|
);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|