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:
zzabisagent-lab
2026-10-29 06:46:45 +00:00
co-authored by Claude Opus 5.5
parent 54db663531
commit 9f1e490b51
44 changed files with 1075 additions and 82 deletions
+94
View File
@@ -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; }
}
}
+104
View File
@@ -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);