Compare commits

...
Author SHA1 Message Date
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
6 changed files with 250 additions and 0 deletions
+5
View File
@@ -124,6 +124,7 @@
### 운영 Webmaker ↔ HAL 연동 점검 도구(2026-10-27, webmaker.ariweb.net) ### 운영 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. - **`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. - 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`) ### 기존 메일 서비스(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 이 멈춰도 메일은 그대로다. - **`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,10 @@
- **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`). - **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. - 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 로 변경 필요).
### 도입 소스 ### 도입 소스
- 가져오기: `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) 를 설치 시점에 내려받는 래퍼만 포함.
+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(운영자 승인 뒤에만 — 메일 중단 동반). 되돌리기: `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 미연결)으로 두었다 — 제어 평면 이전은 사람 결정. 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,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) * 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs)
* node hal/index.mjs mail-import-rollback --import <mimp_id> [--by <who>] 적용된 가져오기 되돌리기 * 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 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 { loadConfig } from './config.mjs';
import { createHalDeps, VERSION } from './deps.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 dr from './dr.mjs';
import * as mailsvc from './mailsvc.mjs'; import * as mailsvc from './mailsvc.mjs';
import { purgeSessions } from '../lib/session.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 { randomToken } from '../lib/crypto.mjs';
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs'; import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname, basename } from 'node:path'; 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); } 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') { } else if (cmd === 'mail-services') {
console.log(JSON.stringify(mailsvc.readback(d), null, 1)); 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') { } else if (cmd === 'serve') {
// "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만). // "하나의 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); } try { core.claimLive(d, { instanceId: d.instanceId, version: VERSION, takeover: !!opt('takeover') }); } catch (e) { console.error(`[hal] 기동 거부: ${e.message}`); process.exit(2); }
+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();