Files
AriWeb-Suite/test/agent.test.mjs

219 lines
41 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 { scanWebmakerInventory, webmakerInventoryBatches, configuredWebmakerInventory } from '../agent/webmaker-inventory.mjs';
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readlinkSync, copyFileSync, statSync, appendFileSync, chmodSync, symlinkSync } 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 = {}, settings = null } = {}) {
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 }); if (method === 'GET' && path.startsWith('/agent/v1/settings/')) { if (!settings) throw Object.assign(new Error('404'), { code: 'http_404' }); return { ok: true, settings }; } 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('Webmaker 사이트 인벤토리: 모든 디렉터리·배포 상태·메타데이터를 비밀 없이 HAL에 전달', async () => {
const base = tmpDir(); const dataRoot = join(base, 'data'); const dataDir = join(base, 'custom-meta'); const sitesRoot = join(base, 'published');
mkdirSync(dataDir, { recursive: true }); for (const id of ['a', 'b', 'c', 'e'].map((x) => x.repeat(32))) { mkdirSync(join(sitesRoot, id), { recursive: true }); writeFileSync(join(sitesRoot, id, 'index.html'), id[0]); }
writeFileSync(join(dataDir, 'a'.repeat(32) + '.json'), JSON.stringify({ id: 'a'.repeat(32), title: '사이트 A', status: 'idle', ownerId: 'owner-a', domains: [{ name: 'a.example.test' }], deployments: [{ ok: true, kind: 'deploy' }], content: 'HAL에 보내면 안 되는 본문', commands: [{ prompt: '비밀' }] }));
writeFileSync(join(dataDir, 'b'.repeat(32) + '.json'), JSON.stringify({ id: 'b'.repeat(32), title: '초안', deployments: [] }));
writeFileSync(join(dataDir, 'c'.repeat(32) + '.json'), JSON.stringify({ id: 'c'.repeat(32), title: '중지', deployments: [{ ok: true, kind: 'deploy' }, { ok: true, kind: 'unpublish' }], unpublishedAt: '2026-01-01T00:00:00Z' }));
writeFileSync(join(dataDir, 'd'.repeat(32) + '.json'), JSON.stringify({ id: 'd'.repeat(32), title: '배포 전 초안', deployments: [] }));
writeFileSync(join(dataDir, 'f'.repeat(32) + '.json'), JSON.stringify([]));
const waitingId = 'f'.repeat(31) + 'd'; const legacyId = 'f'.repeat(31) + 'e';
writeFileSync(join(dataDir, waitingId + '.json'), JSON.stringify({ title: 'DNS 대기', deployments: [{ ok: true, phase: 'dns-wait' }] }));
writeFileSync(join(dataDir, legacyId + '.json'), JSON.stringify({ title: '구형 배포 기록', deployments: [{ ok: true }] }));
writeFileSync(join(dataDir, 'A'.repeat(32) + '.json'), JSON.stringify({ title: '대문자 ID는 비정상' }));
writeFileSync(join(dataDir, 'g'.repeat(32) + '.json.swp'), JSON.stringify({ title: '임시 파일은 제외' }));
const inv = scanWebmakerInventory({ dataRoot, dataDir, sitesRoot });
eq(inv.total, 8); eq(inv.returned, 8); eq(inv.truncated, false); eq(inv.complete, true); eq(inv.sites[0].id, 'a'.repeat(32)); eq(inv.sites[0].title, '사이트 A'); eq(inv.sites[0].ownerId, 'owner-a'); eq(inv.sites[0].domains, ['a.example.test']); eq(inv.sites[0].deploymentState, 'deployed'); ok(inv.sites[0].bytes > 0);
eq(inv.sites[1].deploymentState, 'draft'); eq(inv.sites[2].deploymentState, 'unpublished'); eq(inv.sites[3].deploymentState, 'draft'); eq(inv.sites[3].files, 0, '메타데이터에만 있는 미배포 초안도 포함'); eq(inv.sites[4].metadata, 'missing', '배포 디렉터리에만 있는 사이트도 포함'); eq(inv.sites.find((x) => x.id === 'f'.repeat(32)).metadata, 'invalid', '배열·원시 JSON은 유효한 사이트 메타데이터가 아님'); eq(inv.sites.find((x) => x.id === waitingId).deploymentState, 'draft', 'DNS 대기 단계는 운영 배포 완료가 아님'); eq(inv.sites.find((x) => x.id === legacyId).deploymentState, 'deployed', 'phase 도입 전 성공 기록은 배포됨'); ok(inv.errors.some((x) => x.includes('invalid_shape'))); ok(!JSON.stringify(inv).includes('대문자 ID') && !JSON.stringify(inv).includes('임시 파일은 제외')); ok(!JSON.stringify(inv).includes('본문') && !JSON.stringify(inv).includes('비밀'));
const limited = scanWebmakerInventory({ dataRoot, dataDir, sitesRoot, maxSites: 2 }); eq(limited.total, 8); eq(limited.returned, 2); eq(limited.truncated, true); eq(limited.complete, false);
const broken = scanWebmakerInventory({ dataRoot, dataDir, sitesRoot: join(base, 'missing-root') }); eq(broken.total, 7); eq(broken.complete, false); ok(broken.errors.some((x) => x.startsWith('sites:')), '배포 루트 오류를 숨기지 않으면서 메타데이터 프로젝트는 보존');
const missingMeta = scanWebmakerInventory({ dataRoot, dataDir: join(base, 'missing-meta'), sitesRoot }); eq(missingMeta.complete, false); ok(missingMeta.errors.some((x) => x.startsWith('metadata:')), '메타데이터 루트 읽기 실패 표시'); eq(missingMeta.sites[0].metadata, 'missing');
const aliasRoot = join(base, 'alias-root'); symlinkSync(dataRoot, aliasRoot, 'dir'); const aliasInv = scanWebmakerInventory({ dataRoot: aliasRoot, dataDir, sitesRoot }); eq(aliasInv.total, 8); eq(aliasInv.errors.filter((x) => x.includes('invalid_shape')).length, 1, '실경로가 같은 metadata root는 한 번만 스캔');
const largeSites = Array.from({ length: 100 }, (_, i) => ({ id: String(i).padStart(32, 'a'), title: 'x'.repeat(200), domains: Array.from({ length: 100 }, (_, n) => `${String(n).padStart(240, 'd')}.test`) })); const batches = webmakerInventoryBatches({ ...inv, sites: largeSites, returned: 100 }); ok(Buffer.byteLength(JSON.stringify({ facts: { webmakerInventory: { ...inv, sites: largeSites } } })) > 2 * 1024 * 1024, '단일 heartbeat면 2MiB 초과 fixture'); ok(batches.length > 1 && batches.every((b) => b.sites.length <= 1000 && Buffer.byteLength(JSON.stringify(b)) <= 512 * 1024)); eq(batches.flatMap((b) => b.sites).map((x) => x.id), largeSites.map((x) => x.id), 'byte 배치 후 전체 사이트 무누락');
const manySites = Array.from({ length: 1500 }, (_, i) => ({ id: `s${i}` })); const countBatches = webmakerInventoryBatches({ ...inv, sites: manySites, returned: 1500 }); ok(countBatches.length === 2 && countBatches.every((b) => b.sites.length <= 1000)); eq(countBatches.flatMap((b) => b.sites).length, 1500, 'HAL 항목 제한 배치 후 무누락');
const noisy = webmakerInventoryBatches({ ...inv, sites: [], errors: Array.from({ length: 100000 }, () => 'scan:EACCES'.repeat(40)) }); eq(noisy.length, 1); ok(Buffer.byteLength(JSON.stringify(noisy[0])) <= 512 * 1024 && noisy[0].meta.errorsTruncated, '빈 사이트·대량 오류도 HTTP 한도 내 명시적 절단');
const unconfigured = configuredWebmakerInventory({ units: { webmaker: 'webmaker' } }, {}); ok(unconfigured.webmakerInventory.errors[0].startsWith('configuration:') && unconfigured.webmakerInventory.complete === false, '명시적 운영 경로 없이는 추측 스캔하지 않음'); let incompleteRejected = false; try { webmakerInventoryBatches(unconfigured.webmakerInventory); } catch (e) { incompleteRejected = e.message === 'inventory_incomplete'; } ok(incompleteRejected, '불완전 스캔은 HAL의 마지막 정상 인벤토리를 덮어쓰지 않음');
const configured = configuredWebmakerInventory({ units: { webmaker: 'webmaker' } }, { ARIWEB_WEBMAKER_DATA_ROOT: dataRoot, ARIWEB_WEBMAKER_DATA_DIR: dataDir, ARIWEB_WEBMAKER_SITES_ROOT: sitesRoot }); eq(configured.webmakerInventory.total, 8); eq(configured.webmakerInventory.complete, true);
});
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 }); const oldLocale = process.env.LC_ALL; process.env.LC_ALL = 'C';
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' } });
if (oldLocale == null) delete process.env.LC_ALL; else process.env.LC_ALL = oldLocale;
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'));
});
await test('[0.4.0] module.configure: HAL 설정을 받아 hal-managed.json(0600)으로 쓰고 재시작·건강 확인 뒤 hash 를 보고 — 불일치·받기 실패는 실패로', async () => {
const sec = 'v-' + createHash('sha256').update(String(Date.now())).digest('hex').slice(0, 12); const hash = 'sha256:' + createHash('sha256').update('settings-' + sec).digest('hex');
const settings = { module: 'webmaker', serverId: 'srv_1', hash, generatedAt: new Date().toISOString(), values: { marginPct: 35, 'auth.apiKey': sec } };
const e = env({ settings }); const configDir = join(e.base, 'wmdata');
let r = await e.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir, settingsUrl: '/agent/v1/settings/webmaker', hash, restart: true, descriptor: { unit: 'webmaker', health: {} } });
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.hash, hash); eq(r.result.restarted, true); ok(r.result.health && r.result.health.ok);
const f = join(configDir, 'hal-managed.json'); ok(existsSync(f)); eq(statSync(f).mode & 0o777, 0o600, '0600'); const j = JSON.parse(readFileSync(f, 'utf8')); eq(j.hash, hash); eq(j.values.marginPct, 35); eq(j.values['auth.apiKey'], sec);
ok(e.sys.calls.some((c) => { const a = [c.cmd, ...c.args]; return a.includes('systemctl') && a.includes('restart') && a.includes('webmaker'); }), '재시작'); ok(e.client.calls.some((c) => c.method === 'GET' && c.path === '/agent/v1/settings/webmaker'), 'HAL 에서 받음');
ok(!JSON.stringify(r.log || []).includes(sec), '로그에 비밀값 없음');
// restart=false 면 재시작하지 않는다 · <appDir> 토큰은 모듈 디렉터리로 푼다
const e2 = env({ settings: { ...settings, module: 'arimail' } }); const appDir = join(e2.roots.srv, 'arimail'); mkdirSync(appDir, { recursive: true });
r = await e2.run('module.configure', { module: 'arimail', unit: 'arimail', appDir, configDir: '<appDir>/data', settingsUrl: '/agent/v1/settings/arimail', hash, restart: false, descriptor: { unit: 'arimail' } });
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.restarted, false); ok(existsSync(join(appDir, 'data', 'hal-managed.json'))); ok(!e2.sys.calls.some((c) => [c.cmd, ...c.args].includes('restart')));
// HAL 응답의 hash 가 작업의 hash 와 다르면(그 사이 설정이 바뀜) 쓰지 않고 실패
const e3 = env({ settings: { ...settings, hash: 'sha256:' + 'f'.repeat(64) } }); r = await e3.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir: join(e3.base, 'w3'), settingsUrl: '/agent/v1/settings/webmaker', hash, restart: false });
eq(r.status, 'failed'); eq(r.errorCode, 'settings_hash_mismatch'); ok(!existsSync(join(e3.base, 'w3', 'hal-managed.json')));
const e4 = env({}); r = await e4.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir: join(e4.base, 'w4'), settingsUrl: '/agent/v1/settings/webmaker', hash, restart: false }); eq(r.status, 'failed'); eq(r.errorCode, 'settings_fetch_failed');
});
await test('[0.4.0] Agent 설정: <dataDir>/hal-managed.json 의 heartbeatMs 가 환경 변수보다 우선(범위 밖이면 무시)', async () => {
const { loadAgentConfig } = await import('../agent/config.mjs'); const dir = join(tmpDir(), 'ad'); mkdirSync(dir, { recursive: true });
eq(loadAgentConfig({ ARIWEB_AGENT_DATA: dir, ARIWEB_AGENT_HEARTBEAT_MS: '20000' }).heartbeatMs, 20000);
writeFileSync(join(dir, 'hal-managed.json'), JSON.stringify({ hash: 'sha256:' + 'a'.repeat(64), values: { heartbeatMs: 45000 } })); const c = loadAgentConfig({ ARIWEB_AGENT_DATA: dir, ARIWEB_AGENT_HEARTBEAT_MS: '20000' }); eq(c.heartbeatMs, 45000); eq(c.halManaged.keys, ['heartbeatMs']);
writeFileSync(join(dir, 'hal-managed.json'), JSON.stringify({ values: { heartbeatMs: 10 } })); eq(loadAgentConfig({ ARIWEB_AGENT_DATA: dir }).heartbeatMs, 30000);
});
done();