Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
166 lines
30 KiB
JavaScript
166 lines
30 KiB
JavaScript
/** test/agent.test.mjs — Node Agent 실행기 회귀 테스트. 임시 루트 + 명령 stub(ARIWEB_AGENT_STUB_CMDS=1)으로 실제 시스템을 건드리지 않는다. tar 는 실제로 실행한다. */
|
|
process.env.ARIWEB_AGENT_STUB_CMDS = '1';
|
|
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
|
|
import { createSys } from '../agent/sys.mjs';
|
|
import { createRunner } from '../agent/runner.mjs';
|
|
import { KINDS } from '../agent/jobs/index.mjs';
|
|
import { ingestLog } from '../agent/jobs/site.mjs';
|
|
import { sample } from '../agent/metrics.mjs';
|
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readlinkSync, copyFileSync, statSync, appendFileSync, chmodSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { ROOT as ROOT_DIR, readJson } from '../agent/jobs/common.mjs';
|
|
|
|
/** 가짜 HAL 클라이언트: 전송은 메모리(파일 경로) 저장, artifact 는 미리 둔 파일 */
|
|
function fakeClient({ artifacts = {} } = {}) {
|
|
const transfers = new Map(); const calls = []; const up = join(tmpDir(), 'uploads'); mkdirSync(up, { recursive: true });
|
|
return { calls, transfers,
|
|
async request(method, path, body) { calls.push({ method, path, body }); return { ok: true }; },
|
|
async download(path, dest, expected) { const src = artifacts[path]; if (!src) { const e = new Error('404'); e.code = 'http_404'; throw e; } copyFileSync(src, dest); const sum = createHash('sha256').update(readFileSync(dest)).digest('hex'); if (expected && expected !== sum) throw Object.assign(new Error('checksum'), { code: 'checksum_mismatch' }); return { sha256: sum, size: statSync(dest).size }; },
|
|
async upload(id, file) { const copy = join(up, id + '.bin'); copyFileSync(file, copy); const sum = createHash('sha256').update(readFileSync(copy)).digest('hex'); this.transfers.set(id, { path: copy, sha256: sum }); return { id, sha256: sum, size: statSync(copy).size }; },
|
|
async downloadTransfer(id, dest, expected) { const t = this.transfers.get(id); if (!t) throw Object.assign(new Error('transfer missing'), { code: 'transfer_not_found' }); copyFileSync(t.path, dest); if (expected && String(expected).replace(/^sha256:/, '') !== t.sha256) throw Object.assign(new Error('checksum'), { code: 'checksum_mismatch' }); return { sha256: t.sha256, size: statSync(dest).size }; },
|
|
clockSkewMs: 0 };
|
|
}
|
|
function env(over = {}) {
|
|
const base = tmpDir(); const roots = { sites: join(base, 'sites'), srv: join(base, 'srv'), nginx: join(base, 'nginx'), backups: join(base, 'backups'), logs: join(base, 'logs'), acme: join(base, 'acme'), letsencrypt: join(base, 'le'), wildcardCert: join(base, 'le', 'wild'), platformDomain: 'ariweb.net', maintPage: '' };
|
|
for (const r of Object.values(roots)) if (r) mkdirSync(r, { recursive: true }); mkdirSync(join(roots.nginx, 'sites-available'), { recursive: true }); mkdirSync(join(roots.nginx, 'sites-enabled'), { recursive: true });
|
|
const sys = createSys({ stub: true }); const client = fakeClient(over); const dataDir = join(base, 'agent-data'); mkdirSync(dataDir, { recursive: true });
|
|
const runner = createRunner({ kinds: KINDS, roots, sys, client, dataDir }); let n = 0;
|
|
return { base, roots, sys, client, dataDir, runner, run: (kind, payload, id = `aj_${++n}`) => runner.execute({ id, kind, payload }) };
|
|
}
|
|
/** 사이트 번들 디렉터리 만들기 */
|
|
function bundle(base, name, files = { 'index.html': '<h1>hello marker-1</h1>' }) { const d = join(base, 'bundles', name); mkdirSync(d, { recursive: true }); for (const [f, c] of Object.entries(files)) { mkdirSync(join(d, f, '..'), { recursive: true }); writeFileSync(join(d, f), c); } return d; }
|
|
|
|
await test('site.deploy: 레이아웃(releases/current)·버전 기록·vhost·내부 확인·previous, rollback, 실패 시 이전 버전 유지와 leftovers, remove', async () => {
|
|
const e = env(); const b1 = bundle(e.base, 'b1'); mkdirSync(e.roots.wildcardCert, { recursive: true }); writeFileSync(join(e.roots.wildcardCert, 'fullchain.pem'), 'x');
|
|
let r = await e.run('site.deploy', { siteId: 'site1', versionRef: 'v1', bundlePath: b1, domains: ['site1.ariweb.net'], primaryDomain: 'site1.ariweb.net', expectMarker: 'marker-1' }); eq(r.status, 'done', JSON.stringify(r));
|
|
const sd = join(e.roots.sites, 'site1'); const cur = readlinkSync(join(sd, 'current')); ok(cur.includes('/releases/')); ok(existsSync(join(cur, 'index.html'))); ok(JSON.parse(readFileSync(join(cur, '.ariweb-version.json'), 'utf8')).versionRef === 'v1');
|
|
ok(r.result.https, '와일드카드 인증서 적용'); eq(r.result.certs['site1.ariweb.net'].source, 'wildcard'); ok(existsSync(join(e.roots.nginx, 'sites-enabled', 'aw-site1.conf')));
|
|
const conf = readFileSync(join(e.roots.nginx, 'sites-available', 'aw-site1.conf'), 'utf8'); ok(conf.includes('server_name site1.ariweb.net;') && conf.includes('listen 443 ssl') && conf.includes('awstats') && conf.includes('.maintenance'));
|
|
ok(e.sys.calls.some((c) => c.args.includes('nginx') && c.args.includes('-t')), 'nginx -t 실행'); eq(r.result.previousVersion, null);
|
|
// 두 번째 배포 → previous, rollback
|
|
const b2 = bundle(e.base, 'b2', { 'index.html': '<h1>v2 marker-2</h1>' }); r = await e.run('site.deploy', { siteId: 'site1', versionRef: 'v2', bundlePath: b2, domains: ['site1.ariweb.net'], expectMarker: 'marker-2' }); eq(r.status, 'done'); ok(r.result.previousVersion); ok(readFileSync(join(sd, 'current', 'index.html'), 'utf8').includes('v2'));
|
|
r = await e.run('site.rollback', { siteId: 'site1', domains: ['site1.ariweb.net'] }); eq(r.status, 'done', JSON.stringify(r)); ok(readFileSync(join(sd, 'current', 'index.html'), 'utf8').includes('marker-1'));
|
|
// 실패: 마커 없음 → 이전 버전 유지, leftovers 보고
|
|
const b3 = bundle(e.base, 'b3', { 'index.html': 'broken' }); r = await e.run('site.deploy', { siteId: 'site1', versionRef: 'v3', bundlePath: b3, domains: ['site1.ariweb.net'], expectMarker: 'marker-9' }); eq(r.status, 'failed'); eq(r.errorCode, 'internal_check_failed'); ok(r.result.previousKept); ok(r.result.leftovers.length >= 1); ok(readFileSync(join(sd, 'current', 'index.html'), 'utf8').includes('marker-1'), '이전 버전 유지');
|
|
r = await e.run('site.deploy', { siteId: '../etc', bundlePath: b1 }); eq(r.status, 'failed'); eq(r.errorCode, 'bad_site_id');
|
|
r = await e.run('site.remove', { siteId: 'site1', keepData: true }); eq(r.status, 'done'); ok(!existsSync(join(sd, 'current'))); ok(existsSync(join(sd, 'data'))); ok(!existsSync(join(e.roots.nginx, 'sites-enabled', 'aw-site1.conf')));
|
|
});
|
|
await test('실행기: 단계 저장과 재시작 뒤 재개(끝난 단계 건너뜀), needs_user 보고', async () => {
|
|
const e = env(); let aRuns = 0; let fail = true;
|
|
e.runner.kinds['test.resume'] = { async run(ctx) { await ctx.step('a', () => { aRuns++; return 1; }); if (fail) throw new Error('crash'); await ctx.step('b', () => 2); return { ok: true }; } };
|
|
let r = await e.runner.execute({ id: 'aj_r1', kind: 'test.resume', payload: {} }); eq(r.status, 'failed'); eq(aRuns, 1);
|
|
// 실패한 작업 상태 파일은 지워지지만, 재시작 시나리오(실행 중 죽음)는 상태 파일이 남는다 → 흉내: 상태 파일을 다시 써 둔다
|
|
writeFileSync(join(e.dataDir, 'jobs', 'aj_r1.json'), JSON.stringify({ steps: [{ id: 'a', title: 'a', state: 'done', result: 1 }], log: [] })); fail = false;
|
|
r = await e.runner.execute({ id: 'aj_r1', kind: 'test.resume', payload: {} }); eq(r.status, 'done'); eq(aRuns, 1, '끝난 단계는 다시 실행하지 않음'); eq(r.steps.map((s) => s.id), ['a', 'b']);
|
|
e.runner.kinds['test.ask'] = { async run(ctx) { ctx.needsUser('sudo 가 없습니다', 'sudoers 설치'); } }; r = await e.runner.execute({ id: 'aj_r2', kind: 'test.ask', payload: {} }); eq(r.status, 'needs_user'); ok(r.userMessage.includes('sudoers'));
|
|
ok(e.client.calls.some((c) => c.path.endsWith('/result')), '결과 보고');
|
|
});
|
|
await test('site.usage: awstats 로그 증분 파싱·일별 집계·오류율·p95·디렉터리 크기', async () => {
|
|
const e = env(); const b = bundle(e.base, 'u', { 'index.html': 'x'.repeat(1000) }); await e.run('site.deploy', { siteId: 'u1', bundlePath: b }); const log = join(e.roots.logs, 'aw-u1.access.log'); const today = new Date().toISOString().slice(0, 10);
|
|
writeFileSync(log, `${today}T10:00:00+00:00|u1.preview.ariweb.net|300|5000|200|0.050|/\n${today}T10:00:01+00:00|u1.preview.ariweb.net|200|1000|500|2.000|/x\n`);
|
|
let r = await e.run('site.usage', { siteIds: ['u1'] }); const u = r.result.sites.u1; eq(u.req24h, 2); eq(u.outBytesDay, 6000); eq(u.inBytesDay, 500); eq(u.errorRate, 50); eq(u.p95ms, 1000); ok(u.diskBytes >= 1000); eq(u.series7d.length, 7);
|
|
appendFileSync(log, `${today}T11:00:00+00:00|u1.preview.ariweb.net|100|100|200|0.010|/\n`); r = await e.run('site.usage', { siteIds: ['u1'] }); eq(r.result.sites.u1.req24h, 3, '증분만 더함'); eq(r.result.sites.u1.outBytesDay, 6100);
|
|
const st = { days: {}, offsets: {} }; ingestLog({ roots: e.roots }, 'u1', log, st); eq(Object.values(st.days)[0].req, 3);
|
|
const m = sample(); ok(m.mem && m.mem.total > 0); ok(typeof m.load1 === 'number');
|
|
});
|
|
await test('backup: 생성(파일·데이터·DB·설정)·검증·원격 저장(전송)·복원(보호 시점·격리 확인)·삭제', async () => {
|
|
const e = env(); const b = bundle(e.base, 'k', { 'index.html': '<p>marker-k</p>', 'ariweb.json': '{"runtime":"static"}' }); await e.run('site.deploy', { siteId: 'k1', bundlePath: b, expectMarker: 'marker-k' });
|
|
const sd = join(e.roots.sites, 'k1'); mkdirSync(join(sd, 'data', 'uploads'), { recursive: true }); writeFileSync(join(sd, 'data', 'uploads', 'a.txt'), 'upload-a'); mkdirSync(join(sd, 'data', 'db'), { recursive: true });
|
|
const { DatabaseSync } = await import('node:sqlite'); const db = new DatabaseSync(join(sd, 'data', 'db', 'site.sqlite')); db.exec('CREATE TABLE t(x); INSERT INTO t VALUES(42)'); db.close();
|
|
let r = await e.run('backup.create', { siteId: 'k1', scope: {}, uploadToHal: true, transferId: 'tr_bk_1' }); eq(r.status, 'done', JSON.stringify(r)); ok(r.result.checksum); eq(r.result.scope.db, ['site.sqlite']); ok(r.result.transferId === 'tr_bk_1'); const list = execFileSync('tar', ['-tzf', e.client.transfers.get('tr_bk_1').path]).toString(); ok(list.includes('./meta.json') && list.includes('./files.tar') && list.includes('./data.tar') && list.includes('./db/site.sqlite') && list.includes('./config/vhost.conf'), list);
|
|
// 저장 서버 역할: 전송에서 받아 보관·검증
|
|
const e2 = env(); e2.client.transfers.set('tr_bk_1', e.client.transfers.get('tr_bk_1')); r = await e2.run('backup.store', { siteId: 'k1', backupId: 'bk-x', transferId: 'tr_bk_1', sha256: e.client.transfers.get('tr_bk_1').sha256 }); eq(r.status, 'done', JSON.stringify(r)); const loc = r.result.path; ok(existsSync(loc));
|
|
r = await e2.run('backup.verify', { location: loc, checksum: r.result.checksum }); ok(r.result.ok); r = await e2.run('backup.verify', { location: loc, checksum: 'deadbeef' }); eq(r.result.ok, false); eq(r.result.reason, 'checksum_mismatch');
|
|
r = await e2.run('backup.fetch', { location: loc, transferId: 'tr_rs_1' }); eq(r.status, 'done'); ok(e2.client.transfers.get('tr_rs_1'));
|
|
// 사이트를 망가뜨린 뒤 복원
|
|
writeFileSync(join(sd, 'current', 'index.html'), 'broken'); writeFileSync(join(sd, 'data', 'uploads', 'a.txt'), 'changed'); e.client.transfers.set('tr_rs_1', e2.client.transfers.get('tr_rs_1'));
|
|
r = await e.run('backup.restore', { siteId: 'k1', transferId: 'tr_rs_1', checksum: e2.client.transfers.get('tr_rs_1').sha256, protectPoint: true, isolatedVerify: true }); eq(r.status, 'done', JSON.stringify(r)); ok(r.result.protectPointLocation && existsSync(r.result.protectPointLocation)); ok(r.result.isolatedVerify);
|
|
ok(readFileSync(join(sd, 'current', 'index.html'), 'utf8').includes('marker-k')); eq(readFileSync(join(sd, 'data', 'uploads', 'a.txt'), 'utf8'), 'upload-a'); ok(readdirSync(sd).some((x) => x.startsWith('data.pre-restore-')), '이전 데이터 보존'); const db2 = new DatabaseSync(join(sd, 'data', 'db', 'site.sqlite')); eq(db2.prepare('SELECT x FROM t').get().x, 42); db2.close();
|
|
r = await e.run('backup.restore', { siteId: 'other', location: loc, protectPoint: false }); eq(r.status, 'failed'); eq(r.errorCode, 'backup_site_mismatch');
|
|
r = await e2.run('backup.delete', { location: loc }); ok(r.result.deleted); ok(!existsSync(loc)); r = await e2.run('backup.delete', { location: '/etc/passwd' }); eq(r.result.deleted, false, '저장소 밖은 지우지 않음');
|
|
});
|
|
await test('relocate 보조: export → import → stage_check → activate(새 서버) → deactivate(이전 서버 사본 보존), freeze', async () => {
|
|
const src = env(); const dst = env(); dst.client.transfers = src.client.transfers;
|
|
const b = bundle(src.base, 'r', { 'index.html': 'reloc marker-r' }); await src.run('site.deploy', { siteId: 'r1', bundlePath: b, domains: ['r1.example.com'] }); mkdirSync(join(src.roots.sites, 'r1', 'data'), { recursive: true }); writeFileSync(join(src.roots.sites, 'r1', 'data', 'd.txt'), 'data-r');
|
|
let r = await dst.run('site.prepare', { siteId: 'r1' }); eq(r.status, 'done');
|
|
r = await src.run('site.freeze', { siteId: 'r1', freeze: true }); ok(existsSync(join(src.roots.sites, 'r1', '.frozen')));
|
|
r = await src.run('site.export', { siteId: 'r1', transferId: 'tr_rl1', includeData: true }); eq(r.status, 'done', JSON.stringify(r)); const ck = r.result.checksum;
|
|
r = await dst.run('site.import', { siteId: 'r1', transferId: 'tr_rl1', checksum: ck, stage: 'final' }); eq(r.status, 'done', JSON.stringify(r)); ok(r.result.ok);
|
|
r = await dst.run('site.stage_check', { siteId: 'r1', expectedChecksum: 'wrong' }); eq(r.result.ok, false); r = await dst.run('site.stage_check', { siteId: 'r1', expectedChecksum: ck }); ok(r.result.ok);
|
|
r = await dst.run('site.activate', { siteId: 'r1', domains: ['r1.example.com'], primaryDomain: 'r1.example.com' }); eq(r.status, 'done', JSON.stringify(r)); ok(readFileSync(join(dst.roots.sites, 'r1', 'current', 'index.html'), 'utf8').includes('marker-r')); eq(readFileSync(join(dst.roots.sites, 'r1', 'data', 'd.txt'), 'utf8'), 'data-r'); ok(existsSync(join(dst.roots.nginx, 'sites-enabled', 'aw-r1.conf')));
|
|
r = await src.run('site.deactivate', { siteId: 'r1', retainDays: 3 }); eq(r.status, 'done'); ok(!existsSync(join(src.roots.nginx, 'sites-enabled', 'aw-r1.conf'))); ok(existsSync(join(src.roots.sites, 'r1', 'current')), '사본 보존'); ok(!existsSync(join(src.roots.sites, 'r1', '.frozen'))); ok(JSON.parse(readFileSync(join(src.roots.sites, 'r1', '.retained.json'), 'utf8')).until);
|
|
});
|
|
await test('domain.cert: 와일드카드 재사용 / certbot(stub) 뒤 HTTPS vhost / domain.probe stub, domain.vhost 유지보수', async () => {
|
|
const e = env(); mkdirSync(e.roots.wildcardCert, { recursive: true }); writeFileSync(join(e.roots.wildcardCert, 'fullchain.pem'), 'x'); const b = bundle(e.base, 'c'); await e.run('site.deploy', { siteId: 'c1', bundlePath: b });
|
|
let r = await e.run('domain.cert', { siteId: 'c1', domains: ['c1.ariweb.net'] }); eq(r.status, 'done'); eq(r.result.source, 'wildcard'); eq(r.result.issued, ['c1.ariweb.net']);
|
|
r = await e.run('domain.cert', { siteId: 'c1', domains: ['www.example.com', 'example.com'], email: 'ops@example.test' }); eq(r.status, 'done', JSON.stringify(r)); eq(r.result.source, 'own'); ok(e.sys.calls.some((c) => c.args.includes('certbot') && c.args.includes('--webroot'))); ok(readFileSync(join(e.roots.nginx, 'sites-available', 'aw-c1.conf'), 'utf8').includes('listen 443 ssl'));
|
|
r = await e.run('domain.probe', { name: 'x.example.test', _stubAnswer: { a: ['1.2.3.4'], aOk: true, txtOk: true } }); ok(r.result.aOk);
|
|
r = await e.run('domain.vhost', { siteId: 'c1', domains: ['example.com'], primaryDomain: 'example.com', maintenance: true, redirectOld: true, oldDomains: ['old.example.com'] }); eq(r.status, 'done'); ok(existsSync(join(e.roots.sites, 'c1', '.maintenance'))); ok(readFileSync(join(e.roots.nginx, 'sites-available', 'aw-c1.conf'), 'utf8').includes('old.example.com'));
|
|
});
|
|
await test('module.install: artifact 검증·releases/current·유닛(기존 Environment 보존)·사후 절차·실제 버전, rollback, health, 해시 불일치 실패', async () => {
|
|
const e = env(); const app = join(e.base, 'bundles', 'portal'); mkdirSync(join(app, 'portal'), { recursive: true }); writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'x', version: '0.1.1' })); writeFileSync(join(app, 'portal', 'index.mjs'), ''); const tgz = join(e.base, 'portal-0.1.1.tar.gz'); execFileSync('tar', ['-czf', tgz, '-C', app, '.']); const sum = createHash('sha256').update(readFileSync(tgz)).digest('hex');
|
|
e.client = fakeClient({ artifacts: { '/art/portal': tgz } }); const runner = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: e.client, dataDir: e.dataDir }); const appDir = join(e.roots.srv, 'portal');
|
|
const desc = { name: 'portal', unit: 'ariweb-portal', unitTemplate: 'ariweb-portal.service.tmpl', user: 'master', health: { url: 'http://127.0.0.1:1/api/health', expect: 'ok' }, version: { file: 'package.json' }, layout: 'releases', postStart: [] };
|
|
let r = await runner.execute({ id: 'm1', kind: 'module.install', payload: { module: 'portal', version: '0.1.1', artifactUrl: '/art/portal', sha256: 'bad', descriptor: desc, appDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} x' } }); eq(r.status, 'failed'); eq(r.errorCode, 'checksum_mismatch');
|
|
r = await runner.execute({ id: 'm2', kind: 'module.install', payload: { module: 'portal', version: '0.1.1', artifactUrl: '/art/portal', sha256: sum, descriptor: desc, appDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} x' } }); eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1'); eq(readlinkSync(join(appDir, 'current')), join(appDir, 'releases', '0.1.1')); ok(e.sys.calls.some((c) => c.args.join(' ').includes('systemctl restart ariweb-portal')));
|
|
const unitFile = e.sys.calls.find((c) => c.cmd === 'sudo' && c.args.includes('tee')); ok(unitFile || existsSync('/etc/systemd/system/ariweb-portal.service') === false, '유닛 파일 쓰기 시도');
|
|
// 두 번째 버전 설치 뒤 rollback
|
|
writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'x', version: '0.1.2' })); const tgz2 = join(e.base, 'portal-0.1.2.tar.gz'); execFileSync('tar', ['-czf', tgz2, '-C', app, '.']); e.client.transfers; const sum2 = createHash('sha256').update(readFileSync(tgz2)).digest('hex'); const runner2 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/portal2': tgz2 } }), dataDir: e.dataDir });
|
|
r = await runner2.execute({ id: 'm3', kind: 'module.install', payload: { module: 'portal', version: '0.1.2', artifactUrl: '/art/portal2', sha256: sum2, descriptor: desc, appDir } }); eq(r.result.actualVersion, '0.1.2'); eq(r.result.previousVersion, '0.1.1');
|
|
r = await runner2.execute({ id: 'm4', kind: 'module.rollback', payload: { module: 'portal', version: '0.1.2', toVersion: '0.1.1', descriptor: desc, appDir } }); eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1');
|
|
r = await runner2.execute({ id: 'm5', kind: 'module.health', payload: { module: 'portal', descriptor: desc, appDir, unit: 'ariweb-portal' } }); eq(r.result.actualVersion, '0.1.1'); ok(r.result.health.ok);
|
|
// agent 자기 갱신은 재시작을 결과 보고 뒤로 미룬다
|
|
r = await runner2.execute({ id: 'm6', kind: 'module.install', payload: { module: 'agent', version: '0.1.2', artifactUrl: '/art/portal2', sha256: sum2, descriptor: { ...desc, name: 'agent', unit: 'ariweb-agent' }, appDir: join(e.roots.srv, 'agent') } }); eq(r.result.restartAfterReport, 'ariweb-agent');
|
|
});
|
|
await test('module.install(실제 환경 결함 수정): REVISION.json 의 Suite 버전이 실제 버전(상류 package.json 은 upstreamVersion) · keepExistingUnit 은 current 를 가리키는 기존 유닛을 보존 · 실행 계정이 다른 도입 모듈은 module-as-user.sh 로 풀기·전환·DB 백업·EnvironmentFile 마이그레이션', async () => {
|
|
const e = env(); const { DatabaseSync } = await import('node:sqlite');
|
|
// 상류 버전 1.0.11 인 AriMail 모양의 artifact: server/db-migrate.mjs 는 DB_FILE 환경을 파일에 남겨 EnvironmentFile 이 실렸는지 증명한다
|
|
const app = join(e.base, 'bundles', 'arimail'); mkdirSync(join(app, 'server'), { recursive: true }); writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'arimail', version: '1.0.11' }));
|
|
writeFileSync(join(app, 'server', 'db-migrate.mjs'), "import { writeFileSync } from 'node:fs'; if (process.env.FAIL_MIGRATE) { console.error('migrate boom'); process.exit(1); } writeFileSync(process.env.DB_FILE + '.migrated', JSON.stringify({ db: process.env.DB_FILE, cwd: process.cwd(), name: process.env.MAIL_FROM_NAME, quoted: process.env.QUOTED }));\n");
|
|
const tgz = join(e.base, 'arimail-0.1.1.tar.gz'); execFileSync('tar', ['-czf', tgz, '-C', app, '.']); const sum = createHash('sha256').update(readFileSync(tgz)).digest('hex');
|
|
const client = fakeClient({ artifacts: { '/art/arimail': tgz } }); const runner = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client, dataDir: e.dataDir });
|
|
// 서버에 이미 있던 AriMail 배치: appDir·data·DB·EnvironmentFile. 유닛의 User 는 Agent 계정과 다르다.
|
|
const appDir = join(e.roots.srv, 'arihakdang-mail'); mkdirSync(join(appDir, 'releases', '20261024-114605'), { recursive: true }); mkdirSync(join(appDir, 'data'), { recursive: true }); execFileSync('ln', ['-sfn', join(appDir, 'releases', '20261024-114605'), join(appDir, 'current')]);
|
|
const db = join(appDir, 'data', 'mail-gateway.sqlite'); { const d = new DatabaseSync(db); d.exec('CREATE TABLE t(x)'); d.exec('INSERT INTO t VALUES(1)'); d.close(); }
|
|
const envFile = join(e.base, 'gateway.env'); writeFileSync(envFile, `DB_FILE=${db}\nSECRET_X="not-printed"\nMAIL_FROM_NAME=Ari Hak dang\n# 주석\nQUOTED='a b'\n`); // systemd 문법(공백 값·따옴표·주석)
|
|
e.sys.unitText = async (u) => (u === 'arihakdang-mail' ? `[Service]\nUser=svc-other\nWorkingDirectory=${appDir}/current\nEnvironmentFile=${envFile}\nExecStart=${process.execPath} server/index.mjs\n` : '');
|
|
// sudo -n -u <계정> /usr/bin/bash <스크립트> … 를 같은 계정으로 직접 실행해(권한 없는 테스트 환경) 스크립트 자체의 동작을 검증한다
|
|
const origRun = e.sys.run; const sudoCalls = []; e.sys.run = (cmd, args, o) => { if (cmd === 'sudo' && args[3] === '/usr/bin/bash') { sudoCalls.push(args.slice(5)); return origRun('bash', args.slice(4), { ...(o || {}), allowStub: false }); } return origRun(cmd, args, o); };
|
|
const desc = { name: 'arimail', unit: 'arihakdang-mail', unitTemplate: null, user: null, port: 8790, health: { url: 'http://127.0.0.1:1/api/health', expect: 'ok' }, version: { file: 'package.json' }, layout: 'releases', preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], copyPaths: ['server', 'package.json'] };
|
|
let r = await runner.execute({ id: 'u1', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } });
|
|
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1'); eq(r.result.upstreamVersion, '1.0.11'); eq(r.result.runUser, 'svc-other'); eq(r.result.previousVersion, '20261024-114605');
|
|
eq(readlinkSync(join(appDir, 'current')), join(appDir, 'releases', '0.1.1')); eq(readJson(join(appDir, 'releases', '0.1.1', 'REVISION.json')).version, '0.1.1'); eq(readJson(join(appDir, '.previous.json')).previous, join(appDir, 'releases', '20261024-114605'));
|
|
ok(['extract', 'write', 'backup-db', 'switch', 'write', 'migrate'].every((s) => sudoCalls.some((c) => c[0] === s)), '실행 계정 하위 명령: ' + sudoCalls.map((c) => c[0]).join(','));
|
|
ok(existsSync(join(appDir, 'work')) === false, 'appDir 에 work 디렉터리를 만들지 않는다'); ok(existsSync(join(e.roots.srv, 'agent', 'work')));
|
|
const backups = readdirSync(join(appDir, 'data', 'backups')); eq(backups.length, 1); { const d = new DatabaseSync(join(appDir, 'data', 'backups', backups[0]), { readOnly: true }); eq(d.prepare('SELECT COUNT(*) AS n FROM t').get().n, 1); d.close(); }
|
|
// 같은 버전 재설치(실행 중인 current 디렉터리 보존 → .prev-<시각> 가 되돌리기 대상)
|
|
r = await runner.execute({ id: 'u1b', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } });
|
|
eq(r.status, 'done', JSON.stringify(r)); ok(/^0\.1\.1\.prev-\d{14}(\.\d+)?$/.test(r.result.previousVersion), '보존된 이전 디렉터리: ' + r.result.previousVersion); eq(readlinkSync(join(appDir, 'current')), join(appDir, 'releases', '0.1.1')); ok(existsSync(join(appDir, 'releases', r.result.previousVersion, 'REVISION.json'))); ok(!existsSync(join(appDir, 'releases', '0.1.1.tmp')));
|
|
eq(readJson(db + '.migrated').db, db, 'EnvironmentFile 의 DB_FILE 로 마이그레이션'); eq(readJson(db + '.migrated').name, 'Ari Hak dang', '공백 값'); eq(readJson(db + '.migrated').quoted, 'a b', '따옴표 값');
|
|
// 사후 절차(마이그레이션) 실패 → current 를 이전 릴리스로 되돌린다
|
|
writeFileSync(envFile, `DB_FILE=${db}\nFAIL_MIGRATE=1\n`); const curBefore = readlinkSync(join(appDir, 'current'));
|
|
r = await runner.execute({ id: 'u1c', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } });
|
|
eq(r.status, 'failed'); ok(/migrate boom|migrate 실패/.test(r.error), r.error); eq(readlinkSync(join(appDir, 'current')).replace(/\/$/, ''), r.result?.partial?.rolledBackTo || readlinkSync(join(appDir, 'current')), '되돌림'); ok(/\.prev-\d{14}(\.\d+)?$/.test(readlinkSync(join(appDir, 'current'))), 'current 가 보존된 이전 디렉터리를 가리킨다: ' + readlinkSync(join(appDir, 'current')));
|
|
writeFileSync(envFile, `DB_FILE=${db}\n`); r = await runner.execute({ id: 'u1d', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } }); eq(r.status, 'done', JSON.stringify(r).slice(0, 300)); void curBefore; eq(readJson(db + '.migrated').cwd, join(appDir, 'releases', '0.1.1'));
|
|
ok(e.sys.calls.some((c) => c.cmd === 'sudo' && c.args.join(' ').includes('systemctl restart arihakdang-mail-haraka')));
|
|
// module-as-user.sh 는 모듈 디렉터리 밖 경로를 거부한다
|
|
const bad = await origRun('bash', [join(ROOT_DIR, 'deploy', 'module-as-user.sh'), 'remove', '--app-dir', appDir, '--dir', join(e.base, 'bundles')], { allowStub: false }); eq(bad.code, 3);
|
|
// keepExistingUnit: 유닛이 이미 <appDir>/current 를 가리키면 템플릿으로 다시 쓰지 않는다(강화 지시문 보존)
|
|
const wmApp = join(e.base, 'bundles', 'webmaker'); mkdirSync(wmApp, { recursive: true }); writeFileSync(join(wmApp, 'package.json'), JSON.stringify({ name: 'webmaker', version: '1.7.3' })); const wtgz = join(e.base, 'webmaker-0.1.1.tar.gz'); execFileSync('tar', ['-czf', wtgz, '-C', wmApp, '.']); const wsum = createHash('sha256').update(readFileSync(wtgz)).digest('hex');
|
|
const wmDir = join(e.roots.srv, 'webmaker'); mkdirSync(join(wmDir, 'releases', 'old'), { recursive: true }); execFileSync('ln', ['-sfn', join(wmDir, 'releases', 'old'), join(wmDir, 'current')]);
|
|
const wmData = join(e.base, 'wmdata'); mkdirSync(wmData, { recursive: true }); writeFileSync(join(wmData, 'ok.json'), '{}'); writeFileSync(join(wmData, 'unreadable.json'), '{}'); chmodSync(join(wmData, 'unreadable.json'), 0o000); // root 가 남긴 파일처럼 읽을 수 없는 항목
|
|
e.sys.unitText = async (u) => (u === 'webmaker' ? `[Service]\nUser=${(await import('node:os')).userInfo().username}\nWorkingDirectory=${wmDir}/current\nProtectSystem=strict\nEnvironment=DATA_ROOT=${wmData}\n` : '');
|
|
const before = e.sys.calls.length; const runner2 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/wm': wtgz } }), dataDir: e.dataDir });
|
|
r = await runner2.execute({ id: 'u2', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases', preStop: ['webmaker.backupData'], postStart: [], extraUnits: ['webmaker-setup'] }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
|
|
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1'); eq(r.result.upstreamVersion, '1.7.3'); eq(r.steps.find((s) => s.id === 'unit').result.skipped, 'existing_unit_kept');
|
|
{ const pre = r.steps.find((s) => s.id === 'pre:webmaker.backupData').result; ok(pre.backup && existsSync(pre.backup), '데이터 백업 파일'); ok(pre.unreadable && pre.unreadable.length === 1 && /unreadable\.json/.test(pre.unreadable[0]), '읽지 못한 파일은 경고로: ' + JSON.stringify(pre.unreadable)); }
|
|
ok(!e.sys.calls.slice(before).some((c) => c.cmd === 'sudo' && c.args.includes('tee')), '유닛 파일을 다시 쓰지 않는다'); ok(e.sys.calls.slice(before).some((c) => c.args.join(' ').includes('systemctl restart webmaker-setup')));
|
|
// 유닛이 다른 곳을 가리키면(또는 없으면) 템플릿으로 쓴다
|
|
e.sys.unitText = async () => ''; const runner3 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/wm': wtgz } }), dataDir: e.dataDir }); const b2 = e.sys.calls.length;
|
|
r = await runner3.execute({ id: 'u3', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases' }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
|
|
eq(r.status, 'done', JSON.stringify(r)); eq(r.steps.find((s) => s.id === 'unit').result.written, true); ok(e.sys.calls.slice(b2).some((c) => c.cmd === 'sudo' && c.args.includes('tee')) || existsSync('/etc/systemd/system/webmaker.service'));
|
|
});
|
|
done();
|