2026-10-26 11:58:15 +00:00
/**
* agent/jobs/module.mjs — 모듈 설치·갱신·되돌리기·건강 확인(§32·§29).
*
* 레이아웃: <appDir>/releases/<version>/ + current 심링크. 도입 모듈은 서버에 이미 있던 appDir(/srv/webmaker, /srv/arihakdang-mail …)과 유닛 이름을 facts 로 받아 그대로 쓴다.
* 절차(원본 remote-update.sh / deploy.sh 의 순서를 따른다): artifact 내려받기·sha256 확인 → releases/<ver> 에 풀기 → preStop 절차(데이터 백업) → 유닛 갱신(템플릿이 있을 때) → current 전환 → postStart 절차(마이그레이션 등) → 재시작 → 건강 확인 → 실제 버전 읽기.
* 실패하면 current 를 이전 릴리스로 되돌리고 재시작한 뒤 오류를 보고한다(명령 성공만으로 완료하지 않는다).
* 자기 자신(agent)·HAL 갱신: 결과를 먼저 보고한 뒤 재시작하도록 restartAfterReport 를 돌려준다.
*/
import { existsSync , mkdirSync , readFileSync , readdirSync , renameSync , copyFileSync , rmSync } from 'node:fs' ;
import { join } from 'node:path' ;
import http from 'node:http' ;
import { readLink , switchLink , untar , render , readJson , writeJson , rmrf } from './common.mjs' ;
/** 내부 건강 확인 URL 호출 */
function httpGet ( url , timeoutMs = 8000 ) { return new Promise (( resolve ) => { const t0 = Date . now (); const req = http . get ( url , ( res ) => { let body = '' ; res . on ( 'data' , ( c ) => { if ( body . length < 100000 ) body += c ; }); res . on ( 'end' , () => resolve ({ status : res . statusCode , body , ms : Date . now () - t0 })); }); req . setTimeout ( timeoutMs , () => req . destroy ( new Error ( 'timeout' ))); req . on ( 'error' , ( e ) => resolve ({ status : 0 , error : e . message , ms : Date . now () - t0 })); }); }
async function health ( ctx , desc ) { if ( ctx . sys . stub ) return { ok : true , stubbed : true }; if ( ! desc ? . health ? . url ) return { ok : true , skipped : 'no_health_url' }; let last = null ; for ( let i = 0 ; i < 20 ; i ++ ) { const r = await httpGet ( desc . health . url ); last = r ; if ( r . status && r . status < 500 && ( desc . health . expect !== 'ok' || /"ok"\s*:\s*true|ok/ . test ( r . body ))) return { ok : true , status : r . status , ms : r . ms }; await new Promise (( s ) => setTimeout ( s , 1500 )); } return { ok : false , status : last ? . status , error : last ? . error || `상태 ${ last ? . status } ` }; }
async function detectVersion ( ctx , desc , appDir ) { const cur = readLink ( join ( appDir , 'current' )); if ( desc ? . version ? . http && ! ctx . sys . stub ) { const r = await httpGet ( desc . version . http ); try { const j = JSON . parse ( r . body ); if ( j [ desc . version . field ]) return j [ desc . version . field ]; } catch ( _ ) {} } const pkg = cur ? readJson ( join ( cur , 'package.json' ), null ) : null ; return pkg ? . version || ( cur ? cur . split ( '/' ). pop () : null ); }
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
async function writeUnit ( ctx , desc , unit , appDir , version ) {
if ( ! ctx . payload . unitTemplate ) return { skipped : 'no_template' };
const old = await ctx . sys . unitText ( unit ); const envLines = old . split ( '\n' ). filter (( l ) => /^ Environment = /.test(l) && !/ ^ Environment = ( NODE_ENV | HOST | PORT | ARIWEB_ [ A - Z_ ] + _DATA | NODE_OPTIONS ) = /.test(l)).join('\n');
const user = ( /^User=(.+)$/m . exec ( old ) || [])[ 1 ] || desc . user || process . env . USER || 'master' ; const envFile = ( /^EnvironmentFile=(.+)$/m . exec ( old ) || [])[ 1 ] || '' ; const node = ( /^ExecStart=(\S+)/m . exec ( old ) || [])[ 1 ] || process . execPath ;
const txt = render ( ctx . payload . unitTemplate , { APP_DIR : appDir , USER : user , NODE : /node$/ . test ( node ) ? node : process . execPath , ENV_LINES : envLines , VERSION : version , UNIT : unit , ENV_FILE : envFile });
await ctx . sys . writeFile ( `/etc/systemd/system/ ${ unit } .service` , txt ); await ctx . sys . systemctl ( 'daemon-reload' , '' , { optional : true }); return { written : true , preservedEnv : envLines . split ( '\n' ). filter ( Boolean ). length };
}
/** preStop/postStart 절차 */
async function hook ( ctx , name , { appDir , cur , unit }) {
if ( name === 'webmaker.backupData' ) { const dataRoot = ( /^Environment=DATA_ROOT=(.+)$/m . exec ( await ctx . sys . unitText ( unit )) || [])[ 1 ] || join ( appDir , 'data' ); if ( ! existsSync ( dataRoot )) return { skipped : 'no_data' }; const out = join ( appDir , 'backups' , `data- ${ new Date (). toISOString (). replace ( /[-:.TZ]/g , '' ). slice ( 0 , 14 ) } .tar.gz` ); mkdirSync ( join ( appDir , 'backups' ), { recursive : true }); await ctx . sys . tar ([ '-czf' , out , '-C' , dataRoot , '--exclude=./backups' , '.' ]); const old = readdirSync ( join ( appDir , 'backups' )). sort (); for ( const f of old . slice ( 0 , Math . max ( 0 , old . length - 5 ))) rmSync ( join ( appDir , 'backups' , f ), { force : true }); return { backup : out }; }
if ( name === 'arimail.backupDb' ) { const db = join ( appDir , 'data' , 'mail-gateway.sqlite' ); if ( ! existsSync ( db )) return { skipped : 'no_db' }; const { DatabaseSync } = await import ( 'node:sqlite' ); mkdirSync ( join ( appDir , 'data' , 'backups' ), { recursive : true }); const out = join ( appDir , 'data' , 'backups' , `pre- ${ new Date (). toISOString (). replace ( /[-:.TZ]/g , '' ). slice ( 0 , 14 ) } .sqlite` ); const d = new DatabaseSync ( db , { readOnly : true }); try { d . exec ( `VACUUM INTO ' ${ out } '` ); } finally { d . close (); } return { backup : out }; }
if ( name === 'arimail.migrate' ) { const db = join ( appDir , 'data' , 'mail-gateway.sqlite' ); const r = await ctx . sys . run ( process . execPath , [ join ( cur , 'server' , 'db-migrate.mjs' )], { allowStub : false , timeoutMs : 120000 }). catch (( e ) => ({ code : 1 , stderr : e . message })); if ( ! ctx . sys . stub && ! existsSync ( join ( cur , 'server' , 'db-migrate.mjs' ))) return { skipped : 'no_migrate' }; if ( r . code !== 0 && ! ctx . sys . stub ) throw Object . assign ( new Error ( `AriMail 마이그레이션 실패: ${ ( r . stderr || '' ). slice ( 0 , 300 ) } ` ), { code : 'migrate_failed' }); return { migrated : true , db }; }
if ( name === 'arimail.haraka' ) { const r = await ctx . sys . systemctl ( 'restart' , ` ${ unit } -haraka` , { optional : true }); return { haraka : r . code === 0 ? 'restarted' : 'skipped' }; }
if ( name === 'webmaker.unitCheck' ) { const r = await ctx . sys . run ( process . execPath , [ join ( cur , 'scripts' , 'unit-check.js' )], { allowStub : true , timeoutMs : 60000 }). catch (() => ({ code : 1 })); return { unitCheck : r . code === 0 ? 'ok' : 'warn' }; }
return { skipped : `unknown hook ${ name } ` };
}
2026-10-26 13:18:20 +00:00
/**
* [E2E 결함 수정] 설치 디렉터리 결정. HAL 이 준 appDir 가 기본 루트(/srv/ariweb/…) 아래이고 이 Agent 의 루트(ARIWEB_SRV_ROOT)가 다른 곳으로
* 재정의되어 있으면(검증 환경·일반 계정) 그 루트 아래로 옮긴다. 서버에서 발견한 기존 경로(/srv/webmaker 등)는 그대로 둔다.
*/
function resolveAppDir ( ctx , p ) {
const srv = ctx . roots . srv ; const given = p . appDir ;
if ( ! given ) return join ( srv , p . module );
if ( srv !== '/srv/ariweb' && String ( given ). startsWith ( '/srv/ariweb/' )) return join ( srv , String ( given ). slice ( '/srv/ariweb/' . length ));
return given ;
}
2026-10-26 11:58:15 +00:00
async function installRelease ( ctx , { module , version , appDir , desc , unit , action , toVersion }) {
const relDir = join ( appDir , 'releases' , action === 'rollback' ? ( toVersion || '' ) : version ); const prev = readLink ( join ( appDir , 'current' ));
if ( action === 'rollback' ) { const target = toVersion ? relDir : readJson ( join ( appDir , '.previous.json' ), {}). previous ; if ( ! target || ! existsSync ( target )) throw Object . assign ( new Error ( '되돌릴 이전 릴리스가 없습니다' ), { code : 'no_previous_release' }); return { relDir : target , prev }; }
await ctx . step ( 'download' , async () => { if ( ! ctx . payload . artifactUrl ) throw Object . assign ( new Error ( 'artifact 주소가 없습니다' ), { code : 'artifact_missing' }); mkdirSync ( join ( appDir , 'work' ), { recursive : true }); const f = join ( appDir , 'work' , ` ${ module } - ${ version } .tar.gz` ); const r = await ctx . client . download ( ctx . payload . artifactUrl , f , ctx . payload . sha256 || null ); return { file : f , sha256 : r . sha256 , size : r . size }; }, 'artifact 내려받기·검증' );
await ctx . step ( 'extract' , async () => { rmrf ( relDir ); mkdirSync ( relDir , { recursive : true }); await untar ( ctx , join ( appDir , 'work' , ` ${ module } - ${ version } .tar.gz` ), relDir ); if ( desc . copyPaths && ! existsSync ( join ( relDir , 'package.json' ))) throw Object . assign ( new Error ( 'artifact 에 package.json 이 없습니다' ), { code : 'bad_artifact' }); writeJson ( join ( relDir , 'REVISION.json' ), { module , version , installedAt : new Date (). toISOString () }); rmSync ( join ( appDir , 'work' , ` ${ module } - ${ version } .tar.gz` ), { force : true }); return { dir : relDir }; }, '릴리스 풀기' );
return { relDir , prev };
}
export const kinds = {
'module.install' : { title : '모듈 설치·갱신' , async run ( ctx ) { return installOrRollback ( ctx , ctx . payload . action || 'install' ); } },
'module.rollback' : { title : '모듈 되돌리기' , async run ( ctx ) { return installOrRollback ( ctx , 'rollback' ); } },
2026-10-26 13:18:20 +00:00
'module.health' : { title : '모듈 건강 확인' , async run ( ctx ) { const p = ctx . payload ; const desc = p . descriptor || {}; const appDir = resolveAppDir ( ctx , p ); const unit = p . unit || desc . unit ; const active = unit ? await ctx . sys . isActive ( unit ) : true ; const h = await health ( ctx , desc ); return { module : p . module , actualVersion : await detectVersion ( ctx , desc , appDir ), active , health : { ok : active && h . ok , ... h }, checkedAt : new Date (). toISOString () }; } },
2026-10-26 11:58:15 +00:00
};
async function installOrRollback ( ctx , action ) {
2026-10-26 13:18:20 +00:00
const p = ctx . payload ; const { module , version , descriptor : desc = {}, toVersion = null } = p ; const appDir = resolveAppDir ( ctx , p ); const unit = ( desc && desc . unit ) || `ariweb- ${ module } ` ;
2026-10-26 11:58:15 +00:00
mkdirSync ( join ( appDir , 'releases' ), { recursive : true });
const { relDir , prev } = await ctx . step ( 'place' , () => installRelease ( ctx , { module , version , appDir , desc , unit , action , toVersion }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치' );
await ctx . progress ( 40 );
for ( const h of desc . preStop || []) await ctx . step ( `pre: ${ h } ` , () => hook ( ctx , h , { appDir , cur : relDir , unit }), `사전 절차 ${ h } ` );
await ctx . step ( 'unit' , () => writeUnit ( ctx , desc , unit , appDir , version ), '실행 유닛 갱신' );
await ctx . step ( 'switch' , () => { switchLink ( join ( appDir , 'current' ), relDir ); writeJson ( join ( appDir , '.previous.json' ), { previous : prev , at : new Date (). toISOString () }); return { from : prev , to : relDir }; }, 'current 전환' );
for ( const h of desc . postStart || []) await ctx . step ( `post: ${ h } ` , () => hook ( ctx , h , { appDir , cur : relDir , unit }), `사후 절차 ${ h } ` );
const self = module === 'agent' ; let healthRes = { ok : true };
if ( ! self && desc . layout !== 'agent-feature' ) {
try { await ctx . step ( 'restart' , () => ctx . sys . systemctl ( 'restart' , unit ), '서비스 재시작' ); for ( const u of desc . extraUnits || []) await ctx . sys . systemctl ( 'restart' , u , { optional : true }); }
catch ( e ) { if ( prev && existsSync ( prev )) { switchLink ( join ( appDir , 'current' ), prev ); await ctx . sys . systemctl ( 'restart' , unit , { optional : true }); } throw Object . assign ( e , { code : e . code || 'restart_failed' , partial : { rolledBackTo : prev } }); }
healthRes = await ctx . step ( 'health' , () => health ( ctx , desc ), '건강 확인' );
if ( ! healthRes . ok ) { if ( prev && existsSync ( prev )) { switchLink ( join ( appDir , 'current' ), prev ); await ctx . sys . systemctl ( 'restart' , unit , { optional : true }); } throw Object . assign ( new Error ( `재시작 뒤 건강 확인 실패( ${ healthRes . error || healthRes . status } ) — 이전 릴리스로 되돌렸습니다` ), { code : 'health_failed' , partial : { rolledBackTo : prev , health : healthRes } }); }
}
const actual = self ? readJson ( join ( relDir , 'package.json' ), {}). version || version : await ctx . step ( 'version' , () => detectVersion ( ctx , desc , appDir ), '실제 버전 읽기' );
// 오래된 릴리스 정리(최근 3개 보존)
const rels = readdirSync ( join ( appDir , 'releases' )). sort (); for ( const r of rels . slice ( 0 , Math . max ( 0 , rels . length - 3 ))) { const d = join ( appDir , 'releases' , r ); if ( d !== relDir && d !== prev ) rmrf ( d ); }
return { module , actualVersion : actual , previousVersion : prev ? prev . split ( '/' ). pop () : null , health : healthRes , active : true , restartAfterReport : self ? unit : null , releaseDir : relDir };
}