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
+26
-2
@@ -14,10 +14,10 @@ import { createHash } from 'node:crypto';
|
||||
import { ROOT as ROOT_DIR, readJson } from '../agent/jobs/common.mjs';
|
||||
|
||||
/** 가짜 HAL 클라이언트: 전송은 메모리(파일 경로) 저장, artifact 는 미리 둔 파일 */
|
||||
function fakeClient({ artifacts = {} } = {}) {
|
||||
function fakeClient({ artifacts = {}, settings = null } = {}) {
|
||||
const transfers = new Map(); const calls = []; const up = join(tmpDir(), 'uploads'); mkdirSync(up, { recursive: true });
|
||||
return { calls, transfers,
|
||||
async request(method, path, body) { calls.push({ method, path, body }); return { ok: true }; },
|
||||
async request(method, path, body) { calls.push({ method, path, body }); if (method === 'GET' && path.startsWith('/agent/v1/settings/')) { if (!settings) throw Object.assign(new Error('404'), { code: 'http_404' }); return { ok: true, settings }; } return { ok: true }; },
|
||||
async download(path, dest, expected) { const src = artifacts[path]; if (!src) { const e = new Error('404'); e.code = 'http_404'; throw e; } copyFileSync(src, dest); const sum = createHash('sha256').update(readFileSync(dest)).digest('hex'); if (expected && expected !== sum) throw Object.assign(new Error('checksum'), { code: 'checksum_mismatch' }); return { sha256: sum, size: statSync(dest).size }; },
|
||||
async upload(id, file) { const copy = join(up, id + '.bin'); copyFileSync(file, copy); const sum = createHash('sha256').update(readFileSync(copy)).digest('hex'); this.transfers.set(id, { path: copy, sha256: sum }); return { id, sha256: sum, size: statSync(copy).size }; },
|
||||
async downloadTransfer(id, dest, expected) { const t = this.transfers.get(id); if (!t) throw Object.assign(new Error('transfer missing'), { code: 'transfer_not_found' }); copyFileSync(t.path, dest); if (expected && String(expected).replace(/^sha256:/, '') !== t.sha256) throw Object.assign(new Error('checksum'), { code: 'checksum_mismatch' }); return { sha256: t.sha256, size: statSync(dest).size }; },
|
||||
@@ -191,4 +191,28 @@ await test('module.install(실제 환경 결함 수정): REVISION.json 의 Suite
|
||||
r = await runner3.execute({ id: 'u3', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases' }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.steps.find((s) => s.id === 'unit').result.written, true); ok(e.sys.calls.slice(b2).some((c) => c.cmd === 'sudo' && c.args.includes('tee')) || existsSync('/etc/systemd/system/webmaker.service'));
|
||||
});
|
||||
await test('[0.4.0] module.configure: HAL 설정을 받아 hal-managed.json(0600)으로 쓰고 재시작·건강 확인 뒤 hash 를 보고 — 불일치·받기 실패는 실패로', async () => {
|
||||
const sec = 'v-' + createHash('sha256').update(String(Date.now())).digest('hex').slice(0, 12); const hash = 'sha256:' + createHash('sha256').update('settings-' + sec).digest('hex');
|
||||
const settings = { module: 'webmaker', serverId: 'srv_1', hash, generatedAt: new Date().toISOString(), values: { marginPct: 35, 'auth.apiKey': sec } };
|
||||
const e = env({ settings }); const configDir = join(e.base, 'wmdata');
|
||||
let r = await e.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir, settingsUrl: '/agent/v1/settings/webmaker', hash, restart: true, descriptor: { unit: 'webmaker', health: {} } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.hash, hash); eq(r.result.restarted, true); ok(r.result.health && r.result.health.ok);
|
||||
const f = join(configDir, 'hal-managed.json'); ok(existsSync(f)); eq(statSync(f).mode & 0o777, 0o600, '0600'); const j = JSON.parse(readFileSync(f, 'utf8')); eq(j.hash, hash); eq(j.values.marginPct, 35); eq(j.values['auth.apiKey'], sec);
|
||||
ok(e.sys.calls.some((c) => { const a = [c.cmd, ...c.args]; return a.includes('systemctl') && a.includes('restart') && a.includes('webmaker'); }), '재시작'); ok(e.client.calls.some((c) => c.method === 'GET' && c.path === '/agent/v1/settings/webmaker'), 'HAL 에서 받음');
|
||||
ok(!JSON.stringify(r.log || []).includes(sec), '로그에 비밀값 없음');
|
||||
// restart=false 면 재시작하지 않는다 · <appDir> 토큰은 모듈 디렉터리로 푼다
|
||||
const e2 = env({ settings: { ...settings, module: 'arimail' } }); const appDir = join(e2.roots.srv, 'arimail'); mkdirSync(appDir, { recursive: true });
|
||||
r = await e2.run('module.configure', { module: 'arimail', unit: 'arimail', appDir, configDir: '<appDir>/data', settingsUrl: '/agent/v1/settings/arimail', hash, restart: false, descriptor: { unit: 'arimail' } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.restarted, false); ok(existsSync(join(appDir, 'data', 'hal-managed.json'))); ok(!e2.sys.calls.some((c) => [c.cmd, ...c.args].includes('restart')));
|
||||
// HAL 응답의 hash 가 작업의 hash 와 다르면(그 사이 설정이 바뀜) 쓰지 않고 실패
|
||||
const e3 = env({ settings: { ...settings, hash: 'sha256:' + 'f'.repeat(64) } }); r = await e3.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir: join(e3.base, 'w3'), settingsUrl: '/agent/v1/settings/webmaker', hash, restart: false });
|
||||
eq(r.status, 'failed'); eq(r.errorCode, 'settings_hash_mismatch'); ok(!existsSync(join(e3.base, 'w3', 'hal-managed.json')));
|
||||
const e4 = env({}); r = await e4.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir: join(e4.base, 'w4'), settingsUrl: '/agent/v1/settings/webmaker', hash, restart: false }); eq(r.status, 'failed'); eq(r.errorCode, 'settings_fetch_failed');
|
||||
});
|
||||
await test('[0.4.0] Agent 설정: <dataDir>/hal-managed.json 의 heartbeatMs 가 환경 변수보다 우선(범위 밖이면 무시)', async () => {
|
||||
const { loadAgentConfig } = await import('../agent/config.mjs'); const dir = join(tmpDir(), 'ad'); mkdirSync(dir, { recursive: true });
|
||||
eq(loadAgentConfig({ ARIWEB_AGENT_DATA: dir, ARIWEB_AGENT_HEARTBEAT_MS: '20000' }).heartbeatMs, 20000);
|
||||
writeFileSync(join(dir, 'hal-managed.json'), JSON.stringify({ hash: 'sha256:' + 'a'.repeat(64), values: { heartbeatMs: 45000 } })); const c = loadAgentConfig({ ARIWEB_AGENT_DATA: dir, ARIWEB_AGENT_HEARTBEAT_MS: '20000' }); eq(c.heartbeatMs, 45000); eq(c.halManaged.keys, ['heartbeatMs']);
|
||||
writeFileSync(join(dir, 'hal-managed.json'), JSON.stringify({ values: { heartbeatMs: 10 } })); eq(loadAgentConfig({ ARIWEB_AGENT_DATA: dir }).heartbeatMs, 30000);
|
||||
});
|
||||
done();
|
||||
|
||||
Reference in New Issue
Block a user