HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
54db663531
commit
9f1e490b51
+24
-2
@@ -28,7 +28,9 @@ import * as dr from './dr.mjs';
|
||||
import * as mailsvc from './mailsvc.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(연결·방·제안·도구)
|
||||
import { registerAiRoutes } from './ai/routes.mjs';
|
||||
import * as settingsStore from './settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||
import { MANAGED_MODULES, publicCatalog, catalogFor } from './settings/catalog.mjs'; // 0.3.0 AI 채팅룸 운영자 API(연결·방·제안·도구)
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const bad = (e) => { if (e instanceof HttpError) throw e; if (e instanceof JobError || (e && e.code)) throw new HttpError(e.status || (e.code === 'quota_insufficient' ? 409 : 400), e.code, e.message, e.detail); throw e; };
|
||||
@@ -63,6 +65,7 @@ export function buildApp(d) {
|
||||
app.post('/agent/v1/heartbeat', wrap(async (ctx) => core.heartbeat(d, ctx.agent, await ctx.body())));
|
||||
app.post('/agent/v1/inventory/webmaker', wrap(async (ctx) => core.webmakerInventoryBatch(d, ctx.agent, await ctx.body())));
|
||||
app.get('/agent/v1/jobs/next', wrap(async (ctx) => { const wait = Math.min(25, Number(ctx.query.wait || 0)); const until = Date.now() + wait * 1000; while (true) { const j = core.nextAgentJob(d, ctx.agent.id); if (j || Date.now() >= until || d.stopping) return { ok: true, job: j || null, serverTime: nowIso() }; await new Promise((r) => setTimeout(r, 500)); } }));
|
||||
app.get('/agent/v1/settings/:module', wrap((ctx) => { if (!ctx.agent.server_id) throw new HttpError(403, 'not_managed_server', '관리 서버의 Agent 만 설정을 받을 수 있습니다'); return { ok: true, settings: settingsStore.payloadForAgent(d, ctx.params.module, ctx.agent.server_id) }; })); // [0.4.0] 자기 서버 범위의 유효 설정(비밀값 포함) — 다른 서버의 덮어쓰기는 받을 수 없다
|
||||
app.get('/agent/v1/jobs/unfinished', wrap((ctx) => ({ ok: true, jobs: core.unfinishedAgentJobs(d, ctx.agent.id) })));
|
||||
app.post('/agent/v1/jobs/:id/progress', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobProgress(d, ctx.params.id, ctx.agent.id, b); return { ok, cancelRequested: core.isAgentJobCancelled(d, ctx.params.id) }; }));
|
||||
app.post('/agent/v1/jobs/:id/result', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobResult(d, ctx.params.id, ctx.agent.id, b); if (!ok) throw new HttpError(409, 'job_not_running', '실행 중인 작업이 아닙니다'); return { ok }; }));
|
||||
@@ -186,6 +189,13 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/dr/drills/:id/finish', wrap(async (ctx) => ({ ok: true, drill: dr.finishDrill(d, ctx.params.id, { by: ctx.operator.id, ...(await ctx.body()) }) })));
|
||||
// 포털 운영 화면 중계(§25·§26.2): 포털의 /internal/ops/* 를 운영자 세션으로 대신 부른다
|
||||
app.route('*', '/ops/api/portal/*', wrap(async (ctx) => { const secret = d.vault.get('service:hal'); if (!secret) throw new HttpError(503, 'portal_not_linked', '포털 연동 키가 없습니다'); if (!['GET', 'HEAD'].includes(ctx.method)) requireRecent(ctx); const rest = ctx.path.replace(/^\/ops\/api\/portal/, '') + (ctx.url.search || ''); const body = ['GET', 'HEAD'].includes(ctx.method) ? undefined : { ...(await ctx.body()), _operator: { id: ctx.operator.id, login: ctx.operator.login } }; const { serviceFetch } = d.serviceauth; const r = await serviceFetch(d.config.portalUrl, 'hal', secret, ctx.method, '/internal/ops' + rest, body, { timeoutMs: 20000, fetchImpl: d.fetch || fetch }).catch((e) => { throw new HttpError(503, 'portal_unreachable', '포털에 연결할 수 없습니다 — 정보 확인 지연'); }); ctx.status = r.status; return r.json || { ok: false, error: 'portal_bad_response' }; }));
|
||||
// ---------------- [0.4.0] 통합 설정 관제(hal/settings): 도입 모듈·Agent 의 서버 단위 설정을 HAL 이 저장(비밀값은 Vault)하고 Agent 작업으로 적용한다
|
||||
app.get('/ops/api/settings', () => ({ ok: true, modules: MANAGED_MODULES, catalog: publicCatalog(), overview: settingsStore.settingsOverview(d) }));
|
||||
app.get('/ops/api/settings/:module', wrap((ctx) => { const m = ctx.params.module; const sc = settingsStore.parseScope(ctx.query.scope || 'global'); const g = settingsStore.getSettings(d, m, sc.scope); const out = { ok: true, ...g, catalog: publicCatalog()[m], placements: settingsStore.placements(d, m) }; if (sc.serverId) { const e = settingsStore.effectiveSettings(d, m, sc.serverId); out.effective = { values: e.values, secrets: e.secrets, sources: e.sources }; out.state = settingsStore.stateFor(d, m, sc.serverId); } return out; }));
|
||||
// 가격·키·모델 변경은 고객 영향이 있으므로 저장·삭제·적용 모두 최근 재인증(10분)을 요구한다. 응답·감사 기록에 값은 싣지 않는다(키 이름만).
|
||||
app.put('/ops/api/settings/:module', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = settingsStore.setSettings(d, { module: ctx.params.module, scope: b.scope || 'global', values: b.values || {}, by: ctx.operator.id }); return { ok: true, ...r }; }));
|
||||
app.delete('/ops/api/settings/:module/:key', wrap((ctx) => { requireRecent(ctx); const r = settingsStore.deleteSetting(d, { module: ctx.params.module, scope: ctx.query.scope || 'global', key: ctx.params.key, by: ctx.operator.id }); return { ok: true, ...r }; }));
|
||||
app.post('/ops/api/settings/:module/apply', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, ...enqueueSettingsApply(d, { module: ctx.params.module, serverIds: b.serverIds, restart: b.restart, nonce: b.nonce, by: ctx.operator.id }) }; }));
|
||||
registerAiRoutes(app, d, { requireRecent, threeStep, wrap }); // 0.3.0: 같은 requireRecent·threeStep 으로 인증 키 변경·제안 승인을 지킨다
|
||||
registerUi(app, d); // [ops UI 추가] /ops/* 화면 라우트(아래 기본 /ops/ 는 UI 가 없을 때의 안내용으로 남긴다)
|
||||
app.get('/ops', (ctx) => ctx.redirect('/ops/')); app.get('/ops/', (ctx) => { const f = join(uiDir, 'index.html'); return ctx.html(existsSync(f) ? readFileSync(f, 'utf8') : `<!doctype html><html lang="ko"><meta charset="utf-8"><title>Ariweb HAL</title><body style="font-family:sans-serif;padding:40px"><h1>Ariweb HAL 운영자 콘솔</h1><p>화면(UI)은 아직 설치되지 않았습니다. API: /ops/api/me</p></body></html>`); });
|
||||
@@ -193,6 +203,18 @@ export function buildApp(d) {
|
||||
}
|
||||
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
|
||||
/** [0.3.17] 릴리스 화면 데이터: 릴리스 목록 + 모듈(HAL 제외)별 최신 승인 릴리스·안내·상호 연결·프로필·배치 상태·서버 이름 */
|
||||
/** [0.4.0] 설정 적용 작업 만들기(운영자 API·AI 도구 공용). 대상을 비우면 그 모듈이 배치된(격리되지 않은) 서버 전체, 없으면 no_targets. 배치되지 않은 서버는 not_placed. */
|
||||
export function enqueueSettingsApply(d, { module, serverIds = [], restart = undefined, nonce = '', by = null, actorType = 'operator' }) {
|
||||
settingsStore.getSettings(d, module, 'global'); // 모르는 모듈이면 unknown_module(404)
|
||||
const placed = settingsStore.placements(d, module); const placedIds = placed.map((s) => s.id);
|
||||
let ids = Array.isArray(serverIds) ? serverIds.filter(Boolean).map(String) : [];
|
||||
if (!ids.length) { ids = placed.filter((s) => s.status !== 'isolated').map((s) => s.id); if (!ids.length) throw new HttpError(400, 'no_targets', `${module} 이(가) 배치된 서버가 없습니다 — 먼저 모듈을 배포하세요`); }
|
||||
const notPlaced = ids.filter((id) => !placedIds.includes(id)); if (notPlaced.length) throw new HttpError(400, 'not_placed', `${module} 이(가) 배치되지 않은 서버: ${notPlaced.join(', ')}`);
|
||||
const cat = catalogFor(module); const rs = restart === undefined || restart === null ? !!cat.restartDefault : !!restart;
|
||||
const hashes = ids.slice().sort().map((id) => settingsStore.effectiveSettings(d, module, id).hash.slice(0, 12)).join(',');
|
||||
const r = d.jobs.enqueue({ kind: 'settings.apply', subject: { module, serverIds: ids, restart: rs }, idempotencyKey: `settings.apply:${module}:${ids.slice().sort().join(',')}:${hashes}:${rs ? 1 : 0}:${nonce || ''}`, createdBy: by, actorType, subjectType: 'module', subjectId: module, label: `${cat.label} 설정 적용` });
|
||||
return { job: describe(r.job), created: r.created, targets: ids, restart: rs };
|
||||
}
|
||||
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; }
|
||||
@@ -206,7 +228,7 @@ function overview(d) {
|
||||
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', '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 },
|
||||
return { ok: true, hal: health, components, settings: { stale: settingsStore.staleCount(d) }, 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() };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user