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
+4
-1
@@ -9,6 +9,7 @@ import { openVault } from '../lib/vault.mjs';
|
||||
|
||||
export function loadAgentConfig(env = process.env) {
|
||||
const dataDir = env.ARIWEB_AGENT_DATA || '/srv/ariweb/agent/data'; mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
||||
let managedDoc = {}; try { managedDoc = JSON.parse(readFileSync(join(dataDir, 'hal-managed.json'), 'utf8')) || {}; } catch (_) { managedDoc = {}; } const managed = managedDoc.values && typeof managedDoc.values === 'object' ? managedDoc.values : {};
|
||||
const file = join(dataDir, 'agent.json'); let saved = {}; try { saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}; } catch (_) {}
|
||||
const cfg = {
|
||||
dataDir, file, ...saved,
|
||||
@@ -24,7 +25,9 @@ export function loadAgentConfig(env = process.env) {
|
||||
platformDomain: env.ARIWEB_PLATFORM_DOMAIN || 'ariweb.net',
|
||||
maintPage: env.ARIWEB_MAINT_PAGE || '/var/www/ariweb-maint.html',
|
||||
},
|
||||
heartbeatMs: Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000),
|
||||
// [0.4.0] HAL 통합 설정(module.configure 가 쓰는 <dataDir>/hal-managed.json)이 있으면 그 값이 우선한다(범위 5초~10분)
|
||||
heartbeatMs: (() => { const m = Number(managed.heartbeatMs); return Number.isFinite(m) && m >= 5000 && m <= 600000 ? m : Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000); })(),
|
||||
halManaged: { hash: managedDoc.hash || null, keys: Object.keys(managed) },
|
||||
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label']) if (cfg[k] !== undefined) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
|
||||
vault: () => openVault(dataDir),
|
||||
};
|
||||
|
||||
+41
-2
@@ -15,8 +15,8 @@
|
||||
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
|
||||
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { userInfo } from 'node:os';
|
||||
import http from 'node:http';
|
||||
import { ROOT, readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
|
||||
@@ -124,7 +124,46 @@ async function installRelease(ctx, { module, version, appDir, desc, action, toVe
|
||||
}, '릴리스 풀기');
|
||||
return { relDir, prev: prevDir };
|
||||
}
|
||||
/**
|
||||
* [0.4.0] 통합 설정 적용(module.configure). HAL 작업 payload 에는 비밀값이 없다 — Agent 가 HAL 의 settingsUrl(/agent/v1/settings/<모듈>, HMAC)에서
|
||||
* 자기 서버 범위의 유효 설정을 직접 받아, payload 의 hash 와 같을 때만 <configDir>/hal-managed.json(0600)으로 원자 교체한다.
|
||||
* - 실행 계정이 다르면(User=) 그 계정으로 module-as-user.sh write --mode 600 (configDir 를 --app-dir 로 써서 그 아래로만 쓰게 한다)
|
||||
* - restart: 서비스 재시작 → 건강 확인. agent 자신은 결과를 먼저 보고한 뒤 재시작(restartAfterReport).
|
||||
* - 모듈(Webmaker 1.9.0·AriMail 1.1.0)은 파일을 읽어 적용하고 <configDir>/hal-managed.applied.json(비밀 없음: hash·시각·오류)을 남긴다. Agent 는 그것을 기다려(최대 waitAppliedMs) 결과에 싣는다.
|
||||
* 비밀값은 단계 결과·로그·작업 상태 파일에 남기지 않는다(받은 내용은 이 함수 안에서만 쓴다).
|
||||
*/
|
||||
async function configure(ctx) {
|
||||
const p = ctx.payload; const module = String(p.module || ''); if (!/^[a-z][a-z0-9-]{0,40}$/.test(module)) throw Object.assign(new Error(`모듈 이름이 올바르지 않습니다: ${module}`), { code: 'bad_module' });
|
||||
const appDir = resolveAppDir(ctx, p); const configDir = String(p.configDir || '').replace('<appDir>', appDir).replace('<dataDir>', ctx.dataDir);
|
||||
if (!configDir || !isAbsolute(configDir) || configDir.includes('..')) throw Object.assign(new Error(`설정 디렉터리가 올바르지 않습니다: ${configDir}`), { code: 'bad_config_dir' });
|
||||
const unit = p.unit || p.descriptor?.unit || null; const file = join(configDir, 'hal-managed.json');
|
||||
let settings; try { const r = await ctx.client.request('GET', p.settingsUrl || `/agent/v1/settings/${module}`); settings = r && r.settings; } catch (e) { throw Object.assign(new Error(`HAL 에서 설정을 받지 못했습니다: ${e.message}`), { code: 'settings_fetch_failed', retryable: true }); }
|
||||
if (!settings || settings.module !== module || typeof settings.values !== 'object') throw Object.assign(new Error('HAL 설정 응답 형식이 올바르지 않습니다'), { code: 'settings_fetch_failed' });
|
||||
if (p.hash && settings.hash !== p.hash) throw Object.assign(new Error('HAL 이 지금 주는 설정이 작업을 만들 때와 다릅니다(그 사이 설정이 바뀜) — 다시 적용하세요'), { code: 'settings_hash_mismatch' });
|
||||
const doc = JSON.stringify({ format: 1, module, serverId: settings.serverId || null, hash: settings.hash, generatedAt: settings.generatedAt || null, writtenAt: new Date().toISOString(), values: settings.values }, null, 1);
|
||||
const keys = Object.keys(settings.values).length;
|
||||
await ctx.step('write', async () => {
|
||||
const svc = module === 'agent' ? { asUser: false } : await serviceContext(ctx, unit);
|
||||
if (svc.asUser) { await asUser(ctx, svc, configDir, 'write', ['--file', file, '--mode', '600'], { input: doc }); }
|
||||
else { mkdirSync(configDir, { recursive: true, mode: 0o700 }); const tmp = `${file}.tmp-${process.pid}`; writeFileSync(tmp, doc, { mode: 0o600 }); chmodSync(tmp, 0o600); renameSync(tmp, file); }
|
||||
return { file, keys, asUser: svc.asUser ? svc.user : null };
|
||||
}, `설정 파일 쓰기(${keys}개 항목)`);
|
||||
ctx.log(`HAL 관리 설정 ${keys}개 항목을 ${file} 에 썼습니다(${String(settings.hash).slice(0, 19)})`);
|
||||
let restarted = false; let healthRes = { ok: true, skipped: 'no_restart' }; let restartAfterReport = null;
|
||||
if (p.restart && unit) {
|
||||
if (module === 'agent') { restartAfterReport = unit; restarted = true; }
|
||||
else { await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '서비스 재시작'); restarted = true; healthRes = await ctx.step('health', () => health(ctx, p.descriptor || {}), '건강 확인'); }
|
||||
}
|
||||
let applied = null;
|
||||
if (module !== 'agent' && !ctx.sys.stub) {
|
||||
applied = await ctx.step('applied', async () => { const af = join(configDir, 'hal-managed.applied.json'); const until = Date.now() + Number(p.waitAppliedMs || 45000); let last = null;
|
||||
while (Date.now() < until) { try { last = JSON.parse(readFileSync(af, 'utf8')); if (last && last.hash === settings.hash) return { hash: last.hash, at: last.appliedAt || null, errors: Array.isArray(last.errors) ? last.errors.map((x) => ({ key: x.key, error: String(x.error || '').slice(0, 200) })) : [] }; } catch (e) { if (e.code === 'EACCES') return { unreadable: true }; } await new Promise((r) => setTimeout(r, 1500)); }
|
||||
return last ? { hash: last.hash || null, at: last.appliedAt || null, stale: true } : { missing: true }; }, '모듈 적용 확인');
|
||||
}
|
||||
return { module, hash: settings.hash, file, keys, restarted, health: healthRes, applied, restartAfterReport };
|
||||
}
|
||||
export const kinds = {
|
||||
'module.configure': { title: '모듈 설정 적용(HAL 관리)', run: configure },
|
||||
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
|
||||
'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } },
|
||||
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); const v = await detectVersion(ctx, desc, appDir); return { module: p.module, actualVersion: v.version, upstreamVersion: v.upstreamVersion, active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
|
||||
|
||||
Reference in New Issue
Block a user