2026-10-26 11:58:15 +00:00
/** 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' ;
2026-10-27 08:56:32 +00:00
import { scanWebmakerInventory , webmakerInventoryBatches , configuredWebmakerInventory } from '../agent/webmaker-inventory.mjs' ;
import { mkdirSync , writeFileSync , readFileSync , existsSync , readdirSync , readlinkSync , copyFileSync , statSync , appendFileSync , chmodSync , symlinkSync } from 'node:fs' ;
2026-10-26 11:58:15 +00:00
import { join } from 'node:path' ;
import { execFileSync } from 'node:child_process' ;
import { createHash } from 'node:crypto' ;
2026-10-26 13:38:45 +00:00
import { ROOT as ROOT_DIR , readJson } from '../agent/jobs/common.mjs' ;
2026-10-26 11:58:15 +00:00
/** 가짜 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 ; }
2026-10-27 08:56:32 +00:00
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 );
});
2026-10-26 11:58:15 +00:00
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' );
});
2026-10-26 13:38:45 +00:00
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' }));
2026-10-26 14:03:43 +00:00
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" );
2026-10-26 13:38:45 +00:00
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 (); }
2026-10-26 14:03:43 +00:00
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 문법(공백 값·따옴표·주석)
2026-10-26 13:38:45 +00:00
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 (); }
2026-10-26 13:56:42 +00:00
// 같은 버전 재설치(실행 중인 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 } });
2026-10-26 14:03:43 +00:00
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' ));
2026-10-26 13:38:45 +00:00
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' )]);
2026-10-26 13:56:42 +00:00
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` : '' );
2026-10-27 08:56:32 +00:00
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' ;
2026-10-26 13:38:45 +00:00
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' } });
2026-10-27 08:56:32 +00:00
if ( oldLocale == null ) delete process . env . LC_ALL ; else process . env . LC_ALL = oldLocale ;
2026-10-26 13:38:45 +00:00
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' );
2026-10-26 13:56:42 +00:00
{ 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 )); }
2026-10-26 13:38:45 +00:00
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' ));
});
2026-10-26 11:58:15 +00:00
done ();