From 648247f2241579e947d61ab2928176f678f4b141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hera=20=28=ED=97=A4=EB=9D=BC=29?= Date: Tue, 27 Oct 2026 14:31:59 +0000 Subject: [PATCH] =?UTF-8?q?=EC=9A=B4=EC=98=81=EC=9E=90=20=EC=BD=98?= =?UTF-8?q?=EC=86=94=20=EB=B6=80=ED=8A=B8=EC=8A=A4=ED=8A=B8=EB=9E=A9=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=EA=B2=B0=ED=95=A8=20=EC=88=98=EC=A0=95:?= =?UTF-8?q?=20hal/ui.mjs=20=EA=B0=80=20core.selfHealth().bootstrap=20?= =?UTF-8?q?=EC=9D=98=20camelCase=20=EA=B3=84=EC=95=BD(complete=C2=B7halRun?= =?UTF-8?q?ningVersion=C2=B7firstAgent)=20=EB=8C=80=EC=8B=A0=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20snake=5Fcase=20=EC=97=B4=EC=9D=84=20=EC=9D=BD?= =?UTF-8?q?=EC=96=B4=20=EC=99=84=EB=A3=8C=20=EB=92=A4=EC=97=90=EB=8F=84=20?= =?UTF-8?q?"=EB=B6=80=ED=8A=B8=EC=8A=A4=ED=8A=B8=EB=9E=A9=20=EB=AF=B8?= =?UTF-8?q?=EC=99=84=EB=A3=8C(=ED=99=95=EC=9D=B8=20=EC=A0=84=C2=B7?= =?UTF-8?q?=EB=AF=B8=EB=93=B1=EB=A1=9D)"=EB=A5=BC=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=E2=80=94=20=EB=B0=B0=EB=84=88?= =?UTF-8?q?=C2=B7/ops/releases(running=3DbootstrapState=20=ED=96=89)=202?= =?UTF-8?q?=EA=B3=B3=20=EC=88=98=EC=A0=95=20+=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=201=EA=B1=B4(RED=E2=86=92GREEN),=20?= =?UTF-8?q?VERSION=5FHISTORY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- VERSION_HISTORY.md | 6 ++++++ hal/ui.mjs | 5 +++-- test/hal-ui.test.mjs | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/VERSION_HISTORY.md b/VERSION_HISTORY.md index db7fd6c..2bc5937 100644 --- a/VERSION_HISTORY.md +++ b/VERSION_HISTORY.md @@ -136,6 +136,12 @@ - HAL CLI(시스템 신원, 감사 actor `cli:`·reason) 보완: `config-set`(운영 값만 data/config.json 에 기록, 비밀 키 거부, 서비스 재시작 뒤 반영) · `module-deploy`(운영자 API `POST /ops/api/modules/:name/deploy` 와 같은 멱등 키 규칙으로 `module.deploy` 작업을 큐에 넣고, 실행은 serve 프로세스의 작업 엔진이 맡는다) · `job --id`(작업·자식 readback). 운영자 콘솔 자격 증명이 없는 연동 단계용. 테스트 `test/hal-cli.test.mjs` 3건(별도 프로세스 실행·멱등·감사·서비스 측 엔진이 집어 실행). - 결과(docs/verification/portal-hal21-link-2026-10-27.md): bear HAL21 을 main `dfb708e` 릴리스(artifact 4e419a72…)로 교체·등록·승인, `portalUrl=https://my.ariweb.net`; `link-portal` 값을 파이프로만 포털 vault `service:hal` 에 넣고(구 `service:portal` 제거) 포털 `halUrl=https://hal21.ariweb.net`; HAL21 작업 `j_01a17cb5716c669450412967f7` 로 portal 0.1.1 재배포(이전 릴리스 `.prev-20261027093350` 보존, health ok). 포털→HAL21 인증 읽기(health·servers·jobs 계약) 실측, 포털 DB 집계·Webmaker 인벤토리·AriMail 전후 동일, 외부 URL/TLS 전부 정상. 구 HAL 은 server offline 상태로 잔존(중지는 사람 결정). blocker: hostinger 서버 행 role `mail` → `pickServer(web)` null(운영자가 control 로 변경 필요). +### 운영자 콘솔 부트스트랩 배너 결함 수정 — 완료 뒤에도 "부트스트랩 미완료" 경고(2026-10-27, bear `hal21.ariweb.net`, Hera) +- **증상**: bear HAL21 `/api/health` 는 `bootstrap.complete=true · halRunningVersion=0.1.1 · firstAgent 있음 · manualInstallLocked=true` 인데, 운영자 콘솔 모든 화면 상단에 `! 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)…` 경고가 계속 보이고, 배포 릴리스 화면도 `HAL 실행 버전: 확인 전 · 부트스트랩 미완료` 로 그려졌다. +- **원인**: `hal/ui.mjs` 의 공통 틀(shell) 배너가 `core.selfHealth(d).bootstrap` 의 실제 계약(camelCase `complete`·`halRunningVersion`·`firstAgent`)이 아니라 존재하지 않는 snake_case 열 이름(`completed_at`·`hal_running_version`·`first_agent_id`)을 읽어 항상 `undefined` → 늘 "미완료·확인 전·미등록"으로 렌더링. 같은 파일의 `/ops/releases` 라우트는 `releasesView` 가 bootstrap 행(snake_case `hal_running_version`·`manual_install_locked`)을 기대하는데 camelCase `selfHealth().bootstrap` 을 넘겨 같은 증상. 서버 렌더(HTML) 결함이며 `ops.js`/`ops.css` 는 관여하지 않는다(브라우저 캐시 무관). +- **수정(`hal/ui.mjs` 만, 2곳)**: 배너는 `health.bootstrap.complete` / `halRunningVersion` / `firstAgent` 를 쓰고, `/ops/releases` 는 `running: core.bootstrapState(d)` 를 넘긴다. 옛 snake_case fallback 은 두지 않았다(단일 계약). +- **검증(strict TDD)**: `test/hal-ui.test.mjs` 에 회귀 1건 추가 — selfHealth.bootstrap 에 snake_case 필드가 없음을 고정하고, (1) 초기 `확인 전·미등록` (2) `claimLive` 뒤 `0.1.1·미등록` (3) 첫 Agent 등록·승인(완료) 뒤 운영 현황·서버·릴리스 화면에 경고 없음 + 릴리스 화면 `HAL 실행 버전: 0.1.1 · 수동 설치 잠김` (4) Agent 만 승인된 새 HAL 은 `확인 전·등록됨`. RED: 수정 전 (2)에서 `확인 전` 으로 실패(완료 상태에서도 경고·릴리스 화면 `확인 전` 잔존을 별도 스크립트로 기록). GREEN: 파일 7건·전체 22파일 150건 통과·실패 0. 배포 결과는 `docs/verification/hal21-ops-banner-fix-2026-10-27.md`. + ### 도입 소스 - 가져오기: `node scripts/import-sources.mjs` 가 고정 커밋(Webmaker `3b10859884ef2fd0f2af11934ab6c16370fd64b8`, AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`)을 검사하고 실행에 필요 없는 문서·보고서·샘플을 제외한 전체를 `modules/webmaker`(176 파일)·`modules/arimail`(88 파일)로 복사, 파일별 원본 경로·대상 경로·수정 여부·원본 해시·라이선스 검토를 `IMPORTED_SOURCES.md`/`.json` 에 기록한다. - 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함. diff --git a/hal/ui.mjs b/hal/ui.mjs index 7dfecc2..a196bfe 100644 --- a/hal/ui.mjs +++ b/hal/ui.mjs @@ -46,7 +46,8 @@ export function registerUi(app, d) { if (grant) banner.push(`
ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)
`); if (!health.ok) banner.push(`
⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요
`); if (emOpen) banner.push(`⚠ 긴급 복구 세션 ${emOpen}건 진행 중`); - if (health.bootstrap && !health.bootstrap.completed_at) banner.push(`
! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.hal_running_version || '확인 전')}, 첫 Agent ${health.bootstrap.first_agent_id ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)
`); + // [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함. + if (health.bootstrap && !health.bootstrap.complete) banner.push(`
! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, 첫 Agent ${health.bootstrap.firstAgent ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)
`); if (ctx.operator.mustChangePassword) banner.push(`! 비밀번호를 바꿔야 합니다`); return ctx.html(`${esc(title)} · Ariweb HAL 운영자 콘솔
Ariweb HALv${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}
@@ -75,7 +76,7 @@ export function registerUi(app, d) { const actions = `
${ps.status === 'pending' ? ` ` : ''}${ps.rawStatus === 'maintenance' ? `` : ``}
`; return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}

