/** 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': '
marker-k
', '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: 유닛이 이미