Ctrl K
ComunidadGuías…

test/docker-oneclick-update.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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');
  });
});