Files

47 lines
9.4 KiB
JavaScript

/**
* portal/hal/client.mjs — 포털이 HAL 을 부르는 어댑터(docs/HAL-JOBS.md 의 deps.hal).
* 서명: lib/serviceauth keyId 'portal', 비밀은 포털 보관소 'service:hal'. HAL 에 닿지 못하면 code 'service_unreachable' 을 던져 화면이 '정보 확인 지연' 을 보이고 신규 증가를 막는다(§13.4·§26.2).
* createFake(): 다른 영역의 테스트용 — HAL 없이 작업이 즉시 성공하는 가짜(결과 모양은 실제와 같다).
*/
import { serviceFetch } from '../../lib/serviceauth.mjs';
export function create(deps) {
const base = () => deps.config.halUrl; const secret = () => deps.vault.get('service:hal');
async function request(method, path, body) {
const s = secret(); if (!s) { const e = new Error('HAL 연동 키(service:hal)가 보관소에 없습니다'); e.code = 'service_unreachable'; throw e; }
const r = await serviceFetch(base(), 'portal', s, method, path, body, { timeoutMs: 20000, fetchImpl: deps.fetch || fetch });
if (!r.ok) { const e = new Error(r.json?.message || `HAL ${method} ${path} → ${r.status}`); e.code = r.json?.error || `hal_http_${r.status}`; e.status = r.status; e.detail = r.json?.detail; throw e; }
return r.json;
}
const job = (j) => ({ id: j.id, kind: j.kind, label: j.label, status: j.status, steps: j.steps, progress: j.progress, result: j.result, error: j.error, errorCode: j.errorCode, userMessage: j.userMessage, actions: j.actions, waitReason: j.waitReason, attempt: j.attempt, maxAttempts: j.maxAttempts, nextRunAt: j.nextRunAt, etaAt: j.etaAt, updatedAt: j.updatedAt, startedAt: j.startedAt, endedAt: j.endedAt, currentStep: j.currentStep, completedSteps: j.completedSteps, nextStep: j.nextStep, canCancel: j.canCancel, canRetry: j.canRetry, canResume: j.canResume, terminal: j.terminal, stale: j.stale, children: j.children });
return {
request,
async createJob({ kind, target, payload, idempotencyKey, groupId, subjectType, subjectId, label }) { const r = await request('POST', '/portal/v1/jobs', { kind, target, payload, idempotencyKey, groupId, subjectType, subjectId, label }); return { halJobId: r.halJobId, status: r.status, created: r.created }; },
async getJob(id) { return job(await request('GET', `/portal/v1/jobs/${encodeURIComponent(id)}`)); },
cancelJob: (id) => request('POST', `/portal/v1/jobs/${encodeURIComponent(id)}/cancel`).then(job), retryJob: (id) => request('POST', `/portal/v1/jobs/${encodeURIComponent(id)}/retry`).then(job), revertJob: (id) => request('POST', `/portal/v1/jobs/${encodeURIComponent(id)}/revert`).then(job), resumeJob: (id) => request('POST', `/portal/v1/jobs/${encodeURIComponent(id)}/resume`).then(job),
async listServers({ role } = {}) { return (await request('GET', `/portal/v1/servers${role ? `?role=${encodeURIComponent(role)}` : ''}`)).servers; },
async pickServer({ role = 'web', needBytes = 0 } = {}) { return (await request('POST', '/portal/v1/servers/pick', { role, needBytes })).pick; },
async issueExternalAgentToken({ groupId, siteId, label }) { const r = await request('POST', '/portal/v1/external-agents/token', { groupId, siteId, label }); return { agentId: r.agentId, token: r.token, expiresAt: r.expiresAt, installCommand: r.installCommand }; },
async getExternalAgent(agentId) { return (await request('GET', `/portal/v1/external-agents/${encodeURIComponent(agentId)}`)).agent; },
async getExternalAgentBySite(siteId) { return (await request('GET', `/portal/v1/external-agents/by-site/${encodeURIComponent(siteId)}`)).agent; },
revokeExternalAgent: (agentId) => request('POST', `/portal/v1/external-agents/${encodeURIComponent(agentId)}/revoke`),
async health() { return request('GET', '/portal/v1/health'); },
};
}
/** 테스트용 가짜 HAL: 작업은 만들자마자 done(결과는 종류별 그럴듯한 값). opts.fail 로 특정 kind 를 실패시킬 수 있다. */
export function createFake(opts = {}) {
const jobs = new Map(); let n = 0; const servers = opts.servers || [{ id: 'srv_test_web', name: '테스트 웹 서버', role: 'web', location: '검증', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: new Date().toISOString(), capacity: { diskFreeBytes: 50e9, diskTotalBytes: 100e9, memFreeBytes: 4e9, cpuLoad: 0.2, sitesHosted: 1, reservedBytes: 0 }, modules: {} }, { id: 'srv_test_backup', name: '테스트 백업 서버', role: 'backup', location: '검증', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: new Date().toISOString(), capacity: { diskFreeBytes: 500e9, diskTotalBytes: 1000e9, memFreeBytes: 4e9, cpuLoad: 0.1, sitesHosted: 0, reservedBytes: 0 }, modules: {} }];
const resultFor = (kind, payload) => ({ 'site.deploy': { version: `v${Date.now()}`, deployedAt: new Date().toISOString(), internalCheck: { ok: true, status: 200, ms: 12 }, externalCheck: { ok: !!payload?.externalCheckUrl, url: payload?.externalCheckUrl || null, status: 200 }, siteUrl: payload?.primaryDomain ? `https://${payload.primaryDomain}/` : `http://${payload?.siteId}.preview.ariweb.net/`, https: !!payload?.primaryDomain, leftovers: [] }, 'site.usage': { sites: Object.fromEntries((payload?.siteIds || []).map((s) => [s, { diskBytes: 12_345_678, dbBytes: 0, uploadsBytes: 1024, outBytesDay: 1000, outBytesMonth: 50000, outBytesTotal: 90000, inBytesDay: 100, inBytesMonth: 5000, inBytesTotal: 9000, req24h: 42, pv24h: 40, series7d: [], series30d: [], errorRate: 0, p95ms: 120, measuredAt: new Date().toISOString() }])) }, 'backup.create': { backupId: `bk-${++n}`, location: `srv_test_backup:/srv/ariweb/backups/${payload?.siteId}/bk-${n}.tar.gz`, sizeBytes: 4096, checksum: 'a'.repeat(64), verified: true, createdAt: new Date().toISOString() }, 'backup.verify': { ok: true }, 'backup.restore': { protectPointLocation: `srv_test_web:/srv/ariweb/backups/${payload?.siteId}/protect/p.tar.gz`, restoredVersion: 'restore-1', isolatedVerify: true, externalCheck: { ok: true } }, 'site.relocate': { newServerId: payload?.toServerId, switchedAt: new Date().toISOString(), oldRetainedUntil: new Date(Date.now() + 7 * 86400000).toISOString(), externalCheck: { ok: true } }, 'domain.cert': { issued: payload?.domains || [], failed: [], expiresAt: new Date(Date.now() + 89 * 86400000).toISOString() }, 'domain.vhost': { applied: payload?.domains || [], certs: {} }, 'domain.probe': { a: payload?.expectA || [], aOk: true, txtOk: true, resolvedAt: new Date().toISOString() }, 'site.check': { checks: (payload?.urls || []).map((u) => ({ url: u, status: 200, ok: true, ms: 10, tls: { valid: true, expiresAt: null } })) } }[kind] || { ok: true });
const view = (j) => ({ ...j, currentStep: null, completedSteps: j.steps.filter((s) => s.state === 'done').map((s) => ({ id: s.id, title: s.title })), nextStep: null, canCancel: false, canRetry: j.status === 'failed', canResume: false, terminal: ['done', 'failed'].includes(j.status), stale: false });
return {
calls: [], jobs,
async createJob({ kind, target, payload, idempotencyKey, label }) { const ex = idempotencyKey && [...jobs.values()].find((j) => j.idempotencyKey === idempotencyKey); if (ex) return { halJobId: ex.id, status: ex.status, created: false }; const id = `j_fake_${++n}`; const fail = opts.fail && opts.fail[kind]; const j = { id, kind, label: label || kind, target, payload, idempotencyKey, status: fail ? 'failed' : 'done', steps: [{ id: 'run', title: '실행', state: fail ? 'failed' : 'done' }], progress: fail ? 10 : 100, result: fail ? null : resultFor(kind, payload), error: fail ? (typeof fail === 'string' ? fail : '가짜 실패') : null, errorCode: fail ? 'fake_failed' : null, updatedAt: new Date().toISOString(), startedAt: new Date().toISOString(), endedAt: new Date().toISOString(), attempt: 1, maxAttempts: 1 }; jobs.set(id, j); this.calls.push({ kind, target, payload }); return { halJobId: id, status: j.status, created: true }; },
async getJob(id) { const j = jobs.get(id); if (!j) { const e = new Error('작업이 없습니다'); e.code = 'job_not_found'; throw e; } return view(j); },
async cancelJob(id) { const j = jobs.get(id); j.status = 'cancelled'; return view(j); }, async retryJob(id) { const j = jobs.get(id); j.status = 'done'; j.result = resultFor(j.kind, j.payload); j.error = null; return view(j); }, async revertJob(id) { const j = jobs.get(id); j.status = 'reverted'; return view(j); }, async resumeJob(id) { return view(jobs.get(id)); },
async listServers({ role } = {}) { return servers.filter((s) => !role || s.role === role); }, async pickServer({ role = 'web' } = {}) { const s = servers.find((x) => x.role === role && x.acceptsNew); return s ? { serverId: s.id, name: s.name } : null; },
async issueExternalAgentToken({ siteId }) { return { agentId: null, token: 'example-token', expiresAt: new Date(Date.now() + 600000).toISOString(), installCommand: `curl -fsSL https://hal.example.test/agent/v1/install.sh | bash -s -- --hal https://hal.example.test --token example-token --external --site ${siteId}` }; },
async getExternalAgent(id) { return { id, status: 'connected', lastSeenAt: new Date().toISOString(), version: '0.1.1', lastDeploy: null, lastCheck: null }; }, async getExternalAgentBySite(siteId) { return opts.externalAgent === false ? null : { id: 'ag_fake', siteId, status: 'connected', lastSeenAt: new Date().toISOString(), version: '0.1.1', lastDeploy: null, lastCheck: null }; }, async revokeExternalAgent() { return { ok: true }; },
async health() { if (opts.down) { const e = new Error('HAL 연결 불가'); e.code = 'service_unreachable'; throw e; } return { ok: true, version: '0.1.1' }; },
};
}