운영 Webmaker ↔ HAL 연동 점검 도구와 dry-run 기록: scripts/webmaker-inventory.mjs(읽기 전용 inventory·안정 식별자 대조·충돌/누락 판정·비밀 차단) + 테스트 5건, RUNBOOK §8, 검증 기록

- hostinger webmaker.ariweb.net 실데이터 결과: 프로젝트 10(연결 일치 3·고유 7), 회원 12(SSO 3·고유 9), Gitea refs 13, HAL 모듈 0.1.1 ok — 충돌 0·누락 0, 적용 변경 없음(교체 불필요)
- 사전 스냅샷·복원 검증·되돌리기 절차·사후 readback 은 docs/verification/webmaker-hal-link-2026-10-27.md (비밀값 없음)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-27 05:06:37 +00:00
co-authored by Claude Fable 5.1
parent ac072e6f12
commit 03cb9c0777
5 changed files with 316 additions and 0 deletions
+5
View File
@@ -119,6 +119,11 @@
- bear(Ubuntu 24.04, nginx 1.24, Node 24.22, certbot nginx 플러그인 운영)에 HAL 만 최초 부트스트랩: 인증서는 호스트별 certbot(webroot `/var/www/html`, ECDSA, 갱신 훅 `systemctl reload nginx`) — `--cert /etc/letsencrypt/live/hal21.ariweb.net --acme /var/www/html`. 기존 Webmaker·Gitea·Matrix·기본 서버는 건드리지 않았다. 절차·검증·되돌리기는 `docs/RUNBOOK-0.1.1.md` §7. 배포 artifact: main `ab75b7d`, hal sha256 `f574d875…`(deploy/ 변경으로 ariweb.net 호스트의 `e7741899…` 와 다름). 알려진 사소한 결함: 부트스트랩 5단계의 HTTPS 자체 확인이 nginx reload 직후 실행되어 거짓 경고가 날 수 있다(결과에 영향 없음).
- **HAL 기본 주소 안내**(`hal/app.mjs`): `https://hal21.ariweb.net/` 접속이 JSON 404를 반환하던 동작을 운영자 콘솔 표준 경로 `/ops/`로 이동하도록 수정했다. `/ops`의 기존 사설망/VPN·기기 접근권 관문과 별도 운영자 로그인은 그대로 적용된다.
### 운영 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.
### 도입 소스
- 가져오기: `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) 를 설치 시점에 내려받는 래퍼만 포함.
+13
View File
@@ -85,3 +85,16 @@ sudo rm /etc/nginx/sites-enabled/ariweb-hal.conf /etc/nginx/sites-available/ariw
sudo rm /etc/sudoers.d/ariweb-agent; sudo rm -rf /srv/ariweb /var/www/ariweb-sites # 데이터 포함 — 보관이 필요하면 /srv/ariweb/hal/data 먼저 복사
sudo certbot delete --cert-name hal21.ariweb.net # 인증서까지 지울 때만
```
## 8. 운영 Webmaker(webmaker.ariweb.net) ↔ HAL 연동 점검·dry-run (2026-10-27)
사실: `webmaker.ariweb.net` = hostinger, 이미 HAL 작업으로 설치된 Ariweb Suite Webmaker 모듈 0.1.1(상류 1.7.3)이며 hostinger HAL 의 `module_targets.webmaker` 에 등록돼 있다(교체 불필요). HAL 은 사이트·계정을 모델링하지 않으므로 "HAL 리소스" 는 모듈 등록이고, 프로젝트 연결은 포털 ↔ Webmaker 양쪽 등록부로만 이뤄진다. 기록: `docs/verification/webmaker-hal-link-2026-10-27.md`.
```bash
# 1) 사전 스냅샷(hostinger, master, 사설 0700) — /srv/webmaker/backups/pre-hal-link-<UTC>/ : data·sites tar, 원본 32,608 파일 sha256, conf, Gitea refs, HAL·포털 DB VACUUM INTO 사본, MANIFEST.sha256, ROLLBACK.md. 만든 뒤 임시 디렉터리에 풀어 체크섬 전수 대조(RESTORE_TEST_OK)
# 2) 읽기 전용 inventory + dry-run (비밀값 없음, 충돌·누락이 있으면 종료 코드 2)
node --no-warnings scripts/webmaker-inventory.mjs --webmaker-data /data/webmaker --sites-root /var/www/webmaker-sites --app-dir /srv/webmaker/current \
--portal-db /srv/ariweb/portal/data/portal.sqlite --hal-db /srv/ariweb/hal/data/hal.sqlite --nginx-enabled /etc/nginx/sites-enabled \
--gitea-refs <ls-remote --heads 출력> --out <백업 디렉터리>/dry-run.json
# 3) 사후 readback: (cd / && sha256sum --quiet -c <백업>/checksums-source.txt) · git fsck · ls-remote diff · systemctl show webmaker -p NRestarts · nginx -t · 외부 URL
```
판정 뜻: `linked_consistent` 양쪽 등록부 일치 · `unlinked_native` Webmaker 고유 프로젝트(변경 대상 아님) · `link_*`/`duplicate`/`missing_project`/`gitea_ref`/`hal_*` 는 충돌·누락이라 적용 금지. 2026-10-27 결과: 프로젝트 10(일치 3·고유 7), 회원 12(SSO 3 일치·고유 9), 충돌 0·누락 0 → **적용 변경 없음**. 되돌리기는 스냅샷 디렉터리의 `ROLLBACK.md`.
남은 사람 결정: hal21(bear) 운영자 자격 증명으로 서버 등록·Agent 재등록 여부(제어 평면 이전은 §34 "하나의 HAL" 절차), 고유 회원 9명의 포털 계정 전환(사용자 자격 증명·동의 필요).
@@ -0,0 +1,71 @@
# webmaker.ariweb.net ↔ HAL 연동 점검·dry-run 기록 (2026-10-27)
작업 승인: 오즈. 실행: Claude Fable 5.1(세션 ariweb-suite-8a). 저장소 기준 main `ac072e6` 에서 도구 추가(`scripts/webmaker-inventory.mjs`, `test/webmaker-inventory.test.mjs`). 비밀값(해시·토큰·키·세션)은 어디에도 출력·기록하지 않았다.
## 1. 실제 호스트 (DNS·운영 위치, 개발 저장소로 추정하지 않음)
| 항목 | 실제 값 |
|---|---|
| `webmaker.ariweb.net` A | 187.53.133.173 = hostinger(`srv1953374`, Ubuntu 24.04.4, Node v22.23.2, nginx 1.24.0) |
| 서비스 | systemd `webmaker.service`(User master, ExecStart `node server.js`, 127.0.0.1:3080) + `webmaker-setup.service` |
| 코드 | `/srv/webmaker/current → /srv/webmaker/releases/0.1.1`(프로세스 cwd 동일), `REVISION.json` = 모듈 0.1.1 / 상류 1.7.3, HAL 작업으로 2026-10-26 14:00:21Z 설치 — 즉 **이미 Ariweb Suite Webmaker(도입 모듈)** 이며 교체 대상이 아니다 |
| 데이터 | `DATA_ROOT=/data/webmaker`(0700, 691M) · `DATA_DIR=/data/webmaker/sites` · `SITES_ROOT=/var/www/webmaker-sites`(969M) · `STATS_LOG=/var/log/nginx/webmaker-sites.log` · 설정 `/data/webmaker/config.json`(값은 보관소) |
| 저장 구조 | JSON 파일(원자적 저장 + `.bak`): `users.json`(회원·scrypt 해시) · `sites/<id>.json`(프로젝트·배포 이력·git 변경) · `domains.json` · `deploy-targets.json` · `ariweb-links.json`(포털 연결 등록부) · `mail-servers.json` · `credits.json` 등. 로컬 git `/data/webmaker/git/<id>`(7개) → Gitea `127.0.0.1:3030`(hostinger 로컬, 저장소 `webmaker/sites`, 브랜치 `sites/<id>/main`) |
| HAL | hostinger 자체 HAL(`ariweb-hal`, 127.0.0.1:8900, nginx `hal.ariweb.net` — 공개 DNS 없음) 부트스트랩 완료, 서버 `srv_01a178829d5848f93a15f93a17`(control, ariweb.net) + Agent `ag_01a178829f1c3d88381d2c0c75` 활성, `module_targets.webmaker` 0.1.1/0.1.1 health ok |
| bear HAL(`hal21.ariweb.net`) | 운영자 1·서버 0·Agent 0·부트스트랩 미완료. 운영자 로그인 정보는 이 세션에 없다(피어 세션 ariweb-suite-36 도 만들지 않았다고 답함) |
## 2. inventory (원본, 안정 식별자)
| 자원 | 수 | 식별자·비고 |
|---|---|---|
| Webmaker 회원 | 12 | 고유 9(`oz-admin`, `admin`, `guest01`, `icqmomo`, `keytron`, `thewings`, `mingks01`, `visionplus`, `yunmi7266`) + 포털 SSO 3(`aw-01a17894-01a17894`, `aw-01a17895-01a17895`, `aw-01a1789f-01a1789f`) |
| 프로젝트(사이트) | 10 | 32-hex id. 배포 이력 있음 3(`291da329…` sample.ariweb.net 14회, `41ca22fd…` guide.ariweb.net 3회, `572a1d94…` hs-company 1회), 로컬 git 7, 포털 연결 3(검증 사이트) |
| 도메인(Webmaker 등록부) | 2 | sample.ariweb.net, guide.ariweb.net(모두 icqmomo, active). `hs-company.ariweb.net` 은 프로젝트 도메인 목록에만 있고 DNS 는 bear(121.152.149.45)를 가리킴 |
| 배포 대상 | 1 | `a7ae448c0593` 기본서버-자체호스팅(local, `/var/www/webmaker-sites`, `https://webmaker.ariweb.net`) |
| 메일 서버 | 0 | `mail-servers.json` items 비어 있음(메일 연동은 `config.mail.provider=arimail`, `https://webmail.ariweb.net`) |
| Gitea refs | 13 heads | `sites/<id>/main` 7개 + `changes/*` 5개 + `main` |
| 사이트 디렉터리 | 10 | 파일 수: 291da 13 · 41ca 16 · 4cbe 61 · 572a 31,780(961M) · 7e10 8 · b354 4 · cf7c 4 · 검증 3개 0 |
| nginx | 3 | `webmaker.ariweb.net.conf`, `wh-291da…conf`, `wh-41ca…conf` (+`wh-default.conf`) |
| 포털 DB | users 7 · groups 10 · sites 3 · domains 6 · mail_services 4 · deployments 0 | 사이트 3개 모두 `webmaker_project_id` 로 검증 프로젝트에 연결 |
## 3. 사전 스냅샷 (hostinger, 사설, 비밀 미출력)
위치 `/srv/webmaker/backups/pre-hal-link-20261027T045715Z/`(master 0700, 파일 0600). 내용: `data-webmaker.tar.gz`(708,460,310B) · `sites-root.tar.gz`(76,467,803B) · `checksums-source.txt`(원본 32,608 파일 sha256) · `conf/`(webmaker·webmaker-setup 유닛, nginx 3+1 파일, sites-enabled 목록, 유닛 상태) · `gitea-refs.txt`(13) · `local-git-heads.txt` · `hal.sqlite`·`portal.sqlite`(VACUUM INTO 일관 사본) · `MANIFEST.sha256` · `ROLLBACK.md` · `dry-run.json`.
MANIFEST.sha256 자체 sha256 앞 16자리 `b3d7d97e66ace583`, data tar `29d13e5846871a63`, sites tar `8690f9de6e1246fd`, checksums `8d01acf5721ac7ec`.
복원 가능성 검증: 두 tar 를 임시 디렉터리에 풀어 `checksums-source.txt` 32,608건 전부 대조 → `RESTORE_TEST_OK 32608 files`(04:58:32Z), 임시 디렉터리 삭제. 되돌리기 절차는 `ROLLBACK.md`(정지 → 현재 보존 → tar 복원 → 설정 복원 → 기동 → 체크섬·refs 대조).
## 4. 연동 경로 조사 (코드·테스트)
- HAL 은 사이트·계정을 모델링하지 않는다(테이블: servers·agents·modules·module_targets·releases·jobs…). Webmaker 의 "HAL 리소스" 는 모듈 등록(`module_targets`)뿐이며 이미 등록·건강 ok.
- 포털 ↔ Webmaker 연결은 양쪽 등록부(`ariweb-links.json` ↔ `sites.webmaker_project_id`)로만 이뤄지고, 기존 프로젝트 연결은 포털 사용자 세션의 `사이트 생성(projectMode=existing)` 경로뿐이다(`portal/sites/service.mjs`). Webmaker 내부 API `/api/internal/ariweb/*` 는 포털 서비스 서명 필요. "콘텐츠 가져오기" 는 이번 버전 미지원(`portal/sites/ui.mjs`).
- 비밀 입력 없이 쓸 수 있는 경로: 읽기 전용 inventory/dry-run(`scripts/webmaker-inventory.mjs`)뿐. 고유 회원 9명을 포털 계정으로 만드는 일은 사용자 자격 증명·동의가 필요해 하지 않았다(해시·세션 재사용 금지).
## 5. dry-run 결과 (hostinger 실데이터, 읽기 전용, `dry-run.json` sha256 `e94116e71a5c1a81…`)
| 판정 | 수 |
|---|---|
| linked_consistent(양쪽 등록부 일치) | 3 (`85e1859a…`→`s_01a1789f…`, `899f5bbe…`→`s_01a17895…`, `ad015867…`→`s_01a17894…`) |
| unlinked_native(Webmaker 고유, 변경 대상 아님) | 7 |
| 충돌(link_mismatch·duplicate·한쪽만·SSO 신원 누락·도메인 불일치·HAL 버전/건강) | **0** |
| 누락(missing_project·Gitea ref·HAL 등록) | **0** (Gitea `sites/<id>/main` 7/7, 마지막 푸시 커밋 일치) |
| SSO 회원 ↔ 포털 그룹·사용자 | 3/3 일치, 고유 회원 9 = 포털 신원 없음(keep) |
| HAL 모듈 | registered, server `srv_01a178829d…`, target 0.1.1 = actual 0.1.1 = 설치 0.1.1(상류 1.7.3), health ok, Agent online |
| applyPlan | **no-op — 적용할 변경 없음** |
## 6. 적용 (5단계)
적용한 변경 **없음**. 교체 불필요(이미 Ariweb Suite Webmaker 모듈 0.1.1). 운영 Webmaker 의 회원·프로젝트·도메인·배포·메일 연결·Gitea·설정·세션은 바이트 단위로 보존(아래 7).
## 7. 사후 검증 (05:01~05:03Z, 모두 readback)
- 콘텐츠 체크섬: `/data/webmaker` + `/var/www/webmaker-sites` 32,608 파일 `sha256sum -c` 불일치 0.
- git fsck 로컬 7개 저장소 오류 0 · Gitea `ls-remote --heads` 13건 스냅샷과 동일.
- `webmaker.service` active, MainPID 1041361, NRestarts 0, ActiveEnter 2026-10-26 14:00:23Z(스냅샷 전후 동일), 최근 2시간 err 저널 0. ariweb-hal/portal/agent, arihakdang-mail, nginx, webmaker-setup 모두 active·재시작 0.
- nginx `-t` 성공, 유닛·vhost 4개 sha256 스냅샷과 동일. TLS: webmaker.ariweb.net CN 전용(2027-01-12), sample/guide `*.ariweb.net`(2027-01-13).
- 외부 HTTP: `https://webmaker.ariweb.net/` 302→`/login`(200, 제목 "로그인 — AI Webmaker" — 화면·세션 호환, 자격 증명 미사용) · sample.ariweb.net `/`,`/en/`,`/ja/` 200 · guide.ariweb.net `/`,`/en/` 200 · webmaker.ariweb.net 미리보기 경로 3개(`/291da…/`, `/41ca…/`, `/572a…/`) 200.
- HAL readback: `module_targets.webmaker` 0.1.1/0.1.1 health ok last_check 05:01:44Z. `session.secret`·`users.json` mtime 변동 없음.
## 8. 미해결 blocker (사람 결정 필요)
1. bear HAL(`hal21.ariweb.net`)에 hostinger 를 서버로 등록하거나 Agent 를 재등록하려면 hal21 운영자 로그인(등록 토큰 발급)이 필요하다 — 이 세션에는 자격 증명이 없고, 운영자 1건은 누가 만들었는지 확인되지 않았다. 또한 hostinger Agent 는 hostinger HAL 에 등록돼 있어 제어 평면을 hal21 로 옮기는 것은 별도 결정·절차(§34 "하나의 HAL")가 필요하다.
2. Webmaker 고유 회원 9명·고유 프로젝트 7개를 포털 자원(계정·그룹·사이트)으로 옮기는 기능은 없고, 만들려면 각 사용자의 자격 증명·동의가 필요하다.
+146
View File
@@ -0,0 +1,146 @@
/**
* scripts/webmaker-inventory.mjs — 운영 Webmaker ↔ 포털 ↔ HAL 연동 inventory 와 dry-run(읽기 전용).
*
* node scripts/webmaker-inventory.mjs --webmaker-data /data/webmaker [--sites-root /var/www/webmaker-sites]
* [--portal-db /srv/ariweb/portal/data/portal.sqlite] [--hal-db /srv/ariweb/hal/data/hal.sqlite]
* [--app-dir /srv/webmaker/current] [--nginx-enabled /etc/nginx/sites-enabled] [--gitea-refs <git ls-remote --heads 출력 파일>] [--out <json>]
*
* 무엇을 하나: 세 저장소(Webmaker JSON·포털 DB·HAL DB)를 **읽기만** 하고 안정 식별자로 대조해 연동 판정을 낸다.
* - 정본(source of truth): Webmaker 프로젝트·회원·도메인·배포 이력은 Webmaker 데이터(`data/*.json`)가 정본이다. 포털은 "연결 등록부"
* (Webmaker `ariweb-links.json` ↔ 포털 `sites.webmaker_project_id`)로 프로젝트를 자기 사이트에 연결할 뿐 프로젝트를 복제하지 않는다.
* HAL 은 사이트·계정을 모델링하지 않고 모듈(webmaker) 설치·버전·건강만 등록부(`module_targets`)에 둔다. 그래서 "HAL 리소스로 가져오기" 의
* 실체는 (1) HAL 모듈 등록(서버·버전·건강) (2) 포털 연결 등록부의 일치 확인이며, 그 밖의 복제는 없다. Data Plane(Webmaker 서비스·배포 사이트)은
* HAL 이 죽어도 그대로 동작한다(HAL 은 제어 평면이다).
* - 안정 식별자: Webmaker 프로젝트 id(32 hex, 변하지 않음) · 포털 site id(`s_…`) · group id(`g_…`) · HAL server id(`srv_…`).
* 포털 SSO 사용자의 Webmaker 로그인은 `aw-<group8>-<user8>` 이므로 접두어로 포털 그룹·사용자와 대조한다.
* - 판정: linked_consistent(양쪽 등록부 일치) · link_portal_only / link_webmaker_only(한쪽만 — 충돌) · link_mismatch(다른 사이트를 가리킴 — 충돌)
* · duplicate(같은 프로젝트를 둘 이상의 사이트가 가리킴 — 충돌) · missing_project(포털이 없는 프로젝트를 가리킴 — 누락) · unlinked_native(포털 연결이
* 없는 Webmaker 고유 프로젝트 — 변경 대상 아님, 가져오려면 포털 계정이 있어야 하므로 사람 결정 필요).
* - 비밀값(해시·salt·토큰·키·세션)은 읽지도 출력하지도 않는다. 이메일은 앞 2글자만 남긴다.
*/
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
const args = process.argv.slice(2);
const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
const readJson = (f, fb = null) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch (_) { return fb; } };
const maskEmail = (e) => (typeof e === 'string' && e.includes('@') ? e.slice(0, 2) + '…@' + e.split('@')[1] : null);
const SECRET = /pass|hash|salt|secret|token|key|session|cookie|credential/i;
const countFiles = (dir) => { let n = 0; const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) walk(join(d, e.name)); else if (e.isFile()) n++; } }; try { walk(dir); } catch (_) { return null; } return n; };
/** Webmaker 데이터 루트에서 비밀 없는 inventory 를 읽는다. */
export function readWebmaker(dataRoot, { sitesRoot = null, nginxEnabled = null, appDir = null } = {}) {
const usersRaw = readJson(join(dataRoot, 'users.json'), {});
const users = Object.values(usersRaw).filter((u) => u && u.id).map((u) => ({ id: u.id, name: u.name, role: u.role || 'user', accountType: u.accountType || 'domain', sso: u.sso || null, createdAt: u.createdAt || null, lastLoginAt: u.lastLoginAt || null }));
const links = (readJson(join(dataRoot, 'ariweb-links.json'), { links: {} }) || {}).links || {};
const sitesDir = join(dataRoot, 'sites'); const projects = [];
if (existsSync(sitesDir)) for (const f of readdirSync(sitesDir).filter((x) => /^[0-9a-f]{32}\.json$/.test(x)).sort()) {
const s = readJson(join(sitesDir, f)); if (!s || !s.id) continue;
const deployments = Array.isArray(s.deployments) ? s.deployments : []; const last = deployments[deployments.length - 1] || null;
const changes = (s.git && Array.isArray(s.git.changes)) ? s.git.changes : []; const pushed = changes.filter((c) => c.pushed).slice(-1)[0] || null;
const vhost = nginxEnabled ? existsSync(join(nginxEnabled, `wh-${s.id}.conf`)) : null;
projects.push({ id: s.id, title: String(s.title || '').slice(0, 60), ownerId: s.ownerId || null, status: s.status || null, mode: s.mode || null, createdAt: s.createdAt || null, updatedAt: s.updatedAt || null,
domains: Array.isArray(s.domains) ? s.domains.map((d) => (typeof d === 'string' ? d : d.name || d.domain)).filter(Boolean) : [],
deployments: deployments.length, lastDeploy: last ? { target: last.targetId || last.target || null, at: last.at || last.deployedAt || null, url: last.url || last.publicUrl || null } : null,
git: { changes: changes.length, lastPushed: pushed ? { no: pushed.no, kind: pushed.kind, commit: pushed.commit, branch: pushed.branch } : null },
langs: s.langs && Array.isArray(s.langs.list) ? s.langs.list : null, unpublishedAt: s.unpublishedAt || null,
siteDirFiles: sitesRoot ? countFiles(join(sitesRoot, s.id)) : null, vhost,
link: links[s.id] ? { siteId: links[s.id].siteId, groupId: links[s.id].groupId, siteName: links[s.id].siteName, linkedAt: links[s.id].linkedAt } : null });
}
const domainsRaw = readJson(join(dataRoot, 'domains.json'), { domains: [] }) || {};
const domains = (Array.isArray(domainsRaw) ? domainsRaw : domainsRaw.domains || []).map((d) => ({ name: d.name || d.domain, siteId: d.siteId || null, ownerId: d.ownerId || d.userId || null, status: d.status || null }));
const dt = readJson(join(dataRoot, 'deploy-targets.json'), { defaultId: null, targets: [] }) || {};
const deployTargets = (dt.targets || []).map((t) => ({ id: t.id, name: t.name, type: t.type, baseDir: t.baseDir || null, publicBaseUrl: t.publicBaseUrl || null, isDefault: t.id === dt.defaultId }));
const ms = readJson(join(dataRoot, 'mail-servers.json'), { items: [] }) || {}; const msItems = Array.isArray(ms.items) ? ms.items : Object.values(ms.items || {});
const mailServers = msItems.map((m) => ({ id: m.id, name: m.name || null, provider: m.provider || m.kind || m.type || null, isDefault: m.id === ms.defaultId }));
const linkEntries = Object.entries(links).map(([projectId, l]) => ({ projectId, siteId: l.siteId, groupId: l.groupId }));
const rev = appDir ? (readJson(join(appDir, 'REVISION.json')) || null) : null; // HAL 이 설치한 모듈은 앱 디렉터리(current/)에 REVISION.json 을 둔다
const config = readJson(join(dataRoot, 'config.json'), {}) || {}; const v = config.values || {};
const out = { users, projects, domains, deployTargets, mailServers, links: linkEntries,
config: { publicBaseUrl: v.service?.publicBaseUrl || null, sourceProvider: v.source?.provider || null, mailProvider: v.mail?.provider || null, portalUrl: v.ariweb?.portalUrl || null }, revision: rev };
assertNoSecrets(out); return out;
}
/** 포털 DB(읽기 전용) inventory. */
export function readPortal(dbFile) {
const { DatabaseSync } = requireSqlite(); const d = new DatabaseSync(dbFile, { readOnly: true }); const all = (sql) => d.prepare(sql).all();
const out = {
users: all('SELECT id, email, display_name, status, is_operator, created_at FROM users').map((u) => ({ id: u.id, email: maskEmail(u.email), displayName: u.display_name, status: u.status, isOperator: !!u.is_operator, createdAt: u.created_at })),
groups: all('SELECT id, name, kind, owner_user_id, status FROM groups').map((g) => ({ id: g.id, name: g.name, kind: g.kind, ownerUserId: g.owner_user_id, status: g.status })),
sites: all('SELECT id, group_id, name, webmaker_project_id, lifecycle, deploy_mode, primary_domain, server_id, current_version FROM sites').map((s) => ({ id: s.id, groupId: s.group_id, name: s.name, webmakerProjectId: s.webmaker_project_id, lifecycle: s.lifecycle, deployMode: s.deploy_mode, primaryDomain: s.primary_domain, serverId: s.server_id, currentVersion: s.current_version })),
domains: all('SELECT id, group_id, site_id, name, purpose, is_primary, ownership_state, dns_state FROM domains').map((x) => ({ id: x.id, groupId: x.group_id, siteId: x.site_id, name: x.name, purpose: x.purpose, isPrimary: !!x.is_primary, ownershipState: x.ownership_state, dnsState: x.dns_state })),
mailServices: all('SELECT id, group_id, site_id, kind, domain, status FROM mail_services').map((m) => ({ id: m.id, groupId: m.group_id, siteId: m.site_id, kind: m.kind, domain: m.domain, status: m.status })),
deployments: all('SELECT id, site_id, status, version_label, created_at FROM deployments').map((x) => ({ id: x.id, siteId: x.site_id, status: x.status, version: x.version_label, createdAt: x.created_at })),
};
d.close(); assertNoSecrets(out); return out;
}
/** HAL DB(읽기 전용) inventory — 서버·Agent·모듈 등록부·릴리스. */
export function readHal(dbFile) {
const { DatabaseSync } = requireSqlite(); const d = new DatabaseSync(dbFile, { readOnly: true }); const all = (sql) => d.prepare(sql).all();
const out = {
servers: all('SELECT id, name, role, location, status, agent_id, agent_version, last_seen_at FROM servers').map((s) => ({ id: s.id, name: s.name, role: s.role, location: s.location, status: s.status, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at })),
agents: all('SELECT id, server_id, kind, status, version, last_seen_at FROM agents').map((a) => ({ id: a.id, serverId: a.server_id, kind: a.kind, status: a.status, version: a.version, lastSeenAt: a.last_seen_at })),
modules: all('SELECT name, target_version FROM modules'),
moduleTargets: all('SELECT module, server_id, target_version, actual_version, health, active, last_check_at, last_error FROM module_targets').map((m) => ({ module: m.module, serverId: m.server_id, targetVersion: m.target_version, actualVersion: m.actual_version, health: m.health, active: !!m.active, lastCheckAt: m.last_check_at, lastError: m.last_error })),
releases: all('SELECT version, main_commit, status FROM releases').map((r) => ({ version: r.version, mainCommit: r.main_commit, status: r.status })),
bootstrap: (all('SELECT release_version, completed_at FROM bootstrap_state WHERE id=1')[0] || null),
opsAccounts: all('SELECT count(*) AS c FROM ops_accounts')[0].c,
};
d.close(); assertNoSecrets(out); return out;
}
/** dry-run: 세 inventory 를 안정 식별자로 대조해 판정과 충돌·누락·중복 목록을 만든다(아무것도 쓰지 않는다). */
export function dryRun({ webmaker, portal = null, hal = null, giteaRefs = null }) {
const findings = []; const add = (severity, kind, detail) => findings.push({ severity, kind, ...detail });
const portalSites = portal ? portal.sites : []; const byProject = new Map();
for (const s of portalSites) if (s.webmakerProjectId) { if (!byProject.has(s.webmakerProjectId)) byProject.set(s.webmakerProjectId, []); byProject.get(s.webmakerProjectId).push(s); }
const projects = webmaker.projects.map((p) => {
const ps = byProject.get(p.id) || []; let verdict;
if (ps.length > 1) { verdict = 'duplicate'; add('conflict', 'duplicate_portal_sites', { projectId: p.id, siteIds: ps.map((s) => s.id) }); }
else if (ps.length === 1 && p.link) { if (p.link.siteId === ps[0].id && p.link.groupId === ps[0].groupId) verdict = 'linked_consistent'; else { verdict = 'link_mismatch'; add('conflict', 'link_mismatch', { projectId: p.id, webmakerLink: p.link.siteId, portalSite: ps[0].id }); } }
else if (ps.length === 1 && !p.link) { verdict = 'link_portal_only'; add('conflict', 'link_portal_only', { projectId: p.id, portalSite: ps[0].id }); }
else if (!ps.length && p.link) { verdict = portal ? 'link_webmaker_only' : 'link_unverified'; if (portal) add('conflict', 'link_webmaker_only', { projectId: p.id, webmakerLink: p.link.siteId }); }
else verdict = 'unlinked_native';
return { projectId: p.id, ownerId: p.ownerId, verdict, portalSiteId: ps[0]?.id || null, portalGroupId: ps[0]?.groupId || null, domains: p.domains, deployed: p.deployments > 0, action: verdict === 'linked_consistent' ? 'none' : verdict === 'unlinked_native' ? 'keep (가져오기는 포털 계정 필요 — 사람 결정)' : 'human_review' };
});
const known = new Set(webmaker.projects.map((p) => p.id));
for (const s of portalSites) if (s.webmakerProjectId && !known.has(s.webmakerProjectId)) add('missing', 'missing_project', { siteId: s.id, projectId: s.webmakerProjectId });
// 회원: SSO 로그인(aw-<g8>-<u8>)은 포털 그룹·사용자 접두어와 대조, 고유 회원은 포털 신원 없음(변경 대상 아님)
const g8 = new Map((portal ? portal.groups : []).map((g) => [g.id.replace(/^g_/, '').slice(0, 8).toLowerCase(), g.id])); const u8 = new Map((portal ? portal.users : []).map((u) => [u.id.replace(/^u_/, '').slice(0, 8).toLowerCase(), u.id]));
const users = webmaker.users.map((u) => { const m = /^aw-([0-9a-f]{8})-([0-9a-f]{8})$/.exec(u.id); if (!m) return { userId: u.id, role: u.role, verdict: 'native_no_portal_identity', action: 'keep' }; const gid = g8.get(m[1]) || null, uid = u8.get(m[2]) || null; if (!portal) return { userId: u.id, role: u.role, verdict: 'sso_unverified', action: 'none' }; if (gid && uid) return { userId: u.id, role: u.role, verdict: 'sso_consistent', portalGroupId: gid, portalUserId: uid, action: 'none' }; add('conflict', 'sso_identity_missing', { userId: u.id, portalGroupId: gid, portalUserId: uid }); return { userId: u.id, role: u.role, verdict: 'sso_identity_missing', action: 'human_review' }; });
// 도메인: 이름으로 대조
const pd = new Map((portal ? portal.domains : []).map((d) => [d.name.toLowerCase(), d]));
const domains = webmaker.domains.map((d) => { const p = pd.get(String(d.name).toLowerCase()); if (!portal) return { name: d.name, siteId: d.siteId, verdict: 'unverified' }; if (!p) return { name: d.name, siteId: d.siteId, verdict: 'webmaker_only', action: 'keep' }; const site = projects.find((x) => x.projectId === d.siteId); if (site && site.portalSiteId && p.siteId === site.portalSiteId) return { name: d.name, siteId: d.siteId, verdict: 'consistent', portalDomainId: p.id }; add('conflict', 'domain_site_mismatch', { name: d.name, webmakerSite: d.siteId, portalSite: p.siteId }); return { name: d.name, siteId: d.siteId, verdict: 'mismatch', action: 'human_review' }; });
// Gitea refs: 프로젝트마다 sites/<id>/main 이 있고 마지막 푸시 커밋과 같은지
let gitea = null;
if (giteaRefs) { const refs = new Map(giteaRefs.split('\n').map((l) => l.trim().split(/\s+/)).filter((x) => x.length === 2).map(([sha, ref]) => [ref.replace(/^refs\/heads\//, ''), sha])); gitea = webmaker.projects.filter((p) => p.git.changes > 0).map((p) => { const main = refs.get(`sites/${p.id}/main`) || null; const lp = p.git.lastPushed; const expect = lp ? refs.get(lp.branch) || null : null; const verdict = !main ? 'main_missing' : lp && expect !== lp.commit ? 'last_push_mismatch' : 'ok'; if (verdict !== 'ok') add('missing', 'gitea_ref', { projectId: p.id, verdict, main, lastPushed: lp }); return { projectId: p.id, main, verdict }; }); }
// HAL: webmaker 모듈 등록(서버·버전·건강)과 설치 REVISION 대조
let halModule = null;
if (hal) { const mts = hal.moduleTargets.filter((m) => m.module === 'webmaker'); if (!mts.length) { add('missing', 'hal_module_target_missing', { module: 'webmaker' }); halModule = { registered: false }; } else { const rev = webmaker.revision; const t = mts[0]; const versionOk = !rev || rev.version === t.actualVersion; if (!versionOk) add('conflict', 'hal_version_mismatch', { installed: rev.version, halActual: t.actualVersion }); if (t.health !== 'ok') add('conflict', 'hal_health', { health: t.health, error: t.lastError }); halModule = { registered: true, serverId: t.serverId, targetVersion: t.targetVersion, actualVersion: t.actualVersion, installedVersion: rev ? rev.version : null, upstreamVersion: rev ? rev.upstreamVersion : null, health: t.health, lastCheckAt: t.lastCheckAt, agentOnline: hal.agents.some((a) => a.serverId === t.serverId && a.status === 'active') }; } }
const summary = {
webmaker: { users: webmaker.users.length, usersNative: users.filter((u) => u.verdict.startsWith('native')).length, usersSso: users.filter((u) => u.verdict.startsWith('sso')).length, projects: webmaker.projects.length, projectsDeployed: webmaker.projects.filter((p) => p.deployments > 0).length, domains: webmaker.domains.length, deployTargets: webmaker.deployTargets.length, mailServers: webmaker.mailServers.length, links: webmaker.links.length },
portal: portal ? { users: portal.users.length, groups: portal.groups.length, sites: portal.sites.length, sitesLinked: portalSites.filter((s) => s.webmakerProjectId).length, domains: portal.domains.length, mailServices: portal.mailServices.length } : null,
hal: hal ? { servers: hal.servers.length, agents: hal.agents.length, moduleTargets: hal.moduleTargets.length, opsAccounts: hal.opsAccounts, bootstrapComplete: !!(hal.bootstrap && hal.bootstrap.completed_at) } : null,
verdicts: countBy(projects.map((p) => p.verdict)), conflicts: findings.filter((f) => f.severity === 'conflict').length, missing: findings.filter((f) => f.severity === 'missing').length,
};
const out = { generatedAt: new Date().toISOString(), authority: { projects: 'webmaker', accounts: 'webmaker(native)/portal(sso)', links: 'webmaker ariweb-links.json ↔ portal sites.webmaker_project_id (both must agree)', module: 'hal module_targets' }, summary, projects, users, domains, gitea, halModule, findings, applyPlan: findings.length ? 'blocked: 충돌·누락이 0 이 될 때까지 적용하지 않는다' : 'no-op: 등록부가 이미 일치한다 — 적용할 변경 없음' };
assertNoSecrets(out); return out;
}
function countBy(list) { const o = {}; for (const x of list) o[x] = (o[x] || 0) + 1; return o; }
function requireSqlite() { return process.getBuiltinModule ? process.getBuiltinModule('node:sqlite') : null; }
/** 출력 객체 어디에도 비밀처럼 보이는 키가 없어야 한다(방어선). */
export function assertNoSecrets(o, path = '') { if (!o || typeof o !== 'object') return; for (const [k, v] of Object.entries(o)) { if (SECRET.test(k) && !/^(sessionGen|isDefault|lastCheckAt)$/.test(k)) throw new Error(`비밀로 보이는 키가 출력에 있습니다: ${path}${k}`); assertNoSecrets(v, `${path}${k}.`); } }
if (import.meta.url === `file://${process.argv[1]}`) {
const dataRoot = opt('webmaker-data'); if (!dataRoot) { console.error('--webmaker-data <Webmaker 데이터 루트> 가 필요합니다'); process.exit(1); }
const webmaker = readWebmaker(dataRoot, { sitesRoot: opt('sites-root'), nginxEnabled: opt('nginx-enabled'), appDir: opt('app-dir') });
const portal = opt('portal-db') ? readPortal(opt('portal-db')) : null; const hal = opt('hal-db') ? readHal(opt('hal-db')) : null;
const giteaRefs = opt('gitea-refs') ? readFileSync(opt('gitea-refs'), 'utf8') : null;
const result = { inventory: { webmaker, portal, hal }, dryRun: dryRun({ webmaker, portal, hal, giteaRefs }) };
const text = JSON.stringify(result, null, 1);
if (opt('out')) { const { writeFileSync } = await import('node:fs'); writeFileSync(opt('out'), text, { mode: 0o600 }); }
const s = result.dryRun.summary; console.log(JSON.stringify({ summary: s, halModule: result.dryRun.halModule, findings: result.dryRun.findings, applyPlan: result.dryRun.applyPlan }, null, 1));
process.exit(s.conflicts || s.missing ? 2 : 0);
}
+81
View File
@@ -0,0 +1,81 @@
/** test/webmaker-inventory.test.mjs — 운영 Webmaker ↔ 포털 ↔ HAL inventory/dry-run 도구(scripts/webmaker-inventory.mjs) 회귀 테스트.
* 가짜 Webmaker 데이터 루트(JSON)와 실제 스키마로 만든 포털·HAL SQLite 로 판정·충돌·누락·중복·비밀 차단을 확인한다. */
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { readWebmaker, readPortal, readHal, dryRun, assertNoSecrets } from '../scripts/webmaker-inventory.mjs';
import { openDb } from '../lib/db.mjs';
import { allMigrations } from '../portal/schema.mjs';
import { MIGRATIONS as HAL_MIGRATIONS } from '../hal/schema.mjs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const P1 = 'a'.repeat(32), P2 = 'b'.repeat(32), P3 = 'c'.repeat(32), P4 = 'd'.repeat(32);
function webmakerRoot() {
const root = tmpDir('wm-inv-'); const data = join(root, 'data'); mkdirSync(join(data, 'sites'), { recursive: true }); mkdirSync(join(root, 'sites-root', P1), { recursive: true }); writeFileSync(join(root, 'sites-root', P1, 'index.html'), '<html>');
writeFileSync(join(data, 'users.json'), JSON.stringify({ icqmomo: { id: 'icqmomo', name: '오즈', salt: 'S', passHash: 'H', role: 'admin', accountType: 'domain', createdAt: '2026-10-14T00:00:00Z' }, 'aw-01a17894-01a17894': { id: 'aw-01a17894-01a17894', name: '검증', salt: 'S', passHash: 'H', role: 'user', accountType: 'domain', sso: 'ariweb' }, 'aw-deadbeef-deadbeef': { id: 'aw-deadbeef-deadbeef', name: '고아', salt: 'S', passHash: 'H', role: 'user', sso: 'ariweb' } }));
const site = (id, extra) => writeFileSync(join(data, 'sites', id + '.json'), JSON.stringify({ id, title: 'T' + id.slice(0, 2), ownerId: 'icqmomo', status: 'idle', domains: [], deployments: [], git: { changes: [] }, sessionId: 'SECRET', ...extra }));
site(P1, { domains: ['sample.example'], deployments: [{ targetId: 't1', at: '2026-10-15T07:27:00Z', url: 'https://x/' }], git: { changes: [{ no: 1, kind: 'deploy', branch: `sites/${P1}/main`, commit: '1111', pushed: true }] } });
site(P2, { ownerId: 'aw-01a17894-01a17894' }); site(P3, {}); site(P4, {});
writeFileSync(join(data, 'ariweb-links.json'), JSON.stringify({ version: 1, links: { [P2]: { siteId: 's_01a17894dd8ab3df984ae079f3', groupId: 'g_01a17894dd24bf64a49ff70600', siteName: '검증', linkedAt: 'x' }, [P4]: { siteId: 's_gone', groupId: 'g_gone' } } }));
writeFileSync(join(data, 'domains.json'), JSON.stringify({ domains: [{ name: 'sample.example', siteId: P1, ownerId: 'icqmomo', status: 'active' }], contacts: {} }));
writeFileSync(join(data, 'deploy-targets.json'), JSON.stringify({ defaultId: 't1', targets: [{ id: 't1', name: '기본', type: 'local', baseDir: '/var/www/x', publicBaseUrl: 'https://x', privateKey: 'SECRET' }] }));
writeFileSync(join(data, 'mail-servers.json'), JSON.stringify({ version: 1, items: [{ id: 'm1', name: 'AriMail', provider: 'arimail', apiToken: 'SECRET' }], defaultId: 'm1' }));
writeFileSync(join(data, 'config.json'), JSON.stringify({ version: 1, values: { service: { publicBaseUrl: 'https://wm.example' }, source: { provider: 'gitea', token: 'SECRET' } } }));
writeFileSync(join(root, 'REVISION.json'), JSON.stringify({ module: 'webmaker', version: '0.1.1', upstreamVersion: '1.7.3' }));
mkdirSync(join(root, 'nginx')); writeFileSync(join(root, 'nginx', `wh-${P1}.conf`), 'server {}');
return { root, data };
}
async function portalDb(root, rows) {
const db = openDb(join(root, 'portal.sqlite'), await allMigrations()); const now = '2026-10-26T00:00:00Z';
db.run('INSERT INTO users (id,email,display_name,status,created_at,updated_at) VALUES (?,?,?,?,?,?)', 'u_01a17894dce305ff35231f3676', 'alice@check.example', '앨리스', 'active', now, now);
db.run('INSERT INTO groups (id,name,kind,owner_user_id,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', 'g_01a17894dd24bf64a49ff70600', '팀', 'team', 'u_01a17894dce305ff35231f3676', 'active', now, now);
for (const [id, pid] of rows) db.run('INSERT INTO sites (id,group_id,name,webmaker_project_id,lifecycle,deploy_mode,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', id, 'g_01a17894dd24bf64a49ff70600', 'S', pid, 'draft', 'managed', now, now);
db.close(); return join(root, 'portal.sqlite');
}
function halDb(root, { webmakerTarget = true, health = 'ok' } = {}) {
const db = openDb(join(root, 'hal.sqlite'), HAL_MIGRATIONS); const now = '2026-10-26T00:00:00Z';
db.run('INSERT INTO servers (id,name,role,location,status,agent_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', 'srv_1', 'srv', 'control', 'x', 'ready', 'ag_1', now, now);
db.run('INSERT INTO agents (id,server_id,kind,status,version,registered_at,secret_hash) VALUES (?,?,?,?,?,?,?)', 'ag_1', 'srv_1', 'managed', 'active', '0.1.1', now, 'x');
if (webmakerTarget) db.run('INSERT INTO module_targets (module,server_id,target_version,actual_version,health,active,updated_at) VALUES (?,?,?,?,?,?,?)', 'webmaker', 'srv_1', '0.1.1', '0.1.1', health, 1, now);
db.close(); return join(root, 'hal.sqlite');
}
await test('Webmaker inventory: 비밀 없이 회원·프로젝트·도메인·배포 대상·연결 등록부를 읽는다', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data, { sitesRoot: join(root, 'sites-root'), nginxEnabled: join(root, 'nginx'), appDir: root });
eq(w.users.length, 3); eq(w.projects.length, 4); eq(w.domains.length, 1); eq(w.deployTargets.length, 1); eq(w.mailServers.length, 1); eq(w.links.length, 2);
const p1 = w.projects.find((p) => p.id === P1); eq(p1.deployments, 1); eq(p1.siteDirFiles, 1); eq(p1.vhost, true); eq(p1.git.lastPushed.commit, '1111'); eq(w.revision.version, '0.1.1');
ok(!JSON.stringify(w).includes('SECRET'), '비밀값이 출력에 없어야 한다'); ok(!JSON.stringify(w).includes('passHash'));
});
await test('dry-run: 양쪽 등록부 일치는 linked_consistent, 고유 프로젝트는 unlinked_native, 모두 일치하면 no-op', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data);
// P4 는 Webmaker 쪽에만 연결이 있으므로 충돌 — 이 테스트는 P4 연결을 지운 데이터로 본다
w.projects.find((p) => p.id === P4).link = null; w.links = w.links.filter((l) => l.projectId !== P4); w.users = w.users.filter((u) => u.id !== 'aw-deadbeef-deadbeef'); // 고아 SSO 회원은 충돌 테스트용
const portal = readPortal(await portalDb(root, [['s_01a17894dd8ab3df984ae079f3', P2]])); const hal = readHal(halDb(root));
const r = dryRun({ webmaker: w, portal, hal, giteaRefs: `1111\trefs/heads/sites/${P1}/main\n` });
eq(r.summary.verdicts.linked_consistent, 1); eq(r.summary.verdicts.unlinked_native, 3); eq(r.summary.conflicts, 0); eq(r.summary.missing, 0);
eq(r.users.find((u) => u.userId === 'aw-01a17894-01a17894').verdict, 'sso_consistent'); eq(r.users.find((u) => u.userId === 'icqmomo').verdict, 'native_no_portal_identity');
eq(r.domains[0].verdict, 'webmaker_only'); eq(r.gitea[0].verdict, 'ok'); eq(r.halModule.registered, true); eq(r.halModule.agentOnline, true);
ok(r.applyPlan.startsWith('no-op'), r.applyPlan);
});
await test('dry-run: 한쪽만 있는 연결·다른 사이트를 가리키는 연결·중복·없는 프로젝트·HAL 등록 누락·Gitea ref 누락을 모두 충돌/누락으로 잡는다', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data);
const portal = readPortal(await portalDb(root, [['s_x', P2], ['s_y', P3], ['s_z', P3], ['s_ghost', 'f'.repeat(32)]])); const hal = readHal(halDb(root, { webmakerTarget: false }));
const r = dryRun({ webmaker: w, portal, hal, giteaRefs: `2222\trefs/heads/sites/${P1}/main\n` });
const kinds = r.findings.map((f) => f.kind).sort();
eq(kinds, ['duplicate_portal_sites', 'gitea_ref', 'hal_module_target_missing', 'link_mismatch', 'link_webmaker_only', 'missing_project', 'sso_identity_missing']);
eq(r.projects.find((p) => p.projectId === P2).verdict, 'link_mismatch'); eq(r.projects.find((p) => p.projectId === P3).verdict, 'duplicate'); eq(r.projects.find((p) => p.projectId === P4).verdict, 'link_webmaker_only');
ok(r.applyPlan.startsWith('blocked'), r.applyPlan); ok(r.summary.conflicts >= 4 && r.summary.missing >= 3);
});
await test('포털 없이(HAL 만) 돌리면 연결은 unverified 로 두고 판정하지 않는다', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data); const r = dryRun({ webmaker: w, hal: readHal(halDb(root)) });
eq(r.projects.find((p) => p.projectId === P2).verdict, 'link_unverified'); eq(r.summary.portal, null); eq(r.summary.conflicts, 0);
});
await test('assertNoSecrets 는 비밀로 보이는 키를 거부한다', async () => {
let threw = false; try { assertNoSecrets({ a: { passHash: 'x' } }); } catch (_) { threw = true; } ok(threw);
assertNoSecrets({ sessionGen: 1, isDefault: true, lastCheckAt: 'x' });
});
done();