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 }); }