import { expect, test } from 'bun:test'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; const revision = 'b'.repeat(40); const stub = `#!${process.execPath} import {basename} from 'node:path'; import {appendFileSync,mkdirSync,existsSync,writeFileSync,statSync} from 'node:fs'; const tool=basename(process.argv[1]), args=process.argv.slice(2), root=process.env.STUB_ROOT, mode=process.env.STUB_MODE; appendFileSync(root+'/calls.jsonl',JSON.stringify({tool,args})+'\\n'); if(tool==='flock') process.exit(0); if(tool==='timeout') { const r=Bun.spawnSync(args.slice(1),{env:process.env});process.stdout.write(r.stdout);process.stderr.write(r.stderr);process.exit(r.exitCode); } if(tool==='git') { if(args.includes('init')) mkdirSync(args.at(-1),{recursive:true}); if(args.includes('rev-parse')) console.log('${revision}'); if(args.includes('archive')) { const r=Bun.spawnSync(['tar','-cf','-','-C',process.env.STUB_SOURCE,'.']);process.stdout.write(r.stdout);process.exit(r.exitCode); } process.exit(0); } if(tool==='curl') { console.log('{"status":"ok"}');process.exit(0); } if(tool==='sudo') { if(args.includes('build') && !(statSync(args.at(-1)+'/Caddyfile').mode & 4)) {console.error('Source unreadable to runtime user');process.exit(2);} if(args.includes('build') && mode==='build-failure') process.exit(1); if(args.some(a=>a.includes('SELECT type,name,sql'))) { console.log(mode==='migration-failure' && existsSync(root+'/switched')?'schema-new':'schema-old');process.exit(0); } if(args.includes('compose') && args.includes('up') && args.includes('app')) { writeFileSync(root+'/switched','yes'); if(['switch-failure','migration-failure'].includes(mode)) process.exit(1); } if(args.includes('scripts/backup.ts')) console.log('/data/backups/safety.sqlite'); if(args.includes('inspect')) console.log('sha256:test-image'); process.exit(0); } process.exit(2); `; async function scenario(mode: string, initial?: 'unchanged' | 'failed') { const root = mkdtempSync(join(tmpdir(), 'minabot-deploy-test-')); const bin = join(root, 'bin'), source = join(root, 'source'); mkdirSync(bin); mkdirSync(source); for (const name of ['git', 'sudo', 'timeout', 'flock', 'curl']) writeFileSync(join(bin, name), stub, { mode: 0o755 }); writeFileSync(join(root, '.env'), 'APP_IMAGE=minabot:previous\nCADDY_IMAGE=caddy:pinned\n'); writeFileSync(join(root, '.env.production'), 'SECRET=must-stay-server-side\n'); writeFileSync(join(root, 'compose.yaml'), 'old-compose'); writeFileSync(join(root, 'Caddyfile'), 'old-caddy'); writeFileSync(join(root, 'deployed-sha'), initial === 'unchanged' ? revision : 'a'.repeat(40)); if (initial === 'failed') writeFileSync(join(root, 'failed-sha'), revision); writeFileSync(join(source, 'compose.yaml'), 'new-compose'); writeFileSync(join(source, 'Caddyfile'), 'new-caddy'); try { const child = Bun.spawn(['bash', new URL('./deploy.sh', import.meta.url).pathname], { env: { ...process.env, PATH: `${bin}:${dirname(process.execPath)}:${process.env.PATH}`, MINABOT_DEPLOY_ROOT: root, STUB_ROOT: root, STUB_SOURCE: source, STUB_MODE: mode }, stdout: 'pipe', stderr: 'pipe', }); const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); const calls = readFileSync(join(root, 'calls.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)); return { code, stdout, stderr, calls, env: readFileSync(join(root, '.env'), 'utf8'), secret: readFileSync(join(root, '.env.production'), 'utf8'), deployed: readFileSync(join(root, 'deployed-sha'), 'utf8').trim(), failed: existsSync(join(root, 'failed-sha')) }; } finally { rmSync(root, { recursive: true, force: true }); } } test('deploys tested main, preserves secrets, and stops old workers before starting new ones', async () => { const r = await scenario('success'); expect(r.code).toBe(0); expect(r.deployed).toBe(revision); expect(r.env).toContain('APP_IMAGE=minabot:git-bbbbbbbbbbbb'); expect(r.secret).toBe('SECRET=must-stay-server-side\n'); const stop = r.calls.findIndex(c => c.tool === 'sudo' && c.args.includes('stop')); const up = r.calls.findIndex(c => c.tool === 'sudo' && c.args.includes('up')); expect(stop).toBeGreaterThan(0); expect(up).toBeGreaterThan(stop); }); test('unchanged and previously failed revisions do no Docker work', async () => { for (const initial of ['unchanged', 'failed'] as const) { const r = await scenario('success', initial); expect(r.code).toBe(0); expect(r.calls.some(c => c.tool === 'sudo')).toBe(false); } }); test('build failure leaves the current deployment running', async () => { const r = await scenario('build-failure'); expect(r.code).not.toBe(0); expect(r.failed).toBe(true); expect(r.env).toContain('APP_IMAGE=minabot:previous'); expect(r.calls.some(c => c.args.includes('stop'))).toBe(false); }); test('startup failure rolls back configuration without restoring the database', async () => { const r = await scenario('switch-failure'); expect(r.code).not.toBe(0); expect(r.env).toContain('APP_IMAGE=minabot:previous'); expect(r.stderr).toContain('database was not replaced'); expect(r.deployed).toBe('a'.repeat(40)); }); test('a changed migration fingerprint stops instead of overwriting production data', async () => { const r = await scenario('migration-failure'); expect(r.code).not.toBe(0); expect(r.failed).toBe(true); expect(r.stderr).toContain('No database was overwritten'); expect(r.deployed).toBe('a'.repeat(40)); expect(r.calls.filter(c => c.tool === 'sudo' && c.args.includes('up'))).toHaveLength(1); });