* 1) 포털이 요청하는 작업(docs/HAL-JOBS.md): site.*, domain.*, backup.* — 대상 서버(또는 외부 Agent)에 Agent 작업을 배정하고 결과를 기다린 뒤 돌려준다.
* 복합 작업(backup.create 의 원격 저장, site.relocate, backup.restore)은 여러 Agent 작업을 단계로 잇는다. 모든 단계는 ctx.step 으로 기록되어 HAL 재시작 뒤 이어서 실행된다.
* 2) 운영자 작업(§32·§29): module.deploy / module.rollback / module.health / module.reconcile — 대상 서버마다 자식 작업을 만들고 대상별 결과를 요약한다. 일부 실패는 'partial' 이며 성공으로 보이지 않는다.
if(!target)thrownewJobError('target_required','대상 서버 또는 Agent 가 필요합니다');
if(String(target).startsWith('ag_')){consta=core.getAgent(d,target);if(!a||a.status!=='active')thrownewJobError('agent_unavailable','Agent 가 없거나 해지되었습니다');returna.id;}
// ---------------- 백업(§19·§20). 저장 서버가 운영 서버와 다르면 HAL 전송을 거쳐 옮긴다.
engine.register('backup.create',{title:'백업 만들기',steps:[{id:'create',title:'운영 서버에서 백업 생성'},{id:'store',title:'백업 저장소로 복사'},{id:'verify',title:'읽을 수 있는지 확인'}],
engine.register('backup.restore',{title:'사이트 복원',steps:[{id:'fetch',title:'백업 저장소에서 가져오기'},{id:'protect',title:'현재 상태 보호'},{id:'isolated',title:'격리된 환경에서 백업 확인'},{id:'restore',title:'데이터 복원'},{id:'external',title:'외부 접속 확인'}],
if(p.externalCheckUrl&&!ext.ok)thrownewJobError('restore_external_check_failed','복원 뒤 외부 접속 확인에 실패했습니다 — 보호 시점으로 되돌릴 수 있습니다',{detail:ext});
return{...r,externalCheck:ext};
},
asyncrevert(job,ctx){consts=job.subject;constr=job.steps.find((x)=>x.id==='restore')?.result;if(!r?.protectPointLocation)thrownewJobError('no_protect_point','보호 시점이 없어 되돌릴 수 없습니다');awaitctx.step('revert',async()=>{constid=core.dispatchAgentJob(d,{halJobId:job.id,agentId:agentFor(d,s.target),kind:'backup.restore',payload:{siteId:s.payload.siteId,location:r.protectPointLocation,protectPoint:false,isolatedVerify:true},dedupeKey:`${job.id}:revert`});returncore.awaitAgentJob(d,ctx,id);},'보호 시점으로 되돌리기');}});
// ---------------- 서버 이전(§21). 두 서버에 동시에 운영 상태가 되지 않도록 verify_new 뒤에만 switch 한다.
engine.register('site.relocate',{title:'서버 이전',steps:['precheck','prepare','precopy','verify_copy','final_sync','verify_new','switch','external_check','cleanup'].map((id)=>({id,title:{precheck:'조건 확인',prepare:'새 위치 준비',precopy:'데이터 사전 복사',verify_copy:'복사 결과 확인',final_sync:'최종 변경분 반영',verify_new:'새 위치 확인',switch:'연결 전환',external_check:'외부 접속 확인',cleanup:'이전 위치 정리'}[id]})),
awaitctx.step('precheck',()=>{constt=core.getServer(d,p.toServerId);constf=core.getServer(d,p.fromServerId);if(!t.accepts_new||t.status!=='ready')thrownewJobError('target_not_ready',`도착 서버 ${t.name} 이(가) 신규 배치를 받을 수 없습니다`);if(f.status==='offline')thrownewJobError('source_offline','출발 서버가 응답하지 않습니다');constconflict=d.store.get('SELECT id FROM jobs WHERE subject_id=? AND id<>? AND kind IN (\'site.deploy\',\'backup.restore\',\'site.relocate\') AND status IN (\'queued\',\'running\',\'awaiting_external\',\'needs_user\')',p.siteId,job.id);if(conflict)thrownewJobError('conflict',`같은 사이트의 다른 작업(${conflict.id})이 진행 중입니다`);return{from:f.name,to:t.name};});
if(p.externalCheckUrl&&!ext.ok)thrownewJobError('relocate_external_check_failed','전환 뒤 외부 접속 확인에 실패했습니다 — 되돌리기를 검토하세요',{detail:ext});
constcl=awaitctx.step('cleanup',()=>({oldRetainedUntil:newDate(Date.now()+(p.retainDays||7)*86400000).toISOString(),note:'이전 위치 사본은 보존 기간 뒤 삭제 작업(site.remove)으로 정리'}));
awaitctx.step('revert_switch',async()=>{awaitcore.awaitAgentJob(d,ctx,core.dispatchAgentJob(d,{halJobId:job.id,agentId:agentFor(d,p.fromServerId),kind:'site.activate',payload:{siteId:p.siteId,domains:p.domains||[],primaryDomain:p.primaryDomain,runtime:p.runtime||'static',reactivate:true},dedupeKey:`${job.id}:revert-act`}));awaitcore.awaitAgentJob(d,ctx,core.dispatchAgentJob(d,{halJobId:job.id,agentId:agentFor(d,p.toServerId),kind:'site.deactivate',payload:{siteId:p.siteId,retainDays:1},dedupeKey:`${job.id}:revert-deact`}));},'이전 위치로 연결 되돌리기');}});
if(module==='hal')thrownewJobError('hal_not_deployable','HAL 은 릴리스 배포 대상이 아닙니다 — HAL 은 Suite 를 설치하기 전에 먼저 설치하는 도구이며 HAL 서버에서 다시 배포하지 않습니다(별도 절차로 갱신)');// [0.3.17]
if(ARTIFACT_MODULES.includes(module)&&!core.artifactPath(d,version,module))thrownewJobError('artifact_missing',`릴리스 ${version} 의 ${module} artifact 가 없습니다`);
consthealth=core.selfHealth(d);if(!health.ok)thrownewJobError('hal_unhealthy','HAL 자체 건강 확인에 실패해 배포하지 않습니다');
// [0.3.17] HAL 먼저 규칙: HAL 은 별도 설치되므로 "같은 버전" 이 아니라 "릴리스보다 오래되지 않음" 을 요구한다(HAL 0.3.17 이 0.3.10 모듈을 배포하는 것은 허용, HAL 0.3.9 가 0.3.10 을 배포하는 것은 거부)
if(!core.versionGte(health.bootstrap.halRunningVersion,version))thrownewJobError('hal_first',`HAL(현재 ${health.bootstrap.halRunningVersion||'확인 전'})이 릴리스 ${version} 보다 오래되었습니다 — HAL 을 먼저 갱신하세요`);
engine.register('module.deploy.target',{title:'모듈 배포(대상)',steps:[{id:'dispatch',title:'Agent 에 설치 지시'},{id:'await',title:'설치·재시작'},{id:'verify',title:'실제 버전·건강 확인'}],
d.store.run('INSERT INTO module_targets(module, server_id, target_version, last_job_id, updated_at) VALUES(?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET target_version=excluded.target_version, last_job_id=excluded.last_job_id, updated_at=excluded.updated_at',module,serverId,version,job.id,nowIso());
if(desc.layout==='agent-feature'){// [0.3.17] Agent 안의 기능(backup·hawkeye): 설치할 artifact 가 없다 — Agent 에 기능 확인(module.health)을 시켜 켜져 있으면 그 릴리스 버전으로 기록한다
constdispF=awaitcore.ensureAgentJob(d,ctx,job,{agentId:agentFor(d,serverId),kind:'module.health',title:'Agent 기능 확인',payload:{module,descriptor:desc,unit:null}});
returnctx.step('verify',()=>{constok=rf&&rf.health?.ok!==false;d.store.run('UPDATE module_targets SET actual_version=?, health=?, active=1, last_check_at=?, last_error=?, updated_at=? WHERE module=? AND server_id=?',ok?version:null,ok?'ok':'down',nowIso(),ok?null:`Agent 기능 확인 실패`,nowIso(),module,serverId);if(!ok)thrownewJobError('feature_unhealthy',`${srv.name}: Agent 기능 ${module} 확인에 실패했습니다`,{detail:rf});return{actualVersion:version,health:rf.health||{ok:true},feature:true};});
constdisp=awaitcore.ensureAgentJob(d,ctx,job,{agentId:agentFor(d,serverId),kind:action==='rollback'?'module.rollback':'module.install',title:'Agent 에 설치 지시',payload:{module,version,action,artifactUrl:art?`/agent/v1/releases/${version}/artifact?module=${module}`:null,sha256:art?.sha256,size:art?.size,descriptor:{...desc,unit:unitName},unitTemplate:unitTemplate(desc.unitTemplate),appDir:(facts.appDirs&&facts.appDirs[module])||(desc.baseDir||`/srv/ariweb/${module}`),toVersion:job.subject.toVersion||null}});
d.store.run('UPDATE module_targets SET actual_version=?, health=?, active=?, last_check_at=?, last_error=?, updated_at=? WHERE module=? AND server_id=?',r.actualVersion||null,r.health?.ok===false?'down':'ok',r.active?1:0,nowIso(),ok?null:`기대 ${expected} 실제 ${r.actualVersion}`,nowIso(),module,serverId);
if(!ok)thrownewJobError('version_mismatch',`${srv.name}: 설치 뒤 실제 버전(${r.actualVersion})·건강(${r.health?.ok})이 목표와 다릅니다`,{detail:r});
if(module==='hal'){d.store.run('UPDATE releases SET hal_deployed_at=? WHERE version=?',nowIso(),version);core.setBootstrap(d,{hal_running_version:expected,release_version:expected});}
/** 부모: 대상 서버마다 자식 작업 → 대상별 결과 요약. 일부 실패는 실패로 끝낸다. */
asyncfunctionfanout(job,ctx,{kind,mk,serverIds}){
constids=awaitctx.step('fanout',()=>serverIds.map((serverId)=>engine.enqueue({kind,subject:mk(serverId),parentId:job.id,groupId:null,subjectType:'server',subjectId:serverId,idempotencyKey:`${job.id}:${serverId}`,createdBy:job.created_by,actorType:job.actor_type,label:`${job.label} — ${core.getServer(d,serverId)?.name||serverId}`}).job.id),'대상별 작업 만들기');
constt0=Date.now();while(true){if(Date.now()-t0>(d.config.fanoutTimeoutMs||12*3600*1000))thrownewJobError('fanout_timeout','대상별 작업이 제한 시간 안에 끝나지 않았습니다');constsum=engine.childrenSummary(job.id);ctx.progress(Math.round(100*(sum.byStatus.done||0)/Math.max(1,sum.total)));if(['done','partial','failed'].includes(sum.overall)&&!sum.children.some((c)=>['queued','running','reverting'].includes(c.status))){if(sum.overall!=='done')thrownewJobError(sum.overall==='partial'?'partial_failure':'all_failed',`${sum.total}대 중 ${sum.children.filter((c)=>c.status!=='done').length}대 실패·대기 — 대상별 결과를 확인하세요`,{detail:sum});returnsum;}ctx.checkCancel();awaitnewPromise((r)=>setTimeout(r,d.config.agentPollMs||1000));}
}
engine.register('module.deploy',{title:'모듈 배포',steps:[{id:'precheck',title:'릴리스·HAL·순서 확인'},{id:'fanout',title:'대상별 작업 만들기'},{id:'collect',title:'대상별 결과 확인'}],
d.store.run('INSERT INTO modules(name, target_version, updated_at) VALUES(?,?,?) ON CONFLICT(name) DO UPDATE SET target_version=excluded.target_version, updated_at=excluded.updated_at',module,version,nowIso());
engine.register('module.rollback',{title:'모듈 되돌리기',steps:[{id:'fanout',title:'대상별 작업 만들기'},{id:'collect',title:'대상별 결과 확인'}],
asyncrun(job,ctx){const{module,toVersion,serverIds}=job.subject;constsum=awaitctx.step('collect',()=>fanout(job,ctx,{kind:'module.deploy.target',serverIds,mk:(serverId)=>({module,version:toVersion||core.getRelease(d,d.store.get('SELECT target_version FROM module_targets WHERE module=? AND server_id=?',module,serverId)?.target_version)?.version,serverId,action:'rollback',toVersion})}));audit(d.store,{actorType:job.actor_type,actorId:job.created_by,action:'deploy.module_rollback',subjectType:'module',subjectId:module,detail:{toVersion,serverIds,overall:sum.overall}});returnsum;}});
single(engine,d,'module.health.target',{title:'모듈 건강 확인(대상)',agentKind:'module.health',target:(s)=>s.serverId,build:(s)=>({module:s.module,descriptor:descriptor(s.module),unit:(core.getServer(d,s.serverId)?.facts?.units||{})[s.module]||descriptor(s.module).unit}),
after(job,ctx,r){d.store.run('UPDATE module_targets SET actual_version=COALESCE(?, actual_version), health=?, active=?, last_check_at=?, last_error=?, updated_at=? WHERE module=? AND server_id=?',r.actualVersion||null,r.health?.ok?'ok':'down',r.active?1:0,nowIso(),r.health?.ok?null:(r.health?.error||'건강 확인 실패'),nowIso(),job.subject.module,job.subject.serverId);returnr;}});
engine.register('module.health',{title:'모듈 건강 확인',steps:[{id:'fanout',title:'대상별 작업 만들기'},{id:'collect',title:'대상별 결과 확인'}],asyncrun(job,ctx){const{module,serverIds}=job.subject;returnctx.step('collect',()=>fanout(job,ctx,{kind:'module.health.target',serverIds:serverIds||d.store.all('SELECT server_id FROM module_targets WHERE module=?',module).map((r)=>r.server_id),mk:(serverId)=>({module,serverId})}));}});
/** 다시 맞추기(§29·§34): 목표 버전과 실제 버전이 다른 대상만 다시 설치 */
engine.register('module.reconcile',{title:'목표·실제 상태 맞추기',steps:[{id:'diff',title:'차이 확인'},{id:'fanout',title:'대상별 작업 만들기'},{id:'collect',title:'대상별 결과 확인'}],
asyncrun(job,ctx){constdiff=awaitctx.step('diff',()=>d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'+(job.subject.module?' AND module=?':''),...(job.subject.module?[job.subject.module]:[])));if(!diff.length)return{drift:[],overall:'done'};constsum=awaitctx.step('collect',()=>fanout(job,ctx,{kind:'module.deploy.target',serverIds:diff.map((x)=>x.server_id),mk:(serverId)=>{constx=diff.find((y)=>y.server_id===serverId);return{module:x.module,version:x.target_version,serverId,action:'install'};}}));return{drift:diff,...sum};}});
// ---------------- [0.3.17] 역할별 통합 배포(server.provision): 프로필의 모듈을 순서대로 같은 서버에 배포하고 서버 역할·프로필·슬롯을 기록한다
asyncfunctionwaitChild(ctx,id,maxMs=d.config.fanoutTimeoutMs||12*3600*1000){constt0=Date.now();while(true){constj=engine.get(id);if(!j)thrownewJobError('child_missing','자식 작업이 없습니다');if(['done','failed','cancelled','reverted'].includes(j.status))returnj;if(Date.now()-t0>maxMs)thrownewJobError('child_timeout','자식 작업이 제한 시간 안에 끝나지 않았습니다');ctx.checkCancel();awaitnewPromise((r)=>setTimeout(r,d.config.agentPollMs||1000));}}
/** 포털이 다른 서버에 이미 설치되어 있으면 그 서버 id(싱글턴 검사) */
functionportalOwner(exceptServerId=null){constr=d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1'+(exceptServerId?' AND server_id<>?':'')+' ORDER BY updated_at DESC LIMIT 1',...(exceptServerId?[exceptServerId]:[]));returnr?r.server_id:null;}
engine.register('settings.apply.target',{title:'설정 적용(대상)',steps:[{id:'dispatch',title:'Agent 에 설정 적용 지시'},{id:'await',title:'설정 쓰기·재시작'},{id:'verify',title:'적용 hash·건강 확인'}],
asyncrun(job,ctx){
const{module,serverId,restart}=job.subject;constcat=catalogFor(module);if(!cat)thrownewJobError('unknown_module',`관리 설정이 없는 모듈: ${module}`);
constdisp=awaitcore.ensureAgentJob(d,ctx,job,{agentId:agentFor(d,serverId),kind:'module.configure',title:'Agent 에 설정 적용 지시',payload:{module,unit,appDir,configDir,settingsUrl:`/agent/v1/settings/${module}`,hash,restart:!!restart,descriptor:desc?{unit,health:desc.health||null}:{unit}}});
constsentHash=core.getAgentJob(d,disp.agentJobId)?.payload?.hash||hash;// 재개 때도 실제로 보낸 payload 의 hash 로 판정
if(!r||r.hash!==sentHash)thrownewJobError('settings_hash_mismatch',`${srv.name}: Agent 가 쓴 설정 hash(${String(r&&r.hash).slice(7,19)})가 보낸 설정(${sentHash.slice(7,19)})과 다릅니다`,{detail:{hash:r&&r.hash}});
if(r.health&&r.health.ok===false)thrownewJobError('health_failed',`${srv.name}: 설정 적용 뒤 건강 확인 실패`,{detail:r.health});
if(r.applied&&r.applied.hash&&r.applied.hash!==sentHash)thrownewJobError('module_apply_failed',`${srv.name}: 모듈이 다른 설정(${String(r.applied.hash).slice(7,19)})을 적용했다고 보고했습니다`,{detail:r.applied});
if(r.applied&&Array.isArray(r.applied.errors)&&r.applied.errors.length)thrownewJobError('module_apply_failed',`${srv.name}: 모듈이 일부 항목을 적용하지 못했습니다(${r.applied.errors.map((e)=>e.key).join(', ')})`,{detail:r.applied});
engine.register('server.provision',{title:'역할별 통합 배포',steps:[{id:'precheck',title:'서버·프로필·릴리스 확인'},{id:'profile',title:'역할·프로필·슬롯 기록'},{id:'deploy',title:'모듈 순서대로 배포'}],
awaitctx.step('precheck',()=>{if(srv.status!=='ready'||!srv.agent_id)thrownewJobError('server_not_ready',`서버 ${srv.name} 이(가) 준비(Agent 연결·승인) 상태가 아닙니다(${srv.status})`);for(constmofprof.modules)precheckModule(m,version);if(prof.singleton&&portalOwner(serverId))thrownewJobError('portal_exists',`포털이 이미 다른 서버에 설치되어 있습니다 — 추가 설치 대신 "포털 이전"을 쓰세요`);return{modules:prof.modules,role:prof.role};});
/** [0.3.17] 포털 이전: 새 서버에 포털 설치 → 운영자가 데이터·DNS 전환을 마치고 재개 → 옛 서버 포털을 비활성으로 */
engine.register('portal.relocate',{title:'포털 이전',steps:[{id:'precheck',title:'현재 포털·대상 서버 확인'},{id:'install',title:'새 서버에 포털 설치'},{id:'handover',title:'데이터·DNS 전환(운영자 확인)'},{id:'switch',title:'옛 포털 비활성'}],
awaitctx.step('precheck',()=>{if(!to||!from)thrownewJobError('server_not_found','서버가 없습니다');if(to.status!=='ready'||!to.agent_id)thrownewJobError('server_not_ready',`대상 서버 ${to.name} 이(가) 준비 상태가 아닙니다`);if(portalOwner()!==fromServerId)thrownewJobError('portal_not_here',`서버 ${from.name} 에 활성 포털이 없습니다`);precheckModule('portal',version);return{from:from.name,to:to.name};});
awaitctx.step('install',async()=>{constchild=engine.enqueue({kind:'module.deploy.target',subject:{module:'portal',version,serverId:toServerId,action:'install'},parentId:job.id,subjectType:'server',subjectId:toServerId,idempotencyKey:`${job.id}:portal`,createdBy:job.created_by,actorType:job.actor_type,label:`${job.label} — 새 서버 설치`}).job;constr=awaitwaitChild(ctx,child.id);if(r.status!=='done')thrownewJobError('install_failed',`새 서버 포털 설치 실패: ${r.error||r.status}`);return{jobId:child.id};});
awaitctx.step('handover',()=>{if(hs&&hs.state==='needs_user')return{confirmedBy:'operator',at:nowIso()};returnctx.needsUser(`새 서버(${to.name})에 포털이 설치되었습니다. 포털 데이터(DB·업로드·Vault 연동 키) 이전과 nginx/DNS(my.ariweb.net) 전환을 마친 뒤 "재개"를 누르세요 — 재개하면 옛 서버(${from.name})의 포털을 비활성으로 표시합니다.`,[{id:'resume',label:'전환 완료 — 재개'}]);});
awaitctx.step('switch',()=>{d.store.run('UPDATE module_targets SET active=0, health=\'unknown\', last_error=?, updated_at=? WHERE module=\'portal\' AND server_id=?',`포털 이전으로 비활성(→ ${to.name})`,nowIso(),fromServerId);d.store.run('UPDATE module_targets SET active=1, updated_at=? WHERE module=\'portal\' AND server_id=?',nowIso(),toServerId);audit(d.store,{actorType:job.actor_type,actorId:job.created_by,action:'deploy.portal.relocate',subjectType:'module',subjectId:'portal',detail:{from:fromServerId,to:toServerId,version}});return{from:fromServerId,to:toServerId};});