2026-10-26 13:10:31 +00:00
/**
* 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 };
}
2026-10-28 15:07:35 +00:00
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)
2026-10-26 13:10:31 +00:00
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 ;
}