Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3714e37c | ||
|
|
12994279a5 | ||
|
|
648247f224 |
File diff suppressed because it is too large
Load Diff
@@ -136,6 +136,13 @@
|
|||||||
- HAL CLI(시스템 신원, 감사 actor `cli:<by>`·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건(별도 프로세스 실행·멱등·감사·서비스 측 엔진이 집어 실행).
|
- HAL CLI(시스템 신원, 감사 actor `cli:<by>`·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 로 변경 필요).
|
- 결과(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`.
|
||||||
|
- **배포 결과(bear hal21, 2026-10-27 14:36Z)**: 커밋 `648247f` 릴리스(hal artifact `ed957a0a…`)를 `/srv/ariweb/hal/releases/0.1.1-648247f22415` 에 stage → 추출된 `hal/ui.mjs` sha·`node --check`·snake_case 참조 0 확인 → register-release(approved, by hera-ops-banner-fix)·bootstrap-release → current 전환·재시작(health 까지 1.08초, NRestarts 0). data/config(opsAllowCidrs 5항목)/vault/DB sha·크기 전후 동일, 사설 백업 `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z`. readback: `/api/health` bootstrap complete·mainCommit 648247f, Agent 2대 heartbeat 재개, 배포된 코드만 import 한 in-memory 서버 렌더에서 완료 상태 배너 없음·릴리스 화면 `HAL 실행 버전: 0.1.1 · 수동 설치 잠김`; 포털·Webmaker·메일 회귀 이상 없음. 되돌리기: current 를 `0.1.1-dfb708e933e5` 로 되돌리고 재시작.
|
||||||
|
|
||||||
### 도입 소스
|
### 도입 소스
|
||||||
- 가져오기: `node scripts/import-sources.mjs` 가 고정 커밋(Webmaker `3b10859884ef2fd0f2af11934ab6c16370fd64b8`, AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`)을 검사하고 실행에 필요 없는 문서·보고서·샘플을 제외한 전체를 `modules/webmaker`(176 파일)·`modules/arimail`(88 파일)로 복사, 파일별 원본 경로·대상 경로·수정 여부·원본 해시·라이선스 검토를 `IMPORTED_SOURCES.md`/`.json` 에 기록한다.
|
- 가져오기: `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) 를 설치 시점에 내려받는 래퍼만 포함.
|
- 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 운영자 콘솔 "부트스트랩 미완료" 배너 결함 수정·배포 검증 기록 — 2026-10-27 (bear hal21.ariweb.net, Hera)
|
||||||
|
|
||||||
|
비밀값(토큰·비밀번호·Vault 값·운영자 자격)은 이 문서에 없다. 시각은 UTC.
|
||||||
|
|
||||||
|
## 1. 증상과 원인
|
||||||
|
- 오즈 보고: HAL21 운영 콘솔 모든 화면 상단에 `! 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)…` 경고가 계속 보임.
|
||||||
|
- 실제 readback(수정 전, 14:2xZ `curl http://127.0.0.1:8900/api/health`): `bootstrap.complete=true · halRunningVersion=0.1.1 · firstAgent=ag_01a17bd7201c9a73c157e29968 · manualInstallLocked=true`, Agent 2대 active.
|
||||||
|
- 원인(코드): `hal/ui.mjs` shell 배너가 `core.selfHealth(d).bootstrap` 의 실제 계약(camelCase `complete`·`halRunningVersion`·`firstAgent`, `hal/core.mjs` selfHealth)이 아니라 존재하지 않는 snake_case `completed_at`·`hal_running_version`·`first_agent_id` 를 읽어 항상 `undefined` → "미완료·확인 전·미등록". 같은 파일 `/ops/releases` 라우트는 `releasesView`(`hal/ui/render.mjs`, bootstrap 행의 `hal_running_version`·`manual_install_locked` 기대)에 camelCase 객체를 넘겨 `HAL 실행 버전: 확인 전 · 부트스트랩 미완료` 로 그려짐.
|
||||||
|
- **서버 렌더(HTML) 결함**이다. `hal/ui/public/ops.js`·`ops.css` 는 bootstrap 필드를 읽지 않으며(grep 0건) 변경하지 않았다 → 브라우저 캐시와 무관.
|
||||||
|
|
||||||
|
## 2. RED (수정 전 실패 증명)
|
||||||
|
- 회귀 테스트 `test/hal-ui.test.mjs` "부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약…": (0) `selfHealth.bootstrap` 에 snake_case 필드가 없음을 고정 (1) 초기 `확인 전·미등록` (2) `core.claimLive` 뒤 `HAL 실행 버전 0.1.1, 첫 Agent 미등록` (3) 첫 Agent 등록(`POST /agent/v1/register`)·`core.approveServer` → 완료 뒤 `/ops/`·`/ops/servers`·`/ops/releases` 에 경고 없음 + 릴리스 화면 `HAL 실행 버전: <b>0.1.1</b> · 수동 설치 잠김` (4) Agent 만 승인된 새 HAL 은 `확인 전·등록됨`.
|
||||||
|
- 수정 전 실행 결과(`node --no-warnings test/hal-ui.test.mjs`): **통과 6 · 실패 1** — `Error: 실행 버전 0.1.1 표시: 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록) (실제 false)` (test/hal-ui.test.mjs:50).
|
||||||
|
- 완료 상태 증상도 수정 전 코드로 기록(scratchpad `g7/red-evidence.mjs`): `selfHealth.bootstrap.complete=true` 인데 `/ops/` 배너 `부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)`, `/ops/releases` `HAL 실행 버전: <b>확인 전</b>`.
|
||||||
|
|
||||||
|
## 3. GREEN (수정, `hal/ui.mjs` 만 2곳)
|
||||||
|
- 배너: `!health.bootstrap.complete` / `health.bootstrap.halRunningVersion` / `health.bootstrap.firstAgent`.
|
||||||
|
- `/ops/releases`: `running: core.bootstrapState(d)`(렌더러가 기대하는 bootstrap 행). snake_case fallback 없음, 다른 기능 변경 없음.
|
||||||
|
- 수정 뒤 `test/hal-ui.test.mjs` 통과 7 · 실패 0.
|
||||||
|
|
||||||
|
## 4. 리비전·테스트·빌드
|
||||||
|
| 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 작업 시작 origin/main = HEAD(fetch 뒤 동일, worktree clean — e2e 테스트가 재생성한 `docs/verification/local-e2e-2026-10-27.*` 는 HEAD 로 복원) | `d8e7beeb861ae5edb45fd56d6950fd8a50ba204e` |
|
||||||
|
| 수정 커밋(hal/ui.mjs + test/hal-ui.test.mjs + VERSION_HISTORY, author `Hera (헤라) <hera@mp.local>`) | `648247f2241579e947d61ab2928176f678f4b141` |
|
||||||
|
| 전체 테스트 `node test/run.mjs`(수정 뒤, 2m37s) | 22파일 **150건 통과 · 실패 0**(기존 149 + 신규 1) |
|
||||||
|
| release build(`scripts/release.mjs build`, 커밋 648247f, 테스트 재실행 150건) | 0.1.1, testsPassed=true, dirty=false; verify 5 artifact ✔ |
|
||||||
|
| hal artifact sha256 | `ed957a0aeb8f065085e3163cf3f367a240e90b55f6221844c93fa4404de8512f` (portal `32c76a60…`, agent `cf2e52fe…`, webmaker `9490b59d…`, arimail `b22868c0…` 는 이전 빌드와 동일) |
|
||||||
|
| `bash -n deploy/*.sh` | 4개 통과(agent-install·bootstrap-hal·hal-restore·module-as-user); shellcheck 미설치 |
|
||||||
|
| 소스 `hal/ui.mjs` sha256 | `904059364106888cab17609e3e9f743d96ea239bae87c0af856980a673eed92d` |
|
||||||
|
|
||||||
|
## 5. bear 배포 (코드 릴리스만, data/config/DB/Vault 무변경)
|
||||||
|
- 사전 상태: current `/srv/ariweb/hal/releases/0.1.1-dfb708e933e5`, PID 1792203(14:21:13Z 다른 세션의 opsAllowCidrs 변경·재시작, config.json `updatedBy cli:hera-ops-access` 14:21:12Z, `config.json.pre-ops-cidrs-20261027T142020Z` 존재).
|
||||||
|
- 사설 백업 `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z/`(root 0700, 파일 0600, `sha256sum -c` OK, stage 추출 검증 halVault 4·integrity ok·servers 2·configKeys [publicUrl, portalUrl, opsAllowCidrs], 경로 검증 6/6): archive.tgz `5c0e91f8530403061adf108ec1dbcab684c58854ccd7b60eed20e08aa81490c3`(7: config.json·pre-ops-cidrs 사본·vault.json·vault.key·unit·nginx conf), db.tgz `5182f1a65bb99d8dc084590e3993408fc7dd2129998be14bd536ec03e21c7bd0`(hal.sqlite VACUUM 835,584 B, integrity ok, 28표: servers 2·agents 2·releases 1·jobs 4·agent_jobs 2·mail_services 7), code.tgz `0ec00cb9c4698e4e228d31ffa5432ab0a55e6847da1ff8db721700b1e165b73a`(81, 0.1.1-dfb708e933e5), meta.tgz `75d7ff23f1ebeeccb7ad8da8518c5bbe440c39bd4088197a2d3e236a7d466166`, RESTORE.md `78769ba25e4033a310d9b5c348863946603130fc430ed8356713758e3eaaa1f7`, hal-verify.json `2435b0eb87252cb42a589d98a83b55442f3720c898e51b08c3e81ce1e8d4148d`.
|
||||||
|
- 적용(`~/hera-deploy/bear-apply-g7.sh`, 14:36:34Z): artifact sha 확인 OK → `/srv/ariweb/hal/releases/0.1.1-648247f22415` 추출 → **stage 검증**: 추출된 `hal/ui.mjs` sha256 = 소스(`90405936…`) OK, `node --check` OK, `health.bootstrap.complete` 참조 1, snake_case 참조 0 → `REVISION.json`(by hera-ops-banner-fix) → `register-release --manifest … --approve --by hera-ops-banner-fix`(릴리스 0.1.1 approved, 감사 release.register·release.approve 14:36:34.403Z) → `bootstrap-release --version 0.1.1 --commit 648247f… --sha256 ed957a0a…` → `ln -sfn` current 전환 → `systemctl restart ariweb-hal`. config-set 없음.
|
||||||
|
- 결과: restart→health **1.08초**, PID 1816086, ActiveState active, NRestarts 0, ActiveEnter 14:36:35Z, 프로세스 cwd `/proc/<pid>/cwd` = `/srv/ariweb/hal/releases/0.1.1-648247f22415`, journal 오류 0, 기동 로그 "부트스트랩 완료".
|
||||||
|
- 보존 확인(sha256 전=후): config.json `ae5963d29ae899bc77fcb7c03df7d5802bd493b61fc4b2ab2b7a263a525ba0b7`, vault.json `0e2dc9875f2779a0e7a3c1a56c00321dd2a45b0613a3952b62a8c7505be50bb7`, vault.key `345d92e77c3fcd7853b01024d4aaa3f07d27977ea04a30a287070048afe1bf81`; config readback `opsAllowCidrs=["127.0.0.1/32","::1/128","192.168.10.0/24","192.168.20.0/24","119.203.225.42/32"]`, publicUrl `https://hal21.ariweb.net`, portalUrl `https://my.ariweb.net`, updatedAt 14:21:12.623Z 그대로. data 디렉터리 0700, hal.sqlite 860,160 B·wal 4,132,392 B·shm 32,768 B 그대로. 이전 릴리스 디렉터리 전부 보존(0.1.1, -12958ab6952e, -79b45ca96102, -ac072e6f12a9, -dfb708e933e5).
|
||||||
|
|
||||||
|
## 6. readback
|
||||||
|
- `/api/health`(로컬·`https://hal21.ariweb.net`): ok, version 0.1.1, db true, jobs true, bootstrap `complete=true · releaseVersion 0.1.1 · mainCommit 648247f2241579e947d61ab2928176f678f4b141 · artifactSha256 ed957a0a… · halRunningVersion 0.1.1 · firstAgent ag_01a17bd7201c9a73c157e29968 · manualInstallLocked=true`, instanceId `hal_01a17dcaa5d2cb7866a47b3fa3`.
|
||||||
|
- HAL DB(읽기 전용): bootstrap_state `release_version 0.1.1 · main_commit 648247f… · hal_running_version 0.1.1 · hal_started_at 14:36:35.156Z · first_agent_id ag_01a17bd7… · completed_at 07:21:53.139Z · manual_install_locked 1`; releases 1행 0.1.1 approved by hera-ops-banner-fix(hal_deployed_at null — module.deploy hal 작업이 아닌 CLI bootstrap-release 경로, 이전과 동일); 열린 작업 0; mail_services 7.
|
||||||
|
- Agent heartbeat: 서버 2대 ready, last_seen 14:36:59.073Z(hostinger ag_01a17c99…)·14:36:59.619Z(jitsi ag_01a17bd7…) — 재시작(14:36:35Z) 뒤 재개, 14:38:10Z 까지 heartbeat 6회(bear nginx log). 재시작 순간 Agent long-poll `GET /agent/v1/jobs/next?wait=25` 502 1회(14:36:35Z, 재시작 창) 외 Agent 요청 비-2xx 없음.
|
||||||
|
- **배포된 코드 서버 렌더 검증**(운영자 자격 없음 → `~/hera-deploy/deployed-render.mjs` 가 `/srv/ariweb/hal/current` 의 app/deps/core 만 import, in-memory DB·임시 data dir, 운영 데이터·Vault 미접근): 초기 `부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)` → claimLive 뒤 `(HAL 실행 버전 0.1.1, 첫 Agent 미등록)` → 첫 Agent 등록·승인(complete=true) 뒤 `/ops/`·`/ops/servers`·`/ops/releases` 모두 **배너 없음**, 릴리스 화면 `HAL 실행 버전: <b>0.1.1</b> · 수동 설치 잠김(HAL 전용 운영)`.
|
||||||
|
- 외부(14:37Z): `https://hal21.ariweb.net/api/health` 200 · `/ops/` 200(세션 없음 → 로그인 화면, 본문에 "부트스트랩 미완료" 0건) · `/ops/login` 200 · `/portal/v1/health` 무인증 401; TLS CN=hal21.ariweb.net 만료 2027-01-25, verify 0.
|
||||||
|
- 회귀: `https://my.ariweb.net/api/health` 200 ok 0.1.1(로컬 8910 동일), `/` 302→/login, `/login` 200, http→https 301; hostinger 유닛 ariweb-portal 1500968·ariweb-agent 1483523·webmaker 1041361·arihakdang-mail 1046648·nginx 1414934·(구) ariweb-hal 1059540 모두 active NRestarts 0, `nginx -t` 성공, Agent/포털 journal 오류 0(14:36Z 이후); `https://webmaker.ariweb.net/` 302(첫 프로브 4.5~5.0초, 이후 0.29초 — hostinger 측 콜드 응답, HAL 무관); `https://mail.arihakdang.com/` 200·`/api/health` ok 1.0.11 domains 6; `https://webmail.ibit.kr/` 200·ok 1.0.11 domains 1.
|
||||||
|
|
||||||
|
## 7. 되돌리기
|
||||||
|
- 코드만: `ln -sfn /srv/ariweb/hal/releases/0.1.1-dfb708e933e5 /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal` (이전 릴리스 디렉터리 보존됨). 릴리스 행·bootstrap 행의 main_commit/artifact 는 `cd /srv/ariweb/hal/releases/0.1.1-dfb708e933e5 && ARIWEB_HAL_DATA=/srv/ariweb/hal/data node hal/index.mjs register-release --manifest ~/hera-deploy/dist-dfb708e/manifest.json --approve --by <who> && node hal/index.mjs bootstrap-release --version 0.1.1 --commit dfb708e933e524894822719bce0169008285f4fd --sha256 4e419a7293dc5be07936db58c8cf70dc1c7972f52c11787a9455029eff677377`.
|
||||||
|
- 데이터까지: `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z/RESTORE.md`(검증 `sudo sha256sum -c SHA256SUMS`).
|
||||||
|
|
||||||
|
## 8. blocker / unknown
|
||||||
|
- 운영자 자격 증명이 없어 실제 운영자 세션으로 콘솔 화면을 열어 보지는 못했다 — 배포된 코드의 서버 렌더 harness(§6)와 회귀 테스트, 배포 파일 sha 일치로 판단. 오즈가 로그인하면 경고가 보이지 않아야 한다(서버 렌더이므로 새로고침만으로 충분, 캐시 삭제 불필요).
|
||||||
|
- `releases.hal_deployed_at` 은 null(HAL 자체는 module.deploy 작업이 아니라 CLI bootstrap-release + current 전환으로 교체) — 이전 배포와 같은 방식.
|
||||||
+3
-2
@@ -46,7 +46,8 @@ export function registerUi(app, d) {
|
|||||||
if (grant) banner.push(`<div class="alert info">ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)</div>`);
|
if (grant) banner.push(`<div class="alert info">ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)</div>`);
|
||||||
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
|
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
|
||||||
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
|
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
|
||||||
if (health.bootstrap && !health.bootstrap.completed_at) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.hal_running_version || '확인 전')}, 첫 Agent ${health.bootstrap.first_agent_id ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
|
// [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함.
|
||||||
|
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, 첫 Agent ${health.bootstrap.firstAgent ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
|
||||||
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
|
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
|
||||||
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
|
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
|
||||||
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="logout">로그아웃</button></nav></header>
|
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="logout">로그아웃</button></nav></header>
|
||||||
@@ -75,7 +76,7 @@ export function registerUi(app, d) {
|
|||||||
const actions = `<div class="actions">${ps.status === 'pending' ? `<button class="btn primary" data-act="server-token" data-id="${esc(s.id)}">등록 정보 발급</button> <button class="btn" data-act="server-approve" data-id="${esc(s.id)}">연결 확인 후 승인</button>` : ''}${ps.rawStatus === 'maintenance' ? `<button class="btn" data-act="server-maint-end" data-id="${esc(s.id)}">유지보수 종료·신규 배치 재개</button>` : `<button class="btn" data-act="server-maint-start" data-id="${esc(s.id)}">유지보수 시작(신규 배치 중지)</button>`} <button class="btn" data-act="server-accepts" data-id="${esc(s.id)}" data-v="${ps.acceptsNew ? 0 : 1}">${ps.acceptsNew ? '신규 배치 중지' : '신규 배치 허용'}</button> <button class="btn danger" data-act="server-isolate" data-id="${esc(s.id)}">격리</button> <button class="btn danger" data-act="server-delete" data-id="${esc(s.id)}" data-hosted="${ps.capacity.sitesHosted || 0}">서버 삭제</button></div>`;
|
const actions = `<div class="actions">${ps.status === 'pending' ? `<button class="btn primary" data-act="server-token" data-id="${esc(s.id)}">등록 정보 발급</button> <button class="btn" data-act="server-approve" data-id="${esc(s.id)}">연결 확인 후 승인</button>` : ''}${ps.rawStatus === 'maintenance' ? `<button class="btn" data-act="server-maint-end" data-id="${esc(s.id)}">유지보수 종료·신규 배치 재개</button>` : `<button class="btn" data-act="server-maint-start" data-id="${esc(s.id)}">유지보수 시작(신규 배치 중지)</button>`} <button class="btn" data-act="server-accepts" data-id="${esc(s.id)}" data-v="${ps.acceptsNew ? 0 : 1}">${ps.acceptsNew ? '신규 배치 중지' : '신규 배치 허용'}</button> <button class="btn danger" data-act="server-isolate" data-id="${esc(s.id)}">격리</button> <button class="btn danger" data-act="server-delete" data-id="${esc(s.id)}" data-hosted="${ps.capacity.sitesHosted || 0}">서버 삭제</button></div>`;
|
||||||
return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}<h2>지표(90일 이상 보존)</h2><div class="row"><label>지표 <select id="metric-key"><option value="cpu">CPU</option><option value="mem">메모리</option><option value="disk">디스크</option><option value="inode">inode</option><option value="net">네트워크 송신</option></select></label> <label>기간 <select id="metric-days"><option value="1">24시간</option><option value="7">7일</option><option value="30" selected>30일</option><option value="90">90일</option></select></label></div><div id="metrics">${R.statePanel('loading', '지표를 불러오는 중')}</div>` }); });
|
return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}<h2>지표(90일 이상 보존)</h2><div class="row"><label>지표 <select id="metric-key"><option value="cpu">CPU</option><option value="mem">메모리</option><option value="disk">디스크</option><option value="inode">inode</option><option value="net">네트워크 송신</option></select></label> <label>기간 <select id="metric-days"><option value="1">24시간</option><option value="7">7일</option><option value="30" selected>30일</option><option value="90">90일</option></select></label></div><div id="metrics">${R.statePanel('loading', '지표를 불러오는 중')}</div>` }); });
|
||||||
// ---------------- 릴리스(§32.1)
|
// ---------------- 릴리스(§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)
|
// ---------------- 작업(§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 }));
|
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 = `<div class="row"><a class="btn${!ctx.query.status ? ' on' : ''}" href="/ops/jobs">전체</a> <a class="btn${ctx.query.status === 'running,queued,awaiting_external' ? ' on' : ''}" href="/ops/jobs?status=running,queued,awaiting_external">진행·대기</a> <a class="btn${ctx.query.status === 'needs_user,failed' ? ' on' : ''}" href="/ops/jobs?status=needs_user,failed">확인 필요·실패</a> <a class="btn${ctx.query.kind === 'module.deploy' ? ' on' : ''}" href="/ops/jobs?kind=module.deploy">모듈 배포</a></div>`;
|
const filters = `<div class="row"><a class="btn${!ctx.query.status ? ' on' : ''}" href="/ops/jobs">전체</a> <a class="btn${ctx.query.status === 'running,queued,awaiting_external' ? ' on' : ''}" href="/ops/jobs?status=running,queued,awaiting_external">진행·대기</a> <a class="btn${ctx.query.status === 'needs_user,failed' ? ' on' : ''}" href="/ops/jobs?status=needs_user,failed">확인 필요·실패</a> <a class="btn${ctx.query.kind === 'module.deploy' ? ' on' : ''}" href="/ops/jobs?kind=module.deploy">모듈 배포</a></div>`;
|
||||||
|
|||||||
@@ -39,6 +39,29 @@ await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠
|
|||||||
// 로그인 화면은 고객 로그인과 분리된 문구
|
// 로그인 화면은 고객 로그인과 분리된 문구
|
||||||
r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다'));
|
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 실행 버전: <b>0.1.1</b>') && 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 () => {
|
await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => {
|
||||||
const h = makeHal(); const op = await loginOp(h);
|
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 });
|
const s = core.createServer(h.d, { name: '웹1', role: 'web', location: '서울' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
|
||||||
|
|||||||
Reference in New Issue
Block a user