HAL 0.3.17: HawkEye 이름 변경(observability → hawkeye, 별칭·마이그레이션), 역할별 통합 배포(server.provision: webmaker·webhosting·arimail·backup·portal 프로필, 순서 배포, 역할·프로필·슬롯 기록, 포털 싱글턴·포털 이전 portal.relocate, 슬롯 기반 배치), 화면 부분 갱신(dom-patch, 운영 현황·서버·작업·AI 기록·dock), 배포 릴리스 화면 재구성(HAL 배포 제외 hal_not_deployable, HAL 먼저 규칙 완화 ≥, 모듈별 최신 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·상호 연결·프로필, 과거 버전 보기), 작업 진행 창(dock), agent 배포 "처리 대기" 원인 수정(빈 대상 → 역할 전체, 없으면 즉시 no_targets), 서버 라디오/체크 선택 목록, 개발 지시 v0.3.17(Oz)·VERSION_HISTORY 0.3.17 — test/hal-0317.test.mjs RED → GREEN 5/5, 전체 30파일 204건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+24
-6
@@ -26,7 +26,7 @@ import * as ops from './ops.mjs';
|
||||
import * as emergency from './emergency.mjs';
|
||||
import * as dr from './dr.mjs';
|
||||
import * as mailsvc from './mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES, MODULE_GUIDES, MODULE_LINKS, SERVER_PROFILES, descriptor, canonicalModuleName } from './modules/index.mjs';
|
||||
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
|
||||
import { registerAiRoutes } from './ai/routes.mjs'; // 0.3.0 AI 채팅룸 운영자 API(연결·방·제안·도구)
|
||||
|
||||
@@ -127,7 +127,8 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/servers', wrap(async (ctx) => { const b = await ctx.body(); const s = core.createServer(d, { ...b, by: ctx.operator.id }); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||
app.get('/ops/api/servers/:id', wrap((ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); return { ok: true, server: core.publicServer(d, s), jobs: d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe), agentJobs: d.store.all('SELECT id, kind, status, progress, created_at, ended_at, error FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || '') }; }));
|
||||
app.get('/ops/api/servers/:id/metrics', (ctx) => ({ ok: true, samples: core.metrics(d, ctx.params.id, { since: ctx.query.since, until: ctx.query.until, limit: Number(ctx.query.limit || 5000) }) }));
|
||||
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
|
||||
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); if (b.slots !== undefined) { if (b.slots && typeof b.slots === 'object') { b.slots_json = toJson(Object.fromEntries(Object.entries(b.slots).filter(([k, v]) => ['sites', 'mailServices', 'backups', 'webmaker'].includes(k) && Number.isFinite(Number(v))).map(([k, v]) => [k, Number(v)]))); } delete b.slots; } // [0.3.17] 슬롯
|
||||
return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
|
||||
app.post('/ops/api/servers/:id/token', wrap((ctx) => ({ ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id }) })));
|
||||
app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) })));
|
||||
app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||
@@ -139,9 +140,19 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/mail-services/imports/:id/rollback', wrap((ctx) => { requireRecent(ctx); return { ok: true, result: mailsvc.rollbackImport(d, ctx.params.id, ctx.operator.id) }; }));
|
||||
app.get('/ops/api/mail-services/:id', wrap((ctx) => { const s = mailsvc.getMailService(d, ctx.params.id); if (!s) throw new HttpError(404, 'mail_service_not_found', '메일 서비스가 없습니다'); return { ok: true, service: s, audit: listAudit(d.store, { subjectType: 'mail_service', subjectId: s.id, limit: 50 }) }; }));
|
||||
// 릴리스·모듈 배포(§32)
|
||||
app.get('/ops/api/releases', () => ({ ok: true, releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }));
|
||||
app.get('/ops/api/releases', () => releasesData(d)); // [0.3.17] 모듈별 최신 릴리스·안내·프로필 포함(HAL 제외)
|
||||
// [0.3.17] 역할별 통합 배포
|
||||
app.get('/ops/api/provision/profiles', () => ({ ok: true, profiles: Object.values(SERVER_PROFILES), guides: MODULE_GUIDES, links: MODULE_LINKS }));
|
||||
app.post('/ops/api/provision', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const prof = SERVER_PROFILES[b.profile]; if (!prof) throw new HttpError(400, 'bad_profile', `모르는 프로필: ${String(b.profile).slice(0, 30)}`); const srv = core.getServer(d, b.serverId); if (!srv) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); if (srv.status !== 'ready' || !srv.agent_id) throw new HttpError(409, 'server_not_ready', `서버 ${srv.name} 이(가) 준비(Agent 연결·승인) 상태가 아닙니다(${srv.status})`);
|
||||
const rel = core.getRelease(d, b.version); if (!rel || rel.status !== 'approved') throw new HttpError(400, 'release_not_approved', `승인된 릴리스가 아닙니다: ${b.version}`); if (prof.singleton) { const owner = d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1 AND server_id<>? LIMIT 1', srv.id); if (owner) throw new HttpError(409, 'portal_exists', `포털이 이미 다른 서버(${(core.getServer(d, owner.server_id) || {}).name || owner.server_id})에 설치되어 있습니다 — 추가 설치 대신 "포털 이전"을 쓰세요`); }
|
||||
const r = d.jobs.enqueue({ kind: 'server.provision', subject: { serverId: srv.id, profile: prof.id, version: b.version }, idempotencyKey: `provision:${srv.id}:${prof.id}:${b.version}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'server', subjectId: srv.id, label: `${prof.label} 통합 배포 ${b.version} — ${srv.name}` }); return { ok: true, job: describe(r.job), created: r.created, modules: prof.modules }; }));
|
||||
app.post('/ops/api/portal/relocate', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); threeStep(b, [b.fromServerId, b.toServerId]); const from = core.getServer(d, b.fromServerId); const to = core.getServer(d, b.toServerId); if (!from || !to) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); const r = d.jobs.enqueue({ kind: 'portal.relocate', subject: { fromServerId: from.id, toServerId: to.id, version: b.version }, idempotencyKey: `portal.relocate:${from.id}:${to.id}:${b.version}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: 'portal', label: `포털 이전 ${from.name} → ${to.name} (${b.version})` }); return { ok: true, job: describe(r.job), created: r.created }; }));
|
||||
app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; }));
|
||||
app.post('/ops/api/modules/:name/deploy', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: ctx.params.name, version: b.version, serverIds: b.serverIds || [] }, idempotencyKey: b.idempotencyKey || `module.deploy:${ctx.params.name}:${b.version}:${(b.serverIds || []).slice().sort().join(',')}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} ${b.version} 배포` }); return { ok: true, job: describe(r.job), created: r.created }; }));
|
||||
app.post('/ops/api/modules/:name/deploy', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const module = canonicalModuleName(ctx.params.name); descriptor(module);
|
||||
if (module === 'hal') throw new HttpError(400, 'hal_not_deployable', 'HAL 은 릴리스 배포 대상이 아닙니다 — HAL 은 먼저 설치하는 도구이며 별도 절차로 갱신합니다'); // [0.3.17]
|
||||
// [0.3.17 배포 점검] 대상을 비우면 역할에 맞는 준비된 서버 전체로 푼다. 하나도 없으면 작업을 만들지 않고 바로 알린다(옛 동작: 작업이 1초 만에 no_targets 로 실패해 원인이 보이지 않았다)
|
||||
let serverIds = Array.isArray(b.serverIds) ? b.serverIds.filter(Boolean) : []; if (!serverIds.length) { serverIds = core.defaultTargets(d, module); if (!serverIds.length) throw new HttpError(400, 'no_targets', `대상 서버가 없습니다 — ${module} 은(는) 역할 ${descriptor(module).roles.join('·')} 의 준비된(Agent 연결·승인) 서버가 필요합니다`); }
|
||||
const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version: b.version, serverIds }, idempotencyKey: b.idempotencyKey || `module.deploy:${module}:${b.version}:${serverIds.slice().sort().join(',')}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: module, label: `${module} ${b.version} 배포` }); return { ok: true, job: describe(r.job), created: r.created, targets: serverIds }; }));
|
||||
app.post('/ops/api/modules/:name/rollback', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); threeStep(b, b.serverIds || []); const r = d.jobs.enqueue({ kind: 'module.rollback', subject: { module: ctx.params.name, toVersion: b.toVersion || null, serverIds: b.serverIds || [] }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 되돌리기` }); return { ok: true, job: describe(r.job) }; }));
|
||||
app.post('/ops/api/modules/:name/health', wrap(async (ctx) => { const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.health', subject: { module: ctx.params.name, serverIds: b.serverIds || null }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 건강 확인` }); return { ok: true, job: describe(r.job) }; }));
|
||||
app.post('/ops/api/modules/reconcile', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.reconcile', subject: { module: b.module || null }, createdBy: ctx.operator.id, actorType: 'operator', label: '목표·실제 상태 맞추기' }); return { ok: true, job: describe(r.job) }; }));
|
||||
@@ -181,13 +192,20 @@ export function buildApp(d) {
|
||||
return app;
|
||||
}
|
||||
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
|
||||
/** [0.3.17] 릴리스 화면 데이터: 릴리스 목록 + 모듈(HAL 제외)별 최신 승인 릴리스·안내·상호 연결·프로필·배치 상태·서버 이름 */
|
||||
export function releasesData(d) {
|
||||
const releases = core.listReleases(d); const approved = releases.filter((r) => r.status === 'approved'); const latest = {};
|
||||
for (const m of DEPLOYABLE_MODULES) { const r = approved.find((x) => MODULES[m].layout === 'agent-feature' ? true : !!x.artifacts[m]); latest[m] = r ? r.version : null; }
|
||||
const servers = Object.fromEntries(core.listServers(d).map((s) => [s.id, { name: s.name, role: s.role, status: s.status, profile: s.profile }]));
|
||||
return { ok: true, releases, running: core.selfHealth(d).bootstrap, modules: DEPLOYABLE_MODULES, deployOrder: DEPLOY_ORDER, latest, guides: MODULE_GUIDES, links: MODULE_LINKS, profiles: Object.values(SERVER_PROFILES), servers, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') };
|
||||
}
|
||||
/** 운영 현황(§26.1) */
|
||||
function overview(d) {
|
||||
const servers = core.listServers(d); const health = core.selfHealth(d);
|
||||
const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 });
|
||||
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
|
||||
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'observability'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); }); // [0.3.17] observability → hawkeye
|
||||
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
|
||||
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
|
||||
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
|
||||
@@ -195,7 +213,7 @@ function overview(d) {
|
||||
/** 공개 상태(§31.3): 내부 이름·고객 정보 없음. 고객 영향이 없는 내부 경고는 올리지 않는다. */
|
||||
function publicStatus(d) {
|
||||
const o = overview(d);
|
||||
const label = { HAL: '관리 플랫폼', portal: '고객 포털', webmaker: 'Webmaker', arimail: '메일', backup: '백업', observability: '모니터링' };
|
||||
const label = { HAL: '관리 플랫폼', portal: '고객 포털', webmaker: 'Webmaker', arimail: '메일', backup: '백업', hawkeye: 'HawkEye(모니터링)' };
|
||||
return { ok: true, generatedAt: o.generatedAt, components: o.components.map((c) => ({ name: label[c.name] || c.name, status: c.status === 'ok' ? 'ok' : c.status === 'down' ? 'down' : 'unknown', label: c.status === 'ok' ? '정상' : c.status === 'down' ? '장애' : '확인 중' })), incidents: o.incidents.filter((i) => d.store.get('SELECT public FROM incidents WHERE id=?', i.id)?.public).map((i) => ({ severity: i.severity, title: i.title, startedAt: i.started_at, note: d.store.get('SELECT public_note FROM incidents WHERE id=?', i.id)?.public_note || '' })), maintenance: d.store.all('SELECT name, maintenance_note FROM servers WHERE status=\'maintenance\'').map((s) => ({ note: s.maintenance_note || '계획된 유지보수' })) };
|
||||
}
|
||||
function statusPage(d) { const s = publicStatus(d); return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ariweb 서비스 상태</title><style>body{font-family:-apple-system,"Noto Sans KR",sans-serif;max-width:720px;margin:40px auto;padding:0 16px;color:#1f2933}li{padding:8px 0;border-bottom:1px solid #eee}.ok{color:#1a7f4b}.down{color:#b3261e}.unknown{color:#b26a00}</style></head><body><h1>Ariweb 서비스 상태</h1><p>갱신 ${esc(s.generatedAt)}</p><ul>${s.components.map((c) => `<li><b class="${c.status}">${c.status === 'ok' ? '●' : c.status === 'down' ? '✘' : '◔'} ${esc(c.label)}</b> — ${esc(c.name)}</li>`).join('')}</ul><h2>진행 중인 사건</h2>${s.incidents.length ? `<ul>${s.incidents.map((i) => `<li><b>${esc(i.title)}</b> (${esc(i.startedAt)})<br>${esc(i.note)}</li>`).join('')}</ul>` : '<p>진행 중인 사건이 없습니다.</p>'}<h2>계획된 유지보수</h2>${s.maintenance.length ? `<ul>${s.maintenance.map((m) => `<li>${esc(m.note)}</li>`).join('')}</ul>` : '<p>예정된 유지보수가 없습니다.</p>'}</body></html>`; }
|
||||
|
||||
Reference in New Issue
Block a user