94 lines
5.6 KiB
TypeScript
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);
|
|
});
|