지표(90일 이상 보존)

${R.statePanel('loading', '지표를 불러오는 중')}
` }); }); // ---------------- 릴리스(§32.1) - app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }, changelog()) })); + app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ releases: core.listReleases(d), running: core.bootstrapState(d), modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }, changelog()) })); // ---------------- 작업(§29) app.get('/ops/jobs', (ctx) => { const status = ctx.query.status ? ctx.query.status.split(',') : null; const jobs = d.jobs.list({ status, kind: ctx.query.kind || null, limit: 200 }).map((j) => ({ ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id })); const filters = ``; diff --git a/test/hal-ui.test.mjs b/test/hal-ui.test.mjs index 23351b3..8f42f5d 100644 --- a/test/hal-ui.test.mjs +++ b/test/hal-ui.test.mjs @@ -39,6 +39,29 @@ await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠 // 로그인 화면은 고객 로그인과 분리된 문구 r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다')); }); +await test('부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약(complete·halRunningVersion·firstAgent)만 본다 — 완료 뒤에는 경고가 사라지고, 미완료 때는 실행 버전·첫 Agent 상태가 정확하다', async () => { + const h = makeHal(); const op = await loginOp(h); + // 계약 고정: selfHealth 는 camelCase 만 내고 옛 snake_case 열 이름은 없다(배너가 어떤 필드를 읽는지 못 박는다) + const b0 = core.selfHealth(h.d).bootstrap; eq(b0.complete, false); ok(!('completed_at' in b0) && !('hal_running_version' in b0) && !('first_agent_id' in b0), 'selfHealth.bootstrap 에 snake_case 필드가 없다'); + // 1) 아무것도 안 된 상태: 실행 버전 확인 전 · 첫 Agent 미등록 + let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)'), '초기 상태 문구'); + // 2) HAL 기동(실행 버전 기록)만 된 상태: 실행 버전은 보이고 첫 Agent 는 아직 미등록 + core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.1.1' }); + r = await op.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 0.1.1, 첫 Agent 미등록)'), `실행 버전 0.1.1 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`); + // 3) 첫 Agent 등록·승인 → 부트스트랩 완료(core.approveServer → maybeCompleteBootstrap). 경고는 사라져야 한다 + const s = core.createServer(h.d, { name: '웹1', role: 'web' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id }); + const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1' }, version: '0.1.1' } }); ok(reg.json.ok, JSON.stringify(reg.json)); + core.approveServer(h.d, s.id, 'ozops'); + const b1 = core.selfHealth(h.d).bootstrap; eq(b1.complete, true); eq(b1.halRunningVersion, '0.1.1'); eq(b1.firstAgent, reg.json.agentId); eq(b1.manualInstallLocked, true); + r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(!r.text.includes('부트스트랩 미완료'), `완료 뒤 운영 현황에 경고 없음: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`); + r = await op.as('GET', '/ops/servers'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 다른 화면(서버)에도 경고 없음'); + r = await op.as('GET', '/ops/releases'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 배포 릴리스 화면에도 경고 없음'); ok(r.text.includes('HAL 실행 버전: 0.1.1') && r.text.includes('수동 설치 잠김'), '릴리스 화면의 실행 버전·수동 설치 잠김은 실제 bootstrap 행 값'); + // 4) 첫 Agent 는 등록됐지만 HAL 실행 버전이 없는 새 HAL(재해복구 등): 실행 버전 확인 전 · 첫 Agent 등록됨 + const h2 = makeHal(); const op2 = await loginOp(h2); const s2 = core.createServer(h2.d, { name: '웹2', role: 'web' }); const t2 = core.issueRegistrationToken(h2.d, { serverId: s2.id }); + const reg2 = await invoke(h2.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'web2' }, version: '0.1.1' } }); ok(reg2.json.ok); core.approveServer(h2.d, s2.id, 'ozops'); + eq(core.selfHealth(h2.d).bootstrap.complete, false); eq(core.selfHealth(h2.d).bootstrap.firstAgent, reg2.json.agentId); + r = await op2.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 등록됨)'), `첫 Agent 등록됨 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`); +}); await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => { const h = makeHal(); const op = await loginOp(h); const s = core.createServer(h.d, { name: '웹1', role: 'web', location: '서울' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });