| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- import assert from 'node:assert/strict';
- import {
- chmodSync,
- mkdirSync,
- mkdtempSync,
- readFileSync,
- rmSync,
- writeFileSync
- } from 'node:fs';
- import os from 'node:os';
- import path from 'node:path';
- import { spawnSync } from 'node:child_process';
- import { test } from 'node:test';
- import { fileURLToPath } from 'node:url';
- const scriptPath = fileURLToPath(new URL('../scripts/deploy-remote.sh', import.meta.url));
- const scriptSource = readFileSync(scriptPath, 'utf8');
- const canRun = process.platform !== 'win32';
- test('checks Maildir access with Dovecot mail worker uid instead of container root', () => {
- assert.match(scriptSource, /compose exec -T --user 1000:1000 dovecot <\/dev\/null sh/);
- assert.match(scriptSource, /fs\.statSync\(probe\)\.isFile\(\)/);
- assert.doesNotMatch(scriptSource, /trap .*rm -f -- \"\$probe\"/);
- });
- test('keeps the Maildir migration as an explicit deployment option', () => {
- assert.match(scriptSource, /run_maildir_migration="\$\{MAILHUB_DEPLOY_RUN_MAILDIR_MIGRATION:-0\}"/);
- assert.match(scriptSource, /if \[\[ "\$\{run_maildir_migration\}" == "1" \]\]; then/);
- assert.match(
- scriptSource,
- /docker compose run --rm --no-deps -T app <\/dev\/null node scripts\/migrate-sqlite-maildir\.js/
- );
- assert.match(scriptSource, /Skipping Maildir migration check; set MAILHUB_DEPLOY_RUN_MAILDIR_MIGRATION=1 to run it\./);
- });
- test('isolates runtime probes and setup scripts from the SSH heredoc stdin', () => {
- assert.match(scriptSource, /docker compose exec -T app <\/dev\/null node -e/);
- assert.match(scriptSource, /\.\/scripts\/prepare-dovecot\.sh <\/dev\/null/);
- assert.equal(
- scriptSource.match(/\.\/scripts\/sync-tls-certificate\.sh <\/dev\/null/g)?.length,
- 3
- );
- });
- test('checks the Dovecot IMAPS authentication path through Lua passdb', () => {
- assert.match(scriptSource, /tls\.connect\(\{[\s\S]*host: "dovecot"[\s\S]*port: 31993/);
- assert.match(scriptSource, /setTimeout\(\(\) => finish\(false\), 15000\)/);
- assert.match(scriptSource, /mailhub-healthcheck@invalid\.invalid/);
- assert.match(scriptSource, /temporary authentication failure\|unavailable/);
- assert.match(scriptSource, /Dovecot IMAPS authentication path check failed\./);
- assert.match(scriptSource, /wait_for_compose_health app postfix\s+docker compose restart dovecot <\/dev\/null/);
- });
- test('waits for app and postfix before restarting Dovecot and certificate synchronization', { skip: !canRun }, (t) => {
- const fixture = createFixture(t);
- const result = runDeploy(fixture);
- assert.equal(result.status, 0, result.stderr);
- const events = readEvents(fixture.logFile);
- assert.deepEqual(
- events.filter((event) => event.startsWith('health:') || event.startsWith('sync:')),
- [
- 'sync:0',
- 'health:app-container',
- 'health:postfix-container',
- 'health:app-container',
- 'health:postfix-container',
- 'health:dovecot-container',
- 'sync:1',
- 'health:app-container',
- 'health:postfix-container',
- 'health:dovecot-container'
- ]
- );
- assert.equal(events.filter((event) => event === 'runtime:app').length, 6);
- assert.equal(events.filter((event) => event === 'runtime:dovecot').length, 2);
- });
- test('continues after remote tools actively consume their stdin', { skip: !canRun }, (t) => {
- const fixture = createFixture(t);
- const result = runDeploy(fixture);
- assert.equal(result.status, 0, result.stderr);
- const events = readEvents(fixture.logFile);
- assert.equal(events.includes('migrate'), false, events.join('\n'));
- assert.ok(events.includes('sync:1'), events.join('\n'));
- assert.equal(events.at(-1), 'final:ps', events.join('\n'));
- });
- test('skips the Maildir migration maintenance window by default', { skip: !canRun }, (t) => {
- const fixture = createFixture(t);
- const result = runDeploy(fixture);
- assert.equal(result.status, 0, result.stderr);
- const events = readEvents(fixture.logFile);
- const offlineSync = events.indexOf('sync:0');
- const pull = events.indexOf('pull:dovecot');
- const up = events.indexOf('up');
- const dovecotRestart = events.indexOf('compose-restart:dovecot');
- assert.equal(events.includes('stop:app,dovecot'), false, events.join('\n'));
- assert.equal(events.includes('migrate'), false, events.join('\n'));
- assert.ok(offlineSync >= 0 && offlineSync < up, events.join('\n'));
- assert.ok(pull >= 0 && pull < up, events.join('\n'));
- assert.ok(up >= 0 && up < dovecotRestart, events.join('\n'));
- });
- test('runs the opt-in Maildir migration without consuming the SSH heredoc stdin', { skip: !canRun }, (t) => {
- const fixture = createFixture(t);
- const result = runDeploy(fixture, { runMaildirMigration: '1' });
- assert.equal(result.status, 0, result.stderr);
- const events = readEvents(fixture.logFile);
- const offlineSync = events.indexOf('sync:0');
- const pull = events.indexOf('pull:dovecot');
- const stop = events.indexOf('stop:app,dovecot');
- const migrate = events.indexOf('migrate');
- const up = events.indexOf('up');
- assert.ok(offlineSync >= 0 && offlineSync < stop, events.join('\n'));
- assert.ok(pull >= 0 && pull < stop, events.join('\n'));
- assert.ok(stop >= 0 && stop < migrate, events.join('\n'));
- assert.ok(migrate >= 0 && migrate < up, events.join('\n'));
- });
- test('reports the previous revision when deployment fails', { skip: !canRun }, (t) => {
- const fixture = createFixture(t);
- const result = runDeploy(fixture, { syncStatus: '19' });
- assert.equal(result.status, 19);
- assert.match(
- result.stderr,
- /Deployment failed\. Previous revision was previous-revision; inspect the running containers before recovery\./
- );
- assert.doesNotMatch(result.stderr, /Maildir cutover is already committed/);
- const events = readEvents(fixture.logFile);
- assert.equal(events.filter((event) => event === 'sync:0').length, 1);
- assert.equal(events.filter((event) => event === 'sync:1').length, 1);
- assert.equal(events.filter((event) => event === 'health:app-container').length, 2);
- assert.equal(events.filter((event) => event === 'health:postfix-container').length, 2);
- assert.equal(events.filter((event) => event === 'health:dovecot-container').length, 1);
- assert.equal(events.includes('stop:app,dovecot'), false, events.join('\n'));
- assert.equal(events.at(-1), 'final:ps', events.join('\n'));
- });
- test('does not apply Maildir cutover protection to default runtime probe failures', { skip: !canRun }, (t) => {
- const fixture = createFixture(t);
- const result = runDeploy(fixture, { runtimeStatus: '29' });
- assert.equal(result.status, 1);
- assert.match(
- result.stderr,
- /Deployment failed\. Previous revision was previous-revision; inspect the running containers before recovery\./
- );
- assert.doesNotMatch(result.stderr, /Maildir cutover is already committed/);
- const events = readEvents(fixture.logFile);
- assert.equal(events.includes('migrate'), false, events.join('\n'));
- assert.equal(events.includes('stop:app,dovecot'), false, events.join('\n'));
- assert.ok(events.includes('compose-restart:dovecot'), events.join('\n'));
- assert.equal(events.at(-1), 'final:ps', events.join('\n'));
- });
- test('stops the app for migration and restarts the previous container if migration fails', { skip: !canRun }, (t) => {
- const fixture = createFixture(t);
- const result = runDeploy(fixture, { migrationStatus: '23', runMaildirMigration: '1' });
- assert.equal(result.status, 23);
- assert.match(result.stderr, /Restarting the pre-migration MailHub mail services\./);
- assert.deepEqual(readEvents(fixture.logFile), [
- 'sync:0',
- 'pull:dovecot',
- 'stop:app,dovecot',
- 'migrate',
- 'restart:app-container',
- 'restart:dovecot-container',
- 'final:ps'
- ]);
- });
- test('keeps the maintenance window explicit after cutover health checks fail', { skip: !canRun }, (t) => {
- const fixture = createFixture(t);
- const result = runDeploy(fixture, { runtimeStatus: '29', runMaildirMigration: '1' });
- assert.notEqual(result.status, 0);
- assert.match(result.stderr, /Maildir cutover is already committed; legacy mail services will not be restarted/);
- assert.match(result.stderr, /maintenance window remains active/);
- assert.equal(readEvents(fixture.logFile).some((event) => event.startsWith('restart:')), false);
- assert.equal(readEvents(fixture.logFile).filter((event) => event === 'stop:app,dovecot').length, 2);
- });
- function createFixture(t) {
- const root = mkdtempSync(path.join(os.tmpdir(), 'mailhub-deploy-script-'));
- const fakeBin = path.join(root, 'bin');
- const remoteDir = path.join(root, 'remote');
- const remoteScriptsDir = path.join(remoteDir, 'scripts');
- const logFile = path.join(root, 'events.log');
- mkdirSync(fakeBin, { recursive: true });
- mkdirSync(remoteScriptsDir, { recursive: true });
- writeFileSync(logFile, '');
- writeExecutable(path.join(fakeBin, 'git'), `#!/bin/sh
- if [ "$1" = "remote" ] && [ "$2" = "get-url" ]; then
- printf '%s\\n' 'ssh://git.example.test/mailhub.git'
- elif [ "$1" = "status" ]; then
- :
- elif [ "$1" = "rev-parse" ] && [ "$2" = "HEAD" ]; then
- if [ "$PWD" = "$MAILHUB_DEPLOY_TEST_REMOTE_DIR" ]; then
- printf '%s\\n' 'previous-revision'
- else
- printf '%s\\n' 'pushed-revision'
- fi
- elif [ "$1" = "rev-parse" ]; then
- printf '%s\\n' 'pushed-revision'
- fi
- `);
- writeExecutable(path.join(fakeBin, 'ssh'), `#!/bin/sh
- while [ "$#" -gt 0 ]; do
- case "$1" in
- -o) shift 2 ;;
- *) break ;;
- esac
- done
- [ "$#" -gt 0 ] && shift
- [ "$#" -gt 0 ] && shift
- [ "\${1:-}" = "--" ] && shift
- exec bash -s -- "$@"
- `);
- writeExecutable(path.join(fakeBin, 'docker'), `#!/bin/sh
- if [ "$1" = "compose" ] && [ "$2" = "ps" ] && [ "\${3:-}" = "--all" ]; then
- printf '%s-container\\n' "$5"
- exit 0
- fi
- if [ "$1" = "compose" ] && [ "$2" = "stop" ]; then
- printf 'stop:%s,%s\\n' "$3" "$4" >> "$MAILHUB_DEPLOY_TEST_LOG"
- exit 0
- fi
- if [ "$1" = "compose" ] && [ "$2" = "run" ]; then
- cat >/dev/null
- printf '%s\\n' 'migrate' >> "$MAILHUB_DEPLOY_TEST_LOG"
- exit "\${MAILHUB_DEPLOY_TEST_MIGRATION_STATUS:-0}"
- fi
- if [ "$1" = "compose" ] && [ "$2" = "pull" ]; then
- printf 'pull:%s\\n' "$3" >> "$MAILHUB_DEPLOY_TEST_LOG"
- exit 0
- fi
- if [ "$1" = "compose" ] && [ "$2" = "up" ]; then
- printf '%s\\n' 'up' >> "$MAILHUB_DEPLOY_TEST_LOG"
- exit 0
- fi
- if [ "$1" = "compose" ] && [ "$2" = "restart" ]; then
- printf 'compose-restart:%s\\n' "$3" >> "$MAILHUB_DEPLOY_TEST_LOG"
- exit 0
- fi
- if [ "$1" = "compose" ] && [ "$2" = "exec" ]; then
- cat >/dev/null
- service=""
- for argument in "$@"; do
- if [ "$argument" = "app" ] || [ "$argument" = "dovecot" ]; then
- service="$argument"
- break
- fi
- done
- printf 'runtime:%s\\n' "$service" >> "$MAILHUB_DEPLOY_TEST_LOG"
- if [ "$service" = "app" ] && [ "\${MAILHUB_DEPLOY_TEST_RUNTIME_STATUS:-0}" != "0" ]; then
- exit "$MAILHUB_DEPLOY_TEST_RUNTIME_STATUS"
- fi
- exit 0
- fi
- if [ "$1" = "compose" ] && [ "$2" = "ps" ] && [ -z "\${3:-}" ]; then
- printf '%s\\n' 'final:ps' >> "$MAILHUB_DEPLOY_TEST_LOG"
- exit 0
- fi
- if [ "$1" = "start" ]; then
- printf 'restart:%s\\n' "$2" >> "$MAILHUB_DEPLOY_TEST_LOG"
- exit 0
- fi
- if [ "$1" = "inspect" ]; then
- container=''
- for argument in "$@"; do
- container="$argument"
- done
- printf 'health:%s\\n' "$container" >> "$MAILHUB_DEPLOY_TEST_LOG"
- printf '%s\\n' 'running healthy'
- fi
- `);
- writeExecutable(path.join(remoteScriptsDir, 'sync-tls-certificate.sh'), `#!/bin/sh
- cat >/dev/null
- printf 'sync:%s\\n' "\${MAILHUB_CERT_RESTART:-}" >> "$MAILHUB_DEPLOY_TEST_LOG"
- if [ "\${MAILHUB_CERT_RESTART:-0}" = "1" ]; then
- exit "\${MAILHUB_DEPLOY_TEST_SYNC_STATUS:-0}"
- fi
- exit 0
- `);
- writeExecutable(path.join(remoteScriptsDir, 'prepare-dovecot.sh'), `#!/bin/sh
- cat >/dev/null
- exit 0
- `);
- t.after(() => rmSync(root, { recursive: true, force: true }));
- return { root, fakeBin, remoteDir, logFile };
- }
- function runDeploy(
- fixture,
- { syncStatus = '0', migrationStatus = '0', runtimeStatus = '0', runMaildirMigration = '0' } = {}
- ) {
- return spawnSync('bash', [scriptPath], {
- cwd: fixture.root,
- encoding: 'utf8',
- env: {
- ...process.env,
- PATH: `${fixture.fakeBin}${path.delimiter}${process.env.PATH ?? ''}`,
- MAILHUB_DEPLOY_REMOTE: 'deploy@example.test',
- MAILHUB_DEPLOY_DIR: fixture.remoteDir,
- MAILHUB_DEPLOY_BRANCH: 'main',
- MAILHUB_DEPLOY_GIT_URL: 'ssh://git.example.test/mailhub.git',
- MAILHUB_DEPLOY_TEST_LOG: fixture.logFile,
- MAILHUB_DEPLOY_TEST_REMOTE_DIR: fixture.remoteDir,
- MAILHUB_DEPLOY_TEST_SYNC_STATUS: syncStatus,
- MAILHUB_DEPLOY_TEST_MIGRATION_STATUS: migrationStatus,
- MAILHUB_DEPLOY_TEST_RUNTIME_STATUS: runtimeStatus,
- MAILHUB_DEPLOY_RUN_MAILDIR_MIGRATION: runMaildirMigration
- }
- });
- }
- function writeExecutable(filePath, contents) {
- writeFileSync(filePath, contents);
- chmodSync(filePath, 0o755);
- }
- function readEvents(logFile) {
- return readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean);
- }
|