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
+9
-1
@@ -1,4 +1,4 @@
|
||||
# HAL 운영 스킬 — AI 모델이 HAL 상태·로그를 읽는 법 (Ariweb Suite 0.3.0)
|
||||
# HAL 운영 스킬 — AI 모델이 HAL 상태·로그를 읽는 법 (Ariweb Suite 0.3.0 · 0.4.0 통합 설정)
|
||||
|
||||
이 문서는 HAL 운영자 콘솔 AI 채팅룸에 연결된 모델의 system prompt 에 들어가고, 도구 `hal_skill` 과 `GET /ops/api/ai/skill` 로도 읽을 수 있다. 비밀값은 없다.
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
- 배포 순서(`deployOrder`): agent → portal → webmaker → arimail → backup → hawkeye(HawkEye, 전체 시스템 상황 점검 — 옛 이름 observability). **HAL 자체는 배포 대상이 아니다**(먼저 설치하는 도구, `hal_not_deployable`). HAL 실행 버전이 릴리스보다 오래되면 모듈 배포는 `hal_first` 로 거부된다. 역할별 통합 배포(`server.provision`: webmaker·webhosting·arimail·backup·portal 프로필)와 포털 이전(`portal.relocate`)이 있다. 배포 대상 서버를 비우면 역할에 맞는 준비된 서버 전체로 간다.
|
||||
- `module_targets.health` : `ok` · `down` · `unknown`. `lastCheckAt` 이 10분 넘게 오래되면 "정보 지연(stale)"이지 정상이 아니다.
|
||||
|
||||
## 4.1 통합 설정 관제 (`hal_settings_get`, `hal_settings_set`, `hal_settings_apply`) — 0.4.0
|
||||
- HAL 은 Webmaker·AriMail·Node Agent 의 **서버 단위 설정**(API 키·인증, 마진·가격 정책, 모델, 전체 적용 서비스, 서버별 설정 / AriMail 의 수·발신 방식·Resend·DNS·SMTP 중계·Haraka·표시·사용량 API / Agent heartbeat)을 저장하고 Agent 를 통해 적용한다. AriMail 의 **도메인별 관리**(도메인 제한·계정·메일함)는 메일 서버 사용자가 직접 하며 HAL 이 다루지 않는다.
|
||||
- 범위: `global`(모든 서버 기본값) · `server:<서버 id>`(그 서버만 덮어쓰기). 유효값 = 전체 ← 서버. 서버별 항목(공개 주소·실행 파일 경로·Source·메일 서버 연결 등)은 서버 범위에만 둔다.
|
||||
- 비밀값은 HAL Vault 에만 있고 도구 결과에는 설정 여부(`confidential[].set`)만 나온다. `hal_settings_set` 은 비밀값을 받지 않는다(`secret_not_allowed`) — 운영자가 "통합 설정" 화면에서 직접 넣는다.
|
||||
- 저장만으로는 서버에 반영되지 않는다. `hal_settings_apply` → 작업 `settings.apply`(서버별 `settings.apply.target` → Agent `module.configure`). Agent 는 HAL 에서 자기 서버 범위의 값을 직접 받아 모듈 데이터 디렉터리의 `hal-managed.json` 에 쓰고, 모듈이 적용한 결과(hash)를 확인한다.
|
||||
- 적용 상태: `ok`+`upToDate`(적용됨) · `upToDate=false`(설정이 바뀌어 다시 적용 필요) · `failed`(`settings_hash_mismatch`: Agent 가 받은 설정이 보낸 것과 다름 — 그 사이 설정이 바뀜, `module_apply_failed`: 모듈이 일부 항목을 적용 못 함, `health_failed`: 재시작 뒤 건강 확인 실패). 운영 현황 `settings.stale` 은 적용이 필요한 서버·모듈 수.
|
||||
- HAL 이 관리하는 항목은 각 모듈 관리 콘솔에서 잠긴다(`managed_by_hal`, 409). 모듈 콘솔에서 바꾸라고 안내하지 마라.
|
||||
|
||||
## 5. 사건·알림 (`hal_incidents_list`, `hal_incident_open`, `hal_incident_resolve`)
|
||||
- `severity` : `info` · `warning` · `critical`. `customerImpact` : 고객 영향 설명(없으면 내부 경고). `public` : 공개 상태 페이지 게시 여부.
|
||||
- 같은 원인은 `key` 로 묶인다(열린 사건에 같은 key 로 등록하면 병합). 사건 종료(`resolve`)는 **실제 사용자 경로 확인**(`verifiedUserPath`) 없이는 거부된다.
|
||||
|
||||
@@ -21,6 +21,9 @@ import * as dr from '../dr.mjs';
|
||||
import * as mailsvc from '../mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES } from '../modules/index.mjs';
|
||||
import { overviewLocal, changelog } from '../ui.mjs';
|
||||
import * as settingsStore from '../settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||
import { MANAGED_MODULES, keyDef } from '../settings/catalog.mjs';
|
||||
import { enqueueSettingsApply } from '../app.mjs';
|
||||
|
||||
/** 스킬 문서(상태·로그의 뜻). system prompt 와 hal_skill 도구가 같은 내용을 쓴다. */
|
||||
export const SKILL_DOC = readFileSync(new URL('./skill.md', import.meta.url), 'utf8');
|
||||
@@ -54,6 +57,7 @@ export const TOOLS = [
|
||||
{ name: 'hal_action_status', risk: 'read', description: '승인 요청(제안) 하나의 상태·결과를 본다(proposed·executing·executed·failed·rejected·cancelled). 쓰기·고위험 도구를 부른 뒤 운영자 승인 결과를 확인할 때 쓴다.', schema: S({ actionId: str('제안 id(aia_…)') }, ['actionId']), run: (d, a) => { const x = d.ai && d.ai.getAction(a.actionId); if (!x) throw notFound('action_not_found', '제안이 없습니다'); return { action: x }; } },
|
||||
{ name: 'hal_actions_pending', risk: 'read', description: '운영자 승인을 기다리는 제안 목록(모든 출처).', schema: S({}), run: (d) => ({ actions: d.ai ? d.ai.listActions({ status: 'proposed', limit: 100 }) : [] }) },
|
||||
{ name: 'hal_changelog', risk: 'read', description: 'VERSION_HISTORY.md 의 버전별 변경 이력(버전을 주면 그 절만).', schema: S({ version: str('버전(예: 0.3.0)') }), run: (d, a) => { const c = changelog(); return a.version ? { version: a.version, text: c[a.version] || null } : { versions: Object.keys(c) }; } },
|
||||
{ name: 'hal_settings_get', risk: 'read', description: '[0.4.0] 통합 설정 관제: 모듈(webmaker·arimail·agent)의 HAL 관리 설정을 본다. scope 는 global(기본) 또는 server:<서버 id>. 서버 범위면 유효값(전체←서버)과 적용 상태(적용됨·적용 필요·실패)도 준다. 비밀값은 설정 여부만.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), scope: str('global 또는 server:<서버 id>') }, ['module']), run: (d, a) => { const sc = settingsStore.parseScope(a.scope || 'global'); const g = settingsStore.getSettings(d, a.module, sc.scope); const out = { module: g.module, scope: g.scope, values: g.values, confidential: Object.entries(g.secrets).map(([name, set]) => ({ name, set })) /* 'secret' 이 든 키 이름은 redact 가 가리므로 confidential */, updatedAt: g.updatedAt }; if (sc.serverId) { const e = settingsStore.effectiveSettings(d, a.module, sc.serverId); out.effective = { values: e.values, sources: e.sources }; out.state = settingsStore.stateFor(d, a.module, sc.serverId); } else out.overview = settingsStore.settingsOverview(d)[a.module]; return out; } },
|
||||
// ---------------- 쓰기(write): 승인 + 재인증
|
||||
{ name: 'hal_server_create', risk: 'write', description: '관리 서버를 등록(pending)한다. 등록 토큰 발급·Agent 설치·승인은 운영자가 콘솔에서 한다.', schema: S({ name: str('서버 이름'), role: str('역할', { enum: core.SERVER_ROLES }), location: str('위치') }, ['name', 'role']), run: (d, a, actor) => ({ server: core.publicServer(d, core.createServer(d, { name: a.name, role: a.role, location: a.location || '', by: actor.operatorId })) }) },
|
||||
{ name: 'hal_server_approve', risk: 'write', description: 'Agent 가 연결된 pending 서버를 승인해 배치 가능(ready)으로 만든다.', schema: S({ serverId: SERVER_ID }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => ({ server: core.publicServer(d, core.approveServer(d, a.serverId, actor.operatorId)) }) },
|
||||
@@ -72,6 +76,8 @@ export const TOOLS = [
|
||||
{ name: 'hal_release_approve', risk: 'high', description: '등록된 릴리스를 승인한다(testsPassed·dirty=false·provenanceClean 이 모두 기록된 릴리스만 가능).', schema: S({ version: str('릴리스 버전') }, ['version']), identifiers: (a) => [a.version], run: (d, a, actor) => ({ release: core.approveRelease(d, a.version, actor.operatorId) }) },
|
||||
{ name: 'hal_job_revert', risk: 'high', description: '끝난 작업을 되돌린다(revert 를 지원하는 종류만).', schema: S({ jobId: str('작업 id') }, ['jobId']), identifiers: (a) => [a.jobId], run: async (d, a, actor) => ({ job: jobView(await d.jobs.revert(a.jobId, actor.operatorId)) }) },
|
||||
{ name: 'hal_config_set', risk: 'high', description: 'HAL 운영 설정(공개 주소·포털 주소·offline 판정 시간·지표 보존 일수)을 바꾼다. 비밀값은 받지 않는다. 서비스 재시작 뒤 반영되는 값도 있다.', schema: S({ publicUrl: str('HAL 공개 주소'), portalUrl: str('포털 내부 주소'), offlineAfterMs: num('heartbeat 없음 → offline 판정(ms)'), metricsRetentionDays: num('지표 보존 일수(최소 90)') }), identifiers: () => ['hal'], run: (d, a, actor) => { const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) if (a[k] !== undefined) patch[k] = a[k]; if (!Object.keys(patch).length) throw new HttpError(400, 'nothing_to_set', '바꿀 값이 없습니다'); d.config.set(patch, actor.operatorId); return { updated: Object.keys(patch) }; } },
|
||||
{ name: 'hal_settings_set', risk: 'high', description: '[0.4.0] 통합 설정 관제: 모듈 설정을 HAL 에 저장한다(마진·가격·모델·서비스·서버별 항목). 비밀값(API 키·토큰·비밀번호)은 받지 않는다 — 운영자가 화면에서 직접 넣는다. 저장만으로는 서버에 반영되지 않으며 hal_settings_apply 로 적용한다. 값을 null 로 주면 그 지정을 지운다.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), scope: str('global 또는 server:<서버 id>'), values: { type: 'object', description: '{ 설정 키: 값 } — 키는 hal_settings_get 의 목록을 따른다' } }, ['module', 'values']), identifiers: (a) => [`${a.module}:${a.scope || 'global'}`], run: (d, a, actor) => { for (const k of Object.keys(a.values || {})) { const def = keyDef(a.module, k); if (def && def.type === 'secret') throw new HttpError(400, 'secret_not_allowed', `비밀값(${k})은 AI 도구로 넣을 수 없습니다 — 운영자가 통합 설정 화면에서 직접 입력하세요`); } const r = settingsStore.setSettings(d, { module: a.module, scope: a.scope || 'global', values: a.values || {}, by: actor.operatorId, via: 'ai' }); return { module: r.module, scope: r.scope, changed: r.changed, restartNeeded: r.restartNeeded }; } },
|
||||
{ name: 'hal_settings_apply', risk: 'high', description: '[0.4.0] 통합 설정 관제: 저장된 설정을 서버에 적용하는 작업(settings.apply)을 큐에 넣는다. serverIds 를 비우면 그 모듈이 배치된 서버 전체. restart 는 재시작 필요 항목을 바꿨을 때.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), serverIds: arr('대상 서버 id 목록(비우면 배치된 서버 전체)'), restart: bool('적용 뒤 서비스 재시작') }, ['module']), identifiers: (a) => (a.serverIds && a.serverIds.length ? a.serverIds : [a.module]), run: (d, a, actor) => { const r = enqueueSettingsApply(d, { module: a.module, serverIds: a.serverIds || [], restart: a.restart, nonce: 'ai', by: actor.operatorId }); return { job: jobView(d.jobs.get(r.job.id)), targets: r.targets, restart: r.restart }; } },
|
||||
];
|
||||
const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
|
||||
/** [0.3.2] 연결별 도구 정책 프리셋. 기본값 all(전부) — 쓰기·고위험은 어차피 승인 전 실행되지 않으므로 "바로 쓸 수 있게" 전부 켜 둔다. */
|
||||
@@ -109,6 +115,7 @@ export function validateArgs(tool, args) {
|
||||
if (p.type === 'string') { if (typeof v !== 'string') return `${k} 는 문자열이어야 합니다`; if (v.length > 4000) return `${k} 가 너무 깁니다`; if (p.enum && !p.enum.includes(v)) return `${k} 는 ${p.enum.join('·')} 중 하나여야 합니다`; }
|
||||
else if (p.type === 'number') { if (typeof v !== 'number' || !Number.isFinite(v)) return `${k} 는 숫자여야 합니다`; }
|
||||
else if (p.type === 'boolean') { if (typeof v !== 'boolean') return `${k} 는 true/false 여야 합니다`; }
|
||||
else if (p.type === 'object') { if (typeof v !== 'object' || Array.isArray(v)) return `${k} 는 객체여야 합니다`; if (Object.keys(v).length > 100) return `${k} 항목이 너무 많습니다`; }
|
||||
else if (p.type === 'array') { if (!Array.isArray(v)) return `${k} 는 배열이어야 합니다`; if (v.length > 200) return `${k} 항목이 너무 많습니다`; if (p.items?.type === 'string' && !v.every((x) => typeof x === 'string' && x.length <= 400)) return `${k} 항목은 문자열이어야 합니다`; }
|
||||
}
|
||||
return null;
|
||||
|
||||
+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() };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as core from './core.mjs';
|
||||
import * as settingsStore from './settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||
import { catalogFor } from './settings/catalog.mjs';
|
||||
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES, SERVER_PROFILES } from './modules/index.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -174,6 +176,36 @@ export function registerKinds(engine, d) {
|
||||
async function waitChild(ctx, id, maxMs = d.config.fanoutTimeoutMs || 12 * 3600 * 1000) { const t0 = Date.now(); while (true) { const j = engine.get(id); if (!j) throw new JobError('child_missing', '자식 작업이 없습니다'); if (['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) return j; if (Date.now() - t0 > maxMs) throw new JobError('child_timeout', '자식 작업이 제한 시간 안에 끝나지 않았습니다'); ctx.checkCancel(); await new Promise((r) => setTimeout(r, d.config.agentPollMs || 1000)); } }
|
||||
/** 포털이 다른 서버에 이미 설치되어 있으면 그 서버 id(싱글턴 검사) */
|
||||
function portalOwner(exceptServerId = null) { const r = d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1' + (exceptServerId ? ' AND server_id<>?' : '') + ' ORDER BY updated_at DESC LIMIT 1', ...(exceptServerId ? [exceptServerId] : [])); return r ? r.server_id : null; }
|
||||
// ---------------- [0.4.0] 통합 설정 적용: 부모(settings.apply) → 서버별(settings.apply.target) → Agent module.configure
|
||||
// Agent 는 작업 payload 로 비밀값을 받지 않는다 — 작업에는 hash 만 싣고, Agent 가 HAL 의 /agent/v1/settings/:module 에서 자기 서버 범위의 값을 직접 받아(HMAC) hash 를 맞춰 본 뒤 쓴다.
|
||||
engine.register('settings.apply', { title: '설정 적용', steps: [{ id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
|
||||
async run(job, ctx) {
|
||||
const { module, serverIds, restart } = job.subject; if (!serverIds?.length) throw new JobError('no_targets', '대상 서버가 없습니다');
|
||||
let sum; try { sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'settings.apply.target', serverIds, mk: (serverId) => ({ module, serverId, restart: !!restart }) })); }
|
||||
finally { audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'settings.apply', subjectType: 'module', subjectId: module, detail: { serverIds, restart: !!restart, overall: engine.childrenSummary(job.id).overall } }); }
|
||||
return sum;
|
||||
} });
|
||||
engine.register('settings.apply.target', { title: '설정 적용(대상)', steps: [{ id: 'dispatch', title: 'Agent 에 설정 적용 지시' }, { id: 'await', title: '설정 쓰기·재시작' }, { id: 'verify', title: '적용 hash·건강 확인' }],
|
||||
async run(job, ctx) {
|
||||
const { module, serverId, restart } = job.subject; const cat = catalogFor(module); if (!cat) throw new JobError('unknown_module', `관리 설정이 없는 모듈: ${module}`);
|
||||
const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
const facts = srv.facts || {}; let desc = null; try { desc = descriptor(module); } catch (_) { desc = null; }
|
||||
const unit = (facts.units && facts.units[module]) || desc?.unit || cat.unit; const appDir = (facts.appDirs && facts.appDirs[module]) || desc?.baseDir || `/srv/ariweb/${module}`;
|
||||
const configDir = (facts.configDirs && facts.configDirs[module]) || cat.configDir; const hash = settingsStore.effectiveSettings(d, module, serverId).hash;
|
||||
try {
|
||||
const disp = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: 'module.configure', title: 'Agent 에 설정 적용 지시', payload: { module, unit, appDir, configDir, settingsUrl: `/agent/v1/settings/${module}`, hash, restart: !!restart, descriptor: desc ? { unit, health: desc.health || null } : { unit } } });
|
||||
const sentHash = core.getAgentJob(d, disp.agentJobId)?.payload?.hash || hash; // 재개 때도 실제로 보낸 payload 의 hash 로 판정
|
||||
const r = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
|
||||
return await ctx.step('verify', () => {
|
||||
if (!r || r.hash !== sentHash) throw new JobError('settings_hash_mismatch', `${srv.name}: Agent 가 쓴 설정 hash(${String(r && r.hash).slice(7, 19)})가 보낸 설정(${sentHash.slice(7, 19)})과 다릅니다`, { detail: { hash: r && r.hash } });
|
||||
if (r.health && r.health.ok === false) throw new JobError('health_failed', `${srv.name}: 설정 적용 뒤 건강 확인 실패`, { detail: r.health });
|
||||
if (r.applied && r.applied.hash && r.applied.hash !== sentHash) throw new JobError('module_apply_failed', `${srv.name}: 모듈이 다른 설정(${String(r.applied.hash).slice(7, 19)})을 적용했다고 보고했습니다`, { detail: r.applied });
|
||||
if (r.applied && Array.isArray(r.applied.errors) && r.applied.errors.length) throw new JobError('module_apply_failed', `${srv.name}: 모듈이 일부 항목을 적용하지 못했습니다(${r.applied.errors.map((e) => e.key).join(', ')})`, { detail: r.applied });
|
||||
settingsStore.recordState(d, { serverId, module, status: 'ok', hash: sentHash, jobId: job.id });
|
||||
return { hash: sentHash, restarted: !!r.restarted, appliedVerified: !!(r.applied && r.applied.hash === sentHash), file: r.file || null };
|
||||
});
|
||||
} catch (e) { settingsStore.recordState(d, { serverId, module, status: 'failed', error: `${e.code || 'error'}: ${String(e.message || e).slice(0, 300)}`, jobId: job.id }); throw e; }
|
||||
} });
|
||||
engine.register('server.provision', { title: '역할별 통합 배포', steps: [{ id: 'precheck', title: '서버·프로필·릴리스 확인' }, { id: 'profile', title: '역할·프로필·슬롯 기록' }, { id: 'deploy', title: '모듈 순서대로 배포' }],
|
||||
async run(job, ctx) {
|
||||
const { serverId, profile, version } = job.subject; const prof = SERVER_PROFILES[profile]; if (!prof) throw new JobError('bad_profile', `모르는 프로필: ${profile}`);
|
||||
|
||||
@@ -135,4 +135,9 @@ INSERT OR IGNORE INTO modules(name, target_version, updated_at) SELECT 'hawkeye'
|
||||
DELETE FROM modules WHERE name='observability';
|
||||
` },
|
||||
{ id: 'hal_0009_server_profile', sql: `ALTER TABLE servers ADD COLUMN profile TEXT; ALTER TABLE servers ADD COLUMN slots_json TEXT;` },
|
||||
// 0.4.0 통합 설정 관제(hal/settings): 관리 설정(범위 global | server:<id>, 비밀값은 Vault 참조만) · 서버별 적용 상태(적용된 hash·시각·결과)
|
||||
{ id: 'hal_0010_managed_settings', sql: `
|
||||
CREATE TABLE IF NOT EXISTS managed_settings (scope TEXT NOT NULL, module TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT, is_secret INTEGER NOT NULL DEFAULT 0, updated_by TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(scope, module, key));
|
||||
CREATE TABLE IF NOT EXISTS managed_settings_state (server_id TEXT NOT NULL, module TEXT NOT NULL, applied_hash TEXT, applied_at TEXT, status TEXT NOT NULL DEFAULT 'unknown', error TEXT, job_id TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(server_id, module));
|
||||
` },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* hal/settings/catalog.mjs — 통합 설정 관제(0.4.0, 개발 지시 v0.4.0 §2.1) 설정 목록(catalog).
|
||||
*
|
||||
* HAL 이 도입 모듈·Agent 의 "서버 단위" 설정을 대신 관리한다. 모듈별로 묶음(group)과 항목(key)을 선언하고, 항목마다
|
||||
* type text · int · number · bool · enum · list(쉼표 구분 → 배열) · url · path · secret(비밀값 — Vault 에만, 화면·API 는 있음/없음만)
|
||||
* scope 'both'(기본: 전체 값 + 서버 덮어쓰기) · 'server'(서버별 항목 — 전체 범위에 둘 수 없음)
|
||||
* restart 값이 바뀌면 서비스 재시작이 필요한지(적용 작업의 안내·기본값)
|
||||
* min/max/options/pattern 검증 규칙. 알 수 없는 항목·형식 오류는 저장하지 않는다(bad_setting).
|
||||
* 모듈 쪽 적용 규칙(어느 파일·보관소에 넣는지)은 모듈 자신(Webmaker lib/hal-managed.js, AriMail server/hal-managed.mjs, Agent config.mjs)이 안다 — HAL 은 값만 보낸다.
|
||||
* 도메인별 항목(AriMail LIMIT_* 등 메일 서버 사용자가 직접 관리하는 것)은 여기 없다.
|
||||
*/
|
||||
const K = (key, label, type, extra = {}) => ({ key, label, type, scope: 'both', ...extra });
|
||||
const secret = (key, label, extra = {}) => K(key, label, 'secret', extra);
|
||||
const en = (key, label, options, extra = {}) => K(key, label, 'enum', { options, ...extra });
|
||||
const int = (key, label, min, max, extra = {}) => K(key, label, 'int', { min, max, ...extra });
|
||||
const num = (key, label, min, max, extra = {}) => K(key, label, 'number', { min, max, ...extra });
|
||||
const srv = (def) => ({ ...def, scope: 'server' });
|
||||
const MODEL_REF = { pattern: '^[a-z0-9_-]+:.+$', hint: '<제공자>:<모델> (예: anthropic:claude-opus-5-5)' };
|
||||
|
||||
export const SETTINGS_CATALOG = {
|
||||
webmaker: {
|
||||
module: 'webmaker', label: 'Webmaker', file: 'hal-managed.json', configDir: '/data/webmaker', unit: 'webmaker', restartDefault: false,
|
||||
description: 'Webmaker 관리 콘솔의 서버 단위 설정(API 키·마진·모델·서비스·서버별 설정)을 HAL 이 대신 관리한다. 사이트·회원·템플릿 등 콘텐츠 관리는 Webmaker 콘솔에 남는다.',
|
||||
groups: [
|
||||
{ id: 'auth', label: 'API 키·인증', keys: [
|
||||
en('auth.mode', 'AI 연결 방식', ['server-login', 'oauth-token', 'api-key']), secret('auth.apiKey', 'Anthropic API 키'), secret('auth.oauthToken', 'Anthropic OAuth 토큰'),
|
||||
secret('serviceApiKey', '서비스 키(X-Service-Key, 외부 캐시 조정)'), secret('backlogKey', '작업 반영 목록 읽기 키(X-Backlog-Key)'), secret('mail.apiKey', 'Resend API 키(사이트 메일 발송)'),
|
||||
secret('source.token', 'Gitea API 토큰'), secret('ariweb.secret', 'Ariweb 포털 공유 비밀(SSO·서비스 호출 서명)'), secret('mail.adminPassword', 'AriMail 관리자 비밀번호(계정 생성·삭제용)'),
|
||||
] },
|
||||
{ id: 'pricing', label: '마진·가격 정책', keys: [
|
||||
num('marginPct', '사용자 청구 마진 %', 0, 1000), en('fx.mode', '환율 방식', ['auto', 'manual']), num('fx.manualRate', '직접 입력 환율(1 USD 원화)', 0, 100000), num('fx.spreadPct', '환율 스프레드 %', 0, 50),
|
||||
int('signupCredit', '신규 회원 기본 캐시(원)', 0, 100000000), int('maxEdits', '발행 전 수정 횟수 한도(0=무제한)', 0, 10000), en('defaultTier', '기본 등급', ['basic', 'standard', 'premium']),
|
||||
K('tiers.basic', '기본 등급 모델', 'text', MODEL_REF), K('tiers.standard', '중급 등급 모델', 'text', MODEL_REF), K('tiers.premium', '고급 등급 모델', 'text', MODEL_REF), en('signupApproval', '회원가입 승인 방식', ['immediate', 'approval']),
|
||||
] },
|
||||
{ id: 'models', label: '모델', keys: [
|
||||
K('internalRef', '내부 호출 모델', 'text', MODEL_REF), K('defaultModel', 'Claude 기본 모델 id', 'text', { pattern: '^[a-z0-9.-]+$' }), K('claudeModels', '사용할 Claude 모델(id 목록, 비우면 전체)', 'list'),
|
||||
int('ai.claudeMaxTurns', 'Claude 최대 턴', 1, 500, { restart: true }), int('ai.openaiMaxTurns', 'OpenAI 호환 엔진 최대 턴', 1, 500, { restart: true }), int('ai.maxJobMinutes', 'AI 작업 최대 실행 시간(분)', 5, 240), int('ai.maxConcurrentJobs', '동시 AI 작업 수(서버 전체)', 1, 32),
|
||||
en('ai.codexSandbox', 'codex 샌드박스', ['auto', 'workspace-write', 'none']),
|
||||
] },
|
||||
{ id: 'service', label: '전체 적용 서비스', keys: [
|
||||
K('service.allowSignup', '회원가입 허용', 'bool'), K('letsEncryptEmail', "Let's Encrypt 알림 이메일", 'text'), K('mail.fromName', '발신 표시 이름', 'text'), K('mail.defaultDomain', '기본 발신 도메인', 'text'), K('mail.formBase', '폼 접수 기본 주소', 'url'),
|
||||
K('ariweb.portalUrl', '포털 공개 주소', 'url'), K('endpoints.resendApiBase', 'Resend API', 'url'), K('endpoints.openproviderApiBase', 'Openprovider API', 'url'), K('endpoints.googleApiBase', 'Google API', 'url'), K('endpoints.googleTokenUrl', 'Google 토큰 URL', 'url'), K('endpoints.indexnowEndpoint', 'IndexNow', 'url'),
|
||||
int('limits.copyMaxPages', '복사 최대 페이지 수', 1, 100000, { restart: true }), int('limits.copyMaxAssets', '복사 최대 자원 파일 수', 1, 1000000, { restart: true }), int('ssl.acmeWaitMin', '수동 DNS 대기(분)', 5, 600),
|
||||
] },
|
||||
{ id: 'server', label: '서버별 설정', scope: 'server', keys: [
|
||||
srv(K('service.publicBaseUrl', '서비스 공개 주소', 'url')), srv(K('service.formBaseUrl', '폼 접수 서버 주소', 'url')), srv(K('service.siteOrigin', '생성 사이트 전용 주소', 'text')), srv(K('service.trustedProxies', '신뢰 프록시 IP(쉼표)', 'text')), srv(K('service.internalAllow', '내부 주소 허용 목록(host:port, 쉼표)', 'text')),
|
||||
srv(K('ai.claudeBin', 'claude 실행 파일', 'path', { restart: true })), srv(K('ai.codexBin', 'codex 실행 파일', 'path', { restart: true })),
|
||||
srv(en('source.provider', 'Source 연동', ['gitea', 'none'])), srv(K('source.remote', 'git 푸시 주소', 'text')), srv(K('source.web', 'Source 웹 주소', 'url')), srv(K('source.prefix', '브랜치 접두어', 'text')), srv(K('source.apiUrl', 'Gitea API 주소', 'url')), srv(K('source.user', 'Gitea 계정', 'text')), srv(K('source.repo', 'Gitea 저장소', 'text')),
|
||||
srv(en('mail.provider', '메일 연동', ['none', 'arimail'])), srv(K('mail.url', 'AriMail 공개 주소', 'url')), srv(K('mail.apiUrl', 'AriMail API 주소(내부)', 'url')), srv(K('mail.domain', '메일 도메인', 'text')), srv(K('mail.adminLogin', 'AriMail 관리자 로그인 ID', 'text')), srv(K('mail.nickname', '기본 메일 서버 닉네임', 'text')), srv(K('mail.ip', '기본 메일 서버 IP', 'text')),
|
||||
srv(K('ssl.acmeDir', 'ACME 작업 폴더', 'path', { restart: true })),
|
||||
] },
|
||||
],
|
||||
},
|
||||
arimail: {
|
||||
module: 'arimail', label: 'AriMail', file: 'hal-managed.json', configDir: '<appDir>/data', unit: 'arimail', restartDefault: false,
|
||||
description: 'AriMail 의 서버 단위 설정(수·발신 방식, Resend, DNS, SMTP 중계, Haraka, 표시, 사용량 API, Ariweb SSO)을 HAL 이 관리한다. 도메인별 제한·계정·메일함은 메일 서버 사용자(도메인 관리자)가 직접 관리한다.',
|
||||
groups: [
|
||||
{ id: 'mode', label: '수·발신 방식', keys: [en('inbound', '수신 방식', ['resend', 'haraka'], { restart: true }), en('outbound', '발신 방식', ['resend', 'haraka', 'smtp'], { restart: true })] },
|
||||
{ id: 'resend', label: 'Resend', keys: [secret('RESEND_API_KEY', 'Resend API 키'), secret('RESEND_WEBHOOK_SECRET', 'Resend 웹훅 서명 비밀'), en('RESEND_REGION', 'Resend 지역', ['ap-northeast-1', 'us-east-1', 'eu-west-1', 'sa-east-1'])] },
|
||||
{ id: 'dns', label: 'DNS 제공자', keys: [en('DNS_PROVIDER', 'DNS 제공자', ['none', 'google', 'route53']), secret('DNS_GOOGLE_CREDENTIALS', 'Google Cloud DNS 서비스 계정 JSON'), K('DNS_GOOGLE_PROJECT', 'Google 프로젝트', 'text'), K('DNS_GOOGLE_ZONE', 'Google DNS 존', 'text'), K('DNS_AWS_ACCESS_KEY_ID', 'AWS Access Key ID', 'text'), secret('DNS_AWS_SECRET_ACCESS_KEY', 'AWS Secret Access Key'), K('DNS_AWS_HOSTED_ZONE_ID', 'Route 53 Hosted Zone ID', 'text')] },
|
||||
{ id: 'smtp', label: 'SMTP 중계', keys: [K('SMTP_RELAY_HOST', '중계 호스트', 'text'), int('SMTP_RELAY_PORT', '중계 포트', 1, 65535), en('SMTP_RELAY_SECURITY', '보안', ['starttls', 'tls', 'none']), K('SMTP_RELAY_USER', '중계 계정', 'text'), secret('SMTP_RELAY_PASS', '중계 비밀번호')] },
|
||||
{ id: 'haraka', label: '내장 Haraka(서버별)', scope: 'server', keys: [srv(int('HARAKA_PORT', 'SMTP 수신 포트', 1, 65535, { restart: true })), srv(int('HARAKA_RELAY_PORT', '전달 포트', 1, 65535, { restart: true })), srv(K('HARAKA_HOSTNAME', '서버 호스트 이름', 'text', { restart: true })), srv(K('HARAKA_DKIM_SELECTOR', 'DKIM 선택자', 'text', { restart: true }))] },
|
||||
{ id: 'site', label: '표시·동기화', keys: [K('MAIL_FROM_NAME', '발신 표시 이름', 'text'), K('SITE_TITLE', '사이트 제목', 'text'), K('MAILBOX_OWNER_LOGIN', '기본 메일함 소유 계정', 'text'), K('STATS_TIMEZONE', '통계 시간대', 'text'), int('SYNC_INTERVAL_MS', '동기화 주기(ms)', 60000, 86400000)] },
|
||||
{ id: 'usage', label: '사용량 API', keys: [secret('USAGE_API_KEY', '사용량 API 키(Webmaker 가 호출)'), K('USAGE_API_ALLOW', '사용량 API 허용 IP(쉼표)', 'text')] },
|
||||
{ id: 'ariweb', label: 'Ariweb 연동', keys: [secret('ARIWEB_SSO_SECRET', 'Ariweb SSO 공유 비밀')] },
|
||||
],
|
||||
},
|
||||
agent: {
|
||||
module: 'agent', label: 'Node Agent', file: 'hal-managed.json', configDir: '<dataDir>', unit: 'ariweb-agent', restartDefault: true,
|
||||
description: '각 서버의 Node Agent 설정. 적용하면 Agent 가 결과를 보고한 뒤 스스로 재시작한다.',
|
||||
groups: [{ id: 'agent', label: 'Agent', keys: [int('heartbeatMs', 'heartbeat 주기(ms)', 5000, 600000, { restart: true })] }],
|
||||
},
|
||||
};
|
||||
export const MANAGED_MODULES = Object.keys(SETTINGS_CATALOG);
|
||||
export const catalogFor = (module) => SETTINGS_CATALOG[module] || null;
|
||||
const INDEX = new Map(MANAGED_MODULES.map((m) => [m, new Map(SETTINGS_CATALOG[m].groups.flatMap((g) => g.keys.map((k) => [k.key, { ...k, group: g.id }])))]));
|
||||
export const keyDef = (module, key) => (INDEX.get(module) || new Map()).get(key) || null;
|
||||
export const allKeys = (module) => [...(INDEX.get(module) || new Map()).values()];
|
||||
/** 목록의 공개 형태(화면·AI 도구) */
|
||||
export const publicCatalog = () => Object.fromEntries(MANAGED_MODULES.map((m) => { const c = SETTINGS_CATALOG[m]; return [m, { module: m, label: c.label, description: c.description, restartDefault: c.restartDefault, groups: c.groups.map((g) => ({ id: g.id, label: g.label, scope: g.scope || 'both', keys: g.keys.map((k) => ({ key: k.key, label: k.label, type: k.type, scope: k.scope, options: k.options, min: k.min, max: k.max, restart: !!k.restart, hint: k.hint })) })) }]; }));
|
||||
/** 값 하나를 형식에 맞게 정규화한다. 실패하면 { code: 'bad_setting' } 오류 */
|
||||
export function normalizeValue(def, raw) {
|
||||
const bad = (why) => { throw Object.assign(new Error(`${def.label}(${def.key}): ${why}`), { code: 'bad_setting', status: 400, key: def.key }); };
|
||||
if (raw === null || raw === undefined) return null;
|
||||
switch (def.type) {
|
||||
case 'secret': { const s = String(raw); return s.trim() === '' ? null : s; }
|
||||
case 'bool': { if (typeof raw === 'boolean') return raw; const s = String(raw).trim().toLowerCase(); if (['true', '1', 'on', 'yes'].includes(s)) return true; if (['false', '0', 'off', 'no', ''].includes(s)) return false; return bad('참/거짓 값이어야 합니다'); }
|
||||
case 'int': case 'number': { if (String(raw).trim() === '') return null; const n = Number(raw); if (!Number.isFinite(n)) return bad('숫자여야 합니다'); if (def.type === 'int' && !Number.isInteger(n)) return bad('정수여야 합니다'); if (def.min !== undefined && n < def.min) return bad(`${def.min} 이상이어야 합니다`); if (def.max !== undefined && n > def.max) return bad(`${def.max} 이하여야 합니다`); return n; }
|
||||
case 'enum': { const s = String(raw).trim(); if (s === '') return null; if (!def.options.includes(s)) return bad(`허용 값: ${def.options.join(', ')}`); return s; }
|
||||
case 'list': { const arr = Array.isArray(raw) ? raw : String(raw).split(',').map((x) => x.trim()); return arr.map(String).map((x) => x.trim()).filter(Boolean); }
|
||||
case 'url': { const s = String(raw).trim(); if (s === '') return null; if (!/^https?:\/\/[^\s]+$/i.test(s)) return bad('http(s):// 주소여야 합니다'); return s; }
|
||||
default: { const s = String(raw).trim(); if (s === '') return null; if (s.length > 4000) return bad('너무 깁니다'); if (def.pattern && !new RegExp(def.pattern).test(s)) return bad(def.hint || '형식이 올바르지 않습니다'); return s; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* hal/settings/index.mjs — 통합 설정 관제(0.4.0) 저장소·유효값·적용 상태.
|
||||
*
|
||||
* 저장: managed_settings(scope, module, key, value_json). scope 는 'global'(전체) 또는 'server:<serverId>'(서버 덮어쓰기).
|
||||
* 비밀값(type secret)은 행에 두지 않고 Vault 에 `managed:<scope>:<module>:<key>` 로만 둔다. 행의 value_json 은 { vault: <이름> } 참조.
|
||||
* 유효값 = 전체 ← 서버 덮어쓰기. hash 는 모듈·유효값(비밀값 포함)의 정규 JSON sha256 — Agent 가 받은 내용과 HAL 이 보낸 내용이 같은지 검증하고, 적용 상태(managed_settings_state)와 비교해 "적용 필요"를 판단한다.
|
||||
* 감사 기록에는 키 이름만 남긴다(값·비밀값 없음).
|
||||
*/
|
||||
import { nowIso } from '../../lib/db.mjs';
|
||||
import { audit } from '../../lib/audit.mjs';
|
||||
import { JobError } from '../../lib/jobs.mjs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { catalogFor, keyDef, allKeys, MANAGED_MODULES } from './catalog.mjs';
|
||||
import { normalizeValue } from './catalog.mjs';
|
||||
|
||||
export { MANAGED_MODULES };
|
||||
const vaultName = (scope, module, key) => `managed:${scope}:${module}:${key}`;
|
||||
export function parseScope(scope) {
|
||||
const s = String(scope || 'global'); if (s === 'global') return { scope: 'global', serverId: null };
|
||||
const m = /^server:([A-Za-z0-9_-]+)$/.exec(s); if (!m) throw new JobError('bad_scope', '범위는 global 또는 server:<서버 id> 이어야 합니다', { status: 400 });
|
||||
return { scope: s, serverId: m[1] };
|
||||
}
|
||||
function requireModule(module) { const c = catalogFor(module); if (!c) throw new JobError('unknown_module', `관리 설정이 없는 모듈: ${module}`, { status: 404 }); return c; }
|
||||
|
||||
/** 범위 하나의 저장된 값(비밀값은 있음/없음) */
|
||||
export function getSettings(d, module, scope = 'global') {
|
||||
requireModule(module); const { scope: sc } = parseScope(scope);
|
||||
const values = {}; const secrets = {}; for (const k of allKeys(module)) if (k.type === 'secret') secrets[k.key] = false;
|
||||
for (const r of d.store.all('SELECT key, value_json, is_secret, updated_at, updated_by FROM managed_settings WHERE scope=? AND module=?', sc, module)) {
|
||||
if (!keyDef(module, r.key)) continue;
|
||||
if (r.is_secret) secrets[r.key] = d.vault.has(vaultName(sc, module, r.key)); else values[r.key] = JSON.parse(r.value_json);
|
||||
}
|
||||
return { module, scope: sc, values, secrets, updatedAt: d.store.get('SELECT MAX(updated_at) AS t FROM managed_settings WHERE scope=? AND module=?', sc, module)?.t || null };
|
||||
}
|
||||
/** 저장(여러 항목). 비밀값: 빈 값·null 이면 삭제, 아니면 Vault 에 저장. 일반 값: null 이면 삭제. 검증을 모두 통과한 뒤에만 쓴다. */
|
||||
export function setSettings(d, { module, scope = 'global', values = {}, by = null, actorType = 'operator', via = null }) {
|
||||
requireModule(module); const { scope: sc, serverId } = parseScope(scope);
|
||||
if (!values || typeof values !== 'object' || Array.isArray(values)) throw new JobError('bad_setting', '값은 객체여야 합니다', { status: 400 });
|
||||
const plan = [];
|
||||
for (const [key, raw] of Object.entries(values)) {
|
||||
const def = keyDef(module, key); if (!def) throw new JobError('bad_setting', `알 수 없는 설정 항목: ${module}.${key}`, { status: 400 });
|
||||
if (def.scope === 'server' && !serverId) throw new JobError('bad_setting', `${def.label}(${key})은(는) 서버별 항목이라 전체 범위에 둘 수 없습니다`, { status: 400 });
|
||||
let v; try { v = normalizeValue(def, raw); } catch (e) { throw new JobError('bad_setting', e.message, { status: 400, detail: { key } }); }
|
||||
plan.push({ key, def, value: v });
|
||||
}
|
||||
const now = nowIso(); const changed = [];
|
||||
for (const { key, def, value } of plan) {
|
||||
const name = vaultName(sc, module, key);
|
||||
if (value === null) { const had = d.store.run('DELETE FROM managed_settings WHERE scope=? AND module=? AND key=?', sc, module, key).changes > 0; if (def.type === 'secret') { if (d.vault.remove(name) || had) changed.push(key); } else if (had) changed.push(key); continue; }
|
||||
if (def.type === 'secret') { d.vault.set(name, value, { kind: 'managed-setting' }); d.store.run('INSERT INTO managed_settings(scope, module, key, value_json, is_secret, updated_by, updated_at) VALUES(?,?,?,?,1,?,?) ON CONFLICT(scope, module, key) DO UPDATE SET value_json=excluded.value_json, is_secret=1, updated_by=excluded.updated_by, updated_at=excluded.updated_at', sc, module, key, JSON.stringify({ vault: name }), by, now); changed.push(key); continue; }
|
||||
const prev = d.store.get('SELECT value_json FROM managed_settings WHERE scope=? AND module=? AND key=?', sc, module, key); const vj = JSON.stringify(value);
|
||||
if (prev && prev.value_json === vj) continue;
|
||||
d.store.run('INSERT INTO managed_settings(scope, module, key, value_json, is_secret, updated_by, updated_at) VALUES(?,?,?,?,0,?,?) ON CONFLICT(scope, module, key) DO UPDATE SET value_json=excluded.value_json, is_secret=0, updated_by=excluded.updated_by, updated_at=excluded.updated_at', sc, module, key, vj, by, now); changed.push(key);
|
||||
}
|
||||
if (changed.length) audit(d.store, { actorType, actorId: by, action: 'settings.set', subjectType: 'settings', subjectId: `${module}:${sc}`, detail: { module, scope: sc, keys: changed, ...(via ? { via } : {}) } });
|
||||
return { module, scope: sc, changed, restartNeeded: plan.some((p) => changed.includes(p.key) && p.def.restart) };
|
||||
}
|
||||
export function deleteSetting(d, { module, scope = 'global', key, by = null, actorType = 'operator' }) {
|
||||
requireModule(module); const { scope: sc } = parseScope(scope); const def = keyDef(module, key); if (!def) throw new JobError('bad_setting', `알 수 없는 설정 항목: ${module}.${key}`, { status: 400 });
|
||||
return setSettings(d, { module, scope: sc, values: { [key]: null }, by, actorType });
|
||||
}
|
||||
/** 서버 하나의 유효값(전체 ← 서버). includeSecrets 면 비밀값을 values 에 포함(Agent 전송·hash 계산용). hash 는 언제나 비밀값을 포함해 계산한다. */
|
||||
export function effectiveSettings(d, module, serverId, { includeSecrets = false } = {}) {
|
||||
requireModule(module); const scopes = ['global', ...(serverId ? [`server:${serverId}`] : [])];
|
||||
const values = {}; const sources = {}; const secretVals = {}; const secrets = {};
|
||||
for (const k of allKeys(module)) if (k.type === 'secret') secrets[k.key] = false;
|
||||
for (const sc of scopes) for (const r of d.store.all('SELECT key, value_json, is_secret FROM managed_settings WHERE scope=? AND module=?', sc, module)) {
|
||||
if (!keyDef(module, r.key)) continue; const src = sc === 'global' ? 'global' : 'server';
|
||||
if (r.is_secret) { const v = d.vault.get(vaultName(sc, module, r.key)); if (v === null) continue; secretVals[r.key] = v; secrets[r.key] = true; sources[r.key] = src; }
|
||||
else { values[r.key] = JSON.parse(r.value_json); sources[r.key] = src; }
|
||||
}
|
||||
const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
|
||||
const hash = 'sha256:' + createHash('sha256').update(`${module}|${canon(values)}|${canon(secretVals)}`).digest('hex'); // 'sha256:' 접두어 — 긴 hex 는 비밀값 형태로 가려지므로(lib/redact) 작업 결과·로그에서 비교할 수 있게
|
||||
return { module, serverId: serverId || null, values: includeSecrets ? { ...values, ...secretVals } : values, secrets, sources, hash };
|
||||
}
|
||||
/** Agent 에 보내는 내용(비밀값 포함) */
|
||||
export function payloadForAgent(d, module, serverId) { const e = effectiveSettings(d, module, serverId, { includeSecrets: true }); return { module, serverId, hash: e.hash, generatedAt: nowIso(), values: e.values }; }
|
||||
/** 모듈이 배치된 서버(적용 대상). agent 는 활성 Agent 가 있는 모든 서버. */
|
||||
export function placements(d, module) {
|
||||
requireModule(module);
|
||||
if (module === 'agent') return d.store.all("SELECT s.id, s.name, s.status FROM servers s WHERE EXISTS (SELECT 1 FROM agents a WHERE a.server_id=s.id AND a.status='active') ORDER BY s.created_at");
|
||||
return d.store.all('SELECT s.id, s.name, s.status FROM module_targets t JOIN servers s ON s.id=t.server_id WHERE t.module=? AND t.active=1 ORDER BY s.created_at', module);
|
||||
}
|
||||
export function recordState(d, { serverId, module, status, hash = null, error = null, jobId = null }) {
|
||||
const now = nowIso();
|
||||
if (status === 'ok') d.store.run('INSERT INTO managed_settings_state(server_id, module, applied_hash, applied_at, status, error, job_id, updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(server_id, module) DO UPDATE SET applied_hash=excluded.applied_hash, applied_at=excluded.applied_at, status=excluded.status, error=NULL, job_id=excluded.job_id, updated_at=excluded.updated_at', serverId, module, hash, now, 'ok', null, jobId, now);
|
||||
else d.store.run('INSERT INTO managed_settings_state(server_id, module, applied_hash, applied_at, status, error, job_id, updated_at) VALUES(?,?,NULL,NULL,?,?,?,?) ON CONFLICT(server_id, module) DO UPDATE SET status=excluded.status, error=excluded.error, job_id=excluded.job_id, updated_at=excluded.updated_at', serverId, module, status, error, jobId, now);
|
||||
}
|
||||
/** 서버 하나의 적용 상태(현재 유효값 hash 와 비교) */
|
||||
export function stateFor(d, module, serverId) {
|
||||
const r = d.store.get('SELECT * FROM managed_settings_state WHERE server_id=? AND module=?', serverId, module); const cur = effectiveSettings(d, module, serverId).hash;
|
||||
return { serverId, module, status: r ? r.status : 'unknown', appliedHash: r?.applied_hash || null, appliedAt: r?.applied_at || null, error: r?.error || null, jobId: r?.job_id || null, currentHash: cur, upToDate: !!(r && r.status === 'ok' && r.applied_hash === cur) };
|
||||
}
|
||||
const hasAnySettings = (d, module) => !!d.store.get('SELECT 1 AS x FROM managed_settings WHERE module=? LIMIT 1', module);
|
||||
/** 모듈별 개요: 배치 서버·적용 상태·적용 필요 수(설정이 하나도 없는 모듈은 적용 필요로 세지 않는다) */
|
||||
export function settingsOverview(d) {
|
||||
const out = {};
|
||||
for (const m of MANAGED_MODULES) {
|
||||
const any = hasAnySettings(d, m); const servers = placements(d, m).map((s) => { const st = stateFor(d, m, s.id); return { serverId: s.id, serverName: s.name, serverStatus: s.status, ...st, upToDate: any ? st.upToDate : (st.status === 'ok' || st.status === 'unknown') }; });
|
||||
out[m] = { module: m, hasSettings: any, servers, stale: servers.filter((s) => !s.upToDate).length, globalCount: d.store.get('SELECT COUNT(*) AS n FROM managed_settings WHERE module=? AND scope=?', m, 'global').n };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export const staleCount = (d) => Object.values(settingsOverview(d)).reduce((n, m) => n + m.stale, 0);
|
||||
+9
-2
@@ -28,13 +28,16 @@ import * as mailsvc from './mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||
import { releasesData } from './app.mjs';
|
||||
import * as R from './ui/render.mjs';
|
||||
import * as settingsStore from './settings/index.mjs';
|
||||
import { staleCount as settingsStaleCount, settingsOverview } from './settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||
import { publicCatalog, MANAGED_MODULES } from './settings/catalog.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
/** 메뉴(§25) */
|
||||
export const MENU = [
|
||||
['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'],
|
||||
['sites', '/ops/sites', '사이트와 배포'], ['mail', '/ops/mail', '메일 서비스'], ['domains', '/ops/domains', '도메인과 인증서'], ['servers', '/ops/servers', '서버와 용량'],
|
||||
['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['jobs', '/ops/jobs', '운영 작업'],
|
||||
['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['settings', '/ops/settings', '통합 설정'], ['jobs', '/ops/jobs', '운영 작업'],
|
||||
['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['ai', '/ops/ai', 'AI 연동'], ['account', '/ops/account', '운영자 계정'],
|
||||
];
|
||||
|
||||
@@ -81,6 +84,10 @@ export function registerUi(app, d) {
|
||||
const actions = `<div class="actions">${ps.status === 'pending' ? `<button class="btn primary" data-act="server-token" data-id="${esc(s.id)}">등록 정보 발급</button> <button class="btn" data-act="server-approve" data-id="${esc(s.id)}">연결 확인 후 승인</button>` : ''}${ps.rawStatus === 'maintenance' ? `<button class="btn" data-act="server-maint-end" data-id="${esc(s.id)}">유지보수 종료·신규 배치 재개</button>` : `<button class="btn" data-act="server-maint-start" data-id="${esc(s.id)}">유지보수 시작(신규 배치 중지)</button>`} <button class="btn" data-act="server-accepts" data-id="${esc(s.id)}" data-v="${ps.acceptsNew ? 0 : 1}">${ps.acceptsNew ? '신규 배치 중지' : '신규 배치 허용'}</button> <button class="btn danger" data-act="server-isolate" data-id="${esc(s.id)}">격리</button> <button class="btn danger" data-act="server-delete" data-id="${esc(s.id)}" data-hosted="${ps.capacity.sitesHosted || 0}">서버 삭제</button></div>`;
|
||||
return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}<h2>지표(90일 이상 보존)</h2><div class="row"><label>지표 <select id="metric-key"><option value="cpu">CPU</option><option value="mem">메모리</option><option value="disk">디스크</option><option value="inode">inode</option><option value="net">네트워크 송신</option></select></label> <label>기간 <select id="metric-days"><option value="1">24시간</option><option value="7">7일</option><option value="30" selected>30일</option><option value="90">90일</option></select></label></div><div id="metrics">${R.statePanel('loading', '지표를 불러오는 중')}</div>` }); });
|
||||
// ---------------- 릴리스(§32.1)
|
||||
// [0.4.0] 통합 설정 관제: 모듈 탭 → 범위(전체 / 서버) → 묶음별 입력 → 저장 → 서버에 적용 → 서버별 적용 상태
|
||||
app.get('/ops/settings', (ctx) => { const module = MANAGED_MODULES.includes(ctx.query.module) ? ctx.query.module : MANAGED_MODULES[0]; let scope = String(ctx.query.scope || 'global'); const placements = settingsStore.placements(d, module); if (scope !== 'global' && !placements.some((p) => scope === 'server:' + p.id)) scope = 'global';
|
||||
const stored = settingsStore.getSettings(d, module, scope); const effective = scope === 'global' ? null : settingsStore.effectiveSettings(d, module, scope.slice(7));
|
||||
return shell(ctx, { title: '통합 설정 관제', nav: 'settings', page: 'settings', id: module, body: `<p class="muted">각 서버의 Webmaker·AriMail·Node Agent 관리 콘솔 중 서버 단위 설정(API 키·마진·가격·모델·서비스·서버별 설정)을 HAL 이 관리합니다. HAL 이 관리하는 항목은 각 모듈 관리 콘솔에서 잠깁니다. 도메인별 메일 관리·사이트·회원은 각 서비스에서 관리합니다.</p>${R.settingsPageHtml({ modules: MANAGED_MODULES, catalog: publicCatalog(), module, scope, placements, stored, effective, overview: settingsOverview(d) })}` }); });
|
||||
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ ...releasesData(d), running: core.bootstrapState(d) }, changelog()) })); // [0.3.17] 모듈별 최신 릴리스·안내 중심, HAL 제외
|
||||
// ---------------- 작업(§29)
|
||||
app.get('/ops/jobs', (ctx) => { const status = ctx.query.status ? ctx.query.status.split(',') : null; const jobs = d.jobs.list({ status, kind: ctx.query.kind || null, limit: 200 }).map((j) => ({ ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id }));
|
||||
@@ -133,7 +140,7 @@ export function overviewLocal(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: '배치 없음' }); });
|
||||
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: settingsStaleCount(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() };
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ const PAGES = {
|
||||
server: async (id) => { const draw = async () => { const key = $('#metric-key')?.value || 'cpu'; const days = Number($('#metric-days')?.value || 30); try { const r = await api('GET', `/ops/api/servers/${id}/metrics?since=${encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString())}&limit=5000`); $('#metrics').innerHTML = R.metricsSvg(r.samples, key); } catch (e) { $('#metrics').innerHTML = R.relayError(e); } }; $('#metric-key')?.addEventListener('change', draw); $('#metric-days')?.addEventListener('change', draw); await draw(); },
|
||||
job: async (id) => { try { const r = await api('GET', '/ops/api/jobs/' + id); const j = r.job; if (!['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) { const jb = $('.job', body()); if (jb && jb.outerHTML !== R.jobBlock(j)) jb.outerHTML = R.jobBlock(j); } } catch (_) {} },
|
||||
recovery: async () => { document.querySelectorAll('[data-tab]').forEach((a) => a.onclick = (e) => { e.preventDefault(); document.querySelectorAll('[data-tab]').forEach((x) => x.classList.remove('on')); a.classList.add('on'); document.querySelectorAll('main section').forEach((s) => { s.hidden = s.id !== a.dataset.tab; }); }); if (location.hash === '#dr') $('[data-tab=dr]')?.click(); },
|
||||
// [0.4.0] 통합 설정 관제: 범위 바꾸면 그 범위로 다시 연다
|
||||
settings: () => { const sel = $('#settings-scope'); if (sel) sel.onchange = () => { location.href = `/ops/settings?module=${encodeURIComponent(sel.dataset.module)}&scope=${encodeURIComponent(sel.value)}`; }; },
|
||||
login: () => { const f = $('#login-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { const r = await api('POST', '/ops/api/login', { login: fd.get('login'), password: fd.get('password'), totp: fd.get('totp') || undefined }); if (r.mfaRequired) { $('#totp-row').hidden = false; $('#msg').textContent = r.message || '인증 코드를 입력하세요'; return; } location.href = '/ops/'; } catch (err) { $('#msg').textContent = err.message; } }; },
|
||||
setup: () => { const f = $('#setup-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { await api('POST', '/ops/api/setup', Object.fromEntries(fd)); toast('첫 운영자를 만들었습니다. 로그인하세요'); location.href = '/ops/login'; } catch (err) { $('#msg').textContent = err.message; } }; },
|
||||
};
|
||||
@@ -87,6 +89,13 @@ const ACT = {
|
||||
const r = await call('POST', '/ops/api/portal/relocate', { fromServerId: cur.server_id, toServerId: v.toServerId, version: v.version, ...c }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-health': async (b) => { const r = await call('POST', `/ops/api/modules/${b.m}/health`, { serverIds: b.s ? [b.s] : null }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-rollback': async (b) => { const c = await threeStep({ title: `${b.m} 되돌리기`, impacts: [`서버 ${b.s} 의 ${b.m} 을(를) 이전 릴리스로 되돌립니다`, '되돌리는 동안 해당 모듈이 잠시 중단될 수 있습니다', '이전 릴리스 디렉터리가 남아 있어야 합니다(최근 3개 보관)'], identifiers: [b.s], revertible: '되돌린 뒤 다시 배포하면 복구할 수 있습니다' }); if (!c) return; const r = await call('POST', `/ops/api/modules/${b.m}/rollback`, { ...c, serverIds: [b.s] }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
// ---- [0.4.0] 통합 설정 관제: 저장(재인증) · 비밀값 삭제 · 서버에 적용(서버 선택·재시작 여부 → 작업)
|
||||
'settings-save': async () => { const f = $('#settings-form'); if (!f) return; const values = {}; for (const el of f.querySelectorAll('[data-key]')) { const k = el.dataset.key; const v = el.value; if (el.dataset.type === 'secret') { if (v !== '') values[k] = v; continue; } values[k] = v === '' ? null : v; }
|
||||
const r = await call('PUT', `/ops/api/settings/${f.dataset.module}`, { scope: f.dataset.scope, values }); toast(r.changed.length ? `저장했습니다(${r.changed.length}개${r.restartNeeded ? ' · 재시작 필요 항목 포함' : ''}) — "서버에 적용"으로 반영하세요` : '바뀐 항목이 없습니다'); reload(); },
|
||||
'settings-delete': async (b) => { const f = $('#settings-form'); if (!f || !confirm(`${b.key} 비밀값을 HAL 에서 지웁니다(서버에는 다음 적용 때 반영)`)) return; await call('DELETE', `/ops/api/settings/${f.dataset.module}/${encodeURIComponent(b.key)}?scope=${encodeURIComponent(f.dataset.scope)}`); reload(); },
|
||||
'settings-apply': async (b) => { const sv = await api('GET', `/ops/api/settings/${b.m}`); const servers = (sv.placements || []).map((p) => ({ id: p.id, name: p.name, status: p.status === 'isolated' ? 'isolated' : 'ready' })); const restartDefault = !!(sv.catalog && sv.catalog.restartDefault);
|
||||
const v = await ask(`${(sv.catalog && sv.catalog.label) || b.m} 설정 적용 — 대상 서버`, [{ name: 'serverIds', label: '대상 서버(이 모듈이 배치된 서버)', type: 'checks', html: R.serverChoiceHtml(servers, { name: 'serverIds', multi: true, eligible: b.s ? [b.s] : null }) }, { name: 'restart', label: '적용 뒤 서비스 재시작(재시작 필요 항목을 바꾼 경우)', type: 'checkbox', value: restartDefault }], { okLabel: '적용 작업 만들기', note: 'Agent 가 HAL 에서 설정을 직접 받아 모듈 데이터 디렉터리의 hal-managed.json 에 쓰고, 모듈이 적용한 결과(hash)를 확인합니다.' });
|
||||
if (!v) return; if (!v.serverIds || !v.serverIds.length) return toast('대상 서버를 하나 이상 선택하세요'); const r = await call('POST', `/ops/api/settings/${b.m}/apply`, { serverIds: v.serverIds, restart: !!v.restart, nonce: Date.now() }); toast(`설정 적용 작업을 만들었습니다(${r.targets.length}대)`); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-reconcile': async () => { const r = await call('POST', '/ops/api/modules/reconcile', {}); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'server-new': async () => { const v = await ask('서버 등록 — 1. 역할·위치', [{ name: 'name', label: '서버 이름', required: true }, { name: 'role', label: '역할', type: 'select', options: ['web', 'mail', 'backup', 'control'] }, { name: 'location', label: '위치(예: 서울 IDC-1)' }]); if (!v) return; const r = await call('POST', '/ops/api/servers', v); location.href = '/ops/servers/' + r.server.id; },
|
||||
'server-token': async (b) => { const r = await call('POST', `/ops/api/servers/${b.id}/token`); showOnce('일회용 등록 정보(10분·1회)', r.installCommand || r.token, '대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); },
|
||||
|
||||
+32
-1
@@ -118,6 +118,37 @@ export function serverChoiceHtml(servers, { name = 'serverId', multi = false, el
|
||||
if (!servers || !servers.length) return '<div class="aw-state">선택할 서버가 없습니다 — 먼저 서버를 등록하고 Agent 를 연결·승인하세요.</div>';
|
||||
return `<div class="choice-list">${servers.map((s) => { const ok = s.status === 'ready'; const checked = multi && ok && (!eligible || eligible.includes(s.id)); return `<label class="choice${ok ? '' : ' off'}"><input type="${multi ? 'checkbox' : 'radio'}" name="${esc(name)}" value="${esc(s.id)}"${ok ? '' : ' disabled'}${checked ? ' checked' : ''}> <b>${esc(s.name)}</b> <span class="muted small">${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(SERVER_LABEL[s.status] || s.status)}${s.agentVersion ? ' · Agent ' + esc(s.agentVersion) : ' · Agent 없음'}${s.modules && Object.keys(s.modules).length ? ' · ' + Object.entries(s.modules).map(([m, x]) => `${esc(m)} ${esc(x.actual || '—')}`).join(', ') : ''}</span></label>`; }).join('')}</div>`;
|
||||
}
|
||||
// ---------------- [0.4.0] 통합 설정 관제
|
||||
const SRC_LABEL = { global: '전체', server: '이 서버' };
|
||||
const showVal = (v) => Array.isArray(v) ? v.join(', ') : typeof v === 'boolean' ? (v ? '켬' : '끔') : String(v);
|
||||
/** 설정 입력 폼. cat = 설정 목록의 모듈 하나(groups·keys), scope = 'global' | 'server:<id>'. 비밀값은 값을 보이지 않고 설정됨/없음과 새 값 입력만. 전체 범위에는 서버별 항목이 없다. */
|
||||
export function settingsFormHtml(cat, { scope = 'global', values = {}, secrets = {}, effective = null } = {}) {
|
||||
const isServer = String(scope).startsWith('server:');
|
||||
const field = (k) => {
|
||||
const v = values[k.key]; const has = v !== undefined && v !== null; const inh = isServer && effective && effective.sources && effective.sources[k.key] === 'global' && !has ? `<span class="muted small">전체 값 ${esc(showVal(effective.values[k.key]))}</span>` : '';
|
||||
const tags = `${k.restart ? ' <span class="badge neutral">재시작 필요</span>' : ''}`; const id = `set-${esc(k.key.replace(/[^a-zA-Z0-9_-]/g, '_'))}`;
|
||||
let input;
|
||||
if (k.type === 'secret') { const on = !!secrets[k.key]; input = `<input id="${id}" type="password" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="secret" autocomplete="new-password" placeholder="새 값 입력(비우면 그대로)"> <span class="${on ? 'ok' : 'muted'}">${on ? '● 설정됨' : '○ 없음'}</span>${on ? ` <button type="button" class="btn small" data-act="settings-delete" data-key="${esc(k.key)}">삭제</button>` : ''}`; }
|
||||
else if (k.type === 'bool') input = `<select id="${id}" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="bool"><option value="">(지정 안 함)</option><option value="true"${v === true ? ' selected' : ''}>켬</option><option value="false"${v === false ? ' selected' : ''}>끔</option></select>`;
|
||||
else if (k.type === 'enum') input = `<select id="${id}" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="enum"><option value="">(지정 안 함)</option>${(k.options || []).map((o) => `<option value="${esc(o)}"${v === o ? ' selected' : ''}>${esc(o)}</option>`).join('')}</select>`;
|
||||
else input = `<input id="${id}" type="${['int', 'number'].includes(k.type) ? 'number' : 'text'}"${k.type === 'number' ? ' step="any"' : ''}${k.min !== undefined ? ` min="${k.min}"` : ''}${k.max !== undefined ? ` max="${k.max}"` : ''} name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="${esc(k.type)}" value="${has ? esc(showVal(v)) : ''}"${k.hint ? ` placeholder="${esc(k.hint)}"` : ''}>`;
|
||||
return `<div class="set-field"><label for="${id}">${esc(k.label)} <span class="mono muted small">${esc(k.key)}</span>${tags}</label><div>${input} ${inh}</div></div>`;
|
||||
};
|
||||
const groups = (cat.groups || []).map((g) => { const keys = g.keys.filter((k) => isServer || k.scope !== 'server'); if (!keys.length) return ''; return `<fieldset class="set-group"><legend>${esc(g.label)}</legend>${keys.map(field).join('')}</fieldset>`; }).join('');
|
||||
return `<form id="settings-form" class="set-form" data-module="${esc(cat.module)}" data-scope="${esc(scope)}" autocomplete="off">${groups}<div class="actions"><button type="button" class="btn primary" data-act="settings-save">저장(${isServer ? '이 서버 덮어쓰기' : '전체'})</button> <span class="muted small">비운 칸은 지정을 지웁니다(${isServer ? '전체 값을 따름' : '모듈 기본값을 따름'}). 비밀값은 새 값을 넣을 때만 바뀝니다. 저장만으로는 서버에 반영되지 않습니다 — 아래 "서버에 적용"을 누르세요.</span></div></form>`;
|
||||
}
|
||||
/** 서버별 적용 상태 표 */
|
||||
export function settingsStateHtml(rows, { module = '' } = {}) {
|
||||
const st = (r) => r.status === 'failed' ? badge('failed', '적용 실패') : r.status === 'ok' && r.upToDate ? badge('ok', '적용됨') : r.status === 'ok' ? badge('warn', '적용 필요(설정 변경됨)') : badge('warn', '적용 필요');
|
||||
return `${table([{ l: '서버', f: (r) => `<a href="/ops/settings?module=${esc(module)}&scope=server:${esc(r.serverId)}"><b>${esc(r.serverName || r.serverId)}</b></a><br><span class="muted small">${esc(r.serverStatus || '')}</span>` }, { l: '상태', f: st }, { l: '적용 시각', f: (r) => fmt(r.appliedAt) }, { l: '적용된 설정', f: (r) => `<span class="mono small">${esc(String(r.appliedHash || '—').slice(0, 19))}</span>` }, { l: '오류', f: (r) => r.error ? `<span class="err small">${esc(r.error)}</span>` : '—' }, { l: '', f: (r) => `<button class="btn small" data-act="settings-apply" data-m="${esc(module)}" data-s="${esc(r.serverId)}">이 서버에 적용</button>` }], rows || [], { empty: '이 모듈이 배치된 서버가 없습니다 — 배포 릴리스에서 먼저 배포하세요' })}<div class="actions"><button class="btn primary" data-act="settings-apply" data-m="${esc(module)}">서버에 적용(선택)</button></div>`;
|
||||
}
|
||||
/** 통합 설정 관제 화면 본문 */
|
||||
export function settingsPageHtml({ modules = [], catalog = {}, module, scope = 'global', placements = [], stored = {}, effective = null, overview = {} }) {
|
||||
const cat = catalog[module] || { groups: [] }; const ov = overview[module] || { servers: [] };
|
||||
const tabs = `<div class="tabs">${modules.map((m) => `<a href="/ops/settings?module=${esc(m)}" class="${m === module ? 'on' : ''}">${esc((catalog[m] || {}).label || m)}${overview[m] && overview[m].stale ? ` <span class="badge warn">${overview[m].stale}</span>` : ''}</a>`).join('')}</div>`;
|
||||
const scopeSel = `<div class="row"><label>범위 <select id="settings-scope" data-module="${esc(module)}"><option value="global"${scope === 'global' ? ' selected' : ''}>전체(모든 서버 기본값)</option>${placements.map((p) => `<option value="server:${esc(p.id)}"${scope === 'server:' + p.id ? ' selected' : ''}>서버 ${esc(p.name)}(덮어쓰기)</option>`).join('')}</select></label></div>`;
|
||||
return `${tabs}<p class="muted">${esc(cat.description || '')}</p>${scopeSel}${settingsFormHtml(cat, { scope, values: stored.values || {}, secrets: stored.secrets || {}, effective })}<h2>서버별 적용 상태</h2><div id="settings-state">${settingsStateHtml(ov.servers, { module })}</div>`;
|
||||
}
|
||||
export function incidentsView(incidents, notifications = []) {
|
||||
return `<h2>사건(같은 원인은 하나로 묶임)</h2>${table([{ l: '사건', f: (i) => `<b>${esc(i.title)}</b><br><span class="mono muted">${esc(i.key)}</span>` }, { l: '심각도', f: (i) => badge(i.severity === 'critical' ? 'failed' : i.severity === 'warning' ? 'warn' : 'info', i.severity) }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음(내부)') }, { l: '시작·종료', f: (i) => `${fmt(i.started_at)}<br>${i.resolved_at ? '종료 ' + fmt(i.resolved_at) + (i.verified_at ? ' (사용자 경로 확인)' : '') : badge('running', '대응 중')}` }, { l: '담당', k: 'assignee' }, { l: '공개', f: (i) => i.public ? badge('info', '공개 상태 페이지') + `<br><span class="muted">${esc(i.public_note || '')}</span>` : '비공개' }, { l: '', f: (i) => i.resolved_at ? '' : `<button class="btn small" data-act="incident-note" data-id="${esc(i.id)}">메모</button> <button class="btn small" data-act="incident-public" data-id="${esc(i.id)}">고객 공지</button> <button class="btn small" data-act="incident-resolve" data-id="${esc(i.id)}">종료(사용자 경로 확인 후)</button>` }], incidents, { empty: '진행 중이거나 기록된 사건이 없습니다' })}
|
||||
<div class="actions"><button class="btn primary" data-act="incident-new">사건 등록</button></div>
|
||||
@@ -154,7 +185,7 @@ export function overviewView(o) {
|
||||
const comp = (c) => `<div class="card comp"><b>${esc(COMP_LABEL[c.name] || c.name)}</b> <span class="muted small">${COMP_LABEL[c.name] && COMP_LABEL[c.name] !== c.name ? esc(c.name) : ''}</span> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
|
||||
const jobs = o.jobs || {}; const srv = o.servers || {};
|
||||
return `<div class="cards">${(o.components || []).map(comp).join('')}</div>
|
||||
<div class="cards"><div class="card"><h2>서버</h2>${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `<span class="err">${srv.offline}대</span>` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `<span class="err">${srv.diskWarning}대</span>` : '없음']])}<a class="btn" href="/ops/servers">서버와 용량</a></div>
|
||||
<div class="cards"><div class="card"><h2>서버</h2>${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `<span class="err">${srv.offline}대</span>` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `<span class="err">${srv.diskWarning}대</span>` : '없음'], o.settings ? ['설정 적용 필요', o.settings.stale ? `<a class="err" href="/ops/settings">${o.settings.stale}건</a>` : '없음'] : null])}<a class="btn" href="/ops/servers">서버와 용량</a> <a class="btn" href="/ops/settings">통합 설정</a></div>
|
||||
<div class="card"><h2>진행 중 작업</h2>${kv([['실행 중', `${jobs.running || 0}`], ['대기·외부 대기', `${jobs.waiting || 0}`], ['사용자 확인 필요', jobs.needsUser ? `<span class="err">${jobs.needsUser}</span>` : '0'], ['종류별', Object.entries(jobs.byKind || {}).map(([k, n]) => `${esc(k)} ${n}`).join(', ') || '없음']])}<a class="btn" href="/ops/jobs">작업 화면</a></div>
|
||||
<div class="card"><h2>목표·실제 차이</h2>${(o.drift || []).length ? table([{ l: '모듈', k: 'module' }, { l: '서버', k: 'server_id' }, { l: '목표', k: 'target_version' }, { l: '실제', k: 'actual_version' }], o.drift) : badge('ok', '차이 없음')}</div>
|
||||
<div class="card"><h2>재해복구·긴급</h2>${o.dr ? (o.dr.ready ? badge('ok', o.dr.label) : badge('warn', o.dr.label)) : ''}<br>${(o.emergencyOpen || []).length ? `<span class="err">긴급 복구 세션 ${o.emergencyOpen.length}건 진행 중</span>` : '<span class="muted">긴급 복구 없음</span>'}<br><a class="btn" href="/ops/recovery">플랫폼 복구</a></div></div>
|
||||
|
||||
Reference in New Issue
Block a user