Compare commits

...
Author SHA1 Message Date
oz 4b3714e37c [verified] docs: add v0.1.4 HAL AI development order 2026-10-27 16:16:09 +00:00
HeraandClaude Fable 5.1 12994279a5 운영자 콘솔 부트스트랩 배너 결함 수정 bear hal21 배포 검증 기록: docs/verification/hal21-ops-banner-fix-2026-10-27.md(원인·RED/GREEN·빌드·백업 sha256·stage 검증·current 전환·config/vault 보존·readback·배포 코드 서버 렌더·외부 회귀·되돌리기), VERSION_HISTORY 결과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 14:40:06 +00:00
HeraandClaude Fable 5.1 648247f224 운영자 콘솔 부트스트랩 배너 결함 수정: hal/ui.mjs 가 core.selfHealth().bootstrap 의 camelCase 계약(complete·halRunningVersion·firstAgent) 대신 없는 snake_case 열을 읽어 완료 뒤에도 "부트스트랩 미완료(확인 전·미등록)"를 그리던 문제 — 배너·/ops/releases(running=bootstrapState 행) 2곳 수정 + 회귀 테스트 1건(RED→GREEN), VERSION_HISTORY
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 14:31:59 +00:00
zzabisagent-labandClaude Fable 5.1 d8e7beeb86 포털·hostinger Node Agent 제어 평면 이전(hal.ariweb.net → hal21.ariweb.net) 실제 적용 기록: docs/verification/portal-hal21-link-2026-10-27.md(리비전·preflight·백업 sha256·link-portal 파이프·HAL21 작업 배포·readback·전후 집계·blocker), RUNBOOK §10, VERSION_HISTORY
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 09:43:36 +00:00
zzabisagent-labandClaude Fable 5.1 dfb708e933 HAL CLI(시스템 신원) 보완: config-set(운영 값 기록·비밀 거부·감사) / module-deploy(운영자 API 와 같은 멱등 규칙의 module.deploy 작업 큐 투입, 실행은 서비스 프로세스) / job(작업·자식 readback) + 테스트 3건
운영자 콘솔 자격 증명이 없는 연동 단계(포털·Node Agent 제어 평면을 hal.ariweb.net → hal21.ariweb.net 으로 옮기는 작업)에서 HAL 데이터 소유자가 감사 기록(actor cli:<by>, reason)을 남기며 쓰는 명령. 직접 SQL 없이 기존 core/jobs 경로만 사용한다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 09:27:23 +00:00
oz 0b9c70e90c docs: verify Webmaker HAL21 control-plane link 2026-10-27 09:06:25 +00:00
10 changed files with 1739 additions and 2 deletions
File diff suppressed because it is too large Load Diff
+12
View File
@@ -124,6 +124,7 @@
### 운영 Webmaker ↔ HAL 연동 점검 도구(2026-10-27, webmaker.ariweb.net)
- **`scripts/webmaker-inventory.mjs`**(+ `test/webmaker-inventory.test.mjs` 5건): 운영 Webmaker 데이터(JSON)·포털 DB·HAL DB 를 읽기 전용으로 안정 식별자(프로젝트 32-hex id, 포털 `s_/g_/u_`, HAL `srv_`)로 대조해 연결 등록부 일치·충돌·중복·누락, SSO 회원(`aw-<g8>-<u8>`) 신원, 도메인, Gitea `sites/<id>/main` ref, HAL 모듈 등록·버전·건강을 판정한다. 비밀로 보이는 키는 출력 전에 거부(`assertNoSecrets`). 충돌·누락이 있으면 종료 코드 2.
- 2026-10-27 hostinger 실데이터 결과: 프로젝트 10(포털 연결 일치 3·Webmaker 고유 7), 회원 12(SSO 3 일치·고유 9), 도메인 2, Gitea refs 13, HAL `module_targets.webmaker` 0.1.1 health ok — 충돌 0·누락 0, **적용 변경 없음**(교체도 불필요). 사전 스냅샷·복원 검증·사후 readback 은 `docs/verification/webmaker-hal-link-2026-10-27.md`, 절차는 RUNBOOK §8.
- **HAL21 제어 평면 전환 완료**: bear HAL을 main `12958ab`로 갱신한 뒤 hostinger Node Agent를 `hal21.ariweb.net`의 기존 서버 `srv_01a17bd4dbcdb55877f2d9fcab`에 재등록·승인했다. Webmaker unit·데이터는 변경하거나 재시작하지 않았다. 명시적 `DATA_ROOT`·`DATA_DIR`·`SITES_ROOT`로 수집한 HAL 인벤토리는 프로젝트 10/10, 절단 없음, 오류 0이며 `module_targets.webmaker`는 0.1.1/health ok다. 사전 체크섬 32,608건 불일치 0, Git 7개 fsck 성공, Gitea ref 13개 동일. 결과와 롤백은 `docs/verification/webmaker-hal21-link-2026-10-27.md`.
### 기존 메일 서비스(AriMail 인스턴스) ↔ HAL 관리형 MailService/Node 가져오기(2026-10-27, mail.arihakdang.com · webmail.ibit.kr → bear `hal21.ariweb.net`)
- **`hal/mailsvc.mjs`**(+ 마이그레이션 `hal_0003_mail_services`, `test/hal-mailimport.test.mjs` 7건): HAL 관리형 메일 서비스 등록부(`mail_services`, 도메인 단위 논리 리소스, 불변 id `mls_…`, 출처 권한 `arimail:<hostname>:<unit>`, 도메인별 active owner 정확히 하나 — 부분 유일 인덱스)와 가져오기 단위(`mail_imports`). 읽기 전용 discovery 문서만 입력으로 받고 **비밀값이 어디에든 있으면 거부**(비밀은 `secretRefs` 의 file:/dir:/vault:/env: 참조만 저장). dry-run 이 기본, 충돌(collision: 다른 서버/인스턴스가 이미 active)·중복·누락이 하나라도 있으면 아무것도 적용하지 않음, 적용은 한 트랜잭션 + 감사(immutable), 같은 출처·서버의 재적용은 update(멱등), 적용 단위 되돌리기(생성 행 삭제·갱신 행 복원). 소유 조직은 증거로 확인되기 전까지 `unassigned_legacy_managed`. Agent heartbeat 의 arimail 모듈 상태가 그 서버의 행에 `last_sync`·`health` 로 비친다(`core.heartbeat`). Data Plane(AriMail 송수신·계정·메일·DNS/MX·TLS·provider)은 바꾸지 않는다 — HAL 이 멈춰도 메일은 그대로다.
@@ -131,6 +132,17 @@
- **HAL CLI**(`hal/index.mjs`): `mail-import --discovery <json> (--server <id> | --create-server --name …) [--apply]`, `mail-import-rollback --import <id>`, `mail-services`(readback JSON), `server-token --server <id>`(운영자 콘솔이 닿지 않는 부트스트랩·연동 단계의 시스템 신원 토큰 발급, 감사 `cli:<by>`). **운영자 API**: `GET /ops/api/mail-services`(+readback: 총수·active·도메인 중복·서버별·비밀 누출 검사), `GET /ops/api/mail-services/:id`(감사 포함), `GET /ops/api/mail-services/imports`, `POST /ops/api/mail-services/import {discovery, serverId, apply}`(적용은 재인증), `POST /ops/api/mail-services/imports/:id/rollback`(재인증). **운영자 화면** `/ops/mail`: HAL 관리형 메일 서비스 표(도메인·공개 주소·서버/인스턴스·리비전·계정/메일함/메일·건강/동기화·TLS/DNS·비밀 참조 수)를 먼저 그리고 포털 메일 서비스는 그 아래 `#portal-mail` 에 중계로 채운다(`ops.js`).
- 2026-10-27 실제 적용: bear HAL 을 커밋 79b45ca 릴리스로 교체(재시작 2.6초, 메일 서비스 프로브 무중단) → 서버 2(pending) · MailService 7(hostinger 인스턴스 6 도메인 + jitsi 1, 모두 `unassigned_legacy_managed`, duplicates 0, secretLeaks 0) · jitsi Node Agent 등록(heartbeat → `last_sync_source=agent`, 승인은 운영자 결정) · hostinger 는 기존 HAL(`hal.ariweb.net`) 제어 평면과의 이중 제어를 피해 Agent 미설치(관측 전용). 원본 메일 서비스 변경 0. 결과·백업 sha256·readback 은 `docs/verification/mail-hal-link-2026-10-27.md`, 절차·되돌리기는 `docs/RUNBOOK-0.1.1.md` §9.
### 포털·hostinger Node Agent 제어 평면 이전 — hal.ariweb.net → hal21.ariweb.net (2026-10-27, Hera)
- 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 로 변경 필요).
### 운영자 콘솔 부트스트랩 배너 결함 수정 — 완료 뒤에도 "부트스트랩 미완료" 경고(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` 에 기록한다.
- 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
+23
View File
@@ -121,3 +121,26 @@ node hal/index.mjs server-token --server <srv_id> --by hera # 1회 표시
```
되돌리기: `node hal/index.mjs mail-import-rollback --import <mimp_id>`(그 적용이 만든 행 삭제·갱신 행 복원), 서버 행은 운영자 콘솔 서버 삭제(3단계 확인). Agent 는 `systemctl disable --now ariweb-agent` + `/srv/ariweb/agent` 제거(메일 서비스 영향 없음). 원본 메일 데이터 복원은 각 백업의 RESTORE.md(운영자 승인 뒤에만 — 메일 중단 동반).
hostinger 주의: `arihakdang-mail` 은 hostinger HAL(`hal.ariweb.net`)의 `module_targets.arimail` 로도 관리된다. hal21 에 같은 호스트의 두 번째 Agent 를 설치하면 두 제어 평면이 같은 유닛에 배포할 수 있으므로(§34 "하나의 HAL") **설치하지 않고** 서버 행을 pending(Agent 미연결)으로 두었다 — 제어 평면 이전은 사람 결정.
## 10. 포털·Node Agent 제어 평면 이전 — hostinger HAL(hal.ariweb.net) → bear HAL21 (2026-10-27)
전제: 포털은 hostinger 에서 이미 active(신규 초기화·빈 DB 생성 금지), Agent 는 한 HAL 에만 연결(단일 제어 평면), 비밀값은 파이프로만 이동. 기록: `docs/verification/portal-hal21-link-2026-10-27.md`.
```bash
# 0) 읽기 전용 preflight: DNS(공개·권한 resolver)·TLS·login·/api/health, 유닛 PID/NRestarts/cwd/REVISION/리스너, Agent 의 halUrl(agent.json 의 비밀 아닌 값), 포털 config 값·vault 이름, DB integrity/집계, 구 HAL·HAL21 의 servers/agents/jobs/module_targets
# 1) 사설 백업(각 호스트, root 0700/0600, RESTORE.md 먼저): 포털 DB VACUUM 사본(+wal/shm 크기 기록)·vault·config·유닛·nginx·cert 메타·Agent data·구 HAL DB/config, HAL21 DB/config/release — SHA256SUMS·stage 추출 readback·복원 경로 존재 검증
# 2) HAL21(bear, HAL 데이터 소유자): main 릴리스 artifact 등록·승인, 포털 주소 설정(재시작 필요), current 전환
cd /srv/ariweb/hal/releases/0.1.1-<commit12> && export ARIWEB_HAL_DATA=/srv/ariweb/hal/data
node hal/index.mjs register-release --manifest <dist>/manifest.json --approve --by <who>
node hal/index.mjs bootstrap-release --version 0.1.1 --commit <sha> --sha256 <hal artifact sha>
node hal/index.mjs config-set --portalUrl https://my.ariweb.net --by <who> --reason '…' # 포털이 다른 호스트일 때
ln -sfn $PWD /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal && curl -s http://127.0.0.1:8900/api/health
# 3) 포털 연동 키: 값을 화면에 두지 않고 host-to-host 파이프로 포털 vault 에 넣는다(service:hal 하나 — 포털은 양방향에 같은 이름을 쓴다)
ssh bear 'cd /srv/ariweb/hal/current && ARIWEB_HAL_DATA=/srv/ariweb/hal/data node hal/index.mjs link-portal | sed -n "s/^ service:hal = //p"' \
| ssh hostinger 'node -e "let s=\"\";process.stdin.on(\"data\",c=>s+=c).on(\"end\",async()=>{const m=await import(\"/srv/ariweb/portal/current/lib/vault.mjs\");m.openVault(\"/srv/ariweb/portal/data\").set(\"service:hal\",s.trim(),{kind:\"service-key\"})})"'
# 4) 포털 주소(비밀 아님): portal/config.mjs 의 set 으로 config.json 기록(0600, 원자적). 포털은 기동 때 읽으므로 다음 단계의 재배포(재시작)로 반영된다
ARIWEB_PORTAL_DATA=/srv/ariweb/portal/data node -e "import('/srv/ariweb/portal/current/portal/config.mjs').then(m=>m.loadConfig().set({publicUrl:'https://my.ariweb.net',halUrl:'https://hal21.ariweb.net'},'cli:<who>'))"
# 5) HAL21 작업으로 portal 재배포(같은 0.1.1 이면 Agent 가 현재 릴리스를 <ver>.prev-<시각> 으로 보존하고 health 실패 시 자동 복귀). 실행은 ariweb-hal 서비스가 맡는다
node hal/index.mjs module-deploy --module portal --version 0.1.1 --servers <srv_id> --by <who> --reason '…' # → jobId
node hal/index.mjs job --id <j_id> # status done, children[].steps await.result.actualVersion/previousVersion/health
# 6) readback: module_targets.portal target=actual=0.1.1 health ok · 포털의 실제 client+config+vault 로 /portal/v1/health·servers·jobs 계약 · bear nginx access log 의 포털 요청 200 · 포털 DB 집계·Webmaker 인벤토리 전후 비교 · 외부 URL/TLS · 유닛/NRestarts/journal
```
주의: 포털 vault 교체와 포털 재시작 사이(수 초)에는 포털→HAL 호출이 인증 실패한다(화면은 '정보 확인 지연'). 서버 행의 role 이 `mail` 이면 `pickServer(web)` 이 null 이라 포털 사이트 배포 대상이 없다 — 운영자 콘솔에서 role 을 control 로 바꾼다(운영자 API 만 있음). 구 HAL 은 Agent 가 떠나면 server offline 으로 남는다; 중지·폐기는 사람 결정.
@@ -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 전환으로 교체) — 이전 배포와 같은 방식.
@@ -0,0 +1,57 @@
# 포털·hostinger Node Agent 제어 평면 이전 기록 — hal.ariweb.net → hal21.ariweb.net (2026-10-27, Hera)
대상: hostinger(`srv1953374`, 187.53.133.173)의 Ariweb Portal 0.1.1(`https://my.ariweb.net`)과 Node Agent 의 제어 평면을 hostinger 구 HAL(`hal.ariweb.net`)에서 bear HAL21(`https://hal21.ariweb.net`)로 옮기고 실제 readback 으로 증명한 기록. Webmaker·포털 데이터·고객 계정·사이트·세션·DB·Vault·DNS·TLS·메일은 변경하지 않았다. 비밀값은 어디에도 출력하지 않았다(파이프로만 이동).
## 1. 리비전
| 항목 | 값 |
|---|---|
| 작업 시작 origin/main(rebase 뒤) | `0b9c70e90c7278e895de54e75e3d07f706c703bf` (Neo 의 Webmaker 연동 커밋 `12958ab`·`0b9c70e` 포함 확인, worktree clean) |
| 코드 커밋 = 배포한 HAL / 등록 릴리스 | `dfb708e933e524894822719bce0169008285f4fd` — HAL CLI `config-set`·`module-deploy`·`job` + 테스트 3건 |
| 릴리스 manifest | version 0.1.1, mainCommit `dfb708e…`, testsPassed=true, dirty=false. artifact sha256: hal `4e419a7293dc5be07936db58c8cf70dc1c7972f52c11787a9455029eff677377` · portal `32c76a6047b2a7d74de0929c993984040cb1263044af2593abd399ed7daff04e`(05:28Z 등록본과 동일) · agent `cf2e52fe23496b126248b65d3bb7e94106db573559392bd20f365f1b970e315f` · webmaker `9490b59dc1aca3d953f907bb7e5b447901a4558a7275913ffb1cb03e2c944caa` · arimail `b22868c08d2551e7d10891cd4858ee0525ac6529e6ece965881d601c20bde983` |
| 포털 코드 | 운영 포털(2026-10-26 14:30:42Z 설치, 빌드 3951be5)과 HEAD 의 portal/lib/lang/catalog 경로 diff 없음 → 같은 0.1.1 재배포(제어 평면 이전 목적) |
| HAL21 이전 | `/srv/ariweb/hal/releases/0.1.1-12958ab6952e`(Neo 09:02Z 수동 설치, PID 1314165), bootstrap mainCommit `12958ab…`, releases 행 main_commit `79b45ca…`(등록부와 실행본 불일치) |
| HAL21 이후 | `/srv/ariweb/hal/releases/0.1.1-dfb708e933e5` → `current`, `ariweb-hal` 09:31:52Z 재시작(재시작→/api/health 1.11초, PID 1360348, NRestarts 0, instanceId `hal_01a17cb3add236d2c112f3df1a`), bootstrap mainCommit `dfb708e…`/artifact `4e419a72…`, releases 행 0.1.1 approved(by hera-portal-link 09:31:51.753Z), config `portalUrl` `http://127.0.0.1:8910` → `https://my.ariweb.net`(감사 config.update, actor cli:hera-portal-link) |
| hostinger 포털 | `/srv/ariweb/portal/releases/0.1.1`(REVISION installedAt 2026-10-27T09:33:50.021Z, by master) → `current`; 이전 릴리스 `0.1.1.prev-20261027093350` 보존(.previous.json), 유닛 `ariweb-portal` PID 1500968(09:33:50Z), 127.0.0.1:8910 |
| hostinger Agent | `/srv/ariweb/agent/releases/0.1.1-12958ab6952e`(Neo 09:03Z) 그대로, `ag_01a17c99f0102ce24409dc258a` → `https://hal21.ariweb.net`, PID 1483523, NRestarts 0 |
## 2. 읽기 전용 preflight(09:13~09:18Z)
- DNS `my.ariweb.net` A 187.53.133.173 — 1.1.1.1·8.8.8.8·권한 서버 ns1/ns2.hosting.co.kr(TCP) 일치. `hal21.ariweb.net` 121.152.149.45. `hal.ariweb.net` 공개 A 없음(hostinger `/etc/hosts` 127.0.0.1 매핑만). TLS my: CN=`*.ariweb.net` 만료 2027-01-13T07:57:11Z verify 0; hal21: CN=hal21.ariweb.net 만료 2027-01-25T02:03:16Z.
- hostinger 유닛(모두 active/enabled, NRestarts 0): ariweb-hal PID 1059540(10-26 14:33:18Z) · ariweb-portal PID 1059582(14:33:19Z, cwd `/srv/ariweb/portal/current`) · ariweb-agent PID 1483523(10-27 09:04:12Z) · webmaker PID 1041361(14:00:23Z) · arihakdang-mail PID 1046648(14:09:30Z). 리스너 127.0.0.1:8900/8910/8790/3080, nginx 80/443. `/etc/hosts`: `127.0.0.1 hal.ariweb.net my.ariweb.net webmail.ariweb.net mail.arihakdang.com`.
- Agent(hostinger) 연결 HAL: 이미 hal21(Neo 09:03:45.729Z 재등록, label `srv1953374 (mail+webmaker)`, drop-in `90-hal21-webmaker.conf`). hal21 의 jobs/agent_jobs 0건. 구 HAL: server `srv_01a178829d5848f93a15f93a17` status offline(last_seen 09:03:14.274Z), agent `ag_01a178829f1c3d88381d2c0c75` active(heartbeat 없음), 열린 jobs 0·agent_jobs 0, module_targets 7(목표 0.1.1), 운영자 ariweb-ops 1, vault 3(service:agent, service:portal, service:hal).
- 포털 config.json 값(비밀 아님): mailer·mailFrom·operatorEmail 만 → halUrl 은 코드 기본값 `https://hal.ariweb.net`(=/etc/hosts → 구 HAL). 포털 vault 이름 11: service:hal, service:portal, sso:webmaker, mailer:resend-key, arimail:* 7.
- 포털 DB(portal.sqlite, wal): integrity ok, 표 53, users 7(active), groups 10, memberships 13, sites 3, domains 6, mail_services 4(cancelled), mail_servers 1, mailboxes 6, jobs 4(failed 2 backup.scheduled, needs_user 2 mail.activate), sessions 7(live 7), audit_log 71, mail_usage_log 899.
- HAL21 DB: integrity ok; 서버 2 모두 ready(hostinger 승인 09:04:02.227Z by `cli:neo-webmaker-link`, jitsi 07:21:53.138Z by `op_01a17bae6c0b70acd18b16f029`), bootstrap complete 07:21:53.139Z(firstAgent `ag_01a17bd7201c9a73c157e29968`, manual_install_locked 1); agents 2 active; module_targets 12(hostinger: agent·arimail·backup·hal·observability·portal·webmaker 모두 actual 0.1.1 ok); 운영자 admin-hal 1(세션 live 1, 마지막 09:08:59Z — 다른 세션 소유); mail_services 7.
- Webmaker baseline(`scripts/webmaker-inventory.mjs`, 읽기 전용): 회원 12(고유 9·SSO 3), 프로젝트 10(배포 3), 도메인 2, 연결 3; 포털 users 7·groups 10·sites 3(linked 3)·domains 6·mailServices 4; 판정 linked_consistent 3·unlinked_native 7, 충돌 0·누락 0. 공개 사이트 디렉터리 10.
## 3. 백업(Gate 2, root 0700/0600, git 밖, `sha256sum -c` OK, stage 추출·readback 검증)
**hostinger `/srv/ariweb/backups/pre-hal21-migrate-20261027T092204Z/`** (RESTORE.md 먼저 작성, 경로 존재 검증 12/12 ok)
- archive.tgz `dca689d8c0fcb5d38455351b5db102e31a5d846b4f4735992627edfa7751bf63` (21 entries: portal/agent/hal data 의 config·vault.json·vault.key·agent.json·jobs, 유닛 3+drop-in, nginx ariweb-portal.conf·ariweb-hal.conf, /etc/hosts)
- db.tgz `d36000015028b6cea72173a8365cbd388f3b17c8b81e7daecb942dd0ac038e9c` (VACUUM INTO 일관 사본: portal.sqlite 1,028,096 B integrity ok 53표 — 원본 db 1,032,192 / wal 4,140,632 / shm 32,768 B; hal.sqlite 888,832 B ok 25표)
- code.tgz `7a9c56e39a63ad79706eaa046869e122c135bb0aa85f7420d74d3536b2f7c08e` (296 entries: portal·agent·hal current 실경로 트리), meta.tgz `7be814eb0030d3ccfc4b1fe1e4d778f70e3a56868158ed5ce66805a4ded2b76c`, RESTORE.md `3954ce32299bc51c86c6ccb6e3a157730d4de11ec862ea10c12e404d578b1300`, portal-verify.json `aa212574b7d3dc1c7266c456379a89b0a71e3e1bf2eb00cfe92f248aae953788`, hal-verify.json `91c33cc8b0d6155bd481b02ca7258f4f589884bb841b4de07e6ed067106eee55`
- stage 검증: portalVault 11 · agentVault 1 · halVault 3 · agent.json id `ag_01a17c99…` · 사본 integrity ok/ok.
**bear `/srv/ariweb/backups/hal-pre-portal-link-20261027T092207Z/`**
- archive.tgz `97b880370d4a94a64d712ba63d8b5ea072a847f893bf977435a1160d6dbcc9ed` (6), db.tgz `1deb62fdca1a77792c3367cb27668d297a4ec5d83e41c0d6c256d7c3b9425a32` (hal.sqlite 548,864 B ok 28표), code.tgz `e543244242541faadb067e66e48db32c128fcecfe23d3b1a15c830abb1f6d9ba` (80), meta.tgz `4c1078937387148e69ed602be66a528de14187eb04040d2908a7744a3c8cbe5c`, RESTORE.md `b1245a65fc82ad8a38866211a3cb261069bd77ef928725fc4921838dcc313ec6`, hal-verify.json `aebb63337c569abc821a9b88750b48d9cd0df45acedb80466c2d97f74135961f`; stage 검증 halVault 2 · servers 2 · configKeys publicUrl,portalUrl.
**source**: origin/main `0b9c70e…` git bundle(작업 PC scratchpad, 0600) sha256 `d55c924495b08337efb6fb1a3385bf5648a07b97231284f4d123bba2080e67eb` + clone.
구 HAL 등록 Agent 데이터는 Neo 백업 `/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z`(RESTORE.md 의 B 절차가 가리킴).
## 4. 적용(모두 지원 경로, 직접 SQL 없음)
1. 09:31:51Z bear: `register-release --manifest … --approve --by hera-portal-link`(artifact 5개 복사·등록부 갱신) → `bootstrap-release --version 0.1.1 --commit dfb708e… --sha256 4e419a72…` → `config-set --portalUrl https://my.ariweb.net`(감사) → current 전환·`ariweb-hal` 재시작(1.11초). 재시작 구간 외부 프로브 8회: my=200·mail=200·hal21=200·sample=200 전부, webmaker 302×6/000×2(프로브 4초 타임아웃; 직후 5회 재확인 302, 0.29초 — hostinger 호스트라 HAL 재시작과 무관). 두 Agent heartbeat 재개(09:32:29Z·09:32:45Z).
2. 09:33:44Z `link-portal`(bear vault service:portal·service:hal 09:33:45.756Z 갱신) → stdout 을 `sed` 로 값만 추려 ssh 파이프로 hostinger `lib/vault.mjs` 에 전달 → 포털 vault `service:hal` 09:33:46.334Z 교체(replacedExisting true), 구 연동의 미사용 이름 `service:portal` 제거. 값은 화면·로그·셸 변수에 없음(두 node 프로세스 사이 파이프만).
3. 09:33:46Z 포털 `config.set({publicUrl:'https://my.ariweb.net', halUrl:'https://hal21.ariweb.net'}, 'cli:hera-portal-link')`(config.json 198→292 B, 0600; webmakerUrl·arimailUrl 기본값 유지).
4. 09:33:47Z bear `module-deploy --module portal --version 0.1.1 --servers srv_01a17bd4dbcdb55877f2d9fcab --by hera-portal-link --reason …` → HAL 작업 `j_01a17cb5716c669450412967f7`(module.deploy, created_by cli:hera-portal-link, actor operator) → 자식 `j_01a17cb5719cc5385804317f08`(module.deploy.target) → Agent 작업 `aj_01a17cb573916795ab7bc67a54`(module.install, 09:33:49.009→09:33:52.775Z). 결과: actualVersion 0.1.1, previousVersion `0.1.1.prev-20261027093350`, health ok(200, 10 ms), releaseDir `/srv/ariweb/portal/releases/0.1.1`, runUser master; 부모 done 09:33:53.325Z(precheck→fanout→collect, overall done 1/1). 감사 `deploy.module.request`·`deploy.module`. 포털 재시작 09:33:50Z(vault 교체 뒤 4초 — 그 사이 포털→HAL 호출 없음, 포털 journal 오류 0).
## 5. readback
- HAL21 `module_targets.portal`(hostinger): target 0.1.1 · actual 0.1.1 · health ok · last_job `j_01a17cb5719cc5385804317f08` · last_check 09:34:45Z(heartbeat 로 계속 갱신). agent_jobs 1(done). jobs 2(done). outbox 0(운영자 작업은 포털 이벤트를 만들지 않음).
- **Portal→HAL21 인증 읽기(09:35:09Z, 포털의 실제 `portal/hal/client.mjs` + 실제 config.json·vault 로)**: `/portal/v1/health` ok(version 0.1.1, instanceId `hal_01a17cb3…`, bootstrap complete, mainCommit dfb708e…) · `/portal/v1/servers` 2건(둘 다 ready/ok, hostinger modules webmaker·arimail·hal·portal·agent·observability·backup = 0.1.1/ok) · `POST /portal/v1/servers/pick {role:web}` → null(§7 blocker) · `GET /portal/v1/jobs/j_01a17cb5716c…` 200 · `GET /portal/v1/jobs/j_none` → `job_not_found` 404(작업 계약). bear nginx access log: 187.53.133.173 → 위 5요청 200/200/200/200/404, Agent heartbeat 67회/5분. HAL21 journal 오류 0(재시작 이후).
- 바이트 보존(sha256, 배포 전=후): 유닛 `ef800cf0…1e59`(Agent 가 템플릿으로 다시 썼으나 내용 동일, diff 없음) · nginx ariweb-portal.conf `d7e8e1c7…7167` · fullchain `6650b903…b780` · portal vault.key `6a6af7d6…0094` · agent.json `335cc9e2…b524` · agent vault.json `393fe23d…4d64` · /etc/hosts `eafc0116…6c02`. config.json `a703ce78…`→`66fcb558…`, vault.json `8b3598bc…`→`a7e9e8ae…` 는 3·2단계의 의도된 변경(배포 자체는 data 디렉터리를 건드리지 않음). DB 원본 크기 동일(1,032,192/4,140,632/32,768).
- 포털 DB 전후: integrity ok/ok, 표 53/53, 차이는 `mail_usage_log` 899→915(사용량 수집 주기 작업)뿐; users 7·groups 10·memberships 13·sites 3·domains 6·mail_services 4·mailboxes 6·jobs 4·sessions 7(live 7) 동일. vault 이름 11→10(service:portal 제거·service:hal 교체), config 키 +publicUrl·+halUrl.
- Webmaker 전후 동일(회원 12·프로젝트 10·배포 3·연결 3, linked_consistent 3·unlinked_native 7, 충돌 0·누락 0, apply no-op), webmaker PID 1041361·NRestarts 0 유지, 공개 사이트 10. AriMail health ok 1.0.11 domains 6, webmail.ibit.kr ok domains 1.
- 외부(09:35Z): my http→301 https, https / →302 /login, /login 200 `로그인 · Ariweb 고객 포털`, /api/health ok 0.1.1, TLS verify 0; hal21 /api/health ok·/ops/ 200·/portal/v1/health(무인증) 401; webmaker 302, sample 200, guide 200, mail.arihakdang.com 200, webmail.ibit.kr 200. 유닛 6개 active/enabled NRestarts 0, nginx -t 성공, journal 오류: 포털 0, Agent 1(09:31:52 HAL 재시작 중 502 1회), 구 HAL 0, webmaker 0, mail 0.
- 구 HAL(hostinger): 계속 실행 중(PID 1059540)이나 서버 offline(last_seen 09:03:14Z, Agent heartbeat 없음), 열린 작업 0 — hostinger Agent 의 active owner 아님.
## 6. 테스트·빌드
`node test/run.mjs` 22파일 149건 통과·실패 0(코드 커밋 뒤 release build 안에서 1회, 문서 커밋 뒤 재실행). 신규 `test/hal-cli.test.mjs` 3건. `scripts/release.mjs build/verify` 통과(artifact 5개 ✔). `bash -n deploy/*.sh` 통과, shellcheck 미설치.
## 7. 되돌리기·blocker
- 되돌리기: hostinger `RESTORE.md`(A 포털 연결만 → B Agent 구 HAL 복귀(Neo 백업) → C 구 HAL 데이터 → D 유닛·nginx·hosts), bear `RESTORE.md`(vault·config·DB 사본·current `0.1.1-12958ab6952e`), 포털 코드 `ln -sfn /srv/ariweb/portal/releases/0.1.1.prev-20261027093350 /srv/ariweb/portal/current && systemctl restart ariweb-portal`, HAL 작업 되돌리기는 운영자 콘솔 모듈 되돌리기.
- blocker/unknown: (1) hal21 운영자 자격 증명 없음 — 서버 승인·역할 변경·콘솔 readback 은 수행하지 않음(승인·부트스트랩은 이미 완료 상태를 readback). (2) **hostinger 서버 행 role 이 `mail`**(메일 가져오기 때 생성) → `pickServer(web)` null → 포털 관리형 사이트 신규 배포·백업 저장 대상이 없다. 운영자가 `PATCH /ops/api/servers/srv_01a17bd4dbcdb55877f2d9fcab {role:'control'}`(재인증 필요)로 바꿔야 한다. (3) 구 HAL `ariweb-hal`(hostinger)은 중지하지 않았다(폐기·보존은 사람 결정). `/etc/hosts` 의 `hal.ariweb.net` 매핑도 그대로. (4) Agent 의 3개 보존 정책으로 코드 디렉터리 `0.1.1.prev-20261026141643` 가 정리됨(데이터 아님). (5) HAL→포털 이벤트 경로(`/internal/hal-events`)는 포털 발 작업이 없어 실측하지 못함(portalUrl 은 설정됨). (6) jitsi 서버의 `module_targets.webmaker` actual null(Neo 작업 범위).
@@ -0,0 +1,79 @@
# webmaker.ariweb.net → HAL21 운영 연동 검증
**작성자:** Neo
**작성일:** 2026-10-27 UTC
**목적:** 운영 Webmaker의 데이터·계정·배포 사이트를 변경하지 않고 `hal21.ariweb.net`의 HAL 제어 평면에 연결한 결과와 롤백 근거를 기록한다.
**요약:** 운영 Webmaker는 이미 Ariweb Suite Webmaker 0.1.1(상류 1.7.3)이므로 애플리케이션 교체는 하지 않았다. 검증된 사전 백업을 유지한 채 Node Agent만 HAL21로 재등록했고, HAL21은 프로젝트 10개를 누락·절단·오류 없이 읽었다. 회원 12명(고유 9, SSO 3), 데이터 32,608개 파일, Gitea ref 13개와 서비스 프로세스는 보존됐다. 비밀값은 출력·문서화하지 않았다.
## 개정 이력
- v1.0 — 2026-10-27 — HAL21 연동, 인벤토리, 무결성, 회귀 및 롤백 검증 기록.
## 1. 범위와 결정
- 대상: `webmaker.ariweb.net` / `srv1953374` (`187.53.133.173`).
- 제어 평면: `https://hal21.ariweb.net` / bear.
- Webmaker 실행 버전은 이미 Ariweb Suite 모듈 `0.1.1`, 상류 Webmaker `1.7.3`이었다. 동일 코드를 다시 덮어쓰면 위험만 늘어나므로 Webmaker 코드·DB·데이터는 교체하지 않았다.
- 기존 Webmaker 고유 계정의 비밀번호 해시나 세션을 Portal 계정으로 복제하지 않았다. 고유 회원 9명은 Webmaker 인증 정본으로 유지하고, 기존 SSO 회원 3명과 Portal 연결 3건도 유지했다.
- 기존 hostinger HAL에 등록돼 있던 Node Agent를 HAL21로 재등록했다. Webmaker Data Plane은 작업 중 계속 실행됐다.
## 2. 배포 정본
- AriWeb Suite main: `12958ab6952e78159f4de22771ddf46a51441a34`.
- HAL artifact SHA-256: `01c70d098d614fbd38195bde21c383c1dcaefc2891a4b907d0d268fc08f8f28f`.
- HAL 설치: `/srv/ariweb/hal/releases/0.1.1-12958ab6952e` → `/srv/ariweb/hal/current`.
- Agent 설치: `/srv/ariweb/agent/releases/0.1.1-12958ab6952e` → `/srv/ariweb/agent/current`.
- HAL 백업: `bear:/srv/ariweb/backups/hal-pre-12958ab-20261027T090222Z`.
- Agent 전환 백업: `srv1953374:/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z`.
- Webmaker 사전 백업·복원 시험: `/srv/webmaker/backups/pre-hal-link-20261027T045715Z` (`RESTORE_TEST_OK 32608 files`).
## 3. HAL21 연결
- HAL21 서버: `srv_01a17bd4dbcdb55877f2d9fcab`, 상태 `ready`.
- HAL21 Agent: `ag_01a17c99f0102ce24409dc258a`, 상태 active.
- Agent의 Webmaker 경로는 systemd drop-in으로 명시했다. 경로를 추측하거나 기존 Webmaker unit을 재작성하지 않았다.
- `DATA_ROOT=/data/webmaker`
- `DATA_DIR=/data/webmaker/sites`
- `SITES_ROOT=/var/www/webmaker-sites`
- HAL21 모듈 readback: Webmaker actual `0.1.1`, active, health `ok`, drift 없음.
- HAL21 인벤토리 readback: total `10`, returned `10`, truncated `false`, errors `[]`.
- 인벤토리에 포함된 도메인: `sample.ariweb.net`, `guide.ariweb.net`, `hs-company.ariweb.net`.
- 사이트 본문, 생성 프롬프트, 연락처, 비밀번호·해시·토큰·키·세션은 HAL로 보내지 않는다.
## 4. 데이터 보존 검증
- `/data/webmaker`와 `/var/www/webmaker-sites` 사전 체크섬 32,608건 재검증: exit 0, 불일치 0.
- 로컬 Git 저장소 7개: `git fsck` 성공 7, 실패 0.
- Gitea heads: 사전 스냅샷과 동일한 13개 ref.
- Webmaker 프로세스는 재시작하지 않았다.
- MainPID `1041361`
- NRestarts `0`
- ActiveEnter `2026-10-26 14:00:23 UTC`
- Webmaker 회원 12명(고유 9, SSO 3), 프로젝트 10개, Portal 연결 3개, 배포 이력 및 도메인 등록부는 변경하지 않았다.
## 5. 회귀 검증
- `webmaker.service`, `ariweb-agent`, `nginx`, `ariweb-hal`: active.
- nginx 설정 검사: 성공. 기존 `server_name`/protocol 중복 경고는 남아 있으나 이번 작업에서 nginx 파일을 변경하지 않았다.
- 외부 HTTP:
- `https://webmaker.ariweb.net/` → 로그인 화면 HTTP 200.
- `https://sample.ariweb.net/` → HTTP 200.
- `https://guide.ariweb.net/` → HTTP 200.
- Webmaker의 `572a1d…` 미리보기 경로 → HTTP 200.
- `https://hal21.ariweb.net/api/health` → HTTP 200.
- `https://hal21.ariweb.net/ops/` → HTTP 200.
- 알려진 기존 상태: `https://hs-company.ariweb.net/`은 공개 TLS SNI 오류가 있으며 이번 HAL 연결 작업으로 새로 생긴 문제가 아니다. 프로젝트와 파일은 Webmaker 인벤토리 및 미리보기에서 보존됨을 확인했다.
## 6. 롤백
1. hostinger에서 `ariweb-agent`를 중지한다.
2. `/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z/data`를 `/srv/ariweb/agent/data`로 복원한다.
3. `/srv/ariweb/agent/current`를 백업의 `current-target.txt` 값으로 되돌리고 `90-hal21-webmaker.conf`를 제거한다.
4. daemon-reload 후 `ariweb-agent`를 시작하면 기존 hostinger HAL 등록으로 복귀한다.
5. HAL21의 신규 Agent는 폐기 후 revoke하고, 서버 상태를 점검한다.
6. HAL 자체 롤백이 필요하면 bear의 HAL 백업에서 data를 복원하고 `current-target.txt`의 이전 release로 되돌린 뒤 `ariweb-hal`을 기동한다.
Webmaker Data Plane은 HAL과 분리돼 있으므로 위 롤백 중에도 기존 공개 사이트와 Webmaker 자체 인증 데이터는 그대로 유지된다.
+28
View File
@@ -12,6 +12,11 @@
* 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs)
* node hal/index.mjs mail-import-rollback --import <mimp_id> [--by <who>] 적용된 가져오기 되돌리기
* node hal/index.mjs mail-services HAL 관리형 메일 서비스 readback(JSON, 비밀 없음)
* node hal/index.mjs config-set [--publicUrl <u>] [--portalUrl <u>] [--offlineAfterMs <n>] [--metricsRetentionDays <n>] [--by <who>] [--reason <text>]
* 운영 값(data/config.json) 기록 — 비밀값 거부, 감사 기록. 서비스 프로세스는 기동 때 읽으므로 재시작해야 반영된다
* node hal/index.mjs module-deploy --module <m> --version <v> --servers <srv_id[,srv_id]> [--by <who>] [--reason <text>] [--nonce <n>]
* 모듈 배포 작업(module.deploy)을 운영자 API 와 같은 멱등 규칙으로 큐에 넣는다(시스템 신원, 감사 기록). 실행은 serve 프로세스의 작업 엔진이 맡는다
* node hal/index.mjs job --id <j_id> 작업·자식 작업 readback(JSON)
*/
import { loadConfig } from './config.mjs';
import { createHalDeps, VERSION } from './deps.mjs';
@@ -22,6 +27,9 @@ import * as emergency from './emergency.mjs';
import * as dr from './dr.mjs';
import * as mailsvc from './mailsvc.mjs';
import { purgeSessions } from '../lib/session.mjs';
import { audit } from '../lib/audit.mjs';
import { describe } from '../lib/jobs.mjs';
import { descriptor } from './modules/index.mjs';
import { randomToken } from '../lib/crypto.mjs';
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
@@ -65,6 +73,26 @@ if (cmd === 'setup-token') {
try { const r = mailsvc.rollbackImport(d, opt('import'), `cli:${opt('by', 'cli')}`); console.log(JSON.stringify(r)); } catch (e) { console.error(`되돌리기 실패: ${e.code || ''} ${e.message}`); process.exit(2); }
} else if (cmd === 'mail-services') {
console.log(JSON.stringify(mailsvc.readback(d), null, 1));
} else if (cmd === 'config-set') {
// 운영 중 바꾸는 값만(hal/config.mjs 의 set — 비밀 키 이름은 거부). 받는 키를 고정해 비밀값이 설정 파일에 들어갈 길을 막는다. 감사 actor cli:<by>.
const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) { const v = opt(k); if (v != null && v !== true) patch[k] = String(v); }
if (!Object.keys(patch).length) { console.error('바꿀 값이 없습니다: --publicUrl / --portalUrl / --offlineAfterMs / --metricsRetentionDays 중 하나 이상'); process.exit(1); }
const by = `cli:${opt('by', 'cli')}`; cfg.set(patch, by);
audit(d.store, { actorType: 'operator', actorId: by, action: 'config.update', subjectType: 'config', subjectId: 'hal', detail: { keys: Object.keys(patch), values: patch, reason: opt('reason', '') } });
console.log(JSON.stringify({ updated: Object.keys(patch), file: cfg.file, note: '서비스 프로세스(ariweb-hal)는 기동 때 설정을 읽는다 — 재시작 뒤 반영' }));
} else if (cmd === 'module-deploy') {
// 운영자 API(POST /ops/api/modules/:name/deploy)와 같은 작업·멱등 키 규칙. 검증(모듈·릴리스·서버)은 여기서, 배포 전제 검사(승인·HAL 먼저·부트스트랩)는 작업의 precheck 단계가 한다.
const module = opt('module'); const version = opt('version'); const serverIds = String(opt('servers', '') || '').split(',').map((x) => x.trim()).filter(Boolean); const by = `cli:${opt('by', 'cli')}`;
if (!module || module === true || !version || version === true || !serverIds.length) { console.error('--module <name> --version <v> --servers <srv_id[,srv_id]> 가 필요합니다'); process.exit(1); }
try { descriptor(module); } catch (e) { console.error(e.message); process.exit(1); }
if (!core.getRelease(d, version)) { console.error(`릴리스 ${version} 이(가) 등록되어 있지 않습니다`); process.exit(1); }
const missing = serverIds.filter((sid) => !core.getServer(d, sid)); if (missing.length) { console.error(`없는 서버: ${missing.join(', ')}`); process.exit(1); }
const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version, serverIds }, idempotencyKey: `module.deploy:${module}:${version}:${serverIds.slice().sort().join(',')}:${opt('nonce', '') === true ? '' : opt('nonce', '')}`, createdBy: by, actorType: 'operator', subjectType: 'module', subjectId: module, label: `${module} ${version} 배포` });
audit(d.store, { actorType: 'operator', actorId: by, action: 'deploy.module.request', subjectType: 'module', subjectId: module, detail: { version, serverIds, reason: opt('reason', ''), jobId: r.job.id, created: r.created } });
console.log(JSON.stringify({ jobId: r.job.id, created: r.created, status: r.job.status, kind: r.job.kind, label: r.job.label, note: '실행은 ariweb-hal 서비스 프로세스의 작업 엔진이 맡는다 — `job --id` 로 확인' }));
} else if (cmd === 'job') {
const j = opt('id') && opt('id') !== true ? d.jobs.get(opt('id')) : null; if (!j) { console.error('--id <j_id> 가 필요합니다(없는 작업)'); process.exit(1); }
console.log(JSON.stringify({ job: describe(j), children: d.jobs.list({ parentId: j.id, limit: 100 }).map(describe) }, null, 1));
} 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); }
+3 -2
View File
@@ -46,7 +46,8 @@ export function registerUi(app, d) {
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 (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>`);
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>
@@ -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>`;
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)
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 = `<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>`;
+58
View File
@@ -0,0 +1,58 @@
/** test/hal-cli.test.mjs — HAL CLI(시스템 신원) 회귀 테스트: config-set / module-deploy / job.
* 운영자 콘솔이 닿지 않는 연동 단계에서 HAL 데이터 소유자가 쓰는 명령이다. 별도 프로세스(node hal/index.mjs …)로 실행해 실제 CLI 경로를 검증하고,
* 같은 데이터 디렉터리를 연 서비스 측 작업 엔진이 CLI 가 넣은 작업을 집어 실행하는지(실행은 serve 프로세스 몫) 확인한다. 비밀값은 출력하지 않는다. */
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { createHalDeps } from '../hal/deps.mjs';
import * as core from '../hal/core.mjs';
import { listAudit } from '../lib/audit.mjs';
import { execFileSync } from 'node:child_process';
import { readFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
function makeDataDir() { const dir = join(tmpDir('hal-cli-'), 'data'); mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir; }
/** CLI 를 별도 프로세스로. 실패(종료 코드≠0)는 { code, stdout, stderr } 로 돌려준다. */
function cli(dataDir, args) {
try { const out = execFileSync(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, PORT: '0' }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 }); return { code: 0, stdout: out, stderr: '' }; }
catch (e) { return { code: e.status, stdout: String(e.stdout || ''), stderr: String(e.stderr || '') }; }
}
const deps = (dataDir) => createHalDeps({ 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: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1000, emergencyTtlMinutes: 30, agentPollMs: 10, values: {}, set() {} } });
await test('config-set: 운영 값만 data/config.json 에 기록(actor cli:<by>, 감사), 인자 없으면 거부, 비밀 키는 받지 않음', () => {
const dir = makeDataDir();
eq(cli(dir, ['config-set']).code, 1, '바꿀 값이 없으면 실패');
const r = cli(dir, ['config-set', '--portalUrl', 'https://my.example.test', '--publicUrl', 'https://hal21.example.test', '--by', 'hera', '--reason', 'control-plane move']);
eq(r.code, 0, r.stderr); const out = JSON.parse(r.stdout.trim().split('\n').pop()); eq(out.updated.sort(), ['portalUrl', 'publicUrl']);
const cfg = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8')); eq(cfg.values.portalUrl, 'https://my.example.test'); eq(cfg.values.publicUrl, 'https://hal21.example.test'); eq(cfg.updatedBy, 'cli:hera');
const d = deps(dir); const a = listAudit(d.store, { action: 'config.update' }); eq(a.length, 1); eq(a[0].actor_id, 'cli:hera'); eq(JSON.parse(a[0].detail_json).keys.sort(), ['portalUrl', 'publicUrl']); eq(JSON.parse(a[0].detail_json).reason, 'control-plane move');
// 비밀값 이름은 config-set 이 받는 키 목록에 없다 — 무시되어 '바꿀 값 없음' 으로 거부된다(설정 파일에 비밀을 두지 않는다)
const s = cli(dir, ['config-set', '--apiKey', 'x']); eq(s.code, 1); ok(!readFileSync(join(dir, 'config.json'), 'utf8').includes('apiKey'));
});
await test('module-deploy: 운영자 API 와 같은 멱등 규칙으로 module.deploy 작업을 큐에 넣는다(실행은 서비스 프로세스), 검증 실패는 종료 코드 1, job 명령으로 readback', () => {
const dir = makeDataDir(); const d = deps(dir);
const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' });
eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id]).code, 1, '릴리스가 없으면 거부');
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, approve: true, by: 'test' });
eq(cli(dir, ['module-deploy', '--module', 'nope', '--version', '0.1.1', '--servers', srv.id]).code, 1, '알 수 없는 모듈 거부');
eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', 'srv_none']).code, 1, '없는 서버 거부');
const r = cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--reason', 'portal → hal21 link']);
eq(r.code, 0, r.stderr); const out = JSON.parse(r.stdout.trim().split('\n').pop()); ok(out.jobId.startsWith('j_')); eq(out.created, true); eq(out.status, 'queued');
const j = d.jobs.get(out.jobId); eq(j.kind, 'module.deploy'); eq(j.subject, { module: 'portal', version: '0.1.1', serverIds: [srv.id] }); eq(j.created_by, 'cli:hera'); eq(j.actor_type, 'operator'); eq(j.idempotency_key, `module.deploy:portal:0.1.1:${srv.id}:`);
const again = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop()); eq(again.jobId, out.jobId); eq(again.created, false, '같은 멱등 키 → 같은 작업');
const nonce = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--nonce', '2']).stdout.trim().split('\n').pop()); ok(nonce.jobId !== out.jobId && nonce.created, 'nonce 로 새 작업');
const a = listAudit(d.store, { action: 'deploy.module.request' }); eq(a.length, 3); eq(a[0].actor_id, 'cli:hera'); eq(JSON.parse(a.find((x) => JSON.parse(x.detail_json).jobId === out.jobId && JSON.parse(x.detail_json).created).detail_json).reason, 'portal → hal21 link');
const rb = cli(dir, ['job', '--id', out.jobId]); eq(rb.code, 0, rb.stderr); const view = JSON.parse(rb.stdout); eq(view.job.id, out.jobId); eq(view.job.status, 'queued'); eq(view.children, []);
eq(cli(dir, ['job', '--id', 'j_none']).code, 1);
});
await test('CLI 가 넣은 작업을 같은 데이터의 서비스 측 작업 엔진이 집어 실행한다(artifact 없음·부트스트랩 미완료 → precheck 실패로 끝남, 큐에 남지 않음)', async () => {
const dir = makeDataDir(); const d = deps(dir); const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' });
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, approve: true, by: 'test' });
const out = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop());
d.jobsRunning = true; const n = await d.jobs.drain(); ok(n >= 1, '서비스 측 엔진이 큐 작업을 집었다');
const j = d.jobs.get(out.jobId); eq(j.status, 'failed'); ok(['artifact_missing', 'bootstrap_incomplete'].includes(j.error_code), `precheck 실패로 끝남: ${j.error_code}`); eq(d.jobs.list({ status: 'queued' }).length, 0);
});
done();
+23
View File
@@ -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 실행 버전: <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 () => {
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 });