// deploy/update.sh — one-click Docker update smoke test. // // Runs the real shell script against a fake `docker` and `curl` on PATH, so the // pull → recreate → /health → rollback state machine is exercised without a // Docker daemon and without touching this checkout's containers or data. // // The script is executed on Linux/macOS only: Windows cannot exec shebang // shims from PATH, so the integration cases skip there while the static // contract checks (no `compose down`, no `-v`, rollback command shape) still // run everywhere. import { spawnSync } from 'node:child_process'; import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, dirname, join, resolve } from 'node:path'; import { after, describe, it as nodeIt } from 'node:test'; import assert from 'node:assert/strict'; import { buildDockerDeployerScript } from '../src/dashboard/docker-self-update.js'; const ROOT = resolve(process.cwd()); const SCRIPT = join(ROOT, 'deploy', 'update.sh'); const SCRIPT_SOURCE = readFileSync(SCRIPT, 'utf8'); const COMPOSE_SOURCE = readFileSync(join(ROOT, 'docker-compose.yml'), 'utf8'); const DOCS_SOURCE = readFileSync(join(ROOT, 'docs', 'DOCKER-UPDATE.md'), 'utf8'); function findBash() { if (process.platform !== 'win32') return 'bash'; const candidates = [ process.env.WINDSURFAPI_TEST_BASH, join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'), join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'), 'C:\\Program Files\\Git\\bin\\bash.exe', ].filter(Boolean); for (const candidate of candidates) { if (existsSync(candidate)) return candidate; } return 'bash'; } const BASH = findBash(); // Windows CreateProcess cannot exec a `#!/usr/bin/env bash` file, so a fake // docker on PATH would fall through to the real daemon. Skip integration there. const RUN_INTEGRATION = process.platform !== 'win32'; const INTEGRATION_SKIP = 'deploy/update.sh integration fixtures require a POSIX host (Linux/macOS)'; const it = (name, fn) => nodeIt(name, { skip: RUN_INTEGRATION ? false : INTEGRATION_SKIP }, fn); const OLD_ID = `sha256:${'1'.repeat(64)}`; const NEW_ID = `sha256:${'2'.repeat(64)}`; const ROLLBACK_ID = `sha256:${'3'.repeat(64)}`; const CID = 'a'.repeat(64); const FAKE_DOCKER = `#!/usr/bin/env bash printf '%s\\n' "$*" >> "\${FAKE_DOCKER_LOG:?}" cmd="\${1:-}" if [ "$cmd" = "info" ]; then exit 0; fi if [ "$cmd" = "tag" ]; then exit 0; fi if [ "$cmd" = "image" ]; then printf '%s\\n' "\${FAKE_NEW_ID:-${NEW_ID}}" exit 0 fi if [ "$cmd" = "inspect" ]; then fmt="\${3:-}" case "$fmt" in *".Config.Image"*) printf '%s\\n' "\${FAKE_OLD_REF:-ghcr.io/dwgx/windsurf-api:latest}" ;; *".Image"*) printf '%s\\n' "\${FAKE_OLD_ID:-${OLD_ID}}" ;; *".State.Status"*) printf '%s\\n' "\${FAKE_STATE_STATUS:-running healthy}" ;; *) printf '\\n' ;; esac exit 0 fi if [ "$cmd" = "compose" ]; then joined="$*" case "$joined" in *" version"*) exit 0 ;; *" config --quiet"*) exit 0 ;; *" config --images"*) printf 'ghcr.io/dwgx/windsurf-api:latest\\nnginx:alpine\\n'; exit 0 ;; *" config"*) printf 'services:\\n windsurf-api:\\n image: ghcr.io/dwgx/windsurf-api:latest\\n nginx:\\n image: nginx:alpine\\n'; exit 0 ;; *" ps -q "*) printf '%s\\n' "\${FAKE_CID:-}" ; exit 0 ;; *" port "*) if [ "\${FAKE_NO_PORT:-0}" = "1" ]; then exit 0; fi; printf '0.0.0.0:3999\\n'; exit 0 ;; *" pull "*) if [ "\${FAKE_PULL_FAIL:-0}" = "1" ]; then echo "pull denied" >&2; exit 1; fi; exit 0 ;; *" build "*) if [ "\${FAKE_BUILD_FAIL:-0}" = "1" ]; then echo "build denied" >&2; exit 1; fi; exit 0 ;; *" up "*) if [ "\${FAKE_UP_FAIL:-0}" = "1" ]; then echo "up denied" >&2; exit 1; fi; exit 0 ;; *" exec "*) # First exec hangs like a wedged container; the rollback probe succeeds. if [ "\${FAKE_EXEC_HANG:-0}" = "1" ] && [ ! -f "\${FAKE_EXEC_HANG_FILE:?}" ]; then : > "\${FAKE_EXEC_HANG_FILE}" sleep 60 fi printf '%s\\n' '{"status":"ok","version":"3.9.53","commit":"deadbeef"}'; exit 0 ;; esac exit 0 fi exit 0 `; const FAKE_CURL = `#!/usr/bin/env bash printf 'curl %s\\n' "$*" >> "\${FAKE_DOCKER_LOG:?}" seq="\${FAKE_HEALTH_SEQ:-200}" counter="\${FAKE_HEALTH_COUNTER:?}" n=$(cat "$counter" 2>/dev/null || echo 0) n=$((n + 1)) printf '%s\\n' "$n" > "$counter" code=$(printf '%s' "$seq" | cut -d, -f"$n") if [ -z "$code" ]; then code=$(printf '%s' "$seq" | awk -F, '{print $NF}') fi if [ "$code" = "200" ]; then printf '{"status":"ok","version":"3.9.53","commit":"deadbeef"}\\n' exit 0 fi printf 'unhealthy\\n' >&2 exit 22 `; function writeExecutable(path, source) { writeFileSync(path, source, 'utf8'); chmodSync(path, 0o755); } const FIXTURES = []; function makeFixture() { const root = mkdtempSync(join(tmpdir(), 'wa-docker-oneclick-')); const repo = join(root, 'repo'); const bin = join(root, 'bin'); const state = join(root, 'state'); mkdirSync(join(repo, 'deploy'), { recursive: true }); mkdirSync(bin, { recursive: true }); copyFileSync(SCRIPT, join(repo, 'deploy', 'update.sh')); writeFileSync( join(repo, 'docker-compose.yml'), 'services:\n windsurf-api:\n image: ${WINDSURFAPI_IMAGE:-ghcr.io/dwgx/windsurf-api:latest}\n', 'utf8', ); writeExecutable(join(bin, 'docker'), FAKE_DOCKER); writeExecutable(join(bin, 'curl'), FAKE_CURL); const fixture = { root, repo, bin, state, script: join(repo, 'deploy', 'update.sh'), log: join(root, 'docker.log'), counter: join(root, 'health.counter'), }; FIXTURES.push(root); return fixture; } after(() => { for (const root of FIXTURES) { try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } } }); function run(fixture, args = [], overrides = {}) { const env = { ...process.env, PATH: [fixture.bin, dirname(process.execPath), '/usr/local/bin', '/usr/bin', '/bin'].join(delimiter), FAKE_DOCKER_LOG: fixture.log, FAKE_EXEC_HANG_FILE: join(fixture.root, 'exec.hang'), FAKE_HEALTH_COUNTER: fixture.counter, FAKE_HEALTH_SEQ: '200', FAKE_CID: CID, FAKE_OLD_ID: OLD_ID, FAKE_OLD_REF: 'ghcr.io/dwgx/windsurf-api:latest', FAKE_NEW_ID: NEW_ID, WINDSURFAPI_UPDATE_STATE_DIR: fixture.state, WINDSURFAPI_COMPOSE_FILE: join(fixture.repo, 'docker-compose.yml'), WINDSURFAPI_PROJECT_DIR: fixture.repo, WINDSURFAPI_SERVICE: 'windsurf-api', WINDSURFAPI_LB_SERVICE: 'nginx', WINDSURFAPI_HEALTH_TIMEOUT: '0', WINDSURFAPI_HEALTH_INTERVAL: '0.01', WINDSURFAPI_PROBE_TIMEOUT: '2', WINDSURFAPI_IMAGE: '', ...overrides, }; return spawnSync(BASH, [fixture.script, ...args], { cwd: fixture.repo, encoding: 'utf8', timeout: 30_000, env, }); } function logLines(fixture) { if (!existsSync(fixture.log)) return []; return readFileSync(fixture.log, 'utf8').split('\n').filter(Boolean); } function assertNoDestructiveDockerCalls(fixture) { for (const line of logLines(fixture)) { assert.doesNotMatch(line, /\bcompose\b.*\bdown\b/, `update must never run compose down: ${line}`); assert.doesNotMatch(line, /\bcompose\b.*\s(?:-v|--volumes)\b/, `update must never drop volumes: ${line}`); assert.doesNotMatch(line, /\bvolume\s+rm\b/, `update must never remove volumes: ${line}`); assert.doesNotMatch(line, /^\s*rm\b/, `update must never remove containers/files: ${line}`); } } describe('deploy/update.sh static contract', () => { nodeIt('is a POSIX bash script with strict unset handling', () => { assert.match(SCRIPT_SOURCE, /^#!\/usr\/bin\/env bash\n/); assert.match(SCRIPT_SOURCE, /^set -uo pipefail$/m); }); nodeIt('never tears down the stack or its volumes', () => { assert.doesNotMatch(SCRIPT_SOURCE, /^[^#\n]*\bcompose\s+down\b/m); assert.doesNotMatch(SCRIPT_SOURCE, /^[^#\n]*\bcompose\b[^\n]*\s(?:-v|--volumes)\b/m); assert.doesNotMatch(SCRIPT_SOURCE, /^[^#\n]*\bvolume\s+rm\b/m); assert.doesNotMatch(SCRIPT_SOURCE, /^[^#\n]*\brm\s+-rf\b/m); }); nodeIt('pulls or builds, then recreates only the app, then rolls back', () => { assert.match(SCRIPT_SOURCE, /compose pull "\$SERVICE"/); assert.match(SCRIPT_SOURCE, /compose build "\$SERVICE"/); assert.match(SCRIPT_SOURCE, /up -d --no-deps --no-build/); assert.match(SCRIPT_SOURCE, /up -d --no-deps --no-build --force-recreate "\$SERVICE"/); assert.match(SCRIPT_SOURCE, /resolve_health_url/); assert.match(SCRIPT_SOURCE, /\/health/); }); nodeIt('keeps a rollback point and supports an explicit --rollback', () => { assert.match(SCRIPT_SOURCE, /rollback-point\.env/); assert.match(SCRIPT_SOURCE, /--rollback\)/); assert.match(SCRIPT_SOURCE, /ROLLBACK_ID/); }); nodeIt('exposes a dry-run that prints the plan', () => { assert.match(SCRIPT_SOURCE, /--dry-run\)/); assert.match(SCRIPT_SOURCE, /dry run/); }); nodeIt('compose pins the app image through WINDSURFAPI_IMAGE', () => { assert.match( COMPOSE_SOURCE, /image:\s*\$\{WINDSURFAPI_IMAGE:-ghcr\.io\/dwgx\/windsurf-api:latest\}/, ); }); nodeIt('documents the exact one-command path and the safety properties', () => { assert.match(DOCS_SOURCE, /bash deploy\/update\.sh/); assert.match(DOCS_SOURCE, /--dry-run/); assert.match(DOCS_SOURCE, /--rollback/); assert.match(DOCS_SOURCE, /rollback/i); assert.match(DOCS_SOURCE, /data volume/i); }); }); describe('deploy/update.sh pull → health → rollback', () => { it('pulls, recreates the app, and passes the health gate', () => { const fixture = makeFixture(); const result = run(fixture); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /UPDATE OK/); const lines = logLines(fixture); const pull = lines.findIndex(line => /(^| )compose .* pull windsurf-api$/.test(line)); const up = lines.findIndex(line => /(^| )compose .* up -d --no-deps --no-build windsurf-api$/.test(line)); const probe = lines.findIndex(line => line.startsWith('curl ')); assert.ok(pull >= 0, 'pull must run'); assert.ok(up > pull, 'recreate must follow the pull'); assert.ok(probe > up, 'health probe must follow the recreate'); assert.ok(lines.some(line => line.startsWith(`tag ${OLD_ID} `)), 'previous image must be tagged as the rollback point'); assert.ok(!lines.some(line => line.includes('--force-recreate')), 'successful update must not roll back'); assertNoDestructiveDockerCalls(fixture); }); it('starts the whole stack when no app container exists yet', () => { const fixture = makeFixture(); const result = run(fixture, [], { FAKE_CID: '' }); assert.equal(result.status, 0, result.stderr); const lines = logLines(fixture); assert.ok(lines.some(line => /(^| )compose .* up -d --no-build$/.test(line))); assert.ok(!lines.some(line => line.includes('--no-deps'))); assertNoDestructiveDockerCalls(fixture); }); it('rolls back automatically when /health fails, preserving the data volume', () => { const fixture = makeFixture(); const result = run(fixture, [], { FAKE_HEALTH_SEQ: '500,200' }); assert.equal(result.status, 1, result.stderr); assert.match(result.stdout, /ROLLED BACK/); const lines = logLines(fixture); assert.ok( lines.some(line => / up -d --no-deps --no-build --force-recreate windsurf-api$/.test(line)), 'rollback must force-recreate the app service', ); assert.ok( lines.some(line => line === `tag ${OLD_ID} ghcr.io/dwgx/windsurf-api:latest`), 'rollback must retag the previous image back to the deployment ref', ); assertNoDestructiveDockerCalls(fixture); }); it('bounds a hanging in-container health probe and still rolls back', () => { const fixture = makeFixture(); const started = Date.now(); // No published port forces the `compose exec` fallback. Its first call // hangs like a container that is `running` but wedged — the exact failure // the health gate exists for. Without a bound the script never reaches the // rollback; the fixture's later probes succeed so the rollback gate can // prove the old image healthy. const result = run(fixture, [], { FAKE_NO_PORT: '1', FAKE_EXEC_HANG: '1', WINDSURFAPI_PROBE_TIMEOUT: '1', }); const elapsed = Date.now() - started; assert.equal(result.status, 1, result.stderr); assert.match(result.stdout, /UPDATE FAILED/); assert.match(result.stdout, /ROLLED BACK/); assert.ok(elapsed < 15_000, `a wedged health probe must be bounded (took ${elapsed}ms)`); assertNoDestructiveDockerCalls(fixture); }); it('exits 2 when the rollback is also unhealthy', () => { const fixture = makeFixture(); const result = run(fixture, [], { FAKE_HEALTH_SEQ: '500,500' }); assert.equal(result.status, 2, result.stderr); assert.match(result.stderr, /manual intervention/i); assertNoDestructiveDockerCalls(fixture); }); it('leaves the running container untouched when the pull fails', () => { const fixture = makeFixture(); const result = run(fixture, [], { FAKE_PULL_FAIL: '1' }); assert.equal(result.status, 1, result.stderr); assert.match(result.stderr, /pull failed/); const lines = logLines(fixture); assert.ok(lines.some(line => /(^| )compose .* pull windsurf-api$/.test(line))); assert.ok(!lines.some(line => /(^| )compose .* up /.test(line)), 'a failed pull must not recreate'); assertNoDestructiveDockerCalls(fixture); }); it('builds locally with --build instead of pulling', () => { const fixture = makeFixture(); const result = run(fixture, ['--build']); assert.equal(result.status, 0, result.stderr); const lines = logLines(fixture); assert.ok(lines.some(line => /(^| )compose .* build windsurf-api$/.test(line))); assert.ok(!lines.some(line => /(^| )compose .* pull /.test(line))); assertNoDestructiveDockerCalls(fixture); }); it('pins a release tag with --tag and pulls that exact reference', () => { const fixture = makeFixture(); const result = run(fixture, ['--tag', 'v3.9.52']); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Pulling ghcr\.io\/dwgx\/windsurf-api:3\.9\.52/); assert.ok(logLines(fixture).some(line => /(^| )compose .* pull windsurf-api$/.test(line))); }); it('does not mutate anything in --dry-run', () => { const fixture = makeFixture(); const result = run(fixture, ['--dry-run']); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /dry run/); const lines = logLines(fixture); assert.ok(lines.some(line => /(^| )compose .* ps -q windsurf-api$/.test(line))); for (const line of lines) { assert.doesNotMatch(line, / (?:pull|build|up) /, `dry-run must not mutate: ${line}`); assert.doesNotMatch(line, /^tag /, `dry-run must not tag: ${line}`); } }); it('refuses to run while another update holds the lock', () => { const fixture = makeFixture(); mkdirSync(join(fixture.state, 'lock'), { recursive: true }); writeFileSync(join(fixture.state, 'lock', 'pid'), `${process.pid}\n`, 'utf8'); const result = run(fixture); assert.equal(result.status, 1, result.stderr); assert.match(result.stderr, /another update is already running/); assert.ok(!logLines(fixture).some(line => /(^| )compose .* pull /.test(line))); }); it('fails closed when the lock has no owner pid', () => { const fixture = makeFixture(); mkdirSync(join(fixture.state, 'lock'), { recursive: true }); const result = run(fixture); assert.equal(result.status, 1, result.stderr); assert.match(result.stderr, /without an owner pid/); assert.ok(!logLines(fixture).some(line => /(^| )compose .* pull /.test(line))); }); it('recovers a lock whose owner pid is provably dead', () => { const fixture = makeFixture(); mkdirSync(join(fixture.state, 'lock'), { recursive: true }); writeFileSync(join(fixture.state, 'lock', 'pid'), '2147483646\n', 'utf8'); const result = run(fixture); assert.equal(result.status, 0, result.stderr); assert.ok(logLines(fixture).some(line => /(^| )compose .* pull windsurf-api$/.test(line))); }); it('restores the saved rollback point with --rollback', () => { const fixture = makeFixture(); mkdirSync(fixture.state, { recursive: true }); writeFileSync( join(fixture.state, 'rollback-point.env'), `image_id=${ROLLBACK_ID}\nimage_ref=ghcr.io/dwgx/windsurf-api:3.9.52\nsaved_at=2026-10-23T00:00:00Z\n`, 'utf8', ); const result = run(fixture, ['--rollback']); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /ROLLED BACK/); const lines = logLines(fixture); assert.ok(lines.some(line => line === `tag ${ROLLBACK_ID} ghcr.io/dwgx/windsurf-api:3.9.52`)); assert.ok(lines.some(line => / up -d --no-deps --no-build --force-recreate windsurf-api$/.test(line))); assertNoDestructiveDockerCalls(fixture); }); }); // D6 (audit2/Update.md P2-3): the dashboard docker path used to return // {ok:true} right after starting a deployer that ran a bare `compose up -d`. // No rollback point was recorded, the deployer was auto-removed (so a failure // left no evidence), and a crash-looping container was indistinguishable from // a successful update. The deployer script now health-gates the recreate and // restores the previous image on failure, and its exit code is durable. // // These arms execute the real generated script against a fake docker CLI. // Exit codes: 0 healthy · 1 rolled back healthy · 2 rollback failed · 3 recreate failed. const DEPLOYER_OLD_ID = `sha256:${'1'.repeat(64)}`; const DEPLOYER_ROLLBACK_REF = 'ghcr.io/dwgx/windsurf-api:rollback'; const DEPLOYER_TARGET = `ghcr.io/dwgx/windsurf-api@sha256:${'4'.repeat(64)}`; const FAKE_DEPLOYER_DOCKER = `#!/usr/bin/env bash printf '%s|IMAGE=%s\\n' "$*" "\${WINDSURFAPI_IMAGE:-}" >> "\${FAKE_DOCKER_LOG:?}" cmd="\${1:-}" if [ "$cmd" = "tag" ]; then exit 0; fi if [ "$cmd" = "compose" ]; then joined="$*" case "$joined" in *" exec "*) counter="\${FAKE_HEALTH_COUNTER:?}" n=$(cat "$counter" 2>/dev/null || echo 0) n=$((n + 1)) printf '%s\\n' "$n" > "$counter" code=$(printf '%s' "\${FAKE_HEALTH_SEQ:-200}" | cut -d, -f"$n") if [ -z "$code" ]; then code=$(printf '%s' "\${FAKE_HEALTH_SEQ:-200}" | awk -F, '{print $NF}') fi if [ "$code" = "200" ]; then printf '{"status":"ok"}\\n'; exit 0; fi exit 1 ;; *" up "*) if [ "\${FAKE_UP_FAIL_FIRST:-0}" = "1" ] && [ ! -f "\${FAKE_UP_MARKER:?}" ]; then : > "\$FAKE_UP_MARKER" exit 1 fi if [ "\${FAKE_UP_FAIL:-0}" = "1" ]; then exit 1; fi exit 0 ;; esac exit 0 fi exit 0 `; function makeDeployerFixture() { const root = mkdtempSync(join(tmpdir(), 'wa-docker-deployer-')); const bin = join(root, 'bin'); mkdirSync(bin, { recursive: true }); writeExecutable(join(bin, 'docker'), FAKE_DEPLOYER_DOCKER); const fixture = { root, bin, log: join(root, 'docker.log'), counter: join(root, 'health.counter'), upMarker: join(root, 'up.marker'), composeFile: join(root, 'docker-compose.yml'), }; writeFileSync(fixture.composeFile, 'services:\n windsurf-api:\n image: test\n', 'utf8'); FIXTURES.push(root); return fixture; } function runDeployerScript(fixture, overrides = {}) { const script = buildDockerDeployerScript({ project: 'windsurf', workingDir: fixture.root, configFiles: [fixture.composeFile], service: 'windsurf-api', oldImageId: DEPLOYER_OLD_ID, rollbackRef: DEPLOYER_ROLLBACK_REF, targetImage: DEPLOYER_TARGET, delaySeconds: 0, // Two probes per gate: the sequences below are deterministic. healthTimeoutSeconds: 0.1, healthIntervalSeconds: 0.05, }); return spawnSync(BASH, ['-c', script], { cwd: fixture.root, encoding: 'utf8', timeout: 30_000, env: { ...process.env, PATH: [fixture.bin, dirname(process.execPath), '/usr/local/bin', '/usr/bin', '/bin'].join(delimiter), FAKE_DOCKER_LOG: fixture.log, FAKE_HEALTH_COUNTER: fixture.counter, FAKE_HEALTH_SEQ: '200', FAKE_UP_MARKER: fixture.upMarker, ...overrides, }, }); } describe('docker self-update deployer: health gate and rollback (D6)', () => { nodeIt('exits 0 without rolling back when the recreated container is healthy', { skip: false }, () => { const fixture = makeDeployerFixture(); const result = runDeployerScript(fixture); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); const lines = logLines(fixture); const firstUp = lines.find(line => /\bcompose\b.*\bup -d\b/.test(line)); assert.ok(firstUp, 'must recreate the stack'); assert.match(firstUp, /--no-build/, 'the first compose up must never build from the checkout'); assert.ok(firstUp.includes(`IMAGE=${DEPLOYER_TARGET}`), 'the first compose up must select the verified digest target'); assert.equal(lines.some(line => line.startsWith('tag ')), false, 'a healthy update must not tag a rollback image'); assert.equal(lines.some(line => line.includes('--force-recreate')), false, 'a healthy update must not recreate the previous image'); }); nodeIt('restores the previous image when the first health gate fails', { skip: false }, () => { const fixture = makeDeployerFixture(); const result = runDeployerScript(fixture, { FAKE_HEALTH_SEQ: '500,500,200' }); assert.equal(result.status, 1, `a healthy rollback must exit 1 (not success): ${result.stdout}\n${result.stderr}`); const lines = logLines(fixture); assert.ok( lines.some(line => line.startsWith(`tag ${DEPLOYER_OLD_ID} ${DEPLOYER_ROLLBACK_REF}`)), `must tag the previous image for rollback: ${lines.join('\n')}`, ); const rollbackUp = lines.find(line => line.includes('up -d --no-deps --no-build --force-recreate')); assert.ok(rollbackUp, 'must recreate the app service from the previous image'); assert.match(rollbackUp, new RegExp(`IMAGE=${DEPLOYER_ROLLBACK_REF.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), 'the rollback recreate must select the previous image'); }); nodeIt('exits 2 when the rollback container is also unhealthy', { skip: false }, () => { const fixture = makeDeployerFixture(); const result = runDeployerScript(fixture, { FAKE_HEALTH_SEQ: '500,500,500,500' }); assert.equal(result.status, 2, `${result.stdout}\n${result.stderr}`); }); nodeIt('exits 3 when the recreate itself fails', { skip: false }, () => { const fixture = makeDeployerFixture(); const result = runDeployerScript(fixture, { FAKE_UP_FAIL_FIRST: '1' }); assert.equal(result.status, 3, `${result.stdout}\n${result.stderr}`); assert.equal(logLines(fixture).some(line => line.startsWith('tag ')), false, 'a recreate that never started needs no image rollback'); }); });