Files
AriWeb-Suite/test/e2e/boot.mjs

106 lines
13 KiB
JavaScript

/**
* test/e2e/boot.mjs — 로컬 종단 간 검증 환경 기동.
*
* 한 번의 실행에서 다음을 임시 디렉터리(test/.tmp/e2e-*)와 빈 포트로 띄운다.
* 1) 가짜 서비스: 발신 메일 포획(Resend 호환) · Webmaker 연동 면 · AriMail 연동 면
* 2) HAL(실제 프로세스): 보관소에 서비스 키를 미리 넣고, CLI 로 설정 토큰·릴리스 등록(승인)·부트스트랩 릴리스 기록 → 서버 실행 → 운영자 최초 설정·로그인
* 3) Node Agent(실제 프로세스, ARIWEB_AGENT_STUB_CMDS=1): control/web/backup/web 역할 서버 4대를 등록·승인(첫 승인으로 부트스트랩 완료)
* 4) 고객 포털(실제 프로세스): HAL·Webmaker·AriMail 주소를 가짜에 맞추고 Catalog 자동 시드, AriMail 서버를 운영자 중계로 등록
* 서비스 키 배치: HAL 보관소 service:portal / service:hal 과 포털 보관소 service:hal / service:portal 에 모두 같은 값을 둔다
* (HAL CLI link-portal 은 두 값을 다르게 만드는데, 포털은 양방향에 'service:hal' 하나를 쓰므로 같은 값이어야 중계가 된다 — 보고서의 결함 항목 참고).
*/
import { mkdirSync, writeFileSync, rmSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { createHash, randomBytes } from 'node:crypto';
import { openVault } from '../../lib/vault.mjs';
import { ROOT, freePort, waitFor, makeClient, signedClient, tarGz, runNode, spawnNode, sleep } from './util.mjs';
import { startFakeMail } from './fake-mail.mjs';
import { startFakeWebmaker } from './fake-webmaker.mjs';
import { startFakeArimail } from './fake-arimail.mjs';
const rnd = (n = 24) => randomBytes(n).toString('base64url');
/** Agent 하나 등록·실행. role: control|web|backup, external 이면 사이트 귀속 */
export async function startAgent(env, { name, role = 'web', token = null, external = false, siteId = null, failHealth = false }) {
const dir = join(env.base, 'agents', name); mkdirSync(dir, { recursive: true });
const roots = { sites: join(dir, 'sites'), srv: join(dir, 'srv'), nginx: join(dir, 'nginx'), backups: join(dir, 'backups'), logs: join(dir, 'logs'), acme: join(dir, 'acme'), le: join(dir, 'le') };
for (const r of Object.values(roots)) mkdirSync(r, { recursive: true }); mkdirSync(join(roots.nginx, 'sites-available'), { recursive: true }); mkdirSync(join(roots.nginx, 'sites-enabled'), { recursive: true });
const aenv = { ARIWEB_AGENT_DATA: join(dir, 'data'), ARIWEB_SITES_ROOT: roots.sites, ARIWEB_SRV_ROOT: roots.srv, ARIWEB_NGINX_DIR: roots.nginx, ARIWEB_BACKUP_ROOT: roots.backups, ARIWEB_LOG_DIR: roots.logs, ARIWEB_ACME_WEBROOT: roots.acme, ARIWEB_LE_DIR: roots.le, ARIWEB_WILDCARD_CERT: '', ARIWEB_PLATFORM_DOMAIN: 'localhost', ARIWEB_MAINT_PAGE: '', ARIWEB_AGENT_STUB_CMDS: '1', ARIWEB_AGENT_HEARTBEAT_MS: '3000', USER: process.env.USER || 'master' };
let serverId = null; let regToken = token;
if (!external) { const r = await env.ops.post('/ops/api/servers', { name, role, location: 'e2e' }); if (r.status !== 200) throw new Error(`서버 생성 실패 ${r.status} ${r.text.slice(0, 200)}`); serverId = r.json.server.id; const t = await env.ops.post(`/ops/api/servers/${serverId}/token`); regToken = t.json.token; }
const out = runNode(['agent/index.mjs', 'register', '--hal', env.hal.url, '--token', regToken, '--role', role, '--label', name, ...(external ? ['--external', '--site', siteId] : [])], aenv);
const agentId = (out.match(/agent (ag_[a-z0-9]+)/) || [])[1];
const proc = spawnNode(`agent:${name}`, ['agent/index.mjs', 'run'], aenv);
const a = { name, role, dir, roots, env: aenv, serverId, agentId, proc, external, siteId, restart() { a.proc.kill('SIGKILL'); a.proc = spawnNode(`agent:${name}`, ['agent/index.mjs', 'run'], aenv); return a.proc; } };
env.agents.push(a);
if (!external) { await waitFor(async () => { const s = await env.ops.get(`/ops/api/servers/${serverId}`); return s.json?.server?.agentId ? s : null; }, { timeoutMs: 15000, intervalMs: 300 }); const ap = await env.ops.post(`/ops/api/servers/${serverId}/approve`); if (ap.status !== 200) throw new Error(`서버 승인 실패 ${ap.text.slice(0, 200)}`); }
else { await waitFor(async () => { const s = await env.ops.get('/ops/api/servers'); return s.status === 200 ? s : null; }, { timeoutMs: 5000 }); }
return a;
}
/** 릴리스 artifact(작은 tar.gz)와 manifest 를 만든다. 모듈 디렉터리에 package.json(version) 이 있어야 Agent 가 실제 버전을 읽는다. */
export function buildFakeRelease(base, version, { withWebmaker = false } = {}) {
const dist = join(base, 'dist', version); mkdirSync(dist, { recursive: true }); const artifacts = {};
for (const m of ['hal', 'portal', 'agent', ...(withWebmaker ? ['webmaker', 'arimail'] : [])]) {
const src = join(base, 'dist-src', `${m}-${version}`); mkdirSync(join(src, m), { recursive: true }); writeFileSync(join(src, 'package.json'), JSON.stringify({ name: `ariweb-${m}`, version })); writeFileSync(join(src, m, 'index.mjs'), `// e2e fake ${m} ${version}\n`);
const file = `${m}-${version}.tar.gz`; tarGz(src, join(dist, file));
artifacts[m] = { file, sha256: createHash('sha256').update(readFileSync(join(dist, file))).digest('hex'), size: statSync(join(dist, file)).size };
}
const manifest = { version, mainCommit: 'e2e0000000000000000000000000000000000000', branch: 'e2e', builtAt: new Date().toISOString(), artifacts, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] } }; // register-release --approve 는 세 검사가 모두 깨끗해야 승인한다(0.2.0)
writeFileSync(join(dist, 'manifest.json'), JSON.stringify(manifest, null, 1)); return { dist, manifest: join(dist, 'manifest.json'), artifacts };
}
export async function bootEnv({ log = () => {} } = {}) {
const base = join(ROOT, 'test', '.tmp', `e2e-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`); mkdirSync(base, { recursive: true });
const [pHal, pPortal, pWm, pAm, pMail] = await Promise.all([freePort(), freePort(), freePort(), freePort(), freePort()]);
const secrets = { service: rnd(32), sso: rnd(32), amSetup: 'st_' + rnd(20), amUsage: 'au_' + rnd(20), resend: 're_' + rnd(20), amSso: rnd(24) };
const halData = join(base, 'hal'); const portalData = join(base, 'portal'); mkdirSync(halData, { recursive: true }); mkdirSync(portalData, { recursive: true });
// 보관소: 서비스 키·SSO·발신 키
const hv = openVault(halData); hv.set('service:portal', secrets.service, { kind: 'service-key' }); hv.set('service:hal', secrets.service, { kind: 'service-key' });
const pv = openVault(portalData); pv.set('service:hal', secrets.service, { kind: 'service-key' }); pv.set('service:portal', secrets.service, { kind: 'service-key' }); pv.set('sso:webmaker', secrets.sso, { kind: 'shared-secret' }); pv.set('mailer:resend-key', secrets.resend, { kind: 'secret' });
// 가짜 서비스
const mail = await startFakeMail(pMail); const wm = await startFakeWebmaker(pWm, { secret: secrets.sso, workDir: join(base, 'wm-work') }); const am = await startFakeArimail(pAm, { setupToken: secrets.amSetup, usageKey: secrets.amUsage });
writeFileSync(join(portalData, 'config.json'), JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), updatedBy: 'e2e', values: { mailer: 'resend', resendBase: mail.url, mailVerifier: 'verify@external.e2e.localhost', mailVerifierInsecureTls: true } }));
const env = { base, ports: { hal: pHal, portal: pPortal, wm: pWm, am: pAm, mail: pMail }, secrets, mail, wm, am, agents: [], procs: [], log };
// ---- HAL
const halEnv = { ARIWEB_HAL_DATA: halData, PORT: String(pHal), HOST: '127.0.0.1', HAL_PUBLIC_URL: `http://127.0.0.1:${pHal}`, PORTAL_URL: `http://127.0.0.1:${pPortal}`, OPS_ALLOW_CIDRS: '127.0.0.1/32' };
const tokOut = runNode(['hal/index.mjs', 'setup-token'], halEnv); const setupToken = (tokOut.match(/:\s*([A-Za-z0-9_-]{20,})/) || [])[1];
const rel = buildFakeRelease(base, '0.1.1'); runNode(['hal/index.mjs', 'register-release', '--manifest', rel.manifest, '--approve', '--by', 'e2e'], halEnv);
const rel2 = buildFakeRelease(base, '0.1.2'); runNode(['hal/index.mjs', 'register-release', '--manifest', rel2.manifest, '--approve', '--by', 'e2e'], halEnv);
runNode(['hal/index.mjs', 'bootstrap-release', '--version', '0.1.1', '--commit', rel.manifest ? 'e2e0000000000000000000000000000000000000' : '', '--sha256', rel.artifacts.hal.sha256], halEnv);
env.hal = { url: `http://127.0.0.1:${pHal}`, data: halData, env: halEnv, proc: spawnNode('hal', ['hal/index.mjs'], halEnv), release: rel, release2: rel2, setupToken };
env.hal.restart = async ({ takeover = false } = {}) => { env.hal.proc.kill('SIGTERM'); await env.hal.proc.waitExit(4000); if (!env.hal.proc.exited) env.hal.proc.kill('SIGKILL'); env.hal.proc = spawnNode('hal', ['hal/index.mjs', ...(takeover ? ['--takeover'] : [])], halEnv); return waitFor(async () => { const r = await fetch(env.hal.url + '/api/health').then((x) => x.json()).catch(() => null); return r && r.ok ? r : null; }, { timeoutMs: 15000, intervalMs: 300 }); };
const h = await waitFor(async () => { const r = await fetch(env.hal.url + '/api/health').then((x) => x.json()).catch(() => null); return r && r.ok ? r : null; }, { timeoutMs: 20000, intervalMs: 300 });
if (!h || h.timedOut) throw new Error('HAL 기동 실패: ' + env.hal.proc.lines.slice(-5).join(' | '));
// 운영자 최초 설정·로그인
env.ops = makeClient(env.hal.url); env.opsLogin = 'e2eops'; env.opsPassword = 'ops-password-' + rnd(8);
const su = await env.ops.post('/ops/api/setup', { setupToken, login: env.opsLogin, password: env.opsPassword, displayName: 'E2E 운영자' }); if (su.status !== 200) throw new Error('운영자 설정 실패 ' + su.text.slice(0, 200));
env.loginOps = async () => { const r = await env.ops.post('/ops/api/login', { login: env.opsLogin, password: env.opsPassword }); if (r.status !== 200) throw new Error('운영자 로그인 실패 ' + r.text.slice(0, 200)); env.ops.setCsrf(r.json.csrf); return r; };
await env.loginOps();
/** 운영자 → 포털 내부 운영 API 중계(/ops/api/portal/* → 포털 /internal/ops/*) */
env.relay = (method, path, body) => env.ops.req(method, '/ops/api/portal' + path, body);
/** 하네스가 포털 내부 API 를 HAL 키로 직접 서명 호출(중계 결함 우회가 아니라 비교·검증용) */
env.portalSigned = signedClient(`http://127.0.0.1:${pPortal}`, 'hal', secrets.service, { 'X-Ops-Actor': 'e2e-ops' });
env.halSigned = signedClient(env.hal.url, 'portal', secrets.service);
// ---- Agents: control(부트스트랩) → web → backup → web2
env.servers = {};
env.servers.control = await startAgent(env, { name: 'ctl-1', role: 'control' });
env.servers.web = await startAgent(env, { name: 'web-1', role: 'web' });
env.servers.backup = await startAgent(env, { name: 'backup-1', role: 'backup' });
env.servers.web2 = await startAgent(env, { name: 'web-2', role: 'web' });
// heartbeat 로 용량이 채워질 때까지(pickServer 가 여유 용량을 본다)
await waitFor(async () => { const s = await env.ops.get('/ops/api/servers'); const ok = s.json?.servers?.filter((x) => x.status === 'ready' && x.capacity?.diskTotalBytes > 0).length; return ok >= 4 ? s : null; }, { timeoutMs: 15000, intervalMs: 500 });
// ---- 포털
const portalEnv = { ARIWEB_PORTAL_DATA: portalData, PORT: String(pPortal), HOST: '127.0.0.1', PUBLIC_URL: `http://127.0.0.1:${pPortal}`, HAL_URL: env.hal.url, WEBMAKER_URL: wm.url, WEBMAKER_INTERNAL_URL: wm.url, ARIMAIL_URL: am.url, ARIMAIL_INTERNAL_URL: am.url, MAIL_FROM: 'no-reply@e2e.localhost', ARIWEB_AUTOSEED_CATALOG: '1' };
env.portal = { url: `http://127.0.0.1:${pPortal}`, data: portalData, env: portalEnv, proc: spawnNode('portal', ['portal/index.mjs'], portalEnv) };
env.portal.restart = async () => { env.portal.proc.kill('SIGTERM'); await env.portal.proc.waitExit(4000); if (!env.portal.proc.exited) env.portal.proc.kill('SIGKILL'); env.portal.proc = spawnNode('portal', ['portal/index.mjs'], portalEnv); return waitFor(async () => { const r = await fetch(env.portal.url + '/login').catch(() => null); return r && r.status === 200 ? r : null; }, { timeoutMs: 15000, intervalMs: 300 }); };
const pr = await waitFor(async () => { const r = await fetch(env.portal.url + '/login').catch(() => null); return r && r.status === 200 ? r : null; }, { timeoutMs: 20000, intervalMs: 300 });
if (!pr || pr.timedOut) throw new Error('포털 기동 실패: ' + env.portal.proc.lines.slice(-8).join(' | '));
// AriMail 서버 등록(운영자 중계) — 비밀은 보관소로만
const ms = await env.relay('POST', '/mail/servers', { name: 'e2e AriMail', baseUrl: am.url, internalUrl: am.url, setupUrl: am.url, mailHost: '127.0.0.1', isDefault: true, secrets: { 'setup-token': secrets.amSetup, 'usage-key': secrets.amUsage, 'sso-secret': secrets.amSso } });
env.mailServerRegistered = ms.status === 200; env.mailServerResponse = { status: ms.status, body: ms.json };
env.customer = () => makeClient(env.portal.url);
env.stop = async () => { for (const a of env.agents) a.proc.kill('SIGKILL'); env.portal.proc.kill('SIGTERM'); env.hal.proc.kill('SIGTERM'); await sleep(600); env.portal.proc.kill('SIGKILL'); env.hal.proc.kill('SIGKILL'); await Promise.all([mail.close(), wm.close(), am.close()]); if (!process.env.ARIWEB_E2E_KEEP) { try { rmSync(base, { recursive: true, force: true }); } catch (_) {} } };
return env;
}