2026-10-26 11:58:15 +00:00
/**
* agent/index.mjs — Node Agent 진입점·CLI(§5·§28.2·§10.2).
*
* node agent/index.mjs register --hal <url> --token <t> [--role web|mail|backup|control] [--external --site <siteId>] [--label <이름>]
* node agent/index.mjs run 서비스 루프: heartbeat(30초) + 작업 long-poll + 재시작 뒤 미완료 작업 재개
* node agent/index.mjs status 등록 상태·HAL 연결 확인
* 비밀(Agent secret)은 데이터 디렉터리의 보관소에만 둔다. 등록 응답의 비밀은 그 자리에서 저장하고 화면에 보이지 않는다.
*/
import { loadAgentConfig } from './config.mjs' ;
import { createClient } from './client.mjs' ;
import { createSys } from './sys.mjs' ;
import { createRunner } from './runner.mjs' ;
import { KINDS } from './jobs/index.mjs' ;
import { sample , facts } from './metrics.mjs' ;
import { readFileSync , existsSync , readdirSync } from 'node:fs' ;
import { join } from 'node:path' ;
import { hostname } from 'node:os' ;
const VERSION = JSON . parse ( readFileSync ( new URL ( '../package.json' , import . meta . url ), 'utf8' )). version ;
const log = (... a ) => console . log ( new Date (). toISOString (), ... a );
const args = process . argv . slice ( 2 ); const cmd = args [ 0 ] || 'run' ; const opt = ( n , d = null ) => { const i = args . indexOf ( '--' + n ); return i >= 0 ? ( args [ i + 1 ] && ! args [ i + 1 ]. startsWith ( '--' ) ? args [ i + 1 ] : true ) : d ; };
const cfg = loadAgentConfig (); const sys = createSys ({ log });
if ( cmd === 'register' ) {
const hal = opt ( 'hal' ); const token = opt ( 'token' ); if ( ! hal || ! token ) { console . error ( '--hal 과 --token 이 필요합니다' ); process . exit ( 1 ); }
const f = facts ( sys ); f . role = opt ( 'role' , 'web' ); f . location = opt ( 'location' , '' );
const r = await fetch ( hal . replace ( /\/$/ , '' ) + '/agent/v1/register' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ({ token , facts : f , version : VERSION }) }); const j = await r . json (). catch (() => ({}));
if ( ! r . ok || ! j . ok ) { console . error ( `등록 실패: ${ j . error || r . status } ${ j . message || '' } ` ); process . exit ( 1 ); }
cfg . vault (). set ( 'agent-secret' , j . secret , { kind : 'agent-secret' }); cfg . save ({ id : j . agentId , halUrl : hal , serverId : j . serverId || null , kind : j . kind , siteId : j . siteId || null , groupId : j . groupId || null , registeredAt : new Date (). toISOString (), label : opt ( 'label' , hostname ()) });
console . log ( `등록 완료: agent ${ j . agentId } ( ${ j . kind }${ j . serverId ? `, 서버 ${ j . serverId } ` : '' } ). 비밀은 ${ cfg . dataDir } 의 보관소에 저장했습니다. 운영자 승인 뒤 배치가 가능합니다` );
} else if ( cmd === 'status' ) {
if ( ! cfg . id ) { console . log ( '등록되지 않음' ); process . exit ( 1 ); } const client = createClient ({ halUrl : cfg . halUrl , agentId : cfg . id , secret : cfg . vault (). get ( 'agent-secret' ) });
try { const r = await client . request ( 'POST' , '/agent/v1/heartbeat' , { version : VERSION , metrics : sample ( cfg . roots ), modules : {}, clockSkewMs : 0 }); console . log ( `연결 정상: agent ${ cfg . id } ( ${ cfg . kind } ) → ${ cfg . halUrl } , 대기 작업 ${ r . pendingJobs } , 시계 차이 ${ client . clockSkewMs } ms` ); } catch ( e ) { console . error ( `HAL 연결 실패: ${ e . message } ` ); process . exit ( 1 ); }
} else if ( cmd === 'run' ) {
if ( ! cfg . id ) { console . error ( '등록되지 않았습니다 — register 먼저' ); process . exit ( 1 ); }
const client = createClient ({ halUrl : cfg . halUrl , agentId : cfg . id , secret : cfg . vault (). get ( 'agent-secret' ) });
const runner = createRunner ({ kinds : KINDS , roots : cfg . roots , sys , client , dataDir : cfg . dataDir , log });
let stopping = false ; let running = 0 ; let lastFacts = null ; let restartUnit = null ;
2026-10-26 14:12:28 +00:00
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/REVISION.json(없으면 package.json) 버전 */
async function moduleStatus ( f ) { const out = {}; for ( const [ m , unit ] of Object . entries ( f . units || {})) { const appDir = ( f . appDirs || {})[ m ] || join ( cfg . roots . srv , m ); let version = null ; try { version = JSON . parse ( readFileSync ( join ( appDir , 'current' , 'REVISION.json' ), 'utf8' )). version ; } catch ( _ ) {} if ( ! version ) { try { version = JSON . parse ( readFileSync ( join ( appDir , 'current' , 'package.json' ), 'utf8' )). version ; } catch ( _ ) {} } // 도입 모듈은 REVISION.json 의 Suite 버전(상류 package.json 버전은 목표와 다르다 — 실제 환경 결함 수정) const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
2026-10-26 11:58:15 +00:00
async function heartbeat () { try { if ( ! lastFacts || Date . now () - lastFacts . at > 10 * 60000 ) lastFacts = { at : Date . now (), f : facts ( sys ) }; const sites = existsSync ( cfg . roots . sites ) ? readdirSync ( cfg . roots . sites ). filter (( x ) => existsSync ( join ( cfg . roots . sites , x , 'current' ))). length : 0 ; await client . request ( 'POST' , '/agent/v1/heartbeat' , { version : VERSION , metrics : sample ( cfg . roots ), modules : await moduleStatus ( lastFacts . f ), clockSkewMs : client . clockSkewMs , facts : lastFacts . f , sitesHosted : sites , jobs : { running } }); } catch ( e ) { log ( `[agent] heartbeat 실패: ${ e . message } ` ); } }
async function execute ( job ) { running ++ ; try { const out = await runner . execute ( job ); if ( out ? . result ? . restartAfterReport ) restartUnit = out . result . restartAfterReport ; } finally { running -- ; } if ( restartUnit ) { const u = restartUnit ; restartUnit = null ; log ( `[agent] 자기 갱신 — ${ u } 재시작` ); setTimeout (() => sys . systemctl ( 'restart' , u , { optional : true }), 1500 ); } }
// 재시작 뒤: 보고 못 한 결과 전송, 미완료 작업 재개
await runner . flushPending (); try { const u = await client . request ( 'GET' , '/agent/v1/jobs/unfinished' ); for ( const j of u . jobs || []) { log ( `[agent] 미완료 작업 재개 ${ j . id } ${ j . kind } ` ); execute ( j ); } } catch ( e ) { log ( `[agent] 미완료 작업 조회 실패: ${ e . message } ` ); }
await heartbeat (); const hb = setInterval ( heartbeat , cfg . heartbeatMs );
process . on ( 'SIGTERM' , () => { stopping = true ; clearInterval ( hb ); });
log ( `[agent] Ariweb Node Agent v ${ VERSION } ${ cfg . id } ( ${ cfg . kind } ) → ${ cfg . halUrl } ` );
while ( ! stopping ) { try { const r = await client . request ( 'GET' , '/agent/v1/jobs/next?wait=25' , undefined , { timeoutMs : 40000 }); if ( r . job ) execute ( r . job ); } catch ( e ) { log ( `[agent] 작업 조회 실패: ${ e . message } ` ); await new Promise (( s ) => setTimeout ( s , 5000 )); } }
process . exit ( 0 );
} else { console . error ( `알 수 없는 명령: ${ cmd } ` ); process . exit ( 1 ); }