사이트 목록·생성·상세, 관리형/외부 배포, 배포 방식 변경, 도메인·HTTPS, Source 옵션과 Webmaker 도입 수정(SSO, 프로젝트↔사이트 구분, 삭제 영향 확인, 동시 편집 충돌, 발행 경로) (§7~§12·§0.2)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:56:38 +00:00
co-authored by Claude Fable 5.1
parent a1cde81332
commit b1c06a0304
32 changed files with 1900 additions and 3 deletions
+92
View File
@@ -0,0 +1,92 @@
/**
* portal/sites/api.mjs — 사이트 영역 라우트(화면 + JSON API + 내부/운영자 API).
*
* 화면: /sites, /sites/new, /sites/:id(?tab=), /sites/:id/deploy, /sites/:id/deploy/mode, /sites/:id/external
* API : /api/sites (GET 목록, POST 생성) · /api/sites/:id (GET, PATCH 설정) · /api/sites/:id/overview · /versions · /precheck · /deploy(POST) · /deployments · /rollback · /cleanup
* /api/sites/:id/mode (GET 안내, POST 선택) · /consent(POST) · /external (GET 상태, POST 토큰 발급, DELETE 해제) · /external/remove(POST) · /jobs/:jobId/(retry|cancel|resume|revert)
* /api/sites/:id/usage · /activity
* 내부: /internal/bundles/:token (Agent 가 저장본을 받아 감) · /internal/webmaker/project-impact (Webmaker 가 폐기 전 영향 조회, 키 id 'webmaker')
* 운영자: /internal/ops/sites, /internal/ops/sites/:id (키 id 'hal')
* 권한: 조회 view · 설정 edit/admin · 배포 deploy · 외부 Agent deploy · 해지 billing(다른 영역)
*/
import { requireGroup, requireSitePerm, requireRole } from '../auth/guard.mjs';
import { HttpError } from '../../lib/http.mjs';
import { verifyCtx } from '../../lib/serviceauth.mjs';
import { listAudit } from '../../lib/audit.mjs';
import { describe as describeJob } from '../../lib/jobs.mjs';
import * as quota from '../../lib/quota.mjs';
import { createSitesService } from './service.mjs';
import * as ui from './ui.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createReadStream, existsSync, statSync } from 'node:fs';
const HERE = dirname(fileURLToPath(import.meta.url));
export async function register(app, deps) {
const sites = createSitesService(deps); deps.sites = sites;
app.static('/ui/sites', join(HERE, 'public'));
const secretOf = (keyId) => deps.vault.get('service:' + keyId) || (keyId === 'webmaker' && deps.webmaker ? deps.webmaker.secret() : null);
async function requireService(ctx, keyIds) { const v = await verifyCtx(ctx, (id) => (keyIds.includes(id) ? secretOf(id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증 실패(${v.reason})`); return v.keyId; }
/** 현재 그룹의 사이트를 권한과 함께 가져온다(다른 그룹 사이트는 404) */
/** 조회(view)는 그룹 구성원 누구나(viewer 포함 §4.3 '조회'), 그 밖의 권한은 guard.requireSitePerm */
const siteOf = (ctx, perm = 'view') => { requireGroup(ctx); const s = sites.getForGroup(ctx.group.id, ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); if (perm !== 'view') requireSitePerm(ctx, s, perm); return s; };
const pageCtx = (ctx) => { requireGroup(ctx); return ctx; };
// ---------- 화면 ----------
app.get('/sites', (ctx) => ctx.html(ui.listPage(pageCtx(ctx), deps, { filter: ctx.query.filter || '' })));
app.get('/sites/new', (ctx) => { pageCtx(ctx); requireRole(ctx, 'member'); return ctx.html(ui.newPage(ctx, deps)); });
app.get('/sites/:id', (ctx) => ctx.html(ui.detailPage(ctx, deps, siteOf(ctx, 'view'), { tab: ctx.query.tab || 'overview' })));
app.get('/sites/:id/deploy', (ctx) => ctx.html(ui.deployPage(ctx, deps, siteOf(ctx, 'view'))));
app.get('/sites/:id/deploy/mode', (ctx) => ctx.html(ui.modePage(ctx, deps, siteOf(ctx, 'view'))));
app.get('/sites/:id/external', (ctx) => ctx.html(ui.externalPage(ctx, deps, siteOf(ctx, 'view'))));
/** Webmaker SSO 진입(별도 로그인 없음). site 를 주면 그 프로젝트로, path 로 Webmaker 안의 특정 화면 */
app.get('/sso/webmaker', (ctx) => { requireGroup(ctx); let site = null; if (ctx.query.site) { site = sites.getForGroup(ctx.group.id, ctx.query.site); if (!site) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); requireSitePerm(ctx, site, 'edit'); } if (!deps.webmaker) throw new HttpError(503, 'service_unreachable', 'Webmaker 연동이 설정되지 않았습니다'); return ctx.redirect(deps.webmaker.ssoUrl({ user: ctx.user, group: ctx.group, site, projectId: ctx.query.project || null, path: ctx.query.path || null })); });
// ---------- JSON API ----------
app.get('/api/sites', (ctx) => { requireGroup(ctx); return { ok: true, sites: sites.list(ctx.group.id, { lifecycle: ctx.query.filter || null }), siteCount: quota.figures(deps.store, ctx.group.id, 'sites') }; });
app.post('/api/sites', async (ctx) => { requireRole(ctx, 'member'); if (!ctx.user.email_verified_at) throw new HttpError(403, 'email_unverified', '이메일 확인이 끝나야 사이트를 만들 수 있습니다'); const r = await sites.create({ user: ctx.user, group: ctx.group, ip: ctx.ip }, await ctx.body()); return { ok: true, ...r }; });
app.get('/api/sites/:id', (ctx) => { const s = siteOf(ctx); return { ok: true, site: pub(s), summary: sites.summarize(s) }; });
app.patch('/api/sites/:id', async (ctx) => { const s = siteOf(ctx, 'admin'); const r = sites.update(s, await ctx.body(), ctx.user); return { ok: true, site: pub(r.site), impact: r.impact, jobId: r.jobId || null }; });
app.get('/api/sites/:id/overview', (ctx) => { const s = siteOf(ctx); let refreshed = null; try { refreshed = sites.refresh(s.group_id); } catch (_) {} return { ok: true, overview: sites.overview(s), refreshJob: refreshed ? refreshed.id : null }; });
/** 제작 탭: Webmaker 프로젝트 요약·저장본·AI Wallet(호스팅 사용량과 분리) */
app.get('/api/sites/:id/versions', async (ctx) => { const s = siteOf(ctx); try { const v = await sites.versionsFor(s); let wallet = null; try { wallet = deps.webmaker ? await deps.webmaker.credits(sites.userLogin(ctx.user, ctx.group)) : null; } catch (_) { wallet = { stale: true }; } return { ok: true, ...v, wallet, webmakerUrl: `/sso/webmaker?site=${s.id}` }; } catch (e) { if (e.code === 'service_unreachable') return ctx.json({ ok: false, error: 'service_unreachable', message: 'Webmaker 에 연결할 수 없습니다(정보 확인 지연)', stale: true }, 503); throw e; } });
app.get('/api/sites/:id/precheck', async (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, precheck: await sites.precheck(s, { versionRef: ctx.query.version || 'current' }) }; });
app.post('/api/sites/:id/deploy', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); const r = await sites.startDeploy({ user: ctx.user, group: ctx.group, site: s, versionRef: b.versionRef || 'current', versionLabel: b.versionLabel || null, ip: ctx.ip }); return { ok: true, ...r }; });
app.get('/api/sites/:id/deployments', (ctx) => { const s = siteOf(ctx); return { ok: true, deployments: sites.deployments(s.id), activeJobs: sites.activeJobsFor(s.id).map(describeJob), site: pub(sites.get(s.id)) }; });
app.post('/api/sites/:id/rollback', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.rollback(s, ctx.user) }; });
app.post('/api/sites/:id/cleanup', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.cleanupAndReturn(s, ctx.user) }; });
app.get('/api/sites/:id/mode', async (ctx) => { const s = siteOf(ctx); return { ok: true, ...(await modeInfo(s, ctx)) }; });
app.post('/api/sites/:id/mode', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); const r = await sites.setDeployMode(s, b.mode, ctx.user); return { ok: true, site: pub(r.site), changed: r.changed, job: r.job || null }; });
app.post('/api/sites/:id/consent', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); if (b.version !== sites.EXTERNAL_CONSENT.version || !b.agree) throw new HttpError(400, 'consent_required', '확인 사항에 동의해야 합니다'); return { ok: true, site: pub(sites.recordConsent(s, ctx.user, { ip: ctx.ip })) }; });
app.get('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx); const a = await sites.refreshExternalAgent(s); return { ok: true, agent: a, consent: { version: sites.EXTERNAL_CONSENT.version, agreed: s.consent_version === sites.EXTERNAL_CONSENT.version }, lastDeploy: sites.deployments(s.id, 1)[0] || null }; });
app.post('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx, 'deploy'); if (s.consent_version !== sites.EXTERNAL_CONSENT.version) throw new HttpError(409, 'consent_required', '외부 배포 확인 사항에 먼저 동의하세요', { href: `/sites/${s.id}/deploy/mode` }); const b = await ctx.body(); return { ok: true, ...(await sites.createExternalAgent(s, ctx.user, String(b.label || '').slice(0, 80))) }; });
app.delete('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx, 'deploy'); await sites.revokeExternalAgent(s, ctx.user); return { ok: true }; });
app.post('/api/sites/:id/external/remove', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.removeExternalDeploy(s, ctx.user) }; });
app.post('/api/sites/:id/jobs/:jobId/:action', async (ctx) => { const s = siteOf(ctx, 'deploy'); const j = deps.jobs.get(ctx.params.jobId); if (!j || j.subject_id !== s.id) throw new HttpError(404, 'not_found', '작업을 찾을 수 없습니다'); const a = ctx.params.action; let r; if (a === 'retry') r = deps.jobs.retry(j.id, ctx.user.id); else if (a === 'cancel') r = deps.jobs.cancel(j.id, ctx.user.id); else if (a === 'resume') r = deps.jobs.resume(j.id, ctx.user.id); else if (a === 'revert') r = await deps.jobs.revert(j.id, ctx.user.id); else throw new HttpError(400, 'bad_action', '알 수 없는 작업 동작'); return { ok: true, job: describeJob(r) }; });
app.get('/api/sites/:id/jobs/:jobId', (ctx) => { const s = siteOf(ctx); const j = deps.jobs.get(ctx.params.jobId); if (!j || j.subject_id !== s.id) throw new HttpError(404, 'not_found', '작업을 찾을 수 없습니다'); return { ok: true, job: describeJob(j) }; });
app.get('/api/sites/:id/usage', (ctx) => { const s = siteOf(ctx); const o = sites.overview(s); const alloc = ['web_disk', 'web_out'].map((r) => ({ resource: r, ...quota.RESOURCES[r], allocation: quota.allocationOf(deps.store, s.group_id, r, 'site', s.id), usage: quota.usageOf(deps.store, s.group_id, r, 'site', s.id) })); return { ok: true, overview: o, allocations: alloc, samples: deps.store.all('SELECT metric, value, period, measured_at FROM usage_samples WHERE subject_type=\'site\' AND subject_id=? AND metric<>\'series_json\' ORDER BY measured_at DESC LIMIT 200', s.id) }; });
app.get('/api/sites/:id/activity', (ctx) => { const s = siteOf(ctx); return { ok: true, items: listAudit(deps.store, { subjectType: 'site', subjectId: s.id, limit: 100 }), jobs: deps.jobs.list({ subjectType: 'site', subjectId: s.id, limit: 50 }).map(describeJob) }; });
// ---------- 내부 ----------
/** 저장본 다운로드(Agent). 토큰은 1시간 유효. 비밀값 없는 tar.gz */
app.get('/internal/bundles/:token', (ctx) => { const t = sites.bundleByToken(ctx.params.token); if (!t || !existsSync(t.file)) throw new HttpError(404, 'not_found', '번들을 찾을 수 없거나 만료되었습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': statSync(t.file).size, 'Cache-Control': 'no-store' }); createReadStream(t.file).pipe(ctx.res); return undefined; });
app.post('/internal/webmaker/project-impact', async (ctx) => { await requireService(ctx, ['webmaker']); const b = await ctx.body(); return { ok: true, impact: sites.impactForProject(String(b.projectId || ''), b.siteId || null) }; });
app.post('/internal/webmaker/project-destroyed', async (ctx) => { await requireService(ctx, ['webmaker']); const b = await ctx.body(); return { ok: true, unlinked: sites.onProjectDestroyed(String(b.projectId || '')) }; });
// ---------- 운영자(HAL 콘솔이 부른다) ----------
app.get('/internal/ops/sites', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const { groupId, lifecycle, q } = ctx.query; const w = ['lifecycle<>\'deleted\'']; const p = []; if (groupId) { w.push('group_id=?'); p.push(groupId); } if (lifecycle) { w.push('lifecycle=?'); p.push(lifecycle); } if (q) { w.push('(name LIKE ? OR primary_domain LIKE ? OR id=?)'); p.push(`%${q}%`, `%${q}%`, q); } return { ok: true, sites: deps.store.all(`SELECT * FROM sites WHERE ${w.join(' AND ')} ORDER BY updated_at DESC LIMIT 500`, ...p).map((r) => sites.summarize(sites.get(r.id))).map((s) => ({ ...s, groupId: sites.get(s.id).group_id, serverId: sites.get(s.id).server_id })) }; });
app.get('/internal/ops/sites/:id', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const s = sites.get(ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트 없음'); return { ok: true, site: s, overview: sites.overview(s), deployments: sites.deployments(s.id, 50), jobs: deps.jobs.list({ subjectType: 'site', subjectId: s.id, limit: 100 }).map(describeJob), externalAgent: sites.externalAgentFor(s.id), domains: deps.store.all('SELECT * FROM domains WHERE site_id=?', s.id), audit: listAudit(deps.store, { subjectType: 'site', subjectId: s.id, limit: 100 }) }; });
app.post('/internal/ops/sites/:id/refresh', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const s = sites.get(ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트 없음'); return { ok: true, job: sites.refresh(s.group_id, { force: true }) }; });
app.post('/internal/ops/sites/sweep', async (ctx) => { await requireService(ctx, ['hal', 'portal']); return { ok: true, ...sites.sweep() }; });
/** 배포 방식 안내 데이터(§10.1·§10.2): 권리·보장량·백업 가능 여부·비용(활성 상품정책에서) */
async function modeInfo(s, ctx) {
const ent = ['sites', 'web_disk', 'web_out', 'backup'].map((r) => quota.figures(deps.store, s.group_id, r));
const policy = deps.productPolicy ? await deps.productPolicy.activeFor(s.group_id).catch(() => null) : null; // 상품 영역(fork C)이 제공하면 비용 표시
let servers = null; let halStale = false; try { servers = (await deps.hal.listServers({ role: 'web' })).filter((x) => x.acceptsNew).length; } catch (_) { halStale = true; }
const agent = sites.externalAgentFor(s.id);
return { current: s.deploy_mode, runtime: s.runtime, consent: { ...sites.EXTERNAL_CONSENT, agreed: s.consent_version === sites.EXTERNAL_CONSENT.version }, agent: agent ? { status: agent.status, label: agent.label } : null,
managed: { description: 'Ariweb 가 서버 배치와 운영(모니터링·자동 재시작·용량 조정 이전)을 관리합니다', monitoring: ['외부 접속 확인(대표 도메인, 10분 주기)', '디스크·데이터베이스·네트워크 사용량 수집', '오류율·응답 시간'], limits: ent.filter((e) => ['web_disk', 'web_out'].includes(e.resource)), backupAvailable: ent.find((e) => e.resource === 'backup').purchased > 0 || true, backupNote: '백업은 관리형 사이트 단위 유료 옵션입니다(백업 화면에서 구매)', relocation: '정적 사이트는 조건이 맞으면 자동 이전, 동적 사이트는 운영자 승인 후 이전', estimatedMinutes: s.runtime === 'node' ? 10 : 3, cost: policy ? policy.managedDeployCost || null : null, costNote: policy ? null : '승인된 비용은 활성 상품정책에서 표시됩니다(상품 영역 연결 전)', supportedRuntimes: ['static', 'node'], runtimeNotes: { static: 'HTML·CSS·JS·이미지. 서버 코드 실행 없음', node: 'Ariweb 가 승인한 Node 실행 형태. 데이터베이스: SQLite 파일만. 외부 DB·cron·메일 발송·소켓 서버는 이번 버전에서 지원하지 않음' }, serversAvailable: servers, halStale },
external: { targets: ['External Pull Agent 를 설치할 수 있는 지원 Linux 서버(Ubuntu 22.04/24.04, Debian 12, systemd, sudo, 외부 HTTPS 접속)'], prepare: ['root 또는 sudo 가능한 계정', '서버에서 HAL(hal.ariweb.net) 로 나가는 HTTPS 연결', '웹서버(nginx) 또는 Agent 가 설치할 nginx 허용', '도메인 DNS 를 고객이 직접 관리'], perDeployCost: policy ? policy.externalDeployCost || null : null, costNote: policy ? null : '건별 비용은 활성 상품정책에서 표시됩니다(상품 영역 연결 전)', notProvided: ['관리형 모니터링', '백업·복원', '서버 이전', 'SLA', '메일(외부 메일 배포 미지원)'], isolation: '외부 환경 장애는 Ariweb 정상 상태와 분리되어 상태 페이지·알림에 Ariweb 장애로 표시되지 않습니다', siteCountNote: '외부 배포도 공개 사이트라면 운영 사이트 수를 소비합니다. 관리형 디스크·트래픽·메일·백업 권리는 자동 할당되지 않습니다' } };
}
function pub(s) { if (!s) return null; const { settings_json, pending_cleanup_json, last_external_check_json, next_job_json, ...rest } = s; return rest; }
}
+50
View File
@@ -0,0 +1,50 @@
/**
* portal/sites/fake-hal.mjs — 테스트용 가짜 HAL 클라이언트(docs/HAL-JOBS.md 와 같은 인터페이스).
* 작업은 즉시 '완료' 로 두되, 시나리오(script)로 특정 kind 를 실패·단계 중간 상태로 만들 수 있다. 실제 HAL 클라이언트는 fork A 의 portal/hal/client.mjs.
*
* site.deploy 자식 단계 id(실제 Agent 도 같은 id 를 쓴다): apply · data_check · start · internal_check · external_check
*/
import { newId } from '../../lib/crypto.mjs';
import { nowIso } from '../../lib/db.mjs';
export function createFakeHal(opts = {}) {
const jobs = new Map(); const calls = []; const agents = new Map();
const servers = opts.servers || [{ id: 'srv_web1', name: 'web-1', role: 'web', location: 'KR', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: nowIso(), publicIp: '203.0.113.10', capacity: { diskFreeBytes: 50e9, diskTotalBytes: 100e9, memFreeBytes: 4e9, cpuLoad: 0.2, sitesHosted: 3, reservedBytes: 0 }, version: { agent: '0.1.1', modules: {} } }, { id: 'srv_web2', name: 'web-2', role: 'web', location: 'KR', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: nowIso(), publicIp: '203.0.113.20', capacity: { diskFreeBytes: 80e9, diskTotalBytes: 100e9, memFreeBytes: 4e9, cpuLoad: 0.1, sitesHosted: 1, reservedBytes: 0 }, version: { agent: '0.1.1', modules: {} } }];
const script = { failKinds: new Set(), pendingKinds: new Set(), results: {}, dns: { a: [], txt: {} }, unreachable: false };
const guard = () => { if (script.unreachable) { const e = new Error('HAL 에 연결할 수 없습니다'); e.code = 'service_unreachable'; throw e; } };
const defaultResult = (kind, payload, target) => ({
'site.deploy': { version: payload.versionRef, deployedAt: nowIso(), internalCheck: { ok: true, status: 200, ms: 12 }, externalCheck: { ok: true, url: `https://${payload.primaryDomain || 'preview.example.test'}/`, status: 200 }, siteUrl: `https://${target}.example.test/${payload.siteId}/`, leftovers: [] },
'site.rollback': { version: payload.toVersion }, 'site.remove': { removed: true, leftovers: [] },
'site.check': { checks: (payload.urls || []).map((u) => ({ url: u, status: 200, ok: true, ms: 30, tls: { valid: true, expiresAt: new Date(Date.now() + 60 * 86400000).toISOString() } })) },
'site.usage': { sites: Object.fromEntries((payload.siteIds || []).map((s) => [s, { diskBytes: 120e6, dbBytes: 5e6, uploadsBytes: 20e6, outBytesDay: 3e8, outBytesMonth: 5e9, outBytesTotal: 2e10, inBytesDay: 1e7, inBytesMonth: 2e8, inBytesTotal: 1e9, req24h: 1200, pv24h: 800, series7d: [1, 2, 3, 4, 5, 6, 7].map((d) => ({ date: `2026-10-0${d}`, pv: 100 * d, req: 150 * d })), series30d: [], errorRate: 0.4, p95ms: 210, measuredAt: nowIso() }])) },
'domain.vhost': { applied: payload.domains || [], certs: Object.fromEntries((payload.domains || []).map((d) => [d, { state: 'valid', expiresAt: new Date(Date.now() + 80 * 86400000).toISOString() }])) },
'domain.cert': { issued: payload.domains || [], failed: [], expiresAt: new Date(Date.now() + 80 * 86400000).toISOString() },
'domain.probe': { a: script.dns.a, txtOk: !!(payload.expectTxt && script.dns.txt[payload.expectTxt.name] === payload.expectTxt.value), txt: payload.expectTxt ? script.dns.txt[payload.expectTxt.name] || null : null, resolvedAt: nowIso() },
'site.relocate': { newServerId: payload.toServerId, switchedAt: nowIso(), oldRetainedUntil: new Date(Date.now() + 7 * 86400000).toISOString() },
}[kind] || { ok: true });
const hal = {
calls, jobs, script, servers, agents,
async createJob({ kind, target, payload, idempotencyKey }) {
guard(); calls.push({ kind, target, payload, idempotencyKey });
if (idempotencyKey) { const ex = [...jobs.values()].find((j) => j.idempotencyKey === idempotencyKey); if (ex) return { halJobId: ex.id, status: ex.status }; }
const id = newId('hj_'); const fail = script.failKinds.has(kind); const pending = script.pendingKinds.has(kind);
const steps = kind === 'site.deploy' ? ['apply', 'data_check', 'start', 'internal_check', 'external_check'].map((s, i) => ({ id: s, title: s, state: fail && i === 3 ? 'failed' : (fail && i > 3) || pending ? 'pending' : 'done' })) : [];
jobs.set(id, { id, kind, target, payload, idempotencyKey, status: pending ? 'running' : fail ? 'failed' : 'done', steps, progress: pending ? 40 : 100, result: fail || pending ? null : (script.results[kind] || defaultResult(kind, payload, target)), error: fail ? '내부 확인 실패: HTTP 502' : null, errorCode: fail ? 'internal_check_failed' : null, updatedAt: nowIso() });
return { halJobId: id, status: jobs.get(id).status };
},
async getJob(id) { guard(); const j = jobs.get(id); if (!j) throw Object.assign(new Error('작업 없음'), { code: 'hal_404' }); return { ...j }; },
async cancelJob(id) { guard(); const j = jobs.get(id); if (j) j.status = 'cancelled'; return { ok: true }; },
async retryJob(id) { guard(); const j = jobs.get(id); if (j) { j.status = 'done'; j.result = defaultResult(j.kind, j.payload, j.target); j.steps.forEach((s) => { s.state = 'done'; }); } return { ok: true }; },
async revertJob(id) { guard(); return { ok: true }; }, async resumeJob(id) { guard(); return { ok: true }; },
async listServers({ role } = {}) { guard(); return servers.filter((s) => !role || s.role === role); },
async pickServer({ role = 'web', needBytes = 0 } = {}) { guard(); const s = servers.find((x) => x.role === role && x.acceptsNew && x.status === 'ready' && x.capacity.diskFreeBytes > needBytes); return s ? { serverId: s.id } : null; },
async issueExternalAgentToken({ groupId, siteId, label }) { guard(); const agentId = newId('xa_'); agents.set(agentId, { id: agentId, status: 'pending', lastSeenAt: null, version: null, lastDeploy: null }); return { agentId, token: 'xat_' + newId(''), expiresAt: new Date(Date.now() + 600000).toISOString(), installCommand: `curl -fsSL https://hal.example.test/agent/install.sh | sudo bash -s -- --hal https://hal.example.test --token <토큰> --mode external` }; },
async getExternalAgent(id) { guard(); return agents.get(id) || { id, status: 'revoked' }; },
async revokeExternalAgent(id) { guard(); const a = agents.get(id); if (a) a.status = 'revoked'; return { ok: true }; },
async health() { guard(); return { ok: true, version: '0.1.1', uptime: 1 }; },
/** 테스트 도우미: 진행 중 작업을 끝낸다 */
_finish(id, { fail = false } = {}) { const j = jobs.get(id); j.status = fail ? 'failed' : 'done'; j.progress = 100; j.steps.forEach((s) => { s.state = 'done'; }); j.result = fail ? null : defaultResult(j.kind, j.payload, j.target); j.error = fail ? '외부 접속 확인 실패' : null; j.updatedAt = nowIso(); },
_connectAgent(id) { const a = agents.get(id); if (a) { a.status = 'connected'; a.lastSeenAt = nowIso(); a.version = '0.1.1'; } },
};
return hal;
}
+27
View File
@@ -0,0 +1,27 @@
/**
* portal/sites/model.mjs — 사이트 영역의 세부 표(중심 표 sites·deployments 는 portal/core/model.mjs).
* sso_tickets 포털 → Webmaker SSO 티켓 발급 기록(nonce 만, 티켓 원문 없음)
* external_agents 외부 배포(External Pull Agent) 연결: HAL 이 발급한 agent id·상태·마지막 배포/확인 결과
* site_consents 외부 배포 동의(버전 기록 §10.2) 등 사용자가 확인한 약관 버전
* bundle_tokens 저장본 tar.gz 를 Agent 가 받아 갈 1회용 다운로드 토큰(해시 저장)
* deploy_mode_changes 배포 방식 변경 작업(§10.3) 기록
* sites 표에는 runtime(static|node)·consent_version·pending_cleanup(정리하지 않은 중간 결과)·return_due_at(예약 반환 예정) 열을 더한다.
*/
export const MIGRATIONS = [{ id: 'sites_0001', sql: `
CREATE TABLE IF NOT EXISTS sso_tickets (id TEXT PRIMARY KEY, nonce TEXT NOT NULL, user_id TEXT NOT NULL, group_id TEXT NOT NULL, site_id TEXT, project_id TEXT, issued_at TEXT NOT NULL, expires_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS external_agents (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, group_id TEXT NOT NULL, hal_agent_id TEXT NOT NULL, label TEXT, status TEXT NOT NULL DEFAULT 'pending', token_expires_at TEXT, install_command TEXT,
last_seen_at TEXT, agent_version TEXT, last_deploy_json TEXT, last_check_json TEXT, revoked_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS external_agents_site ON external_agents(site_id);
CREATE TABLE IF NOT EXISTS site_consents (id TEXT PRIMARY KEY, site_id TEXT NOT NULL, group_id TEXT NOT NULL, user_id TEXT NOT NULL, kind TEXT NOT NULL, version TEXT NOT NULL, text_hash TEXT, agreed_at TEXT NOT NULL, ip TEXT);
CREATE TABLE IF NOT EXISTS bundle_tokens (id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, site_id TEXT NOT NULL, deployment_id TEXT, file TEXT NOT NULL, bytes INTEGER, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT);
CREATE TABLE IF NOT EXISTS deploy_mode_changes (id TEXT PRIMARY KEY, site_id TEXT NOT NULL, group_id TEXT NOT NULL, from_mode TEXT, to_mode TEXT NOT NULL, from_target TEXT, to_target TEXT, status TEXT NOT NULL, job_id TEXT, created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ended_at TEXT);
ALTER TABLE sites ADD COLUMN runtime TEXT NOT NULL DEFAULT 'static';
ALTER TABLE sites ADD COLUMN consent_version TEXT;
ALTER TABLE sites ADD COLUMN pending_cleanup_json TEXT;
ALTER TABLE sites ADD COLUMN return_due_at TEXT;
ALTER TABLE sites ADD COLUMN last_deploy_at TEXT;
ALTER TABLE sites ADD COLUMN last_external_check_json TEXT;
ALTER TABLE sites ADD COLUMN next_job_json TEXT;
` }];
+103
View File
@@ -0,0 +1,103 @@
/* portal/sites/public/sites.js — 사이트 화면 스크립트. data-page 로 화면을 고르고 /api/sites/* 를 채운다. 모든 화면: 불러오는 중 → 데이터 | 비어 있음 | 지연(stale) | 부분/전체 실패 | 권한 없음. */
(() => {
const $ = (s, r = document) => r.querySelector(s); const esc = aw.esc; const fmt = aw.fmt;
const B = (n) => { if (n == null) return '제공되지 않음'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = Number(n); while (v >= 1024 && i < 4) { v /= 1024; i++; } return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; };
const L = { draft: '초안', building: '제작 중', deploying: '배포 중', live: '운영 중', needs_domain: '도메인 설정 필요', paused: '일시 중지', cancel_scheduled: '해지 예약', readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류' };
const H = { ok: ['ok', '정상'], degraded: ['warning', '성능 저하'], partial_outage: ['warning', '일부 장애'], down: ['failed', '중단'], unknown: ['neutral', '확인 전'], stale: ['warning', '정보 확인 지연'], not_provided: ['neutral', '제공되지 않음'] };
const V = { verified: '배포 검증 완료', needs_domain: '도메인 설정 필요', public: '공개 운영 중' };
const SC = { none: '-', reserved: '예약됨', in_use: '사용 중', returning: '반환 중' };
const badge = (kind, text) => `<span class="badge ${kind}">${({ ok: '●', done: '✔', failed: '✘', warning: '!', running: '◌', waiting: '◔', neutral: '○' })[kind] || '○'} ${esc(text)}</span>`;
const onErr = (el, e, retry) => aw.state(el, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' || e.status === 503 ? 'stale' : 'failed', e.message, { retry });
const siteId = () => $('[data-site]')?.dataset.site;
const jobAction = (sid) => async (a, j) => { if (a === 'cancel' && !(await aw.confirmDanger({ title: '작업 취소', target: j.label, impacts: ['진행 중인 단계는 안전한 지점에서 멈추고, 남은 중간 결과는 배포 화면에 표시됩니다'] }))) return; if (a === 'revert' && !(await aw.confirmDanger({ title: '되돌리기', target: j.label, impacts: ['지금 배포된 파일이 이전 배포본으로 바뀝니다', '업로드 데이터와 데이터베이스는 유지됩니다', '실제 외부 접속을 확인한 뒤 완료로 표시합니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/jobs/${j.id}/${a === 'go' ? 'resume' : a}`); aw.toast('요청했습니다'); location.reload(); } catch (e) { aw.toast(e.message); } };
// ---------- 목록 ----------
async function list() {
const el = $('#list'); try { const r = await aw.api('GET', `/api/sites${el.dataset.filter ? '?filter=' + el.dataset.filter : ''}`); if (!r.sites.length) return aw.state(el, 'empty', '아직 사이트가 없습니다. "새 사이트" 로 시작하세요', { href: '/sites/new', hrefLabel: '새 사이트' });
const sc = r.siteCount; el.innerHTML = `<p class="sub">운영 사이트 수: 사용 중 ${sc.allocated} · 예약 ${sc.reserved} · 구매 ${sc.purchased} (사용 가능 ${sc.unallocated}) — 초안·제작 중 사이트는 포함되지 않습니다</p><table class="r"><thead><tr><th>이름 · 대표 도메인</th><th>제작·배포 상태</th><th>배포 방식</th><th>메일 · 백업</th><th>최근 배포</th><th>건강 · 조치</th></tr></thead><tbody>${r.sites.map((s) => { const h = H[s.health] || H.unknown; return `<tr><td data-l="이름"><a href="/sites/${s.id}"><b>${esc(s.name)}</b></a><br><span class="mono sub">${esc(s.primaryDomain || '대표 도메인 없음')}</span></td><td data-l="상태">${badge(s.lifecycle === 'live' ? 'ok' : ['paused', 'needs_domain'].includes(s.lifecycle) ? 'warning' : 'neutral', L[s.lifecycle] || s.lifecycle)}${s.deployState ? `<br><span class="sub">${esc(V[s.deployState.verify] || s.deployState.status)}</span>` : ''}<br><span class="sub">사이트 수 ${esc(SC[s.siteCountState] || s.siteCountState)}</span></td><td data-l="배포 방식">${s.deployMode === 'managed' ? '관리형' : s.deployMode === 'external' ? '외부' : '<span class="muted">미선택</span>'}</td><td data-l="메일·백업">${s.mail ? '✉ 메일' : '<span class="muted">메일 없음</span>'}<br>${s.backup ? '⟲ 백업 ' + esc(s.backup.status) : '<span class="muted">백업 없음</span>'}</td><td data-l="최근 배포">${s.lastDeployAt ? esc(fmt(s.lastDeployAt)) : '<span class="muted">없음</span>'}</td><td data-l="건강·조치">${badge(h[0], h[1])}${s.actionNeeded.map((a) => `<br><a href="${esc(a.href)}">! ${esc(a.text)}</a>`).join('')}</td></tr>`; }).join('')}</tbody></table>`; } catch (e) { onErr(el, e, list); }
}
// ---------- 생성 ----------
async function create() {
const f = $('#newSite'); const pick = $('#projectPick'); const bm = $('#buildModePick');
f.querySelectorAll('[name=projectMode]').forEach((r) => { r.onchange = async () => { pick.hidden = r.value !== 'existing' || !r.checked; bm.hidden = r.value !== 'new'; if (r.value === 'existing' && r.checked) { const sel = $('#projectId'); try { const r2 = await aw.api('GET', '/api/webmaker/projects'); sel.innerHTML = r2.projects.length ? r2.projects.map((p) => `<option value="${esc(p.id)}" ${p.link ? 'disabled' : ''}>${esc(p.title)} (${p.versions}개 저장본${p.link ? ', 이미 연결됨' : ''})</option>`).join('') : '<option value="">연결할 수 있는 프로젝트가 없습니다</option>'; } catch (e) { sel.innerHTML = `<option value="">${esc(e.message)}</option>`; } } }; });
f.onsubmit = async (e) => { e.preventDefault(); f.querySelectorAll('.err').forEach((x) => { x.textContent = ''; }); const fd = new FormData(f); const body = Object.fromEntries(fd.entries()); const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
try { const r = await aw.api('POST', '/api/sites', body); aw.markClean(); f.hidden = true; const c = $('#created'); c.hidden = false; c.innerHTML = `<h2>사이트 "${esc(r.site.name)}" 을(를) 만들었습니다</h2>${r.projectNote ? `<p class="err">${esc(r.projectNote)}</p>` : ''}<p>다음 단계</p><ol>${r.nextSteps.map((s) => `<li>${s.unsupported ? `<span class="muted">${esc(s.label)} — ${esc(s.unsupported)}</span>` : `<a href="${esc(s.href)}">${esc(s.label)}</a>`}${s.done ? ' ✔' : ''}</li>`).join('')}</ol><div class="actions"><a class="btn primary" href="/sites/${r.site.id}">사이트 상세로</a><a class="btn" href="/sso/webmaker?site=${r.site.id}" target="_blank" rel="noopener">제작 화면 열기</a></div>`; }
catch (err) { btn.disabled = false; const t = f.querySelector(`[data-err="${err.detail && err.detail.field || 'form'}"]`) || f.querySelector('[data-err=form]'); t.textContent = err.message; } };
}
// ---------- 개요 ----------
async function overview() {
const el = $('#overview'); const sid = siteId();
const render = (o) => {
const head = `<div class="cards"><div class="card"><h2>상태</h2><dl class="kv"><dt>서비스 상태</dt><dd>${badge(o.lifecycle === 'live' ? 'ok' : 'neutral', L[o.lifecycle] || o.lifecycle)}</dd><dt>배포 작업</dt><dd>${o.lastDeploy ? badge(o.lastDeploy.status === 'done' ? 'done' : o.lastDeploy.status === 'failed' ? 'failed' : 'running', aw.JOB_LABEL[o.lastDeploy.status] || o.lastDeploy.status) + (o.lastDeploy.verify ? ' ' + esc(V[o.lastDeploy.verify]) : '') : '<span class="muted">없음</span>'}</dd><dt>외부 건강</dt><dd>${o.kind === 'external' ? badge('neutral', '제공되지 않음') : o.health ? badge((H[o.health] || H.unknown)[0], (H[o.health] || H.unknown)[1]) + (o.healthCheckedAt ? ` <span class="sub">${esc(fmt(o.healthCheckedAt))}</span>` : '') : '-'}</dd><dt>사이트 수</dt><dd>${esc(SC[o.siteCountState] || '-')}${o.returnDueAt ? ` <span class="sub">(반환 예정 ${esc(fmt(o.returnDueAt))})</span>` : ''}</dd>${o.nextJob ? `<dt>다음 예정 작업</dt><dd>${esc(o.nextJob.label)} ${o.nextJob.at ? esc(fmt(o.nextJob.at)) : ''}</dd>` : ''}</dl></div>`;
const actions = o.actionNeeded.length ? `<div class="card"><h2>조치 필요</h2><ul>${o.actionNeeded.map((a) => `<li><a href="${esc(a.href)}">${esc(a.text)}</a></li>`).join('')}</ul></div>` : '';
if (o.kind === 'not_deployed') return `${head}${actions}<div class="card"><h2>준비 단계</h2><p>${esc(o.message)}</p><ol>${o.readiness.map((s) => `<li>${s.unsupported ? `<span class="muted">${esc(s.label)} — ${esc(s.unsupported)}</span>` : `<a href="${esc(s.href)}">${esc(s.label)}</a>`} ${s.done ? '✔' : ''}</li>`).join('')}</ol></div></div>`;
if (o.kind === 'external') return `${head}${actions}<div class="card"><h2>외부 배포 사이트</h2><dl class="kv"><dt>Agent 연결</dt><dd>${o.agent ? badge(o.agent.status === 'connected' ? 'ok' : 'warning', { connected: '연결됨', pending: '연결 대기', offline: '응답 없음', revoked: '해제됨' }[o.agent.status] || o.agent.status) + (o.agent.lastSeenAt ? ` <span class="sub">마지막 응답 ${esc(fmt(o.agent.lastSeenAt))}</span>` : '') : '<span class="muted">없음</span>'}</dd><dt>마지막 배포</dt><dd>${o.lastDeployResult ? `${o.lastDeployResult.ok ? '성공' : '실패'} · ${esc(fmt(o.lastDeployResult.at))} · 버전 ${esc(o.lastDeployResult.version || '-')}` : '없음'}</dd><dt>마지막 외부 확인</dt><dd>${o.lastExternalCheck ? `${o.lastExternalCheck.ok ? '정상' : '실패'} ${esc(o.lastExternalCheck.url || '')} (${o.lastExternalCheck.status || '-'})` : '제공되지 않음'}</dd><dt>고객 책임 범위</dt><dd>${o.customerResponsibility.map(esc).join('<br>')}</dd></dl><h2>제공되지 않음</h2><ul>${o.notProvided.map((x) => `<li>${esc(x)} — 제공되지 않음</li>`).join('')}</ul></div></div>`;
const w = (p) => p == null ? '' : p >= 100 ? 'w100' : p >= 95 ? 'w95' : p >= 90 ? 'w90' : p >= 80 ? 'w80' : p >= 70 ? 'w70' : '';
return `${head}${actions}<div class="card"><h2>접속</h2>${o.stale ? `<p class="aw-state stale"><b>정보 확인 지연</b> ${esc(o.staleReason)}</p>` : ''}<dl class="kv"><dt>대표 도메인</dt><dd>${o.primaryDomain ? `<a href="https://${esc(o.primaryDomain)}/" target="_blank" rel="noopener">${esc(o.primaryDomain)}</a>` : '없음'}</dd><dt>현재 배포 버전</dt><dd>${esc(o.currentVersion || '-')} <span class="sub">${esc(fmt(o.lastDeployAt))}</span></dd><dt>최근 24시간 접속</dt><dd>${o.visits24h != null ? `${o.visits24h.toLocaleString()} 방문 · ${(o.requests24h || 0).toLocaleString()} 요청` : '수집 전'}</dd><dt>7일 추이</dt><dd>${o.series.series7d.length ? o.series.series7d.map((d) => `${esc(d.date.slice(5))}: ${d.pv}`).join(' · ') : '수집 전'}</dd><dt>30일 추이</dt><dd>${o.series.series30d.length ? o.series.series30d.length + '일 수집' : '수집 전'}</dd><dt>마지막 수집</dt><dd>${o.measuredAt ? esc(fmt(o.measuredAt)) : '-'}</dd></dl></div>
<div class="card"><h2>디스크와 데이터베이스</h2><div class="bar ${w(o.disk.percent)}"><i style="width:${Math.min(100, o.disk.percent || 0)}%"></i></div><dl class="kv"><dt>사용</dt><dd>${B(o.disk.used)} / 한도 ${B(o.disk.limit)} ${o.disk.percent != null ? `(${o.disk.percent}%)` : ''}</dd><dt>데이터베이스</dt><dd>${B(o.disk.db)}</dd><dt>업로드</dt><dd>${B(o.disk.uploads)}</dd></dl></div>
<div class="card"><h2>네트워크</h2><dl class="kv"><dt>아웃바운드 일/월/누적</dt><dd>${B(o.network.out.day)} / ${B(o.network.out.month)} / ${B(o.network.out.total)} ${o.network.out.limit ? `(월 한도 ${B(o.network.out.limit)})` : ''}</dd><dt>인바운드 일/월/누적</dt><dd>${B(o.network.in.day)} / ${B(o.network.in.month)} / ${B(o.network.in.total)}</dd></dl></div>
<div class="card"><h2>오류와 성능</h2><dl class="kv"><dt>오류율</dt><dd>${o.errors.errorRate != null ? o.errors.errorRate + '%' : '수집 전'}</dd><dt>응답 시간(p95)</dt><dd>${o.errors.p95ms != null ? o.errors.p95ms + ' ms' : '수집 전'}</dd><dt>마지막 외부 확인</dt><dd>${o.lastExternalCheck ? `${o.lastExternalCheck.ok ? '정상' : '실패'} (${o.lastExternalCheck.status || '-'}, ${o.lastExternalCheck.ms || '-'}ms)` : '-'}</dd></dl></div></div>`;
};
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/overview`); el.innerHTML = render(r.overview); return !!r.refreshJob; } catch (e) { onErr(el, e, load); return false; } };
if (await load()) aw.poll(async () => { await load(); return false; }, 15000);
}
// ---------- 제작 탭 ----------
async function build() {
const el = $('#buildInfo'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/versions`); if (!r.project) return aw.state(el, 'empty', 'Webmaker 프로젝트가 연결되어 있지 않습니다'); el.innerHTML = `<dl class="kv"><dt>프로젝트</dt><dd>${esc(r.project.title)} <span class="sub">(${esc(r.project.id.slice(0, 8))}…, rev ${r.project.rev})</span></dd><dt>제작 방식</dt><dd>${esc({ ai: 'AI에게 맡기기', manual: '내가 고르기', quick: '제목·내용 입력형' }[r.project.mode] || r.project.mode)}</dd><dt>내용</dt><dd>${r.project.hasContent ? '홈 페이지 있음' : '아직 페이지가 없습니다'}</dd><dt>저장본</dt><dd>${r.versions.length}개 ${r.versions.length ? `(최근: #${r.versions[0].no} ${esc(r.versions[0].message || r.versions[0].kind)} · ${esc(fmt(r.versions[0].at))})` : ''}</dd></dl>`; const wl = $('#wallet'); if (wl) wl.innerHTML = r.wallet && !r.wallet.stale && r.wallet.credit ? `<h2>AI Wallet</h2><p class="hint">AI 생성·수정 비용은 AI Wallet(캐시)에서 차감되며 호스팅 디스크·트래픽 사용량과 분리됩니다. 충전·사용 이력은 상품 및 결제 화면에서 봅니다.</p><dl class="kv"><dt>잔액</dt><dd>${r.wallet.credit.balance.toLocaleString()} ${esc(r.wallet.unit || '원')}</dd><dt>사용</dt><dd>${r.wallet.credit.used.toLocaleString()} ${esc(r.wallet.unit || '원')}</dd></dl>` : `<h2>AI Wallet</h2><p class="aw-state stale"><b>정보 확인 지연</b> 잔액을 지금 확인할 수 없습니다</p>`; } catch (e) { onErr(el, e, build); }
}
// ---------- 배포 탭 ----------
async function deploy() {
const sid = siteId(); const cur = $('#deployCurrent'); const start = $('#deployStart'); const hist = $('#deployHistory');
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/deployments`); const s = r.site; const active = r.activeJobs.find((j) => ['site.deploy', 'site.rollback', 'site.cleanup', 'site.mode_change'].includes(j.kind)); const last = r.deployments[0];
cur.innerHTML = `<h2>현재 배포</h2><dl class="kv"><dt>현재 버전</dt><dd>${esc(s.current_version || '없음')} ${s.deploy_verify ? badge(s.deploy_verify === 'public' ? 'ok' : 'warning', V[s.deploy_verify]) : ''}</dd><dt>배포 방식</dt><dd>${s.deploy_mode === 'managed' ? '관리형' : s.deploy_mode === 'external' ? '외부' : '미선택'} <a href="/sites/${sid}/deploy/mode">변경</a></dd><dt>사이트 수</dt><dd>${esc(SC[s.site_count_state] || '-')}${s.return_due_at ? ` · 반환 예정 ${esc(fmt(s.return_due_at))} <button class="btn" id="btnCleanup">지금 정리하고 반환</button>` : ''}</dd></dl>${s.pending_cleanup_json ? '' : ''}<div id="activeJob"></div>${active ? '' : `<div class="actions">${last && last.previous_version ? '<button class="btn danger" id="btnRollback">이전 버전으로 되돌리기</button>' : ''}</div>`}`;
if (active) { aw.job($('#activeJob'), active, { onAction: jobAction(sid) }); } else if (last && last.job && ['failed', 'needs_user'].includes(last.job.status)) { aw.job($('#activeJob'), last.job, { onAction: jobAction(sid) }); }
const rb = $('#btnRollback'); if (rb) rb.onclick = async () => { if (!(await aw.confirmDanger({ title: '이전 버전으로 되돌리기', target: `${s.current_version} → ${last.previous_version}`, impacts: ['지금 배포된 파일이 이전 배포본으로 바뀝니다', '업로드 데이터와 데이터베이스는 유지됩니다', '되돌린 뒤 실제 외부 접속을 확인한 다음 완료로 표시합니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/rollback`); aw.toast('되돌리기를 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
const cl = $('#btnCleanup'); if (cl) cl.onclick = async () => { if (!(await aw.confirmDanger({ title: '중간 결과 정리와 사이트 수 반환', target: s.name, impacts: ['배포 위치에 남은 중간 파일을 제거합니다', '사이트 수 예약을 반환합니다(다른 사이트가 쓸 수 있게 됨)', '현재 운영 중인 버전이 있으면 유지됩니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/cleanup`); aw.toast('정리 작업을 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
hist.innerHTML = `<h2>배포 기록</h2>${r.deployments.length ? `<table class="r"><thead><tr><th>시각</th><th>버전</th><th>결과</th><th>완료 상태</th><th>작업</th></tr></thead><tbody>${r.deployments.map((d) => `<tr><td data-l="시각">${esc(fmt(d.created_at))}</td><td data-l="버전">${esc(d.version_label || d.version_ref)}${d.previous_version ? `<br><span class="sub">이전 ${esc(d.previous_version)}</span>` : ''}</td><td data-l="결과">${badge(d.status === 'done' ? 'done' : d.status === 'failed' ? 'failed' : 'running', aw.JOB_LABEL[d.status] || d.status)}${d.result && d.result.error ? `<br><span class="err">${esc(d.result.error)}</span><br><span class="sub">문의 번호 ${esc(d.job_id || '')}</span>` : ''}</td><td data-l="완료 상태">${d.verify_state ? esc(V[d.verify_state]) : '-'}</td><td data-l="작업">${d.job ? `${esc(aw.JOB_LABEL[d.job.status] || d.job.status)} ${d.job.attempt > 1 ? `(${d.job.attempt}회)` : ''}` : '-'}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">배포 기록이 없습니다</p>'}`;
if (!active) await startForm(s); else start.innerHTML = '';
return !!active; } catch (e) { onErr(cur, e, load); return false; } };
const startForm = async (s) => { try { const r = await aw.api('GET', `/api/sites/${sid}/versions`); const opts = [`<option value="current">현재 저장 상태 (rev ${r.project ? r.project.rev : '-'})</option>`, ...r.versions.map((v) => `<option value="${v.no}">#${v.no} ${esc(v.message || v.kind)} · ${esc(fmt(v.at))}</option>`)].join(''); start.innerHTML = `<h2>배포 시작</h2><div class="f"><label for="ver">배포할 저장본</label><select id="ver">${opts}</select><span class="hint">이전 저장본을 골라 배포할 수 있습니다. 배포 전 Webmaker 미리보기로 내용을 확인하세요(운영 버전은 배포 완료 전까지 바뀌지 않습니다)</span></div><div class="actions"><button class="btn" id="btnPrecheck">배포 전 확인</button><button class="btn primary" id="btnDeploy" disabled>배포 시작</button></div><div id="precheck"></div>`; $('#btnPrecheck').onclick = precheck; $('#ver').onchange = () => { $('#btnDeploy').disabled = true; $('#precheck').innerHTML = ''; }; $('#btnDeploy').onclick = async () => { $('#btnDeploy').disabled = true; try { const v = $('#ver').value; const r2 = await aw.api('POST', `/api/sites/${sid}/deploy`, { versionRef: v, versionLabel: $('#ver').selectedOptions[0].textContent.trim() }); aw.toast(r2.created ? '배포를 시작했습니다. 화면을 닫아도 계속 진행됩니다' : '이미 같은 배포가 진행 중입니다'); load(); } catch (e) { $('#precheck').innerHTML = `<p class="err">${esc(e.message)}</p>` + (e.detail && e.detail.items ? renderPrecheck(e.detail.items) : ''); } }; } catch (e) { start.innerHTML = `<h2>배포 시작</h2>`; aw.state(start.appendChild(document.createElement('div')), e.code === 'service_unreachable' ? 'stale' : 'failed', e.message); } };
const renderPrecheck = (items) => `<ul class="steps">${items.map((i) => `<li class="${i.state === 'block' ? 'failed' : i.state === 'pass' ? 'done' : 'pending'}"><span class="s">${i.state === 'block' ? '✘' : i.state === 'pass' ? '✔' : i.state === 'warn' ? '!' : 'ⓘ'}</span><span><b>${esc(i.title)}</b> — ${esc(i.text)} ${i.href ? `<a href="${esc(i.href)}">해결하기</a>` : ''}</span></li>`).join('')}</ul>`;
const precheck = async () => { const pc = $('#precheck'); aw.state(pc, 'loading'); try { const r = await aw.api('GET', `/api/sites/${sid}/precheck?version=${encodeURIComponent($('#ver').value)}`); pc.innerHTML = `<p>${r.precheck.ok ? '✔ 모든 항목을 통과했습니다' : '✘ 차단된 항목이 있습니다. 해결 후 다시 확인하세요'}</p>${renderPrecheck(r.precheck.items)}`; $('#btnDeploy').disabled = !r.precheck.ok; } catch (e) { onErr(pc, e, precheck); } };
if (await load()) aw.poll(load, 4000);
}
// ---------- 배포 방식 ----------
async function mode() {
const el = $('#modeInfo'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/mode`); const m = r.managed; const x = r.external;
el.innerHTML = `<div class="cards"><div class="card"><h2>관리형 배포 ${r.current === 'managed' ? badge('ok', '현재') : ''}</h2><p>${esc(m.description)}</p><dl class="kv"><dt>모니터링 범위</dt><dd>${m.monitoring.map(esc).join('<br>')}</dd><dt>용량·트래픽 한도</dt><dd>${m.limits.map((l) => `${esc(l.label)}: 구매 ${B(l.purchased)} · 미배정 ${B(l.unallocated)}`).join('<br>') || '구매한 권리 없음'}</dd><dt>백업 옵션</dt><dd>${esc(m.backupNote)}</dd><dt>서버 이전</dt><dd>${esc(m.relocation)}</dd><dt>예상 배포 시간</dt><dd>약 ${m.estimatedMinutes}분</dd><dt>승인된 비용</dt><dd>${m.cost != null ? esc(String(m.cost)) : esc(m.costNote || '-')}</dd><dt>지원 실행 형태</dt><dd>정적: ${esc(m.runtimeNotes.static)}<br>동적: ${esc(m.runtimeNotes.node)}</dd><dt>배치 가능 서버</dt><dd>${m.halStale ? '<span class="aw-state stale" style="padding:2px 6px">정보 확인 지연</span>' : m.serversAvailable + '대'}</dd></dl><button class="btn primary" data-mode="managed" ${r.current === 'managed' ? 'disabled' : ''}>관리형 선택</button></div>
<div class="card"><h2>외부 배포 ${r.current === 'external' ? badge('ok', '현재') : ''}</h2><dl class="kv"><dt>지원 대상</dt><dd>${x.targets.map(esc).join('<br>')}</dd><dt>준비할 것</dt><dd>${x.prepare.map(esc).join('<br>')}</dd><dt>건별 배포 비용</dt><dd>${x.perDeployCost != null ? esc(String(x.perDeployCost)) : esc(x.costNote || '-')}</dd><dt>제공하지 않음</dt><dd>${x.notProvided.map(esc).join(', ')}</dd><dt>장애 분리</dt><dd>${esc(x.isolation)}</dd><dt>사이트 수</dt><dd>${esc(x.siteCountNote)}</dd><dt>Agent</dt><dd>${r.agent ? esc(r.agent.status) : '없음'} — <a href="/sites/${sid}/external">외부 서버 연결 화면</a></dd></dl><h3>${esc(r.consent.title)} (버전 ${esc(r.consent.version)})</h3><ul>${r.consent.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul><label><input type="checkbox" id="agree" ${r.consent.agreed ? 'checked disabled' : ''}> 위 내용을 확인했고 동의합니다${r.consent.agreed ? ' (동의 기록됨)' : ''}</label><div class="actions"><button class="btn primary" data-mode="external" ${r.current === 'external' ? 'disabled' : ''}>외부 배포 선택</button></div></div></div><div id="modeJob"></div>`;
el.querySelectorAll('[data-mode]').forEach((b) => { b.onclick = async () => { const md = b.dataset.mode; try { if (md === 'external' && !r.consent.agreed) { if (!$('#agree').checked) return aw.toast('확인 사항에 동의해야 합니다'); await aw.api('POST', `/api/sites/${sid}/consent`, { version: r.consent.version, agree: true }); } if (r.current && r.current !== md && !(await aw.confirmDanger({ title: '배포 방식 변경', target: `${r.current} → ${md}`, impacts: ['새 위치 준비 → 별도 배포 → 검증 → 도메인 전환 → 이전 위치 정리 순서로 진행합니다', '완료 전까지 현재 위치가 유지되고, 실패하면 되돌립니다'] }))) return; const r2 = await aw.api('POST', `/api/sites/${sid}/mode`, { mode: md }); if (r2.job) { aw.toast('배포 방식 변경 작업을 시작했습니다'); aw.job($('#modeJob'), r2.job, { onAction: jobAction(sid) }); } else { aw.toast('배포 방식을 저장했습니다'); location.href = `/sites/${sid}?tab=deploy`; } } catch (e) { aw.toast(e.message); } }; }); } catch (e) { onErr(el, e, mode); }
}
// ---------- 외부 서버 ----------
async function external() {
const el = $('#external'); const sid = siteId();
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/external`); const a = r.agent; const st = a ? ({ connected: ['ok', '연결됨'], pending: ['warning', '연결 대기'], offline: ['failed', '응답 없음'], revoked: ['neutral', '해제됨'] }[a.status] || ['neutral', a.status]) : null;
el.innerHTML = `<div class="card"><h2>External Pull Agent</h2>${!r.consent.agreed ? `<p class="aw-state partial"><b>동의 필요</b> 외부 배포 확인 사항에 먼저 동의하세요. <a href="/sites/${sid}/deploy/mode">배포 방식 화면</a></p>` : ''}<dl class="kv"><dt>연결 상태</dt><dd>${a ? badge(st[0], st[1]) + (a.stale ? ` <span class="aw-state stale" style="padding:2px 6px">${esc(a.staleReason)}</span>` : '') : '없음'}</dd>${a ? `<dt>이름</dt><dd>${esc(a.label || '-')}</dd><dt>마지막 응답</dt><dd>${a.last_seen_at ? esc(fmt(a.last_seen_at)) : '-'}</dd><dt>Agent 버전</dt><dd>${esc(a.agent_version || '-')}</dd><dt>마지막 배포</dt><dd>${a.lastDeploy ? esc(JSON.stringify(a.lastDeploy)).slice(0, 200) : '-'}</dd><dt>마지막 외부 확인</dt><dd>${a.lastCheck ? esc(JSON.stringify(a.lastCheck)).slice(0, 200) : '제공되지 않음'}</dd>` : ''}</dl>
<div class="actions">${!a || a.status !== 'connected' ? `<button class="btn primary" id="btnIssue" ${r.consent.agreed ? '' : 'disabled'}>연결 토큰 발급</button>` : ''}${a && a.status === 'pending' ? '<button class="btn" id="btnCheck">연결 확인</button>' : ''}${a && a.status === 'connected' ? `<a class="btn primary" href="/sites/${sid}?tab=deploy">배포 / 재배포</a><button class="btn danger" id="btnRemove">배포 제거</button>` : ''}${a && a.status !== 'revoked' ? '<button class="btn danger" id="btnRevoke">Agent 해제</button>' : ''}</div><div id="issued"></div></div>
<div class="card"><h2>설치 안내</h2><ol><li>고객 서버(지원 Linux)에 root 또는 sudo 계정으로 접속합니다.</li><li>"연결 토큰 발급" 을 누르면 설치 명령이 한 번만 표시됩니다(토큰은 10분 유효, 1회용). 복사해 서버에서 실행하세요.</li><li>Agent 가 HAL 에 연결되면 상태가 "연결됨" 으로 바뀝니다. 그 뒤 배포 탭에서 배포합니다.</li><li>실패한 배포의 남은 파일·실행 상태는 "배포 제거" 로 정리할 수 있습니다.</li></ol><p class="hint">외부 서버의 운영·보안·장애 대응은 고객 책임입니다. Ariweb 는 배포·재배포·제거와 마지막 외부 확인 결과만 제공합니다.</p></div>`;
const bi = $('#btnIssue'); if (bi) bi.onclick = async () => { try { const r2 = await aw.api('POST', `/api/sites/${sid}/external`, { label: prompt('서버 이름(표시용)', '') || '' }); $('#issued').innerHTML = `<p class="aw-state partial"><b>한 번만 표시됩니다</b> 아래 명령을 복사해 서버에서 실행하세요 (만료 ${esc(fmt(r2.expiresAt))})</p><pre class="mono" style="white-space:pre-wrap">${esc(r2.installCommand)}</pre><button class="btn copy" data-action="copy" data-copy="${esc(r2.installCommand)}">복사</button>`; } catch (e) { aw.toast(e.message); } };
const bc = $('#btnCheck'); if (bc) bc.onclick = load;
const br = $('#btnRevoke'); if (br) br.onclick = async () => { if (!(await aw.confirmDanger({ title: 'Agent 해제', target: a.label || a.hal_agent_id, impacts: ['이 서버로의 배포·재배포·제거가 더 이상 되지 않습니다', '이미 배포된 파일은 서버에 남습니다(고객이 직접 정리)'] }))) return; try { await aw.api('DELETE', `/api/sites/${sid}/external`); load(); } catch (e) { aw.toast(e.message); } };
const bm = $('#btnRemove'); if (bm) bm.onclick = async () => { if (!(await aw.confirmDanger({ title: '외부 서버 배포 제거', target: a.label || a.hal_agent_id, impacts: ['외부 서버의 사이트 파일과 실행 상태를 제거합니다', '남은 파일 목록이 결과에 표시됩니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/external/remove`); aw.toast('제거 작업을 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
} catch (e) { onErr(el, e, load); } };
load();
}
// ---------- 사용량·활동·설정·도메인 ----------
async function usage() { const el = $('#usage'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/usage`); if (r.overview.kind !== 'managed') return aw.state(el, 'empty', r.overview.kind === 'external' ? '외부 배포 사이트에는 관리형 사용량이 제공되지 않습니다' : '정상 배포되지 않은 사이트에는 운영 사용량을 표시하지 않습니다'); el.innerHTML = `<div class="card"><h2>배정과 사용</h2><table class="r"><thead><tr><th>자원</th><th>배정</th><th>사용</th><th>마지막 계측</th></tr></thead><tbody>${r.allocations.map((a) => `<tr><td data-l="자원">${esc(a.label)}</td><td data-l="배정">${a.allocation ? B(a.allocation.amount) : '미배정'}</td><td data-l="사용">${a.usage ? B(a.usage.used) : '-'}</td><td data-l="계측">${a.usage ? esc(fmt(a.usage.measured_at)) : '-'}</td></tr>`).join('')}</tbody></table><p class="hint">배정량 변경은 <a href="/usage">사용량 화면</a>에서 합니다.</p></div><div class="card"><h2>최근 계측값</h2><table class="r"><thead><tr><th>항목</th><th>값</th><th>기간</th><th>시각</th></tr></thead><tbody>${r.samples.slice(0, 60).map((s) => `<tr><td data-l="항목">${esc(s.metric)}</td><td data-l="값">${/bytes/.test(s.metric) ? B(s.value) : s.value}</td><td data-l="기간">${esc(s.period || '')}</td><td data-l="시각">${esc(fmt(s.measured_at))}</td></tr>`).join('') || '<tr><td colspan="4">수집 전</td></tr>'}</tbody></table></div>`; } catch (e) { onErr(el, e, usage); } }
async function activity() { const el = $('#activity'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/activity`); if (!r.items.length && !r.jobs.length) return aw.state(el, 'empty', '기록이 없습니다'); el.innerHTML = `<div class="card"><h2>작업</h2>${r.jobs.map((j) => `<p><b>${esc(j.label)}</b> ${esc(aw.JOB_LABEL[j.status] || j.status)} <span class="sub">${esc(fmt(j.updatedAt))}</span></p>`).join('') || '<p class="muted">없음</p>'}</div><div class="card"><h2>활동 기록</h2><table class="r"><thead><tr><th>시각</th><th>누가</th><th>무엇</th><th>결과</th></tr></thead><tbody>${r.items.map((a) => `<tr><td data-l="시각">${esc(fmt(a.at))}</td><td data-l="누가">${a.automatic ? '자동' : esc(a.actor_label || a.actor_id || '-')}</td><td data-l="무엇">${esc(a.action)}</td><td data-l="결과">${esc(a.outcome)}</td></tr>`).join('')}</tbody></table></div>`; } catch (e) { onErr(el, e, activity); } }
async function settings() { const f = $('#settings'); const sid = siteId(); f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); const body = { name: fd.get('name'), description: fd.get('description'), lang: fd.get('lang'), timezone: fd.get('timezone'), runtime: fd.get('runtime'), maintenance: fd.get('maintenance') === 'on', searchVisible: fd.get('searchVisible') === 'on' }; if (body.maintenance !== (f.dataset.maint === '1') && $('[data-lifecycle]')?.dataset.lifecycle === 'live') { if (!(await aw.confirmDanger({ title: '유지보수 모드 변경', target: fd.get('name'), impacts: [body.maintenance ? '방문자에게 안내 페이지가 보이고 사이트를 볼 수 없습니다(예상 적용 1분 이내)' : '즉시 다시 공개됩니다'] }))) return; } try { const r = await aw.api('PATCH', `/api/sites/${sid}`, body); aw.markClean(); aw.toast(r.impact.length ? r.impact.join(' ') : '저장했습니다'); } catch (err) { f.querySelector('[data-err=form]').textContent = err.message; } }; f.dataset.maint = f.querySelector('[name=maintenance]').checked ? '1' : '0';
const pe = $('#perms'); try { const r = await aw.api('GET', `/api/sites/${sid}/permissions`); pe.innerHTML = r.members && r.members.length ? `<table class="r"><thead><tr><th>구성원</th><th>권한</th></tr></thead><tbody>${r.members.map((m) => `<tr><td data-l="구성원">${esc(m.alias || m.email)}</td><td data-l="권한">${(m.perms || []).join(', ') || '(그룹 역할 기준)'}</td></tr>`).join('')}</tbody></table><p class="hint">권한 변경은 사용자 그룹 설정 › 사이트별 권한에서 합니다.</p>` : '<p class="muted">사이트별 권한이 없습니다(그룹 역할 기준)</p>'; } catch (e) { aw.state(pe, e.status === 404 ? 'empty' : 'failed', e.status === 404 ? '사이트별 권한 기능은 사용자 그룹 설정에서 제공됩니다' : e.message); } }
async function siteDomains() { const el = $('#siteDomains'); const sid = siteId(); try { const r = await aw.api('GET', `/api/domains?site=${sid}`); if (!r.domains.length) return aw.state(el, 'empty', '연결된 도메인이 없습니다. 여러 도메인을 연결해도 운영 사이트 수는 하나만 씁니다'); el.innerHTML = `<table class="r"><thead><tr><th>도메인</th><th>대표</th><th>소유 확인</th><th>DNS</th><th>HTTPS</th><th>마지막 확인</th></tr></thead><tbody>${r.domains.map((d) => `<tr><td data-l="도메인"><a href="/domains/${d.id}">${esc(d.name)}</a></td><td data-l="대표">${d.is_primary ? '★ 대표' : `<button class="btn" data-primary="${d.id}">대표로 지정</button>`}</td><td data-l="소유">${esc(d.ownership_state)}</td><td data-l="DNS">${esc(d.dns_state)}</td><td data-l="HTTPS">${esc(d.cert_state)}</td><td data-l="확인">${d.last_checked_at ? esc(fmt(d.last_checked_at)) : '-'}</td></tr>`).join('')}</tbody></table>`; el.querySelectorAll('[data-primary]').forEach((b) => { b.onclick = async () => { const redirect = confirm('기존 대표 주소를 새 대표 도메인으로 이동(301) 처리할까요?'); try { await aw.api('POST', `/api/domains/${b.dataset.primary}/primary`, { redirectOld: redirect }); aw.toast('대표 도메인을 지정했습니다'); siteDomains(); } catch (e) { aw.toast(e.message); } }; }); } catch (e) { onErr(el, e, siteDomains); } }
// ---------- Source ----------
async function source() { const el = $('#source'); const sid = siteId(); const load = async () => { try { const r = await aw.api('GET', `/api/source/${sid}`); const o = r.option; const s = r.source;
if (!o) return (el.innerHTML = `<div class="card"><h2>Source 옵션</h2><p>${r.entitlement.unallocated > 0 ? 'Source 옵션 권리가 있습니다. 이 사이트에 활성화하면 Gitea 저장소·변경 이력·clone·export 를 쓸 수 있습니다.' : 'Source 옵션을 구매한 사이트에서 제공합니다. <a href="/billing">상품 및 결제</a>에서 옵션을 추가하세요.'}</p>${r.entitlement.unallocated > 0 ? '<button class="btn primary" id="btnAct">이 사이트에 Source 옵션 활성화</button>' : ''}</div>`, $('#btnAct') && ($('#btnAct').onclick = async () => { try { await aw.api('POST', `/api/source/${sid}/activate`); load(); } catch (e) { aw.toast(e.message); } }));
const stBadge = o.status === 'active' ? badge('ok', '활성') : o.status === 'readonly' ? badge('warning', '읽기 전용 보존') : badge('failed', '삭제 완료');
el.innerHTML = `<div class="card"><h2>Source 옵션 ${stBadge}</h2><dl class="kv">${o.status === 'active' ? `<dt>만료 예정</dt><dd>${o.expires_at ? esc(fmt(o.expires_at)) : '상품 갱신과 연동'}</dd>` : ''}${o.status === 'readonly' ? `<dt>읽기 전용 기간</dt><dd>${esc(fmt(o.readonly_until))} 까지 조회·clone·export 가능 (push·새 변경 불가)</dd><dt>삭제 예정일</dt><dd>${esc(fmt(o.delete_after))}</dd><dt>재활성화</dt><dd><a href="/billing">Source 옵션을 다시 구매</a>하면 삭제 전까지 복구됩니다</dd>` : ''}${o.status === 'deleted' ? `<dt>삭제 완료</dt><dd>${esc(fmt(o.deleted_at))} — 저장소 데이터는 복구할 수 없습니다</dd>` : ''}</dl></div>
${s ? `<div class="card"><h2>저장소</h2>${s.stale ? '<p class="aw-state stale"><b>정보 확인 지연</b> Webmaker/Gitea 상태를 지금 확인할 수 없습니다</p>' : ''}<dl class="kv"><dt>저장소</dt><dd>${esc(s.remote || '-')} (${esc(s.provider)})</dd><dt>기본 브랜치</dt><dd class="mono">${esc(s.mainBranch || '-')} ${s.mainUrl ? `<a href="${esc(s.mainUrl)}" target="_blank" rel="noopener">열기</a>` : ''}</dd><dt>선택한 revision</dt><dd class="mono">${esc(r.selectedRevision || '현재 작업 트리')}</dd><dt>마지막 배포 revision</dt><dd class="mono">${esc(r.lastDeployedRevision || '-')} ${r.lastDeployedVersion ? `(배포 버전 ${esc(r.lastDeployedVersion)})` : ''}</dd><dt>저장소 사용량</dt><dd>${B(s.repoBytes)} / 한도 ${r.limits ? B(r.limits.repoBytes) : '상품정책'}</dd><dt>LFS</dt><dd>${s.lfsSupported ? B(s.lfsBytes) : '제공되지 않음(이번 버전)'}</dd><dt>첨부 파일</dt><dd>${B(s.uploadBytes)}</dd><dt>clone</dt><dd class="mono">${esc(r.cloneInfo || '-')}</dd></dl><div class="actions">${o.status !== 'deleted' ? `<a class="btn" href="/api/source/${sid}/export" target="_blank">export (tar.gz)</a>` : ''}${o.status === 'active' ? `<button class="btn" id="btnUnlink">저장소 연결 해제</button>` : ''}</div></div>
<div class="card"><h2>변경 이력</h2>${s.changes && s.changes.length ? `<table class="r"><thead><tr><th>#</th><th>종류</th><th>메시지</th><th>시각</th><th>브랜치</th><th>배포</th></tr></thead><tbody>${s.changes.slice().reverse().map((c) => `<tr><td data-l="#">${c.no}</td><td data-l="종류">${esc(c.kind)}</td><td data-l="메시지">${esc(c.message || '')}</td><td data-l="시각">${esc(fmt(c.at))}</td><td data-l="브랜치" class="mono">${esc(c.branch || '')} ${c.pushed ? '' : '(push 대기)'}</td><td data-l="배포">${r.deployedCommits && r.deployedCommits[c.commit] ? '✔ ' + esc(r.deployedCommits[c.commit]) : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">변경 이력이 없습니다</p>'}</div>` : ''}`;
const bu = $('#btnUnlink'); if (bu) bu.onclick = async () => { if (!(await aw.confirmDanger({ title: '저장소 연결 해제', target: s.remote, impacts: ['새 변경이 Gitea 에 push 되지 않습니다', '30일 동안 read-only 조회·clone·export 가 가능하고 그 뒤 삭제됩니다'] }))) return; try { await aw.api('POST', `/api/source/${sid}/unlink`); load(); } catch (e) { aw.toast(e.message); } };
} catch (e) { onErr(el, e, load); } }; load(); }
const page = document.querySelector('[data-page]')?.dataset.page;
({ 'sites-list': list, 'sites-new': create, 'site-overview': overview, 'site-build': build, 'site-deploy': deploy, 'site-mode': mode, 'site-external': external, 'site-usage': usage, 'site-activity': activity, 'site-settings': settings, 'site-domains': siteDomains, 'site-source': source }[page] || (() => {}))();
})();
+459
View File
@@ -0,0 +1,459 @@
/**
* portal/sites/service.mjs — 사이트·배포 업무 규칙(개발 지시 §7·§8·§10·§11·§13.1·§35·§36).
*
* 이 모듈이 지키는 규칙
* - 초안(draft) 사이트는 운영 사이트 수를 쓰지 않는다. 최초 공개 배포 직전에 lib/quota 로 1 을 **예약**(site_count_state='reserved')하고,
* 배포와 대표 도메인 확인이 끝나면 **확정**(in_use)한다. 취소·실패 정리는 예약을 **반환**한다(§5.1·§11.2).
* - 배포는 lib/jobs 의 내구성 작업(kind 'site.deploy')이다. 화면을 닫아도 계속되고, 같은 사이트·저장본의 요청은 멱등 키로 한 번만 만들어진다(§36).
* 실제 서버 작업은 HAL(docs/HAL-JOBS.md)에 맡기고 결과(실제 실행 버전·내부/외부 확인)를 받아야만 완료로 표시한다(§32.2 "명령 성공만으로 완료하지 않는다").
* - 배포 완료는 세 단계로 구분한다: verified(배포 검증 완료) → needs_domain(도메인 설정 필요) → public(공개 운영 중)(§11.2).
* - 외부 배포 사이트에는 관리형 지표(uptime·디스크·트래픽·백업 보호)를 보이지 않고 '제공되지 않음' 으로 둔다(§8.1).
* - Webmaker 프로젝트(webmaker_project_id)와 운영 사이트는 다른 것이다. 프로젝트 삭제는 포털 사이트를 지우지 않는다(impactForProject).
*
* HAL site.deploy 작업의 자식 단계 id(Agent 가 보고): apply · data_check · start · internal_check · external_check — 포털의 10단계(§11.2) 4~8 에 대응.
*/
import { newId, randomToken, sha256 } from '../../lib/crypto.mjs';
import { HttpError } from '../../lib/http.mjs';
import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
import * as quota from '../../lib/quota.mjs';
import { JobError, describe as describeJob } from '../../lib/jobs.mjs';
import { notify } from '../../lib/notify.mjs';
import { audit } from '../../lib/audit.mjs';
import { SERVICE_STATE, DEPLOY_VERIFY, HEALTH, isStale } from '../../lib/status.mjs';
import { monthStart } from '../../lib/kst.mjs';
import { loginFor } from '../webmaker/adapter.mjs';
import { mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
/** 사용자용 10단계(§11.2). id 는 작업 steps 와 HAL 단계 대응에 쓴다. */
export const DEPLOY_STEPS = [
{ id: 'accept', title: '요청 접수' }, { id: 'prepare_version', title: '저장본 준비' }, { id: 'prepare_target', title: '배포 위치 준비' }, { id: 'apply', title: '파일과 설정 적용' },
{ id: 'data_check', title: '데이터베이스와 업로드 데이터 확인' }, { id: 'start', title: '서비스 시작' }, { id: 'internal_check', title: '내부 확인' }, { id: 'external_check', title: '외부 접속 확인' },
{ id: 'domain_https', title: '도메인과 HTTPS 확인' }, { id: 'done', title: '완료' },
];
export const EXTERNAL_CONSENT_VERSION = 'external-deploy-2026-10-0.1.1';
export const EXTERNAL_CONSENT = {
version: EXTERNAL_CONSENT_VERSION,
title: '외부 서버 배포 확인 사항',
items: ['외부 서버의 준비·보안·운영·장애 대응은 고객 책임입니다. Ariweb 는 External Pull Agent 를 통한 배포·재배포·제거와 마지막 외부 확인 결과만 제공합니다.',
'관리형 모니터링·백업·서버 이전·SLA 는 제공되지 않습니다. 외부 환경 장애는 Ariweb 서비스 정상 상태와 분리됩니다.',
'배포 건마다 건별 비용이 적용됩니다(상품 조건의 "외부 배포 건별 비용"). 승인된 금액은 배포 화면에 표시됩니다.',
'외부 배포 사이트도 공개 사이트이면 운영 사이트 수를 소비합니다. 관리형 디스크·트래픽·메일·백업 권리는 자동 할당되지 않습니다.',
'버전 0.1.1 에서는 외부 메일 배포를 지원하지 않습니다.'],
};
const RETURN_GRACE_MS = 24 * 3600 * 1000; // 실패한 중간 결과를 정리하지 않을 때 예약이 반환되는 유예(§11.2 "반환 예정 시각")
const USAGE_BUCKET_MS = 10 * 60 * 1000; // 사용량·건강 확인 작업을 10분 단위로 하나만 만든다(멱등)
const LIFECYCLE_MODIFIABLE = new Set(['draft', 'building', 'deploying', 'live', 'needs_domain', 'paused']);
export function createSitesService(deps) {
const { store, jobs, config, log = () => {} } = deps;
const hal = () => { if (!deps.hal) throw Object.assign(new Error('HAL 연결이 설정되지 않았습니다'), { code: 'service_unreachable' }); return deps.hal; };
const wm = () => { if (!deps.webmaker) throw Object.assign(new Error('Webmaker 연결이 설정되지 않았습니다'), { code: 'service_unreachable' }); return deps.webmaker; };
const bundleDir = join(config.dataDir, 'bundles');
const row = (r) => (r ? { ...r, settings: parseJson(r.settings_json, {}), pendingCleanup: parseJson(r.pending_cleanup_json, null), lastExternalCheck: parseJson(r.last_external_check_json, null), nextJob: parseJson(r.next_job_json, null) } : null);
const dep = (r) => (r ? { ...r, result: parseJson(r.result_json, null) } : null);
const log2 = (...a) => log('[sites]', ...a);
function get(id) { return row(store.get('SELECT * FROM sites WHERE id=?', id)); }
function getForGroup(groupId, id) { const s = get(id); return s && s.group_id === groupId ? s : null; }
function save(id, patch) { const sets = []; const vals = []; for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v && typeof v === 'object' ? toJson(v) : v); } sets.push('updated_at=?'); vals.push(nowIso()); store.run(`UPDATE sites SET ${sets.join(', ')} WHERE id=?`, ...vals, id); return get(id); }
function userLogin(user, group) { return loginFor(user, group); }
// ------------------------------------------------------------------ 목록·생성·설정
/** 사이트 목록(§7.1). lifecycle 필터: draft|building|deploying|live|paused|cancel_pending */
function list(groupId, { lifecycle = null } = {}) {
const map = { draft: ['draft'], building: ['building'], deploying: ['deploying'], live: ['live', 'needs_domain'], paused: ['paused'], cancel_pending: ['cancel_scheduled', 'readonly_retention', 'pending_delete', 'delete_hold'] };
const states = lifecycle ? map[lifecycle] || [lifecycle] : null;
const rows = 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);
return rows.map((s) => summarize(s));
}
/** 목록 한 줄 요약: 메일·백업 옵션 여부, 마지막 배포, 건강, 사용자 조치 필요 */
function summarize(s) {
const mail = store.get('SELECT id, status FROM mail_services WHERE site_id=? AND deleted_at IS NULL', s.id) || null;
const backup = store.get('SELECT id, status FROM backup_options WHERE site_id=?', s.id) || null;
const lastDeploy = dep(store.get('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT 1', s.id));
const actions = actionNeeded(s, lastDeploy);
return { id: s.id, name: s.name, primaryDomain: s.primary_domain, lifecycle: s.lifecycle, deployMode: s.deploy_mode, runtime: s.runtime, buildState: buildState(s), deployState: lastDeploy ? { status: lastDeploy.status, verify: lastDeploy.verify_state, at: lastDeploy.created_at, version: lastDeploy.version_label || lastDeploy.version_ref } : null,
mail: mail ? { id: mail.id, status: mail.status } : null, backup: backup ? { id: backup.id, status: backup.status } : null, lastDeployAt: s.last_deploy_at, health: s.deploy_mode === 'external' ? HEALTH.NOT_PROVIDED : (isStale(s.health_checked_at, 15 * 60 * 1000) && ['live', 'needs_domain'].includes(s.lifecycle) ? HEALTH.STALE : s.health),
siteCountState: s.site_count_state, actionNeeded: actions, webmakerProjectId: s.webmaker_project_id, updatedAt: s.updated_at, createdAt: s.created_at };
}
function buildState(s) { if (!s.webmaker_project_id) return 'no_project'; return s.lifecycle === 'draft' ? 'drafting' : 'ready'; }
/** 사용자가 해야 할 일(§7.1 "조치 필요"). 링크는 조치 화면 */
function actionNeeded(s, lastDeploy) {
const out = [];
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') { const a = 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` });
return out;
}
/**
* 사이트 생성(§7.2). 초안이며 사이트 수를 쓰지 않는다. projectMode: new(Webmaker 프로젝트를 새로 만들어 연결) | existing(내 프로젝트 연결) | none(나중에).
*/
async function create({ user, group, ip = '' }, input) {
const name = String(input.name || '').trim().slice(0, 120); if (!name) throw new HttpError(400, 'name_required', '사이트 이름을 입력하세요', { field: 'name' });
const purpose = String(input.purpose || '').trim().slice(0, 300);
const lang = /^[a-z]{2}(-[A-Z]{2})?$/.test(input.lang || '') ? input.lang : 'ko'; const timezone = String(input.timezone || 'Asia/Seoul').slice(0, 60);
const deployMode = ['managed', 'external'].includes(input.deployMode) ? input.deployMode : null;
const projectMode = ['new', 'existing', 'none'].includes(input.projectMode) ? input.projectMode : 'new';
const id = newId('s_'); const now = nowIso(); const login = userLogin(user, group);
let projectId = null; let projectNote = null;
if (projectMode === 'existing') {
const pid = String(input.projectId || ''); if (!/^[a-f0-9]{32}$/.test(pid)) throw new HttpError(400, 'project_required', '연결할 Webmaker 프로젝트를 고르세요', { field: 'projectId' });
const p = await wm().getProject(pid).catch((e) => { throw new HttpError(e.status === 404 ? 404 : 502, e.code || 'webmaker_error', e.status === 404 ? '프로젝트를 찾을 수 없습니다' : 'Webmaker 에 연결할 수 없어 프로젝트를 확인하지 못했습니다'); });
if (p.ownerId !== login) throw new HttpError(403, 'project_not_owned', '내 Webmaker 계정의 프로젝트만 연결할 수 있습니다');
if (p.link && p.link.siteId && get(p.link.siteId)) throw new HttpError(409, 'project_linked', `이 프로젝트는 이미 다른 사이트(${p.link.siteName || p.link.siteId})에 연결되어 있습니다`);
const other = store.get('SELECT id FROM sites WHERE webmaker_project_id=? AND lifecycle<>\'deleted\'', pid); if (other) throw new HttpError(409, 'project_linked', '이 프로젝트는 이미 다른 사이트에 연결되어 있습니다');
projectId = pid;
}
store.run('INSERT INTO sites(id, group_id, name, purpose, webmaker_project_id, template, lang, timezone, lifecycle, deploy_mode, site_count_state, health, runtime, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,\'draft\',?,\'none\',\'unknown\',?,?,?,?)',
id, group.id, name, purpose, projectId, String(input.template || '').slice(0, 80) || null, lang, timezone, deployMode, ['static', 'node'].includes(input.runtime) ? input.runtime : 'static', user.id, now, now);
const link = { siteId: id, groupId: group.id, siteName: name, groupName: group.name, portalUrl: config.publicUrl };
try {
if (projectMode === 'new') { const p = await wm().createProject({ login, ownerName: user.display_name || user.email, title: name, mode: ['ai', 'manual', 'quick'].includes(input.buildMode) ? input.buildMode : 'manual', purpose, link }); projectId = p.id; save(id, { webmaker_project_id: projectId }); }
else if (projectMode === 'existing') await wm().linkProject(projectId, link);
} catch (e) { projectNote = e.code === 'service_unreachable' ? 'Webmaker 에 연결할 수 없어 프로젝트를 만들지 못했습니다. 제작 탭에서 다시 시도하세요' : `프로젝트 연결 실패: ${e.message}`; log2(projectNote); }
audit(store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'site', subjectId: id, action: 'site.create', detail: { name, projectMode, projectId, deployMode }, ip });
return { site: get(id), nextSteps: nextSteps(get(id)), projectNote };
}
/** 생성 후 안내할 다음 단계(§7.2) */
function nextSteps(s) {
const steps = [{ 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 }];
return steps;
}
/** 설정 변경(§8.2). 서비스 중단이 예상되는 항목(유지보수 모드·검색 공개)은 impact 를 함께 돌려준다 */
function update(site, patch, actor) {
if (!LIFECYCLE_MODIFIABLE.has(site.lifecycle)) throw new HttpError(409, 'site_not_modifiable', `지금 상태(${site.lifecycle})에서는 설정을 바꿀 수 없습니다`);
const p = {}; const impact = [];
if (patch.name !== undefined) { const n = String(patch.name).trim().slice(0, 120); if (!n) throw new HttpError(400, 'name_required', '사이트 이름을 입력하세요', { field: 'name' }); p.name = n; }
if (patch.description !== undefined) p.description = String(patch.description).slice(0, 2000);
if (patch.lang !== undefined && /^[a-z]{2}(-[A-Z]{2})?$/.test(patch.lang)) p.lang = patch.lang;
if (patch.timezone !== undefined) p.timezone = String(patch.timezone).slice(0, 60);
if (patch.maintenance !== undefined) { p.maintenance = patch.maintenance ? 1 : 0; if (site.lifecycle === 'live' && p.maintenance !== site.maintenance) impact.push(p.maintenance ? '유지보수 모드를 켜면 공개 주소에 안내 페이지가 보이고 방문자는 사이트를 볼 수 없습니다(예상 적용 1분 이내)' : '유지보수 모드를 끄면 즉시 공개됩니다'); }
if (patch.searchVisible !== undefined) p.search_visible = patch.searchVisible ? 1 : 0;
if (patch.runtime !== undefined && ['static', 'node'].includes(patch.runtime)) p.runtime = patch.runtime;
if (Object.keys(p).length) { save(site.id, p); audit(store, { actorType: 'user', actorId: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'site.settings', detail: p }); }
if (p.maintenance !== undefined && ['live', 'needs_domain'].includes(site.lifecycle) && site.deploy_mode === 'managed' && site.server_id) {
// 유지보수 모드는 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 안내 페이지, 삭제는 프로젝트 연결 해제 */
function setLifecycle(siteId, lifecycle, { actor = null, extra = {} } = {}) {
if (!Object.values(SERVICE_STATE).includes(lifecycle)) throw new HttpError(400, 'bad_lifecycle', `알 수 없는 상태: ${lifecycle}`);
const before = get(siteId); if (!before) throw new HttpError(404, 'not_found', '사이트가 없습니다');
const p = { lifecycle, ...extra }; if (lifecycle === 'paused') p.paused_at = nowIso(); if (lifecycle === 'deleted') p.deleted_at = nowIso();
const after = save(siteId, p);
audit(store, { actorType: actor ? 'user' : 'system', actorId: actor?.id, groupId: before.group_id, subjectType: 'site', subjectId: siteId, action: `site.lifecycle.${lifecycle}`, detail: { from: before.lifecycle, to: lifecycle }, automatic: !actor, immutable: true });
return after;
}
/** 운영 사이트 수 반환 완료(§14 조건은 호출자가 검사) → 배정 삭제·상태 none */
function returnSiteCount(siteId, actor = null) {
const s = get(siteId); if (!s) return null;
const a = quota.allocationOf(store, s.group_id, 'sites', 'site', s.id); if (a) quota.finishReturn(store, a.id);
if (s.site_count_reservation_id) quota.release(store, s.site_count_reservation_id);
audit(store, { actorType: actor ? 'user' : 'system', actorId: actor?.id, groupId: s.group_id, subjectType: 'site', subjectId: s.id, action: 'site_count.returned', detail: { allocation: a ? a.id : null } });
return save(siteId, { site_count_state: 'none', site_count_reservation_id: null, return_due_at: null });
}
// ------------------------------------------------------------------ Webmaker 연동
function buildUrl({ user, group, site, path = null }) { return wm().ssoUrl({ user, group, site, projectId: site.webmaker_project_id, path }); }
async function versionsFor(site) { if (!site.webmaker_project_id) return { project: null, versions: [] }; const p = await wm().getProject(site.webmaker_project_id); return { project: p, versions: (p.versionList || []).slice().reverse() }; }
/** Webmaker 프로젝트 삭제 영향(Webmaker → 포털 /internal/webmaker/project-impact). blocked 면 Webmaker 가 폐기를 거부한다 */
function impactForProject(projectId, siteId = null) {
const s = 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: '연결된 운영 사이트가 없습니다' };
const n = store.get('SELECT COUNT(*) AS n FROM deployments WHERE site_id=?', s.id).n; const domains = store.all('SELECT name FROM domains WHERE site_id=?', s.id).map((d) => d.name);
const live = ['live', 'needs_domain', 'deploying', 'paused'].includes(s.lifecycle);
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 가 프로젝트를 폐기한 뒤 연결 해제 */
function onProjectDestroyed(projectId) { const s = row(store.get('SELECT * FROM sites WHERE webmaker_project_id=?', projectId)); if (s) save(s.id, { webmaker_project_id: null }); return !!s; }
// ------------------------------------------------------------------ 배포 전 확인(§11.1)
function activeJobsFor(siteId) { return jobs.list({ subjectType: 'site', subjectId: siteId, status: ['queued', 'running', 'awaiting_external', 'verifying', 'reverting', 'needs_user'], limit: 20 }); }
function externalAgentFor(siteId) { const a = store.get('SELECT * FROM external_agents WHERE site_id=? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1', siteId); return a ? { ...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 이 없을 때.
*/
async function precheck(site, { versionRef = null } = {}) {
const items = []; const add = (id, title, state, text, href = null) => items.push({ id, title, state, text, href });
// 1 저장본
let project = null; let bundleBytes = 0;
if (!site.webmaker_project_id) add('version', '배포할 저장본', 'block', 'Webmaker 프로젝트가 연결되어 있지 않습니다', `/sites/${site.id}?tab=build`);
else { try { project = await wm().getProject(site.webmaker_project_id); const vlist = project.versionList || []; const v = 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`); else if (!project.hasContent && !v) add('version', '배포할 저장본', 'block', '아직 제작된 페이지가 없습니다. Webmaker 에서 홈 페이지를 만들고 저장하세요', `/sites/${site.id}?tab=build`); else add('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}`); } }
// 2 운영 사이트 수
const f = quota.figures(store, site.group_id, 'sites');
if (site.site_count_state === 'in_use') add('sites', '운영 사이트 수', 'pass', '이 사이트는 이미 운영 사이트 수를 사용 중입니다');
else if (site.site_count_state === 'reserved') add('sites', '운영 사이트 수', 'pass', '예약됨(배포와 대표 도메인 확인 뒤 사용 중으로 전환)');
else if (f.unallocated > 0) add('sites', '운영 사이트 수', 'pass', `사용 가능 ${f.unallocated}개 (구매 ${f.purchased} · 사용 중 ${f.allocated} · 예약 ${f.reserved}) — 배포 시작 때 1개를 예약합니다`);
else add('sites', '운영 사이트 수', 'block', f.purchased === 0 ? '운영 사이트 수를 포함한 상품을 먼저 구매·승인받아야 합니다' : `운영 사이트 수가 모두 사용 중입니다(구매 ${f.purchased}). 상품을 추가하거나 다른 사이트의 사이트 수를 반환하세요`, '/billing');
// 3 배포 위치
if (!site.deploy_mode) add('target', '배포 위치', 'block', '배포 방식을 먼저 선택하세요', `/sites/${site.id}/deploy/mode`);
else if (site.deploy_mode === 'managed') { try { const picked = site.server_id ? { serverId: site.server_id } : await hal().pickServer({ role: 'web', needBytes: bundleBytes }); if (picked) add('target', '배포 위치', 'pass', site.server_id ? '현재 운영 서버에 배포합니다' : '배치 가능한 관리형 서버가 있습니다'); else add('target', '배포 위치', 'block', '지금 신규 배치를 받을 수 있는 서버가 없습니다. 잠시 후 다시 시도하거나 문의하세요'); } catch (e) { add('target', '배포 위치', 'block', 'HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정보 확인 지연). 기존 서비스는 유지되며 신규 배포는 복구 후 가능합니다'); } }
else { const a = externalAgentFor(site.id); if (a && a.status === 'connected') add('target', '배포 위치', 'pass', `외부 서버 Agent 연결됨 (${a.label || a.hal_agent_id})`); else add('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`); else add('consent', '외부 배포 확인 사항 동의', 'pass', `동의함 (${site.consent_version})`); }
// 4 디스크·DB 용량(관리형만)
if (site.deploy_mode === 'managed') { const alloc = quota.allocationOf(store, site.group_id, 'web_disk', 'site', site.id); const wf = quota.figures(store, site.group_id, 'web_disk'); if (alloc && alloc.amount > 0) add('capacity', '디스크와 데이터베이스 용량', 'pass', `배정 ${fmtBytes(alloc.amount)} (사용 ${fmtBytes(usageOf(site, 'web_disk'))})`); else if (wf.unallocated > 0) add('capacity', '디스크와 데이터베이스 용량', 'warn', `이 사이트에 배정된 웹 디스크가 없습니다. 배포 시작 때 미배정량(${fmtBytes(wf.unallocated)})에서 기본량을 배정합니다`, '/usage'); else add('capacity', '디스크와 데이터베이스 용량', 'block', '배정할 수 있는 웹 디스크가 없습니다. 상품을 추가하거나 다른 사이트의 배정을 줄이세요', '/usage'); if (site.runtime === 'node') add('runtime', '동적 사이트 실행 형태', 'info', 'Ariweb 가 승인한 Node 실행 형태입니다. 필요한 저장 공간: 사이트 파일 + 업로드. 데이터베이스는 SQLite 파일만 지원(외부 DB·cron·메일 발송은 이번 버전에서 지원하지 않음)'); else add('runtime', '실행 형태', 'info', '정적 사이트(HTML·CSS·JS·이미지). 서버 측 코드는 실행되지 않습니다'); }
// 5 도메인 상태
const pd = 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 정상`); else if (pd) add('domain', '도메인 상태', 'warn', `대표 도메인 ${pd.name} 의 DNS/HTTPS 가 아직 완료되지 않았습니다(DNS ${pd.dns_state}, 인증서 ${pd.cert_state}). 배포는 진행되며 완료 상태는 "도메인 설정 필요" 가 됩니다`, `/domains/${pd.id}`); else add('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`);
// 7 다른 작업과 충돌
const act = activeJobsFor(site.id).filter((j) => ['site.deploy', 'site.rollback', 'site.mode_change', 'backup.restore', 'site.relocate', 'site.cleanup'].includes(j.kind));
if (act.length) add('conflict', '다른 작업과의 충돌', 'block', `진행 중인 작업이 있습니다: ${act.map((j) => `${j.label}(${j.status})`).join(', ')}`, `/sites/${site.id}?tab=deploy`); else add('conflict', '다른 작업과의 충돌', 'pass', '진행 중인 배포·복원·이전 작업이 없습니다');
return { ok: !items.some((i) => i.state === 'block'), items, project: project ? { id: project.id, title: project.title, rev: project.rev, hasContent: project.hasContent } : null };
}
function usageOf(site, resource) { const u = quota.usageOf(store, site.group_id, resource, 'site', site.id); return u ? u.used : 0; }
// ------------------------------------------------------------------ 배포 시작·작업
/**
* 배포 시작(§11). 사전 확인 통과 → (최초 공개 배포면) 사이트 수 예약 → 웹 디스크 기본 배정 → deployments 행 → 작업 enqueue(멱등).
*/
async function startDeploy({ user, group, site, versionRef = 'current', versionLabel = null, ip = '' }) {
// 멱등: 같은 사이트·저장본·배포 순번의 요청(버튼 연타·새로 고침)은 기존 작업을 돌려준다 — 사전 확인보다 먼저 본다(진행 중 작업 충돌로 409 가 나지 않게)
const seq = store.get('SELECT COUNT(*) AS n FROM deployments WHERE site_id=?', site.id).n;
const idem = `site.deploy:${site.id}:${versionRef}:${seq}`;
const existing = 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) { const d = dep(store.get('SELECT * FROM deployments WHERE job_id=?', existing.id)); return { deployment: d, job: describeJob(jobs.get(existing.id)), created: false }; }
const pc = await precheck(site, { versionRef });
if (!pc.ok) throw new HttpError(409, 'precheck_failed', '배포 전 확인에서 차단된 항목이 있습니다', { items: pc.items });
const now = nowIso(); const did = newId('d_');
let reserved; try { reserved = store.tx(() => {
let resId = site.site_count_reservation_id; let state = site.site_count_state;
if (state === 'none') { const r = quota.reserve(store, { groupId: group.id, resource: 'sites', amount: 1, subjectType: 'site', subjectId: site.id, reason: '최초 공개 배포', ttlMs: 0 }); resId = r.id; state = 'reserved'; }
if (site.deploy_mode === 'managed' && !quota.allocationOf(store, group.id, 'web_disk', 'site', site.id)) { const wf = quota.figures(store, group.id, 'web_disk'); const def = Math.min(wf.unallocated, defaultDiskBytes(group.id)); if (def > 0) quota.setAllocation(store, { groupId: group.id, resource: 'web_disk', subjectType: 'site', subjectId: site.id, amount: def }); }
store.run('INSERT INTO deployments(id, site_id, group_id, version_ref, version_label, mode, target_id, status, previous_version, started_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,\'queued\',?,?,?,?)', did, site.id, group.id, String(versionRef), versionLabel, site.deploy_mode, site.deploy_mode === 'managed' ? site.server_id : (externalAgentFor(site.id) || {}).hal_agent_id, site.current_version, user.id, now, now);
save(site.id, { site_count_state: state, site_count_reservation_id: resId, lifecycle: site.lifecycle === 'draft' || site.lifecycle === 'building' ? 'deploying' : site.lifecycle });
return { resId, state };
}); } catch (e) {
// 동시에 다른 사이트가 마지막 단위를 가져갔다(§13.3): 사전 확인과 같은 모양으로 부족 사유를 돌려준다
if (e instanceof quota.QuotaError) throw new HttpError(409, 'precheck_failed', '배포 전 확인에서 차단된 항목이 있습니다', { items: pc.items.map((i) => (i.id === 'sites' || (i.id === 'capacity' && e.detail && e.detail.resource === 'web_disk') ? { ...i, state: 'block', text: e.message + (e.detail && e.detail.shortage ? ` (부족 ${e.detail.shortage})` : ''), href: i.id === 'sites' ? '/billing' : '/usage' } : i)) });
throw e;
}
const { job } = jobs.enqueue({ kind: 'site.deploy', subject: { siteId: site.id, deploymentId: did, versionRef: String(versionRef), versionLabel, mode: site.deploy_mode }, idempotencyKey: idem, createdBy: user.id, actorType: 'user', groupId: group.id, subjectType: 'site', subjectId: site.id, label: `사이트 배포 (${versionLabel || (versionRef === 'current' ? '현재 저장 상태' : '저장본 #' + versionRef)})`, maxAttempts: 3 });
store.run('UPDATE deployments SET job_id=? WHERE id=?', job.id, did);
audit(store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'site', subjectId: site.id, action: 'deploy.start', detail: { deploymentId: did, versionRef, mode: site.deploy_mode, siteCount: reserved.state }, ip });
return { deployment: dep(store.get('SELECT * FROM deployments WHERE id=?', did)), job: describeJob(job), created: true };
}
function defaultDiskBytes(groupId) { const s = store.get('SELECT value_json FROM settings WHERE key=\'sites.defaultDiskBytes\''); return s ? Number(parseJson(s.value_json, 0)) || 1024 ** 3 : 1024 ** 3; }
/** 다운로드 토큰 만들기(Agent 가 저장본을 받아 갈 서명 URL). 1시간 유효 */
function issueBundleToken(siteId, deploymentId, file, bytes) { const token = randomToken(24); store.run('INSERT INTO bundle_tokens(id, token_hash, site_id, deployment_id, file, bytes, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?)', newId('bt_'), sha256(token), siteId, deploymentId, file, bytes, nowIso(), new Date(Date.now() + 3600000).toISOString()); return `${config.publicUrl.replace(/\/$/, '')}/internal/bundles/${token}`; }
function bundleByToken(token) { const t = store.get('SELECT * FROM bundle_tokens WHERE token_hash=?', sha256(String(token || ''))); if (!t || Date.parse(t.expires_at) < Date.now()) return null; store.run('UPDATE bundle_tokens SET used_at=? WHERE id=?', nowIso(), t.id); return t; }
/**
* 단계에 묶인 HAL 작업 보장. 단계 결과(step.result.halJobId)에 HAL 작업 id 를 기억해 재진입(폴링 재개) 때 같은 작업을 보고,
* 그 HAL 작업이 실패·취소로 끝났는데 다시 실행되는 경우(사용자 재시도)에만 새 멱등 키로 새 HAL 작업을 만든다.
* @param {Function} factory (idemKey) => hal.createJob(...)
*/
async function halJobFor(ctx, job, stepId, factory, { fresh = false } = {}) {
const st = ctx.steps().find((x) => x.id === stepId); const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null;
// fresh: 사용자 재시도(이전 실행이 실패로 끝남) — 끝난 HAL 작업의 결과를 다시 쓰지 않고 새 작업을 만든다. 아니면 진행 중·완료된 이전 작업을 그대로 본다
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL(중앙 관리) 연결 대기 — 정보 확인 지연'); throw e; } if (!['failed', 'cancelled'].includes(hj.status)) return prev; }
const r = await factory(`portal:${job.id}:${stepId}${prev ? ':r' + job.attempt : ''}`);
if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); }
return r.halJobId;
}
/** HAL 작업을 포털 작업 단계에 비추고, 끝날 때까지 5초 간격으로 다시 깨어난다(재진입). */
async function awaitHal(ctx, halJobId, { mirror = [] } = {}) {
let hj; try { hj = await hal().getJob(halJobId); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL(중앙 관리) 연결 대기 — 정보 확인 지연'); throw e; }
if (mirror.length) { const st = ctx.steps(); for (const hs of hj.steps || []) { const ps = st.find((x) => x.id === hs.id); if (ps && ps.state !== 'done') { ps.state = hs.state === 'done' ? 'done' : hs.state === 'failed' ? 'failed' : hs.state === 'running' ? 'running' : ps.state; if (hs.detail) ps.detail = hs.detail; if (hs.state === 'done') ps.endedAt = hs.endedAt || nowIso(); } } ctx.flush(); }
if (typeof hj.progress === 'number') ctx.progress(Math.min(95, Math.max(10, hj.progress)));
if (['done'].includes(hj.status)) return hj;
if (['failed', 'cancelled', 'reverted'].includes(hj.status)) throw new JobError(hj.errorCode || 'hal_job_failed', hj.error || `서버 작업이 ${hj.status} 상태로 끝났습니다`);
if (hj.status === 'needs_user') ctx.needsUser(hj.error || '서버 작업에 운영자 확인이 필요합니다');
ctx.retryLater(5000, '배포 서버 작업 진행 중');
}
/** 배포 작업 종류 등록 */
jobs.register('site.deploy', { title: '사이트 배포', steps: DEPLOY_STEPS, maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
async run(job, ctx) {
const { siteId, deploymentId, versionRef } = job.subject; const site = get(siteId); if (!site) throw new JobError('site_missing', '사이트가 없습니다');
store.run('UPDATE deployments SET status=?, updated_at=? WHERE id=?', 'running', nowIso(), deploymentId);
await ctx.step('accept', () => ({ at: nowIso(), by: job.created_by }));
const bundle = await ctx.step('prepare_version', async () => { mkdirSync(bundleDir, { recursive: true, mode: 0o700 }); const file = join(bundleDir, `${siteId}-${deploymentId}.tar.gz`); const r = await wm().exportVersion(site.webmaker_project_id, versionRef === 'current' ? null : versionRef, file); const url = issueBundleToken(siteId, deploymentId, file, r.bytes); return { file, bytes: r.bytes, version: r.version, commit: r.commit, url }; });
const domains = 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 라 재진입해도 하나만 만들어진다
const target = await ctx.step('prepare_target', async () => {
if (site.deploy_mode === 'managed') { let serverId = site.server_id; if (!serverId) { const p = await hal().pickServer({ role: 'web', needBytes: bundle.bytes * 3 }); if (!p) throw new JobError('no_server', '지금 신규 배치를 받을 수 있는 서버가 없습니다', { retryable: true }); serverId = p.serverId; save(siteId, { server_id: serverId }); } return { kind: 'server', id: serverId }; }
const a = externalAgentFor(siteId); if (!a || a.status !== 'connected') throw new JobError('agent_not_connected', '외부 서버 Agent 가 연결되어 있지 않습니다'); return { kind: 'external', id: a.hal_agent_id };
});
const retrying = (job.steps || []).some((x) => x.state === 'failed'); // 이전 실행이 실패한 뒤의 재시도면 새 HAL 작업
const halJobId = await halJobFor(ctx, job, 'prepare_target', async (key) => { const r = await hal().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); return r; }, { fresh: retrying });
const hj = await awaitHal(ctx, halJobId, { mirror: ['apply', 'data_check', 'start', 'internal_check', 'external_check'] });
for (const id of ['apply', 'data_check', 'start', 'internal_check', 'external_check']) { const s = ctx.steps().find((x) => x.id === id); if (s && s.state !== 'done') { s.state = 'done'; s.endedAt = nowIso(); } } ctx.flush();
const res = hj.result || {};
const verify = await ctx.step('domain_https', async () => { const pd = site.primary_domain ? store.get('SELECT * FROM domains WHERE name=? AND site_id=?', site.primary_domain, siteId) : null; const ext = res.externalCheck && res.externalCheck.ok; const publicOk = !!(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}) 가 아직 완료되지 않았습니다` : '대표 도메인이 없어 플랫폼 확인 주소에서만 볼 수 있습니다' }; });
await ctx.step('done', () => { finishDeployment(site, deploymentId, { ...res, bundleCommit: bundle.commit || null, bundleVersion: bundle.version }, verify, job); return { version: res.version, verify: verify.state }; });
try { if (bundle.file && existsSync(bundle.file)) unlinkSync(bundle.file); } catch (_) {}
return { version: res.version, verify: verify.state, siteUrl: res.siteUrl, externalCheck: res.externalCheck };
},
async revert(job, ctx) { const { siteId, deploymentId } = job.subject; const site = get(siteId); const d = dep(store.get('SELECT * FROM deployments WHERE id=?', deploymentId)); if (!d || !d.previous_version) throw new JobError('no_previous', '되돌릴 이전 버전이 없습니다'); await ctx.step('rollback', async () => { const id = await halJobFor(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') }); const hj = await awaitHal(ctx, id); save(siteId, { current_version: d.previous_version }); store.run('UPDATE deployments SET status=\'reverted\', updated_at=? WHERE id=?', nowIso(), deploymentId); return hj.result; }); } });
/** 배포 완료 처리: deployments·sites 갱신, 사이트 수 확정(public 일 때), 알림·감사 */
function finishDeployment(site, deploymentId, res, verify, job) {
const now = nowIso(); const version = res.version || null;
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);
const patch = { current_version: version, current_deployment_id: deploymentId, deploy_verify: verify.state, last_deploy_at: now, health: res.externalCheck && res.externalCheck.ok ? HEALTH.OK : (res.internalCheck && res.internalCheck.ok ? HEALTH.OK : HEALTH.UNKNOWN), health_checked_at: now, last_external_check_json: res.externalCheck || null, pending_cleanup_json: null, return_due_at: null, lifecycle: verify.state === DEPLOY_VERIFY.PUBLIC ? 'live' : 'needs_domain' };
if (verify.state === DEPLOY_VERIFY.PUBLIC && site.site_count_state !== 'in_use') { const fresh = get(site.id); if (fresh.site_count_reservation_id) quota.commit(store, fresh.site_count_reservation_id); patch.site_count_state = 'in_use'; }
save(site.id, patch);
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 확인' });
audit(store, { actorType: 'system', groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'deploy.done', detail: { deploymentId, version, verify: verify.state, jobId: job.id }, automatic: true });
}
/** 작업 실패 시 배포 기록 갱신(jobs.onChange 가 호출) */
jobs.onChange((j) => {
if (j.kind !== 'site.deploy' || !['failed', 'cancelled'].includes(j.status)) return;
const { siteId, deploymentId } = j.subject; const site = get(siteId); if (!site) return;
const d = store.get('SELECT status FROM deployments WHERE id=?', deploymentId); if (!d || ['failed', 'done', 'cancelled'].includes(d.status)) return;
const now = 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);
const patch = { lifecycle: site.current_version ? site.lifecycle : 'building' };
// 최초 배포가 실패하면 예약은 남긴 채 '반환 예정 시각' 을 두고, 정리하지 않은 중간 결과를 표시한다(§11.2)
if (site.site_count_state === 'reserved') { patch.return_due_at = new Date(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: '재시도 또는 되돌리기' });
});
/** 중간 결과 정리 + 예약 반환(§11.2 "지금 정리하고 반환") */
function cleanupAndReturn(site, actor) {
if (site.site_count_state !== 'reserved') throw new HttpError(409, 'nothing_to_return', '반환할 예약이 없습니다');
if (activeJobsFor(site.id).some((j) => j.kind === 'site.deploy')) throw new HttpError(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: '중간 결과 정리와 사이트 수 반환' });
return describeJob(job);
}
jobs.register('site.cleanup', { title: '중간 결과 정리', steps: [{ id: 'remove', title: '배포 위치의 중간 결과 제거' }, { id: 'return', title: '사이트 수 예약 반환' }], maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
async run(job, ctx) { const { siteId, reservationId, targetId } = job.subject; const site = get(siteId);
const leftovers = await ctx.step('remove', async () => { if (!targetId) return { skipped: '배포 위치 없음' }; const id = await halJobFor(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') }); const hj = await awaitHal(ctx, id); return hj.result; });
await ctx.step('return', () => { if (reservationId) quota.release(store, reservationId); save(siteId, { site_count_state: 'none', site_count_reservation_id: null, pending_cleanup_json: null, return_due_at: null, lifecycle: site.current_version ? site.lifecycle : 'building', server_id: site.current_version ? site.server_id : null }); audit(store, { actorType: 'user', actorId: job.created_by, groupId: site.group_id, subjectType: 'site', subjectId: siteId, action: 'site_count.reservation_returned', detail: { reservationId, leftovers } }); return { returned: true }; });
return { leftovers };
} });
/** 유지보수 모드 전환(vhost 안내 페이지) */
jobs.register('site.vhost', { title: 'vhost 적용', steps: [{ id: 'vhost', title: '웹서버 설정 적용' }], maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
async run(job, ctx) { const { siteId, maintenance } = job.subject; const site = get(siteId); const domains = store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'', siteId).map((d) => d.name); return ctx.step('vhost', async () => { const id = await halJobFor(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 (await awaitHal(ctx, id)).result; }); } });
/** 되돌리기(§11.3): 이전 배포 버전으로. 데이터 영향 안내는 화면이 보여 주고 확인을 받는다 */
function rollback(site, actor) {
const last = 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) throw new HttpError(409, 'no_previous', '되돌릴 이전 버전이 없습니다');
if (activeJobsFor(site.id).some((j) => ['site.deploy', 'site.rollback'].includes(j.kind))) throw new HttpError(409, 'job_running', '배포 작업이 진행 중입니다');
const { job } = jobs.enqueue({ kind: 'site.rollback', subject: { siteId: site.id, toVersion: last.previous_version, fromVersion: site.current_version, targetId: last.target_id }, idempotencyKey: `site.rollback:${site.id}:${site.current_version}:${last.previous_version}`, createdBy: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: `이전 버전(${last.previous_version})으로 되돌리기`, maxAttempts: 2 });
audit(store, { actorType: 'user', actorId: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'deploy.rollback', detail: { to: last.previous_version, from: site.current_version } });
return describeJob(job);
}
jobs.register('site.rollback', { title: '이전 버전으로 되돌리기', steps: [{ id: 'rollback', title: '이전 버전 전환' }, { id: 'external_check', title: '외부 접속 확인' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable',
async run(job, ctx) { const { siteId, toVersion, targetId } = job.subject; const site = get(siteId);
const r1 = await ctx.step('rollback', async () => { const id = await halJobFor(ctx, job, 'rollback', (key) => hal().createJob({ kind: 'site.rollback', target: targetId, idempotencyKey: key, payload: { siteId, toVersion } }), { fresh: (job.steps || []).some((x) => x.id === 'rollback' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
const chk = await ctx.step('external_check', async () => { const urls = site.primary_domain ? [`https://${site.primary_domain}/`] : []; if (!urls.length) return { skipped: '대표 도메인 없음' }; const id = await halJobFor(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 (await awaitHal(ctx, id)).result; });
const now = nowIso(); save(siteId, { current_version: r1.version || toVersion, last_deploy_at: now, health_checked_at: now, last_external_check_json: chk && chk.checks ? chk.checks[0] : null });
store.run('INSERT INTO deployments(id, site_id, group_id, version_ref, version_label, mode, target_id, job_id, status, verify_state, previous_version, result_json, started_by, created_at, updated_at, ended_at) VALUES(?,?,?,?,?,?,?,?,\'done\',?,?,?,?,?,?,?)', newId('d_'), siteId, site.group_id, String(toVersion), `되돌림 → ${toVersion}`, site.deploy_mode, targetId, job.id, site.deploy_verify, job.subject.fromVersion, toJson({ rollback: true, check: chk }), job.created_by, now, now, now);
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 서비스가 부른다): 검증 완료 배포가 있으면 공개 운영 중으로 전환하고 사이트 수를 확정 */
function onPrimaryDomainVerified(siteId, domainName) {
const s = get(siteId); if (!s) return null;
const patch = { primary_domain: domainName };
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 }); }
return save(siteId, patch);
}
/** 대표 도메인이 해제·실패했을 때 */
function onPrimaryDomainLost(siteId) { const s = get(siteId); if (!s) return null; const patch = { primary_domain: null }; if (s.lifecycle === 'live') { patch.lifecycle = 'needs_domain'; patch.deploy_verify = s.current_version ? DEPLOY_VERIFY.NEEDS_DOMAIN : s.deploy_verify; } return save(siteId, patch); }
// ------------------------------------------------------------------ 외부 배포(§10.2)
function recordConsent(site, user, { kind = 'external', version = EXTERNAL_CONSENT_VERSION, ip = '' } = {}) { store.run('INSERT INTO site_consents(id, site_id, group_id, user_id, kind, version, text_hash, agreed_at, ip) VALUES(?,?,?,?,?,?,?,?,?)', newId('c_'), site.id, site.group_id, user.id, kind, version, sha256(JSON.stringify(EXTERNAL_CONSENT.items)), nowIso(), ip); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'consent.external', detail: { version }, ip, immutable: true }); return save(site.id, { consent_version: version }); }
async function createExternalAgent(site, user, label = '') {
const prev = externalAgentFor(site.id); if (prev && prev.status === 'connected') throw new HttpError(409, 'agent_exists', '이미 연결된 외부 서버 Agent 가 있습니다. 먼저 해제하세요');
const r = await hal().issueExternalAgentToken({ groupId: site.group_id, siteId: site.id, label });
if (prev) store.run('UPDATE external_agents SET revoked_at=?, status=\'revoked\' WHERE id=?', nowIso(), prev.id);
const id = newId('xa_'); const now = nowIso();
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, label || null, r.expiresAt, r.installCommand, now, now);
audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.issue', detail: { agentId: r.agentId, label } });
// 토큰 원문은 이 응답에만 — 저장하지 않는다(§28.2 등록 정보 재표시 금지와 같은 원칙)
return { agent: externalAgentFor(site.id), token: r.token, expiresAt: r.expiresAt, installCommand: r.installCommand.replace('<토큰>', r.token) };
}
async function refreshExternalAgent(site) { const a = externalAgentFor(site.id); if (!a) return null; try { const s = await hal().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 에 연결할 수 없습니다(정보 확인 지연)' }; } }
async function revokeExternalAgent(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(404, 'not_found', '외부 서버 Agent 가 없습니다'); try { await hal().revokeExternalAgent(a.hal_agent_id); } catch (e) { if (e.code !== 'service_unreachable') throw e; } 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 } }); return true; }
/** 외부 서버의 배포 제거(실패 잔여 파일 정리 포함) */
function removeExternalDeploy(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(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: '외부 서버 배포 제거' }); return describeJob(job); }
jobs.register('site.remove_external', { title: '외부 서버 배포 제거', steps: [{ id: 'remove', title: '외부 서버에서 파일·실행 상태 제거' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable', async run(job, ctx) { const { siteId, agentId } = job.subject; const r = await ctx.step('remove', async () => { const id = await halJobFor(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 (await awaitHal(ctx, id)).result; }); const a = 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 }); return r; } });
/** 배포 방식 설정(처음 고르거나 바꿀 때). 운영 중 사이트의 변경은 작업(§10.3)으로 */
async function setDeployMode(site, mode, user) {
if (!['managed', 'external'].includes(mode)) throw new HttpError(400, 'bad_mode', '배포 방식은 managed 또는 external 입니다');
if (site.deploy_mode === mode) return { site, changed: false };
if (!site.current_version) { const s = save(site.id, { deploy_mode: mode, server_id: mode === 'external' ? null : site.server_id }); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'site.deploy_mode', detail: { from: site.deploy_mode, to: mode } }); return { site: s, changed: true }; }
// 운영 중: 새 위치 준비 → 별도 배포 → 검증 → 도메인 전환 → 이전 위치 정리
if (activeJobsFor(site.id).length) throw new HttpError(409, 'job_running', '진행 중인 작업이 끝난 뒤 배포 방식을 바꿀 수 있습니다');
if (mode === 'external') { const a = await refreshExternalAgent(site); if (!a || a.status !== 'connected') throw new HttpError(409, 'agent_not_connected', '외부 서버 Agent 를 먼저 연결하세요', { href: `/sites/${site.id}/external` }); }
const id = newId('mc_'); const now = 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 });
store.run('INSERT INTO deploy_mode_changes(id, site_id, group_id, from_mode, to_mode, from_target, status, job_id, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,\'queued\',?,?,?,?)', id, site.id, site.group_id, site.deploy_mode, mode, site.deploy_mode === 'managed' ? site.server_id : null, job.id, user.id, now, now);
audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'site.deploy_mode.change', detail: { from: site.deploy_mode, to: mode, jobId: job.id } });
return { site, changed: false, job: describeJob(job) };
}
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',
async run(job, ctx) {
const { siteId, changeId, toMode, fromTarget } = job.subject; const site = get(siteId);
const newTarget = await ctx.step('prepare_new', async () => { if (toMode === 'managed') { const p = await hal().pickServer({ role: 'web', needBytes: 0 }); if (!p) throw new JobError('no_server', '신규 배치를 받을 수 있는 서버가 없습니다', { retryable: true }); return p.serverId; } const a = externalAgentFor(siteId); if (!a || a.status !== 'connected') throw new JobError('agent_not_connected', '외부 서버 Agent 가 연결되어 있지 않습니다'); return a.hal_agent_id; });
const domains = store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'', siteId).map((d) => d.name);
const bundle = await ctx.step('deploy_new', async () => { mkdirSync(bundleDir, { recursive: true, mode: 0o700 }); const file = join(bundleDir, `${siteId}-${changeId}.tar.gz`); const ex = await wm().exportVersion(site.webmaker_project_id, null, file); const url = issueBundleToken(siteId, null, file, ex.bytes); const id = await halJobFor(ctx, job, 'deploy_new', (key) => hal().createJob({ kind: 'site.deploy', target: newTarget, idempotencyKey: key, payload: { siteId, versionRef: site.current_version || `rev-${nowIso()}`, bundleUrl: url, runtime: site.runtime, env: {}, domains: [], primaryDomain: null, previousVersion: null, external: toMode === 'external' } }), { fresh: (job.steps || []).some((x) => x.id === 'deploy_new' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
await ctx.step('verify_new', async () => { if (!(bundle.internalCheck && bundle.internalCheck.ok)) throw new JobError('verify_failed', '새 위치 내부 확인에 실패했습니다 — 현재 위치를 유지합니다'); return bundle.internalCheck; });
await ctx.step('switch_domain', async () => { if (!domains.length) return { skipped: '연결된 도메인 없음' }; const id = await halJobFor(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 (await awaitHal(ctx, id)).result; });
save(siteId, { deploy_mode: toMode, server_id: toMode === 'managed' ? newTarget : null, external_agent_id: toMode === 'external' ? newTarget : null });
await ctx.step('cleanup_old', async () => { if (!fromTarget) return { skipped: true }; const id = await halJobFor(ctx, job, 'cleanup_old', (key) => hal().createJob({ kind: 'site.remove', target: fromTarget, idempotencyKey: key, payload: { siteId, keepData: true } }), { fresh: (job.steps || []).some((x) => x.id === 'cleanup_old' && x.state === 'failed') }); return (await awaitHal(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 };
},
async revert(job, ctx) { const { siteId, changeId } = job.subject; const st = job.steps || []; const target = st.find((s) => s.id === 'prepare_new')?.result; await ctx.step('revert_new', async () => { if (!target) return { skipped: true }; const id = await halJobFor(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 (await awaitHal(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); const s = 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: '되돌리기 또는 재시도' }); } });
// ------------------------------------------------------------------ 사용량·건강(§8.1·§13.1)
/** 관리형 운영 사이트의 사용량·건강을 10분 단위 멱등 작업으로 모은다. 화면이 열릴 때와 주기 작업이 부른다 */
function refresh(groupId, { force = false } = {}) {
const sites = 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);
if (!sites.length) return null; const bucket = force ? Date.now() : Math.floor(Date.now() / USAGE_BUCKET_MS);
const { job } = jobs.enqueue({ kind: 'site.usage', subject: { groupId, siteIds: sites.map((s) => s.id) }, idempotencyKey: `site.usage:${groupId}:${bucket}`, actorType: 'system', groupId, subjectType: 'group', subjectId: groupId, label: '사이트 사용량·건강 수집' });
return describeJob(job);
}
jobs.register('site.usage', { title: '사용량 수집', steps: [{ id: 'usage', title: '서버 사용량 수집' }, { id: 'health', title: '외부 접속 확인' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable',
async run(job, ctx) {
const { groupId, siteIds } = job.subject; const bySrv = {}; for (const id of siteIds) { const s = get(id); if (s && s.server_id) (bySrv[s.server_id] = bySrv[s.server_id] || []).push(s); }
await ctx.step('usage', async () => { const out = {}; for (const [srv, list] of Object.entries(bySrv)) { const r = await hal().createJob({ kind: 'site.usage', target: srv, idempotencyKey: `portal:${job.id}:${srv}`, payload: { siteIds: list.map((s) => s.id) } }); const hj = await awaitHal(ctx, r.halJobId); for (const [sid, u] of Object.entries((hj.result && hj.result.sites) || {})) { recordUsage(groupId, sid, u); out[sid] = true; } } return { collected: Object.keys(out).length }; });
await ctx.step('health', async () => { let n = 0; for (const [srv, list] of Object.entries(bySrv)) { const urls = list.filter((s) => s.primary_domain).map((s) => `https://${s.primary_domain}/`); if (!urls.length) continue; const r = await hal().createJob({ kind: 'site.check', target: srv, idempotencyKey: `portal:${job.id}:check:${srv}`, payload: { urls } }); const hj = await awaitHal(ctx, r.halJobId); for (const c of (hj.result && hj.result.checks) || []) { const s = list.find((x) => c.url.includes(x.primary_domain)); if (!s) continue; const h = !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 } = await import('../../lib/notify.mjs'); resolveIncident(store, `health:${s.id}`, '외부 확인 정상 복구'); } n++; } } return { checked: n }; });
return { sites: siteIds.length };
} });
/** HAL 사용량 결과 → usage_samples + quota 사용량 */
function recordUsage(groupId, siteId, u) {
const at = u.measuredAt || nowIso(); const ps = monthStart(at);
const ins = (metric, value, period = null) => store.run('INSERT INTO usage_samples(id, group_id, subject_type, subject_id, metric, value, period, measured_at, source) VALUES(?,?,?,?,?,?,?,?,?)', newId('us_'), groupId, 'site', siteId, metric, Math.round(value || 0), period, at, 'hal');
ins('disk_bytes', u.diskBytes); ins('db_bytes', u.dbBytes); ins('uploads_bytes', u.uploadsBytes); ins('out_bytes_day', u.outBytesDay, at.slice(0, 10)); ins('out_bytes_month', u.outBytesMonth, ps); ins('out_bytes_total', u.outBytesTotal); ins('in_bytes_day', u.inBytesDay, at.slice(0, 10)); ins('in_bytes_month', u.inBytesMonth, ps); ins('in_bytes_total', u.inBytesTotal); ins('req_24h', u.req24h); ins('pv_24h', u.pv24h); ins('error_rate_x1000', (u.errorRate || 0) * 1000); ins('p95_ms', u.p95ms);
store.run('INSERT INTO usage_samples(id, group_id, subject_type, subject_id, metric, value, period, measured_at, source) VALUES(?,?,?,?,?,?,?,?,?)', newId('us_'), groupId, 'site', siteId, 'series_json', 0, toJson({ series7d: u.series7d || [], series30d: u.series30d || [] }), at, 'hal');
quota.recordUsage(store, { groupId, resource: 'web_disk', subjectType: 'site', subjectId: siteId, used: (u.diskBytes || 0) + (u.dbBytes || 0) + (u.uploadsBytes || 0), source: 'hal', measuredAt: at });
quota.recordUsage(store, { groupId, resource: 'web_out', subjectType: 'site', subjectId: siteId, used: u.outBytesMonth || 0, periodStart: ps, source: 'hal', measuredAt: at });
}
function latestMetric(siteId, metric) { return store.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). 관리형/외부/미배포를 구분해 보일 값만 돌려준다 */
function overview(site) {
const lastDeploy = dep(store.get('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT 1', site.id));
const base = { id: site.id, name: site.name, lifecycle: site.lifecycle, deployMode: site.deploy_mode, runtime: site.runtime, primaryDomain: site.primary_domain, currentVersion: site.current_version, lastDeployAt: site.last_deploy_at, deployVerify: site.deploy_verify, siteCountState: site.site_count_state, returnDueAt: site.return_due_at, pendingCleanup: site.pendingCleanup, lastDeploy: lastDeploy ? { id: lastDeploy.id, status: lastDeploy.status, verify: lastDeploy.verify_state, at: lastDeploy.created_at, version: lastDeploy.version_label || lastDeploy.version_ref, jobId: lastDeploy.job_id } : null, nextJob: nextJobFor(site), actionNeeded: actionNeeded(site, lastDeploy) };
if (!site.current_version) return { ...base, kind: 'not_deployed', readiness: nextSteps(site), message: '아직 정상 배포되지 않은 사이트입니다. 통계는 배포 뒤에 보입니다' };
if (site.deploy_mode === 'external') { const a = externalAgentFor(site.id); return { ...base, kind: 'external', agent: a ? { status: a.status, label: a.label, lastSeenAt: a.last_seen_at, version: a.agent_version } : null, lastDeployResult: lastDeploy && lastDeploy.result ? { ok: lastDeploy.status === 'done', at: lastDeploy.ended_at, version: lastDeploy.result.version, externalCheck: lastDeploy.result.externalCheck || null } : null, lastExternalCheck: site.lastExternalCheck, customerResponsibility: EXTERNAL_CONSENT.items.slice(0, 2), notProvided: ['정상 여부(uptime)', '관리형 디스크·데이터베이스 사용량', '네트워크 트래픽', '접속 통계', '백업 보호 상태', '서버 이전'] }; }
const disk = latestMetric(site.id, 'disk_bytes'); const db = latestMetric(site.id, 'db_bytes'); const up = latestMetric(site.id, 'uploads_bytes'); const alloc = quota.allocationOf(store, site.group_id, 'web_disk', 'site', site.id); const outAlloc = quota.allocationOf(store, site.group_id, 'web_out', 'site', site.id);
const series = latestMetric(site.id, 'series_json'); const measuredAt = disk ? disk.measured_at : null; const stale = isStale(measuredAt, 30 * 60 * 1000);
const m = (k) => { const r = latestMetric(site.id, k); return r ? r.value : null; };
return { ...base, kind: 'managed', health: isStale(site.health_checked_at, 15 * 60 * 1000) ? HEALTH.STALE : site.health, healthCheckedAt: site.health_checked_at, measuredAt, stale, staleReason: stale ? '마지막 수집으로부터 30분이 지났습니다(정보 확인 지연)' : null,
visits24h: m('pv_24h'), requests24h: m('req_24h'), series: series ? parseJson(series.period, { series7d: [], series30d: [] }) : { series7d: [], series30d: [] },
disk: { used: disk ? disk.value : null, db: db ? db.value : null, uploads: up ? up.value : null, limit: alloc ? alloc.amount : null, percent: alloc && disk ? quota.percent((disk.value || 0) + (db ? db.value : 0) + (up ? up.value : 0), alloc.amount) : null },
network: { out: { day: m('out_bytes_day'), month: m('out_bytes_month'), total: m('out_bytes_total'), limit: outAlloc ? outAlloc.amount : null }, in: { day: m('in_bytes_day'), month: m('in_bytes_month'), total: m('in_bytes_total') } },
errors: { errorRate: m('error_rate_x1000') != null ? m('error_rate_x1000') / 1000 : null, p95ms: m('p95_ms') }, lastExternalCheck: site.lastExternalCheck };
}
function nextJobFor(site) { const j = 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 }; const b = store.get('SELECT next_run_at FROM backup_options WHERE site_id=? AND status=\'active\'', site.id); return b && b.next_run_at ? { label: '정기 백업', at: b.next_run_at } : null; }
function deployments(siteId, limit = 20) { return store.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 기록·번들 토큰 정리 */
function sweep(now = Date.now()) {
const due = store.all('SELECT * FROM sites WHERE site_count_state=\'reserved\' AND return_due_at IS NOT NULL AND return_due_at<=?', new Date(now).toISOString()).map(row); let returned = 0;
for (const s of due) { 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++; }
const cut = new Date(now - 7 * 86400000).toISOString(); store.run('DELETE FROM sso_tickets WHERE expires_at<?', cut); for (const t of store.all('SELECT * FROM bundle_tokens WHERE expires_at<?', new Date(now - 86400000).toISOString())) { try { if (existsSync(t.file)) unlinkSync(t.file); } catch (_) {} store.run('DELETE FROM bundle_tokens WHERE id=?', t.id); }
return { returned };
}
function fmtBytes(n) { if (n == null) return '-'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = Number(n); while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; }
return { DEPLOY_STEPS, EXTERNAL_CONSENT, get, getForGroup, list, summarize, create, update, setLifecycle, returnSiteCount, nextSteps, buildUrl, versionsFor, impactForProject, onProjectDestroyed, precheck, startDeploy, cleanupAndReturn, rollback, onPrimaryDomainVerified, onPrimaryDomainLost, recordConsent, createExternalAgent, refreshExternalAgent, revokeExternalAgent, removeExternalDeploy, externalAgentFor, setDeployMode, refresh, recordUsage, overview, deployments, activeJobsFor, bundleByToken, sweep, userLogin, fmtBytes, save };
}
+62
View File
@@ -0,0 +1,62 @@
/**
* portal/sites/ui.mjs — 사이트 화면 HTML 뼈대(목록·생성·상세 탭·배포·배포 방식·외부 배포). 데이터는 /ui/sites/sites.js 가 /api/sites/* 로 채운다.
* 각 화면은 data-page 속성으로 스크립트가 어떤 화면인지 알고, 모든 화면에 공통 상태 패널(불러오는 중·비어 있음·지연·부분 실패·전체 실패·권한 없음)이 있다.
*/
import { esc } from '../../lib/http.mjs';
import { t } from '../../lib/i18n.mjs';
const TABS = ['overview', 'build', 'source', 'deploy', 'domains', 'mail', 'backup', 'usage', 'activity', 'settings'];
export function listPage(ctx, deps, { filter = '' } = {}) {
const filters = ['all', 'draft', 'building', 'deploying', 'live', 'paused', 'cancel_pending'].map((f) => `<a class="btn${(filter || 'all') === f ? ' primary' : ''}" href="/sites${f === 'all' ? '' : '?filter=' + f}">${esc(t('sites.filters.' + f))}</a>`).join('');
return shell(ctx, deps, { title: t('sites.title'), nav: 'sites', crumbs: [{ label: t('nav.dashboard'), href: '/' }, { label: t('sites.title') }], actions: [{ label: t('sites.new'), href: '/sites/new', kind: 'primary' }], body: `<div class="actions" style="margin-bottom:12px">${filters}</div><div id="list" data-page="sites-list" data-filter="${esc(filter)}">${deps.statePanel('loading')}</div>` });
}
export function newPage(ctx, deps) {
return shell(ctx, deps, { title: t('sites.new'), nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: t('sites.new') }], body: `<form id="newSite" class="card" data-page="sites-new" data-dirty-guard>
<p class="hint">${esc(t('sites.create.noSiteCount'))}</p>
<div class="f"><label for="name">${esc(t('sites.create.name'))} <span class="muted">(${esc(t('common.required'))})</span></label><input id="name" name="name" required maxlength="120" placeholder="예: 우리 회사 홈페이지"><span class="hint">사이트 목록과 알림에 보이는 이름입니다</span><span class="err" data-err="name"></span></div>
<div class="f"><label for="purpose">${esc(t('sites.create.purpose'))}</label><input id="purpose" name="purpose" maxlength="300" placeholder="예: 회사 소개와 문의 접수"><span class="hint">Webmaker 의 기획 단계에 참고 정보로 전달됩니다</span></div>
<fieldset class="f"><legend>${esc(t('sites.create.project'))}</legend><label><input type="radio" name="projectMode" value="new" checked> ${esc(t('sites.create.projectNew'))}</label><label><input type="radio" name="projectMode" value="existing"> ${esc(t('sites.create.projectExisting'))}</label><label><input type="radio" name="projectMode" value="none"> ${esc(t('sites.create.projectNone'))}</label>
<div id="projectPick" hidden><select name="projectId" id="projectId"><option value="">불러오는 중…</option></select><span class="hint">내 Webmaker 계정의 프로젝트만 보입니다. 이미 다른 사이트에 연결된 프로젝트는 고를 수 없습니다</span><span class="err" data-err="projectId"></span></div>
<div id="buildModePick"><label for="buildMode">제작 방식</label><select id="buildMode" name="buildMode"><option value="manual">내가 고르기 (단계별 선택)</option><option value="ai">AI에게 맡기기</option><option value="quick">제목·내용 입력형</option></select><span class="hint">Webmaker 의 기존 제작 방식 그대로입니다</span></div></fieldset>
<div class="f"><label for="template">${esc(t('sites.create.template'))}</label><select id="template" name="template"><option value="">선택 안 함 (Webmaker 에서 고르기)</option></select><span class="hint">템플릿 목록은 Webmaker 제작 화면에서 그대로 제공됩니다</span></div>
<div class="f"><label for="lang">${esc(t('sites.create.lang'))}</label><select id="lang" name="lang"><option value="ko">한국어</option><option value="en">English</option><option value="ja">日本語</option><option value="zh">中文</option></select></div>
<div class="f"><label for="timezone">${esc(t('sites.create.timezone'))}</label><select id="timezone" name="timezone"><option value="Asia/Seoul">Asia/Seoul (KST)</option><option value="UTC">UTC</option><option value="Asia/Tokyo">Asia/Tokyo</option><option value="America/New_York">America/New_York</option></select></div>
<fieldset class="f"><legend>${esc(t('sites.create.deployMode'))}</legend><label><input type="radio" name="deployMode" value="" checked> ${esc(t('sites.create.modeLater'))}</label><label><input type="radio" name="deployMode" value="managed"> ${esc(t('sites.mode.managed'))} — ${esc(t('sites.mode.managedDesc'))}</label><label><input type="radio" name="deployMode" value="external"> ${esc(t('sites.mode.external'))} — ${esc(t('sites.mode.externalDesc'))}</label></fieldset>
<div class="actions"><button class="btn primary" type="submit">${esc(t('common.create'))}</button><a class="btn" href="/sites">${esc(t('common.cancel'))}</a></div><div class="err" data-err="form"></div></form><div id="created" class="card" hidden></div>` });
}
export function detailPage(ctx, deps, site, { tab = 'overview' } = {}) {
if (!TABS.includes(tab)) tab = 'overview';
const tabs = TABS.map((k) => `<a class="${tab === k ? 'on' : ''}" href="/sites/${esc(site.id)}?tab=${k}">${esc(t('sites.tabs.' + k))}</a>`).join('');
const bodies = {
overview: `<div id="overview" data-page="site-overview">${deps.statePanel('loading')}</div>`,
build: `<div class="card" data-page="site-build"><h2>Webmaker 제작</h2><p>제작·AI 생성·페이지 편집·미리보기·저장·이전 저장본 조회는 Webmaker 화면에서 그대로 합니다(별도 로그인 없음). 현재 그룹과 사이트 이름이 Webmaker 상단에 표시됩니다.</p><div class="actions"><a class="btn primary" href="/sso/webmaker?site=${esc(site.id)}" target="_blank" rel="noopener">제작 화면 열기</a><a class="btn" href="/sso/webmaker?site=${esc(site.id)}&path=/domains" target="_blank" rel="noopener">도메인·DNS 관리(Webmaker)</a></div><div id="buildInfo">${deps.statePanel('loading')}</div><p class="hint">이 화면의 "프로젝트" 는 운영 사이트와 다릅니다. 프로젝트를 지워도 운영 사이트·배포본·도메인은 지워지지 않습니다. 콘텐츠 가져오기(기존 사이트 복사·가져오기)는 이번 버전에서 지원하지 않습니다.</p><div id="wallet"></div></div>`,
source: `<div id="source" data-page="site-source">${deps.statePanel('loading')}</div>`,
deploy: `<div data-page="site-deploy"><div id="deployCurrent" class="card">${deps.statePanel('loading')}</div><div id="deployStart" class="card"></div><div id="deployHistory" class="card"></div></div>`,
domains: `<div data-page="site-domains" class="card"><div class="actions" style="margin-bottom:10px"><a class="btn primary" href="/domains/connect?site=${esc(site.id)}">도메인 연결</a><a class="btn" href="/domains">도메인 목록</a></div><div id="siteDomains">${deps.statePanel('loading')}</div></div>`,
mail: `<div class="card"><h2>메일</h2><p>사이트 대표 메일과 메일함은 메일 화면에서 관리합니다.</p><a class="btn primary" href="/mail?site=${esc(site.id)}">메일 화면으로</a></div>`,
backup: `<div class="card"><h2>백업</h2><p>관리형 사이트 백업 옵션·보관 목록·복원 요청은 백업 화면에서 관리합니다.</p><a class="btn primary" href="/backup?site=${esc(site.id)}">백업 화면으로</a></div>`,
usage: `<div id="usage" data-page="site-usage">${deps.statePanel('loading')}</div>`,
activity: `<div id="activity" data-page="site-activity">${deps.statePanel('loading')}</div>`,
settings: `<div data-page="site-settings"><form id="settings" class="card" data-dirty-guard><h2>사이트 설정</h2>
<div class="f"><label for="s_name">사이트 이름</label><input id="s_name" name="name" value="${esc(site.name)}" maxlength="120"></div>
<div class="f"><label for="s_desc">설명</label><textarea id="s_desc" name="description" rows="3">${esc(site.description || '')}</textarea></div>
<div class="f"><label>대표 웹 도메인</label><div id="primaryDomain" class="mono">${esc(site.primary_domain || '없음')}</div><span class="hint">대표 도메인은 <a href="/sites/${esc(site.id)}?tab=domains">도메인 탭</a>에서 지정합니다</span></div>
<div class="f"><label for="s_lang">기본 언어</label><select id="s_lang" name="lang">${['ko', 'en', 'ja', 'zh'].map((l) => `<option value="${l}"${site.lang === l ? ' selected' : ''}>${l}</option>`).join('')}</select></div>
<div class="f"><label for="s_tz">시간대</label><input id="s_tz" name="timezone" value="${esc(site.timezone || 'Asia/Seoul')}"></div>
<div class="f"><label for="s_runtime">실행 형태</label><select id="s_runtime" name="runtime"><option value="static"${site.runtime === 'static' ? ' selected' : ''}>정적 사이트</option><option value="node"${site.runtime === 'node' ? ' selected' : ''}>동적 사이트(승인된 Node 실행 형태)</option></select><span class="hint">배포 전 확인 화면에서 필요한 저장 공간과 지원하지 않는 기능을 안내합니다</span></div>
<div class="f"><label><input type="checkbox" name="maintenance" ${site.maintenance ? 'checked' : ''}> 유지보수 모드</label><span class="hint">켜면 방문자에게 안내 페이지가 보입니다. 적용 전 영향과 예상 시간을 다시 보여 줍니다</span></div>
<div class="f"><label><input type="checkbox" name="searchVisible" ${site.search_visible ? 'checked' : ''}> 검색 엔진 공개</label></div>
<div class="actions"><button class="btn primary" type="submit">저장</button></div><div class="err" data-err="form"></div></form>
<div class="card"><h2>사이트별 접근 권한</h2><div id="perms">${deps.statePanel('loading')}</div></div>
<div class="card"><h2>중지 · 소유권 이전 · 해지</h2><p class="hint">서비스 중단이 예상되는 작업은 영향과 예상 시간을 적용 전에 보여 줍니다.</p><div class="actions"><a class="btn" href="/sites/${esc(site.id)}/lifecycle">생명주기 화면(중지·해지·보존)</a><a class="btn" href="/transfer/new?site=${esc(site.id)}">소유권 이전</a></div></div></div>`,
};
return shell(ctx, deps, { title: site.name, nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: site.name }], actions: tab === 'overview' && site.primary_domain && site.lifecycle === 'live' ? [{ label: '사이트 열기', href: `https://${site.primary_domain}/`, kind: '' }, { label: '배포', href: `/sites/${site.id}?tab=deploy`, kind: 'primary' }] : [{ label: '배포', href: `/sites/${site.id}?tab=deploy`, kind: 'primary' }], body: `<div class="tabs">${tabs}</div><div data-site="${esc(site.id)}" data-lifecycle="${esc(site.lifecycle)}" data-mode="${esc(site.deploy_mode || '')}">${bodies[tab]}</div>` });
}
export function modePage(ctx, deps, site) {
return shell(ctx, deps, { title: '배포 방식 선택', nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: '배포 방식' }], body: `<div data-page="site-mode" data-site="${esc(site.id)}" data-current="${esc(site.deploy_mode || '')}"><div id="modeInfo">${deps.statePanel('loading')}</div></div>` });
}
export function externalPage(ctx, deps, site) {
return shell(ctx, deps, { title: t('sites.external.title'), nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: t('sites.external.title') }], body: `<div data-page="site-external" data-site="${esc(site.id)}"><div id="external">${deps.statePanel('loading')}</div></div>` });
}
export function deployPage(ctx, deps, site) { return detailPage(ctx, deps, site, { tab: 'deploy' }); }
function shell(ctx, deps, o) { return deps.page({ ...o, user: ctx.user, group: ctx.group, groups: ctx.groups || [], unread: ctx.unread || 0, csrf: ctx.session ? ctx.session.csrf : '', scripts: ['/ui/sites/sites.js'] }); }