feat(hal): inventory existing Webmaker sites

This commit is contained in:
oz
2026-10-27 08:56:32 +00:00
parent ac072e6f12
commit 8a0aef37b9
10 changed files with 246 additions and 8 deletions
+4 -2
View File
@@ -42,13 +42,15 @@ await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠
await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => {
const h = makeHal(); const op = await loginOp(h);
const s = core.createServer(h.d, { name: '웹1', role: 'web', location: '서울' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1' }, version: '0.1.1' } }); ok(reg.json.ok);
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1', webmakerInventory: { total: 1, returned: 1, truncated: false, sites: [{ id: 'a'.repeat(32), title: '기존 사이트 <A>', ownerId: 'owner-a', status: '<idle>', domains: ['a.example.test'], deploymentState: 'deployed', bytes: 1234, files: 3, updatedAt: '2026-10-27T00:00:00Z', metadata: 'ok' }] } }, version: '0.1.1' } }); ok(reg.json.ok);
h.d.store.run('UPDATE servers SET last_seen_at=?, capacity_json=?, metrics_json=? WHERE id=?', new Date().toISOString(), JSON.stringify({ diskFreeBytes: 10e9, diskTotalBytes: 100e9, sitesHosted: 3, reservedBytes: 1e9 }), JSON.stringify({ cpu: 12, memUsed: 50, memTotal: 100, netIn: 1000, netOut: 2000 }), s.id);
core.approveServer(h.d, s.id, 'ozops');
h.d.store.run('INSERT INTO module_targets(module, server_id, target_version, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', 'portal', s.id, '0.1.1', '0.1.0', 'ok', 1, new Date().toISOString(), new Date().toISOString());
core.registerRelease(h.d, { version: '0.1.1', mainCommit: 'abcdef1234567890', builtAt: new Date().toISOString(), artifacts: { hal: { file: 'hal.tgz', sha256: 'sha256:x', size: 1 } }, checks: { testsPassed: true } });
let r = await op.as('GET', '/ops/servers'); eq(r.status, 200); ok(r.text.includes('웹1') && r.text.includes('배치 가능') && r.text.includes('0.1.0') && r.text.includes('0.1.1'), '서버 표에 상태·모듈 차이');
r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('모듈(목표 vs 실제)') && r.text.includes('서버 삭제') && r.text.includes('id="metrics"'));
r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('모듈(목표 vs 실제)') && r.text.includes('서버 삭제') && r.text.includes('id="metrics"')); ok(r.text.includes('Webmaker 사이트 디렉터리 인벤토리') && r.text.includes('owner-a') && r.text.includes('a.example.test') && r.text.includes('기존 사이트 &lt;A&gt;') && !r.text.includes('기존 사이트 <A>') && r.text.includes('&lt;idle&gt;') && !r.text.includes('<idle>'));
h.d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'web1', webmakerInventory: { total: 0, returned: 0, truncated: false, errors: ['sites:EACCES<script>'], errorsTruncated: true, sites: [] } }), s.id); r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('Webmaker 사이트 디렉터리 인벤토리') && r.text.includes('인벤토리 스캔 오류 1건') && r.text.includes('EACCES&lt;script&gt;') && !r.text.includes('EACCES<script>'));
h.d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'web1', webmakerInventory: { total: 'bad', returned: {}, errors: '<script>', sites: [null, { id: '<id>', domains: '<img onerror=1>', files: {} }] } }), s.id); r = await op.as('GET', `/ops/servers/${s.id}`); eq(r.status, 200); ok(r.text.includes('&lt;id&gt;') && !r.text.includes('<img onerror=1>'), 'null 등 malformed agent facts도 렌더러를 깨거나 HTML로 실행하지 않음');
r = await op.as('GET', '/ops/releases'); ok(r.text.includes('HAL 을 먼저 배포') && r.text.includes('abcdef123456') && r.text.includes('테스트 통과') && r.text.includes('승인 대기'));
// 작업: 부모 + 자식(하나 실패) → 부분 실패 표시
h.d.jobs.register('ui.demo', { title: '데모', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { await ctx.step('a', () => { if (job.subject.fail) throw new Error('대상 실패'); return 1; }); } });