Files
AriWeb-Suite/test/hal-041.test.mjs
T
zzabisagent-labandClaude Fable 5.1 2b31543a86 HAL 0.4.1: 서버 등록 TLS 수정·"이미 등록된 서버" 안내, HAL 배포가 HAL 서버의 Node Agent·HawkEye 를 함께 설치해 부트스트랩을 닫음
- hal/tls.mjs: 공개 주소 TLS 신뢰 확인(시스템 CA → 실패면 체인·공개키 핀), 신뢰 앵커(체인 자체 서명 / <data>/tls/hal-ca.pem), 10분 캐시, 핀 설치 명령(curl -k --pinnedpubkey … --hal-pin). 공개 경로 GET /agent/v1/hal-ca.pem · /agent/v1/tls, CLI tls-probe
- deploy/agent-install.sh: --hal-pin(핀으로 CA 받기)·--ca·--insecure, curl --cacert / Node NODE_EXTRA_CA_CERTS / register --ca / 유닛 Environment, 연결 실패 원인·조치 한 줄, 이미 등록된 서버 안내
- agent/halca.mjs + agent/index.mjs·config.mjs: caFile 런타임 적용(tls.setDefaultCACertificates), fetch 실패를 읽을 수 있는 한 줄로(종료 1), 이미 등록(종료 2, --force), HAL 거부(종료 3), heartbeat observability → hawkeye
- core.registerAgent: 같은 서버의 두 번째 토큰 409 server_already_registered / 쓴 토큰 409 token_used("이미 등록된 서버"), 거부 토큰 미소비, 재등록 허용(replace: 기존 Agent 해지, 감사 agent.replace), 마이그레이션 hal_0011_token_replace, API/CLI --replace --json(재인증), ops.js 재등록 확인·핀 안내
- deploy/hal-host-agent.sh(server-create --profile hal → server-token --json → agent-install loopback → server-approve), bootstrap-hal.sh 6/7 단계(--no-agent), CLI server-create·server-approve, SERVER_PROFILES.hal
- core.deploymentStatus/selfHealth.deployment, 배너 "부트스트랩 완료 · 기능 단위 서버 배포 대기: …" / "진행 중", 배포 릴리스 화면 문구, heartbeat canonicalModuleName(0.3.17 잔여 결함)
- 개발 지시 v0.4.1(Oz)·VERSION_HISTORY 0.4.1·README·docs/HAL-AGENT §6·RUNBOOK §11·AI 스킬, 도입 모듈 변경 없음(Webmaker 1.9.0·AriMail 1.1.0)
- test/hal-041.test.mjs RED → GREEN 7/7(실제 자체 서명 TLS 픽스처·별도 프로세스 CLI), 기존 배너·프로필·버전 테스트 갱신, 전체 32파일 통과, 로컬 E2E 보고서 2026-11-06

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 08:40:09 +00:00

193 lines
27 KiB
JavaScript

