/** * scripts/webmaker-inventory.mjs — 운영 Webmaker ↔ 포털 ↔ HAL 연동 inventory 와 dry-run(읽기 전용). * * node scripts/webmaker-inventory.mjs --webmaker-data /data/webmaker [--sites-root /var/www/webmaker-sites] * [--portal-db /srv/ariweb/portal/data/portal.sqlite] [--hal-db /srv/ariweb/hal/data/hal.sqlite] * [--app-dir /srv/webmaker/current] [--nginx-enabled /etc/nginx/sites-enabled] [--gitea-refs ] [--out ] * * 무엇을 하나: 세 저장소(Webmaker JSON·포털 DB·HAL DB)를 **읽기만** 하고 안정 식별자로 대조해 연동 판정을 낸다. * - 정본(source of truth): Webmaker 프로젝트·회원·도메인·배포 이력은 Webmaker 데이터(`data/*.json`)가 정본이다. 포털은 "연결 등록부" * (Webmaker `ariweb-links.json` ↔ 포털 `sites.webmaker_project_id`)로 프로젝트를 자기 사이트에 연결할 뿐 프로젝트를 복제하지 않는다. * HAL 은 사이트·계정을 모델링하지 않고 모듈(webmaker) 설치·버전·건강만 등록부(`module_targets`)에 둔다. 그래서 "HAL 리소스로 가져오기" 의 * 실체는 (1) HAL 모듈 등록(서버·버전·건강) (2) 포털 연결 등록부의 일치 확인이며, 그 밖의 복제는 없다. Data Plane(Webmaker 서비스·배포 사이트)은 * HAL 이 죽어도 그대로 동작한다(HAL 은 제어 평면이다). * - 안정 식별자: Webmaker 프로젝트 id(32 hex, 변하지 않음) · 포털 site id(`s_…`) · group id(`g_…`) · HAL server id(`srv_…`). * 포털 SSO 사용자의 Webmaker 로그인은 `aw--` 이므로 접두어로 포털 그룹·사용자와 대조한다. * - 판정: linked_consistent(양쪽 등록부 일치) · link_portal_only / link_webmaker_only(한쪽만 — 충돌) · link_mismatch(다른 사이트를 가리킴 — 충돌) * · duplicate(같은 프로젝트를 둘 이상의 사이트가 가리킴 — 충돌) · missing_project(포털이 없는 프로젝트를 가리킴 — 누락) · unlinked_native(포털 연결이 * 없는 Webmaker 고유 프로젝트 — 변경 대상 아님, 가져오려면 포털 계정이 있어야 하므로 사람 결정 필요). * - 비밀값(해시·salt·토큰·키·세션)은 읽지도 출력하지도 않는다. 이메일은 앞 2글자만 남긴다. */ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; const args = process.argv.slice(2); const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; }; const readJson = (f, fb = null) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch (_) { return fb; } }; const maskEmail = (e) => (typeof e === 'string' && e.includes('@') ? e.slice(0, 2) + '…@' + e.split('@')[1] : null); const SECRET = /pass|hash|salt|secret|token|key|session|cookie|credential/i; const countFiles = (dir) => { let n = 0; const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) walk(join(d, e.name)); else if (e.isFile()) n++; } }; try { walk(dir); } catch (_) { return null; } return n; }; /** Webmaker 데이터 루트에서 비밀 없는 inventory 를 읽는다. */ export function readWebmaker(dataRoot, { sitesRoot = null, nginxEnabled = null, appDir = null } = {}) { const usersRaw = readJson(join(dataRoot, 'users.json'), {}); const users = Object.values(usersRaw).filter((u) => u && u.id).map((u) => ({ id: u.id, name: u.name, role: u.role || 'user', accountType: u.accountType || 'domain', sso: u.sso || null, createdAt: u.createdAt || null, lastLoginAt: u.lastLoginAt || null })); const links = (readJson(join(dataRoot, 'ariweb-links.json'), { links: {} }) || {}).links || {}; const sitesDir = join(dataRoot, 'sites'); const projects = []; if (existsSync(sitesDir)) for (const f of readdirSync(sitesDir).filter((x) => /^[0-9a-f]{32}\.json$/.test(x)).sort()) { const s = readJson(join(sitesDir, f)); if (!s || !s.id) continue; const deployments = Array.isArray(s.deployments) ? s.deployments : []; const last = deployments[deployments.length - 1] || null; const changes = (s.git && Array.isArray(s.git.changes)) ? s.git.changes : []; const pushed = changes.filter((c) => c.pushed).slice(-1)[0] || null; const vhost = nginxEnabled ? existsSync(join(nginxEnabled, `wh-${s.id}.conf`)) : null; projects.push({ id: s.id, title: String(s.title || '').slice(0, 60), ownerId: s.ownerId || null, status: s.status || null, mode: s.mode || null, createdAt: s.createdAt || null, updatedAt: s.updatedAt || null, domains: Array.isArray(s.domains) ? s.domains.map((d) => (typeof d === 'string' ? d : d.name || d.domain)).filter(Boolean) : [], deployments: deployments.length, lastDeploy: last ? { target: last.targetId || last.target || null, at: last.at || last.deployedAt || null, url: last.url || last.publicUrl || null } : null, git: { changes: changes.length, lastPushed: pushed ? { no: pushed.no, kind: pushed.kind, commit: pushed.commit, branch: pushed.branch } : null }, langs: s.langs && Array.isArray(s.langs.list) ? s.langs.list : null, unpublishedAt: s.unpublishedAt || null, siteDirFiles: sitesRoot ? countFiles(join(sitesRoot, s.id)) : null, vhost, link: links[s.id] ? { siteId: links[s.id].siteId, groupId: links[s.id].groupId, siteName: links[s.id].siteName, linkedAt: links[s.id].linkedAt } : null }); } const domainsRaw = readJson(join(dataRoot, 'domains.json'), { domains: [] }) || {}; const domains = (Array.isArray(domainsRaw) ? domainsRaw : domainsRaw.domains || []).map((d) => ({ name: d.name || d.domain, siteId: d.siteId || null, ownerId: d.ownerId || d.userId || null, status: d.status || null })); const dt = readJson(join(dataRoot, 'deploy-targets.json'), { defaultId: null, targets: [] }) || {}; const deployTargets = (dt.targets || []).map((t) => ({ id: t.id, name: t.name, type: t.type, baseDir: t.baseDir || null, publicBaseUrl: t.publicBaseUrl || null, isDefault: t.id === dt.defaultId })); const ms = readJson(join(dataRoot, 'mail-servers.json'), { items: [] }) || {}; const msItems = Array.isArray(ms.items) ? ms.items : Object.values(ms.items || {}); const mailServers = msItems.map((m) => ({ id: m.id, name: m.name || null, provider: m.provider || m.kind || m.type || null, isDefault: m.id === ms.defaultId })); const linkEntries = Object.entries(links).map(([projectId, l]) => ({ projectId, siteId: l.siteId, groupId: l.groupId })); const rev = appDir ? (readJson(join(appDir, 'REVISION.json')) || null) : null; // HAL 이 설치한 모듈은 앱 디렉터리(current/)에 REVISION.json 을 둔다 const config = readJson(join(dataRoot, 'config.json'), {}) || {}; const v = config.values || {}; const out = { users, projects, domains, deployTargets, mailServers, links: linkEntries, config: { publicBaseUrl: v.service?.publicBaseUrl || null, sourceProvider: v.source?.provider || null, mailProvider: v.mail?.provider || null, portalUrl: v.ariweb?.portalUrl || null }, revision: rev }; assertNoSecrets(out); return out; } /** 포털 DB(읽기 전용) inventory. */ export function readPortal(dbFile) { const { DatabaseSync } = requireSqlite(); const d = new DatabaseSync(dbFile, { readOnly: true }); const all = (sql) => d.prepare(sql).all(); const out = { users: all('SELECT id, email, display_name, status, is_operator, created_at FROM users').map((u) => ({ id: u.id, email: maskEmail(u.email), displayName: u.display_name, status: u.status, isOperator: !!u.is_operator, createdAt: u.created_at })), groups: all('SELECT id, name, kind, owner_user_id, status FROM groups').map((g) => ({ id: g.id, name: g.name, kind: g.kind, ownerUserId: g.owner_user_id, status: g.status })), sites: all('SELECT id, group_id, name, webmaker_project_id, lifecycle, deploy_mode, primary_domain, server_id, current_version FROM sites').map((s) => ({ id: s.id, groupId: s.group_id, name: s.name, webmakerProjectId: s.webmaker_project_id, lifecycle: s.lifecycle, deployMode: s.deploy_mode, primaryDomain: s.primary_domain, serverId: s.server_id, currentVersion: s.current_version })), domains: all('SELECT id, group_id, site_id, name, purpose, is_primary, ownership_state, dns_state FROM domains').map((x) => ({ id: x.id, groupId: x.group_id, siteId: x.site_id, name: x.name, purpose: x.purpose, isPrimary: !!x.is_primary, ownershipState: x.ownership_state, dnsState: x.dns_state })), mailServices: all('SELECT id, group_id, site_id, kind, domain, status FROM mail_services').map((m) => ({ id: m.id, groupId: m.group_id, siteId: m.site_id, kind: m.kind, domain: m.domain, status: m.status })), deployments: all('SELECT id, site_id, status, version_label, created_at FROM deployments').map((x) => ({ id: x.id, siteId: x.site_id, status: x.status, version: x.version_label, createdAt: x.created_at })), }; d.close(); assertNoSecrets(out); return out; } /** HAL DB(읽기 전용) inventory — 서버·Agent·모듈 등록부·릴리스. */ export function readHal(dbFile) { const { DatabaseSync } = requireSqlite(); const d = new DatabaseSync(dbFile, { readOnly: true }); const all = (sql) => d.prepare(sql).all(); const out = { servers: all('SELECT id, name, role, location, status, agent_id, agent_version, last_seen_at FROM servers').map((s) => ({ id: s.id, name: s.name, role: s.role, location: s.location, status: s.status, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at })), agents: all('SELECT id, server_id, kind, status, version, last_seen_at FROM agents').map((a) => ({ id: a.id, serverId: a.server_id, kind: a.kind, status: a.status, version: a.version, lastSeenAt: a.last_seen_at })), modules: all('SELECT name, target_version FROM modules'), moduleTargets: all('SELECT module, server_id, target_version, actual_version, health, active, last_check_at, last_error FROM module_targets').map((m) => ({ module: m.module, serverId: m.server_id, targetVersion: m.target_version, actualVersion: m.actual_version, health: m.health, active: !!m.active, lastCheckAt: m.last_check_at, lastError: m.last_error })), releases: all('SELECT version, main_commit, status FROM releases').map((r) => ({ version: r.version, mainCommit: r.main_commit, status: r.status })), bootstrap: (all('SELECT release_version, completed_at FROM bootstrap_state WHERE id=1')[0] || null), opsAccounts: all('SELECT count(*) AS c FROM ops_accounts')[0].c, }; d.close(); assertNoSecrets(out); return out; } /** dry-run: 세 inventory 를 안정 식별자로 대조해 판정과 충돌·누락·중복 목록을 만든다(아무것도 쓰지 않는다). */ export function dryRun({ webmaker, portal = null, hal = null, giteaRefs = null }) { const findings = []; const add = (severity, kind, detail) => findings.push({ severity, kind, ...detail }); const portalSites = portal ? portal.sites : []; const byProject = new Map(); for (const s of portalSites) if (s.webmakerProjectId) { if (!byProject.has(s.webmakerProjectId)) byProject.set(s.webmakerProjectId, []); byProject.get(s.webmakerProjectId).push(s); } const projects = webmaker.projects.map((p) => { const ps = byProject.get(p.id) || []; let verdict; if (ps.length > 1) { verdict = 'duplicate'; add('conflict', 'duplicate_portal_sites', { projectId: p.id, siteIds: ps.map((s) => s.id) }); } else if (ps.length === 1 && p.link) { if (p.link.siteId === ps[0].id && p.link.groupId === ps[0].groupId) verdict = 'linked_consistent'; else { verdict = 'link_mismatch'; add('conflict', 'link_mismatch', { projectId: p.id, webmakerLink: p.link.siteId, portalSite: ps[0].id }); } } else if (ps.length === 1 && !p.link) { verdict = 'link_portal_only'; add('conflict', 'link_portal_only', { projectId: p.id, portalSite: ps[0].id }); } else if (!ps.length && p.link) { verdict = portal ? 'link_webmaker_only' : 'link_unverified'; if (portal) add('conflict', 'link_webmaker_only', { projectId: p.id, webmakerLink: p.link.siteId }); } else verdict = 'unlinked_native'; return { projectId: p.id, ownerId: p.ownerId, verdict, portalSiteId: ps[0]?.id || null, portalGroupId: ps[0]?.groupId || null, domains: p.domains, deployed: p.deployments > 0, action: verdict === 'linked_consistent' ? 'none' : verdict === 'unlinked_native' ? 'keep (가져오기는 포털 계정 필요 — 사람 결정)' : 'human_review' }; }); const known = new Set(webmaker.projects.map((p) => p.id)); for (const s of portalSites) if (s.webmakerProjectId && !known.has(s.webmakerProjectId)) add('missing', 'missing_project', { siteId: s.id, projectId: s.webmakerProjectId }); // 회원: SSO 로그인(aw--)은 포털 그룹·사용자 접두어와 대조, 고유 회원은 포털 신원 없음(변경 대상 아님) const g8 = new Map((portal ? portal.groups : []).map((g) => [g.id.replace(/^g_/, '').slice(0, 8).toLowerCase(), g.id])); const u8 = new Map((portal ? portal.users : []).map((u) => [u.id.replace(/^u_/, '').slice(0, 8).toLowerCase(), u.id])); const users = webmaker.users.map((u) => { const m = /^aw-([0-9a-f]{8})-([0-9a-f]{8})$/.exec(u.id); if (!m) return { userId: u.id, role: u.role, verdict: 'native_no_portal_identity', action: 'keep' }; const gid = g8.get(m[1]) || null, uid = u8.get(m[2]) || null; if (!portal) return { userId: u.id, role: u.role, verdict: 'sso_unverified', action: 'none' }; if (gid && uid) return { userId: u.id, role: u.role, verdict: 'sso_consistent', portalGroupId: gid, portalUserId: uid, action: 'none' }; add('conflict', 'sso_identity_missing', { userId: u.id, portalGroupId: gid, portalUserId: uid }); return { userId: u.id, role: u.role, verdict: 'sso_identity_missing', action: 'human_review' }; }); // 도메인: 이름으로 대조 const pd = new Map((portal ? portal.domains : []).map((d) => [d.name.toLowerCase(), d])); const domains = webmaker.domains.map((d) => { const p = pd.get(String(d.name).toLowerCase()); if (!portal) return { name: d.name, siteId: d.siteId, verdict: 'unverified' }; if (!p) return { name: d.name, siteId: d.siteId, verdict: 'webmaker_only', action: 'keep' }; const site = projects.find((x) => x.projectId === d.siteId); if (site && site.portalSiteId && p.siteId === site.portalSiteId) return { name: d.name, siteId: d.siteId, verdict: 'consistent', portalDomainId: p.id }; add('conflict', 'domain_site_mismatch', { name: d.name, webmakerSite: d.siteId, portalSite: p.siteId }); return { name: d.name, siteId: d.siteId, verdict: 'mismatch', action: 'human_review' }; }); // Gitea refs: 프로젝트마다 sites//main 이 있고 마지막 푸시 커밋과 같은지 let gitea = null; if (giteaRefs) { const refs = new Map(giteaRefs.split('\n').map((l) => l.trim().split(/\s+/)).filter((x) => x.length === 2).map(([sha, ref]) => [ref.replace(/^refs\/heads\//, ''), sha])); gitea = webmaker.projects.filter((p) => p.git.changes > 0).map((p) => { const main = refs.get(`sites/${p.id}/main`) || null; const lp = p.git.lastPushed; const expect = lp ? refs.get(lp.branch) || null : null; const verdict = !main ? 'main_missing' : lp && expect !== lp.commit ? 'last_push_mismatch' : 'ok'; if (verdict !== 'ok') add('missing', 'gitea_ref', { projectId: p.id, verdict, main, lastPushed: lp }); return { projectId: p.id, main, verdict }; }); } // HAL: webmaker 모듈 등록(서버·버전·건강)과 설치 REVISION 대조 let halModule = null; if (hal) { const mts = hal.moduleTargets.filter((m) => m.module === 'webmaker'); if (!mts.length) { add('missing', 'hal_module_target_missing', { module: 'webmaker' }); halModule = { registered: false }; } else { const rev = webmaker.revision; const t = mts[0]; const versionOk = !rev || rev.version === t.actualVersion; if (!versionOk) add('conflict', 'hal_version_mismatch', { installed: rev.version, halActual: t.actualVersion }); if (t.health !== 'ok') add('conflict', 'hal_health', { health: t.health, error: t.lastError }); halModule = { registered: true, serverId: t.serverId, targetVersion: t.targetVersion, actualVersion: t.actualVersion, installedVersion: rev ? rev.version : null, upstreamVersion: rev ? rev.upstreamVersion : null, health: t.health, lastCheckAt: t.lastCheckAt, agentOnline: hal.agents.some((a) => a.serverId === t.serverId && a.status === 'active') }; } } const summary = { webmaker: { users: webmaker.users.length, usersNative: users.filter((u) => u.verdict.startsWith('native')).length, usersSso: users.filter((u) => u.verdict.startsWith('sso')).length, projects: webmaker.projects.length, projectsDeployed: webmaker.projects.filter((p) => p.deployments > 0).length, domains: webmaker.domains.length, deployTargets: webmaker.deployTargets.length, mailServers: webmaker.mailServers.length, links: webmaker.links.length }, portal: portal ? { users: portal.users.length, groups: portal.groups.length, sites: portal.sites.length, sitesLinked: portalSites.filter((s) => s.webmakerProjectId).length, domains: portal.domains.length, mailServices: portal.mailServices.length } : null, hal: hal ? { servers: hal.servers.length, agents: hal.agents.length, moduleTargets: hal.moduleTargets.length, opsAccounts: hal.opsAccounts, bootstrapComplete: !!(hal.bootstrap && hal.bootstrap.completed_at) } : null, verdicts: countBy(projects.map((p) => p.verdict)), conflicts: findings.filter((f) => f.severity === 'conflict').length, missing: findings.filter((f) => f.severity === 'missing').length, }; const out = { generatedAt: new Date().toISOString(), authority: { projects: 'webmaker', accounts: 'webmaker(native)/portal(sso)', links: 'webmaker ariweb-links.json ↔ portal sites.webmaker_project_id (both must agree)', module: 'hal module_targets' }, summary, projects, users, domains, gitea, halModule, findings, applyPlan: findings.length ? 'blocked: 충돌·누락이 0 이 될 때까지 적용하지 않는다' : 'no-op: 등록부가 이미 일치한다 — 적용할 변경 없음' }; assertNoSecrets(out); return out; } function countBy(list) { const o = {}; for (const x of list) o[x] = (o[x] || 0) + 1; return o; } function requireSqlite() { return process.getBuiltinModule ? process.getBuiltinModule('node:sqlite') : null; } /** 출력 객체 어디에도 비밀처럼 보이는 키가 없어야 한다(방어선). */ export function assertNoSecrets(o, path = '') { if (!o || typeof o !== 'object') return; for (const [k, v] of Object.entries(o)) { if (SECRET.test(k) && !/^(sessionGen|isDefault|lastCheckAt)$/.test(k)) throw new Error(`비밀로 보이는 키가 출력에 있습니다: ${path}${k}`); assertNoSecrets(v, `${path}${k}.`); } } if (import.meta.url === `file://${process.argv[1]}`) { const dataRoot = opt('webmaker-data'); if (!dataRoot) { console.error('--webmaker-data 가 필요합니다'); process.exit(1); } const webmaker = readWebmaker(dataRoot, { sitesRoot: opt('sites-root'), nginxEnabled: opt('nginx-enabled'), appDir: opt('app-dir') }); const portal = opt('portal-db') ? readPortal(opt('portal-db')) : null; const hal = opt('hal-db') ? readHal(opt('hal-db')) : null; const giteaRefs = opt('gitea-refs') ? readFileSync(opt('gitea-refs'), 'utf8') : null; const result = { inventory: { webmaker, portal, hal }, dryRun: dryRun({ webmaker, portal, hal, giteaRefs }) }; const text = JSON.stringify(result, null, 1); if (opt('out')) { const { writeFileSync } = await import('node:fs'); writeFileSync(opt('out'), text, { mode: 0o600 }); } const s = result.dryRun.summary; console.log(JSON.stringify({ summary: s, halModule: result.dryRun.halModule, findings: result.dryRun.findings, applyPlan: result.dryRun.applyPlan }, null, 1)); process.exit(s.conflicts || s.missing ? 2 : 0); }