functionsave(id,patch){constsets=[];constvals=[];for(const[k,v]ofObject.entries(patch)){sets.push(`${k}=?`);vals.push(v&&typeofv==='object'?toJson(v):v);}sets.push('updated_at=?');vals.push(nowIso());store.run(`UPDATE sites SET ${sets.join(', ')} WHERE id=?`,...vals,id);returnget(id);}
constrows=store.all(`SELECT * FROM sites WHERE group_id=? AND lifecycle<>'deleted' ${states?`AND lifecycle IN (${states.map(()=>'?').join(',')})`:''} ORDER BY created_at DESC`,...(states?[groupId,...states]:[groupId])).map(row);
returnrows.map((s)=>summarize(s));
}
/** 목록 한 줄 요약: 메일·백업 옵션 여부, 마지막 배포, 건강, 사용자 조치 필요 */
functionsummarize(s){
constmail=store.get('SELECT id, status FROM mail_services WHERE site_id=? AND deleted_at IS NULL',s.id)||null;
constbackup=store.get('SELECT id, status FROM backup_options WHERE site_id=?',s.id)||null;
constlastDeploy=dep(store.get('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT 1',s.id));
if(s.lifecycle==='needs_domain')out.push({code:'needs_domain',text:'대표 도메인 연결과 HTTPS 확인이 필요합니다',href:`/sites/${s.id}?tab=domains`});
if(lastDeploy&&lastDeploy.status==='failed')out.push({code:'deploy_failed',text:'마지막 배포가 실패했습니다. 재시도 또는 되돌리기',href:`/sites/${s.id}?tab=deploy`});
if(s.pendingCleanup)out.push({code:'cleanup',text:`정리하지 않은 중간 결과가 있습니다 (예약 반환 예정 ${s.return_due_at||'-'})`,href:`/sites/${s.id}?tab=deploy`});
if(s.deploy_mode==='external'){consta=store.get('SELECT status FROM external_agents WHERE site_id=? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1',s.id);if(!a||a.status!=='connected')out.push({code:'agent',text:'외부 서버 Agent 연결이 필요합니다',href:`/sites/${s.id}/external`});}
if(!s.deploy_mode&&s.lifecycle==='draft')out.push({code:'mode',text:'배포 방식을 선택하세요',href:`/sites/${s.id}/deploy/mode`});
returnout;
}
/**
* 사이트 생성(§7.2). 초안이며 사이트 수를 쓰지 않는다. projectMode: new(Webmaker 프로젝트를 새로 만들어 연결) | existing(내 프로젝트 연결) | none(나중에).
*/
asyncfunctioncreate({user,group,ip=''},input){
constname=String(input.name||'').trim().slice(0,120);if(!name)thrownewHttpError(400,'name_required','사이트 이름을 입력하세요',{field:'name'});
constpid=String(input.projectId||'');if(!/^[a-f0-9]{32}$/.test(pid))thrownewHttpError(400,'project_required','연결할 Webmaker 프로젝트를 고르세요',{field:'projectId'});
constp=awaitwm().getProject(pid).catch((e)=>{thrownewHttpError(e.status===404?404:502,e.code||'webmaker_error',e.status===404?'프로젝트를 찾을 수 없습니다':'Webmaker 에 연결할 수 없어 프로젝트를 확인하지 못했습니다');});
if(p.ownerId!==login)thrownewHttpError(403,'project_not_owned','내 Webmaker 계정의 프로젝트만 연결할 수 있습니다');
if(p.link&&p.link.siteId&&get(p.link.siteId))thrownewHttpError(409,'project_linked',`이 프로젝트는 이미 다른 사이트(${p.link.siteName||p.link.siteId})에 연결되어 있습니다`);
constother=store.get('SELECT id FROM sites WHERE webmaker_project_id=? AND lifecycle<>\'deleted\'',pid);if(other)thrownewHttpError(409,'project_linked','이 프로젝트는 이미 다른 사이트에 연결되어 있습니다');
}catch(e){projectNote=e.code==='service_unreachable'?'Webmaker 에 연결할 수 없어 프로젝트를 만들지 못했습니다. 제작 탭에서 다시 시도하세요':`프로젝트 연결 실패: ${e.message}`;log2(projectNote);}
conststeps=[{id:'build',label:'Webmaker 에서 사이트 제작',href:`/sites/${s.id}?tab=build`,done:!!s.webmaker_project_id&&s.lifecycle!=='draft'},{id:'import',label:'콘텐츠 가져오기',href:`/sites/${s.id}?tab=build`,done:false,unsupported:'이번 버전에서 지원하지 않음'},{id:'domain',label:'도메인 연결',href:`/sites/${s.id}?tab=domains`,done:!!s.primary_domain},{id:'mode',label:'배포 방식 선택',href:`/sites/${s.id}/deploy/mode`,done:!!s.deploy_mode},{id:'perms',label:'구성원 권한 설정',href:`/sites/${s.id}?tab=settings`,done:false}];
returnsteps;
}
/** 설정 변경(§8.2). 서비스 중단이 예상되는 항목(유지보수 모드·검색 공개)은 impact 를 함께 돌려준다 */
functionupdate(site,patch,actor){
if(!LIFECYCLE_MODIFIABLE.has(site.lifecycle))thrownewHttpError(409,'site_not_modifiable',`지금 상태(${site.lifecycle})에서는 설정을 바꿀 수 없습니다`);
constp={};constimpact=[];
if(patch.name!==undefined){constn=String(patch.name).trim().slice(0,120);if(!n)thrownewHttpError(400,'name_required','사이트 이름을 입력하세요',{field:'name'});p.name=n;}
if(patch.maintenance!==undefined){p.maintenance=patch.maintenance?1:0;if(site.lifecycle==='live'&&p.maintenance!==site.maintenance)impact.push(p.maintenance?'유지보수 모드를 켜면 공개 주소에 안내 페이지가 보이고 방문자는 사이트를 볼 수 없습니다(예상 적용 1분 이내)':'유지보수 모드를 끄면 즉시 공개됩니다');}
// 유지보수 모드는 vhost 안내 페이지 전환 작업으로 적용한다(HAL domain.vhost maintenance)
const{job}=jobs.enqueue({kind:'site.vhost',subject:{siteId:site.id,maintenance:!!p.maintenance},idempotencyKey:`site.vhost:${site.id}:maint:${p.maintenance}:${Date.now()/60000|0}`,createdBy:actor?.id,groupId:site.group_id,subjectType:'site',subjectId:site.id,label:p.maintenance?'유지보수 모드 켜기':'유지보수 모드 끄기'});
return{site:get(site.id),impact,jobId:job.id};
}
return{site:get(site.id),impact};
}
/** 생명주기 전환(다른 영역 — 중지·해지·이전 — 이 부른다). 상태별 부수 효과: 공개 중지는 vhost 안내 페이지, 삭제는 프로젝트 연결 해제 */
/** Webmaker 프로젝트 삭제 영향(Webmaker → 포털 /internal/webmaker/project-impact). blocked 면 Webmaker 가 폐기를 거부한다 */
functionimpactForProject(projectId,siteId=null){
consts=siteId?get(siteId):row(store.get('SELECT * FROM sites WHERE webmaker_project_id=? AND lifecycle<>\'deleted\'',projectId));
if(!s)return{blocked:false,liveSite:false,lifecycle:null,deployments:0,domains:[],message:'연결된 운영 사이트가 없습니다'};
constn=store.get('SELECT COUNT(*) AS n FROM deployments WHERE site_id=?',s.id).n;constdomains=store.all('SELECT name FROM domains WHERE site_id=?',s.id).map((d)=>d.name);
return{blocked:live,liveSite:live,lifecycle:s.lifecycle,siteName:s.name,deployments:n,domains,message:live?`사이트 "${s.name}" 이(가) ${s.lifecycle==='paused'?'일시 중지':'운영'} 중이라 프로젝트를 폐기할 수 없습니다. 포털에서 사이트를 먼저 중지·해지하세요. 운영 사이트·배포본·도메인은 프로젝트 폐기로 지워지지 않습니다`:`사이트 "${s.name}"(${s.lifecycle}) 의 제작 프로젝트 연결이 끊깁니다. 포털의 사이트·배포 기록(${n}건)·도메인(${domains.length}개)은 유지됩니다`};
}
/** Webmaker 가 프로젝트를 폐기한 뒤 연결 해제 */
functiononProjectDestroyed(projectId){consts=row(store.get('SELECT * FROM sites WHERE webmaker_project_id=?',projectId));if(s)save(s.id,{webmaker_project_id:null});return!!s;}
// ------------------------------------------------------------------ 배포 전 확인(§11.1)
functionexternalAgentFor(siteId){consta=store.get('SELECT * FROM external_agents WHERE site_id=? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1',siteId);returna?{...a,lastDeploy:parseJson(a.last_deploy_json,null),lastCheck:parseJson(a.last_check_json,null)}:null;}
/**
* 배포 전 확인. items[] 마다 { id, title, state: 'pass'|'block'|'warn'|'info', text, href }. ok 는 block 이 없을 때.
if(!site.webmaker_project_id)add('version','배포할 저장본','block','Webmaker 프로젝트가 연결되어 있지 않습니다',`/sites/${site.id}?tab=build`);
else{try{project=awaitwm().getProject(site.webmaker_project_id);constvlist=project.versionList||[];constv=versionRef&&versionRef!=='current'?vlist.find((x)=>String(x.no)===String(versionRef)):null;if(versionRef&&versionRef!=='current'&&!v)add('version','배포할 저장본','block',`저장본 #${versionRef} 이(가) 없습니다`,`/sites/${site.id}?tab=deploy`);elseif(!project.hasContent&&!v)add('version','배포할 저장본','block','아직 제작된 페이지가 없습니다. Webmaker 에서 홈 페이지를 만들고 저장하세요',`/sites/${site.id}?tab=build`);elseadd('version','배포할 저장본','pass',v?`저장본 #${v.no} (${v.message||v.kind}, ${v.at})`:`현재 저장 상태(rev ${project.rev})`);}catch(e){add('version','배포할 저장본','block',e.code==='service_unreachable'?'Webmaker 상태를 확인할 수 없습니다(정보 확인 지연). 잠시 후 다시 시도하세요':`저장본 확인 실패: ${e.message}`);}}
if(site.site_count_state==='in_use')add('sites','운영 사이트 수','pass','이 사이트는 이미 운영 사이트 수를 사용 중입니다');
elseif(site.site_count_state==='reserved')add('sites','운영 사이트 수','pass','예약됨(배포와 대표 도메인 확인 뒤 사용 중으로 전환)');
elseif(f.unallocated>0)add('sites','운영 사이트 수','pass',`사용 가능 ${f.unallocated}개 (구매 ${f.purchased} · 사용 중 ${f.allocated} · 예약 ${f.reserved}) — 배포 시작 때 1개를 예약합니다`);
elseadd('sites','운영 사이트 수','block',f.purchased===0?'운영 사이트 수를 포함한 상품을 먼저 구매·승인받아야 합니다':`운영 사이트 수가 모두 사용 중입니다(구매 ${f.purchased}). 상품을 추가하거나 다른 사이트의 사이트 수를 반환하세요`,'/billing');
// 3 배포 위치
if(!site.deploy_mode)add('target','배포 위치','block','배포 방식을 먼저 선택하세요',`/sites/${site.id}/deploy/mode`);
elseif(site.deploy_mode==='managed'){try{constpicked=site.server_id?{serverId:site.server_id}:awaithal().pickServer({role:'web',needBytes:bundleBytes});if(picked)add('target','배포 위치','pass',site.server_id?'현재 운영 서버에 배포합니다':'배치 가능한 관리형 서버가 있습니다');elseadd('target','배포 위치','block','지금 신규 배치를 받을 수 있는 서버가 없습니다. 잠시 후 다시 시도하거나 문의하세요');}catch(e){add('target','배포 위치','block','HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정보 확인 지연). 기존 서비스는 유지되며 신규 배포는 복구 후 가능합니다');}}
else{consta=externalAgentFor(site.id);if(a&&a.status==='connected')add('target','배포 위치','pass',`외부 서버 Agent 연결됨 (${a.label||a.hal_agent_id})`);elseadd('target','배포 위치','block',a?`외부 서버 Agent 가 아직 연결되지 않았습니다(${a.status})`:'외부 서버 Agent 를 먼저 연결하세요',`/sites/${site.id}/external`);
if(!site.consent_version||site.consent_version!==EXTERNAL_CONSENT_VERSION)add('consent','외부 배포 확인 사항 동의','block','외부 배포 확인 사항(고객 책임·미제공 기능·건별 비용)에 동의해야 합니다',`/sites/${site.id}/deploy/mode`);elseadd('consent','외부 배포 확인 사항 동의','pass',`동의함 (${site.consent_version})`);}
// 4 디스크·DB 용량(관리형만)
if(site.deploy_mode==='managed'){constalloc=quota.allocationOf(store,site.group_id,'web_disk','site',site.id);constwf=quota.figures(store,site.group_id,'web_disk');if(alloc&&alloc.amount>0)add('capacity','디스크와 데이터베이스 용량','pass',`배정 ${fmtBytes(alloc.amount)} (사용 ${fmtBytes(usageOf(site,'web_disk'))})`);elseif(wf.unallocated>0)add('capacity','디스크와 데이터베이스 용량','warn',`이 사이트에 배정된 웹 디스크가 없습니다. 배포 시작 때 미배정량(${fmtBytes(wf.unallocated)})에서 기본량을 배정합니다`,'/usage');elseadd('capacity','디스크와 데이터베이스 용량','block','배정할 수 있는 웹 디스크가 없습니다. 상품을 추가하거나 다른 사이트의 배정을 줄이세요','/usage');if(site.runtime==='node')add('runtime','동적 사이트 실행 형태','info','Ariweb 가 승인한 Node 실행 형태입니다. 필요한 저장 공간: 사이트 파일 + 업로드. 데이터베이스는 SQLite 파일만 지원(외부 DB·cron·메일 발송은 이번 버전에서 지원하지 않음)');elseadd('runtime','실행 형태','info','정적 사이트(HTML·CSS·JS·이미지). 서버 측 코드는 실행되지 않습니다');}
// 5 도메인 상태
constpd=site.primary_domain?store.get('SELECT * FROM domains WHERE name=? AND site_id=?',site.primary_domain,site.id):null;
if(pd&&pd.dns_state==='ok'&&pd.cert_state==='valid')add('domain','도메인 상태','pass',`대표 도메인 ${pd.name} 연결·HTTPS 정상`);elseif(pd)add('domain','도메인 상태','warn',`대표 도메인 ${pd.name} 의 DNS/HTTPS 가 아직 완료되지 않았습니다(DNS ${pd.dns_state}, 인증서 ${pd.cert_state}). 배포는 진행되며 완료 상태는 "도메인 설정 필요" 가 됩니다`,`/domains/${pd.id}`);elseadd('domain','도메인 상태','warn','대표 도메인이 없습니다. 배포 후 "도메인 설정 필요" 상태가 되며 플랫폼 확인 주소에서만 볼 수 있습니다',`/sites/${site.id}?tab=domains`);
// 6 필수 설정
if(['cancel_scheduled','readonly_retention','pending_delete','deleted','delete_hold'].includes(site.lifecycle))add('lifecycle','사이트 상태','block',`해지·보존 중인 사이트(${site.lifecycle})에는 배포할 수 없습니다`,`/sites/${site.id}/lifecycle`);
if(act.length)add('conflict','다른 작업과의 충돌','block',`진행 중인 작업이 있습니다: ${act.map((j)=>`${j.label}(${j.status})`).join(', ')}`,`/sites/${site.id}?tab=deploy`);elseadd('conflict','다른 작업과의 충돌','pass','진행 중인 배포·복원·이전 작업이 없습니다');
constexisting=store.get('SELECT * FROM jobs WHERE idempotency_key=?',idem)||activeJobsFor(site.id).find((j)=>j.kind==='site.deploy'&&String(j.subject.versionRef)===String(versionRef));
if(existing){constd=dep(store.get('SELECT * FROM deployments WHERE job_id=?',existing.id));return{deployment:d,job:describeJob(jobs.get(existing.id)),created:false};}
constpc=awaitprecheck(site,{versionRef});
if(!pc.ok)thrownewHttpError(409,'precheck_failed','배포 전 확인에서 차단된 항목이 있습니다',{items:pc.items});
if(state==='none'){constr=quota.reserve(store,{groupId:group.id,resource:'sites',amount:1,subjectType:'site',subjectId:site.id,reason:'최초 공개 배포',ttlMs:0});resId=r.id;state='reserved';}
return{deployment:dep(store.get('SELECT * FROM deployments WHERE id=?',did)),job:describeJob(job),created:true};
}
functiondefaultDiskBytes(groupId){consts=store.get('SELECT value_json FROM settings WHERE key=\'sites.defaultDiskBytes\'');returns?Number(parseJson(s.value_json,0))||1024**3:1024**3;}
/** 다운로드 토큰 만들기(Agent 가 저장본을 받아 갈 서명 URL). 1시간 유효 */
functionbundleByToken(token){constt=store.get('SELECT * FROM bundle_tokens WHERE token_hash=?',sha256(String(token||'')));if(!t||Date.parse(t.expires_at)<Date.now())returnnull;store.run('UPDATE bundle_tokens SET used_at=? WHERE id=?',nowIso(),t.id);returnt;}
/**
* 단계에 묶인 HAL 작업 보장. 단계 결과(step.result.halJobId)에 HAL 작업 id 를 기억해 재진입(폴링 재개) 때 같은 작업을 보고,
* 그 HAL 작업이 실패·취소로 끝났는데 다시 실행되는 경우(사용자 재시도)에만 새 멱등 키로 새 HAL 작업을 만든다.
if(prev&&!fresh){lethj;try{hj=awaithal().getJob(prev);}catch(e){if(e.code==='service_unreachable')ctx.retryLater(15000,'HAL(중앙 관리) 연결 대기 — 정보 확인 지연');throwe;}returnprev;}// [E2E 결함 수정] 실패·취소된 HAL 작업도 그대로 돌려줘 awaitHal 이 실패를 드러낸다(새 작업은 fresh 일 때만)
constdomains=store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'',siteId).map((d)=>d.name);
// 배포 위치 준비 = 서버(또는 외부 Agent) 결정 + HAL 작업 등록. HAL 작업 멱등 키는 포털 작업 id 라 재진입해도 하나만 만들어진다
if(site.deploy_mode==='managed'){letserverId=site.server_id;if(!serverId){constp=awaithal().pickServer({role:'web',needBytes:bundle.bytes*3});if(!p)thrownewJobError('no_server','지금 신규 배치를 받을 수 있는 서버가 없습니다',{retryable:true});serverId=p.serverId;save(siteId,{server_id:serverId});}return{kind:'server',id:serverId};}
consta=externalAgentFor(siteId);if(!a||a.status!=='connected')thrownewJobError('agent_not_connected','외부 서버 Agent 가 연결되어 있지 않습니다');return{kind:'external',id:a.hal_agent_id};
});
constretrying=(job.steps||[]).some((x)=>x.state==='failed');// 이전 실행이 실패한 뒤의 재시도면 새 HAL 작업
consthalJobId=awaithalJobFor(ctx,job,'prepare_target',async(key)=>{constr=awaithal().createJob({kind:'site.deploy',target:target.id,idempotencyKey:key,groupId:site.group_id,subjectType:'site',subjectId:siteId,payload:{siteId,versionRef:bundle.version==='current'?`rev-${bundle.commit||nowIso()}`:`v${bundle.version}`,bundleUrl:bundle.url,bundleSha256:null,runtime:site.runtime,env:{},domains,primaryDomain:site.primary_domain,previousVersion:site.current_version,external:site.deploy_mode==='external',maintenance:!!site.maintenance}});store.run('UPDATE deployments SET hal_job_id=?, target_id=?, updated_at=? WHERE id=?',r.halJobId,target.id,nowIso(),deploymentId);returnr;},{fresh:retrying});
constverify=awaitctx.step('domain_https',async()=>{constpd=site.primary_domain?store.get('SELECT * FROM domains WHERE name=? AND site_id=?',site.primary_domain,siteId):null;constext=res.externalCheck&&res.externalCheck.ok;constpublicOk=!!(pd&&pd.dns_state==='ok'&&pd.cert_state==='valid'&&ext);return{state:publicOk?DEPLOY_VERIFY.PUBLIC:DEPLOY_VERIFY.NEEDS_DOMAIN,primaryDomain:pd?pd.name:null,dns:pd?pd.dns_state:null,cert:pd?pd.cert_state:null,externalOk:!!ext,detail:publicOk?`${pd.name} 에서 외부 확인 완료`:pd?`대표 도메인 ${pd.name} 의 DNS(${pd.dns_state})/HTTPS(${pd.cert_state}) 가 아직 완료되지 않았습니다`:'대표 도메인이 없어 플랫폼 확인 주소에서만 볼 수 있습니다'};});
asyncrevert(job,ctx){const{siteId,deploymentId}=job.subject;constsite=get(siteId);constd=dep(store.get('SELECT * FROM deployments WHERE id=?',deploymentId));if(!d||!d.previous_version)thrownewJobError('no_previous','되돌릴 이전 버전이 없습니다');awaitctx.step('rollback',async()=>{constid=awaithalJobFor(ctx,job,'rollback',(key)=>hal().createJob({kind:'site.rollback',target:d.target_id,idempotencyKey:key,payload:{siteId,toVersion:d.previous_version}}),{fresh:(job.steps||[]).some((x)=>x.id==='rollback'&&x.state==='failed')});consthj=awaitawaitHal(ctx,id);save(siteId,{current_version:d.previous_version});store.run('UPDATE deployments SET status=\'reverted\', updated_at=? WHERE id=?',nowIso(),deploymentId);returnhj.result;});}});
/** 배포 완료 처리: deployments·sites 갱신, 사이트 수 확정(public 일 때), 알림·감사 */
store.run('UPDATE deployments SET status=\'done\', verify_state=?, result_json=?, updated_at=?, ended_at=? WHERE id=?',verify.state,toJson({...res,verify}),now,now,deploymentId);
notify(store,{groupId:site.group_id,category:'site',severity:'info',incidentKey:`deploy:${site.id}:${deploymentId}`,title:`사이트 "${site.name}" 배포 ${verify.state==='public'?'완료 — 공개 운영 중':'검증 완료 — 도메인 설정 필요'}`,body:verify.detail,link:`/sites/${site.id}?tab=deploy`,subjectType:'site',subjectId:site.id,actionRequired:verify.state==='public'?null:'대표 도메인 연결과 HTTPS 확인'});
constd=store.get('SELECT status FROM deployments WHERE id=?',deploymentId);if(!d||['failed','done','cancelled'].includes(d.status))return;
constnow=nowIso();store.run('UPDATE deployments SET status=?, result_json=?, updated_at=?, ended_at=? WHERE id=?',j.status,toJson({error:j.error,errorCode:j.error_code,failedStep:(j.steps||[]).find((s)=>s.state==='failed')?.id||null}),now,now,deploymentId);
// 최초 배포가 실패하면 예약은 남긴 채 '반환 예정 시각' 을 두고, 정리하지 않은 중간 결과를 표시한다(§11.2)
if(site.site_count_state==='reserved'){patch.return_due_at=newDate(Date.now()+RETURN_GRACE_MS).toISOString();patch.pending_cleanup_json={deploymentId,failedAt:now,leftovers:['배포 위치의 중간 파일(있을 수 있음)'],message:'실패한 배포의 중간 결과가 남아 있을 수 있습니다. "지금 정리하고 반환" 을 누르면 정리 작업을 실행하고 사이트 수 예약을 반환합니다'};}
save(siteId,patch);
notify(store,{groupId:site.group_id,category:'site',severity:'warning',incidentKey:`deploy:${siteId}:${deploymentId}`,title:`사이트 "${site.name}" 배포 ${j.status==='failed'?'실패':'취소'}`,body:`${j.error||''} (문의 번호 ${j.id}). 기존 버전${site.current_version?'은 유지됩니다':'이 없어 공개되지 않았습니다'}`,link:`/sites/${siteId}?tab=deploy`,subjectType:'site',subjectId:siteId,actionRequired:'재시도 또는 되돌리기'});
if(activeJobsFor(site.id).some((j)=>j.kind==='site.deploy'))thrownewHttpError(409,'deploy_running','배포가 진행 중입니다. 먼저 취소하세요');
const{job}=jobs.enqueue({kind:'site.cleanup',subject:{siteId:site.id,reservationId:site.site_count_reservation_id,targetId:site.server_id||(externalAgentFor(site.id)||{}).hal_agent_id||null},idempotencyKey:`site.cleanup:${site.id}:${site.site_count_reservation_id}`,createdBy:actor?.id,groupId:site.group_id,subjectType:'site',subjectId:site.id,label:'중간 결과 정리와 사이트 수 반환'});
returndescribeJob(job);
}
jobs.register('site.cleanup',{title:'중간 결과 정리',steps:[{id:'remove',title:'배포 위치의 중간 결과 제거'},{id:'return',title:'사이트 수 예약 반환'}],maxAttempts:3,retryable:(e)=>e&&e.code==='service_unreachable',
constleftovers=awaitctx.step('remove',async()=>{if(!targetId)return{skipped:'배포 위치 없음'};constid=awaithalJobFor(ctx,job,'remove',(key)=>hal().createJob({kind:'site.remove',target:targetId,idempotencyKey:key,payload:{siteId,keepData:false}}),{fresh:(job.steps||[]).some((x)=>x.id==='remove'&&x.state==='failed')});consthj=awaitawaitHal(ctx,id);returnhj.result;});
jobs.register('site.vhost',{title:'vhost 적용',steps:[{id:'vhost',title:'웹서버 설정 적용'}],maxAttempts:3,retryable:(e)=>e&&e.code==='service_unreachable',
asyncrun(job,ctx){const{siteId,maintenance}=job.subject;constsite=get(siteId);constdomains=store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'',siteId).map((d)=>d.name);returnctx.step('vhost',async()=>{constid=awaithalJobFor(ctx,job,'vhost',(key)=>hal().createJob({kind:'domain.vhost',target:site.server_id,idempotencyKey:key,payload:{siteId,domains,primaryDomain:site.primary_domain,redirectOld:false,maintenance:!!maintenance}}),{fresh:(job.steps||[]).some((x)=>x.id==='vhost'&&x.state==='failed')});return(awaitawaitHal(ctx,id)).result;});}});
/** 되돌리기(§11.3): 이전 배포 버전으로. 데이터 영향 안내는 화면이 보여 주고 확인을 받는다 */
functionrollback(site,actor){
constlast=dep(store.get('SELECT * FROM deployments WHERE site_id=? AND status=\'done\' ORDER BY created_at DESC LIMIT 1',site.id));
if(!last||!last.previous_version)thrownewHttpError(409,'no_previous','되돌릴 이전 버전이 없습니다');
if(activeJobsFor(site.id).some((j)=>['site.deploy','site.rollback'].includes(j.kind)))thrownewHttpError(409,'job_running','배포 작업이 진행 중입니다');
jobs.register('site.rollback',{title:'이전 버전으로 되돌리기',steps:[{id:'rollback',title:'이전 버전 전환'},{id:'external_check',title:'외부 접속 확인'}],maxAttempts:2,retryable:(e)=>e&&e.code==='service_unreachable',
constchk=awaitctx.step('external_check',async()=>{consturls=site.primary_domain?[`https://${site.primary_domain}/`]:[];if(!urls.length)return{skipped:'대표 도메인 없음'};constid=awaithalJobFor(ctx,job,'external_check',(key)=>hal().createJob({kind:'site.check',target:targetId,idempotencyKey:key,payload:{siteId,urls}}),{fresh:(job.steps||[]).some((x)=>x.id==='external_check'&&x.state==='failed')});return(awaitawaitHal(ctx,id)).result;});
notify(store,{groupId:site.group_id,category:'site',severity:'info',incidentKey:`rollback:${siteId}:${job.id}`,title:`사이트 "${site.name}" 를 이전 버전(${toVersion})으로 되돌렸습니다`,link:`/sites/${siteId}?tab=deploy`,subjectType:'site',subjectId:siteId});
return{version:r1.version||toVersion,check:chk};
}});
/** 대표 도메인 확인 완료 훅(domains 서비스가 부른다): 검증 완료 배포가 있으면 공개 운영 중으로 전환하고 사이트 수를 확정 */
if(s.current_version&&s.deploy_verify!==DEPLOY_VERIFY.PUBLIC){patch.deploy_verify=DEPLOY_VERIFY.PUBLIC;if(['needs_domain','deploying'].includes(s.lifecycle))patch.lifecycle='live';if(s.site_count_state==='reserved'&&s.site_count_reservation_id){quota.commit(store,s.site_count_reservation_id);patch.site_count_state='in_use';}if(s.current_deployment_id)store.run('UPDATE deployments SET verify_state=?, updated_at=? WHERE id=?',DEPLOY_VERIFY.PUBLIC,nowIso(),s.current_deployment_id);notify(store,{groupId:s.group_id,category:'site',severity:'info',incidentKey:`public:${siteId}`,title:`사이트 "${s.name}" 공개 운영 중 — ${domainName}`,link:`/sites/${siteId}`,subjectType:'site',subjectId:siteId});}
store.run('INSERT INTO external_agents(id, site_id, group_id, hal_agent_id, label, status, token_expires_at, install_command, created_at, updated_at) VALUES(?,?,?,?,?,\'pending\',?,?,?,?)',id,site.id,site.group_id,r.agentId||`pending:${id}`/* [E2E 결함 수정] HAL 은 Agent 가 실제 등록될 때 id 를 만든다 — 그 전에는 자리표시자 */,label||null,r.expiresAt,r.installCommand,now,now);
asyncfunctionrefreshExternalAgent(site){consta=externalAgentFor(site.id);if(!a)returnnull;try{lets;if(String(a.hal_agent_id).startsWith('pending:')){s=hal().getExternalAgentBySite?awaithal().getExternalAgentBySite(site.id):null;if(!s)return{...a,stale:false};store.run('UPDATE external_agents SET hal_agent_id=? WHERE id=?',s.id,a.id);a.hal_agent_id=s.id;}elses=awaithal().getExternalAgent(a.hal_agent_id);store.run('UPDATE external_agents SET status=?, last_seen_at=?, agent_version=?, last_deploy_json=COALESCE(?, last_deploy_json), updated_at=? WHERE id=?',s.status,s.lastSeenAt||a.last_seen_at,s.version||a.agent_version,s.lastDeploy?toJson(s.lastDeploy):null,nowIso(),a.id);return{...externalAgentFor(site.id),stale:false};}catch(e){return{...a,stale:true,staleReason:'HAL 에 연결할 수 없습니다(정보 확인 지연)'};}}
asyncfunctionrevokeExternalAgent(site,user){consta=externalAgentFor(site.id);if(!a)thrownewHttpError(404,'not_found','외부 서버 Agent 가 없습니다');try{if(!String(a.hal_agent_id).startsWith('pending:'))awaithal().revokeExternalAgent(a.hal_agent_id);}catch(e){if(e.code!=='service_unreachable')throwe;}store.run('UPDATE external_agents SET status=\'revoked\', revoked_at=?, updated_at=? WHERE id=?',nowIso(),nowIso(),a.id);audit(store,{actorType:'user',actorId:user.id,groupId:site.group_id,subjectType:'site',subjectId:site.id,action:'external_agent.revoke',detail:{agentId:a.hal_agent_id}});returntrue;}
functionremoveExternalDeploy(site,user){consta=externalAgentFor(site.id);if(!a)thrownewHttpError(409,'agent_missing','외부 서버 Agent 가 없습니다');const{job}=jobs.enqueue({kind:'site.remove_external',subject:{siteId:site.id,agentId:a.hal_agent_id},idempotencyKey:`site.remove_external:${site.id}:${Date.now()/60000|0}`,createdBy:user.id,groupId:site.group_id,subjectType:'site',subjectId:site.id,label:'외부 서버 배포 제거'});returndescribeJob(job);}
jobs.register('site.remove_external',{title:'외부 서버 배포 제거',steps:[{id:'remove',title:'외부 서버에서 파일·실행 상태 제거'}],maxAttempts:2,retryable:(e)=>e&&e.code==='service_unreachable',asyncrun(job,ctx){const{siteId,agentId}=job.subject;constr=awaitctx.step('remove',async()=>{constid=awaithalJobFor(ctx,job,'remove',(key)=>hal().createJob({kind:'site.remove',target:agentId,idempotencyKey:key,payload:{siteId,keepData:false}}),{fresh:(job.steps||[]).some((x)=>x.id==='remove'&&x.state==='failed')});return(awaitawaitHal(ctx,id)).result;});consta=externalAgentFor(siteId);if(a)store.run('UPDATE external_agents SET last_deploy_json=?, updated_at=? WHERE id=?',toJson({removedAt:nowIso(),leftovers:(r&&r.leftovers)||[]}),nowIso(),a.id);save(siteId,{current_version:null,deploy_verify:null,lifecycle:'building',pending_cleanup_json:null});returnr;}});
/** 배포 방식 설정(처음 고르거나 바꿀 때). 운영 중 사이트의 변경은 작업(§10.3)으로 */
asyncfunctionsetDeployMode(site,mode,user){
if(!['managed','external'].includes(mode))thrownewHttpError(400,'bad_mode','배포 방식은 managed 또는 external 입니다');
if(activeJobsFor(site.id).length)thrownewHttpError(409,'job_running','진행 중인 작업이 끝난 뒤 배포 방식을 바꿀 수 있습니다');
if(mode==='external'){consta=awaitrefreshExternalAgent(site);if(!a||a.status!=='connected')thrownewHttpError(409,'agent_not_connected','외부 서버 Agent 를 먼저 연결하세요',{href:`/sites/${site.id}/external`});}
constid=newId('mc_');constnow=nowIso();
const{job}=jobs.enqueue({kind:'site.mode_change',subject:{siteId:site.id,changeId:id,fromMode:site.deploy_mode,toMode:mode,fromTarget:site.deploy_mode==='managed'?site.server_id:(externalAgentFor(site.id)||{}).hal_agent_id,versionRef:'current'},idempotencyKey:`site.mode_change:${site.id}:${mode}:${store.get('SELECT COUNT(*) AS n FROM deploy_mode_changes WHERE site_id=?',site.id).n}`,createdBy:user.id,groupId:site.group_id,subjectType:'site',subjectId:site.id,label:`배포 방식 변경 (${site.deploy_mode} → ${mode})`,maxAttempts:2});
jobs.register('site.mode_change',{title:'배포 방식 변경',steps:[{id:'prepare_new',title:'새 위치 준비'},{id:'deploy_new',title:'새 위치에 별도 배포'},{id:'verify_new',title:'새 위치 검증'},{id:'switch_domain',title:'도메인 전환'},{id:'cleanup_old',title:'이전 위치 정리'}],maxAttempts:2,retryable:(e)=>e&&e.code==='service_unreachable',
constnewTarget=awaitctx.step('prepare_new',async()=>{if(toMode==='managed'){constp=awaithal().pickServer({role:'web',needBytes:0});if(!p)thrownewJobError('no_server','신규 배치를 받을 수 있는 서버가 없습니다',{retryable:true});returnp.serverId;}consta=externalAgentFor(siteId);if(!a||a.status!=='connected')thrownewJobError('agent_not_connected','외부 서버 Agent 가 연결되어 있지 않습니다');returna.hal_agent_id;});
constdomains=store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'',siteId).map((d)=>d.name);
awaitctx.step('verify_new',async()=>{if(!(bundle.internalCheck&&bundle.internalCheck.ok))thrownewJobError('verify_failed','새 위치 내부 확인에 실패했습니다 — 현재 위치를 유지합니다');returnbundle.internalCheck;});
awaitctx.step('switch_domain',async()=>{if(!domains.length)return{skipped:'연결된 도메인 없음'};constid=awaithalJobFor(ctx,job,'switch_domain',(key)=>hal().createJob({kind:'domain.vhost',target:newTarget,idempotencyKey:key,payload:{siteId,domains,primaryDomain:site.primary_domain,redirectOld:false,maintenance:false}}),{fresh:(job.steps||[]).some((x)=>x.id==='switch_domain'&&x.state==='failed')});return(awaitawaitHal(ctx,id)).result;});
store.run('UPDATE deploy_mode_changes SET status=\'done\', to_target=?, updated_at=?, ended_at=? WHERE id=?',newTarget,nowIso(),nowIso(),changeId);
notify(store,{groupId:site.group_id,category:'site',severity:'info',incidentKey:`modechange:${siteId}:${changeId}`,title:`사이트 "${site.name}" 배포 방식을 ${toMode==='managed'?'관리형':'외부'} 으로 바꿨습니다`,link:`/sites/${siteId}?tab=deploy`,subjectType:'site',subjectId:siteId});
return{newTarget};
},
asyncrevert(job,ctx){const{siteId,changeId}=job.subject;constst=job.steps||[];consttarget=st.find((s)=>s.id==='prepare_new')?.result;awaitctx.step('revert_new',async()=>{if(!target)return{skipped:true};constid=awaithalJobFor(ctx,job,'revert_new',(key)=>hal().createJob({kind:'site.remove',target,idempotencyKey:key,payload:{siteId,keepData:false}}),{fresh:(job.steps||[]).some((x)=>x.id==='revert_new'&&x.state==='failed')});return(awaitawaitHal(ctx,id)).result;});store.run('UPDATE deploy_mode_changes SET status=\'reverted\', updated_at=?, ended_at=? WHERE id=?',nowIso(),nowIso(),changeId);}});
jobs.onChange((j)=>{if(j.kind==='site.mode_change'&&['failed','cancelled'].includes(j.status)){store.run('UPDATE deploy_mode_changes SET status=?, updated_at=? WHERE id=?',j.status,nowIso(),j.subject.changeId);consts=get(j.subject.siteId);if(s)notify(store,{groupId:s.group_id,category:'site',severity:'warning',incidentKey:`modechange:${s.id}:${j.subject.changeId}`,title:`사이트 "${s.name}" 배포 방식 변경 실패 — 현재 위치를 유지합니다`,body:j.error,link:`/sites/${s.id}?tab=deploy`,subjectType:'site',subjectId:s.id,actionRequired:'되돌리기 또는 재시도'});}});
/** 관리형 운영 사이트의 사용량·건강을 10분 단위 멱등 작업으로 모은다. 화면이 열릴 때와 주기 작업이 부른다 */
functionrefresh(groupId,{force=false}={}){
constsites=store.all('SELECT id, server_id, primary_domain FROM sites WHERE group_id=? AND deploy_mode=\'managed\' AND current_version IS NOT NULL AND lifecycle IN (\'live\',\'needs_domain\',\'paused\')',groupId);
awaitctx.step('health',async()=>{letn=0;for(const[srv,list]ofObject.entries(bySrv)){consturls=list.filter((s)=>s.primary_domain).map((s)=>`https://${s.primary_domain}/`);if(!urls.length)continue;constr=awaithal().createJob({kind:'site.check',target:srv,idempotencyKey:`portal:${job.id}:check:${srv}`,payload:{urls}});consthj=awaitawaitHal(ctx,r.halJobId);for(constcof(hj.result&&hj.result.checks)||[]){consts=list.find((x)=>c.url.includes(x.primary_domain));if(!s)continue;consth=!c.ok?HEALTH.DOWN:c.ms>3000?HEALTH.DEGRADED:HEALTH.OK;save(s.id,{health:h,health_checked_at:nowIso(),last_external_check_json:c});if(h!==HEALTH.OK)notify(store,{groupId,category:'incident',severity:h===HEALTH.DOWN?'critical':'warning',incidentKey:`health:${s.id}`,title:`사이트 "${s.name}" 외부 접속 ${h===HEALTH.DOWN?'중단':'성능 저하'}`,body:`${c.url} 응답 ${c.status||'없음'} (${c.ms}ms)`,link:`/sites/${s.id}`,subjectType:'site',subjectId:s.id,mandatory:h===HEALTH.DOWN});else{const{resolveIncident}=awaitimport('../../lib/notify.mjs');resolveIncident(store,`health:${s.id}`,'외부 확인 정상 복구');}n++;}}return{checked:n};});
functionlatestMetric(siteId,metric){returnstore.get('SELECT value, period, measured_at FROM usage_samples WHERE subject_type=\'site\' AND subject_id=? AND metric=? ORDER BY measured_at DESC LIMIT 1',siteId,metric)||null;}
/** 사이트 개요 데이터(§8.1). 관리형/외부/미배포를 구분해 보일 값만 돌려준다 */
functionoverview(site){
constlastDeploy=dep(store.get('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT 1',site.id));
functionnextJobFor(site){constj=jobs.list({subjectType:'site',subjectId:site.id,status:['queued','awaiting_external'],limit:1})[0];if(j)return{id:j.id,label:j.label,at:j.next_run_at,status:j.status};constb=store.get('SELECT next_run_at FROM backup_options WHERE site_id=? AND status=\'active\'',site.id);returnb&&b.next_run_at?{label:'정기 백업',at:b.next_run_at}:null;}
functiondeployments(siteId,limit=20){returnstore.all('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT ?',siteId,limit).map((d)=>({...dep(d),job:d.job_id?describeJob(jobs.get(d.job_id)):null}));}
/** 주기 작업: 반환 예정 시각이 지난 예약 반환, 오래된 SSO 기록·번들 토큰 정리 */
functionsweep(now=Date.now()){
constdue=store.all('SELECT * FROM sites WHERE site_count_state=\'reserved\' AND return_due_at IS NOT NULL AND return_due_at<=?',newDate(now).toISOString()).map(row);letreturned=0;
for(constsofdue){if(activeJobsFor(s.id).length)continue;if(s.site_count_reservation_id)quota.release(store,s.site_count_reservation_id);save(s.id,{site_count_state:'none',site_count_reservation_id:null,return_due_at:null,pending_cleanup_json:s.pendingCleanup?{...s.pendingCleanup,autoReturnedAt:nowIso()}:null});notify(store,{groupId:s.group_id,category:'site',severity:'info',incidentKey:`return:${s.id}`,title:`사이트 "${s.name}" 의 사이트 수 예약이 반환되었습니다`,body:'정리하지 않은 중간 결과가 있으면 배포 화면에서 정리하세요',link:`/sites/${s.id}?tab=deploy`,subjectType:'site',subjectId:s.id});returned++;}
constcut=newDate(now-7*86400000).toISOString();store.run('DELETE FROM sso_tickets WHERE expires_at<?',cut);for(consttofstore.all('SELECT * FROM bundle_tokens WHERE expires_at<?',newDate(now-86400000).toISOString())){try{if(existsSync(t.file))unlinkSync(t.file);}catch(_){}store.run('DELETE FROM bundle_tokens WHERE id=?',t.id);}