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
+32
View File
@@ -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}`);