Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
57 lines
6.1 KiB
JavaScript
57 lines
6.1 KiB
JavaScript
/**
|
|
* hal/index.mjs — HAL 프로세스 진입점과 CLI.
|
|
*
|
|
* node hal/index.mjs 서버 실행(기본). 기동 때 "하나의 HAL" 임대를 잡고(§34) 실행 버전을 기록한다(§32).
|
|
* node hal/index.mjs setup-token 최초 운영자 계정을 만들 설정 토큰을 발급(1회 표시, data/setup.token). 부트스트랩 스크립트가 부른다.
|
|
* node hal/index.mjs grant --login <id> [--hours 8] 허용 네트워크 밖 기기용 접근권 URL 발급(§25)
|
|
* node hal/index.mjs register-release --manifest <dist/<ver>/manifest.json> [--approve] 릴리스 등록(부트스트랩·CI)
|
|
* node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회)
|
|
* node hal/index.mjs bootstrap-release --version <v> --commit <sha> --sha256 <artifact sha> 부트스트랩 때 선택한 릴리스 정보를 기록
|
|
*/
|
|
import { loadConfig } from './config.mjs';
|
|
import { createHalDeps, VERSION } from './deps.mjs';
|
|
import { buildApp } from './app.mjs';
|
|
import * as core from './core.mjs';
|
|
import * as ops from './ops.mjs';
|
|
import * as emergency from './emergency.mjs';
|
|
import * as dr from './dr.mjs';
|
|
import { purgeSessions } from '../lib/session.mjs';
|
|
import { randomToken } from '../lib/crypto.mjs';
|
|
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import { join, dirname, basename } from 'node:path';
|
|
|
|
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
|
const cfg = loadConfig(); const args = process.argv.slice(2); const cmd = args[0] && !args[0].startsWith('--') ? args[0] : 'serve';
|
|
const opt = (name, def = null) => { const i = args.indexOf('--' + name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : def; };
|
|
const d = createHalDeps({ config: cfg, log });
|
|
|
|
if (cmd === 'setup-token') {
|
|
if (core.ensureOpsAccountExists(d)) { console.error('운영자 계정이 이미 있습니다 — 설정 토큰은 최초 1회만 쓸 수 있습니다'); process.exit(1); }
|
|
const t = randomToken(24); writeFileSync(join(cfg.dataDir, 'setup.token'), t, { mode: 0o600 });
|
|
console.log(`설정 토큰(1회 표시, 공유 금지): ${t}\n운영자 콘솔 ${cfg.publicUrl}/ops/ 의 최초 설정 또는 curl -X POST ${cfg.publicUrl}/ops/api/setup -d '{"setupToken":"…","login":"…","password":"…","displayName":"…"}' 로 첫 운영자 계정을 만드세요`);
|
|
} else if (cmd === 'grant') {
|
|
const g = ops.issueGrant(d, { accountId: opt('login'), hours: Number(opt('hours', 8)), by: null, reason: opt('reason', 'cli') }); console.log(`접근권 URL(1회, ${g.expiresAt} 까지): ${g.url}`);
|
|
} else if (cmd === 'register-release') {
|
|
const mf = JSON.parse(readFileSync(opt('manifest'), 'utf8')); const dir = join(core.artifactDir(d), mf.version); mkdirSync(dir, { recursive: true });
|
|
for (const [m, a] of Object.entries(mf.artifacts)) { const src = join(dirname(opt('manifest')), a.file); if (existsSync(src)) copyFileSync(src, join(dir, basename(a.file))); }
|
|
const r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size }])), checks: mf.checks || null, approve: !!opt('approve'), by: opt('by', 'cli') });
|
|
console.log(`릴리스 ${r.version} 등록(${r.status}) — artifact ${Object.keys(r.artifacts).join(', ')}`);
|
|
} else if (cmd === 'link-portal') {
|
|
// [E2E 결함 수정] 포털은 양방향 모두 보관소 이름 'service:hal' 하나를 쓴다(포털→HAL 서명, HAL→포털 검증). 그래서 공유 비밀 하나를 HAL 의 두 이름(service:portal·service:hal)에 같이 둔다.
|
|
const shared = randomToken(32); d.vault.set('service:portal', shared, { kind: 'service-key' }); d.vault.set('service:hal', shared, { kind: 'service-key' });
|
|
console.log(`포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${shared}\n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)`);
|
|
} else if (cmd === 'bootstrap-release') {
|
|
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
|
|
} else if (cmd === 'serve') {
|
|
// "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만).
|
|
try { core.claimLive(d, { instanceId: d.instanceId, version: VERSION, takeover: !!opt('takeover') }); } catch (e) { console.error(`[hal] 기동 거부: ${e.message}`); process.exit(2); }
|
|
const app = buildApp(d); d.jobs.start(); d.jobsRunning = true;
|
|
const every = (ms, fn) => setInterval(() => { try { const r = fn(); if (r && r.catch) r.catch((e) => log('[hal] 주기 작업 오류', e.message)); } catch (e) { log('[hal] 주기 작업 오류', e.message); } }, ms).unref();
|
|
every(20000, () => core.renewLease(d, d.instanceId)); every(30000, () => core.markOffline(d)); every(3000, () => core.flushPortalEvents(d)); every(60000, () => emergency.expireCredentials(d));
|
|
every(3600000, () => { core.purgeMetrics(d); purgeSessions(d.store); });
|
|
every(60000, () => { const c = dr.getConfig(d); if (!c.snapshot_interval_minutes) return; const last = d.store.get('SELECT created_at FROM dr_snapshots WHERE verify_state=\'ok\' ORDER BY created_at DESC LIMIT 1'); if (!last || Date.now() - Date.parse(last.created_at) >= c.snapshot_interval_minutes * 60000) dr.snapshot(d, { kind: 'scheduled' }); });
|
|
await app.listen(cfg.port, cfg.host);
|
|
log(`[hal] Ariweb HAL v${VERSION} — http://${cfg.host}:${cfg.port} (공개 ${cfg.publicUrl}, 데이터 ${cfg.dataDir}, 부트스트랩 ${core.bootstrapComplete(d) ? '완료' : '진행 중'})`);
|
|
process.on('SIGTERM', () => { d.stopping = true; d.jobs.stop(); setTimeout(() => process.exit(0), 500); });
|
|
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
|