feat(hal): inventory existing Webmaker sites
This commit is contained in:
+20
-1
@@ -12,6 +12,7 @@ import { createSys } from './sys.mjs';
|
||||
import { createRunner } from './runner.mjs';
|
||||
import { KINDS } from './jobs/index.mjs';
|
||||
import { sample, facts } from './metrics.mjs';
|
||||
import { configuredWebmakerInventory, webmakerInventoryBatches } from './webmaker-inventory.mjs';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { hostname } from 'node:os';
|
||||
@@ -36,9 +37,27 @@ if (cmd === 'register') {
|
||||
const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
|
||||
const runner = createRunner({ kinds: KINDS, roots: cfg.roots, sys, client, dataDir: cfg.dataDir, log });
|
||||
let stopping = false; let running = 0; let lastFacts = null; let restartUnit = null;
|
||||
/** 기존 Webmaker 사이트: 운영에 필요한 식별자·상태·도메인·배포 용량만 facts에 싣고 본문·프롬프트·연락처·비밀은 제외한다. */
|
||||
function inventoryFacts(f) {
|
||||
return configuredWebmakerInventory(f);
|
||||
}
|
||||
/** 2 MiB 요청 한도와 무관하게 전체 인벤토리를 512 KiB 이하 배치로 순차 전송한다. */
|
||||
async function syncWebmakerInventory(inv) {
|
||||
if (!inv) return; const batches = webmakerInventoryBatches(inv); let offset = 0;
|
||||
for (const body of batches) { const r = await client.request('POST', '/agent/v1/inventory/webmaker', body); offset = Number(r.nextOffset); }
|
||||
if (offset !== inv.sites.length) throw new Error(`Webmaker 인벤토리 전송 수 불일치(${offset}/${inv.sites.length})`);
|
||||
}
|
||||
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/REVISION.json(없으면 package.json) 버전 */
|
||||
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); let version = null; try { version = JSON.parse(readFileSync(join(appDir, 'current', 'REVISION.json'), 'utf8')).version; } catch (_) {} if (!version) { try { version = JSON.parse(readFileSync(join(appDir, 'current', 'package.json'), 'utf8')).version; } catch (_) {} } /* 도입 모듈은 REVISION.json 의 Suite 버전(상류 package.json 버전은 목표와 다르다 — 실제 환경 결함 수정) */ const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
|
||||
async function heartbeat() { try { if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: facts(sys) }; const sites = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))).length : 0; await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: lastFacts.f, sitesHosted: sites, jobs: { running } }); } catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } }
|
||||
async function heartbeat() { try {
|
||||
if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: inventoryFacts(facts(sys)), inventorySynced: false };
|
||||
const managed = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))) : [];
|
||||
const legacy = lastFacts.f.webmakerInventory?.sites?.map((x) => x.id) || [];
|
||||
const sites = new Set([...managed, ...legacy]).size;
|
||||
if (lastFacts.f.webmakerInventory && !lastFacts.inventorySynced) { try { await syncWebmakerInventory(lastFacts.f.webmakerInventory); lastFacts.inventorySynced = true; } catch (e) { log(`[agent] Webmaker 인벤토리 전송 실패: ${e.message}`); } }
|
||||
const heartbeatFacts = { ...lastFacts.f }; delete heartbeatFacts.webmakerInventory;
|
||||
await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: heartbeatFacts, sitesHosted: sites, jobs: { running } });
|
||||
} catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } }
|
||||
async function execute(job) { running++; try { const out = await runner.execute(job); if (out?.result?.restartAfterReport) restartUnit = out.result.restartAfterReport; } finally { running--; } if (restartUnit) { const u = restartUnit; restartUnit = null; log(`[agent] 자기 갱신 — ${u} 재시작`); setTimeout(() => sys.systemctl('restart', u, { optional: true }), 1500); } }
|
||||
// 재시작 뒤: 보고 못 한 결과 전송, 미완료 작업 재개
|
||||
await runner.flushPending(); try { const u = await client.request('GET', '/agent/v1/jobs/unfinished'); for (const j of u.jobs || []) { log(`[agent] 미완료 작업 재개 ${j.id} ${j.kind}`); execute(j); } } catch (e) { log(`[agent] 미완료 작업 조회 실패: ${e.message}`); }
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* agent/webmaker-inventory.mjs — 기존 Webmaker 사이트 디렉터리를 HAL 서버 인벤토리로 정리한다.
|
||||
*
|
||||
* 운영 정보만 보낸다. 페이지 본문, 프롬프트, 사용자 연락처, 인증정보와 비밀값은 결과에
|
||||
* 넣지 않는다. 사이트 작업 디렉터리도 존재할 수 있으므로 배포 이력을 함께 읽어 상태를
|
||||
* deployed/unpublished/draft/unknown으로 구분한다. 메타데이터 없는 디렉터리도 누락하지 않는다.
|
||||
*/
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const SITE_ID = /^[a-f0-9]{32}$/;
|
||||
const text = (v, max = 240) => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max) : null;
|
||||
function domainsOf(site) {
|
||||
const src = Array.isArray(site?.domains) ? site.domains : [];
|
||||
return [...new Set(src.map((d) => typeof d === 'string' ? d : d?.domain || d?.hostname || d?.host || d?.name)
|
||||
.map((d) => text(d, 253)?.toLowerCase()).filter(Boolean))].slice(0, 100);
|
||||
}
|
||||
function treeStat(root, errors, siteId) {
|
||||
let bytes = 0; let files = 0; let complete = true; const stack = [root];
|
||||
const note = (e) => { complete = false; const code = e?.code || 'read_failed'; const marker = `site:${siteId}:${code}`; if (!errors.includes(marker)) errors.push(marker); };
|
||||
while (stack.length) {
|
||||
const dir = stack.pop(); let entries = [];
|
||||
try { entries = readdirSync(dir, { withFileTypes: true }); } catch (e) { note(e); continue; }
|
||||
for (const e of entries) {
|
||||
const p = join(dir, e.name); try {
|
||||
if (e.isSymbolicLink()) continue;
|
||||
if (e.isDirectory()) stack.push(p);
|
||||
else if (e.isFile()) { bytes += lstatSync(p).size; files += 1; }
|
||||
} catch (error) { note(error); }
|
||||
}
|
||||
}
|
||||
return { bytes, files, complete };
|
||||
}
|
||||
function deploymentState(site) {
|
||||
if (!site) return 'unknown';
|
||||
if (site.unpublishedAt) return 'unpublished';
|
||||
const history = Array.isArray(site.deployments) ? site.deployments : [];
|
||||
const lastLifecycle = [...history].reverse().find((d) => d && ((d.kind === 'unpublish' && d.ok !== false) || (d.ok && d.kind !== 'unpublish' && (d.ready === true || d.phase === 'ready' || d.phase === 'transferred' || !d.phase))));
|
||||
if (lastLifecycle?.kind === 'unpublish') return 'unpublished';
|
||||
if (lastLifecycle || site.deployedAt || site.publishedAt) return 'deployed';
|
||||
return 'draft';
|
||||
}
|
||||
export function scanWebmakerInventory({ dataRoot, dataDir = null, sitesRoot, maxSites = Infinity } = {}) {
|
||||
const metaRoot = dataDir || join(dataRoot || '', 'sites');
|
||||
let dirs = []; let metadataIds = []; let complete = true; const errors = [];
|
||||
try { dirs = readdirSync(sitesRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && SITE_ID.test(e.name)).map((e) => e.name); }
|
||||
catch (e) { complete = false; errors.push(`sites:${e.code || 'read_failed'}`); }
|
||||
try { metadataIds = readdirSync(metaRoot, { withFileTypes: true }).filter((e) => e.isFile() && SITE_ID.test(e.name.replace(/\.json$/i, '')) && /\.json$/i.test(e.name)).map((e) => e.name.slice(0, -5)); } catch (e) { complete = false; errors.push(`metadata:${e.code || 'read_failed'}`); }
|
||||
const deployed = new Set(dirs); const ids = [...new Set([...dirs, ...metadataIds])].sort();
|
||||
const limit = Number.isFinite(maxSites) && maxSites >= 0 ? Math.floor(maxSites) : ids.length;
|
||||
const selected = ids.slice(0, limit);
|
||||
const sites = selected.map((id) => {
|
||||
let site = null; let metadata = 'missing';
|
||||
const metadataFile = join(metaRoot, `${id}.json`);
|
||||
try {
|
||||
site = JSON.parse(readFileSync(metadataFile, 'utf8'));
|
||||
if (!site || typeof site !== 'object' || Array.isArray(site)) { site = null; metadata = 'invalid'; const marker = `metadata:${id}:invalid_shape`; if (!errors.includes(marker)) errors.push(marker); }
|
||||
else metadata = 'ok';
|
||||
} catch (e) {
|
||||
if (e?.code === 'ENOENT' && !existsSync(metadataFile)) metadata = 'missing';
|
||||
else { metadata = 'invalid'; const marker = `metadata:${id}:${e?.code || 'invalid_json'}`; if (!errors.includes(marker)) errors.push(marker); }
|
||||
}
|
||||
const size = deployed.has(id) ? treeStat(join(sitesRoot, id), errors, id) : { bytes: 0, files: 0, complete: true };
|
||||
if (!size.complete) complete = false;
|
||||
return {
|
||||
id,
|
||||
title: text(site?.title || site?.name || id, 200),
|
||||
ownerId: text(site?.ownerId, 128),
|
||||
status: text(site?.status, 80),
|
||||
deploymentState: deploymentState(site),
|
||||
domains: domainsOf(site),
|
||||
updatedAt: text(site?.updatedAt || site?.deployedAt || site?.publishedAt, 40),
|
||||
metadata,
|
||||
bytes: size.bytes,
|
||||
files: size.files,
|
||||
};
|
||||
});
|
||||
if (sites.length < ids.length) complete = false;
|
||||
return {
|
||||
dataRoot: dataRoot || null,
|
||||
dataDir: metaRoot || null,
|
||||
sitesRoot: sitesRoot || null,
|
||||
total: ids.length,
|
||||
returned: sites.length,
|
||||
truncated: sites.length < ids.length,
|
||||
errors,
|
||||
complete,
|
||||
scannedAt: new Date().toISOString(),
|
||||
sites,
|
||||
};
|
||||
}
|
||||
|
||||
/** 운영 경로는 Agent unit에 명시적으로 주입한다. systemd 설정을 추측·재해석해 다른 데이터 루트를 스캔하지 않는다. */
|
||||
export function configuredWebmakerInventory(facts, env = process.env) {
|
||||
if (!facts?.units?.webmaker) return facts;
|
||||
const dataRoot = env.ARIWEB_WEBMAKER_DATA_ROOT; const dataDir = env.ARIWEB_WEBMAKER_DATA_DIR; const sitesRoot = env.ARIWEB_WEBMAKER_SITES_ROOT;
|
||||
if (![dataRoot, dataDir, sitesRoot].every((p) => typeof p === 'string' && p.startsWith('/'))) return { ...facts, webmakerInventory: { dataRoot: null, dataDir: null, sitesRoot: null, total: 0, returned: 0, truncated: false, errors: ['configuration:ARIWEB_WEBMAKER_DATA_ROOT_DATA_DIR_SITES_ROOT_required'], complete: false, scannedAt: new Date().toISOString(), sites: [] } };
|
||||
return { ...facts, webmakerInventory: scanWebmakerInventory({ dataRoot, dataDir, sitesRoot }) };
|
||||
}
|
||||
|
||||
/** HAL JSON 본문 상한과 무관하게 전체 사이트를 byte·항목 수 기준 배치로 나눈다. */
|
||||
export function webmakerInventoryBatches(inv, maxBytes = 512 * 1024) {
|
||||
if (!Number.isInteger(maxBytes) || maxBytes < 64 * 1024) throw new Error('인벤토리 배치 한도가 너무 작습니다');
|
||||
if (inv?.complete === false) throw new Error('inventory_incomplete');
|
||||
const sites = Array.isArray(inv?.sites) ? inv.sites : []; const rawErrors = Array.isArray(inv?.errors) ? inv.errors : []; const meta = {
|
||||
dataRoot: String(inv?.dataRoot || '').slice(0, 4096), dataDir: String(inv?.dataDir || '').slice(0, 4096), sitesRoot: String(inv?.sitesRoot || '').slice(0, 4096), total: Number.isFinite(Number(inv?.total)) ? Number(inv.total) : sites.length, truncated: !!inv?.truncated,
|
||||
errors: rawErrors.slice(0, 1000).map((x) => String(x).slice(0, 500)), errorsTruncated: rawErrors.length > 1000, scannedAt: String(inv?.scannedAt || new Date().toISOString()).slice(0, 80),
|
||||
}; const scanId = meta.scannedAt || String(Date.now()); const out = []; let offset = 0; let part = [];
|
||||
const body = (items) => ({ scanId, offset, itemTotal: sites.length, meta, sites: items });
|
||||
for (const site of sites) {
|
||||
const candidate = [...part, site]; if (candidate.length > 1000 || Buffer.byteLength(JSON.stringify(body(candidate))) > maxBytes) {
|
||||
if (!part.length) throw new Error('Webmaker 인벤토리 단일 항목이 배치 한도를 넘습니다'); out.push(body(part)); offset += part.length; part = [site]; if (Buffer.byteLength(JSON.stringify(body(part))) > maxBytes) throw new Error('Webmaker 인벤토리 단일 항목이 배치 한도를 넘습니다');
|
||||
} else part = candidate;
|
||||
}
|
||||
if (part.length || !sites.length) { const last = body(part); if (Buffer.byteLength(JSON.stringify(last)) > maxBytes) throw new Error('Webmaker 인벤토리 메타데이터가 배치 한도를 넘습니다'); out.push(last); } return out;
|
||||
}
|
||||
Reference in New Issue
Block a user