feat(hal): inventory existing Webmaker sites

This commit is contained in:
oz
2026-10-27 08:57:21 +00:00
parent 4bd698452b
commit 12958ab695
10 changed files with 246 additions and 8 deletions
+12
View File
@@ -64,6 +64,18 @@ await test('heartbeat → 지표·용량·모듈 버전 저장, 무응답 → of
await sleep(80); eq(core.markOffline(h.d).length, 1); eq(core.listServers(h.d)[0].status, 'offline'); await a.hb(); eq(core.listServers(h.d)[0].status, 'ready', 'heartbeat 복귀');
h.d.store.run('INSERT INTO metrics_samples(server_id, at, cpu) VALUES(?,?,?)', a.serverId, new Date(Date.now() - 100 * 86400000).toISOString(), 1); eq(core.purgeMetrics(h.d), 1); ok(core.metrics(h.d, a.serverId, { since: new Date(0).toISOString() }).every((m) => Date.now() - Date.parse(m.at) < 91 * 86400000));
});
await test('Webmaker 인벤토리 배치: 2MiB heartbeat와 분리·순서 검증·완료 시 원자 반영·후속 heartbeat 보존', async () => {
const h = makeHal(); const a = await registerAgent(h); const path = '/agent/v1/inventory/webmaker'; const send = (body) => call(h.app, `agent:${a.agentId}`, a.secret, 'POST', path, body);
const sites = ['a', 'b', 'c'].map((x) => ({ id: x.repeat(32), title: `site-${x}`, domains: [`${x}.test`], files: 1, bytes: 2 })); sites[0].privateContact = 'do-not-store@example.test'; sites[0].nested = { secret: 'do-not-store' }; const meta = { total: 3, errors: [], truncated: false, scannedAt: '2026-10-27T00:00:00.000Z' };
let r = await send({ scanId: 'scan-1', offset: 1, itemTotal: 3, meta, sites: [sites[1]] }); eq(r.status, 400); eq(r.json.error, 'inventory_batch_order');
r = await send({ scanId: 'scan-1', offset: 0, itemTotal: 3, meta, sites: sites.slice(0, 2) }); eq(r.status, 200); eq(r.json.complete, false); eq(core.getServer(h.d, a.serverId).facts.webmakerInventory, undefined, '중간 배치는 공개하지 않음');
r = await send({ scanId: 'scan-1', offset: 2, itemTotal: 3, meta, sites: sites.slice(2) }); eq(r.status, 200); eq(r.json.complete, true); const stored = core.getServer(h.d, a.serverId).facts.webmakerInventory; eq(stored.sites.map((x) => x.id), sites.map((x) => x.id)); eq(stored.truncated, false); ok(!JSON.stringify(stored).includes('privateContact') && !JSON.stringify(stored).includes('do-not-store'), '허용되지 않은 개인정보·중첩 필드 저장 금지'); eq(h.d.store.get('SELECT COUNT(*) AS n FROM agent_inventory_staging').n, 0);
r = await send({ scanId: 'scan-partial', offset: 0, itemTotal: 1, meta: { ...meta, total: 2, truncated: true }, sites: sites.slice(0, 1) }); eq(r.status, 400); eq(r.json.error, 'inventory_incomplete');
r = await send({ scanId: 'scan-bad', offset: 0, itemTotal: 1, meta, sites: [{ id: '../bad', title: 'bad' }] }); eq(r.status, 400); eq(r.json.error, 'bad_inventory_batch');
r = await send({ scanId: 'scan-too-many', offset: 0, itemTotal: 10001, meta, sites: [] }); eq(r.status, 400); eq(r.json.error, 'bad_inventory_batch');
await a.hb({ facts: { hostname: 'web-after' } }); const facts = core.getServer(h.d, a.serverId).facts; eq(facts.hostname, 'web-after'); eq(facts.webmakerInventory.returned, 3, '작은 heartbeat가 완성된 인벤토리를 지우지 않음');
r = await send({ scanId: 'scan-cascade', offset: 0, itemTotal: 2, meta: { ...meta, total: 2 }, sites: sites.slice(0, 1) }); eq(r.status, 200); eq(h.d.store.get('SELECT COUNT(*) AS n FROM agent_inventory_staging').n, 1); ok(h.d.store.get("SELECT name FROM sqlite_master WHERE type='trigger' AND name='agent_inventory_staging_agent_cleanup'"), '이전 스키마에도 적용 가능한 Agent 삭제 cleanup trigger'); h.d.store.run('DELETE FROM agents WHERE id=?', a.agentId); eq(h.d.store.get('SELECT COUNT(*) AS n FROM agent_inventory_staging').n, 0, 'Agent 삭제 시 미완료 staging cascade');
});
await test('setBootstrap: HAL 기동 전(행 없음) CLI bootstrap-release 의 기록이 남는다(실제 환경 결함 수정)', async () => {
const h = makeHal(); eq(h.d.store.get('SELECT COUNT(*) AS n FROM bootstrap_state').n, 0); core.setBootstrap(h.d, { release_version: '0.1.1', main_commit: 'abc', artifact_sha256: 'def' });
eq(core.bootstrapState(h.d).release_version, '0.1.1'); eq(core.bootstrapState(h.d).main_commit, 'abc');