Files
minabot/deploy/deploy.test.ts
2026-09-07 13:03:52 +02:00

94 lines
5.6 KiB
TypeScript

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