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 를 돌려준다.
2026-10-26 13:38:45 +00:00
*
* [실제 환경 결함 수정 0.1.1] 실행 계정이 다른 도입 모듈: 유닛의 User= 가 Agent 계정과 다르면(예: AriMail 의 arihakdang) 그 디렉터리에 Agent 가 직접 쓸 수 없다.
* 이때는 모든 파일 작업(풀기·REVISION·current 전환·정리·DB 백업·마이그레이션)을 `sudo -n -u <계정> bash deploy/module-as-user.sh …` 로 그 계정이 수행한다
* (sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용). artifact 는 Agent 의 작업 디렉터리(<srv>/agent/work)에 받아 그 계정이 읽게 한다.
* 마이그레이션은 유닛의 EnvironmentFile(DB_FILE 등)을 같은 계정으로 읽어 실행한다 — 없으면 릴리스 디렉터리 안에 빈 DB 를 만드는 잘못된 동작이 된다.
2026-10-28 15:07:35 +00:00
* [실제 환경 결함 수정 0.1.1] 실제 버전: 도입 모듈의 package.json 버전(Webmaker 1.7.3·AriMail 1.0.11 — 0.2.0 부터 Webmaker 1.8.0)은 Suite 릴리스 버전(0.1.1·0.2.0)과 다르다.
2026-10-26 13:38:45 +00:00
* HAL 은 "목표 릴리스 버전 = 실제 버전" 으로 판정하므로, 풀 때 적은 REVISION.json 의 version 을 실제 버전으로 읽고 package.json 버전은 upstreamVersion 으로 함께 보고한다.
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
2026-10-26 11:58:15 +00:00
*/
2026-10-26 13:56:42 +00:00
import { existsSync , mkdirSync , readFileSync , readdirSync , rmSync , chmodSync , renameSync } from 'node:fs' ;
2026-10-26 11:58:15 +00:00
import { join } from 'node:path' ;
2026-10-26 13:38:45 +00:00
import { userInfo } from 'node:os' ;
2026-10-26 11:58:15 +00:00
import http from 'node:http' ;
2026-10-26 13:38:45 +00:00
import { ROOT , readLink , switchLink , untar , render , readJson , writeJson , rmrf } from './common.mjs' ;
2026-10-26 11:58:15 +00:00
/** 내부 건강 확인 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 } ` }; }
2026-10-26 13:38:45 +00:00
/**
* 실제 버전 읽기. 우선순위: 건강 응답의 version(HAL·포털) → current/REVISION.json 의 Suite 릴리스 버전(도입 모듈) → package.json → 디렉터리 이름.
* 도입 모듈은 package.json 이 상류 버전이라 upstreamVersion 으로 따로 돌려준다(화면·영수증 표시용).
*/
async function detectVersion ( ctx , desc , appDir ) {
const cur = readLink ( join ( appDir , 'current' )); const pkg = cur ? readJson ( join ( cur , 'package.json' ), null ) : null ; const rev = cur ? readJson ( join ( cur , 'REVISION.json' ), null ) : null ;
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 { version : j [ desc . version . field ], upstreamVersion : pkg ? . version || null }; } catch ( _ ) {} }
if ( rev ? . version ) return { version : rev . version , upstreamVersion : pkg ? . version && pkg . version !== rev . version ? pkg . version : null };
return { version : pkg ? . version || ( cur ? cur . split ( '/' ). pop () : null ), upstreamVersion : null };
}
/** 유닛에서 실행 계정·EnvironmentFile·node 경로를 읽는다. 계정이 Agent 계정과 다르면 asUser 모드(파일 작업을 그 계정으로). */
async function serviceContext ( ctx , unit ) {
const txt = unit ? await ctx . sys . unitText ( unit ) : '' ; const me = (() => { try { return userInfo (). username ; } catch ( _ ) { return process . env . USER || '' ; } })();
const user = ( /^User=(.+)$/m . exec ( txt ) || [])[ 1 ] ? . trim () || null ; const envFile = (( /^EnvironmentFile=(.+)$/m . exec ( txt ) || [])[ 1 ] || '' ). replace ( /^-/ , '' ). trim () || null ; const node = ( /^ExecStart=(\S+)/m . exec ( txt ) || [])[ 1 ] || null ;
return { unitText : txt , user , envFile , nodeBin : node && /node$/ . test ( node ) ? node : null , asUser : !! ( user && user !== me && user !== 'root' ), me };
}
/** 실행 계정으로 파일 작업(deploy/module-as-user.sh). 실패하면 stderr 를 포함한 오류를 던진다. */
async function asUser ( ctx , svc , appDir , sub , args , { input = null } = {}) {
2026-10-26 13:56:42 +00:00
// sudoers 규칙은 current 심링크 경로(<srv>/agent/current/deploy/module-as-user.sh)로 허용한다 — ESM 의 import.meta.url 은 실제 경로(releases/<ver>)로 풀리므로 current 경로를 우선 쓴다.
const viaCurrent = join ( ctx . roots . srv , 'agent' , 'current' , 'deploy' , 'module-as-user.sh' ); const script = existsSync ( viaCurrent ) ? viaCurrent : join ( ROOT , 'deploy' , 'module-as-user.sh' );
const r = await ctx . sys . run ( 'sudo' , [ '-n' , '-u' , svc . user , '/usr/bin/bash' , script , sub , '--app-dir' , appDir , ... args ], { input , timeoutMs : 600000 });
2026-10-26 13:38:45 +00:00
if ( r . code !== 0 ) throw Object . assign ( new Error ( `실행 계정( ${ svc . user } )으로 ${ sub } 실패: ${ ( r . stderr || r . stdout || '' ). trim (). slice ( 0 , 300 ) || `코드 ${ r . code } ` } ` ), { code : r . code === 1 && /sudo|password/i . test ( r . stderr || '' ) ? 'sudo_denied' : 'as_user_failed' , hint : 'deploy/sudoers/ariweb-agent 의 module-as-user.sh 규칙이 설치되어 있어야 합니다' });
return r ;
}
/** 파일 쓰기·심링크 전환·삭제를 모드(직접/실행 계정)에 맞게 수행 */
const fsOps = ( ctx , svc , appDir ) => ({
writeJson : async ( p , v ) => ( svc . asUser ? asUser ( ctx , svc , appDir , 'write' , [ '--file' , p ], { input : JSON . stringify ( v , null , 1 ) }) : writeJson ( p , v )),
switchLink : async ( link , target ) => ( svc . asUser ? asUser ( ctx , svc , appDir , 'switch' , [ '--link' , link , '--target' , target ]) : switchLink ( link , target )),
remove : async ( dir ) => ( svc . asUser ? asUser ( ctx , svc , appDir , 'remove' , [ '--dir' , dir ]) : rmrf ( dir )),
2026-10-26 13:56:42 +00:00
rename : async ( from , to ) => ( svc . asUser ? asUser ( ctx , svc , appDir , 'rename' , [ '--dir' , from , '--target' , to ]) : renameSync ( from , to )),
2026-10-26 13:38:45 +00:00
});
2026-10-26 11:58:15 +00:00
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
2026-10-26 13:38:45 +00:00
async function writeUnit ( ctx , desc , unit , appDir , version , svc ) {
2026-10-26 11:58:15 +00:00
if ( ! ctx . payload . unitTemplate ) return { skipped : 'no_template' };
2026-10-26 13:38:45 +00:00
const old = svc . unitText ;
if ( desc . keepExistingUnit && new RegExp ( `^WorkingDirectory= ${ appDir . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) } /current/?$` , 'm' ). test ( old )) return { skipped : 'existing_unit_kept' , user : svc . user };
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 = svc . user || desc . user || process . env . USER || 'master' ; const envFile = svc . envFile || '' ; const node = svc . nodeBin || process . execPath ;
const txt = render ( ctx . payload . unitTemplate , { APP_DIR : appDir , USER : user , NODE : node , ENV_LINES : envLines , VERSION : version , UNIT : unit , ENV_FILE : envFile });
2026-10-26 14:12:28 +00:00
await ctx . sys . writeFile ( `/etc/systemd/system/ ${ unit } .service` , txt ); await ctx . sys . systemctl ( 'daemon-reload' , '' , { optional : true }); const en = await ctx . sys . systemctl ( 'enable' , unit , { optional : true }); /* 새로 만든 유닛은 재부팅 뒤에도 뜨도록 enable(실제 환경 결함 수정: 포털 유닛이 disabled 였다) */ return { written : true , enabled : en . code === 0 , preservedEnv : envLines . split ( '\n' ). filter ( Boolean ). length };
2026-10-26 11:58:15 +00:00
}
2026-10-26 13:38:45 +00:00
/** EnvironmentFile(KEY=값 줄) 읽기 — 직접 모드의 마이그레이션에 싣는다. 값은 돌려주되 로그에는 쓰지 않는다. */
function readEnvFile ( p ) { const out = {}; if ( ! p || ! existsSync ( p )) return out ; for ( const line of readFileSync ( p , 'utf8' ). split ( '\n' )) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/ . exec ( line ); if ( ! m ) continue ; let v = m [ 2 ]. trim (); if (( v . startsWith ( '"' ) && v . endsWith ( '"' )) || ( v . startsWith ( "'" ) && v . endsWith ( "'" ))) v = v . slice ( 1 , - 1 ); out [ m [ 1 ]] = v ; } return out ; }
2026-10-26 11:58:15 +00:00
/** preStop/postStart 절차 */
2026-10-26 13:38:45 +00:00
async function hook ( ctx , name , { appDir , cur , unit , svc }) {
2026-10-26 13:56:42 +00:00
if ( name === 'webmaker.backupData' ) {
const dataRoot = ( /^Environment=DATA_ROOT=(.+)$/m . exec ( svc . unitText ) || [])[ 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 });
// [실제 환경 결함 수정] 운영 데이터 디렉터리에는 다른 계정(root)이 남긴 파일이나 백업 중 바뀌는 파일이 있을 수 있다. 읽지 못한 파일은 경고로 남기고 배포를 막지 않는다(tar 종료 코드 2 = 치명적 오류만 실패).
const r = await ctx . sys . run ( 'tar' , [ '-czf' , out , '-C' , dataRoot , '--exclude=./backups' , '--ignore-failed-read' , '--warning=no-file-changed' , '--warning=no-file-removed' , '.' ], { allowStub : false });
if ( r . code !== 0 && r . code !== 1 ) throw new Error ( `데이터 백업 실패: ${ ( r . stderr || '' ). slice ( 0 , 300 ) } ` );
const warnings = ( r . stderr || '' ). split ( '\n' ). filter (( l ) => / Cannot open | Permission denied | Cannot stat / . test ( l )). map (( l ) => l . replace ( /^tar: / , '' )). slice ( 0 , 20 );
if ( warnings . length ) ctx . log ( `데이터 백업에서 읽지 못한 파일 ${ warnings . length } 개(경고): ${ warnings [ 0 ] } ` );
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 , unreadable : warnings . length ? warnings : undefined };
}
2026-10-26 13:38:45 +00:00
if ( name === 'arimail.backupDb' ) {
2026-10-26 13:56:42 +00:00
const env = svc . asUser ? {} : readEnvFile ( svc . envFile ); const db = env . DB_FILE || join ( appDir , 'data' , 'mail-gateway.sqlite' ); let out = join ( appDir , 'data' , 'backups' , `pre- ${ new Date (). toISOString (). replace ( /[-:.TZ]/g , '' ). slice ( 0 , 14 ) } .sqlite` ); for ( let n = 1 ; existsSync ( out ); n ++ ) out = out . replace ( /(-\d+)?\.sqlite$/ , `- ${ n } .sqlite` ); // 같은 초에 두 번 설치해도 VACUUM INTO 대상이 겹치지 않게
2026-10-26 13:38:45 +00:00
if ( svc . asUser ) { const r = await asUser ( ctx , svc , appDir , 'backup-db' , [ '--db' , db , '--out' , out , ...( svc . nodeBin ? [ '--node' , svc . nodeBin ] : [])]); return /skipped/ . test ( r . stdout ) ? { skipped : 'no_db' } : { backup : out , via : svc . user }; }
if ( ! existsSync ( db )) return { skipped : 'no_db' }; const { DatabaseSync } = await import ( 'node:sqlite' ); mkdirSync ( join ( appDir , 'data' , 'backups' ), { recursive : true }); const d = new DatabaseSync ( db , { readOnly : true }); try { d . exec ( `VACUUM INTO ' ${ out . replace ( /'/g , "''" ) } '` ); } finally { d . close (); } return { backup : out };
}
if ( name === 'arimail.migrate' ) {
const script = join ( 'server' , 'db-migrate.mjs' ); if ( ! ctx . sys . stub && ! existsSync ( join ( cur , script ))) return { skipped : 'no_migrate' };
if ( svc . asUser ) { const r = await asUser ( ctx , svc , appDir , 'migrate' , [ '--cwd' , cur , '--script' , script , ...( svc . envFile ? [ '--env-file' , svc . envFile ] : []), ...( svc . nodeBin ? [ '--node' , svc . nodeBin ] : [])]); return /skipped/ . test ( r . stdout ) ? { skipped : 'no_migrate' } : { migrated : true , via : svc . user , envFile : svc . envFile || null }; }
const env = readEnvFile ( svc . envFile ); const r = await ctx . sys . run ( svc . nodeBin || process . execPath , [ join ( cur , script )], { allowStub : false , timeoutMs : 120000 , env : { ... process . env , ... env } }). catch (( e ) => ({ code : 1 , stderr : e . message }));
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 : env . DB_FILE || join ( appDir , 'data' , 'mail-gateway.sqlite' ) };
}
2026-10-26 11:58:15 +00:00
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 13:38:45 +00:00
async function installRelease ( ctx , { module , version , appDir , desc , action , toVersion , svc }) {
2026-10-26 11:58:15 +00:00
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 }; }
2026-10-26 13:38:45 +00:00
// 실행 계정 모드에서는 appDir 에 쓸 수 없으므로 Agent 의 작업 디렉터리에 받는다(그 계정이 읽을 수 있게 0644).
const workDir = svc . asUser ? join ( ctx . roots . srv , 'agent' , 'work' ) : join ( appDir , 'work' ); const tgz = join ( workDir , ` ${ module } - ${ version } .tar.gz` );
2026-10-26 13:56:42 +00:00
await ctx . step ( 'download' , async () => { if ( ! ctx . payload . artifactUrl ) throw Object . assign ( new Error ( 'artifact 주소가 없습니다' ), { code : 'artifact_missing' }); mkdirSync ( workDir , { recursive : true }); const r = await ctx . client . download ( ctx . payload . artifactUrl , tgz , ctx . payload . sha256 || null ); if ( svc . asUser ) chmodSync ( tgz , 0o644 ); /* 내려받기는 0600 으로 쓰므로 실행 계정이 읽게 한다(artifact 는 비밀이 아니다) */ return { file : tgz , sha256 : r . sha256 , size : r . size }; }, 'artifact 내려받기·검증' );
// [실제 환경 결함 수정] 같은 버전 재설치: 실행 중인 current 가 가리키는 디렉터리를 지우고 다시 풀면 실행 중인 서비스가 잠깐 코드를 잃는다(지연 require 실패) 데다 되돌릴 곳도 없다.
// 임시 디렉터리에 먼저 풀고, 기존 디렉터리는 <ver>.prev-<시각> 으로 이름을 바꿔 보존한 뒤(이것이 되돌리기 대상) 새 디렉터리를 제자리에 둔다.
let prevDir = prev ;
2026-10-26 13:38:45 +00:00
await ctx . step ( 'extract' , async () => {
2026-10-26 13:56:42 +00:00
const ops = fsOps ( ctx , svc , appDir ); const tmp = ` ${ relDir } .tmp` ;
if ( svc . asUser ) await asUser ( ctx , svc , appDir , 'extract' , [ '--tgz' , tgz , '--dest' , tmp ]); else { rmrf ( tmp ); mkdirSync ( tmp , { recursive : true }); await untar ( ctx , tgz , tmp ); }
if ( desc . copyPaths && ! existsSync ( join ( tmp , 'package.json' ))) { await ops . remove ( tmp ). catch (() => {}); throw Object . assign ( new Error ( 'artifact 에 package.json 이 없습니다' ), { code : 'bad_artifact' }); }
await ops . writeJson ( join ( tmp , 'REVISION.json' ), { module , version , upstreamVersion : readJson ( join ( tmp , 'package.json' ), {}). version || null , installedAt : new Date (). toISOString (), by : svc . asUser ? svc . user : svc . me });
let kept = null ;
2026-10-26 14:03:43 +00:00
if ( existsSync ( relDir )) { if ( prev && prev . replace ( /\/$/ , '' ) === relDir ) { const base = ` ${ relDir } .prev- ${ new Date (). toISOString (). replace ( /[-:.TZ]/g , '' ). slice ( 0 , 14 ) } ` ; kept = base ; for ( let n = 1 ; existsSync ( kept ); n ++ ) kept = ` ${ base } . ${ n } ` ; await ops . rename ( relDir , kept ); prevDir = kept ; } else await ops . remove ( relDir ); }
2026-10-26 13:56:42 +00:00
await ops . rename ( tmp , relDir );
rmSync ( tgz , { force : true }); return { dir : relDir , asUser : svc . asUser ? svc . user : null , replacedRunning : kept };
2026-10-26 13:38:45 +00:00
}, '릴리스 풀기' );
2026-10-26 13:56:42 +00:00
return { relDir , prev : prevDir };
2026-10-26 11:58:15 +00:00
}
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:38:45 +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 ); const v = await detectVersion ( ctx , desc , appDir ); return { module : p . module , actualVersion : v . version , upstreamVersion : v . upstreamVersion , 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 13:38:45 +00:00
const svc = await serviceContext ( ctx , unit ); const ops = fsOps ( ctx , svc , appDir );
if ( ! svc . asUser ) mkdirSync ( join ( appDir , 'releases' ), { recursive : true });
if ( svc . asUser ) ctx . log ( `실행 계정 모드: 유닛 ${ unit } 의 User= ${ svc . user } 로 파일 작업을 수행합니다` );
const { relDir , prev } = await ctx . step ( 'place' , () => installRelease ( ctx , { module , version , appDir , desc , action , toVersion , svc }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치' );
2026-10-26 11:58:15 +00:00
await ctx . progress ( 40 );
2026-10-26 13:38:45 +00:00
for ( const h of desc . preStop || []) await ctx . step ( `pre: ${ h } ` , () => hook ( ctx , h , { appDir , cur : relDir , unit , svc }), `사전 절차 ${ h } ` );
await ctx . step ( 'unit' , () => writeUnit ( ctx , desc , unit , appDir , version , svc ), '실행 유닛 갱신' );
await ctx . step ( 'switch' , async () => { await ops . switchLink ( join ( appDir , 'current' ), relDir ); await ops . writeJson ( join ( appDir , '.previous.json' ), { previous : prev , at : new Date (). toISOString () }); return { from : prev , to : relDir }; }, 'current 전환' );
2026-10-26 11:58:15 +00:00
const self = module === 'agent' ; let healthRes = { ok : true };
2026-10-26 13:38:45 +00:00
const revert = async () => { if ( prev && existsSync ( prev )) { await ops . switchLink ( join ( appDir , 'current' ), prev ). catch (() => {}); await ctx . sys . systemctl ( 'restart' , unit , { optional : true }); } };
2026-10-26 14:03:43 +00:00
// 사후 절차(마이그레이션 등)가 실패하면 current 를 이전 릴리스로 되돌린다 — 그대로 두면 다음 재시작 때 마이그레이션 없는 새 코드가 뜬다(실제 환경 결함 수정).
for ( const h of desc . postStart || []) { try { await ctx . step ( `post: ${ h } ` , () => hook ( ctx , h , { appDir , cur : relDir , unit , svc }), `사후 절차 ${ h } ` ); } catch ( e ) { await revert (); throw Object . assign ( e , { code : e . code || 'post_hook_failed' , partial : { rolledBackTo : prev , hook : h } }); } }
2026-10-26 11:58:15 +00:00
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 }); }
2026-10-26 13:38:45 +00:00
catch ( e ) { await revert (); throw Object . assign ( e , { code : e . code || 'restart_failed' , partial : { rolledBackTo : prev } }); }
2026-10-26 11:58:15 +00:00
healthRes = await ctx . step ( 'health' , () => health ( ctx , desc ), '건강 확인' );
2026-10-26 13:38:45 +00:00
if ( ! healthRes . ok ) { await revert (); throw Object . assign ( new Error ( `재시작 뒤 건강 확인 실패( ${ healthRes . error || healthRes . status } ) — 이전 릴리스로 되돌렸습니다` ), { code : 'health_failed' , partial : { rolledBackTo : prev , health : healthRes } }); }
2026-10-26 11:58:15 +00:00
}
2026-10-26 13:38:45 +00:00
const ver = self ? { version : readJson ( join ( relDir , 'package.json' ), {}). version || version , upstreamVersion : null } : await ctx . step ( 'version' , () => detectVersion ( ctx , desc , appDir ), '실제 버전 읽기' );
2026-10-26 11:58:15 +00:00
// 오래된 릴리스 정리(최근 3개 보존)
2026-10-26 13:38:45 +00:00
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 ) await ops . remove ( d ). catch (( e ) => ctx . log ( `오래된 릴리스 정리 실패( ${ r } ): ${ e . message } ` )); }
return { module , actualVersion : ver . version , upstreamVersion : ver . upstreamVersion , previousVersion : prev ? prev . split ( '/' ). pop () : null , health : healthRes , active : true , restartAfterReport : self ? unit : null , releaseDir : relDir , runUser : svc . user || svc . me };
2026-10-26 11:58:15 +00:00
}