/**
* test/hal-041.test.mjs — Ariweb Suite 0.4.1(개발 지시 `Ariweb-Suitle_Dev-Order_v0.4.1.md`, Oz 2026-11-06): 서버 등록 TLS·이미 등록 메시지 + HAL 배포가 Agent·HawkEye 를 함께 설치해 부트스트랩을 닫는다.
*
* 계약(요약)
* 1) hal/tls.mjs: HAL 공개 주소가 자체 서명·사설 CA 면 trusted=false·공개키 핀(curl --pinnedpubkey 형식)·신뢰 앵커 PEM. http 는 trusted. 설치 명령은 핀(-k --pinnedpubkey / --hal-pin)을 붙인다.
* 공개 경로 GET /agent/v1/hal-ca.pem(신뢰 앵커) · GET /agent/v1/tls(상태) — 무서명.
* 2) 이미 등록된 서버: 같은 서버의 두 번째 토큰 → 409 server_already_registered("이미 등록된 서버"), 쓴 토큰 재사용 → 409 token_used(같은 문구), 재등록 허용 토큰(replace) → 기존 Agent 해지·새 Agent. API 의 replace 는 재인증.
* 3) Agent CLI register: CA 없이 자체 서명 HAL → 스택 대신 한 줄 원인·조치(종료 1), --ca → HAL 응답(bad_token), 등록 뒤 재실행 → "이미 등록된 서버"(종료 2). agent.json 에 caFile, status 는 런타임 CA 로 연결.
* 4) 부트스트랩: HAL 서버 자신(control, 프로필 hal) 의 Agent 등록·승인으로 완료(CLI server-create/server-token --json/server-approve), 배너는 "부트스트랩 미완료" 대신 기능 단위 서버 배포 대기. heartbeat 의 observability 는 hawkeye 로.
* 5) 스크립트·문서·버전: agent-install.sh(--hal-pin·--ca·NODE_EXTRA_CA_CERTS·이미 등록), hal-host-agent.sh, bootstrap-hal.sh 가 부름, 0.4.1.
*/
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { invoke } from '../lib/http.mjs';
import { signHeaders } from '../lib/serviceauth.mjs';
import { createHalDeps } from '../hal/deps.mjs';
import { buildApp } from '../hal/app.mjs';
import * as core from '../hal/core.mjs';
import * as ops from '../hal/ops.mjs';
import { probePublicTls, installCommand, caBundle, spkiPin, ensureTlsInfo } from '../hal/tls.mjs';
import { SERVER_PROFILES, MODULE_GUIDES } from '../hal/modules/index.mjs';
import * as R from '../hal/ui/render.mjs';
import { applyHalCa, friendlyFetchError, splitPem } from '../agent/halca.mjs';
import { loadAgentConfig } from '../agent/config.mjs';
import https from 'node:https';
import { X509Certificate } from 'node:crypto';
import { execFileSync, spawnSync, spawn } from 'node:child_process';
import { mkdirSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const rnd = (p) => `${p}-${randomBytes(4).toString('hex')}`;
function makeHal(over = {}) {
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 4000, fanoutTimeoutMs: 6000, values: {}, set(p) { Object.assign(this, p); }, ...over };
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => ({ ok: true, status: 200, text: async () => 'ok', json: async () => ({ ok: true }) }) }); d.jobsRunning = true;
return { d, app: buildApp(d), dataDir };
}
async function loginOp(h) { const pw = rnd('pw'); ops.createAccount(h.d, { login: 'ozops', password: pw, displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } }); ok(r.json.ok); return { pw, as: (m, p, b) => invoke(h.app, m, p, { body: b, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) }; }
const sig = (keyId, secret, method, path, body) => Object.fromEntries(Object.entries(signHeaders(keyId, secret, method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]));
const agentCall = (app, id, secret, method, path, body) => invoke(app, method, path, { body, headers: sig(`agent:${id}`, secret, method, path, body) });
/** 자체 서명 인증서(hal.local.test) + HTTPS 서버. handler 가 없으면 HAL 앱(invoke)으로 중계한다 — 실제 TLS 핸드셰이크·fetch 경로를 시험한다. */
async function tlsFixture(h = null) {
const dir = tmpDir(); const key = join(dir, 'key.pem'); const cert = join(dir, 'cert.pem');
execFileSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', key, '-out', cert, '-days', '2', '-subj', '/CN=hal.local.test', '-addext', 'subjectAltName=DNS:hal.local.test,DNS:localhost,IP:127.0.0.1'], { stdio: 'ignore' });
const srv = https.createServer({ key: readFileSync(key), cert: readFileSync(cert) }, async (req, res) => {
if (!h) { res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ ok: true })); }
let text = ''; for await (const c of req) text += c; const r = await invoke(h.app, req.method, req.url, { body: text ? JSON.parse(text) : undefined, headers: Object.fromEntries(Object.entries(req.headers).filter(([k]) => k.startsWith('x-') || k === 'content-type')) });
res.writeHead(r.status || 200, { 'content-type': 'application/json', ...(r.headers || {}) }); res.end(r.text);
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port;
return { dir, cert, key, port, url: `https://127.0.0.1:${port}`, close: () => new Promise((r) => srv.close(r)) };
}
/** Agent CLI 를 별도 프로세스로(비동기 — 같은 프로세스의 HTTPS 픽스처가 응답해야 하므로 spawnSync 를 쓰면 안 된다) */
const agentCli = (dataDir, args, extraEnv = {}) => new Promise((resolve) => { const p = spawn(process.execPath, ['--no-warnings', join(ROOT, 'agent', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_AGENT_DATA: dataDir, ARIWEB_AGENT_STUB_CMDS: '1', ...extraEnv } }); let stdout = ''; let stderr = ''; p.stdout.on('data', (c) => { stdout += c; }); p.stderr.on('data', (c) => { stderr += c; }); const t = setTimeout(() => p.kill('SIGKILL'), 40000); p.on('close', (status) => { clearTimeout(t); resolve({ status, stdout, stderr }); }); });
// HAL CLI: 공개 주소는 환경(HAL_PUBLIC_URL)으로 고정해 실제 hal.ariweb.net 에 TLS 확인을 보내지 않는다
const halCli = (dataDir, args, extraEnv = {}) => spawnSync(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, PORT: '0', HAL_PUBLIC_URL: 'http://127.0.0.1:1', ...extraEnv }, encoding: 'utf8', timeout: 40000 });
await test('[요구 1] TLS 확인: 자체 서명 HAL 주소 → trusted=false·공개키 핀·신뢰 앵커 PEM / http 주소 → 신뢰 / 접속 불가 → null', async () => {
const fx = await tlsFixture();
try {
const info = await probePublicTls(fx.url); eq(info.trusted, false); eq(info.reason, 'DEPTH_ZERO_SELF_SIGNED_CERT'); ok(/^sha256\/\/[A-Za-z0-9+/]+=*$/.test(info.pin), '핀 형식 ' + info.pin);
eq(info.pin, spkiPin(new X509Certificate(readFileSync(fx.cert))), '핀 = 서버 인증서 SPKI sha256'); eq(info.ca.length, 1); eq(info.caSource, 'chain'); ok(caBundle(info).includes('BEGIN CERTIFICATE')); ok(info.subject.includes('hal.local.test'));
// 운영자 지정 CA 파일(<data>/tls/hal-ca.pem)이 있으면 체인 대신 그 파일 — 그리고 그 CA 로 검증되면 trusted=true
const dataDir = tmpDir(); mkdirSync(join(dataDir, 'tls')); writeFileSync(join(dataDir, 'tls', 'hal-ca.pem'), readFileSync(fx.cert)); const info2 = await probePublicTls(fx.url, { dataDir }); eq(info2.trusted, true); eq(info2.reason, 'trusted_by_file_ca'); eq(info2.caSource, 'file'); eq(info2.ca.length, 1, '파일 CA 를 그대로 제공(다른 서버가 받아 쓴다)');
const plain = await probePublicTls('http://127.0.0.1:1'); eq(plain.trusted, true); eq(plain.reason, 'plain_http'); eq(plain.pin, null);
const dead = await probePublicTls('https://127.0.0.1:1', { timeoutMs: 1500 }); eq(dead.trusted, null); ok(dead.error.includes('접속할 수 없습니다'));
eq((await probePublicTls('not a url')).reason, 'bad_url');
} finally { await fx.close(); }
});
await test('[요구 1] 설치 명령·공개 경로: 신뢰되지 않으면 -k --pinnedpubkey 와 --hal-pin, 신뢰되면 핀 없음; /agent/v1/hal-ca.pem·/agent/v1/tls 는 무서명 공개', async () => {
const fx = await tlsFixture(); const h = makeHal({ publicUrl: fx.url });
try {
const info = await ensureTlsInfo(h.d); eq(info.trusted, false); eq(h.d.tlsInfo.pin, info.pin); ok(await ensureTlsInfo(h.d) === info, '10분 안에는 캐시');
const srv = core.createServer(h.d, { name: 'ws2', role: 'web' }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
ok(t.installCommand.startsWith(`curl -fsSL -k --pinnedpubkey '${info.pin}' ${fx.url}/agent/v1/install.sh | bash -s -- --hal ${fx.url} --token ${t.token} --hal-pin '${info.pin}'`), t.installCommand); eq(t.tls.trusted, false); eq(t.tls.caAvailable, true); eq(t.existingAgent, null);
const plain = installCommand('https://hal.ariweb.net', 'TOK', { tlsInfo: { trusted: true, pin: 'sha256//x' } }); eq(plain.installCommand, 'curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token TOK'); eq(installCommand('https://h', 'T', { tlsInfo: null }).tls, null);
eq(installCommand('https://h', 'T', { kind: 'external', siteId: 's1', tlsInfo: { trusted: false, pin: 'sha256//p' } }).installCommand, "curl -fsSL -k --pinnedpubkey 'sha256//p' https://h/agent/v1/install.sh | bash -s -- --hal https://h --token T --external --site s1 --hal-pin 'sha256//p'");
let r = await invoke(h.app, 'GET', '/agent/v1/hal-ca.pem'); eq(r.status, 200); ok(r.text.includes('BEGIN CERTIFICATE'), '신뢰 앵커 PEM'); ok(!r.text.includes('PRIVATE'), '개인키 없음');
r = await invoke(h.app, 'GET', '/agent/v1/tls'); eq(r.status, 200); eq(r.json.tls.trusted, false); eq(r.json.tls.pin, info.pin); eq(r.json.tls.caAvailable, true); ok(!JSON.stringify(r.json).includes('PRIVATE'));
// 신뢰되는(http) 주소면 CA 경로는 404(필요 없음)
const h2 = makeHal({ publicUrl: 'http://127.0.0.1:1' }); r = await invoke(h2.app, 'GET', '/agent/v1/hal-ca.pem'); eq(r.status, 404); eq(r.json.error, 'no_private_ca');
// 운영자 API: 토큰 발급 응답에 tls·installCommand(핀)·existingAgent
const op = await loginOp(h); r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, {}); eq(r.status, 200); ok(r.json.installCommand.includes('--hal-pin')); eq(r.json.tls.trusted, false);
} finally { await fx.close(); }
});
await test('[요구 1] 이미 등록된 서버: 두 번째 토큰 → 409 server_already_registered, 쓴 토큰 → 409 token_used(이미 등록된 서버 문구), 재등록 허용 토큰 → 기존 Agent 해지·새 Agent, API replace 는 재인증', async () => {
const h = makeHal(); const srv = core.createServer(h.d, { name: 'WS1', role: 'web' });
const t1 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
let r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t1.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); ok(r.json.ok, JSON.stringify(r.json)); const a1 = r.json.agentId;
// 같은 명령을 다시 실행(쓴 토큰)
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t1.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.status, 409); eq(r.json.error, 'token_used'); ok(r.json.message.includes('이미 등록된 서버'), r.json.message); ok(r.json.message.includes(a1));
// 새 토큰(재등록 허용 없음)
const t2 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id }); ok(t2.existingAgent && t2.existingAgent.id === a1, '발급 응답이 기존 Agent 를 알린다');
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.status, 409); eq(r.json.error, 'server_already_registered'); ok(r.json.message.includes('이미 등록된 서버입니다') && r.json.message.includes('WS1') && r.json.message.includes(a1), r.json.message);
eq(core.getServer(h.d, srv.id).agent_id, a1, '서버의 Agent 는 그대로'); eq(core.getAgent(h.d, a1).status, 'active'); ok(!h.d.store.get('SELECT used_at FROM registration_tokens WHERE id=?', t2.id).used_at, '거부된 토큰은 소비되지 않는다');
// 서버 없는 managed 토큰 + 같은 호스트 이름 → 같은 거부
const t3 = core.issueRegistrationToken(h.d, { kind: 'managed' }); r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t3.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.json.error, 'server_already_registered');
// 재등록 허용 토큰
const t4 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id, replace: true }); eq(t4.replace, true);
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t4.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); ok(r.json.ok, JSON.stringify(r.json)); const a2 = r.json.agentId; ok(a2 !== a1);
eq(core.getServer(h.d, srv.id).agent_id, a2); eq(core.getAgent(h.d, a1).status, 'revoked'); eq(core.getAgent(h.d, a2).status, 'active'); ok(!h.d.vault.has(`service:agent:${a1}`), '옛 Agent 비밀 제거'); ok(h.d.vault.has(`service:agent:${a2}`));
const hb = await agentCall(h.app, a1, 'x', 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: {}, modules: {} }); eq(hb.status, 401, '해지된 Agent 는 거부');
ok(h.d.store.get("SELECT COUNT(*) AS n FROM audit_log WHERE action='agent.replace'").n >= 1, '감사 agent.replace');
// 운영자 API: replace 는 재인증 필요, 보통 발급은 재인증 없이
const op = await loginOp(h); h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, { replace: true }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, {}); eq(r.status, 200); eq(r.json.existingAgent.id, a2); eq(r.json.replace, false);
h.d.store.run('UPDATE sessions SET reauth_at=?', new Date().toISOString()); r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, { replace: true }); eq(r.status, 200); eq(r.json.replace, true);
});
await test('[요구 1] Agent CLI register: CA 없이 자체 서명 HAL → 한 줄 원인·조치(스택 없음), --ca → HAL 응답, 등록 뒤 재실행 → 이미 등록된 서버, caFile 저장·런타임 CA 로 status 연결', async () => {
const h = makeHal(); const fx = await tlsFixture(h); h.d.config.publicUrl = fx.url;
try {
const data = join(tmpDir(), 'agent-data'); mkdirSync(data, { recursive: true });
let r = await agentCli(data, ['register', '--hal', fx.url, '--token', 'nope']); eq(r.status, 1); ok(r.stderr.includes('인증서를 확인할 수 없습니다') && r.stderr.includes('DEPTH_ZERO_SELF_SIGNED_CERT') && r.stderr.includes('--hal-pin') && r.stderr.includes('--ca'), r.stderr); ok(!r.stderr.includes('TypeError') && !r.stderr.includes('at async'), '스택 없음: ' + r.stderr);
r = await agentCli(data, ['register', '--hal', fx.url, '--token', 'nope', '--ca', fx.cert]); eq(r.status, 1); ok(r.stderr.includes('등록 실패: bad_token'), r.stderr);
const srv = core.createServer(h.d, { name: 'ws-cli', role: 'web' }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
r = await agentCli(data, ['register', '--hal', fx.url, '--token', t.token, '--ca', fx.cert, '--role', 'web', '--label', 'ws-cli']); eq(r.status, 0, r.stderr); ok(r.stdout.includes('등록 완료'));
const saved = JSON.parse(readFileSync(join(data, 'agent.json'), 'utf8')); eq(saved.caFile, fx.cert); eq(saved.serverId, srv.id); eq(loadAgentConfig({ ARIWEB_AGENT_DATA: data }).caFile, fx.cert);
// 다시 실행(새 토큰이어도) → 이미 등록된 서버, 종료 2, HAL 호출 없음
const t2 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id }); r = await agentCli(data, ['register', '--hal', fx.url, '--token', t2.token, '--ca', fx.cert]); eq(r.status, 2); ok(r.stderr.includes('이미 등록된 서버입니다') && r.stderr.includes(saved.id), r.stderr); ok(!h.d.store.get('SELECT used_at FROM registration_tokens WHERE id=?', t2.id).used_at);
// status: agent.json 의 caFile 을 런타임에 적용해 자체 서명 HAL 에 연결(NODE_EXTRA_CA_CERTS 없이)
r = await agentCli(data, ['status']); eq(r.status, 0, r.stderr + r.stdout); ok(r.stdout.includes('연결 정상') && r.stdout.includes('runtime'), r.stdout);
// HAL 쪽 거부(이미 등록된 서버)도 읽을 수 있는 한 줄 + 종료 3
const data2 = join(tmpDir(), 'agent-data2'); mkdirSync(data2, { recursive: true }); r = await agentCli(data2, ['register', '--hal', fx.url, '--token', t2.token, '--ca', fx.cert]); eq(r.status, 3); ok(r.stderr.includes('server_already_registered') && r.stderr.includes('이미 등록된 서버'), r.stderr);
} finally { await fx.close(); }
// halca 단위: 메시지 분류·PEM 분할·적용
eq(friendlyFetchError({ cause: { code: 'SELF_SIGNED_CERT_IN_CHAIN' } }, 'https://hal.local.test/').tls, true); ok(friendlyFetchError({ cause: { code: 'ECONNREFUSED' } }, 'https://x').message.includes('연결할 수 없습니다'));
eq(splitPem('junk\n-----BEGIN CERTIFICATE-----\nA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nB\n-----END CERTIFICATE-----\n').length, 2); eq(applyHalCa(null).reason, 'no_ca'); eq(applyHalCa('/nonexistent/ca.pem').reason, 'missing'); eq(applyHalCa('/etc/hostname', { env: { NODE_EXTRA_CA_CERTS: '/etc/hostname' } }).via, 'env');
});
await test('[요구 2] 부트스트랩: HAL 서버 자신의 Agent(control·프로필 hal) 등록·승인으로 완료, 배너는 기능 단위 서버 배포 대기, observability 보고는 hawkeye 로', async () => {
const h = makeHal(); core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.4.1' }); core.setBootstrap(h.d, { release_version: '0.4.1', main_commit: 'abc', artifact_sha256: 'x' });
const op = await loginOp(h);
let page = await op.as('GET', '/ops/'); ok(page.text.includes('부트스트랩 진행 중') && page.text.includes('hal-host-agent.sh'), '완료 전: 진행 중 안내'); ok(!page.text.includes('기능 단위 서버 배포 대기'));
eq(core.selfHealth(h.d).bootstrap.complete, false); eq(core.selfHealth(h.d).deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']);
// HAL 서버 자신(프로필 hal: agent + hawkeye 만)
ok(SERVER_PROFILES.hal && SERVER_PROFILES.hal.role === 'control'); eq(SERVER_PROFILES.hal.modules, ['agent', 'hawkeye']); ok(MODULE_GUIDES.agent.install.includes('bootstrap-hal.sh'));
const srv = core.createServer(h.d, { name: 'hal21.ariweb.net', role: 'control', by: 'cli:bootstrap' }); core.updateServer(h.d, srv.id, { profile: 'hal', role: 'control', slots_json: '{}' }, 'cli:bootstrap');
const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id, by: 'cli:bootstrap' });
let r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'bear', units: { hal: 'ariweb-hal' } }, version: '0.4.1' } }); ok(r.json.ok); const ag = r.json;
r = await agentCall(h.app, ag.agentId, ag.secret, 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: { cpu: 1, mem: { total: 10, used: 5 }, disk: { total: 100, used: 10 }, load1: 0.1 }, modules: { agent: { version: '0.4.1', active: true, health: 'ok' }, observability: { version: '0.4.1', active: true, health: 'ok' }, hal: { version: '0.4.1', active: true, health: 'ok' } }, clockSkewMs: 0 }); ok(r.json.ok);
const mods = h.d.store.all('SELECT module FROM module_targets WHERE server_id=? ORDER BY module', srv.id).map((x) => x.module); eq(mods, ['agent', 'hal', 'hawkeye'], 'observability → hawkeye(옛 행 없음)');
const s = core.approveServer(h.d, srv.id, 'cli:bootstrap'); eq(s.status, 'ready'); eq(s.profile, 'hal');
const hh = core.selfHealth(h.d); eq(hh.bootstrap.complete, true); eq(hh.bootstrap.manualInstallLocked, true); eq(hh.bootstrap.firstAgent, ag.agentId); eq(hh.deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']); eq(hh.deployment.agents, 1);
page = await op.as('GET', '/ops/'); ok(!page.text.includes('부트스트랩 미완료') && !page.text.includes('부트스트랩 진행 중'), '완료 뒤 미완료 문구 없음'); ok(page.text.includes('부트스트랩 완료') && page.text.includes('기능 단위 서버 배포 대기') && page.text.includes('고객 포털, Webmaker, AriMail, 백업'), page.text.slice(page.text.indexOf('alerts'), page.text.indexOf('alerts') + 600));
r = await op.as('GET', '/ops/api/releases'); eq(r.json.deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']); const html = R.releasesPage ? R.releasesPage(r.json) : ''; if (html) ok(html.includes('기능 단위 서버 배포 대기') && !html.includes('부트스트랩 미완료'));
r = await invoke(h.app, 'GET', '/api/health'); eq(r.json.bootstrap.complete, true); eq(r.json.deployment.pending.length, 4);
// 기능 모듈이 어떤 서버에든 배치되면 대기 목록에서 빠진다(Webmaker 서버 추가 + heartbeat)
const web = core.createServer(h.d, { name: 'web1', role: 'web' }); const tw = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: web.id }); r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: tw.token, facts: { hostname: 'web1' }, version: '0.4.1' } }); const aw = r.json;
await agentCall(h.app, aw.agentId, aw.secret, 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: {}, modules: { agent: { version: '0.4.1', active: true, health: 'ok' }, webmaker: { version: '1.9.0', active: true, health: 'ok' } }, clockSkewMs: 0 });
eq(core.selfHealth(h.d).deployment.pending, ['portal', 'arimail', 'backup']); eq(core.deploymentStatus(h.d).deployed.map((i) => i.module), ['webmaker']);
page = await op.as('GET', '/ops/'); ok(page.text.includes('고객 포털, AriMail, 백업'));
eq(R.moduleLabel('observability'), 'HawkEye'); eq(R.moduleLabel('zzz'), 'zzz');
});
await test('[요구 2] CLI: server-create --profile hal · server-token --json · server-approve → 부트스트랩 완료(별도 프로세스)', async () => {
const dataDir = join(tmpDir(), 'hal-cli'); mkdirSync(dataDir, { recursive: true });
// HAL 기동이 남기는 실행 버전(claimLive) — 서비스 대신 같은 DB 로 기록
{ const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'http://127.0.0.1:1', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1, emergencyTtlMinutes: 30, values: {}, set() {} }; const d = createHalDeps({ config }); core.claimLive(d, { instanceId: d.instanceId, version: '0.4.1' }); d.store.close(); }
let r = halCli(dataDir, ['server-create', '--name', 'hal.local.test', '--role', 'control', '--profile', 'hal', '--by', 'bootstrap']); eq(r.status, 0, r.stderr); const srv = JSON.parse(r.stdout.trim()); ok(srv.id.startsWith('srv_')); eq(srv.profile, 'hal'); eq(srv.role, 'control');
r = halCli(dataDir, ['server-create', '--name', 'x', '--profile', 'nope']); eq(r.status, 1); ok(r.stderr.includes('알 수 없는 프로필'));
r = halCli(dataDir, ['server-token', '--server', srv.id, '--by', 'bootstrap', '--json']); eq(r.status, 0, r.stderr); const tok = JSON.parse(r.stdout.trim()); ok(tok.token && tok.installCommand.includes('--hal http://127.0.0.1:1')); eq(tok.tls.trusted, true, 'http 주소는 신뢰(핀 없음)'); ok(!tok.installCommand.includes('--hal-pin'));
r = halCli(dataDir, ['server-approve', '--server', srv.id, '--by', 'bootstrap']); eq(r.status, 1); ok(r.stderr.includes('agent_missing'), 'Agent 연결 전 승인 거부: ' + r.stderr);
// 등록(앱 없이 core 로 같은 DB) → 승인 CLI → 부트스트랩 완료
{ const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'http://127.0.0.1:1', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1, emergencyTtlMinutes: 30, values: {}, set() {} }; const d = createHalDeps({ config }); const reg = core.registerAgent(d, { token: tok.token, facts: { hostname: 'hal.local.test' }, version: '0.4.1' }); ok(reg.agentId); d.store.close(); }
r = halCli(dataDir, ['server-approve', '--server', srv.id, '--by', 'bootstrap']); eq(r.status, 0, r.stderr); const ap = JSON.parse(r.stdout.trim()); eq(ap.status, 'ready'); eq(ap.bootstrapComplete, true); eq(ap.pendingModules, ['portal', 'webmaker', 'arimail', 'backup']);
r = halCli(dataDir, ['tls-probe', '--url', 'http://127.0.0.1:1']); eq(r.status, 0, r.stderr); eq(JSON.parse(r.stdout).trusted, true);
// --replace 가 없으면 기존 Agent 가 있는 서버의 토큰 발급 출력에 주의가 붙는다
r = halCli(dataDir, ['server-token', '--server', srv.id]); eq(r.status, 0); ok(r.stdout.includes('이미 Agent') && r.stdout.includes('--replace'), r.stdout);
});
await test('[요구 1·2] 스크립트·문서·버전: agent-install.sh(핀·CA·이미 등록), hal-host-agent.sh, bootstrap-hal.sh 연결, 0.4.1 문서', async () => {
const inst = readFileSync(join(ROOT, 'deploy', 'agent-install.sh'), 'utf8');
for (const s of ['--hal-pin', '--ca', '--insecure', '--pinnedpubkey', '/agent/v1/hal-ca.pem', '--cacert', 'NODE_EXTRA_CA_CERTS', '이미 등록된 서버', '인증서 문제', 'Environment=NODE_EXTRA_CA_CERTS']) ok(inst.includes(s), `agent-install.sh: ${s}`);
const host = readFileSync(join(ROOT, 'deploy', 'hal-host-agent.sh'), 'utf8'); for (const s of ['server-create', '--profile hal', 'server-token', '--json', 'agent-install.sh', '--role control', 'server-approve', 'http://127.0.0.1:8900']) ok(host.includes(s), `hal-host-agent.sh: ${s}`);
const boot = readFileSync(join(ROOT, 'deploy', 'bootstrap-hal.sh'), 'utf8'); ok(boot.includes('hal-host-agent.sh') && boot.includes('--no-agent') && boot.includes('tls-probe') && boot.includes('6/7') && boot.includes('7/7'));
for (const f of ['agent-install.sh', 'hal-host-agent.sh', 'bootstrap-hal.sh']) { const r = spawnSync('bash', ['-n', join(ROOT, 'deploy', f)], { encoding: 'utf8' }); eq(r.status, 0, `${f} bash -n: ${r.stderr}`); }
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(makeHal().d.version, '0.4.1');
ok(existsSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.1.md')) && readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.1.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.4.1'));
ok(readFileSync(join(ROOT, 'README.md'), 'utf8').includes('0.4.1')); ok(readFileSync(join(ROOT, 'docs', 'HAL-AGENT.md'), 'utf8').includes('hal-ca.pem'));
// 도입 모듈은 바뀌지 않았다(HAL 0.4.0 대응 그대로 — 0.4.1 호환)
const wm = JSON.parse(readFileSync(join(ROOT, 'modules', 'webmaker', 'package.json'), 'utf8')); eq(wm.version, '1.9.0'); eq(wm.ariwebSuite.halCompat, '0.4.0');
const am = JSON.parse(readFileSync(join(ROOT, 'modules', 'arimail', 'package.json'), 'utf8')); eq(am.version, '1.1.0'); eq(am.ariwebSuite.halCompat, '0.4.0');
ok(!readFileSync(join(ROOT, 'agent', 'index.mjs'), 'utf8').includes('out.observability'), 'Agent 는 hawkeye 로 보고');
});
done();