Compare commits
24
Commits
@@ -91,6 +91,58 @@
|
||||
- 긴급 복구(§33): 사건 번호·사유·대상·허용 작업·짧은 유효 시간, 재인증 + 서로 다른 내용의 3단계 승인(한 명도 가능, 단계별 시각 기록), 모든 작업 기록, 자격 1회 표시·정상화 후 만료, 허용 범위(HAL·HAL 저장 정보·비밀정보·인증서 기반·artifact 저장소·최초 Agent 연결) 밖 작업 거부, HAL 정상화 뒤 실제/원하는 상태 비교·조정.
|
||||
- 플랫폼 재해복구(§34): HAL 운영 정보·진행 작업·로그인·비밀정보·인증서 기반·배치·서버 등록 정보의 복구 사본·외부 보관·검증·훈련 기록, RTO/RPO/주기/보존 설정과 측정값 비교, 복구 시작 별도 승인과 기존 제어 위치 중지 확인, 하나의 HAL 만 운영(임대, 같은 호스트의 죽은 프로세스는 이어받음), 훈련 성공 전 정식 운영 준비 미표시.
|
||||
|
||||
### 실제 환경 배포에서 발견한 결함 수정(2026-10-26, ariweb.net 호스트 첫 배포)
|
||||
- **Agent 실행 계정 모드**(`agent/jobs/module.mjs`, `deploy/module-as-user.sh`, `deploy/sudoers/ariweb-agent`): 도입 모듈 AriMail 은 서버에서 다른 계정(`arihakdang`)으로 운영되어 `/srv/arihakdang-mail` 을 Agent(master)가 쓸 수 없었다. 유닛의 `User=` 가 Agent 계정과 다르면 풀기·REVISION·current 전환·정리·DB 백업·마이그레이션을 `sudo -n -u <계정> bash deploy/module-as-user.sh` 로 그 계정이 수행한다(sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용, 경로는 모듈 디렉터리 안으로 제한). artifact 는 `<srv>/agent/work` 에 받는다.
|
||||
- **AriMail 마이그레이션 DB 경로**: `server/db-migrate.mjs` 는 `DB_FILE` 환경이 없으면 릴리스 디렉터리 안에 빈 DB 를 만든다. 유닛의 `EnvironmentFile`(`/etc/<유닛>/gateway.env`)을 실행 계정으로 읽어 실행하도록 고쳤다(직접 모드도 같은 파일을 읽는다).
|
||||
- **도입 모듈 실제 버전**: Webmaker(`package.json` 1.7.3)·AriMail(1.0.11)의 상류 버전이 HAL 의 목표 버전(0.1.1)과 달라 `version_mismatch` 로 배포가 실패했을 것이다. 풀 때 적는 `REVISION.json` 의 Suite 릴리스 버전을 실제 버전으로 읽고 상류 버전은 `upstreamVersion` 으로 함께 보고한다.
|
||||
- **기존 유닛 보존**(`hal/modules/index.mjs` `keepExistingUnit`): Webmaker 의 운영 유닛은 `ProtectSystem=strict`·`ReadWritePaths` 등 강화 지시문을 갖는데 템플릿이 Environment 줄만 보존하고 나머지를 지웠을 것이다. 유닛이 이미 `<appDir>/current` 를 가리키면 다시 쓰지 않는다.
|
||||
- **포털 nginx 접근 경로**(`deploy/bootstrap-hal.sh`): `deploy/nginx/ariweb-portal.conf.tmpl` 이 어디서도 적용되지 않아 `my.ariweb.net` 이 열리지 않았다. 부트스트랩 4단계(접근 경로 준비)에서 HAL 과 함께 설치한다.
|
||||
- **부트스트랩 인증서 확인**(`deploy/bootstrap-hal.sh`): `/etc/letsencrypt/live` 는 root 만 읽을 수 있어 일반 계정의 `[ -f ]` 가 항상 거짓이었다(실제 부트스트랩이 4단계에서 중단). `sudo -n test -f` 로 확인한다.
|
||||
- **포털 artifact 에 Catalog 누락**(`scripts/release.mjs`): 상품정책 시드 파일 `catalog/release-0.1.1.json` 이 portal artifact 에 들어가지 않아 운영자 시드(`/internal/ops/commerce/catalog/seed`)가 실서버에서 실패했을 것이다. portal 경로에 `catalog` 를 넣었다.
|
||||
- **artifact 디렉터리 모드**(`scripts/release.mjs`): 개발 PC 의 umask(077)가 artifact 에 0700 디렉터리로 들어가 서버에서 다른 실행 계정이 Agent 의 `deploy/module-as-user.sh` 와 풀린 릴리스를 읽지 못했다. `tar --mode=u=rwX,go=rX --owner=0 --group=0` 로 정규화한다. `deploy/agent-install.sh` 도 호출자의 umask 와 무관하게 코드 트리를 `a+rX` 로 맞춘다.
|
||||
- **실행 계정 모드 보완**(`agent/jobs/module.mjs`): 내려받은 artifact(0600)를 실행 계정이 읽도록 0644 로 바꾸고, 도우미 스크립트는 sudoers 규칙과 같은 `<srv>/agent/current/deploy/module-as-user.sh` 경로로 부른다(ESM 의 `import.meta.url` 은 `releases/<ver>` 실제 경로로 풀린다).
|
||||
- **같은 버전 재설치**(`agent/jobs/module.mjs`, `module-as-user.sh rename`): 실행 중인 current 디렉터리를 지우고 다시 풀던 것을, 임시 디렉터리에 풀고 기존 디렉터리를 `<ver>.prev-<시각>` 으로 보존(되돌리기 대상)한 뒤 제자리에 두도록 바꿨다. DB 백업 파일 이름은 같은 초에 겹치지 않게 한다.
|
||||
- **Webmaker 데이터 백업**(`agent/jobs/module.mjs`): 운영 데이터 디렉터리의 읽을 수 없는 파일(root 가 남긴 마이그레이션 기록)이 tar 를 실패시켜 배포가 막혔다. `--ignore-failed-read` 로 경고만 남기고(결과 `unreadable` 목록) 진행한다. ariweb.net 호스트의 해당 파일(`/data/webmaker/migrations`)은 소유권을 master 로 바로잡았다.
|
||||
- **부트스트랩 릴리스 기록**(`hal/core.mjs setBootstrap`): HAL 기동 전 CLI `bootstrap-release` 가 쓴 UPDATE 가 행(id=1)이 없어 무효였다(건강 응답의 `releaseVersion` 이 null). 행을 먼저 만든다(`test/hal.test.mjs` 회귀 테스트).
|
||||
- **AriMail 마이그레이션의 EnvironmentFile 읽기**(`deploy/module-as-user.sh migrate`): systemd 문법(`MAIL_FROM_NAME=Ari Hak dang` 처럼 따옴표 없는 공백 값)을 bash 로 소스하다 실패했다. 줄 단위로 읽어 따옴표를 벗기고 export 한다.
|
||||
- **사후 절차 실패 시 되돌리기**(`agent/jobs/module.mjs`): current 전환 뒤 마이그레이션이 실패하면 current 가 새 릴리스를 가리킨 채 남았다(다음 재시작 때 마이그레이션 없는 새 코드가 뜬다). 실패하면 이전 릴리스로 되돌리고 재시작한다.
|
||||
- **AriMail 전체 설정 API 주소**(`portal/mail/arimail.mjs` 주석·운영 안내): AriMail 은 설정 API(`/api/setup/*`)를 기본 도메인 호스트(`req.headers.host`, 예: `mail.arihakdang.com`)에서만 응답하고 다른 테넌트 호스트로는 404 를 준다. Node fetch 는 Host 헤더를 바꿀 수 없으므로(시도했다가 되돌림) 포털의 설정 주소(`setup_url`)는 기본 도메인 이름이어야 하고, 그 이름이 설정 포트(loopback 전용 8791)로 풀려야 한다 — ariweb.net 호스트에서는 `/etc/hosts` 에 `127.0.0.1 mail.arihakdang.com` 을 두었다(알려진 제한에 기록).
|
||||
- **Agent heartbeat 의 모듈 버전**(`agent/index.mjs`): `current/package.json` 버전(상류 1.7.3·1.0.11)을 보고해 HAL 이 배포 직후 "목표와 다름(drift)" 으로 표시했다. `REVISION.json` 의 Suite 버전을 먼저 읽는다.
|
||||
- **새 유닛 enable**(`agent/jobs/module.mjs`): 템플릿으로 만든 유닛(포털)을 enable 하지 않아 재부팅 뒤 뜨지 않았을 것이다. 유닛을 쓸 때 `systemctl enable` 한다.
|
||||
- **서버 한 대 운영의 역할 대체**(`hal/core.mjs pickServer`, `portal/backup/service.mjs pickStoreServer`): 첫 서버는 역할이 `control` 하나뿐이라 사이트 배포(web)·백업 저장(backup) 대상 서버를 고를 수 없었다. 요청 역할의 서버가 없으면 control 서버를 web·backup 으로도 쓴다(전용 서버가 생기면 우선). 같은 서버에 백업을 두는 것은 여전히 운영자 override 가 필요하다.
|
||||
- **초안 사이트의 백업 중지 사유**(`portal/backup/service.mjs`): 미배포(초안) 사이트의 옵션을 정기 실행이 `site_cancelled`(해지) 로 중지해 표시가 틀렸다. `site_not_deployed`(사이트 미배포) 로 구분한다.
|
||||
- 회귀 테스트: `test/agent.test.mjs` 에 실행 계정 모드(도우미 스크립트 실제 실행·경로 제한 거부)·REVISION 버전·기존 유닛 보존 검사를 추가했다.
|
||||
|
||||
|
||||
### HAL 전용 호스트 부트스트랩(2026-10-27, bear `hal21.ariweb.net`)
|
||||
- **`deploy/bootstrap-hal.sh --portal-host none`**: 포털을 다른 호스트에서 운영하는 HAL 전용 호스트에는 `my.ariweb.net` nginx 접근 경로를 만들지 않는다(이전에는 항상 만들어 포털이 없는 호스트에도 죽은 vhost 가 남았다). 기본값(`my.ariweb.net`)의 동작은 그대로다.
|
||||
- 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·기기 접근권 관문과 별도 운영자 로그인은 그대로 적용된다.
|
||||
- **기존 사이트 인벤토리**(`agent/webmaker-inventory.mjs`, HAL 서버 상세): Node Agent가 사이트 디렉터리와 Webmaker 메타데이터·배포 이력을 대조해 모든 디렉터리의 ID·제목·계정 식별자·배포 상태(`deployed`/`unpublished`/`draft`/`unknown`)·서비스 상태·도메인·크기·파일 수를 HAL에 연결한다. 메타데이터가 없는 디렉터리도 `missing`으로 남기며 총 수·수집 수·잘림 여부·스캔 오류를 함께 기록한다. 2 MiB heartbeat 제한과 1,000항목 제한을 넘는 인벤토리는 512 KiB 이하 인증 배치로 전송하고 마지막 배치에서만 원자 반영한다. 페이지 본문·프롬프트·연락처·인증정보는 전송하지 않는다.
|
||||
|
||||
|
||||
### 운영 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 이 멈춰도 메일은 그대로다.
|
||||
- **`scripts/mail-discover.mjs`**(+ `test/mail-discover.test.mjs` 2건): 원본 호스트에서 단독 실행하는 읽기 전용 discovery — 유닛 상태(`systemctl show`)·`current`/REVISION·package.json·AriMail 도메인 등록부(`data/domains.json`)·건강 API·haraka-status·게이트웨이/테넌트 DB **집계**(계정·메일 수·크기·무결성, 주소·내용 미열람)·유닛 PID 소유 리스너·nginx vhost·TLS 주체/만료·공개 DNS(1.1.1.1/8.8.8.8, 호스트 /etc/hosts 오염 방지)·certbot 훅. env/config 는 키 이름과 경로·권한만.
|
||||
- **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) 를 설치 시점에 내려받는 래퍼만 포함.
|
||||
@@ -99,11 +151,15 @@
|
||||
|
||||
### 알려진 제한
|
||||
- 로컬 E2E 검증(`docs/verification/local-e2e-2026-10-26.md`, 실제 HAL·포털·Node Agent 4대 + 가짜 Webmaker·AriMail·메일): 34개 완료 조건 중 19개 로컬 확인, 14개 부분 확인, 1개(외부 메일 왕복) 로컬 확인 불가, 결함 0. 부분 확인 항목은 실제 DNS·공인 서버·실 AriMail 왕복·시간 경과(30일 보존)가 필요한 부분이며, 실제 환경 확인 결과는 `docs/verification/` 에 별도 기록한다.
|
||||
- ariweb.net 호스트의 `/etc/hosts` 에 `127.0.0.1 hal.ariweb.net my.ariweb.net webmail.ariweb.net mail.arihakdang.com` 이 있다: 앞의 둘은 공개 DNS 생성 전 같은 서버 안의 Agent·포털이 HAL 에 닿기 위한 임시 항목이고, `mail.arihakdang.com` 은 AriMail 전체 설정 포트(8791, loopback 전용)에 기본 도메인 이름으로 접속하기 위한 항목이다(cloud-init 이 `/etc/hosts` 를 관리하므로 재부팅 뒤 확인 필요).
|
||||
- 실제 환경 검증(`docs/verification/real-env-0.1.1.md`, ariweb.net 호스트, 2026-10-26): 34개 중 실제 확인 7(가입·확인 메일, 그룹·초대, 주문·승인·권리, 초안 사이트, 독립 메일 서비스, HAL 우선 배포·HAL 작업 배포, HAL 재시작 유지), 부분 확인 7(Webmaker 진입, 관리형 배포 요청, 사이트 화면, 대표 메일·메일함, 백업 옵션, 모듈 갱신, 감사 일치), 미수행 20(공개 DNS·실 도메인·수신 주소·다중 서버·시간 경과 필요), 결함 0. 출시 완료는 선언하지 않는다.
|
||||
- DNS: `ariweb.net` 은 hosting.co.kr 네임서버(웹 패널)라 `hal.ariweb.net`·`my.ariweb.net` A 레코드는 운영자가 패널에서 만들어야 한다. 레코드가 생기기 전에는 로컬 `--resolve` 로만 확인된다.
|
||||
- 실제 외부 메일 왕복 검증은 운영자가 검증용 수신 주소(자동 응답 메일함)를 설정해야 완료된다.
|
||||
- AI Wallet 은 Webmaker credits 를 읽어 표시만 하며, 초과 사용 과금 라인 생성은 하지 않는다(표시·제한 해제만).
|
||||
- 메일 서비스 서버 이전은 AriMail `operating` 상태 전환 도우미(setMigrating) 가 포털에 없어 `설정 필요` 로 안내된다.
|
||||
- 별칭·전달 전용 주소 생성, 대량 .eml 내보내기(IMAP/POP3·원문 다운로드 안내로 대체), Gitea 브랜치 자동 삭제(상태만), 외부 메일 배포, 자동 결제수단은 이번 버전에서 제공하지 않는다.
|
||||
- 실제 환경(ariweb.net 호스트)은 서버 한 대(역할 control)로 운영된다. 관리형 사이트 배포·백업 저장은 control 서버를 대체 사용하고, 백업을 같은 서버에 두려면 운영자 override 가 필요하다. 서버 간 이전·외부 Agent·다른 호스트 재해복구는 서버가 늘어난 뒤 실환경에서 확인한다.
|
||||
- Webmaker 의 페이지 제작(AI 생성·저장)은 사람이 제작 화면에서 수행해야 하므로 실환경 검증에서 저장본 없는 초안 사이트까지만 자동 확인했다(관리형 배포·백업 실행·복원·외부 확인은 저장본이 생긴 뒤 수행).
|
||||
- Agent 의 사용량 수집은 Agent 가 배포한 사이트의 nginx `awstats` 로그만 집계한다(Webmaker 자체 호스팅 사이트는 Webmaker 통계 유지).
|
||||
|
||||
### 미확정 정책값
|
||||
|
||||
+22
-3
@@ -12,6 +12,7 @@ import { createSys } from './sys.mjs';
|
||||
import { createRunner } from './runner.mjs';
|
||||
import { KINDS } from './jobs/index.mjs';
|
||||
import { sample, facts } from './metrics.mjs';
|
||||
import { configuredWebmakerInventory, webmakerInventoryBatches } from './webmaker-inventory.mjs';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { hostname } from 'node:os';
|
||||
@@ -36,9 +37,27 @@ if (cmd === 'register') {
|
||||
const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
|
||||
const runner = createRunner({ kinds: KINDS, roots: cfg.roots, sys, client, dataDir: cfg.dataDir, log });
|
||||
let stopping = false; let running = 0; let lastFacts = null; let restartUnit = null;
|
||||
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/package.json 버전 */
|
||||
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); const cur = join(appDir, 'current', 'package.json'); let version = null; try { version = JSON.parse(readFileSync(cur, 'utf8')).version; } catch (_) {} const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
|
||||
async function heartbeat() { try { if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: facts(sys) }; const sites = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))).length : 0; await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: lastFacts.f, sitesHosted: sites, jobs: { running } }); } catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } }
|
||||
/** 기존 Webmaker 사이트: 운영에 필요한 식별자·상태·도메인·배포 용량만 facts에 싣고 본문·프롬프트·연락처·비밀은 제외한다. */
|
||||
function inventoryFacts(f) {
|
||||
return configuredWebmakerInventory(f);
|
||||
}
|
||||
/** 2 MiB 요청 한도와 무관하게 전체 인벤토리를 512 KiB 이하 배치로 순차 전송한다. */
|
||||
async function syncWebmakerInventory(inv) {
|
||||
if (!inv) return; const batches = webmakerInventoryBatches(inv); let offset = 0;
|
||||
for (const body of batches) { const r = await client.request('POST', '/agent/v1/inventory/webmaker', body); offset = Number(r.nextOffset); }
|
||||
if (offset !== inv.sites.length) throw new Error(`Webmaker 인벤토리 전송 수 불일치(${offset}/${inv.sites.length})`);
|
||||
}
|
||||
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/REVISION.json(없으면 package.json) 버전 */
|
||||
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); let version = null; try { version = JSON.parse(readFileSync(join(appDir, 'current', 'REVISION.json'), 'utf8')).version; } catch (_) {} if (!version) { try { version = JSON.parse(readFileSync(join(appDir, 'current', 'package.json'), 'utf8')).version; } catch (_) {} } /* 도입 모듈은 REVISION.json 의 Suite 버전(상류 package.json 버전은 목표와 다르다 — 실제 환경 결함 수정) */ const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
|
||||
async function heartbeat() { try {
|
||||
if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: inventoryFacts(facts(sys)), inventorySynced: false };
|
||||
const managed = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))) : [];
|
||||
const legacy = lastFacts.f.webmakerInventory?.sites?.map((x) => x.id) || [];
|
||||
const sites = new Set([...managed, ...legacy]).size;
|
||||
if (lastFacts.f.webmakerInventory && !lastFacts.inventorySynced) { try { await syncWebmakerInventory(lastFacts.f.webmakerInventory); lastFacts.inventorySynced = true; } catch (e) { log(`[agent] Webmaker 인벤토리 전송 실패: ${e.message}`); } }
|
||||
const heartbeatFacts = { ...lastFacts.f }; delete heartbeatFacts.webmakerInventory;
|
||||
await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: heartbeatFacts, sitesHosted: sites, jobs: { running } });
|
||||
} catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } }
|
||||
async function execute(job) { running++; try { const out = await runner.execute(job); if (out?.result?.restartAfterReport) restartUnit = out.result.restartAfterReport; } finally { running--; } if (restartUnit) { const u = restartUnit; restartUnit = null; log(`[agent] 자기 갱신 — ${u} 재시작`); setTimeout(() => sys.systemctl('restart', u, { optional: true }), 1500); } }
|
||||
// 재시작 뒤: 보고 못 한 결과 전송, 미완료 작업 재개
|
||||
await runner.flushPending(); try { const u = await client.request('GET', '/agent/v1/jobs/unfinished'); for (const j of u.jobs || []) { log(`[agent] 미완료 작업 재개 ${j.id} ${j.kind}`); execute(j); } } catch (e) { log(`[agent] 미완료 작업 조회 실패: ${e.message}`); }
|
||||
|
||||
+109
-30
@@ -5,29 +5,90 @@
|
||||
* 절차(원본 remote-update.sh / deploy.sh 의 순서를 따른다): artifact 내려받기·sha256 확인 → releases/<ver> 에 풀기 → preStop 절차(데이터 백업) → 유닛 갱신(템플릿이 있을 때) → current 전환 → postStart 절차(마이그레이션 등) → 재시작 → 건강 확인 → 실제 버전 읽기.
|
||||
* 실패하면 current 를 이전 릴리스로 되돌리고 재시작한 뒤 오류를 보고한다(명령 성공만으로 완료하지 않는다).
|
||||
* 자기 자신(agent)·HAL 갱신: 결과를 먼저 보고한 뒤 재시작하도록 restartAfterReport 를 돌려준다.
|
||||
*
|
||||
* [실제 환경 결함 수정 0.1.1] 실행 계정이 다른 도입 모듈: 유닛의 User= 가 Agent 계정과 다르면(예: AriMail 의 arihakdang) 그 디렉터리에 Agent 가 직접 쓸 수 없다.
|
||||
* 이때는 모든 파일 작업(풀기·REVISION·current 전환·정리·DB 백업·마이그레이션)을 `sudo -n -u <계정> bash deploy/module-as-user.sh …` 로 그 계정이 수행한다
|
||||
* (sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용). artifact 는 Agent 의 작업 디렉터리(<srv>/agent/work)에 받아 그 계정이 읽게 한다.
|
||||
* 마이그레이션은 유닛의 EnvironmentFile(DB_FILE 등)을 같은 계정으로 읽어 실행한다 — 없으면 릴리스 디렉터리 안에 빈 DB 를 만드는 잘못된 동작이 된다.
|
||||
* [실제 환경 결함 수정 0.1.1] 실제 버전: 도입 모듈의 package.json 버전(Webmaker 1.7.3·AriMail 1.0.11)은 Suite 릴리스 버전(0.1.1)과 다르다.
|
||||
* HAL 은 "목표 릴리스 버전 = 실제 버전" 으로 판정하므로, 풀 때 적은 REVISION.json 의 version 을 실제 버전으로 읽고 package.json 버전은 upstreamVersion 으로 함께 보고한다.
|
||||
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
|
||||
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, copyFileSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { userInfo } from 'node:os';
|
||||
import http from 'node:http';
|
||||
import { readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
|
||||
import { ROOT, readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
|
||||
|
||||
/** 내부 건강 확인 URL 호출 */
|
||||
function httpGet(url, timeoutMs = 8000) { return new Promise((resolve) => { const t0 = Date.now(); const req = http.get(url, (res) => { let body = ''; res.on('data', (c) => { if (body.length < 100000) body += c; }); res.on('end', () => resolve({ status: res.statusCode, body, ms: Date.now() - t0 })); }); req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout'))); req.on('error', (e) => resolve({ status: 0, error: e.message, ms: Date.now() - t0 })); }); }
|
||||
async function health(ctx, desc) { if (ctx.sys.stub) return { ok: true, stubbed: true }; if (!desc?.health?.url) return { ok: true, skipped: 'no_health_url' }; let last = null; for (let i = 0; i < 20; i++) { const r = await httpGet(desc.health.url); last = r; if (r.status && r.status < 500 && (desc.health.expect !== 'ok' || /"ok"\s*:\s*true|ok/.test(r.body))) return { ok: true, status: r.status, ms: r.ms }; await new Promise((s) => setTimeout(s, 1500)); } return { ok: false, status: last?.status, error: last?.error || `상태 ${last?.status}` }; }
|
||||
async function detectVersion(ctx, desc, appDir) { const cur = readLink(join(appDir, 'current')); if (desc?.version?.http && !ctx.sys.stub) { const r = await httpGet(desc.version.http); try { const j = JSON.parse(r.body); if (j[desc.version.field]) return j[desc.version.field]; } catch (_) {} } const pkg = cur ? readJson(join(cur, 'package.json'), null) : null; return pkg?.version || (cur ? cur.split('/').pop() : null); }
|
||||
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
|
||||
async function writeUnit(ctx, desc, unit, appDir, version) {
|
||||
if (!ctx.payload.unitTemplate) return { skipped: 'no_template' };
|
||||
const old = await ctx.sys.unitText(unit); const envLines = old.split('\n').filter((l) => /^Environment=/.test(l) && !/^Environment=(NODE_ENV|HOST|PORT|ARIWEB_[A-Z_]+_DATA|NODE_OPTIONS)=/.test(l)).join('\n');
|
||||
const user = (/^User=(.+)$/m.exec(old) || [])[1] || desc.user || process.env.USER || 'master'; const envFile = (/^EnvironmentFile=(.+)$/m.exec(old) || [])[1] || ''; const node = (/^ExecStart=(\S+)/m.exec(old) || [])[1] || process.execPath;
|
||||
const txt = render(ctx.payload.unitTemplate, { APP_DIR: appDir, USER: user, NODE: /node$/.test(node) ? node : process.execPath, ENV_LINES: envLines, VERSION: version, UNIT: unit, ENV_FILE: envFile });
|
||||
await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); return { written: true, preservedEnv: envLines.split('\n').filter(Boolean).length };
|
||||
/**
|
||||
* 실제 버전 읽기. 우선순위: 건강 응답의 version(HAL·포털) → current/REVISION.json 의 Suite 릴리스 버전(도입 모듈) → package.json → 디렉터리 이름.
|
||||
* 도입 모듈은 package.json 이 상류 버전이라 upstreamVersion 으로 따로 돌려준다(화면·영수증 표시용).
|
||||
*/
|
||||
async function detectVersion(ctx, desc, appDir) {
|
||||
const cur = readLink(join(appDir, 'current')); const pkg = cur ? readJson(join(cur, 'package.json'), null) : null; const rev = cur ? readJson(join(cur, 'REVISION.json'), null) : null;
|
||||
if (desc?.version?.http && !ctx.sys.stub) { const r = await httpGet(desc.version.http); try { const j = JSON.parse(r.body); if (j[desc.version.field]) return { version: j[desc.version.field], upstreamVersion: pkg?.version || null }; } catch (_) {} }
|
||||
if (rev?.version) return { version: rev.version, upstreamVersion: pkg?.version && pkg.version !== rev.version ? pkg.version : null };
|
||||
return { version: pkg?.version || (cur ? cur.split('/').pop() : null), upstreamVersion: null };
|
||||
}
|
||||
/** 유닛에서 실행 계정·EnvironmentFile·node 경로를 읽는다. 계정이 Agent 계정과 다르면 asUser 모드(파일 작업을 그 계정으로). */
|
||||
async function serviceContext(ctx, unit) {
|
||||
const txt = unit ? await ctx.sys.unitText(unit) : ''; const me = (() => { try { return userInfo().username; } catch (_) { return process.env.USER || ''; } })();
|
||||
const user = (/^User=(.+)$/m.exec(txt) || [])[1]?.trim() || null; const envFile = ((/^EnvironmentFile=(.+)$/m.exec(txt) || [])[1] || '').replace(/^-/, '').trim() || null; const node = (/^ExecStart=(\S+)/m.exec(txt) || [])[1] || null;
|
||||
return { unitText: txt, user, envFile, nodeBin: node && /node$/.test(node) ? node : null, asUser: !!(user && user !== me && user !== 'root'), me };
|
||||
}
|
||||
/** 실행 계정으로 파일 작업(deploy/module-as-user.sh). 실패하면 stderr 를 포함한 오류를 던진다. */
|
||||
async function asUser(ctx, svc, appDir, sub, args, { input = null } = {}) {
|
||||
// sudoers 규칙은 current 심링크 경로(<srv>/agent/current/deploy/module-as-user.sh)로 허용한다 — ESM 의 import.meta.url 은 실제 경로(releases/<ver>)로 풀리므로 current 경로를 우선 쓴다.
|
||||
const viaCurrent = join(ctx.roots.srv, 'agent', 'current', 'deploy', 'module-as-user.sh'); const script = existsSync(viaCurrent) ? viaCurrent : join(ROOT, 'deploy', 'module-as-user.sh');
|
||||
const r = await ctx.sys.run('sudo', ['-n', '-u', svc.user, '/usr/bin/bash', script, sub, '--app-dir', appDir, ...args], { input, timeoutMs: 600000 });
|
||||
if (r.code !== 0) throw Object.assign(new Error(`실행 계정(${svc.user})으로 ${sub} 실패: ${(r.stderr || r.stdout || '').trim().slice(0, 300) || `코드 ${r.code}`}`), { code: r.code === 1 && /sudo|password/i.test(r.stderr || '') ? 'sudo_denied' : 'as_user_failed', hint: 'deploy/sudoers/ariweb-agent 의 module-as-user.sh 규칙이 설치되어 있어야 합니다' });
|
||||
return r;
|
||||
}
|
||||
/** 파일 쓰기·심링크 전환·삭제를 모드(직접/실행 계정)에 맞게 수행 */
|
||||
const fsOps = (ctx, svc, appDir) => ({
|
||||
writeJson: async (p, v) => (svc.asUser ? asUser(ctx, svc, appDir, 'write', ['--file', p], { input: JSON.stringify(v, null, 1) }) : writeJson(p, v)),
|
||||
switchLink: async (link, target) => (svc.asUser ? asUser(ctx, svc, appDir, 'switch', ['--link', link, '--target', target]) : switchLink(link, target)),
|
||||
remove: async (dir) => (svc.asUser ? asUser(ctx, svc, appDir, 'remove', ['--dir', dir]) : rmrf(dir)),
|
||||
rename: async (from, to) => (svc.asUser ? asUser(ctx, svc, appDir, 'rename', ['--dir', from, '--target', to]) : renameSync(from, to)),
|
||||
});
|
||||
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
|
||||
async function writeUnit(ctx, desc, unit, appDir, version, svc) {
|
||||
if (!ctx.payload.unitTemplate) return { skipped: 'no_template' };
|
||||
const old = svc.unitText;
|
||||
if (desc.keepExistingUnit && new RegExp(`^WorkingDirectory=${appDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/current/?$`, 'm').test(old)) return { skipped: 'existing_unit_kept', user: svc.user };
|
||||
const envLines = old.split('\n').filter((l) => /^Environment=/.test(l) && !/^Environment=(NODE_ENV|HOST|PORT|ARIWEB_[A-Z_]+_DATA|NODE_OPTIONS)=/.test(l)).join('\n');
|
||||
const user = svc.user || desc.user || process.env.USER || 'master'; const envFile = svc.envFile || ''; const node = svc.nodeBin || process.execPath;
|
||||
const txt = render(ctx.payload.unitTemplate, { APP_DIR: appDir, USER: user, NODE: node, ENV_LINES: envLines, VERSION: version, UNIT: unit, ENV_FILE: envFile });
|
||||
await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); const en = await ctx.sys.systemctl('enable', unit, { optional: true }); /* 새로 만든 유닛은 재부팅 뒤에도 뜨도록 enable(실제 환경 결함 수정: 포털 유닛이 disabled 였다) */ return { written: true, enabled: en.code === 0, preservedEnv: envLines.split('\n').filter(Boolean).length };
|
||||
}
|
||||
/** EnvironmentFile(KEY=값 줄) 읽기 — 직접 모드의 마이그레이션에 싣는다. 값은 돌려주되 로그에는 쓰지 않는다. */
|
||||
function readEnvFile(p) { const out = {}; if (!p || !existsSync(p)) return out; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); if (!m) continue; let v = m[2].trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); out[m[1]] = v; } return out; }
|
||||
/** preStop/postStart 절차 */
|
||||
async function hook(ctx, name, { appDir, cur, unit }) {
|
||||
if (name === 'webmaker.backupData') { const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(await ctx.sys.unitText(unit)) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true }); await ctx.sys.tar(['-czf', out, '-C', dataRoot, '--exclude=./backups', '.']); const old = readdirSync(join(appDir, 'backups')).sort(); for (const f of old.slice(0, Math.max(0, old.length - 5))) rmSync(join(appDir, 'backups', f), { force: true }); return { backup: out }; }
|
||||
if (name === 'arimail.backupDb') { const db = join(appDir, 'data', 'mail-gateway.sqlite'); if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out}'`); } finally { d.close(); } return { backup: out }; }
|
||||
if (name === 'arimail.migrate') { const db = join(appDir, 'data', 'mail-gateway.sqlite'); const r = await ctx.sys.run(process.execPath, [join(cur, 'server', 'db-migrate.mjs')], { allowStub: false, timeoutMs: 120000 }).catch((e) => ({ code: 1, stderr: e.message })); if (!ctx.sys.stub && !existsSync(join(cur, 'server', 'db-migrate.mjs'))) return { skipped: 'no_migrate' }; if (r.code !== 0 && !ctx.sys.stub) throw Object.assign(new Error(`AriMail 마이그레이션 실패: ${(r.stderr || '').slice(0, 300)}`), { code: 'migrate_failed' }); return { migrated: true, db }; }
|
||||
async function hook(ctx, name, { appDir, cur, unit, svc }) {
|
||||
if (name === 'webmaker.backupData') {
|
||||
const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(svc.unitText) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true });
|
||||
// [실제 환경 결함 수정] 운영 데이터 디렉터리에는 다른 계정(root)이 남긴 파일이나 백업 중 바뀌는 파일이 있을 수 있다. 읽지 못한 파일은 경고로 남기고 배포를 막지 않는다(tar 종료 코드 2 = 치명적 오류만 실패).
|
||||
const r = await ctx.sys.run('tar', ['-czf', out, '-C', dataRoot, '--exclude=./backups', '--ignore-failed-read', '--warning=no-file-changed', '--warning=no-file-removed', '.'], { allowStub: false });
|
||||
if (r.code !== 0 && r.code !== 1) throw new Error(`데이터 백업 실패: ${(r.stderr || '').slice(0, 300)}`);
|
||||
const warnings = (r.stderr || '').split('\n').filter((l) => /Cannot open|Permission denied|Cannot stat/.test(l)).map((l) => l.replace(/^tar: /, '')).slice(0, 20);
|
||||
if (warnings.length) ctx.log(`데이터 백업에서 읽지 못한 파일 ${warnings.length}개(경고): ${warnings[0]}`);
|
||||
const old = readdirSync(join(appDir, 'backups')).sort(); for (const f of old.slice(0, Math.max(0, old.length - 5))) rmSync(join(appDir, 'backups', f), { force: true }); return { backup: out, unreadable: warnings.length ? warnings : undefined };
|
||||
}
|
||||
if (name === 'arimail.backupDb') {
|
||||
const env = svc.asUser ? {} : readEnvFile(svc.envFile); const db = env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite'); let out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); for (let n = 1; existsSync(out); n++) out = out.replace(/(-\d+)?\.sqlite$/, `-${n}.sqlite`); // 같은 초에 두 번 설치해도 VACUUM INTO 대상이 겹치지 않게
|
||||
if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'backup-db', ['--db', db, '--out', out, ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_db' } : { backup: out, via: svc.user }; }
|
||||
if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`); } finally { d.close(); } return { backup: out };
|
||||
}
|
||||
if (name === 'arimail.migrate') {
|
||||
const script = join('server', 'db-migrate.mjs'); if (!ctx.sys.stub && !existsSync(join(cur, script))) return { skipped: 'no_migrate' };
|
||||
if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'migrate', ['--cwd', cur, '--script', script, ...(svc.envFile ? ['--env-file', svc.envFile] : []), ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_migrate' } : { migrated: true, via: svc.user, envFile: svc.envFile || null }; }
|
||||
const env = readEnvFile(svc.envFile); const r = await ctx.sys.run(svc.nodeBin || process.execPath, [join(cur, script)], { allowStub: false, timeoutMs: 120000, env: { ...process.env, ...env } }).catch((e) => ({ code: 1, stderr: e.message }));
|
||||
if (r.code !== 0 && !ctx.sys.stub) throw Object.assign(new Error(`AriMail 마이그레이션 실패: ${(r.stderr || '').slice(0, 300)}`), { code: 'migrate_failed' }); return { migrated: true, db: env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite') };
|
||||
}
|
||||
if (name === 'arimail.haraka') { const r = await ctx.sys.systemctl('restart', `${unit}-haraka`, { optional: true }); return { haraka: r.code === 0 ? 'restarted' : 'skipped' }; }
|
||||
if (name === 'webmaker.unitCheck') { const r = await ctx.sys.run(process.execPath, [join(cur, 'scripts', 'unit-check.js')], { allowStub: true, timeoutMs: 60000 }).catch(() => ({ code: 1 })); return { unitCheck: r.code === 0 ? 'ok' : 'warn' }; }
|
||||
return { skipped: `unknown hook ${name}` };
|
||||
@@ -42,36 +103,54 @@ function resolveAppDir(ctx, p) {
|
||||
if (srv !== '/srv/ariweb' && String(given).startsWith('/srv/ariweb/')) return join(srv, String(given).slice('/srv/ariweb/'.length));
|
||||
return given;
|
||||
}
|
||||
async function installRelease(ctx, { module, version, appDir, desc, unit, action, toVersion }) {
|
||||
async function installRelease(ctx, { module, version, appDir, desc, action, toVersion, svc }) {
|
||||
const relDir = join(appDir, 'releases', action === 'rollback' ? (toVersion || '') : version); const prev = readLink(join(appDir, 'current'));
|
||||
if (action === 'rollback') { const target = toVersion ? relDir : readJson(join(appDir, '.previous.json'), {}).previous; if (!target || !existsSync(target)) throw Object.assign(new Error('되돌릴 이전 릴리스가 없습니다'), { code: 'no_previous_release' }); return { relDir: target, prev }; }
|
||||
await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(join(appDir, 'work'), { recursive: true }); const f = join(appDir, 'work', `${module}-${version}.tar.gz`); const r = await ctx.client.download(ctx.payload.artifactUrl, f, ctx.payload.sha256 || null); return { file: f, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증');
|
||||
await ctx.step('extract', async () => { rmrf(relDir); mkdirSync(relDir, { recursive: true }); await untar(ctx, join(appDir, 'work', `${module}-${version}.tar.gz`), relDir); if (desc.copyPaths && !existsSync(join(relDir, 'package.json'))) throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); writeJson(join(relDir, 'REVISION.json'), { module, version, installedAt: new Date().toISOString() }); rmSync(join(appDir, 'work', `${module}-${version}.tar.gz`), { force: true }); return { dir: relDir }; }, '릴리스 풀기');
|
||||
return { relDir, prev };
|
||||
// 실행 계정 모드에서는 appDir 에 쓸 수 없으므로 Agent 의 작업 디렉터리에 받는다(그 계정이 읽을 수 있게 0644).
|
||||
const workDir = svc.asUser ? join(ctx.roots.srv, 'agent', 'work') : join(appDir, 'work'); const tgz = join(workDir, `${module}-${version}.tar.gz`);
|
||||
await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(workDir, { recursive: true }); const r = await ctx.client.download(ctx.payload.artifactUrl, tgz, ctx.payload.sha256 || null); if (svc.asUser) chmodSync(tgz, 0o644); /* 내려받기는 0600 으로 쓰므로 실행 계정이 읽게 한다(artifact 는 비밀이 아니다) */ return { file: tgz, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증');
|
||||
// [실제 환경 결함 수정] 같은 버전 재설치: 실행 중인 current 가 가리키는 디렉터리를 지우고 다시 풀면 실행 중인 서비스가 잠깐 코드를 잃는다(지연 require 실패) 데다 되돌릴 곳도 없다.
|
||||
// 임시 디렉터리에 먼저 풀고, 기존 디렉터리는 <ver>.prev-<시각> 으로 이름을 바꿔 보존한 뒤(이것이 되돌리기 대상) 새 디렉터리를 제자리에 둔다.
|
||||
let prevDir = prev;
|
||||
await ctx.step('extract', async () => {
|
||||
const ops = fsOps(ctx, svc, appDir); const tmp = `${relDir}.tmp`;
|
||||
if (svc.asUser) await asUser(ctx, svc, appDir, 'extract', ['--tgz', tgz, '--dest', tmp]); else { rmrf(tmp); mkdirSync(tmp, { recursive: true }); await untar(ctx, tgz, tmp); }
|
||||
if (desc.copyPaths && !existsSync(join(tmp, 'package.json'))) { await ops.remove(tmp).catch(() => {}); throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); }
|
||||
await ops.writeJson(join(tmp, 'REVISION.json'), { module, version, upstreamVersion: readJson(join(tmp, 'package.json'), {}).version || null, installedAt: new Date().toISOString(), by: svc.asUser ? svc.user : svc.me });
|
||||
let kept = null;
|
||||
if (existsSync(relDir)) { if (prev && prev.replace(/\/$/, '') === relDir) { const base = `${relDir}.prev-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; kept = base; for (let n = 1; existsSync(kept); n++) kept = `${base}.${n}`; await ops.rename(relDir, kept); prevDir = kept; } else await ops.remove(relDir); }
|
||||
await ops.rename(tmp, relDir);
|
||||
rmSync(tgz, { force: true }); return { dir: relDir, asUser: svc.asUser ? svc.user : null, replacedRunning: kept };
|
||||
}, '릴리스 풀기');
|
||||
return { relDir, prev: prevDir };
|
||||
}
|
||||
export const kinds = {
|
||||
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
|
||||
'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } },
|
||||
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); return { module: p.module, actualVersion: await detectVersion(ctx, desc, appDir), active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
|
||||
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); const v = await detectVersion(ctx, desc, appDir); return { module: p.module, actualVersion: v.version, upstreamVersion: v.upstreamVersion, active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
|
||||
};
|
||||
async function installOrRollback(ctx, action) {
|
||||
const p = ctx.payload; const { module, version, descriptor: desc = {}, toVersion = null } = p; const appDir = resolveAppDir(ctx, p); const unit = (desc && desc.unit) || `ariweb-${module}`;
|
||||
mkdirSync(join(appDir, 'releases'), { recursive: true });
|
||||
const { relDir, prev } = await ctx.step('place', () => installRelease(ctx, { module, version, appDir, desc, unit, action, toVersion }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치');
|
||||
const svc = await serviceContext(ctx, unit); const ops = fsOps(ctx, svc, appDir);
|
||||
if (!svc.asUser) mkdirSync(join(appDir, 'releases'), { recursive: true });
|
||||
if (svc.asUser) ctx.log(`실행 계정 모드: 유닛 ${unit} 의 User=${svc.user} 로 파일 작업을 수행합니다`);
|
||||
const { relDir, prev } = await ctx.step('place', () => installRelease(ctx, { module, version, appDir, desc, action, toVersion, svc }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치');
|
||||
await ctx.progress(40);
|
||||
for (const h of desc.preStop || []) await ctx.step(`pre:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit }), `사전 절차 ${h}`);
|
||||
await ctx.step('unit', () => writeUnit(ctx, desc, unit, appDir, version), '실행 유닛 갱신');
|
||||
await ctx.step('switch', () => { switchLink(join(appDir, 'current'), relDir); writeJson(join(appDir, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { from: prev, to: relDir }; }, 'current 전환');
|
||||
for (const h of desc.postStart || []) await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit }), `사후 절차 ${h}`);
|
||||
for (const h of desc.preStop || []) await ctx.step(`pre:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사전 절차 ${h}`);
|
||||
await ctx.step('unit', () => writeUnit(ctx, desc, unit, appDir, version, svc), '실행 유닛 갱신');
|
||||
await ctx.step('switch', async () => { await ops.switchLink(join(appDir, 'current'), relDir); await ops.writeJson(join(appDir, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { from: prev, to: relDir }; }, 'current 전환');
|
||||
const self = module === 'agent'; let healthRes = { ok: true };
|
||||
const revert = async () => { if (prev && existsSync(prev)) { await ops.switchLink(join(appDir, 'current'), prev).catch(() => {}); await ctx.sys.systemctl('restart', unit, { optional: true }); } };
|
||||
// 사후 절차(마이그레이션 등)가 실패하면 current 를 이전 릴리스로 되돌린다 — 그대로 두면 다음 재시작 때 마이그레이션 없는 새 코드가 뜬다(실제 환경 결함 수정).
|
||||
for (const h of desc.postStart || []) { try { await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사후 절차 ${h}`); } catch (e) { await revert(); throw Object.assign(e, { code: e.code || 'post_hook_failed', partial: { rolledBackTo: prev, hook: h } }); } }
|
||||
if (!self && desc.layout !== 'agent-feature') {
|
||||
try { await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '서비스 재시작'); for (const u of desc.extraUnits || []) await ctx.sys.systemctl('restart', u, { optional: true }); }
|
||||
catch (e) { if (prev && existsSync(prev)) { switchLink(join(appDir, 'current'), prev); await ctx.sys.systemctl('restart', unit, { optional: true }); } throw Object.assign(e, { code: e.code || 'restart_failed', partial: { rolledBackTo: prev } }); }
|
||||
catch (e) { await revert(); throw Object.assign(e, { code: e.code || 'restart_failed', partial: { rolledBackTo: prev } }); }
|
||||
healthRes = await ctx.step('health', () => health(ctx, desc), '건강 확인');
|
||||
if (!healthRes.ok) { if (prev && existsSync(prev)) { switchLink(join(appDir, 'current'), prev); await ctx.sys.systemctl('restart', unit, { optional: true }); } throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); }
|
||||
if (!healthRes.ok) { await revert(); throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); }
|
||||
}
|
||||
const actual = self ? readJson(join(relDir, 'package.json'), {}).version || version : await ctx.step('version', () => detectVersion(ctx, desc, appDir), '실제 버전 읽기');
|
||||
const ver = self ? { version: readJson(join(relDir, 'package.json'), {}).version || version, upstreamVersion: null } : await ctx.step('version', () => detectVersion(ctx, desc, appDir), '실제 버전 읽기');
|
||||
// 오래된 릴리스 정리(최근 3개 보존)
|
||||
const rels = readdirSync(join(appDir, 'releases')).sort(); for (const r of rels.slice(0, Math.max(0, rels.length - 3))) { const d = join(appDir, 'releases', r); if (d !== relDir && d !== prev) rmrf(d); }
|
||||
return { module, actualVersion: actual, previousVersion: prev ? prev.split('/').pop() : null, health: healthRes, active: true, restartAfterReport: self ? unit : null, releaseDir: relDir };
|
||||
const rels = readdirSync(join(appDir, 'releases')).sort(); for (const r of rels.slice(0, Math.max(0, rels.length - 3))) { const d = join(appDir, 'releases', r); if (d !== relDir && d !== prev) await ops.remove(d).catch((e) => ctx.log(`오래된 릴리스 정리 실패(${r}): ${e.message}`)); }
|
||||
return { module, actualVersion: ver.version, upstreamVersion: ver.upstreamVersion, previousVersion: prev ? prev.split('/').pop() : null, health: healthRes, active: true, restartAfterReport: self ? unit : null, releaseDir: relDir, runUser: svc.user || svc.me };
|
||||
}
|
||||
|
||||
+2
-2
@@ -13,10 +13,10 @@ export class NeedsUser extends Error { constructor(message, hint) { super(messag
|
||||
export function createSys({ stub = process.env.ARIWEB_AGENT_STUB_CMDS === '1', log = () => {} } = {}) {
|
||||
const calls = [];
|
||||
/** 명령 실행. 실패해도 예외 대신 { code, stdout, stderr } 를 돌려준다(호출자가 판단). */
|
||||
function run(cmd, args = [], { input = null, timeoutMs = 600000, allowStub = true } = {}) {
|
||||
function run(cmd, args = [], { input = null, timeoutMs = 600000, allowStub = true, env = null } = {}) {
|
||||
calls.push({ cmd, args });
|
||||
if (stub && allowStub && cmd !== 'tar' && cmd !== 'node') return Promise.resolve({ code: 0, stdout: '', stderr: '', stubbed: true });
|
||||
return new Promise((resolve) => { const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); let out = '', err = ''; const t = setTimeout(() => p.kill('SIGKILL'), timeoutMs); p.stdout.on('data', (c) => { out += c; }); p.stderr.on('data', (c) => { err += c; }); p.on('error', (e) => { clearTimeout(t); resolve({ code: 127, stdout: out, stderr: e.message }); }); p.on('close', (code) => { clearTimeout(t); resolve({ code, stdout: out, stderr: err }); }); if (input != null) p.stdin.end(input); else p.stdin.end(); });
|
||||
return new Promise((resolve) => { const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: env || process.env }); let out = '', err = ''; const t = setTimeout(() => p.kill('SIGKILL'), timeoutMs); p.stdout.on('data', (c) => { out += c; }); p.stderr.on('data', (c) => { err += c; }); p.on('error', (e) => { clearTimeout(t); resolve({ code: 127, stdout: out, stderr: e.message }); }); p.on('close', (code) => { clearTimeout(t); resolve({ code, stdout: out, stderr: err }); }); if (input != null) p.stdin.end(input); else p.stdin.end(); });
|
||||
}
|
||||
const sudo = (args, o) => run('sudo', ['-n', ...args], o);
|
||||
async function hasSudo() { if (stub) return true; const r = await run('sudo', ['-n', 'true']); return r.code === 0; }
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* agent/webmaker-inventory.mjs — 기존 Webmaker 사이트 디렉터리를 HAL 서버 인벤토리로 정리한다.
|
||||
*
|
||||
* 운영 정보만 보낸다. 페이지 본문, 프롬프트, 사용자 연락처, 인증정보와 비밀값은 결과에
|
||||
* 넣지 않는다. 사이트 작업 디렉터리도 존재할 수 있으므로 배포 이력을 함께 읽어 상태를
|
||||
* deployed/unpublished/draft/unknown으로 구분한다. 메타데이터 없는 디렉터리도 누락하지 않는다.
|
||||
*/
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const SITE_ID = /^[a-f0-9]{32}$/;
|
||||
const text = (v, max = 240) => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max) : null;
|
||||
function domainsOf(site) {
|
||||
const src = Array.isArray(site?.domains) ? site.domains : [];
|
||||
return [...new Set(src.map((d) => typeof d === 'string' ? d : d?.domain || d?.hostname || d?.host || d?.name)
|
||||
.map((d) => text(d, 253)?.toLowerCase()).filter(Boolean))].slice(0, 100);
|
||||
}
|
||||
function treeStat(root, errors, siteId) {
|
||||
let bytes = 0; let files = 0; let complete = true; const stack = [root];
|
||||
const note = (e) => { complete = false; const code = e?.code || 'read_failed'; const marker = `site:${siteId}:${code}`; if (!errors.includes(marker)) errors.push(marker); };
|
||||
while (stack.length) {
|
||||
const dir = stack.pop(); let entries = [];
|
||||
try { entries = readdirSync(dir, { withFileTypes: true }); } catch (e) { note(e); continue; }
|
||||
for (const e of entries) {
|
||||
const p = join(dir, e.name); try {
|
||||
if (e.isSymbolicLink()) continue;
|
||||
if (e.isDirectory()) stack.push(p);
|
||||
else if (e.isFile()) { bytes += lstatSync(p).size; files += 1; }
|
||||
} catch (error) { note(error); }
|
||||
}
|
||||
}
|
||||
return { bytes, files, complete };
|
||||
}
|
||||
function deploymentState(site) {
|
||||
if (!site) return 'unknown';
|
||||
if (site.unpublishedAt) return 'unpublished';
|
||||
const history = Array.isArray(site.deployments) ? site.deployments : [];
|
||||
const lastLifecycle = [...history].reverse().find((d) => d && ((d.kind === 'unpublish' && d.ok !== false) || (d.ok && d.kind !== 'unpublish' && (d.ready === true || d.phase === 'ready' || d.phase === 'transferred' || !d.phase))));
|
||||
if (lastLifecycle?.kind === 'unpublish') return 'unpublished';
|
||||
if (lastLifecycle || site.deployedAt || site.publishedAt) return 'deployed';
|
||||
return 'draft';
|
||||
}
|
||||
export function scanWebmakerInventory({ dataRoot, dataDir = null, sitesRoot, maxSites = Infinity } = {}) {
|
||||
const metaRoot = dataDir || join(dataRoot || '', 'sites');
|
||||
let dirs = []; let metadataIds = []; let complete = true; const errors = [];
|
||||
try { dirs = readdirSync(sitesRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && SITE_ID.test(e.name)).map((e) => e.name); }
|
||||
catch (e) { complete = false; errors.push(`sites:${e.code || 'read_failed'}`); }
|
||||
try { metadataIds = readdirSync(metaRoot, { withFileTypes: true }).filter((e) => e.isFile() && SITE_ID.test(e.name.replace(/\.json$/i, '')) && /\.json$/i.test(e.name)).map((e) => e.name.slice(0, -5)); } catch (e) { complete = false; errors.push(`metadata:${e.code || 'read_failed'}`); }
|
||||
const deployed = new Set(dirs); const ids = [...new Set([...dirs, ...metadataIds])].sort();
|
||||
const limit = Number.isFinite(maxSites) && maxSites >= 0 ? Math.floor(maxSites) : ids.length;
|
||||
const selected = ids.slice(0, limit);
|
||||
const sites = selected.map((id) => {
|
||||
let site = null; let metadata = 'missing';
|
||||
const metadataFile = join(metaRoot, `${id}.json`);
|
||||
try {
|
||||
site = JSON.parse(readFileSync(metadataFile, 'utf8'));
|
||||
if (!site || typeof site !== 'object' || Array.isArray(site)) { site = null; metadata = 'invalid'; const marker = `metadata:${id}:invalid_shape`; if (!errors.includes(marker)) errors.push(marker); }
|
||||
else metadata = 'ok';
|
||||
} catch (e) {
|
||||
if (e?.code === 'ENOENT' && !existsSync(metadataFile)) metadata = 'missing';
|
||||
else { metadata = 'invalid'; const marker = `metadata:${id}:${e?.code || 'invalid_json'}`; if (!errors.includes(marker)) errors.push(marker); }
|
||||
}
|
||||
const size = deployed.has(id) ? treeStat(join(sitesRoot, id), errors, id) : { bytes: 0, files: 0, complete: true };
|
||||
if (!size.complete) complete = false;
|
||||
return {
|
||||
id,
|
||||
title: text(site?.title || site?.name || id, 200),
|
||||
ownerId: text(site?.ownerId, 128),
|
||||
status: text(site?.status, 80),
|
||||
deploymentState: deploymentState(site),
|
||||
domains: domainsOf(site),
|
||||
updatedAt: text(site?.updatedAt || site?.deployedAt || site?.publishedAt, 40),
|
||||
metadata,
|
||||
bytes: size.bytes,
|
||||
files: size.files,
|
||||
};
|
||||
});
|
||||
if (sites.length < ids.length) complete = false;
|
||||
return {
|
||||
dataRoot: dataRoot || null,
|
||||
dataDir: metaRoot || null,
|
||||
sitesRoot: sitesRoot || null,
|
||||
total: ids.length,
|
||||
returned: sites.length,
|
||||
truncated: sites.length < ids.length,
|
||||
errors,
|
||||
complete,
|
||||
scannedAt: new Date().toISOString(),
|
||||
sites,
|
||||
};
|
||||
}
|
||||
|
||||
/** 운영 경로는 Agent unit에 명시적으로 주입한다. systemd 설정을 추측·재해석해 다른 데이터 루트를 스캔하지 않는다. */
|
||||
export function configuredWebmakerInventory(facts, env = process.env) {
|
||||
if (!facts?.units?.webmaker) return facts;
|
||||
const dataRoot = env.ARIWEB_WEBMAKER_DATA_ROOT; const dataDir = env.ARIWEB_WEBMAKER_DATA_DIR; const sitesRoot = env.ARIWEB_WEBMAKER_SITES_ROOT;
|
||||
if (![dataRoot, dataDir, sitesRoot].every((p) => typeof p === 'string' && p.startsWith('/'))) return { ...facts, webmakerInventory: { dataRoot: null, dataDir: null, sitesRoot: null, total: 0, returned: 0, truncated: false, errors: ['configuration:ARIWEB_WEBMAKER_DATA_ROOT_DATA_DIR_SITES_ROOT_required'], complete: false, scannedAt: new Date().toISOString(), sites: [] } };
|
||||
return { ...facts, webmakerInventory: scanWebmakerInventory({ dataRoot, dataDir, sitesRoot }) };
|
||||
}
|
||||
|
||||
/** HAL JSON 본문 상한과 무관하게 전체 사이트를 byte·항목 수 기준 배치로 나눈다. */
|
||||
export function webmakerInventoryBatches(inv, maxBytes = 512 * 1024) {
|
||||
if (!Number.isInteger(maxBytes) || maxBytes < 64 * 1024) throw new Error('인벤토리 배치 한도가 너무 작습니다');
|
||||
if (inv?.complete === false) throw new Error('inventory_incomplete');
|
||||
const sites = Array.isArray(inv?.sites) ? inv.sites : []; const rawErrors = Array.isArray(inv?.errors) ? inv.errors : []; const meta = {
|
||||
dataRoot: String(inv?.dataRoot || '').slice(0, 4096), dataDir: String(inv?.dataDir || '').slice(0, 4096), sitesRoot: String(inv?.sitesRoot || '').slice(0, 4096), total: Number.isFinite(Number(inv?.total)) ? Number(inv.total) : sites.length, truncated: !!inv?.truncated,
|
||||
errors: rawErrors.slice(0, 1000).map((x) => String(x).slice(0, 500)), errorsTruncated: rawErrors.length > 1000, scannedAt: String(inv?.scannedAt || new Date().toISOString()).slice(0, 80),
|
||||
}; const scanId = meta.scannedAt || String(Date.now()); const out = []; let offset = 0; let part = [];
|
||||
const body = (items) => ({ scanId, offset, itemTotal: sites.length, meta, sites: items });
|
||||
for (const site of sites) {
|
||||
const candidate = [...part, site]; if (candidate.length > 1000 || Buffer.byteLength(JSON.stringify(body(candidate))) > maxBytes) {
|
||||
if (!part.length) throw new Error('Webmaker 인벤토리 단일 항목이 배치 한도를 넘습니다'); out.push(body(part)); offset += part.length; part = [site]; if (Buffer.byteLength(JSON.stringify(body(part))) > maxBytes) throw new Error('Webmaker 인벤토리 단일 항목이 배치 한도를 넘습니다');
|
||||
} else part = candidate;
|
||||
}
|
||||
if (part.length || !sites.length) { const last = body(part); if (Buffer.byteLength(JSON.stringify(last)) > maxBytes) throw new Error('Webmaker 인벤토리 메타데이터가 배치 한도를 넘습니다'); out.push(last); } return out;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ if [ ! -f "$APP_DIR/releases/$VERSION/agent/index.mjs" ]; then
|
||||
# 등록 전에는 서명을 만들 수 없으므로 부트스트랩 전용 공개 경로가 아니라 등록 토큰으로 1회 받는다: HAL 의 install.sh 는 토큰과 함께 artifact 를 함께 보낸다(아래 등록 뒤 재시도 경로).
|
||||
if [ -n "$TOKEN" ]; then curl -fsS -H "X-Registration-Token: $TOKEN" "$HAL/agent/v1/bootstrap-artifact?module=agent&version=$VERSION" -o /tmp/ariweb-agent-$$.tgz; tar -xzf /tmp/ariweb-agent-$$.tgz -C "$APP_DIR/releases/$VERSION"; rm -f /tmp/ariweb-agent-$$.tgz; else echo "코드가 없고 토큰도 없습니다" >&2; exit 1; fi
|
||||
fi
|
||||
# 실행 계정이 다른 도입 모듈 설치(deploy/module-as-user.sh)를 위해 코드 트리는 다른 계정도 읽을 수 있어야 한다 — 호출자의 umask 와 무관하게 맞춘다(실제 환경 결함 수정)
|
||||
chmod 755 "$APP_DIR" "$APP_DIR/releases"; chmod -R a+rX "$APP_DIR/releases/$VERSION"
|
||||
ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"; cd "$APP_DIR/current"
|
||||
export ARIWEB_AGENT_DATA="$APP_DIR/data"
|
||||
if [ ! -f "$APP_DIR/data/agent.json" ]; then [ -n "$TOKEN" ] || { echo "등록되지 않았고 --token 도 없습니다" >&2; exit 1; }; echo "== 등록"; if [ "$EXTERNAL" = "1" ]; then $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --external --site "$SITE" --label "$LABEL"; else $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --role "$ROLE" --label "$LABEL"; fi; else echo "== 이미 등록됨($(cat "$APP_DIR/data/agent.json" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>console.log(JSON.parse(s).id))")) — 코드·유닛만 갱신"; fi
|
||||
|
||||
+12
-4
@@ -4,7 +4,8 @@
|
||||
# 그 뒤 모든 모듈(포털·Webmaker·AriMail·Agent 갱신 등)은 HAL 작업으로만 설치한다. 이 스크립트는 HAL 데이터에 부트스트랩이 완료된 흔적이 있으면 실행을 거부한다.
|
||||
#
|
||||
# 사용(대상 서버에서, sudo 가능한 일반 계정으로):
|
||||
# bash deploy/bootstrap-hal.sh --dist <dist/<version> 디렉터리(manifest.json·artifact)> --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net [--portal-host my.ariweb.net] [--app-dir /srv/ariweb/hal]
|
||||
# bash deploy/bootstrap-hal.sh --dist <dist/<version> 디렉터리(manifest.json·artifact)> --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net [--portal-host my.ariweb.net | none] [--app-dir /srv/ariweb/hal]
|
||||
# --portal-host none : 포털 접근 경로(nginx my.ariweb.net → 127.0.0.1:8910)를 만들지 않는다 — HAL 만 두는 호스트(예: hal21.ariweb.net)용.
|
||||
# 출력: 운영자 최초 설정 토큰(1회)과 첫 Agent 설치 명령. 비밀값은 그 밖에 출력하지 않는다.
|
||||
set -euo pipefail
|
||||
DIST=""; HOST="hal.ariweb.net"; CERT="/etc/letsencrypt/live/wh-wild-ariweb.net"; APP_DIR="/srv/ariweb/hal"; PORTAL_HOST="my.ariweb.net"; ACME="/var/www/letsencrypt"; USER_NAME="$(id -un)"
|
||||
@@ -37,10 +38,17 @@ echo "== 3/6 systemd 유닛 ariweb-hal"
|
||||
sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" deploy/units/ariweb-hal.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-hal.service >/dev/null
|
||||
sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent; sudo -n visudo -c >/dev/null
|
||||
sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-hal >/dev/null; sleep 2; systemctl is-active --quiet ariweb-hal || { echo "ariweb-hal 기동 실패 — journalctl -u ariweb-hal" >&2; exit 1; }
|
||||
echo "== 4/6 nginx $HOST (인증서 $CERT)"
|
||||
[ -f "$CERT/fullchain.pem" ] || { echo "인증서 $CERT/fullchain.pem 이 없습니다" >&2; exit 1; }
|
||||
echo "== 4/6 nginx $HOST$([ "$PORTAL_HOST" != none ] && echo " · $PORTAL_HOST") (인증서 $CERT)"
|
||||
sudo -n test -f "$CERT/fullchain.pem" || { echo "인증서 $CERT/fullchain.pem 이 없습니다" >&2; exit 1; } # /etc/letsencrypt/live 는 root 만 읽을 수 있어 sudo 로 확인한다(실제 환경 결함 수정)
|
||||
sed -e "s#{{HAL_HOST}}#$HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8900#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-hal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-hal.conf >/dev/null
|
||||
sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf; sudo -n nginx -t && sudo -n systemctl reload nginx
|
||||
sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf
|
||||
# 포털 접근 경로(my.ariweb.net → 127.0.0.1:8910)도 여기서 준비한다 — 포털 자체는 HAL 작업으로 설치되지만 nginx 접근 경로는 "접근 경로 준비"(§32.2 2단계)에 속한다(실제 환경 결함 수정: 이전에는 어디서도 만들지 않았다).
|
||||
# --portal-host none 이면 포털 접근 경로를 만들지 않는다(HAL 만 두는 호스트 — 포털은 다른 호스트에서 운영).
|
||||
if [ "$PORTAL_HOST" != "none" ]; then
|
||||
sed -e "s#{{PORTAL_HOST}}#$PORTAL_HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8910#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-portal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-portal.conf >/dev/null
|
||||
sudo -n ln -sf /etc/nginx/sites-available/ariweb-portal.conf /etc/nginx/sites-enabled/ariweb-portal.conf
|
||||
fi
|
||||
sudo -n nginx -t && sudo -n systemctl reload nginx
|
||||
echo "== 5/6 HAL 자체 건강 확인"
|
||||
curl -fsS http://127.0.0.1:8900/api/health | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const h=JSON.parse(s);console.log(' HAL',h.version,'db',h.db,'jobs',h.jobs,'bootstrap',JSON.stringify(h.bootstrap))})"
|
||||
curl -fsS --resolve "$HOST:443:127.0.0.1" "https://$HOST/api/health" >/dev/null && echo " https://$HOST/api/health OK (로컬 resolve)" || echo " 경고: https://$HOST 확인 실패 — DNS A 레코드($HOST → 이 서버)와 인증서를 확인하세요"
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# deploy/module-as-user.sh — 도입 모듈을 "그 모듈의 실행 계정" 으로 설치하기 위한 최소 파일 작업 도우미(§32).
|
||||
#
|
||||
# 왜 — Node Agent 는 master 계정으로 돌지만 도입 모듈(AriMail 등)은 서버에 이미 다른 계정(arihakdang 등)으로 설치되어 있고
|
||||
# 그 디렉터리(/srv/<모듈>/releases, data)는 그 계정만 쓸 수 있다. Agent 가 직접 쓰면 EACCES 로 끝나므로(실제 환경 검증에서 발견),
|
||||
# sudoers(deploy/sudoers/ariweb-agent)에 "이 스크립트만 root 가 아닌 임의 계정으로" 허용하고 Agent 가 `sudo -n -u <계정> bash <이 파일> <하위 명령>` 으로 부른다.
|
||||
# 하위 명령은 모듈 배치에 필요한 것만 두고, 모든 경로 인자는 --app-dir 아래(또는 명시한 artifact)로 제한한다. 비밀값은 다루지 않고 출력하지 않는다.
|
||||
#
|
||||
# 사용: module-as-user.sh <하위 명령> --app-dir <모듈 디렉터리> [인자…]
|
||||
# extract --tgz <artifact.tar.gz> --dest <appDir/releases/<ver>> 릴리스 디렉터리를 비우고 artifact 를 푼다
|
||||
# write --file <appDir 아래 파일> 표준 입력을 파일로 쓴다(REVISION.json·.previous.json)
|
||||
# switch --link <appDir/current> --target <릴리스 디렉터리> 심링크 원자 교체(ln → mv -T)
|
||||
# rename --dir <releases 아래 디렉터리> --target <releases 아래 새 이름> 디렉터리 이름 바꾸기(같은 버전 재설치 때 실행 중인 디렉터리 보존)
|
||||
# remove --dir <appDir/releases/<old>> 오래된 릴리스 삭제(releases 아래만)
|
||||
# backup-db --db <sqlite 파일> --out <백업 파일> [--node <node>] SQLite 온라인 백업(VACUUM INTO)
|
||||
# migrate --cwd <릴리스 디렉터리> --script <상대 경로> [--env-file <f>] [--node <node>] EnvironmentFile 을 읽어 마이그레이션 실행
|
||||
set -euo pipefail
|
||||
SUB="${1:-}"; shift || true
|
||||
APP_DIR=""; TGZ=""; DEST=""; FILE=""; LINK=""; TARGET=""; DIR=""; DB=""; OUT=""; NODE_BIN=""; CWD=""; SCRIPT=""; ENV_FILE=""
|
||||
while [ $# -gt 0 ]; do case "$1" in
|
||||
--app-dir) APP_DIR="$2"; shift 2;; --tgz) TGZ="$2"; shift 2;; --dest) DEST="$2"; shift 2;; --file) FILE="$2"; shift 2;; --link) LINK="$2"; shift 2;; --target) TARGET="$2"; shift 2;;
|
||||
--dir) DIR="$2"; shift 2;; --db) DB="$2"; shift 2;; --out) OUT="$2"; shift 2;; --node) NODE_BIN="$2"; shift 2;; --cwd) CWD="$2"; shift 2;; --script) SCRIPT="$2"; shift 2;; --env-file) ENV_FILE="$2"; shift 2;;
|
||||
*) echo "알 수 없는 인자 $1" >&2; exit 2;; esac; done
|
||||
[ -n "$APP_DIR" ] && [ -d "$APP_DIR" ] || { echo "--app-dir <존재하는 디렉터리> 가 필요합니다" >&2; exit 2; }
|
||||
APP_DIR="$(cd "$APP_DIR" && pwd -P)"
|
||||
# 경로가 appDir 아래인지(심링크를 따라간 실제 경로 기준, 아직 없는 경로는 부모 기준) 확인한다 — 다른 디렉터리를 건드리지 못하게.
|
||||
inside() { local p="$1" d real; d="$(dirname "$p")"; while [ ! -d "$d" ] && [ "$d" != "/" ]; do d="$(dirname "$d")"; done; real="$(cd "$d" && pwd -P)"; case "$real" in "$APP_DIR"|"$APP_DIR"/*) return 0;; esac; echo "경로가 모듈 디렉터리($APP_DIR) 밖입니다: $p" >&2; exit 3; }
|
||||
[ -n "$NODE_BIN" ] || NODE_BIN="$(command -v node || true)"
|
||||
case "$SUB" in
|
||||
extract)
|
||||
[ -f "$TGZ" ] || { echo "artifact 가 없습니다: $TGZ" >&2; exit 2; }; inside "$DEST"; case "$DEST" in "$APP_DIR"/releases/*) ;; *) echo "--dest 는 releases 아래여야 합니다" >&2; exit 3;; esac
|
||||
rm -rf "$DEST"; mkdir -p "$DEST"; tar -xzf "$TGZ" -C "$DEST"; echo "extracted $DEST";;
|
||||
write)
|
||||
inside "$FILE"; mkdir -p "$(dirname "$FILE")"; tmp="$FILE.tmp-$$"; cat > "$tmp"; mv -f "$tmp" "$FILE"; echo "written $FILE";;
|
||||
switch)
|
||||
inside "$LINK"; inside "$TARGET"; [ -d "$TARGET" ] || { echo "대상 디렉터리가 없습니다: $TARGET" >&2; exit 2; }
|
||||
ln -sfn "$TARGET" "$LINK.tmp-$$"; mv -Tf "$LINK.tmp-$$" "$LINK"; echo "switched $LINK -> $TARGET";;
|
||||
rename)
|
||||
inside "$DIR"; inside "$TARGET"; case "$DIR" in "$APP_DIR"/releases/*) ;; *) echo "--dir 는 releases 아래여야 합니다" >&2; exit 3;; esac; case "$TARGET" in "$APP_DIR"/releases/*) ;; *) echo "--target 은 releases 아래여야 합니다" >&2; exit 3;; esac
|
||||
mv -T "$DIR" "$TARGET"; echo "renamed $DIR -> $TARGET";;
|
||||
remove)
|
||||
inside "$DIR"; case "$DIR" in "$APP_DIR"/releases/*) ;; *) echo "--dir 는 releases 아래여야 합니다" >&2; exit 3;; esac; rm -rf "$DIR"; echo "removed $DIR";;
|
||||
backup-db)
|
||||
inside "$OUT"; [ -f "$DB" ] || { echo "skipped no_db"; exit 0; }; mkdir -p "$(dirname "$OUT")"
|
||||
# node:sqlite 의 VACUUM INTO 로 WAL 포함 일관된 사본을 만든다(원본 deploy.sh 의 python backup 과 같은 목적).
|
||||
"$NODE_BIN" --no-warnings -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync(process.argv[1],{readOnly:true});try{d.exec(\"VACUUM INTO '\"+process.argv[2].replace(/'/g,\"''\")+\"'\")}finally{d.close()}" "$DB" "$OUT"; echo "backup $OUT";;
|
||||
migrate)
|
||||
inside "$CWD"; [ -f "$CWD/$SCRIPT" ] || { echo "skipped no_migrate"; exit 0; }
|
||||
# systemd 유닛의 EnvironmentFile(DB_FILE·DATA_DIR 등)을 같은 계정으로 읽어 환경에 싣는다 — 없으면 모듈 기본 경로(릴리스 디렉터리 안)를 쓰게 되어 운영 DB 가 아닌 빈 DB 를 만드는 문제를 막는다.
|
||||
# systemd EnvironmentFile 문법(KEY=값, 값에 공백·따옴표 가능)은 bash 소스가 아니다 — 줄 단위로 읽어 따옴표를 벗기고 export 한다(실제 환경: MAIL_FROM_NAME=Ari Hak dang 에서 소스 실패).
|
||||
if [ -n "$ENV_FILE" ]; then [ -r "$ENV_FILE" ] || { echo "EnvironmentFile 을 읽을 수 없습니다: $ENV_FILE" >&2; exit 2; }
|
||||
while IFS= read -r line || [ -n "$line" ]; do line="${line%$'\r'}"; case "$line" in ''|'#'*) continue;; esac; key="${line%%=*}"; val="${line#*=}"; key="${key#export }"; key="${key// /}"
|
||||
case "$key" in *[!A-Za-z0-9_]*|'') continue;; esac; case "$val" in \"*\") val="${val#\"}"; val="${val%\"}";; \'*\') val="${val#\'}"; val="${val%\'}";; esac; export "$key=$val"; done < "$ENV_FILE"; fi
|
||||
cd "$CWD"; exec "$NODE_BIN" --no-warnings "$SCRIPT";;
|
||||
*) echo "알 수 없는 하위 명령: $SUB" >&2; exit 2;;
|
||||
esac
|
||||
@@ -7,3 +7,6 @@ master ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/sites-available/aw-*, /usr/b
|
||||
master ALL=(root) NOPASSWD: /usr/bin/ln -sf /etc/nginx/sites-available/aw-* /etc/nginx/sites-enabled/*, /usr/bin/ln -sf /etc/nginx/sites-available/ariweb-* /etc/nginx/sites-enabled/*, /usr/bin/rm -f /etc/nginx/sites-enabled/aw-*, /usr/bin/rm -f /etc/nginx/sites-available/aw-*, /usr/bin/rm -f /etc/systemd/system/ariweb-site-*
|
||||
master ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/nginx/*, /usr/bin/mkdir -p /var/www/*, /usr/bin/chown master\:master /var/www/*, /usr/bin/chmod * /etc/nginx/sites-available/aw-*, /usr/bin/chmod * /etc/systemd/system/ariweb-*, /usr/bin/chmod * /etc/nginx/conf.d/awstats.conf
|
||||
master ALL=(root) NOPASSWD: /usr/bin/true
|
||||
# 실행 계정이 다른 도입 모듈(AriMail 의 arihakdang 등) 설치: 이 도우미 스크립트만 root 가 아닌 계정으로 허용(agent/jobs/module.mjs 실행 계정 모드)
|
||||
Runas_Alias ARIWEB_SVC = ALL, !root
|
||||
master ALL=(ARIWEB_SVC) NOPASSWD: /usr/bin/bash /srv/ariweb/agent/current/deploy/module-as-user.sh *
|
||||
|
||||
+105
-3
@@ -23,18 +23,30 @@ bash /tmp/ariweb-hal-src/deploy/bootstrap-hal.sh --dist /tmp/ariweb-dist-0.1.1 -
|
||||
출력된 **운영자 설정 토큰(1회)** 으로 첫 운영자를 만든다: `POST https://hal.ariweb.net/ops/api/setup {setupToken, login, password}` (허용 네트워크 밖이면 `node hal/index.mjs grant --login <id>` 로 접근권 URL 발급).
|
||||
확인: `curl -s https://hal.ariweb.net/api/health` 의 `version`·`db`·`jobs`·`bootstrap.halRunningVersion`.
|
||||
|
||||
## 3. 첫 Node Agent(같은 서버, 역할 control+web)
|
||||
DNS 레코드가 아직 없으면 같은 서버의 Agent·포털이 `https://hal.ariweb.net` 에 닿도록 `/etc/hosts` 에 `127.0.0.1 hal.ariweb.net my.ariweb.net` 을 임시로 둔다(cloud-init 이 `/etc/hosts` 를 관리하는 호스트는 재부팅 때 지워질 수 있다 — DNS 레코드가 생기면 필요 없다).
|
||||
부트스트랩은 `my.ariweb.net` nginx 접근 경로(→ 127.0.0.1:8910)도 함께 만든다. HAL 만 두는 호스트(예: bear `hal21.ariweb.net`, 포털은 다른 호스트)는 `--portal-host none` 으로 포털 접근 경로를 만들지 않고, 인증서가 호스트별 certbot(webroot `/var/www/html`)이면 `--cert /etc/letsencrypt/live/<호스트> --acme /var/www/html` 을 준다(§7 참조).
|
||||
|
||||
## 3. 첫 Node Agent(같은 서버, 역할 control)
|
||||
운영자 콘솔 › 서버 › 새 서버(역할 control, 위치 ariweb.net) → 등록 토큰(10분·1회) →
|
||||
```bash
|
||||
curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token <토큰> --role control
|
||||
```
|
||||
운영자 승인 → `bootstrap.complete=true`, 수동 설치 잠김. 포털 연동 키: `node hal/index.mjs link-portal` (출력 값은 포털 보관소에 1회 입력).
|
||||
운영자 승인 → `bootstrap.complete=true`, 수동 설치 잠김. 모듈 기술서는 서버 역할을 배포 조건으로 두지 않으므로 한 대(control)에 portal·webmaker·arimail 을 모두 둘 수 있다.
|
||||
포털 연동 키: `node hal/index.mjs link-portal` 의 출력 값을 포털 보관소에 1회 입력한다. 포털 데이터 디렉터리(`/srv/ariweb/portal/data`)는 배포 전에 만들어 보관소를 먼저 채워도 된다(같은 `lib/vault.mjs`). 값을 화면·로그에 남기지 않으려면 서버 안에서 파이프로 바로 넣는다.
|
||||
|
||||
## 4. 모듈 배포(HAL 작업)
|
||||
운영자 콘솔 › 배포 릴리스 › 0.1.1 승인 확인 → 모듈 배포 순서: `portal` → `webmaker` → `arimail` → (`agent` 갱신). 각 작업의 대상별 결과·실제 버전·건강 상태를 확인한다. HAL 자체 건강이 실패하면 나머지를 배포하지 않는다.
|
||||
- portal: `/srv/ariweb/portal`, 유닛 `ariweb-portal`, nginx `my.ariweb.net`(와일드카드 인증서). 포털 보관소에 `service:hal`(link-portal 출력), `sso:webmaker`, `arimail:*` 키 입력.
|
||||
- webmaker: 기존 `/srv/webmaker` + `/data/webmaker` 유지(HAL 모듈 설명자가 기존 유닛명·데이터 경로를 발견해 보존), 설정 `ariweb.portalUrl`·`ariweb.secret`.
|
||||
- arimail: 기존 `/srv/arihakdang-mail` 유지, 마이그레이션 `0009_ariweb_queue.sql` 적용, 테넌트 `ariweb.net` 에 `ARIWEB_SSO_SECRET`.
|
||||
- arimail: 기존 `/srv/arihakdang-mail` 유지(실행 계정 `arihakdang` — Agent 가 `deploy/module-as-user.sh` 로 그 계정이 파일 작업을 하게 한다), 마이그레이션 `0009_ariweb_queue.sql` 적용(유닛의 `EnvironmentFile` 의 `DB_FILE`). 포털 › 운영자 › 메일 서버 등록(`POST /internal/ops/mail/servers`)에 setup-token(`<appDir>/data/setup-token`)·usage-key(기본 도메인 설정의 `USAGE_API_KEY`)를 넣고, 고객 도메인의 `ARIWEB_SSO_SECRET` 은 포털이 서비스 생성 때 스스로 넣는다.
|
||||
- 도입 모듈의 실제 버전은 `REVISION.json` 의 Suite 버전(0.1.1)이고 상류 버전(1.7.3·1.0.11)은 `upstreamVersion` 으로 함께 보인다.
|
||||
|
||||
## 4-1. 실제 배포(2026-10-26)에서 확인한 운영 사실
|
||||
- 서버가 한 대(역할 control)면 사이트 배포·백업 저장은 control 서버가 대체한다(`pickServer`). 백업을 같은 서버에 두려면 운영자 override(`/internal/ops/backup/options/:id/override-same-server`)가 필요하다.
|
||||
- 포털 메일 서버 등록(`POST /ops/api/portal/mail/servers`): `baseUrl https://webmail.ariweb.net`, `internalUrl http://127.0.0.1:8790`, `setupUrl https://mail.arihakdang.com:8791`(AriMail 전체 설정 API 는 기본 도메인 호스트에서만 응답하므로 기본 도메인 이름을 쓰고, 설정 포트가 loopback 전용이라 서버 `/etc/hosts` 에 `127.0.0.1 mail.arihakdang.com` 을 둔다), 비밀은 `setup-token`(`<appDir>/data/setup-token`)·`usage-key`(없으면 `POST /api/setup/usage-key {action:'generate'}` 로 발급, 기본 도메인 Host 로).
|
||||
- 발신 메일: 포털 보관소 `mailer:resend-key`(Webmaker 보관소의 Resend 키 재사용), 설정 `mailer: resend`, `mailFrom no-reply@ariweb.net`. 검증 때는 `resendBase` 를 로컬 포획 서버로 돌려 링크를 꺼냈고, 끝난 뒤 되돌렸다.
|
||||
- 수정 artifact 를 같은 릴리스(0.1.1)로 다시 내려면 `node hal/index.mjs register-release --manifest <manifest> --approve` 로 재등록(기존 버전은 artifact·커밋이 갱신된다)한 뒤 운영자 콘솔에서 `agent` → `hal` → 바뀐 모듈 순으로 배포한다. `bootstrap-release --commit` 으로 건강 응답의 main 커밋도 맞춘다.
|
||||
- 검증 데이터(`*@check.ariweb.net` 계정, `check-*/solo-*.ariweb.net` 도메인·메일 서비스)는 운영자 취소로 정리했고 보존 기간 뒤 삭제된다.
|
||||
|
||||
## 5. 검증(Release 0.1.1 완료 조건 34개)
|
||||
`docs/verification/` 의 로컬 E2E 결과와 실제 환경 확인 기록을 함께 둔다. 각 흐름은 버튼 성공이 아니라 최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전으로 판정한다. 충족하지 못한 흐름은 완료로 표시하지 않는다.
|
||||
@@ -42,3 +54,93 @@ curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https:/
|
||||
## 6. 되돌리기
|
||||
- 모듈: 운영자 콘솔 › 모듈 › 되돌리기(이전 릴리스 디렉터리 유지, 3개 보관).
|
||||
- HAL: `deploy/hal-restore.sh` + 재해복구 절차(§34, 기존 제어 위치 중지 확인 뒤 `--takeover`).
|
||||
|
||||
## 7. HAL 전용 호스트 부트스트랩 — bear `hal21.ariweb.net` (2026-10-27)
|
||||
대상: bear(`ssh bear`, 192.168.20.23 / 공인 121.152.149.45, master 계정, sudo NOPASSWD, Ubuntu 24.04, nginx 1.24, Node v24.22(엔진 요구 ≥22.13 충족), certbot 2.9 nginx 플러그인·`certbot.timer`). 기존 운영(Webmaker `webmaker.ibit.kr`·`hs-company.ariweb.net`, Gitea `source.ibit.kr`, Matrix, board/file/office/xmap, 기본 서버 `wh-default.conf`)은 건드리지 않는다. 이 호스트에는 **HAL 만** 둔다(포털·Webmaker·AriMail·Agent 는 다음 단계).
|
||||
|
||||
```bash
|
||||
# 개발 PC: main 이 깨끗한 상태(미추적 파일 없음)에서 빌드 — 전체 테스트 뒤 dist/0.1.1 (manifest.mainCommit = HEAD, dirty=false)
|
||||
node scripts/release.mjs build && node scripts/release.mjs verify --dist dist/0.1.1
|
||||
scp -r dist/0.1.1 bear:ariweb-deploy-0.1.1/dist
|
||||
|
||||
# bear: 호스트별 인증서(기존 인증서는 ibit.kr 7개뿐, ariweb.net 와일드카드 없음). 기본 서버가 /.well-known/acme-challenge/ 를 /var/www/html 로 서비스하므로 webroot 로 발급한다(nginx 설정을 건드리지 않음)
|
||||
sudo certbot certonly --webroot -w /var/www/html -d hal21.ariweb.net --non-interactive --keep-until-expiring
|
||||
sudo certbot reconfigure --cert-name hal21.ariweb.net --deploy-hook "systemctl reload nginx" # 갱신 뒤 reload(installer 가 없으므로)
|
||||
# bear: 부트스트랩(출력에 1회 설정 토큰이 있으므로 0600 파일로만 받는다)
|
||||
cd ~/ariweb-deploy-0.1.1 && mkdir -p -m 700 src && tar -xzf dist/hal-0.1.1.tar.gz -C src
|
||||
umask 077; bash src/deploy/bootstrap-hal.sh --dist ~/ariweb-deploy-0.1.1/dist --host hal21.ariweb.net \
|
||||
--cert /etc/letsencrypt/live/hal21.ariweb.net --acme /var/www/html --portal-host none > bootstrap.log 2>&1
|
||||
```
|
||||
- `--acme /var/www/html`: HAL vhost 의 acme 경로를 webroot 발급 경로와 같게 둔다(아니면 vhost 가 생긴 뒤 갱신 실패).
|
||||
- 만든 것: `/srv/ariweb/hal/{releases/0.1.1,current,data(0700)}`, `/var/www/ariweb-sites`, 유닛 `ariweb-hal`(enabled, 127.0.0.1:8900), nginx `sites-available/ariweb-hal.conf`(+enabled 링크), `conf.d/awstats.conf`(log_format awstats — 기존 wmstats 와 이름이 다름), `/etc/sudoers.d/ariweb-agent`(master 는 이미 NOPASSWD ALL 이라 추가 권한 없음). 포털 vhost 는 만들지 않았다.
|
||||
- 비밀값: 설정 토큰은 `/srv/ariweb/hal/data/setup.token`(0600)과 `~/ariweb-deploy-0.1.1/bootstrap.log`(0600)에만 있다. 첫 운영자 생성(`POST /ops/api/setup`)은 이 단계에서 하지 않았다 — `/ops` 는 허용 네트워크(기본 사설망 CIDR) 또는 접근권이 필요하다.
|
||||
- 알려진 사소한 결함: 5단계의 `https://<host>` 자체 확인은 `nginx reload` 직후 바로 실행되어 새 vhost 가 아직 적용되기 전에 "unrecognized name" 경고가 날 수 있다(거짓 경고, 몇 초 뒤 정상). 다음 릴리스에서 reload 뒤 짧은 대기를 넣는다.
|
||||
|
||||
검증(2026-10-27 03:08~03:12 UTC, 모두 통과): `systemctl is-active/is-enabled ariweb-hal` active/enabled · 127.0.0.1:8900 리슨(MainPID) · `nginx -t` 성공 후 reload, 기존 nginx 설정 파일 12개 sha256 변화 없음 · `https://hal21.ariweb.net/api/health` 외부(LAN 밖 fetch·LAN NAT 양쪽)에서 200, 인증서 CN=hal21.ariweb.net(Let's Encrypt, 2027-01-25 만료), HTTP→HTTPS 301 · 건강 응답 version 0.1.1·mainCommit ab75b7d·artifactSha256 f574d875…·bootstrap.complete=false(첫 Agent 전) · `systemctl restart ariweb-hal` 뒤 재기동·건강 ok(NRestarts 0, enabled 로 재부팅 지속; 서버 재부팅은 기존 서비스 중단이라 하지 않음) · 기존 서비스 회귀: nginx/webmaker/webmaker-setup/webmaker-gitea-reverse-tunnel/docker/certbot.timer active, 기존 vhost 8개 응답 코드 배포 전과 동일 · `certbot reconfigure` 의 모의 갱신 성공, `renew_hook = systemctl reload nginx`.
|
||||
|
||||
되돌리기(HAL 만 제거, 기존 서비스 영향 없음):
|
||||
```bash
|
||||
sudo systemctl disable --now ariweb-hal && sudo rm /etc/systemd/system/ariweb-hal.service && sudo systemctl daemon-reload
|
||||
sudo rm /etc/nginx/sites-enabled/ariweb-hal.conf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/conf.d/awstats.conf && sudo nginx -t && sudo systemctl reload nginx
|
||||
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명의 포털 계정 전환(사용자 자격 증명·동의 필요).
|
||||
|
||||
## 9. 기존 메일 서비스(AriMail) ↔ HAL 관리형 MailService/Node 가져오기 (2026-10-27)
|
||||
사실(읽기 전용 조사): `https://mail.arihakdang.com/` = hostinger(187.53.133.173, 유닛 `arihakdang-mail`, `/srv/arihakdang-mail`, 실행 계정 arihakdang, Ariweb Suite arimail 모듈 0.1.1 = AriMail 1.0.11, 도메인 6개 멀티테넌트) · `https://webmail.ibit.kr/` = 115.68.194.192(`ssh jitsi`, 유닛 `arimail`, `/srv/arimail`, 실행 계정 arimail, 원본 AriMail 1.0.11 `a07f69f`, 도메인 1개). **서로 다른 호스트·다른 인스턴스**다. 두 호스트 모두 Haraka 비활성(25 포트 없음), 수신 MX 는 Resend(inbound-smtp.ap-northeast-1.amazonaws.com), 제출 587/465·IMAP 143/993·POP 110/995 는 AriMail 프로세스가 직접 연다. hostinger 에는 자체 HAL(`hal.ariweb.net`)과 거기에 등록된 Node Agent 가 이미 있다(bear `hal21` 과 별개 제어 평면).
|
||||
|
||||
원칙: 기존 송수신·계정·메일·DNS/MX·TLS·provider 는 그대로. HAL 에는 **등록·감시·통제용 등록부**(`mail_services` 도메인별 논리 리소스 + `servers` 노드)만 만든다. AriMail 재시작·DNS 변경·계정/DB 변형·리스너 교체 금지.
|
||||
|
||||
```bash
|
||||
# 1) 사전 사설 스냅샷(각 원본 호스트, root 0700/0600, git 밖) — <app>/backups/pre-hal-onboard-<UTC>/ : archive.tgz(데이터·설정·유닛·env·nginx·훅, DB 제외) · db.tgz(VACUUM INTO 사본 + integrity_check) · code.tgz(current 릴리스 트리) · meta.tgz · SHA256SUMS · RESTORE.md
|
||||
# bear HAL 도 같은 모양: /srv/ariweb/backups/hal-pre-mail-onboard-<UTC>/ (hal.sqlite VACUUM 사본·vault·config·유닛·nginx·current 트리)
|
||||
# 2) 읽기 전용 discovery(원본 호스트, 파일 하나만 복사해 sudo 로 실행 — 데이터 디렉터리가 0700 이라서). 출력에 비밀값·메일 주소 없음
|
||||
sudo node scripts/mail-discover.mjs --unit arihakdang-mail.service --app-dir /srv/arihakdang-mail --etc /etc/arihakdang-mail \
|
||||
--vhost /etc/nginx/sites-available/mail.arihakdang.conf --hook /etc/letsencrypt/renewal-hooks/deploy/arihakdang-mail.sh --out discovery.json
|
||||
# 3) bear(HAL 데이터 소유자 master): 서버(노드) 만들기 + dry-run → 거부 0 이면 --apply. 같은 문서를 다시 적용하면 update(멱등)
|
||||
cd /srv/ariweb/hal/current && export ARIWEB_HAL_DATA=/srv/ariweb/hal/data
|
||||
node hal/index.mjs mail-import --discovery discovery.json --create-server --name "srv1953374 (mail.arihakdang.com)" --location hostinger --by hera # dry-run, 서버 id 출력
|
||||
node hal/index.mjs mail-import --discovery discovery.json --server <srv_id> --apply --by hera
|
||||
node hal/index.mjs mail-services # readback: counts.active = 도메인 수, duplicates [], secretLeaks 0
|
||||
# 4) (선택) Node Agent — Agent 가 없는 호스트만. 유닛 ariweb-agent(별도 프로세스, HAL 로의 outbound HMAC 만, 메일 포트·유닛 무관)
|
||||
node hal/index.mjs server-token --server <srv_id> --by hera # 1회 표시 토큰 → 원본 호스트에서 install.sh (--role mail)
|
||||
# 운영자 콘솔에서 서버 '승인' 은 사람 결정(첫 Agent 승인은 부트스트랩을 닫고 수동 설치를 잠근다 §32.2)
|
||||
```
|
||||
되돌리기: `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 전환으로 교체) — 이전 배포와 같은 방식.
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"generatedAt": "2026-10-26T13:18:11.518Z",
|
||||
"startedAt": "2026-10-26T13:15:47.881Z",
|
||||
"generatedAt": "2026-10-26T14:29:49.315Z",
|
||||
"startedAt": "2026-10-26T14:27:30.390Z",
|
||||
"env": {
|
||||
"agents": 4,
|
||||
"ports": {
|
||||
"hal": 42511,
|
||||
"portal": 32983,
|
||||
"wm": 35307,
|
||||
"am": 33813,
|
||||
"mail": 41473
|
||||
"hal": 40961,
|
||||
"portal": 39713,
|
||||
"wm": 37209,
|
||||
"am": 46099,
|
||||
"mail": 45135
|
||||
},
|
||||
"mailServerRegistered": true,
|
||||
"node": "v24.22.1"
|
||||
@@ -99,12 +99,12 @@
|
||||
{
|
||||
"name": "팀 그룹 만들기 → 현재 그룹 전환",
|
||||
"ok": true,
|
||||
"detail": "g_01a1785a66113e63a08c972cc5"
|
||||
"detail": "g_01a1789c0ba4cc346b9a816d13"
|
||||
},
|
||||
{
|
||||
"name": "이메일 초대(7일) → 초대 메일 링크",
|
||||
"ok": true,
|
||||
"detail": "uVdQrHwu9UjZmbh1PlCVMIPsJFxbHnvo"
|
||||
"detail": "92k9hZNmEVZ5s11c5YD_AKFXC9VLtCw2"
|
||||
},
|
||||
{
|
||||
"name": "초대 수락(1회용) → 별칭 user-02 · 역할 member",
|
||||
@@ -162,7 +162,7 @@
|
||||
"detail": {
|
||||
"count": 2,
|
||||
"team": {
|
||||
"id": "pv_01a1785a6358a08c3194ac03c2",
|
||||
"id": "pv_01a1789c0a26a92faf63434d5a",
|
||||
"price": 49000,
|
||||
"sites": 3
|
||||
},
|
||||
@@ -179,7 +179,7 @@
|
||||
"orderNo": "AW-20261026-0001",
|
||||
"status": "awaiting_payment",
|
||||
"total": 63800,
|
||||
"dueAt": "2026-11-02T13:15:52.839Z",
|
||||
"dueAt": "2026-11-02T14:27:35.064Z",
|
||||
"hasInstructions": true
|
||||
}
|
||||
},
|
||||
@@ -221,7 +221,7 @@
|
||||
"detail": {
|
||||
"sites": 3,
|
||||
"subs": 1,
|
||||
"nextBilling": "2026-11-26T13:15:52.905Z"
|
||||
"nextBilling": "2026-11-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -233,7 +233,7 @@
|
||||
"bill": {
|
||||
"sites": 3,
|
||||
"subs": 1,
|
||||
"nextBilling": "2026-11-26T13:15:52.905Z"
|
||||
"nextBilling": "2026-11-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -248,7 +248,7 @@
|
||||
"name": "사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "s_01a1785a670945da067e955da4",
|
||||
"id": "s_01a1789c0c9a313add86e28440",
|
||||
"lifecycle": "draft",
|
||||
"projectId": "00000000000000000000000000000001",
|
||||
"nextSteps": [
|
||||
@@ -283,7 +283,7 @@
|
||||
"name": "제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용)",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"portalRedirect": "http://127.0.0.1:35307/api/auth/sso?ticket=…",
|
||||
"portalRedirect": "http://127.0.0.1:37209/api/auth/sso?ticket=…",
|
||||
"webmaker": 302,
|
||||
"reuse": 401
|
||||
}
|
||||
@@ -299,7 +299,7 @@
|
||||
],
|
||||
"evidence": {
|
||||
"sso": {
|
||||
"portalRedirect": "http://127.0.0.1:35307/api/auth/sso?ticket=…",
|
||||
"portalRedirect": "http://127.0.0.1:37209/api/auth/sso?ticket=…",
|
||||
"webmaker": 302,
|
||||
"reuse": 401
|
||||
},
|
||||
@@ -320,7 +320,7 @@
|
||||
"name": "배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8",
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d",
|
||||
"steps": 10,
|
||||
"again": false,
|
||||
"siteCount": "reserved",
|
||||
@@ -351,7 +351,7 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"lifecycle": "needs_domain",
|
||||
"version": "20261026131553-rev-c1-000000",
|
||||
"version": "20261026142736-rev-c1-000000",
|
||||
"verify": "needs_domain",
|
||||
"health": "ok",
|
||||
"agentHasSite": true
|
||||
@@ -360,7 +360,7 @@
|
||||
],
|
||||
"evidence": {
|
||||
"d": {
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8",
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d",
|
||||
"steps": 10,
|
||||
"again": false,
|
||||
"siteCount": "reserved",
|
||||
@@ -383,7 +383,7 @@
|
||||
},
|
||||
"after": {
|
||||
"lifecycle": "needs_domain",
|
||||
"version": "20261026131553-rev-c1-000000",
|
||||
"version": "20261026142736-rev-c1-000000",
|
||||
"verify": "needs_domain",
|
||||
"health": "ok",
|
||||
"agentHasSite": true
|
||||
@@ -401,7 +401,7 @@
|
||||
"name": "도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"domainId": "d_01a1785a772932b8936927ad0a",
|
||||
"domainId": "d_01a1789c1cb665247a22f3d74a",
|
||||
"txtName": "_ariweb-verify.aw-e2e.localhost",
|
||||
"txtValueShape": "ariweb-verify=…",
|
||||
"copyable": true
|
||||
@@ -422,7 +422,7 @@
|
||||
],
|
||||
"evidence": {
|
||||
"dm": {
|
||||
"domainId": "d_01a1785a772932b8936927ad0a",
|
||||
"domainId": "d_01a1789c1cb665247a22f3d74a",
|
||||
"txtName": "_ariweb-verify.aw-e2e.localhost",
|
||||
"txtValueShape": "ariweb-verify=…",
|
||||
"copyable": true
|
||||
@@ -500,7 +500,7 @@
|
||||
"name": "사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "msv_01a1785affa395a9602c99bbb5",
|
||||
"id": "msv_01a1789ca5510f0181ed01486e",
|
||||
"status": "needs_setup",
|
||||
"tenant": true,
|
||||
"ariwebKind": "site_bound",
|
||||
@@ -517,7 +517,7 @@
|
||||
"name": "메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "mb_01a1785affda99dffac745d1e9",
|
||||
"id": "mb_01a1789ca58c25f8651b6813b4",
|
||||
"address": "kim@aw-e2e.localhost",
|
||||
"passwordShownOnce": "[redacted]",
|
||||
"mustChange": true,
|
||||
@@ -584,7 +584,7 @@
|
||||
],
|
||||
"evidence": {
|
||||
"svc": {
|
||||
"id": "msv_01a1785affa395a9602c99bbb5",
|
||||
"id": "msv_01a1789ca5510f0181ed01486e",
|
||||
"status": "needs_setup",
|
||||
"tenant": true,
|
||||
"ariwebKind": "site_bound",
|
||||
@@ -592,7 +592,7 @@
|
||||
"siteCountAllocated": 0
|
||||
},
|
||||
"mb": {
|
||||
"id": "mb_01a1785affda99dffac745d1e9",
|
||||
"id": "mb_01a1789ca58c25f8651b6813b4",
|
||||
"address": "kim@aw-e2e.localhost",
|
||||
"passwordShownOnce": "[redacted]",
|
||||
"mustChange": true,
|
||||
@@ -644,7 +644,7 @@
|
||||
"name": "메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "d_01a1785b2be9c7ee6d0d94a95c",
|
||||
"id": "d_01a1789cbfd52a398c63de3b05",
|
||||
"jobCancelled": null
|
||||
}
|
||||
},
|
||||
@@ -652,7 +652,7 @@
|
||||
"name": "독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "msv_01a1785b2bf14cefa9e1f81fd7",
|
||||
"id": "msv_01a1789cbfddf212586ba4b18d",
|
||||
"kind": "standalone",
|
||||
"mailServicesAllocated": 1,
|
||||
"sitesAllocated": 0
|
||||
@@ -661,7 +661,7 @@
|
||||
],
|
||||
"evidence": {
|
||||
"r": {
|
||||
"id": "msv_01a1785b2bf14cefa9e1f81fd7",
|
||||
"id": "msv_01a1789cbfddf212586ba4b18d",
|
||||
"kind": "standalone",
|
||||
"mailServicesAllocated": 1,
|
||||
"sitesAllocated": 0
|
||||
@@ -701,8 +701,8 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"status": "active",
|
||||
"firstDue": "2026-10-27T13:16:43.530Z",
|
||||
"nextRun": "2026-10-26T13:16:43.530Z"
|
||||
"firstDue": "2026-10-27T14:28:21.235Z",
|
||||
"nextRun": "2026-10-26T14:28:21.235Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -712,7 +712,7 @@
|
||||
"timedOut": false,
|
||||
"optionStatus": "active",
|
||||
"lastJob": null,
|
||||
"siteServer": "srv_01a1785a5d26219326f7ad26ec",
|
||||
"siteServer": "srv_01a1789c03fc9ee146184fa07c",
|
||||
"backup": {
|
||||
"status": "done",
|
||||
"tier": "monthly",
|
||||
@@ -733,14 +733,14 @@
|
||||
},
|
||||
"opt": {
|
||||
"status": "active",
|
||||
"firstDue": "2026-10-27T13:16:43.530Z",
|
||||
"nextRun": "2026-10-26T13:16:43.530Z"
|
||||
"firstDue": "2026-10-27T14:28:21.235Z",
|
||||
"nextRun": "2026-10-26T14:28:21.235Z"
|
||||
},
|
||||
"run1": {
|
||||
"timedOut": false,
|
||||
"optionStatus": "active",
|
||||
"lastJob": null,
|
||||
"siteServer": "srv_01a1785a5d26219326f7ad26ec",
|
||||
"siteServer": "srv_01a1789c03fc9ee146184fa07c",
|
||||
"backup": {
|
||||
"status": "done",
|
||||
"tier": "monthly",
|
||||
@@ -763,19 +763,19 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"backup": {
|
||||
"id": "bk_01a1785b465fad18eaeb8a679e",
|
||||
"createdAt": "2026-10-26T13:16:43.538Z",
|
||||
"id": "bk_01a1789cd5b1cc3ce60f2f4349",
|
||||
"createdAt": "2026-10-26T14:28:21.242Z",
|
||||
"scope": {
|
||||
"files": true,
|
||||
"db": true,
|
||||
"uploads": true,
|
||||
"config": true,
|
||||
"storeServerId": "srv_01a1785a5dd874fe86b67f0f38",
|
||||
"storeServerId": "srv_01a1789c04ba2129a08801860c",
|
||||
"verifyMethod": "sha256 + 격리 환경 읽기 확인"
|
||||
},
|
||||
"sizeBytes": 1896,
|
||||
"verifyState": "verified",
|
||||
"expiresAt": "2027-01-27T13:16:50.144Z",
|
||||
"expiresAt": "2027-01-27T14:28:26.674Z",
|
||||
"tier": "monthly"
|
||||
},
|
||||
"included": [
|
||||
@@ -809,7 +809,7 @@
|
||||
"name": "복원 요청 → 운영자 승인 대기 · 중복 요청 없음",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "rs_01a1785b4a2808cc16818090d9",
|
||||
"id": "rs_01a1789cd82e8d48adf5b09f5a",
|
||||
"status": "awaiting_approval",
|
||||
"dup": false,
|
||||
"finalConfirmation": {
|
||||
@@ -838,19 +838,19 @@
|
||||
"evidence": {
|
||||
"info": {
|
||||
"backup": {
|
||||
"id": "bk_01a1785b465fad18eaeb8a679e",
|
||||
"createdAt": "2026-10-26T13:16:43.538Z",
|
||||
"id": "bk_01a1789cd5b1cc3ce60f2f4349",
|
||||
"createdAt": "2026-10-26T14:28:21.242Z",
|
||||
"scope": {
|
||||
"files": true,
|
||||
"db": true,
|
||||
"uploads": true,
|
||||
"config": true,
|
||||
"storeServerId": "srv_01a1785a5dd874fe86b67f0f38",
|
||||
"storeServerId": "srv_01a1789c04ba2129a08801860c",
|
||||
"verifyMethod": "sha256 + 격리 환경 읽기 확인"
|
||||
},
|
||||
"sizeBytes": 1896,
|
||||
"verifyState": "verified",
|
||||
"expiresAt": "2027-01-27T13:16:50.144Z",
|
||||
"expiresAt": "2027-01-27T14:28:26.674Z",
|
||||
"tier": "monthly"
|
||||
},
|
||||
"included": [
|
||||
@@ -880,7 +880,7 @@
|
||||
"deferred": []
|
||||
},
|
||||
"rq": {
|
||||
"id": "rs_01a1785b4a2808cc16818090d9",
|
||||
"id": "rs_01a1789cd82e8d48adf5b09f5a",
|
||||
"status": "awaiting_approval",
|
||||
"dup": false,
|
||||
"finalConfirmation": {
|
||||
@@ -970,8 +970,8 @@
|
||||
"name": "외부 배포 사이트 생성(초안) + 저장본",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "s_01a1785c35f8d649e375f0c391",
|
||||
"group_id": "g_01a1785a66113e63a08c972cc5",
|
||||
"id": "s_01a1789dc3ada89ee43472fe26",
|
||||
"group_id": "g_01a1789c0ba4cc346b9a816d13",
|
||||
"name": "E2E 외부 사이트",
|
||||
"purpose": "e2e",
|
||||
"webmaker_project_id": "00000000000000000000000000000004",
|
||||
@@ -999,9 +999,9 @@
|
||||
"retention_until": null,
|
||||
"delete_after": null,
|
||||
"deleted_at": null,
|
||||
"created_by": "u_01a1785a65614c4deeec61ea3d",
|
||||
"created_at": "2026-10-26T13:17:51.480Z",
|
||||
"updated_at": "2026-10-26T13:17:51.485Z",
|
||||
"created_by": "u_01a1789c0afae66fc53b01be8a",
|
||||
"created_at": "2026-10-26T14:29:27.597Z",
|
||||
"updated_at": "2026-10-26T14:29:27.603Z",
|
||||
"runtime": "static",
|
||||
"consent_version": null,
|
||||
"pending_cleanup_json": null,
|
||||
@@ -1042,8 +1042,8 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"token": "[redacted]",
|
||||
"install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391",
|
||||
"expiresAt": "2026-10-26T13:27:51.761Z",
|
||||
"install": "curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26",
|
||||
"expiresAt": "2026-10-26T14:39:27.642Z",
|
||||
"status": "pending"
|
||||
}
|
||||
},
|
||||
@@ -1126,8 +1126,8 @@
|
||||
"agreed": false
|
||||
},
|
||||
"tok": {
|
||||
"install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391",
|
||||
"expiresAt": "2026-10-26T13:27:51.761Z"
|
||||
"install": "curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26",
|
||||
"expiresAt": "2026-10-26T14:39:27.642Z"
|
||||
},
|
||||
"conn": {
|
||||
"status": "connected",
|
||||
@@ -1311,7 +1311,7 @@
|
||||
"approve": 200,
|
||||
"sites": 1,
|
||||
"mailboxes": 5,
|
||||
"product": "pv_01a1785a6358a0946ad508ee8d"
|
||||
"product": "pv_01a1789c0a2573b629e03f3013"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1347,7 +1347,7 @@
|
||||
"approve": 200,
|
||||
"sites": 1,
|
||||
"mailboxes": 5,
|
||||
"product": "pv_01a1785a6358a0946ad508ee8d"
|
||||
"product": "pv_01a1789c0a2573b629e03f3013"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1430,9 +1430,9 @@
|
||||
"name": "요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "tr_01a1785c58c47561320ed6f007",
|
||||
"id": "tr_01a1789de6a46d148f37fda029",
|
||||
"status": "requested",
|
||||
"expiresAt": "2026-11-02T13:18:00.388Z",
|
||||
"expiresAt": "2026-11-02T14:29:36.548Z",
|
||||
"bobNotified": true
|
||||
}
|
||||
},
|
||||
@@ -1460,7 +1460,7 @@
|
||||
"bad": "final_confirmation_mismatch",
|
||||
"ok": 200,
|
||||
"status": "completed",
|
||||
"cutover": "2026-10-26T13:18:00.432Z"
|
||||
"cutover": "2026-10-26T14:29:36.598Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1489,9 +1489,9 @@
|
||||
"excluded": 3
|
||||
},
|
||||
"rq": {
|
||||
"id": "tr_01a1785c58c47561320ed6f007",
|
||||
"id": "tr_01a1789de6a46d148f37fda029",
|
||||
"status": "requested",
|
||||
"expiresAt": "2026-11-02T13:18:00.388Z",
|
||||
"expiresAt": "2026-11-02T14:29:36.548Z",
|
||||
"bobNotified": true
|
||||
},
|
||||
"acc": {
|
||||
@@ -1503,7 +1503,7 @@
|
||||
"bad": "final_confirmation_mismatch",
|
||||
"ok": 200,
|
||||
"status": "completed",
|
||||
"cutover": "2026-10-26T13:18:00.432Z"
|
||||
"cutover": "2026-10-26T14:29:36.598Z"
|
||||
},
|
||||
"after": {
|
||||
"aliceSees": 404,
|
||||
@@ -1664,9 +1664,9 @@
|
||||
"running": {
|
||||
"complete": true,
|
||||
"firstInstall": false,
|
||||
"releaseVersion": null,
|
||||
"mainCommit": null,
|
||||
"artifactSha256": null,
|
||||
"releaseVersion": "0.1.1",
|
||||
"mainCommit": "[redacted]",
|
||||
"artifactSha256": "[redacted]",
|
||||
"halRunningVersion": "0.1.1",
|
||||
"firstAgent": "[redacted]",
|
||||
"manualInstallLocked": true
|
||||
@@ -1726,9 +1726,9 @@
|
||||
"running": {
|
||||
"complete": true,
|
||||
"firstInstall": false,
|
||||
"releaseVersion": null,
|
||||
"mainCommit": null,
|
||||
"artifactSha256": null,
|
||||
"releaseVersion": "0.1.1",
|
||||
"mainCommit": "[redacted]",
|
||||
"artifactSha256": "[redacted]",
|
||||
"halRunningVersion": "0.1.1",
|
||||
"firstAgent": "[redacted]",
|
||||
"manualInstallLocked": true
|
||||
@@ -1781,7 +1781,7 @@
|
||||
"detail": {
|
||||
"status": "done",
|
||||
"canRetry": false,
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8"
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1806,7 +1806,7 @@
|
||||
"dep": {
|
||||
"status": "done",
|
||||
"canRetry": false,
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8"
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d"
|
||||
},
|
||||
"rt": {
|
||||
"status": 400,
|
||||
@@ -1832,7 +1832,7 @@
|
||||
"detail": {
|
||||
"title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다",
|
||||
"category": "billing",
|
||||
"link": "/orders/o_01a1785a6686ffeba5d2781add"
|
||||
"link": "/orders/o_01a1789c0c18393dba2213c118"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1848,7 +1848,7 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"status": "approved",
|
||||
"approvedAt": "2026-10-26T13:15:52.905Z"
|
||||
"approvedAt": "2026-10-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1856,7 +1856,7 @@
|
||||
"n": {
|
||||
"title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다",
|
||||
"category": "billing",
|
||||
"link": "/orders/o_01a1785a6686ffeba5d2781add"
|
||||
"link": "/orders/o_01a1789c0c18393dba2213c118"
|
||||
},
|
||||
"a": {
|
||||
"action": "order.approve",
|
||||
@@ -1864,7 +1864,7 @@
|
||||
},
|
||||
"same": {
|
||||
"status": "approved",
|
||||
"approvedAt": "2026-10-26T13:15:52.905Z"
|
||||
"approvedAt": "2026-10-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1907,9 +1907,9 @@
|
||||
"rpo_minutes": 30,
|
||||
"snapshot_interval_minutes": 60,
|
||||
"retention_count": 3,
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external",
|
||||
"updated_at": "2026-10-26T13:18:08.570Z",
|
||||
"updated_by": "op_01a1785a5c27caed1a96f25d99"
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026142730/dr-external",
|
||||
"updated_at": "2026-10-26T14:29:46.345Z",
|
||||
"updated_by": "op_01a1789c02fd72d51f469e4d25"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1969,9 +1969,9 @@
|
||||
"rpo_minutes": 30,
|
||||
"snapshot_interval_minutes": 60,
|
||||
"retention_count": 3,
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external",
|
||||
"updated_at": "2026-10-26T13:18:08.570Z",
|
||||
"updated_by": "op_01a1785a5c27caed1a96f25d99"
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026142730/dr-external",
|
||||
"updated_at": "2026-10-26T14:29:46.345Z",
|
||||
"updated_by": "op_01a1789c02fd72d51f469e4d25"
|
||||
},
|
||||
"snap": {
|
||||
"verify": "ok",
|
||||
@@ -2005,7 +2005,7 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"halJobs": 18
|
||||
@@ -2017,7 +2017,7 @@
|
||||
"detail": {
|
||||
"sessionKept": "[redacted]",
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"same": true
|
||||
@@ -2036,7 +2036,7 @@
|
||||
"evidence": {
|
||||
"before": {
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"halJobs": 18
|
||||
@@ -2044,7 +2044,7 @@
|
||||
"pr": {
|
||||
"sessionKept": "[redacted]",
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"same": true
|
||||
@@ -2075,8 +2075,8 @@
|
||||
"ok": true,
|
||||
"detail": [
|
||||
{
|
||||
"id": "s_01a1785af3ab8d0d00e0063124",
|
||||
"group_id": "g_01a1785a663da04c2c5802e990",
|
||||
"id": "s_01a1789c994d3878d056f3d2fc",
|
||||
"group_id": "g_01a1789c0bd144ad600dd3bc46",
|
||||
"name": "동시 A",
|
||||
"purpose": "e2e",
|
||||
"webmaker_project_id": "00000000000000000000000000000002",
|
||||
@@ -2104,9 +2104,9 @@
|
||||
"retention_until": null,
|
||||
"delete_after": null,
|
||||
"deleted_at": null,
|
||||
"created_by": "u_01a1785a663df67cc036d6bc02",
|
||||
"created_at": "2026-10-26T13:16:28.971Z",
|
||||
"updated_at": "2026-10-26T13:16:28.978Z",
|
||||
"created_by": "u_01a1789c0bd1e6e269d0df3589",
|
||||
"created_at": "2026-10-26T14:28:11.213Z",
|
||||
"updated_at": "2026-10-26T14:28:11.225Z",
|
||||
"runtime": "static",
|
||||
"consent_version": null,
|
||||
"pending_cleanup_json": null,
|
||||
@@ -2125,8 +2125,8 @@
|
||||
"nextJob": null
|
||||
},
|
||||
{
|
||||
"id": "s_01a1785af3aeacb2a4c3e486f3",
|
||||
"group_id": "g_01a1785a663da04c2c5802e990",
|
||||
"id": "s_01a1789c9951c330ce1ad58f13",
|
||||
"group_id": "g_01a1789c0bd144ad600dd3bc46",
|
||||
"name": "동시 B",
|
||||
"purpose": "e2e",
|
||||
"webmaker_project_id": "00000000000000000000000000000003",
|
||||
@@ -2154,9 +2154,9 @@
|
||||
"retention_until": null,
|
||||
"delete_after": null,
|
||||
"deleted_at": null,
|
||||
"created_by": "u_01a1785a663df67cc036d6bc02",
|
||||
"created_at": "2026-10-26T13:16:28.974Z",
|
||||
"updated_at": "2026-10-26T13:16:28.980Z",
|
||||
"created_by": "u_01a1789c0bd1e6e269d0df3589",
|
||||
"created_at": "2026-10-26T14:28:11.217Z",
|
||||
"updated_at": "2026-10-26T14:28:11.228Z",
|
||||
"runtime": "static",
|
||||
"consent_version": null,
|
||||
"pending_cleanup_json": null,
|
||||
@@ -2214,8 +2214,8 @@
|
||||
"name": "외부 배포 사이트 생성(초안) + 저장본",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "s_01a1785c35f8d649e375f0c391",
|
||||
"group_id": "g_01a1785a66113e63a08c972cc5",
|
||||
"id": "s_01a1789dc3ada89ee43472fe26",
|
||||
"group_id": "g_01a1789c0ba4cc346b9a816d13",
|
||||
"name": "E2E 외부 사이트",
|
||||
"purpose": "e2e",
|
||||
"webmaker_project_id": "00000000000000000000000000000004",
|
||||
@@ -2243,9 +2243,9 @@
|
||||
"retention_until": null,
|
||||
"delete_after": null,
|
||||
"deleted_at": null,
|
||||
"created_by": "u_01a1785a65614c4deeec61ea3d",
|
||||
"created_at": "2026-10-26T13:17:51.480Z",
|
||||
"updated_at": "2026-10-26T13:17:51.485Z",
|
||||
"created_by": "u_01a1789c0afae66fc53b01be8a",
|
||||
"created_at": "2026-10-26T14:29:27.597Z",
|
||||
"updated_at": "2026-10-26T14:29:27.603Z",
|
||||
"runtime": "static",
|
||||
"consent_version": null,
|
||||
"pending_cleanup_json": null,
|
||||
@@ -2286,8 +2286,8 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"token": "[redacted]",
|
||||
"install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391",
|
||||
"expiresAt": "2026-10-26T13:27:51.761Z",
|
||||
"install": "curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26",
|
||||
"expiresAt": "2026-10-26T14:39:27.642Z",
|
||||
"status": "pending"
|
||||
}
|
||||
},
|
||||
@@ -2483,8 +2483,8 @@
|
||||
"overall": "partial",
|
||||
"children": [
|
||||
"srv_missing_e2e:failed:서버가 없습니다",
|
||||
"srv_01a1785a5d26219326f7ad26ec:done:",
|
||||
"srv_01a1785a5dd874fe86b67f0f38:done:"
|
||||
"srv_01a1789c04ba2129a08801860c:done:",
|
||||
"srv_01a1789c03fc9ee146184fa07c:done:"
|
||||
],
|
||||
"timedOut": false
|
||||
}
|
||||
@@ -2522,8 +2522,8 @@
|
||||
"overall": "partial",
|
||||
"children": [
|
||||
"srv_missing_e2e:failed:서버가 없습니다",
|
||||
"srv_01a1785a5d26219326f7ad26ec:done:",
|
||||
"srv_01a1785a5dd874fe86b67f0f38:done:"
|
||||
"srv_01a1789c04ba2129a08801860c:done:",
|
||||
"srv_01a1789c03fc9ee146184fa07c:done:"
|
||||
],
|
||||
"timedOut": false
|
||||
},
|
||||
@@ -2562,7 +2562,7 @@
|
||||
"name": "critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "em_01a1785c79d6aa634fc6516b7b",
|
||||
"id": "em_01a1789e0e13d2c9660482e88c",
|
||||
"noCred": "approvals_insufficient",
|
||||
"wrongStep": "wrong_step",
|
||||
"badIds": "identifiers_mismatch",
|
||||
@@ -2570,7 +2570,7 @@
|
||||
"approved": "approved",
|
||||
"approvals": 3,
|
||||
"credential": "[redacted]",
|
||||
"expiresAt": "2026-10-26T13:48:08.882Z"
|
||||
"expiresAt": "2026-10-26T14:59:46.671Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2591,14 +2591,14 @@
|
||||
"evidence": {
|
||||
"healthy": "hal_healthy",
|
||||
"sess": {
|
||||
"id": "em_01a1785c79d6aa634fc6516b7b",
|
||||
"id": "em_01a1789e0e13d2c9660482e88c",
|
||||
"noCred": "approvals_insufficient",
|
||||
"wrongStep": "wrong_step",
|
||||
"badIds": "identifiers_mismatch",
|
||||
"badText": "final_confirm_mismatch",
|
||||
"approved": "approved",
|
||||
"approvals": 3,
|
||||
"expiresAt": "2026-10-26T13:48:08.882Z"
|
||||
"expiresAt": "2026-10-26T14:59:46.671Z"
|
||||
},
|
||||
"perf": {
|
||||
"forbidden": "action_forbidden",
|
||||
@@ -2650,8 +2650,8 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"status": "readonly",
|
||||
"deleteAfter": "2026-11-25T13:18:00.329Z",
|
||||
"readonlyUntil": "2026-11-25T13:18:00.329Z"
|
||||
"deleteAfter": "2026-11-25T14:29:36.479Z",
|
||||
"readonlyUntil": "2026-11-25T14:29:36.479Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2677,8 +2677,8 @@
|
||||
},
|
||||
"un": {
|
||||
"status": "readonly",
|
||||
"deleteAfter": "2026-11-25T13:18:00.329Z",
|
||||
"readonlyUntil": "2026-11-25T13:18:00.329Z"
|
||||
"deleteAfter": "2026-11-25T14:29:36.479Z",
|
||||
"readonlyUntil": "2026-11-25T14:29:36.479Z"
|
||||
},
|
||||
"sweep": {
|
||||
"sweep": 200,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 로컬 종단 간 검증 기록 — 2026-10-26
|
||||
|
||||
생성: 2026-10-26T13:18:11.518Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/)
|
||||
생성: 2026-10-26T14:29:49.315Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/)
|
||||
|
||||
실행 환경: 실제 HAL·고객 포털·Node Agent(명령 stub 모드) 프로세스 4대 + 가짜 Webmaker/AriMail/발신 메일 서버(127.0.0.1). 실 DNS·실 메일 왕복·실 서버는 포함하지 않는다.
|
||||
|
||||
@@ -88,20 +88,20 @@
|
||||
|
||||
### 2. 개인 사용자 그룹 생성과 다른 사용자 초대
|
||||
|
||||
- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a1785a66113e63a08c972cc5
|
||||
- ✔ 이메일 초대(7일) → 초대 메일 링크 — uVdQrHwu9UjZmbh1PlCVMIPsJFxbHnvo
|
||||
- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a1789c0ba4cc346b9a816d13
|
||||
- ✔ 이메일 초대(7일) → 초대 메일 링크 — 92k9hZNmEVZ5s11c5YD_AKFXC9VLtCw2
|
||||
- ✔ 초대 수락(1회용) → 별칭 user-02 · 역할 member — {"alias":"user-02","role":"member","reuse":"invite_invalid"}
|
||||
- ✔ 권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할 — {"requiredRole":["owner","admin"],"yourRole":"member"}
|
||||
- ✔ 그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음 — {"team":{"live":0,"purchased":0,"inUse":0,"reserved":0,"unallocated":0},"personal":{"live":0,"purchased":0,"inUse":0,"reserved":0,"unallocated":0}}
|
||||
|
||||
### 3. 상품 주문, 결제 확인, 권리 활성화
|
||||
|
||||
- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a1785a6358a08c3194ac03c2","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]}
|
||||
- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T13:15:52.839Z","hasInstructions":true}
|
||||
- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a1789c0a26a92faf63434d5a","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]}
|
||||
- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T14:27:35.064Z","hasInstructions":true}
|
||||
- ✔ 입금 확인 전 승인 거부(409 payment_not_confirmed) — payment_not_confirmed
|
||||
- ✔ 운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시 — {"status":"awaiting_approval","delta":["sites","web_disk","web_out","mail_storage","mail_msgs","mailboxes","mail_services","mail_storage","mail_msgs","mailboxes"]}
|
||||
- ✔ 운영자 승인 → 권리 활성화(재승인은 중복 없음) — {"granted":10,"already":true}
|
||||
- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T13:15:52.905Z"}
|
||||
- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T14:27:35.130Z"}
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -112,25 +112,25 @@
|
||||
"bill": {
|
||||
"sites": 3,
|
||||
"subs": 1,
|
||||
"nextBilling": "2026-11-26T13:15:52.905Z"
|
||||
"nextBilling": "2026-11-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 초안 사이트 생성
|
||||
|
||||
- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a1785a670945da067e955da4","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]}
|
||||
- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a1789c0c9a313add86e28440","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]}
|
||||
- ✔ 사이트 목록 필터(초안)·사용량 요약에 예약 0 — {"drafts":1,"sitesReserved":0,"sitesAllocated":0}
|
||||
|
||||
### 5. Webmaker에서 페이지 제작
|
||||
|
||||
- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:35307/api/auth/sso?ticket=…","webmaker":302,"reuse":401}
|
||||
- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:37209/api/auth/sso?ticket=…","webmaker":302,"reuse":401}
|
||||
- ✔ Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과 — {"versions":1,"precheckOk":true}
|
||||
|
||||
```json
|
||||
{
|
||||
"sso": {
|
||||
"portalRedirect": "http://127.0.0.1:35307/api/auth/sso?ticket=…",
|
||||
"portalRedirect": "http://127.0.0.1:37209/api/auth/sso?ticket=…",
|
||||
"webmaker": 302,
|
||||
"reuse": 401
|
||||
},
|
||||
@@ -143,14 +143,14 @@
|
||||
|
||||
### 6. 관리형 배포
|
||||
|
||||
- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a1785a6757555a33d6269bd8","steps":10,"again":false,"siteCount":"reserved","server":null}
|
||||
- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a1789c0ce4dcffc59011e15d","steps":10,"again":false,"siteCount":"reserved","server":null}
|
||||
- ✔ HAL·Agent 를 거쳐 작업 완료 대기(최대 40초) — {"status":"done","steps":["accept:done","prepare_version:done","prepare_target:done","apply:done","data_check:done","start:done","internal_check:done","external_check:done","domain_https:done","done:done"]}
|
||||
- ✔ 배포 뒤 사이트 상태·버전·건강 — {"lifecycle":"needs_domain","version":"20261026131553-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true}
|
||||
- ✔ 배포 뒤 사이트 상태·버전·건강 — {"lifecycle":"needs_domain","version":"20261026142736-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true}
|
||||
|
||||
```json
|
||||
{
|
||||
"d": {
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8",
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d",
|
||||
"steps": 10,
|
||||
"again": false,
|
||||
"siteCount": "reserved",
|
||||
@@ -173,7 +173,7 @@
|
||||
},
|
||||
"after": {
|
||||
"lifecycle": "needs_domain",
|
||||
"version": "20261026131553-rev-c1-000000",
|
||||
"version": "20261026142736-rev-c1-000000",
|
||||
"verify": "needs_domain",
|
||||
"health": "ok",
|
||||
"agentHasSite": true
|
||||
@@ -183,14 +183,14 @@
|
||||
|
||||
### 7. 도메인 연결과 HTTPS 발급
|
||||
|
||||
- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1785a772932b8936927ad0a","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true}
|
||||
- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1789c1cb665247a22f3d74a","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true}
|
||||
- ✔ 도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시 — {}
|
||||
- ✔ (정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다 — {"queuedProbes":0}
|
||||
|
||||
```json
|
||||
{
|
||||
"dm": {
|
||||
"domainId": "d_01a1785a772932b8936927ad0a",
|
||||
"domainId": "d_01a1789c1cb665247a22f3d74a",
|
||||
"txtName": "_ariweb-verify.aw-e2e.localhost",
|
||||
"txtValueShape": "ariweb-verify=…",
|
||||
"copyable": true
|
||||
@@ -233,9 +233,9 @@
|
||||
|
||||
### 10. 사이트 대표 메일과 메일함 생성
|
||||
|
||||
- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1785affa395a9602c99bbb5","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0}
|
||||
- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1789ca5510f0181ed01486e","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0}
|
||||
- ✔ 같은 사이트에 두 번째 대표 메일 거부 — mail_domain_in_use
|
||||
- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a1785affda99dffac745d1e9","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true}
|
||||
- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a1789ca58c25f8651b6813b4","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true}
|
||||
- ✔ 초대 방식 메일함(링크 만료·재발송·사용 완료 상태) — {"inviteState":"pending","accepted":200,"reuse":"invite_used"}
|
||||
- ✔ DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing) — {"ok":false,"missing":3,"items":["mx:missing","spf:missing","dkim:missing","dmarc:recommended"]}
|
||||
- ✔ 활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등 — {"status":"awaiting_external","waitReason":"DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요","sameJob":true,"steps":["entitlement:done","dns:waiting","prepare:pending","verify:pending","activate:pending"]}
|
||||
@@ -244,7 +244,7 @@
|
||||
```json
|
||||
{
|
||||
"svc": {
|
||||
"id": "msv_01a1785affa395a9602c99bbb5",
|
||||
"id": "msv_01a1789ca5510f0181ed01486e",
|
||||
"status": "needs_setup",
|
||||
"tenant": true,
|
||||
"ariwebKind": "site_bound",
|
||||
@@ -252,7 +252,7 @@
|
||||
"siteCountAllocated": 0
|
||||
},
|
||||
"mb": {
|
||||
"id": "mb_01a1785affda99dffac745d1e9",
|
||||
"id": "mb_01a1789ca58c25f8651b6813b4",
|
||||
"address": "kim@aw-e2e.localhost",
|
||||
"passwordShownOnce": "[redacted]",
|
||||
"mustChange": true,
|
||||
@@ -287,13 +287,13 @@
|
||||
|
||||
### 12. 독립 메일 서비스 생성
|
||||
|
||||
- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a1785b2be9c7ee6d0d94a95c","jobCancelled":null}
|
||||
- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1785b2bf14cefa9e1f81fd7","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0}
|
||||
- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a1789cbfd52a398c63de3b05","jobCancelled":null}
|
||||
- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1789cbfddf212586ba4b18d","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0}
|
||||
|
||||
```json
|
||||
{
|
||||
"r": {
|
||||
"id": "msv_01a1785b2bf14cefa9e1f81fd7",
|
||||
"id": "msv_01a1789cbfddf212586ba4b18d",
|
||||
"kind": "standalone",
|
||||
"mailServicesAllocated": 1,
|
||||
"sitesAllocated": 0
|
||||
@@ -307,8 +307,8 @@
|
||||
|
||||
- ✔ 백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리 — {"granted":7,"orderStatus":"approved"}
|
||||
- ✔ 백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서) — {"eligible":true,"reasons":[],"deferred":[],"policy":null,"status":200}
|
||||
- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T13:16:43.530Z","nextRun":"2026-10-26T13:16:43.530Z"}
|
||||
- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a1785a5d26219326f7ad26ec","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0}
|
||||
- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T14:28:21.235Z","nextRun":"2026-10-26T14:28:21.235Z"}
|
||||
- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a1789c03fc9ee146184fa07c","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0}
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -321,14 +321,14 @@
|
||||
},
|
||||
"opt": {
|
||||
"status": "active",
|
||||
"firstDue": "2026-10-27T13:16:43.530Z",
|
||||
"nextRun": "2026-10-26T13:16:43.530Z"
|
||||
"firstDue": "2026-10-27T14:28:21.235Z",
|
||||
"nextRun": "2026-10-26T14:28:21.235Z"
|
||||
},
|
||||
"run1": {
|
||||
"timedOut": false,
|
||||
"optionStatus": "active",
|
||||
"lastJob": null,
|
||||
"siteServer": "srv_01a1785a5d26219326f7ad26ec",
|
||||
"siteServer": "srv_01a1789c03fc9ee146184fa07c",
|
||||
"backup": {
|
||||
"status": "done",
|
||||
"tier": "monthly",
|
||||
@@ -342,8 +342,8 @@
|
||||
|
||||
### 14. 백업 복원과 외부 사이트 확인
|
||||
|
||||
- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a1785b465fad18eaeb8a679e","createdAt":"2026-10-26T13:16:43.538Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a1785a5dd874fe86b67f0f38","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1896,"verifyState":"verified","expiresAt":"2027-01-2
|
||||
- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a1785b4a2808cc16818090d9","status":"awaiting_approval","dup":false,"finalConfirmation":{"group":"E2E 팀","site":"E2E 사이트","domain":null}}
|
||||
- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a1789cd5b1cc3ce60f2f4349","createdAt":"2026-10-26T14:28:21.242Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a1789c04ba2129a08801860c","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1896,"verifyState":"verified","expiresAt":"2027-01-2
|
||||
- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a1789cd82e8d48adf5b09f5a","status":"awaiting_approval","dup":false,"finalConfirmation":{"group":"E2E 팀","site":"E2E 사이트","domain":null}}
|
||||
- ✔ 운영자 최종 확인(그룹·사이트·백업 시각) 불일치 거부 → 일치 승인(멱등) — {"bad":"final_confirmation_mismatch","ok":400}
|
||||
- ✔ 9단계 작업 진행 → 사용자 확인 필요(보호 시점·되돌리기 가능) — {"steps":[]}
|
||||
|
||||
@@ -351,19 +351,19 @@
|
||||
{
|
||||
"info": {
|
||||
"backup": {
|
||||
"id": "bk_01a1785b465fad18eaeb8a679e",
|
||||
"createdAt": "2026-10-26T13:16:43.538Z",
|
||||
"id": "bk_01a1789cd5b1cc3ce60f2f4349",
|
||||
"createdAt": "2026-10-26T14:28:21.242Z",
|
||||
"scope": {
|
||||
"files": true,
|
||||
"db": true,
|
||||
"uploads": true,
|
||||
"config": true,
|
||||
"storeServerId": "srv_01a1785a5dd874fe86b67f0f38",
|
||||
"storeServerId": "srv_01a1789c04ba2129a08801860c",
|
||||
"verifyMethod": "sha256 + 격리 환경 읽기 확인"
|
||||
},
|
||||
"sizeBytes": 1896,
|
||||
"verifyState": "verified",
|
||||
"expiresAt": "2027-01-27T13:16:50.144Z",
|
||||
"expiresAt": "2027-01-27T14:28:26.674Z",
|
||||
"tier": "monthly"
|
||||
},
|
||||
"included": [
|
||||
@@ -393,7 +393,7 @@
|
||||
"deferred": []
|
||||
},
|
||||
"rq": {
|
||||
"id": "rs_01a1785b4a2808cc16818090d9",
|
||||
"id": "rs_01a1789cd82e8d48adf5b09f5a",
|
||||
"status": "awaiting_approval",
|
||||
"dup": false,
|
||||
"finalConfirmation": {
|
||||
@@ -445,10 +445,10 @@
|
||||
|
||||
### 16. 외부 배포 사이트 생성과 배포
|
||||
|
||||
- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1785c35f8d649e375f0c391","group_id":"g_01a1785a66113e63a08c972cc5","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou
|
||||
- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1789dc3ada89ee43472fe26","group_id":"g_01a1789c0ba4cc346b9a816d13","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou
|
||||
- ✔ 배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전 — {"notProvided":"[redacted]","consentVersion":"external-deploy-2026-10-0.1.1","agreed":false}
|
||||
- ✔ 동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전) — {"before":"consent_required","consent":200}
|
||||
- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391","expiresAt":"2026-10-26T13:27:51.761Z","status":"pending"}
|
||||
- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26","expiresAt":"2026-10-26T14:39:27.642Z","status":"pending"}
|
||||
- ✔ 고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속) — {"agentId":"[redacted]"}
|
||||
- ✔ 포털 연결 확인(외부 Agent 상태 connected) — {"status":"connected","stale":false,"halAgentId":"[redacted]"}
|
||||
- ✔ 배포 전 확인(외부: 관리형 용량 확인 없음) — {"ok":true,"blocks":[]}
|
||||
@@ -465,8 +465,8 @@
|
||||
"agreed": false
|
||||
},
|
||||
"tok": {
|
||||
"install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391",
|
||||
"expiresAt": "2026-10-26T13:27:51.761Z"
|
||||
"install": "curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26",
|
||||
"expiresAt": "2026-10-26T14:39:27.642Z"
|
||||
},
|
||||
"conn": {
|
||||
"status": "connected",
|
||||
@@ -554,7 +554,7 @@
|
||||
- ✔ 축소 미리보기(팀→베이직): 줄여야 할 항목(메일함 6→5) 구체 표시 — {"shortages":["mailboxes:1"]}
|
||||
- ✔ 축소 요청 → 보류(사용 중 권리 즉시 회수 없음) — {"fits":false,"hold":"holding","sitesStillPurchased":3}
|
||||
- ✔ 정리: 메일함 배정 6→5 축소 → 재검증 적합 → 축소 주문 생성 — {"allocate":200,"fits":true,"shortages":[],"order":"awaiting_payment"}
|
||||
- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a1785a6358a0946ad508ee8d"}
|
||||
- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a1789c0a2573b629e03f3013"}
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -589,7 +589,7 @@
|
||||
"approve": 200,
|
||||
"sites": 1,
|
||||
"mailboxes": 5,
|
||||
"product": "pv_01a1785a6358a0946ad508ee8d"
|
||||
"product": "pv_01a1789c0a2573b629e03f3013"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -623,10 +623,10 @@
|
||||
### 20. 사이트 소유권 이전
|
||||
|
||||
- ✔ 이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목) — {"sites":1,"domains":1,"siteMail":1,"needed":{"backup":1,"web_disk":1073741824,"mail_msgs":500,"mail_storage":2147483648,"mailboxes":6},"excluded":3}
|
||||
- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a1785c58c47561320ed6f007","status":"requested","expiresAt":"2026-11-02T13:18:00.388Z","bobNotified":true}
|
||||
- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a1789de6a46d148f37fda029","status":"requested","expiresAt":"2026-11-02T14:29:36.548Z","bobNotified":true}
|
||||
- ✔ (정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개) — {"endBackup":200,"alloc":200}
|
||||
- ✔ 받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내) — {"status":200,"tstatus":"reviewing","hold":null}
|
||||
- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T13:18:00.432Z"}
|
||||
- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T14:29:36.598Z"}
|
||||
- ✔ 완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사 — {"aliceSees":404,"bobSees":200,"bobUsageSites":0,"audit":true}
|
||||
|
||||
```json
|
||||
@@ -645,9 +645,9 @@
|
||||
"excluded": 3
|
||||
},
|
||||
"rq": {
|
||||
"id": "tr_01a1785c58c47561320ed6f007",
|
||||
"id": "tr_01a1789de6a46d148f37fda029",
|
||||
"status": "requested",
|
||||
"expiresAt": "2026-11-02T13:18:00.388Z",
|
||||
"expiresAt": "2026-11-02T14:29:36.548Z",
|
||||
"bobNotified": true
|
||||
},
|
||||
"acc": {
|
||||
@@ -659,7 +659,7 @@
|
||||
"bad": "final_confirmation_mismatch",
|
||||
"ok": 200,
|
||||
"status": "completed",
|
||||
"cutover": "2026-10-26T13:18:00.432Z"
|
||||
"cutover": "2026-10-26T14:29:36.598Z"
|
||||
},
|
||||
"after": {
|
||||
"aliceSees": 404,
|
||||
@@ -738,7 +738,7 @@
|
||||
|
||||
### 22. HAL 우선 배포 후 HAL을 통한 전체 모듈 배포
|
||||
|
||||
- ✔ 릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서 — {"releases":["0.1.2:approved","0.1.1:approved"],"running":{"complete":true,"firstInstall":false,"releaseVersion":null,"mainCommit":null,"artifactSha256":null,"halRunningVersion":"0.1.1","firstAgent":"[redacted]","manualInstallLocked":true},"order":["hal","agent","portal","webmaker","arimail","backup
|
||||
- ✔ 릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서 — {"releases":["0.1.2:approved","0.1.1:approved"],"running":{"complete":true,"firstInstall":false,"releaseVersion":"0.1.1","mainCommit":"[redacted]","artifactSha256":"[redacted]","halRunningVersion":"0.1.1","firstAgent":"[redacted]","manualInstallLocked":true},"order":["hal","agent","portal","webmaker
|
||||
- ✔ HAL 이 아직 0.1.2 가 아닐 때 다른 모듈 0.1.2 배포 → hal_first 거부 — {"status":"failed","error":"hal_first"}
|
||||
- ✔ 승인되지 않은 릴리스는 거부 / 수동 설치 잠김 표시 — {"manualInstallLocked":true,"complete":true}
|
||||
- ✔ HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인) — {"status":"done","steps":["precheck:done","fanout:done","collect:done"],"error":null,"children":["done:"],"agentJobs":[],"timedOut":false}
|
||||
@@ -753,9 +753,9 @@
|
||||
"running": {
|
||||
"complete": true,
|
||||
"firstInstall": false,
|
||||
"releaseVersion": null,
|
||||
"mainCommit": null,
|
||||
"artifactSha256": null,
|
||||
"releaseVersion": "0.1.1",
|
||||
"mainCommit": "[redacted]",
|
||||
"artifactSha256": "[redacted]",
|
||||
"halRunningVersion": "0.1.1",
|
||||
"firstAgent": "[redacted]",
|
||||
"manualInstallLocked": true
|
||||
@@ -798,7 +798,7 @@
|
||||
|
||||
### 23. 실패 작업의 재시도와 되돌리기
|
||||
|
||||
- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a1785a6757555a33d6269bd8"}
|
||||
- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a1789c0ce4dcffc59011e15d"}
|
||||
- ✔ 재시도 요청(POST /api/sites/:id/jobs/:jobId/retry) — {"status":400,"error":"job_not_retryable"}
|
||||
- ✔ 되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부 — {"status":409,"error":"no_previous","message":"되돌릴 이전 버전이 없습니다"}
|
||||
|
||||
@@ -807,7 +807,7 @@
|
||||
"dep": {
|
||||
"status": "done",
|
||||
"canRetry": false,
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8"
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d"
|
||||
},
|
||||
"rt": {
|
||||
"status": 400,
|
||||
@@ -823,16 +823,16 @@
|
||||
|
||||
### 24. 운영자 승인, 감사 기록, 고객 알림의 일치
|
||||
|
||||
- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a1785a6686ffeba5d2781add"}
|
||||
- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a1789c0c18393dba2213c118"}
|
||||
- ✔ 감사 기록: order.approve 는 삭제 불가(immutable) — {"action":"order.approve","immutable":true}
|
||||
- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T13:15:52.905Z"}
|
||||
- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T14:27:35.130Z"}
|
||||
|
||||
```json
|
||||
{
|
||||
"n": {
|
||||
"title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다",
|
||||
"category": "billing",
|
||||
"link": "/orders/o_01a1785a6686ffeba5d2781add"
|
||||
"link": "/orders/o_01a1789c0c18393dba2213c118"
|
||||
},
|
||||
"a": {
|
||||
"action": "order.approve",
|
||||
@@ -840,7 +840,7 @@
|
||||
},
|
||||
"same": {
|
||||
"status": "approved",
|
||||
"approvedAt": "2026-10-26T13:15:52.905Z"
|
||||
"approvedAt": "2026-10-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -848,7 +848,7 @@
|
||||
### 25. HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련
|
||||
|
||||
- ✔ 설정 전 준비 상태(미설정 항목) — {"ready":false,"missingConfig":["rto_minutes","rpo_minutes","snapshot_interval_minutes","retention_count"],"lastDrill":null,"lastSnapshot":null,"config":{"rto_minutes":null,"rpo_minutes":null,"snapshot_interval_minutes":null,"retention_count":null,"external_store":null},"label":"정식 운영 준비 전(복구 훈련 또는
|
||||
- ✔ RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정 — {"id":1,"rto_minutes":60,"rpo_minutes":30,"snapshot_interval_minutes":60,"retention_count":3,"external_store":"/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external","updated_at":"2026-10-26T13:18:08.570Z","updated_by":"op_01a1785a5c27caed1a96f25d99"}
|
||||
- ✔ RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정 — {"id":1,"rto_minutes":60,"rpo_minutes":30,"snapshot_interval_minutes":60,"retention_count":3,"external_store":"/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026142730/dr-external","updated_at":"2026-10-26T14:29:46.345Z","updated_by":"op_01a1789c02fd72d51f469e4d25"}
|
||||
- ✔ 복구 사본 생성·검증·외부 보관(vault.key 제외) — {"verify":"ok","external":true,"sha":true}
|
||||
- ✔ 복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료 — {"early":"restore_not_approved","noStop":"old_control_not_confirmed","command":true,"incomplete":"checks_incomplete","final":"completed"}
|
||||
- ✔ 복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료 — {"first":"failed","second":"success","ready":true,"measured":{}}
|
||||
@@ -880,9 +880,9 @@
|
||||
"rpo_minutes": 30,
|
||||
"snapshot_interval_minutes": 60,
|
||||
"retention_count": 3,
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external",
|
||||
"updated_at": "2026-10-26T13:18:08.570Z",
|
||||
"updated_by": "op_01a1785a5c27caed1a96f25d99"
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026142730/dr-external",
|
||||
"updated_at": "2026-10-26T14:29:46.345Z",
|
||||
"updated_by": "op_01a1789c02fd72d51f469e4d25"
|
||||
},
|
||||
"snap": {
|
||||
"verify": "ok",
|
||||
@@ -907,15 +907,15 @@
|
||||
|
||||
### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지
|
||||
|
||||
- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a1785b141b2aba663738d139","status":"awaiting_external"},"halJobs":18}
|
||||
- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a1785b141b2aba663738d139","status":"awaiting_external"},"same":true}
|
||||
- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a1789cb9c0e6f752db2a47b6","status":"awaiting_external"},"halJobs":18}
|
||||
- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a1789cb9c0e6f752db2a47b6","status":"awaiting_external"},"same":true}
|
||||
- ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":18,"sameCount":true}
|
||||
|
||||
```json
|
||||
{
|
||||
"before": {
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"halJobs": 18
|
||||
@@ -923,7 +923,7 @@
|
||||
"pr": {
|
||||
"sessionKept": "[redacted]",
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"same": true
|
||||
@@ -939,7 +939,7 @@
|
||||
### 27. 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공
|
||||
|
||||
- ✔ 밥 개인 그룹: 베이직(사이트 1) 주문·승인 — {"granted":6}
|
||||
- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1785af3ab8d0d00e0063124","group_id":"g_01a1785a663da04c2c5802e990","name":"동시 A","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000002","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_sta
|
||||
- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1789c994d3878d056f3d2fc","group_id":"g_01a1789c0bd144ad600dd3bc46","name":"동시 A","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000002","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_sta
|
||||
- ✔ 두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족) — {"ok":1,"blocked":"precheck_failed","shortage":"block"}
|
||||
- ✔ (정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환 — {"cancel":200,"cleanup":200,"unallocated":0}
|
||||
|
||||
@@ -955,10 +955,10 @@
|
||||
|
||||
### 28. 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리
|
||||
|
||||
- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1785c35f8d649e375f0c391","group_id":"g_01a1785a66113e63a08c972cc5","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou
|
||||
- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1789dc3ada89ee43472fe26","group_id":"g_01a1789c0ba4cc346b9a816d13","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou
|
||||
- ✔ 배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전 — {"notProvided":"[redacted]","consentVersion":"external-deploy-2026-10-0.1.1","agreed":false}
|
||||
- ✔ 동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전) — {"before":"consent_required","consent":200}
|
||||
- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391","expiresAt":"2026-10-26T13:27:51.761Z","status":"pending"}
|
||||
- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26","expiresAt":"2026-10-26T14:39:27.642Z","status":"pending"}
|
||||
- ✔ 고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속) — {"agentId":"[redacted]"}
|
||||
- ✔ 포털 연결 확인(외부 Agent 상태 connected) — {"status":"connected","stale":false,"halAgentId":"[redacted]"}
|
||||
- ✔ 배포 전 확인(외부: 관리형 용량 확인 없음) — {"ok":true,"blocks":[]}
|
||||
@@ -1039,7 +1039,7 @@
|
||||
|
||||
### 30. 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정
|
||||
|
||||
- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"failed","errorCode":"partial_failure","overall":"partial","children":["srv_missing_e2e:failed:서버가 없습니다","srv_01a1785a5d26219326f7ad26ec:done:","srv_01a1785a5dd874fe86b67f0f38:done:"],"timedOut":false}
|
||||
- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"failed","errorCode":"partial_failure","overall":"partial","children":["srv_missing_e2e:failed:서버가 없습니다","srv_01a1789c04ba2129a08801860c:done:","srv_01a1789c03fc9ee146184fa07c:done:"],"timedOut":false}
|
||||
- ✔ 모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업 — {"health":"done","drift":0,"reconcile":200,"overviewComponents":["HAL:ok","portal:unknown","webmaker:unknown","arimail:unknown","backup:ok","observability:ok"]}
|
||||
- ✔ 되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부 — {"step1":"confirm_impact_required","step2":"identifiers_mismatch"}
|
||||
|
||||
@@ -1051,8 +1051,8 @@
|
||||
"overall": "partial",
|
||||
"children": [
|
||||
"srv_missing_e2e:failed:서버가 없습니다",
|
||||
"srv_01a1785a5d26219326f7ad26ec:done:",
|
||||
"srv_01a1785a5dd874fe86b67f0f38:done:"
|
||||
"srv_01a1789c04ba2129a08801860c:done:",
|
||||
"srv_01a1789c03fc9ee146184fa07c:done:"
|
||||
],
|
||||
"timedOut": false
|
||||
},
|
||||
@@ -1079,21 +1079,21 @@
|
||||
### 31. 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구
|
||||
|
||||
- ✔ HAL 정상 상태에서 선언 거부(hal_healthy) — hal_healthy
|
||||
- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a1785c79d6aa634fc6516b7b","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T13:48:08.882Z"}
|
||||
- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a1789e0e13d2c9660482e88c","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T14:59:46.671Z"}
|
||||
- ✔ 일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료 — {"forbidden":"action_forbidden","outOfScope":"out_of_scope","badCred":"bad_credential","performed":400,"revoked":true,"afterRevoke":"credential_revoked","reconcile":200,"closed":"closed"}
|
||||
|
||||
```json
|
||||
{
|
||||
"healthy": "hal_healthy",
|
||||
"sess": {
|
||||
"id": "em_01a1785c79d6aa634fc6516b7b",
|
||||
"id": "em_01a1789e0e13d2c9660482e88c",
|
||||
"noCred": "approvals_insufficient",
|
||||
"wrongStep": "wrong_step",
|
||||
"badIds": "identifiers_mismatch",
|
||||
"badText": "final_confirm_mismatch",
|
||||
"approved": "approved",
|
||||
"approvals": 3,
|
||||
"expiresAt": "2026-10-26T13:48:08.882Z"
|
||||
"expiresAt": "2026-10-26T14:59:46.671Z"
|
||||
},
|
||||
"perf": {
|
||||
"forbidden": "action_forbidden",
|
||||
@@ -1113,7 +1113,7 @@
|
||||
- ✔ Source 옵션 주문(대상 사이트)·승인 → source 권리 — {"granted":7}
|
||||
- ✔ Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보 — {"activate":200,"status":"active","provider":"gitea","changes":1,"clone":true}
|
||||
- ✔ export(저장본 내려받기) — {"status":200,"type":"application/gzip"}
|
||||
- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T13:18:00.329Z","readonlyUntil":"2026-11-25T13:18:00.329Z"}
|
||||
- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T14:29:36.479Z","readonlyUntil":"2026-11-25T14:29:36.479Z"}
|
||||
- ✔ 삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음 — {"sweep":200,"statusAfter":"readonly"}
|
||||
|
||||
```json
|
||||
@@ -1131,8 +1131,8 @@
|
||||
},
|
||||
"un": {
|
||||
"status": "readonly",
|
||||
"deleteAfter": "2026-11-25T13:18:00.329Z",
|
||||
"readonlyUntil": "2026-11-25T13:18:00.329Z"
|
||||
"deleteAfter": "2026-11-25T14:29:36.479Z",
|
||||
"readonlyUntil": "2026-11-25T14:29:36.479Z"
|
||||
},
|
||||
"sweep": {
|
||||
"sweep": 200,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
# 기존 메일 서비스 ↔ HAL(bear hal21) 연동 기록 — 2026-10-27 (Hera)
|
||||
|
||||
대상: `https://mail.arihakdang.com/` · `https://webmail.ibit.kr/` → bear HAL `https://hal21.ariweb.net` 의 관리형 MailService/Node 등록. 기존 송수신·계정·메일·DNS/MX·TLS·provider 는 바꾸지 않았다. 이 문서에 비밀값은 없다(경로·키 이름·해시만).
|
||||
|
||||
## 1. 소스·배포 리비전
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 작업 시작 시 origin/main | `ac072e6f12a910ccd60dc7e63a9724363617a46b` (rebase 뒤 `03cb9c0777ddc3dc2003893d3dddd53a01565f2b`) |
|
||||
| 코드 커밋(배포한 HAL) | `79b45ca96102213d2bd30856ef79b6d732758349` — release.mjs build: testsPassed=true, dirty=false, hal artifact sha256 `bf636f847266e319c888f3ba9f40fc8a22c9f5e6f43775d61d967752742db489` |
|
||||
| bear HAL 배포 전 | `/srv/ariweb/hal/releases/0.1.1-ac072e6f12a9` (hal.sqlite servers 0 · agents 0 · ops_accounts 1 · mail_services 표 없음) |
|
||||
| bear HAL 배포 후 | `/srv/ariweb/hal/releases/0.1.1-79b45ca96102` → `current`, `ariweb-hal` 05:28:55Z 재시작(재시작→/api/health 2.6초, NRestarts 0), `/api/health.bootstrap.mainCommit=79b45ca…`, 릴리스 0.1.1 artifact 재등록(approved, by hera-mail-onboard) |
|
||||
|
||||
## 2. 실제 호스트·인스턴스(읽기 전용 조사)
|
||||
| | mail.arihakdang.com | webmail.ibit.kr |
|
||||
|---|---|---|
|
||||
| 호스트 | hostinger `srv1953374` (187.53.133.173, Ubuntu, Node v24.14.1 /opt/arihakdang-node) | `jitsi.ibit.kr` (115.68.194.192, Ubuntu 22.04, Node v22.23.3 /opt/arimail-node) |
|
||||
| 유닛 / 실행 계정 / cwd | `arihakdang-mail.service` (+`-haraka`) / arihakdang / `/srv/arihakdang-mail/current` | `arimail.service` (+`-haraka`) / arimail / `/srv/arimail/current` |
|
||||
| 리비전 | `releases/0.1.1` REVISION.json: Suite arimail 0.1.1, upstream AriMail 1.0.11, 2026-10-26 설치(hostinger HAL 모듈 배포) | `releases/20261024-204656` REVISION `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`, package 1.0.11 |
|
||||
| 설정·비밀 참조 | `/etc/arihakdang-mail/gateway.env`(0600) · `data/config.json`(0600) · `/etc/arihakdang-mail/tls/` · `data/setup-token` | `/etc/arimail/gateway.env`(0600) · `data/config.json`(0600) · `/etc/arimail/tls/` · `data/setup-token` |
|
||||
| 도메인 등록부 | 6: arihakdang.com(primary, auth www) · ariweb.net(webmaker) · check/solo-mvpcgayc·mvpcu283.ariweb.net(4개, Suite E2E 테넌트, operating=frozen, hostinger 포털 group/mailService 링크) | 1: ibit.kr(primary, auth local) |
|
||||
| 집계(계정=메일함 / 메일 / 저장) | 10 / 195 / 30.4MB(DB 기준; data 디렉터리 545MB — 과거 pre-* DB 백업 포함) | 3 / 0 / 2.1MB |
|
||||
| 리스너(유닛 PID 소유) | 110·143·465·587·993·995(0.0.0.0), 8790·8791(127.0.0.1). 25 없음 | 110·143·465·587·993·995, 8790. 25 없음 |
|
||||
| provider | Resend(inbound/outbound), Haraka 설치됨·비활성 | Resend, Haraka 비활성. journal: `resend._domainkey.ibit.kr` TXT 검증 실패 반복(기존 상태, 변경 없음) |
|
||||
| DNS(공개 리졸버) | A 187.53.133.173 · MX arihakdang.com→inbound-smtp.ap-northeast-1.amazonaws.com | A 115.68.194.192 · MX ibit.kr→inbound-smtp.ap-northeast-1.amazonaws.com |
|
||||
| TLS | LE `CN=mail.arihakdang.com`(+SAN webmail.ariweb.net) 만료 2027-01-12 | LE `CN=webmail.ibit.kr` 만료 2027-01-19 |
|
||||
| nginx / certbot 훅 | `sites-available/mail.arihakdang.conf` → 127.0.0.1:8790, 80→301 https · deploy 훅 `arihakdang-mail.sh`(갱신 시 유닛 재시작) | `webmail.ibit.kr.conf` → 127.0.0.1:8790, 80→301 · 훅 `arimail.sh` |
|
||||
| 기존 제어 평면 | hostinger HAL(`hal.ariweb.net`, 부트스트랩 완료) `module_targets.arimail` 0.1.1 ok, Agent `ag_01a178829f…` 등록 | 없음 |
|
||||
|
||||
두 URL 은 **서로 다른 호스트·다른 AriMail 인스턴스**다(같은 멀티도메인 리스너가 아님).
|
||||
|
||||
## 3. 사전 스냅샷(비밀 제외 sha256, 모두 root 0700/0600, git 밖, `sha256sum -c` 통과, SQLite `integrity_check=ok`)
|
||||
| 위치 | archive.tgz | db.tgz | code.tgz |
|
||||
|---|---|---|---|
|
||||
| hostinger `/srv/arihakdang-mail/backups/pre-hal-onboard-20261027T051350Z/` (RESTORE.md 포함) | `abb731f26e6a06c4…` (32 entries: data·config·domains.json·gateway.env·유닛·nginx·훅) | `1ba2eb9c2ba6c8ad…` (VACUUM INTO 사본 6개: 게이트웨이 + 테넌트 5) | `releases/0.1.1` 트리 106 entries |
|
||||
| jitsi `/srv/arimail/backups/pre-hal-onboard-20261027T051352Z/` | `7e36bcbff719dc36…` (20 entries) | `26035be7a4afdc71…` (사본 1) | `releases/20261024-204656` 66 entries |
|
||||
| bear `/srv/ariweb/backups/hal-pre-mail-onboard-20261027T051353Z/` | `534c9eb1228c011e…` (vault·config·유닛·nginx) | `da848bc05c84ef7d…` (hal.sqlite 사본, servers 0/agents 0/ops_accounts 1) | `releases/0.1.1-ac072e6f12a9` 79 entries |
|
||||
복원 순서는 각 디렉터리의 `RESTORE.md`(메일 복원은 서비스 중단 동반 → 운영자 승인 뒤에만). HAL 되돌리기: `mail-import-rollback --import <id>` 또는 RESTORE.md(hal.sqlite 사본 + current 심링크 `0.1.1-ac072e6f12a9`).
|
||||
|
||||
## 4. discovery → dry-run → apply → readback (bear, HAL CLI, 시스템 신원 `cli:hera`)
|
||||
- discovery 문서: `scripts/mail-discover.mjs`(sudo, 읽기 전용) — sha256 `1cfb2042138d1de3…`(hostinger, 05:22:06Z) · `18cda7182ac68dd7…`(jitsi, 05:22:07Z). `validateDiscovery` ok(비밀값 0, 메일 주소 0).
|
||||
- 서버(노드) 생성: `srv_01a17bd4dbcdb55877f2d9fcab` "srv1953374 (mail.arihakdang.com)" role mail · `srv_01a17bd4dc2a2bc3050054fee0` "jitsi.ibit.kr (webmail.ibit.kr)" role mail — 둘 다 pending.
|
||||
- dry-run: `mimp_01a17bd4dbd076ebd300d5a681`(create 6·reject 0) · `mimp_01a17bd4dc2e21a185a4cefc09`(create 1·reject 0). DB 변경 없음(mail_services 0).
|
||||
- apply: `mimp_01a17bd5f2685f8929d7638a28` → 생성 6(`mls_01a17bd5f26934dd18266db83a` arihakdang.com, `…f26a6e17e69ff81adb` ariweb.net, `…f26afc33723ce9c622` check-mvpcgayc, `…f26a1478f1bafcdca7` solo-mvpcgayc, `…f26aa7eab73a48883e` check-mvpcu283, `…f26bbb6aa9e045b23b` solo-mvpcu283) · `mimp_01a17bd5f2eaf5667cbc127dac` → 생성 1(`mls_01a17bd5f2eaf032215343d7cb` ibit.kr).
|
||||
- 멱등·충돌 검증: 같은 문서 재적용 `mimp_01a17bd5f35c29928b0609ec45` = update 6·create 0(id 유지) · jitsi 문서를 hostinger 서버에 적용 시도 `mimp_01a17bd5f3c482c2797e178e46` = rejected(ibit.kr collision) — 부분 적용 없음.
|
||||
- readback(`hal/index.mjs mail-services`, 05:33Z): total 7 · active 7 · 도메인 7(각 1) · duplicates [] · secretLeaks 0 · perServer {hostinger 6, jitsi 1} · 모두 kind legacy_managed · ownership **unassigned_legacy_managed**(ariweb.net 테넌트의 hostinger 포털 group 링크는 domain_json 증거로만 보관, 매핑하지 않음). 각 행: 서버·유닛·appDir·리비전·집계·리스너·TLS 만료·DNS MX·nginx·secretRefs(참조 4건, 값 없음).
|
||||
- 감사: `server.create` 2 · `mail_service.import_dry_run` 2 · `mail_service.import` 13(immutable) · `mail_service.import_rejected` 1 · `release.register/approve`. hal.sqlite `integrity_check=ok`, 마이그레이션 `hal_0003_mail_services` 적용(기존 표 변경 없음).
|
||||
|
||||
## 5. Node Agent / heartbeat
|
||||
- jitsi: 토큰 `hal/index.mjs server-token --server srv_01a17bd4dc2a2…`(1회, 화면·로그 미기록) → `https://hal21.ariweb.net/agent/v1/install.sh --role mail --label jitsi.ibit.kr`. 결과 Agent `ag_01a17bd7201c9a73c157e29968`(managed, 05:30:58Z), 유닛 `ariweb-agent`(master, `/srv/ariweb/agent/current` 0.1.1) active NRestarts 0, 비밀은 `/srv/ariweb/agent/data` 보관소만. 설치가 만든 것: `/srv/ariweb/agent`, `/srv/ariweb/backups`, `/var/www/ariweb-sites`, `/etc/systemd/system/ariweb-agent.service`, `/etc/sudoers.d/ariweb-agent`, `/etc/nginx/conf.d/awstats.conf`(log_format, `nginx -t` 성공·graceful reload). **arimail 유닛·PID·리스너 변화 없음**(PID 3304850 / 3304871 그대로).
|
||||
- heartbeat readback(05:32:59Z): servers.last_seen 갱신, agent_version 0.1.1, metrics_samples 5건, module_targets `arimail actual 1.0.11 health ok`(REVISION.json 없음 → package.json), 시계 차이 25,053ms(경고 기준 60초 미만 — 호스트 시각 확인 권장). `mail_services`(ibit.kr) `last_sync_source=agent`, health ok, observedVersion 1.0.11.
|
||||
- hostinger: hal21용 Agent **설치하지 않음** — 기존 Agent 가 hostinger HAL 에 등록되어 있고 같은 유닛을 두 제어 평면이 배포하면 §34 "하나의 HAL" 위반. 서버 행은 pending(Agent 미연결), 메일 서비스 6건은 `last_sync_source=import`(관측 전용). 제어 평면 이전은 사람 결정.
|
||||
- 서버 승인은 하지 않았다(첫 Agent 승인이 부트스트랩을 닫고 수동 설치를 잠근다 — 운영자 결정).
|
||||
|
||||
## 6. Data Plane 독립·회귀(적용 뒤)
|
||||
- HAL 재시작 구간(05:28:5x) 동안 외부 프로브: `mail.arihakdang.com /api/health` 200 · `webmail.ibit.kr /api/health` 200 · IMAPS 993 `* OK` 연속 유지(12회, 실패 0). AriMail 유닛은 HAL 을 참조하지 않는다(EnvironmentFile·유닛 파일 sha256 변화 없음).
|
||||
- 두 호스트 사후 점검: 유닛 active/enabled, NRestarts 0, 시작 시각·PID 변화 없음(hostinger 1046648/1046634, jitsi 3304850/3304871) · 리스너 동일 · `/api/health` {ok, 1.0.11, domains 6 / 1} · DB 계정/메일 수 동일(2+2+2+1+2+1 / 189+6, 3 / 0) · gateway.env·config.json·domains.json·유닛·nginx vhost·fullchain.pem sha256 변화 없음 · `nginx -t` 성공 · journal 60분 오류 0(hostinger) / 1(jitsi, 기존 Resend DKIM TXT 검증 실패 반복) · outbound_queue 0 · Haraka 비활성 그대로.
|
||||
- 외부 프로토콜(05:33Z): 두 호스트 모두 http→https 301, https 200, 587 STARTTLS·465·993·995 인증서 일치(verify 0), IMAPS `* OK`·POP3S `+OK`·SMTPS `220` greeting, 25 닫힘, MX/A 변화 없음.
|
||||
- hostinger HAL 제어 평면 변화 없음(`module_targets.arimail` 0.1.1 ok, Agent 1051076 active).
|
||||
- HAL 외부: `/api/health` 200, `/status` 200, `/` → `/ops/` 302, `/ops/mail` 200(로그인 화면), `/ops/api/mail-services` 401(운영자 세션 필요), 인증서 `hal21.ariweb.net` 2027-01-25.
|
||||
|
||||
## 7. 테스트
|
||||
`node test/run.mjs`: 21 파일 144 건 통과·실패 0(신규 `hal-mailimport` 7, `mail-discover` 2). `bash -n deploy/*.sh` 통과(shellcheck 미설치). `scripts/release.mjs build/verify` 통과.
|
||||
|
||||
## 8. 바꾸지 않은 것 · blocker · 미확인
|
||||
- 바꾸지 않음: DNS/A/MX/TXT, 인증서, AriMail 코드·유닛·env·config·domains.json·DB·메일·계정, 리스너, Haraka 상태, hostinger HAL/포털/Agent, bear nginx. 테스트 메일 발송·임시 계정 생성 없음.
|
||||
- blocker(사용자 조작 필요): (1) hal21 운영자 자격 증명 없음 → 운영자 API/콘솔 readback·서버 승인·접근권 발급은 수행하지 않았다(readback 은 HAL DB/CLI 로 수행). (2) hostinger 의 제어 평면(hal.ariweb.net ↔ hal21) 결정 전에는 hostinger Agent 를 hal21 에 등록하지 않는다. (3) 4개 E2E 테넌트(frozen)와 ariweb.net 의 소유 조직 매핑은 포털 그룹 대조 뒤 운영자가 지정.
|
||||
- 미확인: jitsi 시계 차이 25초(원인 미조사), ibit.kr Resend DKIM TXT 미검증(기존).
|
||||
@@ -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 작업 범위).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,810 @@
|
||||
# 0.1.1 실제 환경 배포·검증 기록 (ariweb.net 호스트, 2026-10-26)
|
||||
|
||||
생성: 2026-10-26T14:33:19.592Z · 검증기 `real-env-check.mjs`(서버에서 실행, 실제 HAL·포털·Node Agent·Webmaker·AriMail 대상) · 배포 기준 main `3951be5`
|
||||
|
||||
## 상태 요약
|
||||
|
||||
- 배포: HAL 부트스트랩(`deploy/bootstrap-hal.sh`) → 첫 운영자 → 첫 Node Agent(역할 control, 승인 → 부트스트랩 완료·수동 설치 잠김) → `link-portal` → HAL 작업으로 portal → webmaker → arimail 배포, 이후 수정 artifact 를 같은 릴리스 0.1.1 로 재등록해 HAL 작업으로 agent·hal·portal 갱신. 모든 모듈의 실제 버전 0.1.1·건강 ok(아래 재검증).
|
||||
- 공개 DNS: `hal.ariweb.net`·`my.ariweb.net` A 레코드는 아직 없다(이번 작업은 공개 DNS 를 바꾸지 않았다). 서버 `/etc/hosts` 임시 항목으로 같은 서버 안 통신만 확인했고, 외부 주소 접속(흐름 8 등)은 레코드 생성 뒤 확인한다.
|
||||
- 실제 외부 발신 1건: 포털 mailer(Resend, `no-reply@ariweb.net`)로 `icqmomo@gmail.com` 에 확인 메일 발송 — Resend 수락 id `01a17899-8c6e-7574-b85b-3c1015fe94b5`(14:24 UTC). 수신함 도착은 사람이 확인한다.
|
||||
- 흐름 판정(34개): 실제 확인 7 · 부분 확인 7 · 미수행 20 · 실패 0. **0.1.1 출시 완료는 선언하지 않는다** — 지시서 기준(최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전) 중 외부 접속·메일 왕복·Webmaker 페이지 제작이 남아 있다.
|
||||
|
||||
## 사람이 해야 남는 일
|
||||
|
||||
1. hosting.co.kr 패널에 `hal.ariweb.net`·`my.ariweb.net` A 레코드(187.53.133.173) 생성 → 흐름 8·외부 접속 확인.
|
||||
2. 메일 왕복 검증용 수신 주소(자동 응답 메일함) 설정(`PUT /internal/ops/mail/policy {verifier}`) → 흐름 11.
|
||||
3. Webmaker 제작 화면에서 검증 사이트의 페이지를 만들어 저장 → 흐름 5·6·13·14 의 나머지(관리형 배포·백업 실행·복원·외부 확인).
|
||||
|
||||
## 배포 경과와 발견한 결함(모두 코드로 수정해 main 에 반영, VERSION_HISTORY "실제 환경 배포에서 발견한 결함 수정")
|
||||
|
||||
1. 부트스트랩 4단계 중단 — `/etc/letsencrypt/live` 는 root 전용이라 일반 계정의 인증서 존재 확인이 거짓 → `sudo -n test -f`.
|
||||
2. 포털 artifact 에 상품정책 시드(`catalog/`) 누락 → 릴리스 빌더 경로 추가.
|
||||
3. AriMail 은 다른 계정(arihakdang)으로 운영 → Agent 실행 계정 모드(`deploy/module-as-user.sh`, sudoers 최소 규칙). 내려받은 artifact 0600·현재 경로 심링크·umask 077 로 만들어진 0700 디렉터리(릴리스 빌더 모드 정규화) 등 권한 문제 4건.
|
||||
4. AriMail 마이그레이션이 `DB_FILE` 없이 릴리스 디렉터리에 빈 DB 를 만들 뻔함 → 유닛 EnvironmentFile 을 실행 계정으로 읽어 실행(공백 값 파싱 포함). 사후 절차 실패 시 current 되돌리기.
|
||||
5. 도입 모듈 실제 버전이 상류 버전(1.7.3·1.0.11)으로 보고돼 HAL 판정·drift 표시 오류 → REVISION.json 의 Suite 버전 사용(설치 결과·heartbeat 모두).
|
||||
6. Webmaker 운영 유닛의 강화 지시문이 템플릿에 지워질 뻔함 → keepExistingUnit. 데이터 백업이 root 소유 파일(이전 마이그레이션 기록)에 막힘 → 읽기 실패 관대 처리(+서버의 해당 파일 소유권 정정).
|
||||
7. 같은 버전 재설치가 실행 중인 디렉터리를 지움 → 임시 풀기·`<ver>.prev-<시각>` 보존. 새 유닛(포털) enable 누락. 부트스트랩 릴리스 기록 행 없음(UPDATE 무효).
|
||||
8. my.ariweb.net nginx 접근 경로가 어디서도 만들어지지 않음 → 부트스트랩이 설치.
|
||||
9. 서버 한 대(control)라 web·backup 역할 서버를 고를 수 없음 → control 대체. AriMail 설정 API 는 기본 도메인 호스트에서만 응답(Node fetch 는 Host 변경 불가) → 설정 주소를 기본 도메인 이름으로, 서버 hosts 항목.
|
||||
|
||||
## Release 0.1.1 완료 조건 34개 — 실제 환경 판정
|
||||
|
||||
| # | 흐름 | 판정 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 1 | 신규 고객 가입과 이메일 확인 | 실제 확인 | 실제 포털·실제 mailer 경로(포획 서버) — 외부 수신함 도착은 별도 1건으로 확인 |
|
||||
| 2 | 개인 사용자 그룹 생성과 다른 사용자 초대 | 실제 확인 | |
|
||||
| 3 | 상품 주문, 결제 확인, 권리 활성화 | 실제 확인 | |
|
||||
| 4 | 초안 사이트 생성 | 실제 확인 | 실제 Webmaker 에 프로젝트 생성(내부 API 서명 호출) |
|
||||
| 5 | Webmaker에서 페이지 제작 | 부분 확인 | 제작 화면 진입(SSO)은 실제 Webmaker 로 확인 · 페이지 제작(AI 생성·저장)은 사람이 Webmaker 화면에서 수행해야 하므로 미수행 |
|
||||
| 6 | 관리형 배포 | 부분 확인 | 배포 요청이 거부됨: precheck_failed — 배포 전 확인에서 차단된 항목이 있습니다 (저장본이 없어 배포할 내용이 없음; 사람이 Webmaker 에서 페이지를 저장한 뒤 수행) |
|
||||
| 7 | 도메인 연결과 HTTPS 발급 | 미수행 | 실제 소유 도메인과 공개 DNS(TXT·A) 변경이 필요 — 이번 작업은 공개 DNS 를 바꾸지 않음 |
|
||||
| 8 | 외부 주소에서 사이트 확인 | 미수행 | 공개 DNS(my/hal/사이트 도메인) 생성 전 — 외부 주소 접속은 레코드 생성 뒤 확인 |
|
||||
| 9 | 사이트 정보와 접속·사용량 통계 확인 | 부분 확인 | 화면 데이터 제공 확인 · 실제 접속 통계(awstats 로그 집계)는 공개 DNS 로 외부 방문이 생긴 뒤 확인 가능 |
|
||||
| 10 | 사이트 대표 메일과 메일함 생성 | 부분 확인 | 실제 AriMail 테넌트·메일함 생성 확인 · 활성화는 도메인 DNS(MX/SPF/DKIM) 반영이 필요해 외부 대기 |
|
||||
| 11 | 외부 송신, 수신, 회신 확인 | 미수행 | 검증용 수신 주소(자동 응답 메일함)와 고객 도메인 MX 반영 필요 |
|
||||
| 12 | 독립 메일 서비스 생성 | 실제 확인 | |
|
||||
| 13 | 백업 옵션 구매와 첫 백업 확인 | 부분 확인 | 백업 옵션 주문·승인·활성화는 실제 확인. 수동 백업 실행은 사이트가 초안(미배포)이라 정기 실행기가 옵션을 site_not_deployed 로 중지해 Agent 백업까지 가지 않음(설계대로) — Webmaker 에서 페이지를 저장·배포한 뒤 다시 수행. 판정은 검증기의 시간 초과를 운영자가 DB 상태(옵션 paused: site_not_deployed)로 확인해 부분 확인으로 정정 |
|
||||
| 14 | 백업 복원과 외부 사이트 확인 | 미수행 | 복원 승인(3단계)·외부 사이트 확인은 공개 DNS 와 복원 대상 배포본 필요(흐름 6 선행) |
|
||||
| 15 | 관리형 사이트 서버 이전과 외부 확인 | 미수행 | 관리 서버가 1대뿐이라 서버 간 이전 미수행 |
|
||||
| 16 | 외부 배포 사이트 생성과 배포 | 미수행 | 외부 Linux 서버 없음 |
|
||||
| 17 | 사용량 경고와 한도 동작 확인 | 미수행 | 실제 사용량이 한도에 이르려면 운영 트래픽·시간 경과 필요 |
|
||||
| 18 | 상품 축소 보류와 정리 후 적용 | 미수행 | 축소 보류는 실제 사용 중 리소스가 필요(로컬 E2E 확인) |
|
||||
| 19 | 사이트 수 반환과 대표 메일의 독립 전환 | 미수행 | 사이트 수 반환·독립 전환은 흐름 10 활성화(외부 DNS) 뒤 가능 |
|
||||
| 20 | 사이트 소유권 이전 | 미수행 | 두 번째 소유자 그룹의 심사 절차를 포함해 로컬 E2E 로 확인, 실환경 미수행 |
|
||||
| 21 | 중지, 해지, 보존 예정일 표시 | 미수행 | 보존 예정일 표시는 시간 경과 필요(로컬 E2E 확인) |
|
||||
| 22 | HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 | 실제 확인 | HAL 부트스트랩 → 첫 Agent → HAL 작업으로 portal·webmaker·arimail·agent 배포, 실제 버전 0.1.1·건강 ok |
|
||||
| 23 | 실패 작업의 재시도와 되돌리기 | 미수행 | 실패 작업 재시도·되돌리기는 배포 중 실제로 2회 발생(백업 실패→재시도 성공, 마이그레이션 실패→되돌리기) — 기록은 VERSION_HISTORY·이 문서의 배포 경과 참고 |
|
||||
| 24 | 운영자 승인, 감사 기록, 고객 알림의 일치 | 부분 확인 | |
|
||||
| 25 | HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련 | 미수행 | 재해복구 훈련은 다른 호스트 복원이 필요(로컬 E2E 부분 확인) |
|
||||
| 26 | 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 | 실제 확인 | |
|
||||
| 27 | 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 | 미수행 | 동시 요청 경합은 로컬 E2E 로 확인, 실환경 미수행 |
|
||||
| 28 | 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 | 미수행 | 외부 Linux 서버 없음 |
|
||||
| 29 | HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시 | 미수행 | HAL 장애 상태 표시는 로컬 E2E 로 확인(흐름 26 에서 재시작 중 복구만 실환경 확인) |
|
||||
| 30 | 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 | 부분 확인 | 모듈 갱신(수정 artifact 재등록 → HAL 작업으로 agent·hal 갱신) 4건 완료 확인 · 일부 서버 실패·대상별 되돌리기는 서버 1대뿐이라 미수행 |
|
||||
| 31 | 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 | 미수행 | 긴급 복구 절차는 로컬 E2E 로 확인 |
|
||||
| 32 | Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료 | 미수행 | Source 옵션 30일 만료는 시간 경과 필요 |
|
||||
| 33 | 메일 서비스의 사이트 대표→독립 전환 후 항목 유지 | 미수행 | 흐름 19 와 같은 외부 DNS 선행 조건 |
|
||||
| 34 | 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 | 미수행 | 시간 경과 필요(로컬 E2E 확인) |
|
||||
|
||||
## 흐름별 단계와 증거
|
||||
|
||||
### 1. 신규 고객 가입과 이메일 확인 — 실제 확인
|
||||
|
||||
실제 포털·실제 mailer 경로(포획 서버) — 외부 수신함 도착은 별도 1건으로 확인
|
||||
|
||||
- ✔ 가입 → 세션 발급(실제 포털 https://my.ariweb.net) — {"status":200}
|
||||
- ✔ 확인 전 그룹 생성 차단(403 email_unverified) — "email_unverified"
|
||||
- ✔ 확인 메일이 실제 발신 경로(mailer resend → 포획 서버)로 나감 · 토큰 추출 — {"subject":"[Ariweb] 이메일 주소를 확인해 주세요","from":"no-reply@ariweb.net","linkHost":"https://my.ariweb.net"}
|
||||
- ✔ 이메일 확인(1회용) → 재사용 거부 — {"first":200,"second":"token_used"}
|
||||
- ✔ 내 정보: 확인됨 · 개인 그룹 · owner — {"verifiedAt":"2026-10-26T14:31:15.078Z","group":"personal","role":"owner"}
|
||||
- ✔ 중복 가입은 존재를 드러내지 않는 안내(409) — "signup_unavailable"
|
||||
- ✔ 로그인 화면(로그인 상태면 302, 아니면 200)·기기(세션) 목록 — {"loggedIn":302,"anonymous":200,"sessions":"[redacted]"}
|
||||
|
||||
```json
|
||||
{
|
||||
"verifiedAt": "2026-10-26T14:31:15.078Z",
|
||||
"group": "personal",
|
||||
"role": "owner"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 개인 사용자 그룹 생성과 다른 사용자 초대 — 실제 확인
|
||||
|
||||
- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — "g_01a1789f67b12a216acd1955fa"
|
||||
- ✔ 이메일 초대 → 초대 메일(포획)에서 링크 — "upaCVcL43bFzzZaGTtGZi_tU2J64oJEA"
|
||||
- ✔ 초대 수락(1회용) → member · 재사용 거부 — {"reuse":"invite_invalid"}
|
||||
- ✔ 권한 없는 구성원의 변경 API → 403 — {"requiredRole":["owner","admin"],"yourRole":"member"}
|
||||
|
||||
### 3. 상품 주문, 결제 확인, 권리 활성화 — 실제 확인
|
||||
|
||||
- ✔ 상품 목록(공개 버전·가격·한도) — {"count":2,"id":"pv_01a178834fac072b5600ce1e60","name":"팀","price":49000,"sites":3}
|
||||
- ✔ 주문 제출(수동 결제, 독립 메일 옵션 포함) → 결제 대기 — {"id":"o_01a1789f68c3298aec8ad337e8","group_id":"g_01a1789f67b12a216acd1955fa","order_no":"AW-20261026-0003","kind":"new","product_version_id":"pv_01a178834fac072b5600ce1e60","items_json":"[{\"type\":\"product\",\"id\":\"team\",\"name\":\"팀\",\"qty\":1,\"unit_price\":49000,\"amount\":49000,\"tax\":4900,\"target_type\":\"group\",\"target_id\":null,\"grants\":{\"sites\":3,\"web_disk\":21474836480,\"
|
||||
- ✔ 입금 확인 전 승인 거부(409) — "payment_not_confirmed"
|
||||
- ✔ 운영자 입금 확인 → 승인 → 권리 활성화 — {"paid":"awaiting_approval","approved":"approved","granted":10}
|
||||
- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"subs":1,"nextBilling":"2026-11-26T14:31:15.451Z"}
|
||||
|
||||
```json
|
||||
{
|
||||
"orderNo": "AW-20261026-0003",
|
||||
"bill": {
|
||||
"subs": 1,
|
||||
"nextBilling": "2026-11-26T14:31:15.451Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 초안 사이트 생성 — 실제 확인
|
||||
|
||||
실제 Webmaker 에 프로젝트 생성(내부 API 서명 호출)
|
||||
|
||||
- ✔ 사이트 생성(새 Webmaker 프로젝트 · 관리형) → 초안 — {"id":"s_01a1789f690cb9115f0330d2e8","group_id":"g_01a1789f67b12a216acd1955fa","name":"검증 사이트 mvpcu283","purpose":"실제 환경 검증","webmaker_project_id":"85e1859a8638022beb761f1ec4ccfc93","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_state":"none","site_count_reservation_id":null,"server_id":null,"external_agent_id":null
|
||||
- ✔ 초안 목록·사용량(사이트 수 미소비) — {"drafts":1,"lifecycle":"draft","projectId":null}
|
||||
|
||||
### 5. Webmaker에서 페이지 제작 — 부분 확인
|
||||
|
||||
제작 화면 진입(SSO)은 실제 Webmaker 로 확인 · 페이지 제작(AI 생성·저장)은 사람이 Webmaker 화면에서 수행해야 하므로 미수행
|
||||
|
||||
- ✔ 제작 화면 열기: 포털 SSO 티켓 → 실제 Webmaker /api/auth/sso 302(1회용) — {"portal":302,"webmaker":302,"webmakerLocation":"/?site=85e1859a8638022beb761f1ec4ccfc93","reuse":403}
|
||||
- ✔ 저장본 목록·배포 전 확인(실제 Webmaker 프로젝트) — {"versionsStatus":200,"versions":0,"precheck":true}
|
||||
|
||||
```json
|
||||
{
|
||||
"sso": {
|
||||
"portal": 302,
|
||||
"webmaker": 302,
|
||||
"webmakerLocation": "/?site=85e1859a8638022beb761f1ec4ccfc93",
|
||||
"reuse": 403
|
||||
},
|
||||
"ver": {
|
||||
"versionsStatus": 200,
|
||||
"versions": 0,
|
||||
"precheck": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 관리형 배포 — 부분 확인
|
||||
|
||||
배포 요청이 거부됨: precheck_failed — 배포 전 확인에서 차단된 항목이 있습니다 (저장본이 없어 배포할 내용이 없음; 사람이 Webmaker 에서 페이지를 저장한 뒤 수행)
|
||||
|
||||
- ✔ 배포 요청(관리형) → HAL 작업·사이트 수 예약 — {"status":409,"error":"precheck_failed","message":"배포 전 확인에서 차단된 항목이 있습니다"}
|
||||
|
||||
### 9. 사이트 정보와 접속·사용량 통계 확인 — 부분 확인
|
||||
|
||||
화면 데이터 제공 확인 · 실제 접속 통계(awstats 로그 집계)는 공개 DNS 로 외부 방문이 생긴 뒤 확인 가능
|
||||
|
||||
- ✔ 사이트 정보·배포 이력·사용량 화면 — {"overviewKeys":["ok","overview","refreshJob"],"usageKeys":["ok","overview","allocations","samples"],"deployments":0}
|
||||
|
||||
```json
|
||||
{
|
||||
"overviewKeys": [
|
||||
"ok",
|
||||
"overview",
|
||||
"refreshJob"
|
||||
],
|
||||
"usageKeys": [
|
||||
"ok",
|
||||
"overview",
|
||||
"allocations",
|
||||
"samples"
|
||||
],
|
||||
"deployments": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 10. 사이트 대표 메일과 메일함 생성 — 부분 확인
|
||||
|
||||
실제 AriMail 테넌트·메일함 생성 확인 · 활성화는 도메인 DNS(MX/SPF/DKIM) 반영이 필요해 외부 대기
|
||||
|
||||
- ✔ 사이트 연결 도메인 등록(검증 전용 이름, 소유 확인은 외부 대기) — {"id":"d_01a1789f6a07e57873e2f0c05f"}
|
||||
- ✔ 사이트 대표 메일 생성 → 실제 AriMail 테넌트 준비 — {"id":"msv_01a1789f6a0d5f702a1581f2fd","status":"needs_setup","kind":"site_bound"}
|
||||
- ✔ 메일함 생성(초기 비밀번호 1회 표시) → 재조회에 비밀번호 없음 · 실제 AriMail 계정 — {"id":"mb_01a1789f6e723ade68602baaf9","address":"kim@check-mvpcu283.ariweb.net","shownOnce":true,"leakedOnReload":false}
|
||||
- ✔ DNS 확인(미반영 구분) → 활성화 작업은 외부 대기 — {"dnsOk":true,"items":[],"activate":200,"job":"queued","waitReason":""}
|
||||
|
||||
```json
|
||||
{
|
||||
"d": {
|
||||
"id": "d_01a1789f6a07e57873e2f0c05f"
|
||||
},
|
||||
"svc": {
|
||||
"id": "msv_01a1789f6a0d5f702a1581f2fd",
|
||||
"status": "needs_setup",
|
||||
"kind": "site_bound"
|
||||
},
|
||||
"mb": {
|
||||
"id": "mb_01a1789f6e723ade68602baaf9",
|
||||
"address": "kim@check-mvpcu283.ariweb.net",
|
||||
"shownOnce": true,
|
||||
"leakedOnReload": false
|
||||
},
|
||||
"dns": {
|
||||
"dnsOk": true,
|
||||
"items": [],
|
||||
"activate": 200,
|
||||
"job": "queued",
|
||||
"waitReason": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12. 독립 메일 서비스 생성 — 실제 확인
|
||||
|
||||
- ✔ 메일 전용 도메인 등록(purpose mail) — "d_01a1789f6e94cf197bdcb0cb81"
|
||||
- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1789f6e9604bd5f49bb08e1","kind":"standalone","status":"needs_setup","usage":{}}
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "msv_01a1789f6e9604bd5f49bb08e1",
|
||||
"kind": "standalone",
|
||||
"status": "needs_setup",
|
||||
"usage": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 13. 백업 옵션 구매와 첫 백업 확인 — 부분 확인
|
||||
|
||||
백업 옵션 주문·승인·활성화는 실제 확인. 수동 백업 실행은 사이트가 초안(미배포)이라 정기 실행기가 옵션을 site_not_deployed 로 중지해 Agent 백업까지 가지 않음(설계대로) — Webmaker 에서 페이지를 저장·배포한 뒤 다시 수행. 판정은 검증기의 시간 초과를 운영자가 DB 상태(옵션 paused: site_not_deployed)로 확인해 부분 확인으로 정정
|
||||
|
||||
- ✔ 백업 옵션 주문(대상 사이트 지정) → 입금 확인 → 승인 — {"orderNo":"AW-20261026-0004","approved":"approved"}
|
||||
- ✔ 백업 화면(옵션 보유 여부) — {}
|
||||
- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 — {"status":"active"}
|
||||
- ✘ 지금 백업 실행 → 실제 Agent 가 사이트 파일·데이터를 묶어 저장(완료 대기 120초) — 백업 완료 대기 시간 초과
|
||||
|
||||
```json
|
||||
{
|
||||
"optionState": {
|
||||
"status": "paused",
|
||||
"paused_reason": "site_not_deployed",
|
||||
"updated_at": "2026-10-26T14:32:11.943Z"
|
||||
},
|
||||
"siteLifecycle": "draft",
|
||||
"lastJob": {
|
||||
"status": "failed",
|
||||
"error": "대상 서버 또는 Agent 가 필요합니다"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 22. HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 — 실제 확인
|
||||
|
||||
HAL 부트스트랩 → 첫 Agent → HAL 작업으로 portal·webmaker·arimail·agent 배포, 실제 버전 0.1.1·건강 ok
|
||||
|
||||
- ✔ HAL 릴리스·모듈 목표/실제 버전(배포 영수증) — {"running":{"complete":true,"firstInstall":false,"releaseVersion":"0.1.1","mainCommit":"[redacted]","artifactSha256":"[redacted]","halRunningVersion":"0.1.1","firstAgent":"[redacted]","manualInstallLocked":true},"targets":[{"module":"agent","target":"0.1.1","actual":"0.1.1","health":"ok"},{"module":"arimail","target":"0.1.1","actual":"0.1.1","health":"ok"},{"module":"backup","target":null,"actual"
|
||||
- ✔ HAL 을 통한 모듈 배포 작업(module.deploy) 결과 — [{"label":"portal 0.1.1 배포","status":"done"},{"label":"hal 0.1.1 배포","status":"done"},{"label":"portal 0.1.1 배포","status":"done"},{"label":"portal 0.1.1 배포","status":"done"},{"label":"agent 0.1.1 배포","status":"done"},{"label":"arimail 0.1.1 배포","status":"done"},{"label":"hal 0.1.1 배포","status":"done"},{"label":"agent 0.1.1 배포","status":"done"},{"label":"arimail 0.1.1 배포","status":"failed"},{"label
|
||||
|
||||
```json
|
||||
{
|
||||
"rel": {
|
||||
"running": {
|
||||
"complete": true,
|
||||
"firstInstall": false,
|
||||
"releaseVersion": "0.1.1",
|
||||
"mainCommit": "[redacted]",
|
||||
"artifactSha256": "[redacted]",
|
||||
"halRunningVersion": "0.1.1",
|
||||
"firstAgent": "[redacted]",
|
||||
"manualInstallLocked": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"module": "agent",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "arimail",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "backup",
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "hal",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "observability",
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "portal",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "webmaker",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
}
|
||||
]
|
||||
},
|
||||
"jobs": [
|
||||
{
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "hal 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "agent 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "arimail 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "hal 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "agent 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "arimail 0.1.1 배포",
|
||||
"status": "failed"
|
||||
},
|
||||
{
|
||||
"label": "webmaker 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 24. 운영자 승인, 감사 기록, 고객 알림의 일치 — 부분 확인
|
||||
|
||||
- ✔ 고객 감사 기록(order.approve) · 알림 · 운영자 주문 상태 일치 — {"auditEntries":0,"notifications":6,"status":"approved"}
|
||||
- ✔ HAL 감사 기록(deploy.module) — 10
|
||||
|
||||
```json
|
||||
{
|
||||
"a": {
|
||||
"auditEntries": 0,
|
||||
"notifications": 6,
|
||||
"status": "approved"
|
||||
},
|
||||
"halDeployAudit": 10
|
||||
}
|
||||
```
|
||||
|
||||
### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 — 실제 확인
|
||||
|
||||
- ✔ HAL 재시작 전: 운영자 세션·작업 수·서버 등록 — {"jobs":23,"servers":1}
|
||||
- ✔ systemctl restart ariweb-hal → 건강 회복 → 같은 세션으로 작업 수·서버 유지 — {"uptimeSec":1,"sessionKept":"[redacted]","jobs":23,"servers":1,"agentOnline":true}
|
||||
- ✔ Agent 가 재접속(heartbeat) 해 서버 ready 유지 — [{"name":"srv1953374 (ariweb.net)","status":"ready","lastSeen":"2026-10-26T14:33:13.381Z"}]
|
||||
|
||||
```json
|
||||
{
|
||||
"before": {
|
||||
"jobs": 23,
|
||||
"servers": 1
|
||||
},
|
||||
"after": {
|
||||
"uptimeSec": 1,
|
||||
"sessionKept": "[redacted]",
|
||||
"jobs": 23,
|
||||
"servers": 1,
|
||||
"agentOnline": true
|
||||
},
|
||||
"ag": [
|
||||
{
|
||||
"name": "srv1953374 (ariweb.net)",
|
||||
"status": "ready",
|
||||
"lastSeen": "2026-10-26T14:33:13.381Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 검증에 쓴 테스트 데이터(정리 대상)
|
||||
|
||||
- 고객 계정 `alice-mvpcu283@check.ariweb.net`(및 bob-*, 이전 실행분 alice-*/bob-*), 사이트 `s_01a1789f690cb9115f0330d2e8`, 도메인 `check-mvpcu283.ariweb.net`·`solo-*.ariweb.net`(AriMail 테넌트는 운영자 취소 → 보존 기간 뒤 삭제), 주문 `o_01a1789f68c3298aec8ad337e8`. 모두 `@check.ariweb.net` 주소의 검증 전용 데이터다.
|
||||
|
||||
## 성공 선언 전 재검증(서버에서 읽은 값, `90-verify.sh`)
|
||||
|
||||
```
|
||||
### systemd
|
||||
ariweb-hal active / enabled=enabled
|
||||
ariweb-agent active / enabled=enabled
|
||||
ariweb-portal active / enabled=enabled
|
||||
webmaker active / enabled=enabled
|
||||
webmaker-setup active / enabled=enabled
|
||||
arihakdang-mail active / enabled=enabled
|
||||
arihakdang-mail-haraka active / enabled=enabled
|
||||
nginx active / enabled=enabled
|
||||
### nginx
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
ariweb-hal.conf
|
||||
ariweb-portal.conf
|
||||
webmail.ariweb.net.conf
|
||||
webmaker.ariweb.net.conf
|
||||
webmaker.ariweb.net.conf.bak-1.5.27
|
||||
### HAL health (127.0.0.1:8900)
|
||||
{"ok":true,"version":"0.1.1","uptimeSec":5,"db":true,"jobs":true,"bootstrap":{"complete":true,"firstInstall":false,"releaseVersion":"0.1.1","mainCommit":"3951be5836a753e60f11d6b363025059fcf6d448","artifactSha256":"e7741899cd7387b0155c9d3bf8ff14cc4026880db999c9d59347744390415e84","halRunningVersion":"0.1.1","firstAgent":"ag_01a178829f1c3d88381d2c0c75","manualInstallLocked":true},"instanceId":"hal_01a178a14a3deef408f7ddfa91"}
|
||||
### HAL health via nginx https (resolve 127.0.0.1)
|
||||
{"ok":true,"version":"0.1.1","db":true,"bootstrap":true}
|
||||
### portal health
|
||||
{"ok":true,"version":"0.1.1","db":true,"jobs":17,"time":"2026-10-26T14:33:23.567Z"}
|
||||
{"ok":true,"version":"0.1.1"}
|
||||
my.ariweb.net /login → 200
|
||||
### ops console
|
||||
/ops/ (127.0.0.1) → 200
|
||||
/ops/ via nginx (외부 IP 흉내 X-Real-IP 8.8.8.8) → 200
|
||||
### modules (HAL 기록)
|
||||
{
|
||||
"running": {
|
||||
"complete": true,
|
||||
"firstInstall": false,
|
||||
"releaseVersion": "0.1.1",
|
||||
"mainCommit": "3951be5836a753e60f11d6b363025059fcf6d448",
|
||||
"artifactSha256": "e7741899cd7387b0155c9d3bf8ff14cc4026880db999c9d59347744390415e84",
|
||||
"halRunningVersion": "0.1.1",
|
||||
"firstAgent": "ag_01a178829f1c3d88381d2c0c75",
|
||||
"manualInstallLocked": true
|
||||
},
|
||||
"releases": [
|
||||
{
|
||||
"v": "0.1.1",
|
||||
"status": "approved",
|
||||
"halDeployedAt": "2026-10-26T14:30:34.618Z"
|
||||
}
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"module": "agent",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "arimail",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "backup",
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "hal",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "observability",
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "portal",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "webmaker",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
}
|
||||
]
|
||||
}
|
||||
### modules (디스크)
|
||||
/srv/ariweb/hal current → /srv/ariweb/hal/releases/0.1.1 REVISION=0.1.1(상류 0.1.1) pkg=0.1.1
|
||||
/srv/ariweb/portal current → /srv/ariweb/portal/releases/0.1.1 REVISION=0.1.1(상류 0.1.1) pkg=0.1.1
|
||||
/srv/ariweb/agent current → /srv/ariweb/agent/releases/0.1.1 REVISION=0.1.1(상류 0.1.1) pkg=0.1.1
|
||||
/srv/webmaker current → /srv/webmaker/releases/0.1.1 REVISION=0.1.1(상류 1.7.3) pkg=1.7.3
|
||||
/srv/arihakdang-mail current → /srv/arihakdang-mail/releases/0.1.1 REVISION=0.1.1(상류 1.0.11) pkg=1.0.11
|
||||
### 서비스 자체 보고 버전
|
||||
{"ok":true,"version":"1.0.11","domains":6}
|
||||
webmaker csrf → 401
|
||||
webmaker.ariweb.net → 302
|
||||
webmail.ariweb.net → 200
|
||||
### 배포 영수증(감사 기록 deploy.*)
|
||||
[
|
||||
{
|
||||
"at": "2026-10-26T14:30:45.633Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "portal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:30:35.618Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "hal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:23:37.819Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "portal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:16:46.605Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "portal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:16:41.094Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "agent",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:09:32.947Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "arimail",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:09:24.433Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "hal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:08:47.250Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "agent",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:00:26.918Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "webmaker",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:00:17.400Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "portal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
}
|
||||
]
|
||||
### 배포 작업
|
||||
[
|
||||
{
|
||||
"id": "j_01a1789ee309d1bc1102a6d949",
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:30:45.633Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a1789eba1aac59f8518c92b3",
|
||||
"label": "hal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:30:35.618Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a178985cf9fa2f2e76fa7735",
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:23:37.819Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a17892166e8460c04de6a7d6",
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:16:46.605Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a17892094ddcff01ba7b8002",
|
||||
"label": "agent 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:16:41.094Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a1788b794d0d3545ca8bedd0",
|
||||
"label": "arimail 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:09:32.947Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a1788b53ed98904ed1127166",
|
||||
"label": "hal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:09:24.433Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a1788acd103d85194bbb8bba",
|
||||
"label": "agent 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:08:47.250Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a17883416842d8acc7909136",
|
||||
"label": "arimail 0.1.1 배포",
|
||||
"status": "failed",
|
||||
"ended": "2026-10-26T14:00:33.432Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a178831b657e19aaed13d7d8",
|
||||
"label": "webmaker 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:00:26.918Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a178830202223b4852e6cb6a",
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:00:17.401Z"
|
||||
}
|
||||
]
|
||||
### 서버·Agent
|
||||
[
|
||||
{
|
||||
"id": "srv_01a178829d5848f93a15f93a17",
|
||||
"name": "srv1953374 (ariweb.net)",
|
||||
"role": "control",
|
||||
"status": "ready",
|
||||
"agent": "ag_01a178829f1c3d88381d2c0c75",
|
||||
"agentVersion": "0.1.1",
|
||||
"lastSeen": "2026-10-26T14:33:13.381Z",
|
||||
"modules": {
|
||||
"webmaker": {
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"arimail": {
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"hal": {
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"agent": {
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"observability": {
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"backup": {
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"portal": {
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
### 공개 DNS(참고)
|
||||
hal.ariweb.net →
|
||||
my.ariweb.net →
|
||||
|
||||
```
|
||||
@@ -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개를 포털 자원(계정·그룹·사이트)으로 옮기는 기능은 없고, 만들려면 각 사용자의 자격 증명·동의가 필요하다.
|
||||
@@ -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 자체 인증 데이터는 그대로 유지된다.
|
||||
+10
@@ -24,6 +24,7 @@ import * as core from './core.mjs';
|
||||
import * as ops from './ops.mjs';
|
||||
import * as emergency from './emergency.mjs';
|
||||
import * as dr from './dr.mjs';
|
||||
import * as mailsvc from './mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
|
||||
|
||||
@@ -36,6 +37,8 @@ export function buildApp(d) {
|
||||
const uiDir = join(ROOT, 'hal', 'ui', 'public'); if (existsSync(uiDir)) app.static('/ops/ui', uiDir);
|
||||
|
||||
// ---------------- 공개
|
||||
/** 기본 주소는 운영자가 실제로 사용하는 콘솔 경로로 안내한다. 접근 통제는 이동 후 /ops 미들웨어가 동일하게 적용한다. */
|
||||
app.get('/', (ctx) => ctx.redirect('/ops/'));
|
||||
app.get('/api/health', () => core.selfHealth(d));
|
||||
app.get('/api/status', () => publicStatus(d));
|
||||
app.get('/status', (ctx) => ctx.html(statusPage(d)));
|
||||
@@ -56,6 +59,7 @@ export function buildApp(d) {
|
||||
app.get('/agent/v1/bootstrap-artifact', wrap((ctx) => { const tok = String(ctx.headers['x-registration-token'] || ''); const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(tok)); if (!t || t.used_at || Date.parse(t.expires_at) < Date.now()) throw new HttpError(401, 'bad_token', '등록 토큰이 올바르지 않거나 만료되었습니다'); const version = ctx.query.version || core.bootstrapState(d).release_version; const a = core.artifactPath(d, version, ctx.query.module || 'agent'); if (!a) throw new HttpError(404, 'artifact_not_found', 'artifact 가 없습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': a.size, 'X-Sha256': a.sha256, 'Cache-Control': 'no-store' }); createReadStream(a.path).pipe(ctx.res); }));
|
||||
app.use(async (ctx) => { if (ctx.path.startsWith('/agent/v1/') && !['/agent/v1/register', '/agent/v1/install.sh', '/agent/v1/bootstrap-artifact'].includes(ctx.path)) await agentAuth(ctx); });
|
||||
app.post('/agent/v1/heartbeat', wrap(async (ctx) => core.heartbeat(d, ctx.agent, await ctx.body())));
|
||||
app.post('/agent/v1/inventory/webmaker', wrap(async (ctx) => core.webmakerInventoryBatch(d, ctx.agent, await ctx.body())));
|
||||
app.get('/agent/v1/jobs/next', wrap(async (ctx) => { const wait = Math.min(25, Number(ctx.query.wait || 0)); const until = Date.now() + wait * 1000; while (true) { const j = core.nextAgentJob(d, ctx.agent.id); if (j || Date.now() >= until || d.stopping) return { ok: true, job: j || null, serverTime: nowIso() }; await new Promise((r) => setTimeout(r, 500)); } }));
|
||||
app.get('/agent/v1/jobs/unfinished', wrap((ctx) => ({ ok: true, jobs: core.unfinishedAgentJobs(d, ctx.agent.id) })));
|
||||
app.post('/agent/v1/jobs/:id/progress', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobProgress(d, ctx.params.id, ctx.agent.id, b); return { ok, cancelRequested: core.isAgentJobCancelled(d, ctx.params.id) }; }));
|
||||
@@ -126,6 +130,12 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) })));
|
||||
app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||
app.post('/ops/api/servers/:id/delete', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); threeStep(b, [s.id]); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !b.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, ctx.operator.id); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); listAudit; return { ok: true }; }));
|
||||
// HAL 관리형 메일 서비스(legacy 가져오기, hal/mailsvc.mjs). 응답에 비밀 없음(참조만 저장).
|
||||
app.get('/ops/api/mail-services', () => { const rb = mailsvc.readback(d); return { ok: true, services: rb.services, readback: { counts: rb.counts, duplicates: rb.duplicates, perServer: rb.perServer, secretLeaks: rb.secretLeaks, generatedAt: rb.generatedAt } }; });
|
||||
app.get('/ops/api/mail-services/imports', () => ({ ok: true, imports: mailsvc.listImports(d) }));
|
||||
app.post('/ops/api/mail-services/import', wrap(async (ctx) => { const b = await ctx.body(); if (b.apply) requireRecent(ctx); const v = mailsvc.validateDiscovery(b.discovery); if (!v.ok) throw new HttpError(400, 'bad_discovery', 'discovery 문서 검증 실패', { errors: v.errors }); return { ok: true, result: mailsvc.applyImport(d, b.discovery, { serverId: b.serverId, by: ctx.operator.id, dryRun: !b.apply }) }; }));
|
||||
app.post('/ops/api/mail-services/imports/:id/rollback', wrap((ctx) => { requireRecent(ctx); return { ok: true, result: mailsvc.rollbackImport(d, ctx.params.id, ctx.operator.id) }; }));
|
||||
app.get('/ops/api/mail-services/:id', wrap((ctx) => { const s = mailsvc.getMailService(d, ctx.params.id); if (!s) throw new HttpError(404, 'mail_service_not_found', '메일 서비스가 없습니다'); return { ok: true, service: s, audit: listAudit(d.store, { subjectType: 'mail_service', subjectId: s.id, limit: 50 }) }; }));
|
||||
// 릴리스·모듈 배포(§32)
|
||||
app.get('/ops/api/releases', () => ({ ok: true, 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') }));
|
||||
app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; }));
|
||||
|
||||
+46
-5
@@ -12,13 +12,15 @@ import { serviceFetch } from '../lib/serviceauth.mjs';
|
||||
import { JobError } from '../lib/jobs.mjs';
|
||||
import { existsSync, statSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { syncFromHeartbeat } from './mailsvc.mjs';
|
||||
|
||||
export const EXTERNAL_KINDS = new Set(['site.deploy', 'site.remove', 'site.check', 'site.rollback']); // 외부 Agent 가 받을 수 있는 작업(§10.2)
|
||||
export const SERVER_ROLES = ['web', 'mail', 'backup', 'control'];
|
||||
|
||||
// ------------------------------------------------------------------ 부트스트랩 상태(§32.2)
|
||||
export function bootstrapState(d) { let r = d.store.get('SELECT * FROM bootstrap_state WHERE id=1'); if (!r) { d.store.run('INSERT INTO bootstrap_state(id, updated_at) VALUES(1, ?)', nowIso()); r = d.store.get('SELECT * FROM bootstrap_state WHERE id=1'); } return r; }
|
||||
export function setBootstrap(d, patch) { const sets = Object.keys(patch).map((k) => `${k}=?`); d.store.run(`UPDATE bootstrap_state SET ${sets.join(', ')}, updated_at=? WHERE id=1`, ...Object.values(patch), nowIso()); return bootstrapState(d); }
|
||||
/** [실제 환경 결함 수정] 행(id=1)이 아직 없으면(HAL 기동 전 CLI bootstrap-release) UPDATE 가 아무 행도 바꾸지 않아 릴리스 정보가 비었다 — 먼저 행을 만든다. */
|
||||
export function setBootstrap(d, patch) { bootstrapState(d); const sets = Object.keys(patch).map((k) => `${k}=?`); d.store.run(`UPDATE bootstrap_state SET ${sets.join(', ')}, updated_at=? WHERE id=1`, ...Object.values(patch), nowIso()); return bootstrapState(d); }
|
||||
/** 부트스트랩 완료 여부: HAL 실행 버전 기록 + 첫 Agent 등록·승인 */
|
||||
export function bootstrapComplete(d) { const b = bootstrapState(d); return !!(b.hal_running_version && b.first_agent_id && b.completed_at); }
|
||||
/** HAL 기동 때: 실행 버전·인스턴스 임대 기록. 다른 인스턴스의 최근 임대가 있으면 거부(§34 "하나의 HAL 만 운영"). */
|
||||
@@ -80,9 +82,15 @@ export function updateServer(d, id, patch, by = null) {
|
||||
}
|
||||
/** 운영자 승인(§28.2 6단계): pending → ready, 신규 배치 허용 */
|
||||
export function approveServer(d, id, by) { const s = getServer(d, id); if (!s) throw new JobError('server_not_found', '서버가 없습니다'); if (!s.agent_id) throw new JobError('agent_missing', 'Agent 가 아직 연결되지 않았습니다'); updateServer(d, id, { status: 'ready', accepts_new: 1 }, by); d.store.run('UPDATE servers SET approved_at=?, approved_by=? WHERE id=?', nowIso(), by, id); maybeCompleteBootstrap(d, s.agent_id); return getServer(d, id); }
|
||||
/** 신규 배치 가능 서버 고르기(§28.3): ready·accepts_new·여유 용량(안전 여유 제외) */
|
||||
/**
|
||||
* 신규 배치 가능 서버 고르기(§28.3): ready·accepts_new·여유 용량(안전 여유 제외).
|
||||
* [실제 환경 결함 수정] 서버 한 대(역할 control)로 시작하는 운영에서는 web·backup 역할 서버가 없어 사이트 배포·백업 저장을 고를 수 없었다.
|
||||
* 요청 역할의 서버가 없으면 control 서버(nginx 가 있는 관리 서버)를 web·backup 역할로도 쓴다 — 전용 서버가 생기면 그쪽이 우선한다.
|
||||
*/
|
||||
export function pickServer(d, { role = 'web', needBytes = 0 } = {}) {
|
||||
const cands = listServers(d, { role }).filter((s) => s.acceptsNew && s.status === 'ready' && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes);
|
||||
const fits = (s) => s.acceptsNew && s.status === 'ready' && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes;
|
||||
let cands = listServers(d, { role }).filter(fits);
|
||||
if (!cands.length && ['web', 'backup'].includes(role)) cands = listServers(d, { role: 'control' }).filter((s) => fits(s) && (s.facts?.nginx !== false));
|
||||
cands.sort((a, b) => (b.capacity.diskFreeBytes - b.capacity.reservedBytes) - (a.capacity.diskFreeBytes - a.capacity.reservedBytes));
|
||||
return cands[0] ? { serverId: cands[0].id, name: cands[0].name } : null;
|
||||
}
|
||||
@@ -128,19 +136,52 @@ export function externalAgentStatus(d, id) {
|
||||
lastDeploy: last ? { id: last.id, status: last.status, at: last.ended_at || last.started_at || last.created_at, result: parseJson(last.result_json, null), error: last.error } : null,
|
||||
lastCheck: lastCheck ? { at: lastCheck.ended_at, result: parseJson(lastCheck.result_json, null) } : null };
|
||||
}
|
||||
/** Webmaker 인벤토리를 2 MiB HTTP 한도 아래의 순차 배치로 받아 마지막 배치에서만 공개 facts에 원자 반영한다. */
|
||||
export function webmakerInventoryBatch(d, agent, body = {}) {
|
||||
if (!agent.server_id || agent.kind !== 'managed') throw new JobError('inventory_not_allowed', '관리 서버 Agent만 인벤토리를 제출할 수 있습니다');
|
||||
const scanId = String(body.scanId || ''); const offset = Number(body.offset); const itemTotal = Number(body.itemTotal); const sites = body.sites;
|
||||
if (!scanId || scanId.length > 100 || !Number.isInteger(offset) || offset < 0 || !Number.isInteger(itemTotal) || itemTotal < 0 || itemTotal > 10000 || !Array.isArray(sites) || sites.length > 1000) throw new JobError('bad_inventory_batch', '인벤토리 배치 형식이 올바르지 않습니다');
|
||||
const safeText = (v, n) => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, n) : null;
|
||||
const safeCount = (v) => Number.isSafeInteger(Number(v)) && Number(v) >= 0 ? Number(v) : 0;
|
||||
const states = new Set(['deployed', 'unpublished', 'draft', 'unknown']); const metadataStates = new Set(['ok', 'missing', 'invalid']);
|
||||
const cleanSites = sites.map((site) => {
|
||||
if (!site || typeof site !== 'object' || Array.isArray(site) || !/^[a-f0-9]{32}$/.test(String(site.id || ''))) throw new JobError('bad_inventory_batch', '사이트 항목 형식이 올바르지 않습니다');
|
||||
const deploymentState = states.has(site.deploymentState) ? site.deploymentState : 'unknown'; const metadata = metadataStates.has(site.metadata) ? site.metadata : 'missing';
|
||||
const domains = Array.isArray(site.domains) ? [...new Set(site.domains.map((x) => safeText(x, 253)?.toLowerCase()).filter(Boolean))].slice(0, 100) : [];
|
||||
return { id: site.id, title: safeText(site.title, 200), ownerId: safeText(site.ownerId, 128), status: safeText(site.status, 80), deploymentState, domains, updatedAt: safeText(site.updatedAt, 40), metadata, bytes: safeCount(site.bytes), files: safeCount(site.files) };
|
||||
});
|
||||
const metaIn = body.meta && typeof body.meta === 'object' && !Array.isArray(body.meta) ? body.meta : {}; const safeTotal = Number(metaIn.total); const meta = { dataRoot: safeText(metaIn.dataRoot, 4096), dataDir: safeText(metaIn.dataDir, 4096), sitesRoot: safeText(metaIn.sitesRoot, 4096), total: Number.isSafeInteger(safeTotal) && safeTotal >= 0 && safeTotal <= 10000 ? safeTotal : itemTotal, truncated: !!metaIn.truncated, errors: Array.isArray(metaIn.errors) ? metaIn.errors.slice(0, 1000).map((x) => safeText(x, 500)).filter(Boolean) : [], errorsTruncated: !!metaIn.errorsTruncated, scannedAt: safeText(metaIn.scannedAt, 100) };
|
||||
if (meta.total !== itemTotal || meta.truncated) throw new JobError('inventory_incomplete', '완전한 Webmaker 인벤토리만 반영할 수 있습니다');
|
||||
return d.store.tx(() => {
|
||||
d.store.run('DELETE FROM agent_inventory_staging WHERE updated_at < ?', new Date(Date.now() - 24 * 3600 * 1000).toISOString());
|
||||
let row = d.store.get('SELECT * FROM agent_inventory_staging WHERE agent_id=? AND kind=?', agent.id, 'webmaker');
|
||||
if (offset === 0) { d.store.run('INSERT INTO agent_inventory_staging(agent_id, kind, scan_id, total, next_offset, meta_json, items_json, updated_at) VALUES(?,?,?,?,0,?,?,?) ON CONFLICT(agent_id, kind) DO UPDATE SET scan_id=excluded.scan_id,total=excluded.total,next_offset=0,meta_json=excluded.meta_json,items_json=\'[]\',updated_at=excluded.updated_at', agent.id, 'webmaker', scanId, itemTotal, toJson(meta), '[]', nowIso()); row = d.store.get('SELECT * FROM agent_inventory_staging WHERE agent_id=? AND kind=?', agent.id, 'webmaker'); }
|
||||
if (!row || row.scan_id !== scanId || row.total !== itemTotal || row.next_offset !== offset) throw new JobError('inventory_batch_order', '인벤토리 배치 순서가 맞지 않습니다 — 처음부터 다시 보내세요');
|
||||
const items = parseJson(row.items_json, []); items.push(...cleanSites); if (items.length > itemTotal || offset + cleanSites.length !== items.length) throw new JobError('bad_inventory_batch', '인벤토리 항목 수가 선언값과 다릅니다'); const itemsJson = toJson(items); if (Buffer.byteLength(itemsJson) > 16 * 1024 * 1024) throw new JobError('inventory_too_large', '인벤토리 전체 크기가 안전 한도를 넘습니다');
|
||||
const complete = items.length === itemTotal; d.store.run('UPDATE agent_inventory_staging SET next_offset=?, items_json=?, updated_at=? WHERE agent_id=? AND kind=?', items.length, itemsJson, nowIso(), agent.id, 'webmaker');
|
||||
if (!complete) return { ok: true, complete: false, nextOffset: items.length };
|
||||
const inventory = { ...parseJson(row.meta_json, {}), returned: items.length, sites: items }; const aFacts = parseJson(d.store.get('SELECT facts_json FROM agents WHERE id=?', agent.id)?.facts_json, {}) || {}; aFacts.webmakerInventory = inventory;
|
||||
const sFacts = parseJson(d.store.get('SELECT facts_json FROM servers WHERE id=?', agent.server_id)?.facts_json, {}) || {}; sFacts.webmakerInventory = inventory;
|
||||
d.store.run('UPDATE agents SET facts_json=? WHERE id=?', toJson(aFacts), agent.id); d.store.run('UPDATE servers SET facts_json=?, updated_at=? WHERE id=?', toJson(sFacts), nowIso(), agent.server_id); d.store.run('DELETE FROM agent_inventory_staging WHERE agent_id=? AND kind=?', agent.id, 'webmaker');
|
||||
return { ok: true, complete: true, nextOffset: items.length };
|
||||
});
|
||||
}
|
||||
/** heartbeat 처리: 지표 저장, 서버 last_seen·시계 차이·모듈 실제 버전·용량 갱신 */
|
||||
export function heartbeat(d, agent, body) {
|
||||
const now = nowIso(); const m = body.metrics || {}; const skew = Number.isFinite(body.clockSkewMs) ? Math.round(body.clockSkewMs) : null;
|
||||
d.store.run('UPDATE agents SET last_seen_at=?, version=?, facts_json=COALESCE(?, facts_json) WHERE id=?', now, body.version || agent.version, body.facts ? toJson(redact(body.facts)) : null, agent.id);
|
||||
const mergeFacts = (currentText) => { if (!body.facts) return null; const current = parseJson(currentText, {}) || {}; const incoming = redact(body.facts); if (!Object.hasOwn(incoming, 'webmakerInventory') && current.webmakerInventory) incoming.webmakerInventory = current.webmakerInventory; return toJson(incoming); };
|
||||
const agentFacts = mergeFacts(agent.facts_json); d.store.run('UPDATE agents SET last_seen_at=?, version=?, facts_json=COALESCE(?, facts_json) WHERE id=?', now, body.version || agent.version, agentFacts, agent.id);
|
||||
if (!agent.server_id) return { ok: true };
|
||||
const capacity = { diskFreeBytes: m.disk ? m.disk.total - m.disk.used : 0, diskTotalBytes: m.disk?.total || 0, memFreeBytes: m.mem ? m.mem.total - m.mem.used : 0, cpuLoad: m.load1 || 0, sitesHosted: body.sitesHosted || 0, reservedBytes: body.reservedBytes || 0, exhaustEta: body.exhaustEta || null };
|
||||
d.store.run('UPDATE servers SET last_seen_at=?, clock_skew_ms=?, agent_version=?, metrics_json=?, capacity_json=?, facts_json=COALESCE(?, facts_json), status=CASE WHEN status=\'offline\' THEN \'ready\' ELSE status END, updated_at=? WHERE id=?', now, skew, body.version || null, toJson(m), toJson(capacity), body.facts ? toJson(redact(body.facts)) : null, now, agent.server_id);
|
||||
const serverRow = d.store.get('SELECT facts_json FROM servers WHERE id=?', agent.server_id); const serverFacts = mergeFacts(serverRow?.facts_json); d.store.run('UPDATE servers SET last_seen_at=?, clock_skew_ms=?, agent_version=?, metrics_json=?, capacity_json=?, facts_json=COALESCE(?, facts_json), status=CASE WHEN status=\'offline\' THEN \'ready\' ELSE status END, updated_at=? WHERE id=?', now, skew, body.version || null, toJson(m), toJson(capacity), serverFacts, now, agent.server_id);
|
||||
d.store.run('INSERT OR REPLACE INTO metrics_samples(server_id, at, cpu, mem_used, mem_total, disk_used, disk_total, inode_used, inode_total, io_read, io_write, net_in, net_out, clock_skew_ms, load1) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
agent.server_id, now, m.cpu ?? null, m.mem?.used ?? null, m.mem?.total ?? null, m.disk?.used ?? null, m.disk?.total ?? null, m.inode?.used ?? null, m.inode?.total ?? null, m.io?.read ?? null, m.io?.write ?? null, m.net?.in ?? null, m.net?.out ?? null, skew, m.load1 ?? null);
|
||||
for (const [name, mod] of Object.entries(body.modules || {})) {
|
||||
d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET actual_version=excluded.actual_version, health=excluded.health, active=excluded.active, last_check_at=excluded.last_check_at, updated_at=excluded.updated_at',
|
||||
name, agent.server_id, mod.version || null, mod.health || 'unknown', mod.active ? 1 : 0, now, now);
|
||||
}
|
||||
// [legacy 메일 서비스] Agent 가 보고한 arimail 모듈 상태를 그 서버의 HAL 관리형 메일 서비스 행에 비춘다(행이 없으면 무관)
|
||||
try { syncFromHeartbeat(d, agent.server_id, body.modules || {}); } catch (e) { d.log && d.log('[hal] mail_services sync 실패', e.message); }
|
||||
// 시계 차이·디스크 고갈 경고(§28.1)
|
||||
if (skew != null && Math.abs(skew) > 60000) notify(d.store, { category: 'ops', severity: 'warning', incidentKey: `server:${agent.server_id}:clock`, title: `서버 시계 차이 ${Math.round(skew / 1000)}초`, link: `/ops/servers/${agent.server_id}` });
|
||||
else { try { const { resolveIncident } = d.notifyLib; resolveIncident(d.store, `server:${agent.server_id}:clock`); } catch (_) {} }
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
* node hal/index.mjs register-release --manifest <dist/<ver>/manifest.json> [--approve] 릴리스 등록(부트스트랩·CI)
|
||||
* node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회)
|
||||
* node hal/index.mjs bootstrap-release --version <v> --commit <sha> --sha256 <artifact sha> 부트스트랩 때 선택한 릴리스 정보를 기록
|
||||
* node hal/index.mjs server-token --server <srv_id> [--by <who>] 관리 서버의 Node Agent 일회용 등록 토큰(10분, 1회 표시) — 운영자 콘솔이 닿지 않는 부트스트랩 단계용(시스템 신원, 감사 기록)
|
||||
* node hal/index.mjs mail-import --discovery <json> (--server <srv_id> | --create-server --name <n> [--location <l>]) [--apply] [--by <who>]
|
||||
* 기존 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';
|
||||
@@ -15,7 +25,11 @@ import * as core from './core.mjs';
|
||||
import * as ops from './ops.mjs';
|
||||
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';
|
||||
@@ -42,6 +56,43 @@ if (cmd === 'setup-token') {
|
||||
console.log(`포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${shared}\n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)`);
|
||||
} else if (cmd === 'bootstrap-release') {
|
||||
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
|
||||
} else if (cmd === 'server-token') {
|
||||
// 운영자 콘솔(네트워크 관문·운영자 세션)이 닿지 않는 부트스트랩·연동 단계에서 HAL 데이터 소유자가 발급한다. 토큰은 1회 표시, 감사 기록(actor cli:<by>).
|
||||
const sid = opt('server'); if (!sid || !core.getServer(d, sid)) { console.error('--server <srv_id> 가 필요합니다(없는 서버)'); process.exit(1); }
|
||||
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}` });
|
||||
console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}`);
|
||||
} else if (cmd === 'mail-import') {
|
||||
const file = opt('discovery'); if (!file) { console.error('--discovery <json> 이 필요합니다'); process.exit(1); }
|
||||
const doc = JSON.parse(readFileSync(file, 'utf8')); const v = mailsvc.validateDiscovery(doc); if (!v.ok) { console.error(`discovery 검증 실패:\n - ${v.errors.join('\n - ')}`); process.exit(1); }
|
||||
let serverId = opt('server'); const by = `cli:${opt('by', 'cli')}`;
|
||||
if (!serverId && opt('create-server')) { const s = core.createServer(d, { name: opt('name', doc.source.hostname), role: 'mail', location: opt('location', ''), by }); serverId = s.id; console.log(`서버 생성(pending, Agent 미연결): ${s.id} ${s.name}`); }
|
||||
if (!serverId) { console.error('--server <srv_id> 또는 --create-server 가 필요합니다'); process.exit(1); }
|
||||
try { const r = mailsvc.applyImport(d, doc, { serverId, by, dryRun: !opt('apply') }); console.log(JSON.stringify({ importId: r.importId, mode: r.mode, serverId, authority: r.plan.authority, summary: r.plan.summary, items: r.plan.items, missingInSource: r.plan.missingInSource, created: r.created, updated: r.updated }, null, 1)); if (r.mode === 'rejected') process.exit(3); }
|
||||
catch (e) { console.error(`가져오기 실패: ${e.code || ''} ${e.message}`); process.exit(2); }
|
||||
} else if (cmd === 'mail-import-rollback') {
|
||||
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); }
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* hal/mailsvc.mjs — HAL 관리형 메일 서비스 등록부와 기존(legacy) AriMail 인스턴스 가져오기(importer).
|
||||
*
|
||||
* 왜 있나: 이미 운영 중인 AriMail(mail.arihakdang.com · webmail.ibit.kr 같은)을 교체·중단·재구성하지 않고, HAL 이 **등록·감시·통제**할 수 있는
|
||||
* 논리 리소스(MailService = 도메인 단위)와 노드(servers 행)로 가져온다. Data Plane(AriMail 의 송수신·계정·메일·DNS/MX·TLS·provider)은 그대로이며
|
||||
* HAL 이 멈춰도 메일 서비스는 계속 동작한다(HAL 은 제어 평면, 이 표는 등록부일 뿐이다).
|
||||
*
|
||||
* 원칙
|
||||
* - 읽기 전용 discovery: 원본 호스트에서 `scripts/mail-discover.mjs` 가 만든 문서(JSON)만 입력으로 받는다. 비밀값(토큰·키·비밀번호·해시)은 문서에 있으면 거부하고,
|
||||
* 비밀은 `secretRefs`(file:/dir:/vault:/env: 참조)로만 둔다. HAL DB 에는 참조만 저장한다.
|
||||
* - 불변 id(`mls_…`), 출처 권한(authority = `arimail:<hostname>:<unit>`), 도메인별 active owner 정확히 하나(부분 유일 인덱스).
|
||||
* - dry-run 이 기본. 계획(plan)은 충돌(collision: 다른 서버/인스턴스가 이미 active)·중복·누락을 거부하며, 거부가 하나라도 있으면 아무것도 적용하지 않는다(전체 거부).
|
||||
* - 적용은 한 트랜잭션, 모든 변화는 감사 기록(immutable), 적용 단위(mail_imports)로 되돌리기(생성 행 삭제·갱신 행 복원) 가능.
|
||||
* - 같은 authority·서버의 재적용은 update(집계·증거 갱신, id 유지) — 멱등.
|
||||
* - 소유 조직이 증거로 확인되지 않으면 임의 매핑하지 않고 `unassigned_legacy_managed` 로 둔다(AriMail 등록부 `ariweb.groupId` 가 있어도 포털 그룹과 대조하기 전에는 추정하지 않는다).
|
||||
* - Agent heartbeat 가 arimail 모듈 상태를 보고하면 last_sync·health 가 갱신된다(core.heartbeat → syncFromHeartbeat). Agent 가 없는 서버의 행은 import 시각만 가진다.
|
||||
*/
|
||||
import { newId } from '../lib/crypto.mjs';
|
||||
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
|
||||
import { audit } from '../lib/audit.mjs';
|
||||
import { looksSecret } from '../lib/redact.mjs';
|
||||
import { JobError } from '../lib/jobs.mjs';
|
||||
import * as core from './core.mjs';
|
||||
|
||||
export const DISCOVERY_FORMAT = 1;
|
||||
export const MAIL_SERVER_ROLES = ['mail', 'control'];
|
||||
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
||||
const SECRET_KEY_RE = /password|passwd|secret|token|api[-_]?key|private[-_]?key|privkey|credential|cookie|authorization|recovery|otp|totp|^(pass|key)$/i;
|
||||
const REF_KEY_RE = /(ref|refs|path|file|files|dir|keys|names|at|mode|owner|hint|exists|count|url)$/i;
|
||||
const REF_RE = /^(file|dir|vault|env):\S+$/;
|
||||
/** git 커밋·artifact 해시처럼 "비밀처럼 보이지만 공개 식별자" 인 값: 키 이름이 commit/sha256 류이고 값이 40/64 hex 일 때만 허용 */
|
||||
const isPublicDigest = (k, v) => /^(commit|main_?commit|sha256|artifact_?sha256|digest)$/i.test(String(k)) && /^([a-f0-9]{40}|[a-f0-9]{64})$/i.test(String(v));
|
||||
|
||||
export const MIGRATIONS = [{ id: 'hal_0003_mail_services', sql: `
|
||||
-- HAL 관리형 메일 서비스(도메인 단위 논리 리소스). 비밀은 secret_refs_json 의 참조만. 도메인별 active 는 정확히 하나.
|
||||
CREATE TABLE IF NOT EXISTS mail_services (
|
||||
id TEXT PRIMARY KEY, domain TEXT NOT NULL, server_id TEXT NOT NULL, authority TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'legacy_managed',
|
||||
ownership TEXT NOT NULL DEFAULT 'unassigned_legacy_managed', owner_group_id TEXT, active INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'managed',
|
||||
public_host TEXT, web_url TEXT, instance_json TEXT NOT NULL, domain_json TEXT, evidence_json TEXT, aggregates_json TEXT, secret_refs_json TEXT, health TEXT,
|
||||
import_id TEXT, imported_at TEXT NOT NULL, imported_by TEXT, last_sync_at TEXT, last_sync_source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mail_services_active_domain ON mail_services(domain) WHERE active=1;
|
||||
CREATE INDEX IF NOT EXISTS mail_services_server ON mail_services(server_id);
|
||||
-- 가져오기 단위(dry-run 도 기록). status: planned(dry-run) | rejected | applied | rolled_back
|
||||
CREATE TABLE IF NOT EXISTS mail_imports (
|
||||
id TEXT PRIMARY KEY, mode TEXT NOT NULL, status TEXT NOT NULL, actor TEXT, server_id TEXT, authority TEXT NOT NULL, plan_json TEXT NOT NULL,
|
||||
created_ids_json TEXT, updated_json TEXT, created_at TEXT NOT NULL, applied_at TEXT, rolled_back_at TEXT, rolled_back_by TEXT
|
||||
);` }];
|
||||
|
||||
// ------------------------------------------------------------------ 검증
|
||||
/** discovery 문서 검증. 비밀값이 어디에든 있으면 거부한다. */
|
||||
export function validateDiscovery(doc) {
|
||||
const errors = [];
|
||||
if (!doc || typeof doc !== 'object') return { ok: false, errors: ['discovery 문서가 객체가 아닙니다'] };
|
||||
if (doc.format !== DISCOVERY_FORMAT) errors.push(`format 은 ${DISCOVERY_FORMAT} 이어야 합니다`);
|
||||
if (!doc.collectedAt || isNaN(Date.parse(doc.collectedAt))) errors.push('collectedAt(ISO 시각)이 필요합니다');
|
||||
const s = doc.source;
|
||||
if (!s || typeof s !== 'object') errors.push('source 가 필요합니다');
|
||||
else { for (const k of ['hostname', 'unit', 'appDir', 'user']) if (!s[k] || typeof s[k] !== 'string') errors.push(`source.${k} 가 필요합니다`); if (!s.revision || typeof s.revision !== 'object') errors.push('source.revision 이 필요합니다'); if (!s.health || typeof s.health !== 'object') errors.push('source.health 가 필요합니다'); }
|
||||
if (!Array.isArray(doc.domains) || !doc.domains.length) errors.push('domains 배열이 필요합니다');
|
||||
else {
|
||||
const seen = new Set();
|
||||
doc.domains.forEach((x, i) => {
|
||||
if (!x || typeof x !== 'object') { errors.push(`domains[${i}] 가 객체가 아닙니다`); return; }
|
||||
const dn = String(x.domain || '').toLowerCase(); if (!DOMAIN_RE.test(dn)) errors.push(`domains[${i}] 도메인 형식 오류: ${x.domain}`);
|
||||
if (seen.has(dn)) errors.push(`도메인 중복: ${dn}`); seen.add(dn);
|
||||
if (!Array.isArray(x.hosts)) errors.push(`domains[${i}].hosts 배열이 필요합니다`);
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(doc.listeners)) errors.push('listeners 배열이 필요합니다');
|
||||
if (!doc.aggregates || typeof doc.aggregates !== 'object') errors.push('aggregates 가 필요합니다');
|
||||
if (doc.secretRefs != null) {
|
||||
if (!Array.isArray(doc.secretRefs)) errors.push('secretRefs 는 배열이어야 합니다');
|
||||
else doc.secretRefs.forEach((r, i) => { if (!r || typeof r !== 'object') { errors.push(`secretRefs[${i}] 형식 오류`); return; } if ('value' in r || 'secret' in r) errors.push(`secretRefs[${i}] 에 비밀값을 넣을 수 없습니다 — 참조만`); if (!REF_RE.test(String(r.ref || ''))) errors.push(`secretRefs[${i}].ref 는 file:/dir:/vault:/env: 형식이어야 합니다`); });
|
||||
}
|
||||
// 비밀값 탐지(이름·형태). 참조 키(…Ref/Path/File/Dir/Keys)는 허용.
|
||||
const walk = (v, path, depth) => {
|
||||
if (depth > 8 || v == null) return;
|
||||
if (typeof v === 'string') { if (looksSecret(v)) errors.push(`비밀 형태의 값: ${path}`); return; }
|
||||
if (Array.isArray(v)) { v.forEach((x, i) => walk(x, `${path}[${i}]`, depth + 1)); return; }
|
||||
if (typeof v === 'object') for (const [k, val] of Object.entries(v)) { if (isPublicDigest(k, val)) continue; if (SECRET_KEY_RE.test(k) && !REF_KEY_RE.test(k) && typeof val === 'string' && val !== '') errors.push(`비밀 이름의 키에 값이 있습니다: ${path}.${k}`); walk(val, `${path}.${k}`, depth + 1); }
|
||||
};
|
||||
walk(doc, 'doc', 0);
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
export const authorityOf = (doc) => `arimail:${doc.source.hostname}:${doc.source.unit}`;
|
||||
|
||||
// ------------------------------------------------------------------ 조회
|
||||
function rowToPublic(d, r) {
|
||||
if (!r) return null;
|
||||
const srv = d.store.get('SELECT name, agent_id, status, last_seen_at FROM servers WHERE id=?', r.server_id);
|
||||
return { id: r.id, domain: r.domain, serverId: r.server_id, serverName: srv?.name || null, agentId: srv?.agent_id || null, serverStatus: srv?.status || null, authority: r.authority, kind: r.kind, ownership: r.ownership, ownerGroupId: r.owner_group_id, active: !!r.active, status: r.status,
|
||||
publicHost: r.public_host, webUrl: r.web_url, instance: parseJson(r.instance_json, {}), domainInfo: parseJson(r.domain_json, {}), evidence: parseJson(r.evidence_json, {}), aggregates: parseJson(r.aggregates_json, {}), secretRefs: parseJson(r.secret_refs_json, []), health: r.health,
|
||||
importId: r.import_id, importedAt: r.imported_at, importedBy: r.imported_by, lastSyncAt: r.last_sync_at, lastSyncSource: r.last_sync_source, createdAt: r.created_at, updatedAt: r.updated_at };
|
||||
}
|
||||
export function listMailServices(d, { serverId = null } = {}) { return d.store.all(`SELECT * FROM mail_services ${serverId ? 'WHERE server_id=?' : ''} ORDER BY domain, created_at`, ...(serverId ? [serverId] : [])).map((r) => rowToPublic(d, r)); }
|
||||
export function getMailService(d, id) { return rowToPublic(d, d.store.get('SELECT * FROM mail_services WHERE id=?', id)); }
|
||||
export function listImports(d, { limit = 50 } = {}) { return d.store.all('SELECT id, mode, status, actor, server_id, authority, created_at, applied_at, rolled_back_at, rolled_back_by, plan_json, created_ids_json FROM mail_imports ORDER BY created_at DESC LIMIT ?', limit).map((r) => ({ id: r.id, mode: r.mode, status: r.status, actor: r.actor, serverId: r.server_id, authority: r.authority, createdAt: r.created_at, appliedAt: r.applied_at, rolledBackAt: r.rolled_back_at, rolledBackBy: r.rolled_back_by, summary: parseJson(r.plan_json, {}).summary || null, createdIds: parseJson(r.created_ids_json, []) })); }
|
||||
/** 적용 뒤 정확성 readback: 총수·active·도메인별 중복·서버별·비밀 누출(형태 검사). */
|
||||
export function readback(d) {
|
||||
const rows = d.store.all('SELECT * FROM mail_services ORDER BY domain'); const services = rows.map((r) => rowToPublic(d, r));
|
||||
const byDomain = {}; for (const r of rows) if (r.active) byDomain[r.domain] = (byDomain[r.domain] || 0) + 1;
|
||||
const duplicates = Object.entries(byDomain).filter(([, n]) => n > 1).map(([dn]) => dn);
|
||||
const perServer = {}; for (const r of rows) perServer[r.server_id] = (perServer[r.server_id] || 0) + 1;
|
||||
let secretLeaks = 0; const scan = (v, depth = 0) => { if (depth > 8 || v == null) return; if (typeof v === 'string') { if (looksSecret(v)) secretLeaks++; return; } if (Array.isArray(v)) v.forEach((x) => scan(x, depth + 1)); else if (typeof v === 'object') Object.entries(v).forEach(([k, x]) => { if (!isPublicDigest(k, x)) scan(x, depth + 1); }); };
|
||||
for (const r of rows) scan([parseJson(r.instance_json, null), parseJson(r.evidence_json, null), parseJson(r.aggregates_json, null), parseJson(r.secret_refs_json, null), parseJson(r.domain_json, null)]);
|
||||
return { counts: { total: rows.length, active: rows.filter((r) => r.active).length, domains: Object.keys(byDomain).length, byDomain }, duplicates, perServer, secretLeaks, services, generatedAt: nowIso() };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 계획(dry-run)
|
||||
/** 계획 세우기. 거부(reject)가 하나라도 있으면 plan.ok=false. 서버 없음·역할·authority 불일치는 예외로 던진다(문서 전체가 대상이 아님). */
|
||||
export function planImport(d, doc, { serverId }) {
|
||||
const v = validateDiscovery(doc); if (!v.ok) throw new JobError('bad_discovery', `discovery 문서 검증 실패: ${v.errors.join(' · ')}`, { errors: v.errors });
|
||||
const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
if (!MAIL_SERVER_ROLES.includes(srv.role)) throw new JobError('bad_role', `메일 서비스를 둘 서버 역할은 ${MAIL_SERVER_ROLES.join('/')} 여야 합니다(현재 ${srv.role})`);
|
||||
const facts = srv.facts || {}; if (facts.hostname && facts.hostname !== doc.source.hostname) throw new JobError('authority_mismatch', `서버 facts 의 hostname(${facts.hostname})과 discovery 의 hostname(${doc.source.hostname})이 다릅니다`);
|
||||
if (facts.units && facts.units.arimail && facts.units.arimail !== doc.source.unit.replace(/\.service$/, '')) throw new JobError('authority_mismatch', `서버 facts 의 arimail 유닛(${facts.units.arimail})과 discovery 의 유닛(${doc.source.unit})이 다릅니다`);
|
||||
const authority = authorityOf(doc); const items = [];
|
||||
for (const x of doc.domains) {
|
||||
const domain = String(x.domain).toLowerCase();
|
||||
if (x.enabled === false) { items.push({ domain, action: 'skip', reason: 'disabled_in_source' }); continue; }
|
||||
const ex = d.store.get('SELECT * FROM mail_services WHERE domain=? AND active=1', domain);
|
||||
if (!ex) items.push({ domain, action: 'create' });
|
||||
else if (ex.authority === authority && ex.server_id === serverId) items.push({ domain, action: 'update', existingId: ex.id });
|
||||
else items.push({ domain, action: 'reject', reason: `collision: 도메인 ${domain} 의 active owner 는 이미 ${ex.id}(${ex.authority}, 서버 ${ex.server_id}) 입니다`, existingId: ex.id });
|
||||
}
|
||||
const present = new Set(doc.domains.map((x) => String(x.domain).toLowerCase()));
|
||||
const missingInSource = d.store.all('SELECT domain FROM mail_services WHERE authority=? AND server_id=? AND active=1', authority, serverId).map((r) => r.domain).filter((dn) => !present.has(dn));
|
||||
const summary = { create: 0, update: 0, reject: 0, skip: 0, missingInSource: missingInSource.length }; for (const i of items) summary[i.action]++;
|
||||
return { ok: summary.reject === 0, authority, serverId, serverName: srv.name, items, missingInSource, summary, collectedAt: doc.collectedAt };
|
||||
}
|
||||
|
||||
const domainView = (x) => ({ domain: String(x.domain).toLowerCase(), enabled: x.enabled !== false, primary: !!x.primary, hosts: (x.hosts || []).map(String), authMode: x.authMode || null, operating: x.operating || null, ariweb: x.ariweb || null, note: x.note || null, tenantDb: x.tenantDb || null, createdAt: x.createdAt || null, updatedAt: x.updatedAt || null });
|
||||
function buildRow(doc, x, serverId, authority) {
|
||||
const dv = domainView(x); const host = dv.hosts[0] ? String(dv.hosts[0]).split('/')[0] : null; const agg = (doc.aggregates.byDomain || {})[dv.domain] || null;
|
||||
const s = doc.source;
|
||||
const instance = { hostname: s.hostname, unit: s.unit, appDir: s.appDir, currentTarget: s.currentTarget || null, user: s.user, nodeBin: s.nodeBin || null, nodeVersion: s.nodeVersion || null, revision: s.revision || {}, packageVersion: s.packageVersion || null, healthUrl: s.healthUrl || null, health: s.health || null, unitState: s.unitState || null, harakaUnit: s.harakaUnit || null, dataDir: s.dataDir || null, listeners: doc.listeners, provider: doc.provider || null, collector: doc.collector || null, collectedAt: doc.collectedAt };
|
||||
return { domain: dv.domain, server_id: serverId, authority, public_host: host, web_url: host ? `https://${host}/` : null, instance_json: toJson(instance), domain_json: toJson(dv), evidence_json: toJson(doc.evidence || {}), aggregates_json: toJson(agg), secret_refs_json: toJson(doc.secretRefs || []), health: s.health && s.health.ok === false ? 'down' : s.unitState && s.unitState.active && s.unitState.active !== 'active' ? 'down' : 'ok' };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 적용
|
||||
/**
|
||||
* dry-run(기본) 또는 적용. 결과 { importId, mode: 'dry_run'|'rejected'|'apply', plan, created[], updated[] }.
|
||||
* 거부가 있으면 아무것도 적용하지 않고 mode='rejected' 로 기록만 남긴다.
|
||||
*/
|
||||
export function applyImport(d, doc, { serverId, by = null, dryRun = true }) {
|
||||
const plan = planImport(d, doc, { serverId }); const authority = plan.authority; const importId = newId('mimp_'); const now = nowIso();
|
||||
const actorType = by ? 'operator' : 'system';
|
||||
if (dryRun || !plan.ok) {
|
||||
const mode = plan.ok ? 'dry_run' : 'rejected';
|
||||
d.store.run('INSERT INTO mail_imports(id, mode, status, actor, server_id, authority, plan_json, created_at) VALUES(?,?,?,?,?,?,?,?)', importId, mode, plan.ok ? 'planned' : 'rejected', by, serverId, authority, toJson(plan), now);
|
||||
audit(d.store, { actorType, actorId: by, action: plan.ok ? 'mail_service.import_dry_run' : 'mail_service.import_rejected', subjectType: 'server', subjectId: serverId, detail: { importId, authority, summary: plan.summary, rejected: plan.items.filter((i) => i.action === 'reject').map((i) => i.domain) } });
|
||||
return { importId, mode, plan, created: [], updated: [] };
|
||||
}
|
||||
return d.store.tx(() => {
|
||||
const created = []; const updated = [];
|
||||
for (const item of plan.items) {
|
||||
if (item.action === 'skip') continue;
|
||||
const x = doc.domains.find((y) => String(y.domain).toLowerCase() === item.domain); const row = buildRow(doc, x, serverId, authority);
|
||||
if (item.action === 'create') {
|
||||
const id = newId('mls_');
|
||||
d.store.run('INSERT INTO mail_services(id, domain, server_id, authority, kind, ownership, owner_group_id, active, status, public_host, web_url, instance_json, domain_json, evidence_json, aggregates_json, secret_refs_json, health, import_id, imported_at, imported_by, last_sync_at, last_sync_source, created_at, updated_at) VALUES(?,?,?,?,\'legacy_managed\',\'unassigned_legacy_managed\',NULL,1,\'managed\',?,?,?,?,?,?,?,?,?,?,?,?,\'import\',?,?)',
|
||||
id, row.domain, row.server_id, row.authority, row.public_host, row.web_url, row.instance_json, row.domain_json, row.evidence_json, row.aggregates_json, row.secret_refs_json, row.health, importId, now, by, now, now, now);
|
||||
created.push(id); item.createdId = id;
|
||||
audit(d.store, { actorType, actorId: by, action: 'mail_service.import', subjectType: 'mail_service', subjectId: id, immutable: true, detail: { importId, domain: row.domain, serverId, authority, publicHost: row.public_host, aggregates: parseJson(row.aggregates_json, null) } });
|
||||
} else if (item.action === 'update') {
|
||||
const before = d.store.get('SELECT * FROM mail_services WHERE id=?', item.existingId);
|
||||
d.store.run('UPDATE mail_services SET public_host=?, web_url=?, instance_json=?, domain_json=?, evidence_json=?, aggregates_json=?, secret_refs_json=?, health=?, last_sync_at=?, last_sync_source=\'import\', updated_at=? WHERE id=?', row.public_host, row.web_url, row.instance_json, row.domain_json, row.evidence_json, row.aggregates_json, row.secret_refs_json, row.health, now, now, item.existingId);
|
||||
updated.push({ id: item.existingId, before });
|
||||
audit(d.store, { actorType, actorId: by, action: 'mail_service.import', subjectType: 'mail_service', subjectId: item.existingId, immutable: true, detail: { importId, domain: row.domain, serverId, authority, update: true } });
|
||||
}
|
||||
}
|
||||
d.store.run('INSERT INTO mail_imports(id, mode, status, actor, server_id, authority, plan_json, created_ids_json, updated_json, created_at, applied_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', importId, 'apply', 'applied', by, serverId, authority, toJson(plan), toJson(created), toJson(updated), now, now);
|
||||
return { importId, mode: 'apply', plan, created, updated: updated.map((u) => u.id) };
|
||||
});
|
||||
}
|
||||
/** 되돌리기: 그 적용이 만든 행 삭제, 갱신한 행은 이전 값으로 복원. */
|
||||
export function rollbackImport(d, importId, by = null) {
|
||||
const imp = d.store.get('SELECT * FROM mail_imports WHERE id=?', importId); if (!imp) throw new JobError('import_not_found', '가져오기 기록이 없습니다');
|
||||
if (imp.status === 'rolled_back') throw new JobError('already_rolled_back', '이미 되돌린 가져오기입니다');
|
||||
if (imp.status !== 'applied') throw new JobError('not_applied', `적용된 가져오기만 되돌릴 수 있습니다(상태 ${imp.status})`);
|
||||
return d.store.tx(() => {
|
||||
let deleted = 0, restored = 0;
|
||||
for (const id of parseJson(imp.created_ids_json, [])) deleted += d.store.run('DELETE FROM mail_services WHERE id=? AND import_id=?', id, importId).changes;
|
||||
for (const u of parseJson(imp.updated_json, [])) { const b = u.before; if (!b) continue; restored += d.store.run('UPDATE mail_services SET public_host=?, web_url=?, instance_json=?, domain_json=?, evidence_json=?, aggregates_json=?, secret_refs_json=?, health=?, last_sync_at=?, last_sync_source=?, updated_at=? WHERE id=?', b.public_host, b.web_url, b.instance_json, b.domain_json, b.evidence_json, b.aggregates_json, b.secret_refs_json, b.health, b.last_sync_at, b.last_sync_source, nowIso(), u.id).changes; }
|
||||
d.store.run('UPDATE mail_imports SET status=\'rolled_back\', rolled_back_at=?, rolled_back_by=? WHERE id=?', nowIso(), by, importId);
|
||||
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'mail_service.import_rollback', subjectType: 'server', subjectId: imp.server_id, immutable: true, detail: { importId, deleted, restored } });
|
||||
return { importId, deleted, restored };
|
||||
});
|
||||
}
|
||||
/** heartbeat 연동(core.heartbeat 가 부른다): 그 서버의 메일 서비스 행에 Agent 관측 상태를 비춘다. 행이 없으면 아무것도 하지 않는다. */
|
||||
export function syncFromHeartbeat(d, serverId, modules = {}) {
|
||||
const m = modules && modules.arimail; if (!m) return 0;
|
||||
const rows = d.store.all('SELECT id, instance_json FROM mail_services WHERE server_id=?', serverId); if (!rows.length) return 0; const now = nowIso();
|
||||
for (const r of rows) { const inst = parseJson(r.instance_json, {}); inst.observedVersion = m.version || null; inst.observedActive = !!m.active; inst.observedAt = now; d.store.run('UPDATE mail_services SET health=?, last_sync_at=?, last_sync_source=\'agent\', instance_json=?, updated_at=? WHERE id=?', m.health || 'unknown', now, toJson(inst), now, r.id); }
|
||||
return rows.length;
|
||||
}
|
||||
@@ -23,10 +23,12 @@ export const MODULES = {
|
||||
agent: { name: 'agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master',
|
||||
health: { heartbeat: true }, version: { file: 'package.json' }, dataDirs: ['/srv/ariweb/agent/data'], selfUpdate: true },
|
||||
// 도입 모듈: Webmaker(WebHosting_AI_agent 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며,
|
||||
// 유닛은 deploy/units/webmaker.service.tmpl 로 다시 쓰되 기존 유닛의 Environment 줄(DATA_ROOT 등)을 facts 로 읽어 유지한다. 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다.
|
||||
webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', user: 'master', port: 3080, baseDir: '/srv/webmaker',
|
||||
// 유닛은 서버에 이미 있고 <appDir>/current 를 가리키면 그대로 둔다(keepExistingUnit — 운영 서버의 강화 지시문·ReadWritePaths 보존, 실제 환경 결함 수정). 없을 때만 deploy/units/webmaker.service.tmpl 로 만든다.
|
||||
// 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.7.3 은 upstreamVersion).
|
||||
webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker',
|
||||
health: { url: 'http://127.0.0.1:3080/api/auth/csrf', expect: 'any' }, version: { file: 'package.json' }, preStop: ['webmaker.backupData'], postStart: ['webmaker.unitCheck'], dataDirs: ['/data/webmaker', '/var/www/webmaker-sites'], extraUnits: ['webmaker-setup'] },
|
||||
// 도입 모듈: AriMail 1.0.11. 서버마다 유닛 이름이 다르다(arihakdang-mail / arimail / heart-mail) — 서버 facts.arimail.unit 이 정한다. 설치는 원본 deploy/deploy.sh 와 같은 절차(테스트→릴리스 복사→DB 백업·마이그레이션→재시작→health)를 Agent 가 수행한다.
|
||||
// 실행 계정이 Agent 와 다르면(ariweb.net 호스트의 arihakdang) Agent 가 deploy/module-as-user.sh 로 그 계정이 파일 작업을 하게 한다(실제 환경 결함 수정). 마이그레이션은 유닛의 EnvironmentFile(DB_FILE)을 같은 계정으로 읽어 실행한다.
|
||||
arimail: { name: 'arimail', roles: ['mail'], layout: 'releases', unit: 'arimail', unitTemplate: null, user: null, port: 8790,
|
||||
health: { url: 'http://127.0.0.1:8790/api/health', expect: 'ok' }, version: { file: 'package.json' }, preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], dataDirs: ['<appDir>/data'], copyPaths: ['server', 'web', 'lang', 'migrations', 'package.json', 'haraka'] },
|
||||
backup: { name: 'backup', roles: ['backup', 'web'], layout: 'agent-feature', unit: null, health: { feature: 'backup' }, version: { agent: true }, dataDirs: ['/srv/ariweb/backups'] },
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as session from '../lib/session.mjs';
|
||||
import * as jobs from '../lib/jobs.mjs';
|
||||
import * as notify from '../lib/notify.mjs';
|
||||
import * as audit from '../lib/audit.mjs';
|
||||
import { MIGRATIONS as MAIL_MIGRATIONS } from './mailsvc.mjs';
|
||||
|
||||
export const MIGRATIONS = [...session.MIGRATIONS, ...jobs.MIGRATIONS, ...notify.MIGRATIONS, ...audit.MIGRATIONS, { id: 'hal_0001', sql: `
|
||||
-- 관리 서버(§28). agent_id 는 그 서버에서 돌아가는 Node Agent. target/actual 모듈 버전은 module_targets 가 가진다.
|
||||
@@ -85,4 +86,22 @@ CREATE TABLE IF NOT EXISTS portal_events_outbox (id TEXT PRIMARY KEY, payload_js
|
||||
` },
|
||||
// [통합 보완] 같은 호스트 재시작 판정을 위한 임대 소유자 호스트·PID
|
||||
{ id: 'hal_0002_live_host', sql: `ALTER TABLE bootstrap_state ADD COLUMN live_host TEXT; ALTER TABLE bootstrap_state ADD COLUMN live_pid TEXT;` },
|
||||
// [legacy 메일 서비스 가져오기] HAL 관리형 메일 서비스 등록부·가져오기 단위(hal/mailsvc.mjs). 표 추가만(기존 표 변경 없음).
|
||||
...MAIL_MIGRATIONS,
|
||||
// Webmaker 전체 인벤토리는 heartbeat 2 MiB 한도를 넘을 수 있어 인증된 배치로 원자적으로 조립한다.
|
||||
{ id: 'hal_0004_webmaker_inventory_stage', sql: `
|
||||
CREATE TABLE IF NOT EXISTS agent_inventory_staging (
|
||||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, kind TEXT NOT NULL, scan_id TEXT NOT NULL, total INTEGER NOT NULL, next_offset INTEGER NOT NULL DEFAULT 0,
|
||||
meta_json TEXT NOT NULL, items_json TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(agent_id, kind)
|
||||
);
|
||||
` },
|
||||
// 사전 릴리스에서 FK 없이 같은 staging 표를 만든 경우에도 고아 row를 정리하고 이후 삭제를 보장한다.
|
||||
{ id: 'hal_0005_webmaker_inventory_cleanup', sql: `
|
||||
DELETE FROM agent_inventory_staging WHERE agent_id NOT IN (SELECT id FROM agents);
|
||||
CREATE TRIGGER IF NOT EXISTS agent_inventory_staging_agent_cleanup
|
||||
AFTER DELETE ON agents BEGIN
|
||||
DELETE FROM agent_inventory_staging WHERE agent_id=OLD.id;
|
||||
END;
|
||||
` },
|
||||
];
|
||||
|
||||
+23
-3
@@ -22,6 +22,7 @@ import * as core from './core.mjs';
|
||||
import * as ops from './ops.mjs';
|
||||
import * as emergency from './emergency.mjs';
|
||||
import * as dr from './dr.mjs';
|
||||
import * as mailsvc from './mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||
import * as R from './ui/render.mjs';
|
||||
|
||||
@@ -45,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>
|
||||
@@ -64,7 +66,9 @@ export function registerUi(app, d) {
|
||||
relayPage('/ops/customers', 'customers', '고객과 사용자 그룹', 'customers'); relayPage('/ops/customers/:groupId', 'customers', '고객 상세', 'customer');
|
||||
relayPage('/ops/orders', 'orders', '주문과 결제 승인', 'orders'); relayPage('/ops/products', 'products', '상품과 정책', 'products');
|
||||
relayPage('/ops/sites', 'sites', '사이트와 배포', 'sites'); relayPage('/ops/sites/:id', 'sites', '사이트 상세', 'site');
|
||||
relayPage('/ops/mail', 'mail', '메일 서비스', 'mail'); relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
|
||||
// 메일 서비스: HAL 관리형(legacy 가져오기, HAL 자체 데이터)을 먼저 서버에서 그리고, 포털 메일 서비스는 브라우저가 중계로 채운다(ops.js 는 #portal-mail 만 바꾼다).
|
||||
app.get('/ops/mail', (ctx) => shell(ctx, { title: '메일 서비스', nav: 'mail', page: 'mail', body: `<h2>HAL 관리형 메일 서비스(기존 AriMail 인스턴스 가져오기)</h2>${mailServicesView(mailsvc.readback(d))}<h2>포털 메일 서비스</h2><div id="portal-mail">${R.statePanel('loading', '포털에서 불러오는 중')}</div>` }));
|
||||
relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
|
||||
relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation');
|
||||
// ---------------- 서버(§28)
|
||||
app.get('/ops/servers', (ctx) => shell(ctx, { title: '서버와 용량', nav: 'servers', page: 'servers', body: `<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
|
||||
@@ -72,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>`;
|
||||
@@ -109,3 +113,19 @@ export function overviewLocal(d) {
|
||||
}
|
||||
/** VERSION_HISTORY.md 의 `## <버전>` 절을 버전별 문자열로 */
|
||||
export function changelog() { const f = join(ROOT, 'VERSION_HISTORY.md'); if (!existsSync(f)) return {}; const out = {}; let cur = null; for (const line of readFileSync(f, 'utf8').split('\n')) { const m = line.match(/^## ([0-9][^\s(]*)/); if (m) { cur = m[1]; out[cur] = ''; continue; } if (cur) out[cur] += line + '\n'; } for (const k of Object.keys(out)) out[k] = out[k].trim().slice(0, 4000); return out; }
|
||||
|
||||
/** HAL 관리형 메일 서비스 표(비밀 없음). 도메인별 active owner 는 하나여야 하며 중복·비밀 누출은 readback 이 드러낸다. */
|
||||
function mailServicesView(rb) {
|
||||
const warn = []; if (rb.duplicates.length) warn.push(`<div class="alert critical">⚠ 도메인 active owner 중복: ${esc(rb.duplicates.join(', '))}</div>`); if (rb.secretLeaks) warn.push(`<div class="alert critical">⚠ 등록부에 비밀 형태의 값 ${rb.secretLeaks}건 — 즉시 확인</div>`);
|
||||
const t = R.table([
|
||||
{ l: '도메인', f: (s) => `<b>${esc(s.domain)}</b><br><span class="muted">${esc(s.kind)} · ${esc(s.ownership)}${s.ownerGroupId ? ' · ' + esc(s.ownerGroupId) : ''}</span>` },
|
||||
{ l: '공개 주소', f: (s) => s.webUrl ? `<a href="${esc(s.webUrl)}" rel="noopener" target="_blank">${esc(s.publicHost)}</a>` : '—' },
|
||||
{ l: '서버·인스턴스', f: (s) => `<a href="/ops/servers/${esc(s.serverId)}">${esc(s.serverName || s.serverId)}</a><br><span class="muted mono">${esc(s.instance.unit || '')} · ${esc(s.instance.appDir || '')}</span>` },
|
||||
{ l: '리비전', f: (s) => `${esc(s.instance.revision?.version || '—')} <span class="muted">(AriMail ${esc(s.instance.revision?.upstreamVersion || s.instance.packageVersion || '—')})</span>${s.instance.observedVersion ? `<br><span class="muted">Agent 관측 ${esc(s.instance.observedVersion)}</span>` : ''}` },
|
||||
{ l: '계정·메일함·메일', f: (s) => s.aggregates ? `${esc(String(s.aggregates.accounts ?? '—'))} · ${esc(String(s.aggregates.mailboxes ?? '—'))} · ${esc(String(s.aggregates.messages ?? '—'))}` : '—' },
|
||||
{ l: '건강·동기화', f: (s) => `${R.healthBadge(s.health || 'unknown')}<br><span class="muted">${esc(s.lastSyncSource || '—')} · ${esc(R.ago(s.lastSyncAt))}${s.agentId ? '' : ' · Agent 없음(관측 전용)'}</span>` },
|
||||
{ l: 'TLS·DNS', f: (s) => `${s.evidence.tls?.validTo ? '인증서 ' + R.fmt(s.evidence.tls.validTo) : '인증서 정보 없음'}<br><span class="muted">${esc((s.evidence.dns && s.evidence.dns[s.domain] && (s.evidence.dns[s.domain].mx || []).join(', ')) || 'MX 정보 없음')}</span>` },
|
||||
{ l: '비밀', f: (s) => `<span class="muted">참조 ${(s.secretRefs || []).length}건(값 없음)</span>` },
|
||||
], rb.services, { empty: 'HAL 관리형 메일 서비스가 없습니다 — node hal/index.mjs mail-import 또는 운영자 API /ops/api/mail-services/import 로 가져옵니다' });
|
||||
return warn.join('') + t + `<p class="muted">총 ${rb.counts.total} · active ${rb.counts.active} · 도메인 ${rb.counts.domains} · 서버 ${Object.keys(rb.perServer).length}. 기존 AriMail 의 송수신·계정·메일·DNS/MX·TLS 는 HAL 이 바꾸지 않습니다(등록·감시·통제용 등록부).</p>`;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ const PAGES = {
|
||||
products: async () => { try { body().innerHTML = R.productsView(await relay('/commerce/products')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
sites: async () => { try { body().innerHTML = R.sitesView(await relay('/sites')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
site: async (id) => { try { body().innerHTML = R.siteDetailView(await relay('/sites/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
mail: async () => { try { body().innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
mail: async () => { const el = document.getElementById('portal-mail') || body(); try { el.innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { el.innerHTML = R.relayError(e); } },
|
||||
domains: async () => { const out = []; let src = null; try { const dom = await relay('/domains'); try { src = await relay('/source'); } catch (e) { out.push(R.statePanel('partial', 'Source 목록을 받지 못했습니다')); } body().innerHTML = out.join('') + R.domainsView(dom, src); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
backup: async () => { try { const [r, o, b] = await Promise.all([relay('/backup/restores'), relay('/backup/options'), relay('/backup/backups')]); body().innerHTML = R.backupView({ restores: r.restores, options: o.options, backups: b.backups }); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
relocation: async () => { let tr = null; try { const rl = await relay('/relocation'); try { tr = await relay('/transfer'); } catch (_) { tr = null; } body().innerHTML = (tr ? '' : R.statePanel('partial', '소유권 이전 목록을 받지 못했습니다')) + R.relocationView(rl, tr); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
|
||||
+12
-1
@@ -55,10 +55,21 @@ export function serversTable(servers) {
|
||||
], servers, { empty: '등록된 서버가 없습니다' });
|
||||
}
|
||||
export function serverDetail(s, { jobs = [], agentJobs = [] } = {}) {
|
||||
const webmakerData = s.facts?.webmakerInventory || null; const webmakerSites = Array.isArray(webmakerData?.sites) ? webmakerData.sites.filter((x) => x && typeof x === 'object' && !Array.isArray(x)) : []; const inventoryErrors = Array.isArray(webmakerData?.errors) ? webmakerData.errors : [];
|
||||
const webmakerInventory = webmakerData ? `<h2>Webmaker 사이트 디렉터리 인벤토리</h2><p class="muted">Node Agent가 사이트 디렉터리와 Webmaker 메타데이터·배포 이력을 대조한 운영 인벤토리입니다. 전체 ${Number(webmakerData.total || 0).toLocaleString('ko-KR')}개 · 수집 ${Number(webmakerData.returned || 0).toLocaleString('ko-KR')}개${webmakerData.truncated ? ' · 일부만 표시됨' : ''}. 페이지 본문·프롬프트·연락처·인증정보는 HAL로 보내지 않습니다.</p>${inventoryErrors.length || webmakerData.errorsTruncated ? statePanel('failed', `인벤토리 스캔 오류 ${inventoryErrors.length}건${webmakerData.errorsTruncated ? ' 이상(전송 한도에 따라 일부 오류만 표시)' : ''}: ${inventoryErrors.join(', ')}`) : ''}${table([
|
||||
{ l: '사이트', f: (x) => `<span class="mono">${esc(x.id)}</span><br><b>${esc(x.title || '제목 정보 없음')}</b>` },
|
||||
{ l: '계정 연결', f: (x) => esc(x.ownerId || '메타데이터 없음') },
|
||||
{ l: '배포 상태', f: (x) => badge(x.deploymentState === 'deployed' ? 'ok' : x.deploymentState === 'unknown' ? 'warn' : 'neutral', x.deploymentState || 'unknown') },
|
||||
{ l: '서비스 상태', f: (x) => esc(x.status || '—') },
|
||||
{ l: '도메인', f: (x) => (Array.isArray(x.domains) ? x.domains : []).map(esc).join('<br>') || '—' },
|
||||
{ l: '크기·파일', f: (x) => `${bytes(x.bytes || 0)} · ${Number(x.files || 0).toLocaleString('ko-KR')}개` },
|
||||
{ l: '최근 변경', f: (x) => fmt(x.updatedAt) },
|
||||
{ l: '메타데이터', f: (x) => badge(x.metadata === 'ok' ? 'ok' : 'warn', x.metadata || 'unknown') },
|
||||
], webmakerSites, { empty: '사이트 디렉터리가 없습니다' })}` : '';
|
||||
return `<div class="cards"><div class="card"><h2>연결·역할</h2>${kv([['역할·위치', `${esc(s.role)} · ${esc(s.location || '—')}`], ['연결 상태', badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : 'warn', SERVER_LABEL[s.status] || s.status)], ['마지막 heartbeat', `${fmt(s.lastSeenAt)} (${esc(ago(s.lastSeenAt))})`], ['Agent', `<span class="mono">${esc(s.agentId || '미연결')}</span> ${esc(s.agentVersion || '')}`], ['시계 차이', s.clockSkewMs == null ? '—' : `${s.clockSkewMs}ms`], ['승인', s.approvedAt ? fmt(s.approvedAt) : badge('waiting', '승인 대기')], ['신규 배치', s.acceptsNew ? '허용' : '중지'], ['유지보수·격리', esc(s.maintenanceNote || s.isolatedReason || '—')]])}</div>
|
||||
<div class="card"><h2>용량</h2>${kv([['디스크', s.capacity.diskTotalBytes ? `${bytes(s.capacity.diskTotalBytes - s.capacity.diskFreeBytes)} / ${bytes(s.capacity.diskTotalBytes)} (여유 ${bytes(s.capacity.diskFreeBytes)})` : '정보 없음'], ['예약량', bytes(s.capacity.reservedBytes)], ['안전 여유', bytes(s.capacity.safetyMarginBytes)], ['배치 서비스', `${s.capacity.sitesHosted || 0}개`], ['고갈 예상', s.capacity.exhaustEta ? fmt(s.capacity.exhaustEta) : '예상 없음'], ['백업 저장소', s.facts && s.facts.backupStore ? `${bytes(s.facts.backupStore.usedBytes)} / ${bytes(s.facts.backupStore.totalBytes)} · 최근 검증 ${fmt(s.facts.backupStore.lastVerifiedAt)}` : '해당 없음']])}</div>
|
||||
<div class="card"><h2>모듈(목표 vs 실제)</h2>${table([{ l: '모듈', k: 'name' }, { l: '목표', k: 'target' }, { l: '실제', f: (m) => m.drift ? `<span class="err">${esc(m.actual || '없음')}</span>` : esc(m.actual || '—') }, { l: '건강', f: (m) => healthBadge(m.health) }, { l: '마지막 확인', f: (m) => fmt(m.lastCheckAt) }, { l: '오류', f: (m) => `<span class="err">${esc(m.lastError || '')}</span>` }], Object.entries(s.modules || {}).map(([name, m]) => ({ name, ...m })), { empty: '배치된 모듈이 없습니다' })}</div></div>
|
||||
<h2>대기열·반복 실패</h2>${agentJobsTable(agentJobs)}<h2>이 서버의 작업</h2>${table([{ l: '작업', f: (j) => `<a href="/ops/jobs/${esc(j.id)}">${esc(j.label)}</a>` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], jobs)}`;
|
||||
${webmakerInventory}<h2>대기열·반복 실패</h2>${agentJobsTable(agentJobs)}<h2>이 서버의 작업</h2>${table([{ l: '작업', f: (j) => `<a href="/ops/jobs/${esc(j.id)}">${esc(j.label)}</a>` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], jobs)}`;
|
||||
}
|
||||
/** 90일 지표 SVG(§28.1). samples 는 최신순. key: 'cpu'|'disk'|'mem'|'net'. */
|
||||
export function metricsSvg(samples, key = 'cpu', { w = 720, h = 160 } = {}) {
|
||||
|
||||
@@ -99,7 +99,7 @@ export function resumeForSite(deps, siteId, { by = 'system', userConfirmed = fal
|
||||
deps.store.run('UPDATE backup_options SET status=\'active\', paused_reason=NULL, next_run_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id);
|
||||
return optionById(deps, o.id);
|
||||
}
|
||||
export function reasonLabel(r) { return { site_paused: '사이트 일시 중지', site_cancelled: '사이트 해지', storage_full: '저장 공간 부족', operator: '운영자 조치', option_ended: '옵션 종료' }[r] || r; }
|
||||
export function reasonLabel(r) { return { site_paused: '사이트 일시 중지', site_not_deployed: '사이트 미배포(배포 뒤 다시 시작)', site_cancelled: '사이트 해지', storage_full: '저장 공간 부족', operator: '운영자 조치', option_ended: '옵션 종료' }[r] || r; }
|
||||
/** 옵션 종료(만료·해지): 새 백업 중지, 백업은 7일 보관 뒤 삭제(§20.3·§23.2). 권리 반환. */
|
||||
export function expireOption(deps, optionId, { by = 'system', reason = 'option_ended' } = {}) {
|
||||
const o = optionById(deps, optionId); if (!o || o.status === 'ended') return o;
|
||||
@@ -115,7 +115,8 @@ export function expireOption(deps, optionId, { by = 'system', reason = 'option_e
|
||||
export async function pickStoreServer(deps, site, option) {
|
||||
const hal = halOf(deps); let servers = [];
|
||||
try { servers = await hal.listServers({}); } catch (e) { throw asTransient(e); }
|
||||
const ok = servers.filter((s) => s.acceptsNew && s.status === 'ready' && ['backup', 'web'].includes(s.role));
|
||||
// [실제 환경 결함 수정] 서버 한 대(control) 운영: 전용 backup·web 서버가 없으면 control 서버도 저장 후보로 본다(같은 서버 저장은 여전히 운영자 override 가 필요).
|
||||
const pool = servers.filter((s) => s.acceptsNew && s.status === 'ready'); const ok = pool.some((s) => ['backup', 'web'].includes(s.role)) ? pool.filter((s) => ['backup', 'web'].includes(s.role)) : pool.filter((s) => s.role === 'control');
|
||||
const pref = ok.filter((s) => s.id !== site.server_id).sort((a, b) => (a.role === 'backup' ? -1 : 1) - (b.role === 'backup' ? -1 : 1) || ((b.capacity?.diskFreeBytes || 0) - (a.capacity?.diskFreeBytes || 0)));
|
||||
if (pref.length) return pref[0].id;
|
||||
if (option.same_server_override && ok.some((s) => s.id === site.server_id)) return site.server_id;
|
||||
@@ -135,7 +136,7 @@ export function runDue(deps, now = nowIso()) {
|
||||
const started = [];
|
||||
for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'active\' AND next_run_at IS NOT NULL AND next_run_at<=?', now)) {
|
||||
const site = deps.store.get('SELECT * FROM sites WHERE id=?', o.site_id);
|
||||
if (!site || !['live', 'needs_domain', 'deploying'].includes(site.lifecycle)) { stopForSite(deps, o.site_id, site && site.lifecycle === 'paused' ? 'site_paused' : 'site_cancelled'); continue; }
|
||||
if (!site || !['live', 'needs_domain', 'deploying'].includes(site.lifecycle)) { stopForSite(deps, o.site_id, site && site.lifecycle === 'paused' ? 'site_paused' : site && site.lifecycle === 'draft' ? 'site_not_deployed' : 'site_cancelled'); continue; } // 초안(미배포) 사이트는 '해지' 가 아니라 '미배포' 로 표시한다(실제 환경 확인)
|
||||
const slot = kstDate(now) + ':' + String(o.next_run_at);
|
||||
const { job } = deps.jobs.enqueue({ kind: 'backup.scheduled', subject: { optionId: o.id, siteId: o.site_id, groupId: o.group_id, slot }, idempotencyKey: `backup:${o.id}:${slot}`, actorType: 'system', groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, label: `정기 백업 (${site.name})` });
|
||||
deps.store.run('UPDATE backup_options SET next_run_at=?, updated_at=? WHERE id=?', nextRunFrom(parseJson(o.schedule_json, {}), now), now, o.id);
|
||||
|
||||
+12
-10
@@ -38,6 +38,8 @@ export function create(deps) {
|
||||
const list = await api.domains(s.id); const d = list.find((x) => x.domain === domain); const host = d ? String(d.hosts[0]).split('/')[0] : `mail.${domain}`;
|
||||
hostCache.set(s.id + '|' + domain, { host, at: Date.now() }); return host;
|
||||
}
|
||||
/** 전체 설정 API 호출. AriMail 은 설정 API 를 기본 도메인 호스트(req.headers.host)에서만 응답하고 Node fetch 는 Host 헤더를 바꿀 수 없으므로 setup_url 자체가 기본 도메인 이름이어야 한다(등록 화면 안내, 실제 환경 확인). */
|
||||
const setupCall = (s, path, opts = {}) => call(fetchImpl, setupOf(s) + path, { ...opts, headers: { ...setupHeaders(s), ...(opts.headers || {}) } });
|
||||
const setupHeaders = (s) => { const tok = vault.get(secretName(s.id, 'setup-token')); if (!tok) { const e = new Error('AriMail 전체 설정 토큰이 보관소에 없습니다'); e.code = 'mail_server_token_missing'; throw e; } return { 'X-Setup-Token': tok }; };
|
||||
/** 관리자 세션 쿠키(도메인별). 보관소의 관리자 로그인으로 로그인해 10분 캐시. */
|
||||
const adminCache = new Map();
|
||||
@@ -80,25 +82,25 @@ export function create(deps) {
|
||||
return r.json;
|
||||
},
|
||||
// ---- 전체 설정 API(도메인 등록부)
|
||||
async domains(serverId) { const s = serverRow(serverId); return (await call(fetchImpl, setupOf(s) + '/api/setup/domains', { headers: setupHeaders(s) })).json.domains; },
|
||||
async domains(serverId) { const s = serverRow(serverId); return (await setupCall(s, '/api/setup/domains', { headers: setupHeaders(s) })).json.domains; },
|
||||
async addDomain(serverId, { domain, hosts = null, mailbox = 'admin', adminPassword, ariweb = null, operating = 'active', note = '' }) {
|
||||
const s = serverRow(serverId); const pw = adminPassword || randomBytes(12).toString('base64url');
|
||||
const r = await call(fetchImpl, setupOf(s) + '/api/setup/domains', { method: 'POST', headers: setupHeaders(s), body: { domain, hosts: hosts || [`mail.${domain}`], mailbox, adminPassword: pw, ariweb, operating, note, copyFromPrimary: true } });
|
||||
const r = await setupCall(s, '/api/setup/domains', { method: 'POST', headers: setupHeaders(s), body: { domain, hosts: hosts || [`mail.${domain}`], mailbox, adminPassword: pw, ariweb, operating, note, copyFromPrimary: true } });
|
||||
hostCache.delete(s.id + '|' + domain);
|
||||
if (r.json.initialAdmin) api.storeAdmin(s.id, domain, r.json.initialAdmin.login, pw);
|
||||
return { entry: r.json.domain, initialAdmin: r.json.initialAdmin ? { login: r.json.initialAdmin.login } : null };
|
||||
},
|
||||
async updateDomain(serverId, domain, patch) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await call(fetchImpl, setupOf(s) + '/api/setup/domains/' + encodeURIComponent(domain), { method: 'PATCH', headers: setupHeaders(s), body: patch })).json.domain; },
|
||||
async removeDomain(serverId, domain) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await call(fetchImpl, setupOf(s) + '/api/setup/domains/' + encodeURIComponent(domain), { method: 'DELETE', headers: setupHeaders(s) })).json.removed; },
|
||||
async updateDomain(serverId, domain, patch) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await setupCall(s, '/api/setup/domains/' + encodeURIComponent(domain), { method: 'PATCH', headers: setupHeaders(s), body: patch })).json.domain; },
|
||||
async removeDomain(serverId, domain) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await setupCall(s, '/api/setup/domains/' + encodeURIComponent(domain), { method: 'DELETE', headers: setupHeaders(s) })).json.removed; },
|
||||
/** 도메인 설정(비밀 가림)·메타(DNS 레코드·발신 상태·호스트). */
|
||||
async domainConfig(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/config?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json; },
|
||||
async setDomainConfig(serverId, domain, values) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/config?domain=${encodeURIComponent(domain)}`, { method: 'PUT', headers: setupHeaders(s), body: { values } })).json; },
|
||||
async domainConfig(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/config?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json; },
|
||||
async setDomainConfig(serverId, domain, values) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/config?domain=${encodeURIComponent(domain)}`, { method: 'PUT', headers: setupHeaders(s), body: { values } })).json; },
|
||||
/** 도메인별 보장량(§13.3): 메일함 수·저장 공간·월간 통수를 AriMail 설정으로 */
|
||||
setDomainLimits(serverId, domain, { mailboxes, storageBytes, monthlyMessages }) { return api.setDomainConfig(serverId, domain, { LIMIT_ACCOUNTS: mailboxes ?? '', LIMIT_TOTAL_BYTES: storageBytes != null ? String(storageBytes) : '', LIMIT_MONTHLY_MESSAGES: monthlyMessages ?? '' }); },
|
||||
async plan(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/plan?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json.plan; },
|
||||
async runPlan(serverId, domain, body = {}) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/plan/run?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body })).json; },
|
||||
async dnsProbe(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/dns/probe?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: {} })).json.probe; },
|
||||
async outboundCheck(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/check?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: { what: 'outbound' } })).json.outboundStatus; },
|
||||
async plan(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/plan?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json.plan; },
|
||||
async runPlan(serverId, domain, body = {}) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/plan/run?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body })).json; },
|
||||
async dnsProbe(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/dns/probe?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: {} })).json.probe; },
|
||||
async outboundCheck(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/check?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: { what: 'outbound' } })).json.outboundStatus; },
|
||||
// ---- 관리자 API(메일함)
|
||||
async listUsers(serverId, domain) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users')).json; },
|
||||
async createUser(serverId, domain, { login, name = '', password, quotaBytes = undefined, services = undefined, role = 'user' }) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users', { method: 'POST', body: { login, name, password, role, ...(quotaBytes !== undefined ? { quotaBytes } : {}), ...(services ? { services } : {}) } })).json; },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* portal/mail/model.mjs — 메일 영역 세부 표(§15~§18, §23.2). 중심 표(mail_services·mailboxes·domains)는 portal/core/model.mjs 에 있다.
|
||||
*
|
||||
* mail_servers 운영자가 등록한 AriMail 서버(공개 주소·내부 주소·전체 설정 주소·메일 프로그램 접속 호스트). 비밀(설정 토큰·사용량 키·SSO 비밀)은 보관소 'arimail:<id>:*'.
|
||||
* mail_servers 운영자가 등록한 AriMail 서버(공개 주소·내부 주소·전체 설정 주소·메일 프로그램 접속 호스트). 전체 설정 주소(setup_url)는 AriMail 이 설정 API 를 기본 도메인 호스트에서만 응답하므로
|
||||
* 기본 도메인 이름(예: https://mail.arihakdang.com:8791)이어야 한다 — Node fetch 는 Host 헤더를 바꿀 수 없어 이름이 그 서버의 설정 포트로 풀려야 한다(같은 호스트면 /etc/hosts). 비밀(설정 토큰·사용량 키·SSO 비밀)은 보관소 'arimail:<id>:*'.
|
||||
* mail_tests 실제 외부 송신·수신·회신 확인 기록(활성화·전환 전후·수동). 단계와 결과만, 메일 내용은 없음.
|
||||
* mail_invites 메일함 초대(초기 비밀번호 대신 링크로 비밀번호를 정함): 토큰 해시·만료·사용 완료·재발송 횟수.
|
||||
* mail_aliases 별칭·전달 전용 주소 기록(정책 미확정이면 생성 불가, §16.2).
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* scripts/mail-discover.mjs — 운영 중인 AriMail 인스턴스의 **읽기 전용** discovery 문서 만들기(HAL `mail-import` 의 입력, hal/mailsvc.mjs).
|
||||
*
|
||||
* sudo node scripts/mail-discover.mjs --unit arihakdang-mail.service --app-dir /srv/arihakdang-mail [--etc /etc/arihakdang-mail] [--vhost /etc/nginx/sites-available/mail.arihakdang.conf]
|
||||
* [--hook /etc/letsencrypt/renewal-hooks/deploy/arihakdang-mail.sh] [--no-net] [--out <json>]
|
||||
*
|
||||
* 무엇을 읽나(모두 읽기 전용, 원본 파일은 바꾸지 않음): systemd 유닛 상태(systemctl show)·유닛 파일의 EnvironmentFile 경로, 앱 디렉터리의 current 심링크·REVISION(.json)·package.json,
|
||||
* AriMail 도메인 등록부(data/domains.json, AriMail 1.0.7+ 형식)·건강 API(/api/health)·haraka-status.json, 게이트웨이 DB 와 도메인별 테넌트 DB 의 **집계**(계정 수·메일 수·DB 크기 — 주소·내용은 읽지 않음),
|
||||
* 리스너(ss -ltnp 에서 유닛 PID 소유 포트), nginx vhost 의 server_name/proxy_pass, TLS 인증서 주체·만료(127.0.0.1:IMAPS 로 handshake), DNS(A·MX, --no-net 이면 생략), certbot 훅 경로.
|
||||
* 비밀값은 읽지도 쓰지도 않는다: env/config 는 **키 이름과 경로·권한**만(값 없음), 메일 주소는 제외, TLS 개인키 경로만. 결과는 `secretRefs` 에 참조로 남는다.
|
||||
* 이 파일은 단독 실행용이라 저장소의 다른 모듈을 import 하지 않는다(원본 호스트에 파일 하나만 복사해 실행).
|
||||
*/
|
||||
import { readFileSync, existsSync, readdirSync, statSync, realpathSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { hostname } from 'node:os';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { connect as tlsConnect } from 'node:tls';
|
||||
import { promises as dns } from 'node:dns';
|
||||
|
||||
export const FORMAT = 1;
|
||||
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 flag = (n) => args.includes('--' + n);
|
||||
const readJson = (f, fb = null) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch (_) { return fb; } };
|
||||
const run = (cmd, a) => { try { return execFileSync(cmd, a, { encoding: 'utf8', timeout: 10000 }); } catch (_) { return ''; } };
|
||||
const mode = (f) => { try { const st = statSync(f); return { mode: (st.mode & 0o777).toString(8), size: st.size }; } catch (_) { return null; } };
|
||||
const owner = (f) => run('stat', ['-c', '%U:%G', f]).trim() || null;
|
||||
/** 환경 파일의 **키 이름만**(값은 절대 읽지 않는다) */
|
||||
const envKeys = (f) => { try { return readFileSync(f, 'utf8').split('\n').map((l) => /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(l)).filter(Boolean).map((m) => m[1]); } catch (_) { return null; } };
|
||||
|
||||
export function unitShow(unit) {
|
||||
const out = run('systemctl', ['show', unit, '-p', 'ActiveState,SubState,UnitFileState,NRestarts,ExecMainPID,ExecMainStartTimestamp,User,Group,WorkingDirectory,FragmentPath,EnvironmentFiles']);
|
||||
const kv = Object.fromEntries(out.split('\n').filter((l) => l.includes('=')).map((l) => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1)]; }));
|
||||
if (!Object.keys(kv).length) return null;
|
||||
return { active: kv.ActiveState || null, sub: kv.SubState || null, enabled: kv.UnitFileState || null, nRestarts: Number(kv.NRestarts || 0), mainPid: Number(kv.ExecMainPID || 0) || null, startedAt: kv.ExecMainStartTimestamp || null, user: kv.User || null, group: kv.Group || null, workingDirectory: kv.WorkingDirectory || null, fragmentPath: kv.FragmentPath || null, environmentFiles: (kv.EnvironmentFiles || '').split(/\s+/).map((x) => x.replace(/\s*\(ignore_errors=\w+\)/, '')).filter((x) => x.startsWith('/')) };
|
||||
}
|
||||
/** DB 집계: 표 이름·행 수만. 주소·본문은 읽지 않는다. */
|
||||
export function dbCounts(file) {
|
||||
if (!existsSync(file)) return null;
|
||||
const db = new DatabaseSync(file, { readOnly: true });
|
||||
try {
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((r) => r.name); const counts = {};
|
||||
for (const t of tables) { try { counts[t] = db.prepare(`SELECT count(*) c FROM "${t}"`).get().c; } catch (_) { counts[t] = null; } }
|
||||
const integrity = db.prepare('PRAGMA integrity_check').get().integrity_check;
|
||||
let activeAccounts = null; try { activeAccounts = db.prepare("SELECT count(*) c FROM mail_users WHERE status='active'").get().c; } catch (_) {}
|
||||
const wal = existsSync(file + '-wal') ? statSync(file + '-wal').size : 0;
|
||||
return { file, bytes: statSync(file).size + wal, integrity, accounts: counts.mail_users ?? null, activeAccounts, messages: counts.messages ?? null, attachments: counts.attachments ?? null, outboundQueue: counts.outbound_queue ?? null, migrations: counts.schema_migrations ?? null, tables: Object.keys(counts).length };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
export function listeners(pid) {
|
||||
const out = run('ss', ['-ltnpH']); const rows = [];
|
||||
for (const l of out.split('\n')) { const m = /LISTEN\s+\S+\s+\S+\s+(\S+):(\d+)\s+\S+\s*(.*)$/.exec(l.trim()); if (!m) continue; const p = /pid=(\d+)/.exec(m[3]); rows.push({ bind: m[1].replace(/^\[|\]$/g, ''), port: Number(m[2]), pid: p ? Number(p[1]) : null }); }
|
||||
const mine = rows.filter((r) => r.pid && r.pid === pid).map((r) => ({ port: r.port, bind: r.bind, owner: 'unit' })).sort((a, b) => a.port - b.port);
|
||||
const mailPorts = [25, 110, 143, 465, 587, 993, 995]; const others = rows.filter((r) => mailPorts.includes(r.port) && r.pid !== pid).map((r) => ({ port: r.port, bind: r.bind, owner: 'other', pid: r.pid }));
|
||||
return { unit: mine, otherMailPortOwners: others, port25Open: rows.some((r) => r.port === 25) };
|
||||
}
|
||||
const dirBytes = (dir) => { let n = 0; const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = join(d, e.name); if (e.isDirectory()) walk(p); else if (e.isFile()) { try { n += statSync(p).size; } catch (_) {} } } }; try { walk(dir); } catch (_) {} return n; };
|
||||
function tlsInfo(host, port = 993) {
|
||||
return new Promise((resolve) => { const t = setTimeout(() => { try { s.destroy(); } catch (_) {} resolve({ host, port, error: 'timeout' }); }, 5000); const s = tlsConnect({ host: '127.0.0.1', port, servername: host, rejectUnauthorized: false }, () => { const c = s.getPeerCertificate(); clearTimeout(t); s.destroy(); resolve({ host, port, subject: c.subject ? `CN=${c.subject.CN}` : null, issuer: c.issuer ? `${c.issuer.O || ''} ${c.issuer.CN || ''}`.trim() : null, validFrom: c.valid_from ? new Date(c.valid_from).toISOString() : null, validTo: c.valid_to ? new Date(c.valid_to).toISOString() : null, altNames: c.subjectaltname || null, protocol: s.getProtocol() }); }); s.on('error', (e) => { clearTimeout(t); resolve({ host, port, error: e.code || e.message }); }); });
|
||||
}
|
||||
/** 공개 DNS(1.1.1.1·8.8.8.8)로 조회한다 — 호스트의 /etc/hosts(자기 이름 → 127.0.0.1)가 증거를 오염시키지 않게. */
|
||||
async function dnsInfo(hosts, domains) {
|
||||
const r = new dns.Resolver({ timeout: 4000, tries: 2 }); try { r.setServers(['1.1.1.1', '8.8.8.8']); } catch (_) {}
|
||||
const out = { _resolver: 'public:1.1.1.1,8.8.8.8' };
|
||||
for (const h of hosts) { out[h] = {}; try { out[h].a = await r.resolve4(h); } catch (e) { out[h].a = []; out[h].error = e.code || e.message; } }
|
||||
for (const dn of domains) { out[dn] = out[dn] || {}; try { out[dn].mx = (await r.resolveMx(dn)).sort((a, b) => a.priority - b.priority).map((m) => m.exchange); } catch (e) { out[dn].mx = []; } try { out[dn].txt = (await r.resolveTxt(dn)).map((t) => t.join('')).filter((t) => /^v=spf1/i.test(t)); } catch (e) { out[dn].txt = []; } }
|
||||
return out;
|
||||
}
|
||||
async function httpHealth(url) { try { const r = await fetch(url, { signal: AbortSignal.timeout(5000) }); const j = await r.json().catch(() => null); return { status: r.status, body: j && typeof j === 'object' ? { ok: j.ok, version: j.version, domains: j.domains } : null }; } catch (e) { return { status: 0, error: e.code || e.message }; } }
|
||||
|
||||
/** discovery 문서 만들기(순수: 파일 시스템·명령 결과만 읽는다). opts.net=false 면 TLS/DNS/HTTP 를 생략. */
|
||||
export async function discover({ unit, appDir, etcDir = null, vhost = null, hook = null, net = true, host = hostname() }) {
|
||||
const us = unitShow(unit); const haraka = unitShow(unit.replace(/\.service$/, '') + '-haraka.service');
|
||||
const current = existsSync(join(appDir, 'current')) ? realpathSync(join(appDir, 'current')) : null;
|
||||
const revision = readJson(join(appDir, 'current', 'REVISION.json')) || (existsSync(join(appDir, 'current', 'REVISION')) ? { commit: readFileSync(join(appDir, 'current', 'REVISION'), 'utf8').trim() } : {});
|
||||
const pkg = readJson(join(appDir, 'current', 'package.json'), {});
|
||||
const nodeBin = us && us.fragmentPath ? (/^ExecStart=(\S+)/m.exec(readFileSync(us.fragmentPath, 'utf8')) || [])[1] || null : null;
|
||||
const nodeVersion = nodeBin ? run(nodeBin, ['--version']).trim() || null : null;
|
||||
const dataDir = join(appDir, 'data'); const reg = readJson(join(dataDir, 'domains.json'), { domains: {} });
|
||||
const regDomains = Array.isArray(reg.domains) ? reg.domains : Object.values(reg.domains || {});
|
||||
const domains = []; const byDomain = {}; let accounts = 0, messages = 0, storageBytes = 0;
|
||||
const main = dbCounts(join(dataDir, 'mail-gateway.sqlite'));
|
||||
for (const x of regDomains) {
|
||||
const dn = String(x.domain || '').toLowerCase(); const tenant = join(dataDir, 'domains', dn, 'mail.sqlite'); const isPrimary = !!x.primary || dn === String(reg.primary || '').toLowerCase();
|
||||
const c = isPrimary ? main : dbCounts(tenant); const tenantDir = join(dataDir, 'domains', dn);
|
||||
const agg = c ? { accounts: c.accounts, activeAccounts: c.activeAccounts, mailboxes: c.accounts, messages: c.messages, attachments: c.attachments, outboundQueue: c.outboundQueue, dbBytes: c.bytes, dbIntegrity: c.integrity, dbMigrations: c.migrations, storageBytes: isPrimary ? dirBytes(dataDir) - dirBytes(join(dataDir, 'domains')) - dirBytes(join(dataDir, 'backups')) : dirBytes(tenantDir) } : null;
|
||||
if (agg) { accounts += agg.accounts || 0; messages += agg.messages || 0; storageBytes += agg.storageBytes || 0; byDomain[dn] = agg; }
|
||||
domains.push({ domain: dn, enabled: x.enabled !== false, primary: isPrimary, hosts: (x.hosts || []).map(String), authMode: x.auth && x.auth.mode || null, operating: x.operating || 'active', ariweb: x.ariweb ? { kind: x.ariweb.kind || null, siteId: x.ariweb.siteId || null, groupId: x.ariweb.groupId || null, mailServiceId: x.ariweb.mailServiceId || null } : null, note: x.note || null, tenantDb: isPrimary ? join(dataDir, 'mail-gateway.sqlite') : (existsSync(tenant) ? tenant : null), createdAt: x.createdAt || null, updatedAt: x.updatedAt || null });
|
||||
}
|
||||
const hs = readJson(join(dataDir, 'haraka-status.json'), null);
|
||||
const cfg = readJson(join(dataDir, 'config.json'), null); const configKeys = cfg ? Object.keys(cfg).filter((k) => k !== '_meta') : null;
|
||||
const providerKind = hs && hs.enabled ? 'haraka' : cfg && (cfg.outbound || cfg.inbound) ? String((cfg.outbound && cfg.outbound.provider) || (cfg.inbound && cfg.inbound.provider) || (configKeys && configKeys.some((k) => /^RESEND/.test(k)) ? 'resend' : 'unknown')) : (configKeys && configKeys.some((k) => /^RESEND/.test(k)) ? 'resend' : 'unknown');
|
||||
const secretRefs = [];
|
||||
for (const f of (us && us.environmentFiles) || []) secretRefs.push({ name: basename(f), ref: `file:${f}`, ...(mode(f) ? { mode: mode(f).mode } : {}), owner: owner(f), keys: envKeys(f) });
|
||||
if (existsSync(join(dataDir, 'config.json'))) secretRefs.push({ name: 'config.json', ref: `file:${join(dataDir, 'config.json')}`, mode: mode(join(dataDir, 'config.json')).mode, owner: owner(join(dataDir, 'config.json')), keys: configKeys });
|
||||
if (etcDir && existsSync(join(etcDir, 'tls'))) secretRefs.push({ name: 'tls', ref: `dir:${join(etcDir, 'tls')}`, files: readdirSync(join(etcDir, 'tls')) });
|
||||
if (existsSync(join(dataDir, 'setup-token'))) secretRefs.push({ name: 'setup-token', ref: `file:${join(dataDir, 'setup-token')}`, mode: mode(join(dataDir, 'setup-token')).mode });
|
||||
const vh = vhost && existsSync(vhost) ? readFileSync(vhost, 'utf8') : '';
|
||||
const evidence = { nginx: vhost ? { vhostFile: vhost, exists: !!vh, serverNames: [...new Set([...vh.matchAll(/server_name\s+([^;]+);/g)].flatMap((m) => m[1].trim().split(/\s+/)))], proxyPass: [...new Set([...vh.matchAll(/proxy_pass\s+([^;]+);/g)].map((m) => m[1].trim()))], httpRedirect: /return\s+301\s+https/.test(vh), sslCertificate: (/ssl_certificate\s+([^;]+);/.exec(vh) || [])[1] || null } : null, certbot: hook ? { hookFile: hook, exists: existsSync(hook), restartsUnit: existsSync(hook) ? /systemctl\s+restart/.test(readFileSync(hook, 'utf8')) : null } : null, haraka: hs ? { enabled: !!hs.enabled, running: !!hs.running, inbound: !!hs.inbound, outbound: !!hs.outbound, listen: hs.listen || [], version: hs.version || null, dkim: !!hs.dkim, updatedAt: hs.updatedAt || null } : null };
|
||||
const hostsAll = [...new Set(domains.flatMap((x) => x.hosts.map((h) => h.split('/')[0])))];
|
||||
let health = null;
|
||||
if (net) {
|
||||
health = await httpHealth('http://127.0.0.1:8790/api/health');
|
||||
evidence.tls = hostsAll.length ? await tlsInfo(hostsAll[0]) : null;
|
||||
evidence.dns = await dnsInfo(hostsAll, domains.map((x) => x.domain));
|
||||
evidence.http = { localHealth: health, public: {} };
|
||||
for (const h of hostsAll.slice(0, 4)) { try { const r = await fetch(`https://${h}/api/health`, { signal: AbortSignal.timeout(8000) }); const j = await r.json().catch(() => null); evidence.http.public[h] = { status: r.status, ok: !!(j && j.ok), version: j && j.version }; const r2 = await fetch(`http://${h}/`, { redirect: 'manual', signal: AbortSignal.timeout(8000) }); evidence.http.public[h].httpRedirect = { status: r2.status, location: r2.headers.get('location') }; } catch (e) { evidence.http.public[h] = { error: e.code || e.message }; } }
|
||||
}
|
||||
const doc = {
|
||||
format: FORMAT, collectedAt: new Date().toISOString(), collector: 'scripts/mail-discover.mjs',
|
||||
source: { hostname: host, unit, unitState: us, harakaUnit: haraka, appDir, currentTarget: current, user: (us && us.user) || (owner(dataDir) || '').split(':')[0] || null, nodeBin, nodeVersion, revision, packageName: pkg.name || null, packageVersion: pkg.version || null, healthUrl: 'http://127.0.0.1:8790/api/health', health: health && health.body ? health.body : { ok: null, version: pkg.version || null, domains: domains.length, note: net ? 'health 응답 없음' : 'no-net' }, dataDir, dataBytes: dirBytes(dataDir), releases: existsSync(join(appDir, 'releases')) ? readdirSync(join(appDir, 'releases')).sort() : [], previous: readJson(join(appDir, '.previous.json'), null) },
|
||||
domains, listeners: us && us.mainPid ? listeners(us.mainPid).unit : [], listenerDetail: us && us.mainPid ? listeners(us.mainPid) : null,
|
||||
aggregates: { accounts, mailboxes: accounts, messages, storageBytes, domains: domains.length, enabledDomains: domains.filter((x) => x.enabled).length, byDomain },
|
||||
provider: { kind: providerKind, haraka: evidence.haraka, configPath: existsSync(join(dataDir, 'config.json')) ? join(dataDir, 'config.json') : null, configKeys },
|
||||
secretRefs, evidence,
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
||||
if (process.argv[1] && /mail-discover\.mjs$/.test(process.argv[1]) && !process.env.ARIWEB_DISCOVER_NO_MAIN) {
|
||||
const unit = opt('unit'); const appDir = opt('app-dir'); if (!unit || !appDir) { console.error('--unit <유닛> --app-dir <앱 디렉터리> 가 필요합니다'); process.exit(1); }
|
||||
const doc = await discover({ unit: unit.endsWith('.service') ? unit : unit + '.service', appDir, etcDir: opt('etc'), vhost: opt('vhost'), hook: opt('hook'), net: !flag('no-net') });
|
||||
const text = JSON.stringify(doc, null, 1);
|
||||
if (/-----BEGIN|password=|secret=/i.test(text)) { console.error('비밀 형태의 내용이 포함되어 출력하지 않습니다'); process.exit(2); }
|
||||
if (opt('out')) { const { writeFileSync } = await import('node:fs'); writeFileSync(opt('out'), text, { mode: 0o600 }); console.error(`discovery → ${opt('out')} (${doc.domains.length} 도메인, 계정 ${doc.aggregates.accounts}, 메일 ${doc.aggregates.messages})`); } else console.log(text);
|
||||
}
|
||||
+3
-2
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url';
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2); const cmd = args[0] || 'build'; const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); const VERSION = pkg.version;
|
||||
const MODULES = { hal: ['hal', 'lib', 'deploy', 'lang', 'package.json', 'README.md'], portal: ['portal', 'lib', 'lang', 'package.json'], agent: ['agent', 'lib', 'deploy', 'package.json'], webmaker: ['modules/webmaker'], arimail: ['modules/arimail'] };
|
||||
const MODULES = { hal: ['hal', 'lib', 'deploy', 'lang', 'package.json', 'README.md'], portal: ['portal', 'lib', 'lang', 'catalog', 'package.json'], agent: ['agent', 'lib', 'deploy', 'package.json'], webmaker: ['modules/webmaker'], arimail: ['modules/arimail'] };
|
||||
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex');
|
||||
const git = (a) => { try { return execFileSync('git', a, { cwd: ROOT, encoding: 'utf8' }).trim(); } catch (_) { return ''; } };
|
||||
|
||||
@@ -29,7 +29,8 @@ if (cmd === 'build') {
|
||||
const artifacts = {};
|
||||
for (const [m, paths] of Object.entries(MODULES)) {
|
||||
const file = `${m}-${VERSION}.tar.gz`; const abs = join(out, file); const present = paths.filter((p) => existsSync(join(ROOT, p)));
|
||||
const tarArgs = ['-czf', abs, '-C', ROOT, '--exclude=.git', '--exclude=node_modules', '--exclude=*/data', '--exclude=*/data/*', '--exclude=modules/*/data', '--exclude=dist', '--exclude=*.sqlite', '--exclude=*.log', '--exclude=*.tmp'];
|
||||
// 디렉터리·파일 모드 정규화(실제 환경 결함 수정): 개발 PC 의 umask(077)가 artifact 에 0700 으로 들어가면 서버에서 다른 실행 계정(arihakdang 등)이 코드를 읽지 못한다.
|
||||
const tarArgs = ['-czf', abs, '-C', ROOT, '--mode=u=rwX,go=rX', '--owner=0', '--group=0', '--numeric-owner', '--exclude=.git', '--exclude=node_modules', '--exclude=*/data', '--exclude=*/data/*', '--exclude=modules/*/data', '--exclude=dist', '--exclude=*.sqlite', '--exclude=*.log', '--exclude=*.tmp'];
|
||||
if (m === 'webmaker' || m === 'arimail') { tarArgs.push('--transform', `s,^modules/${m},.,`); } tarArgs.push(...present);
|
||||
execFileSync('tar', tarArgs); artifacts[m] = { file, sha256: sha(abs), size: statSync(abs).size, paths: present };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+79
-1
@@ -6,10 +6,12 @@ import { createRunner } from '../agent/runner.mjs';
|
||||
import { KINDS } from '../agent/jobs/index.mjs';
|
||||
import { ingestLog } from '../agent/jobs/site.mjs';
|
||||
import { sample } from '../agent/metrics.mjs';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readlinkSync, copyFileSync, statSync, appendFileSync } from 'node:fs';
|
||||
import { scanWebmakerInventory, webmakerInventoryBatches, configuredWebmakerInventory } from '../agent/webmaker-inventory.mjs';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readlinkSync, copyFileSync, statSync, appendFileSync, chmodSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ROOT as ROOT_DIR, readJson } from '../agent/jobs/common.mjs';
|
||||
|
||||
/** 가짜 HAL 클라이언트: 전송은 메모리(파일 경로) 저장, artifact 는 미리 둔 파일 */
|
||||
function fakeClient({ artifacts = {} } = {}) {
|
||||
@@ -31,6 +33,33 @@ function env(over = {}) {
|
||||
/** 사이트 번들 디렉터리 만들기 */
|
||||
function bundle(base, name, files = { 'index.html': '<h1>hello marker-1</h1>' }) { const d = join(base, 'bundles', name); mkdirSync(d, { recursive: true }); for (const [f, c] of Object.entries(files)) { mkdirSync(join(d, f, '..'), { recursive: true }); writeFileSync(join(d, f), c); } return d; }
|
||||
|
||||
await test('Webmaker 사이트 인벤토리: 모든 디렉터리·배포 상태·메타데이터를 비밀 없이 HAL에 전달', async () => {
|
||||
const base = tmpDir(); const dataRoot = join(base, 'data'); const dataDir = join(base, 'custom-meta'); const sitesRoot = join(base, 'published');
|
||||
mkdirSync(dataDir, { recursive: true }); for (const id of ['a', 'b', 'c', 'e'].map((x) => x.repeat(32))) { mkdirSync(join(sitesRoot, id), { recursive: true }); writeFileSync(join(sitesRoot, id, 'index.html'), id[0]); }
|
||||
writeFileSync(join(dataDir, 'a'.repeat(32) + '.json'), JSON.stringify({ id: 'a'.repeat(32), title: '사이트 A', status: 'idle', ownerId: 'owner-a', domains: [{ name: 'a.example.test' }], deployments: [{ ok: true, kind: 'deploy' }], content: 'HAL에 보내면 안 되는 본문', commands: [{ prompt: '비밀' }] }));
|
||||
writeFileSync(join(dataDir, 'b'.repeat(32) + '.json'), JSON.stringify({ id: 'b'.repeat(32), title: '초안', deployments: [] }));
|
||||
writeFileSync(join(dataDir, 'c'.repeat(32) + '.json'), JSON.stringify({ id: 'c'.repeat(32), title: '중지', deployments: [{ ok: true, kind: 'deploy' }, { ok: true, kind: 'unpublish' }], unpublishedAt: '2026-01-01T00:00:00Z' }));
|
||||
writeFileSync(join(dataDir, 'd'.repeat(32) + '.json'), JSON.stringify({ id: 'd'.repeat(32), title: '배포 전 초안', deployments: [] }));
|
||||
writeFileSync(join(dataDir, 'f'.repeat(32) + '.json'), JSON.stringify([]));
|
||||
const waitingId = 'f'.repeat(31) + 'd'; const legacyId = 'f'.repeat(31) + 'e';
|
||||
writeFileSync(join(dataDir, waitingId + '.json'), JSON.stringify({ title: 'DNS 대기', deployments: [{ ok: true, phase: 'dns-wait' }] }));
|
||||
writeFileSync(join(dataDir, legacyId + '.json'), JSON.stringify({ title: '구형 배포 기록', deployments: [{ ok: true }] }));
|
||||
writeFileSync(join(dataDir, 'A'.repeat(32) + '.json'), JSON.stringify({ title: '대문자 ID는 비정상' }));
|
||||
writeFileSync(join(dataDir, 'g'.repeat(32) + '.json.swp'), JSON.stringify({ title: '임시 파일은 제외' }));
|
||||
const inv = scanWebmakerInventory({ dataRoot, dataDir, sitesRoot });
|
||||
eq(inv.total, 8); eq(inv.returned, 8); eq(inv.truncated, false); eq(inv.complete, true); eq(inv.sites[0].id, 'a'.repeat(32)); eq(inv.sites[0].title, '사이트 A'); eq(inv.sites[0].ownerId, 'owner-a'); eq(inv.sites[0].domains, ['a.example.test']); eq(inv.sites[0].deploymentState, 'deployed'); ok(inv.sites[0].bytes > 0);
|
||||
eq(inv.sites[1].deploymentState, 'draft'); eq(inv.sites[2].deploymentState, 'unpublished'); eq(inv.sites[3].deploymentState, 'draft'); eq(inv.sites[3].files, 0, '메타데이터에만 있는 미배포 초안도 포함'); eq(inv.sites[4].metadata, 'missing', '배포 디렉터리에만 있는 사이트도 포함'); eq(inv.sites.find((x) => x.id === 'f'.repeat(32)).metadata, 'invalid', '배열·원시 JSON은 유효한 사이트 메타데이터가 아님'); eq(inv.sites.find((x) => x.id === waitingId).deploymentState, 'draft', 'DNS 대기 단계는 운영 배포 완료가 아님'); eq(inv.sites.find((x) => x.id === legacyId).deploymentState, 'deployed', 'phase 도입 전 성공 기록은 배포됨'); ok(inv.errors.some((x) => x.includes('invalid_shape'))); ok(!JSON.stringify(inv).includes('대문자 ID') && !JSON.stringify(inv).includes('임시 파일은 제외')); ok(!JSON.stringify(inv).includes('본문') && !JSON.stringify(inv).includes('비밀'));
|
||||
const limited = scanWebmakerInventory({ dataRoot, dataDir, sitesRoot, maxSites: 2 }); eq(limited.total, 8); eq(limited.returned, 2); eq(limited.truncated, true); eq(limited.complete, false);
|
||||
const broken = scanWebmakerInventory({ dataRoot, dataDir, sitesRoot: join(base, 'missing-root') }); eq(broken.total, 7); eq(broken.complete, false); ok(broken.errors.some((x) => x.startsWith('sites:')), '배포 루트 오류를 숨기지 않으면서 메타데이터 프로젝트는 보존');
|
||||
const missingMeta = scanWebmakerInventory({ dataRoot, dataDir: join(base, 'missing-meta'), sitesRoot }); eq(missingMeta.complete, false); ok(missingMeta.errors.some((x) => x.startsWith('metadata:')), '메타데이터 루트 읽기 실패 표시'); eq(missingMeta.sites[0].metadata, 'missing');
|
||||
const aliasRoot = join(base, 'alias-root'); symlinkSync(dataRoot, aliasRoot, 'dir'); const aliasInv = scanWebmakerInventory({ dataRoot: aliasRoot, dataDir, sitesRoot }); eq(aliasInv.total, 8); eq(aliasInv.errors.filter((x) => x.includes('invalid_shape')).length, 1, '실경로가 같은 metadata root는 한 번만 스캔');
|
||||
const largeSites = Array.from({ length: 100 }, (_, i) => ({ id: String(i).padStart(32, 'a'), title: 'x'.repeat(200), domains: Array.from({ length: 100 }, (_, n) => `${String(n).padStart(240, 'd')}.test`) })); const batches = webmakerInventoryBatches({ ...inv, sites: largeSites, returned: 100 }); ok(Buffer.byteLength(JSON.stringify({ facts: { webmakerInventory: { ...inv, sites: largeSites } } })) > 2 * 1024 * 1024, '단일 heartbeat면 2MiB 초과 fixture'); ok(batches.length > 1 && batches.every((b) => b.sites.length <= 1000 && Buffer.byteLength(JSON.stringify(b)) <= 512 * 1024)); eq(batches.flatMap((b) => b.sites).map((x) => x.id), largeSites.map((x) => x.id), 'byte 배치 후 전체 사이트 무누락');
|
||||
const manySites = Array.from({ length: 1500 }, (_, i) => ({ id: `s${i}` })); const countBatches = webmakerInventoryBatches({ ...inv, sites: manySites, returned: 1500 }); ok(countBatches.length === 2 && countBatches.every((b) => b.sites.length <= 1000)); eq(countBatches.flatMap((b) => b.sites).length, 1500, 'HAL 항목 제한 배치 후 무누락');
|
||||
const noisy = webmakerInventoryBatches({ ...inv, sites: [], errors: Array.from({ length: 100000 }, () => 'scan:EACCES'.repeat(40)) }); eq(noisy.length, 1); ok(Buffer.byteLength(JSON.stringify(noisy[0])) <= 512 * 1024 && noisy[0].meta.errorsTruncated, '빈 사이트·대량 오류도 HTTP 한도 내 명시적 절단');
|
||||
const unconfigured = configuredWebmakerInventory({ units: { webmaker: 'webmaker' } }, {}); ok(unconfigured.webmakerInventory.errors[0].startsWith('configuration:') && unconfigured.webmakerInventory.complete === false, '명시적 운영 경로 없이는 추측 스캔하지 않음'); let incompleteRejected = false; try { webmakerInventoryBatches(unconfigured.webmakerInventory); } catch (e) { incompleteRejected = e.message === 'inventory_incomplete'; } ok(incompleteRejected, '불완전 스캔은 HAL의 마지막 정상 인벤토리를 덮어쓰지 않음');
|
||||
const configured = configuredWebmakerInventory({ units: { webmaker: 'webmaker' } }, { ARIWEB_WEBMAKER_DATA_ROOT: dataRoot, ARIWEB_WEBMAKER_DATA_DIR: dataDir, ARIWEB_WEBMAKER_SITES_ROOT: sitesRoot }); eq(configured.webmakerInventory.total, 8); eq(configured.webmakerInventory.complete, true);
|
||||
});
|
||||
|
||||
await test('site.deploy: 레이아웃(releases/current)·버전 기록·vhost·내부 확인·previous, rollback, 실패 시 이전 버전 유지와 leftovers, remove', async () => {
|
||||
const e = env(); const b1 = bundle(e.base, 'b1'); mkdirSync(e.roots.wildcardCert, { recursive: true }); writeFileSync(join(e.roots.wildcardCert, 'fullchain.pem'), 'x');
|
||||
let r = await e.run('site.deploy', { siteId: 'site1', versionRef: 'v1', bundlePath: b1, domains: ['site1.ariweb.net'], primaryDomain: 'site1.ariweb.net', expectMarker: 'marker-1' }); eq(r.status, 'done', JSON.stringify(r));
|
||||
@@ -113,4 +142,53 @@ await test('module.install: artifact 검증·releases/current·유닛(기존 Env
|
||||
// agent 자기 갱신은 재시작을 결과 보고 뒤로 미룬다
|
||||
r = await runner2.execute({ id: 'm6', kind: 'module.install', payload: { module: 'agent', version: '0.1.2', artifactUrl: '/art/portal2', sha256: sum2, descriptor: { ...desc, name: 'agent', unit: 'ariweb-agent' }, appDir: join(e.roots.srv, 'agent') } }); eq(r.result.restartAfterReport, 'ariweb-agent');
|
||||
});
|
||||
await test('module.install(실제 환경 결함 수정): REVISION.json 의 Suite 버전이 실제 버전(상류 package.json 은 upstreamVersion) · keepExistingUnit 은 current 를 가리키는 기존 유닛을 보존 · 실행 계정이 다른 도입 모듈은 module-as-user.sh 로 풀기·전환·DB 백업·EnvironmentFile 마이그레이션', async () => {
|
||||
const e = env(); const { DatabaseSync } = await import('node:sqlite');
|
||||
// 상류 버전 1.0.11 인 AriMail 모양의 artifact: server/db-migrate.mjs 는 DB_FILE 환경을 파일에 남겨 EnvironmentFile 이 실렸는지 증명한다
|
||||
const app = join(e.base, 'bundles', 'arimail'); mkdirSync(join(app, 'server'), { recursive: true }); writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'arimail', version: '1.0.11' }));
|
||||
writeFileSync(join(app, 'server', 'db-migrate.mjs'), "import { writeFileSync } from 'node:fs'; if (process.env.FAIL_MIGRATE) { console.error('migrate boom'); process.exit(1); } writeFileSync(process.env.DB_FILE + '.migrated', JSON.stringify({ db: process.env.DB_FILE, cwd: process.cwd(), name: process.env.MAIL_FROM_NAME, quoted: process.env.QUOTED }));\n");
|
||||
const tgz = join(e.base, 'arimail-0.1.1.tar.gz'); execFileSync('tar', ['-czf', tgz, '-C', app, '.']); const sum = createHash('sha256').update(readFileSync(tgz)).digest('hex');
|
||||
const client = fakeClient({ artifacts: { '/art/arimail': tgz } }); const runner = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client, dataDir: e.dataDir });
|
||||
// 서버에 이미 있던 AriMail 배치: appDir·data·DB·EnvironmentFile. 유닛의 User 는 Agent 계정과 다르다.
|
||||
const appDir = join(e.roots.srv, 'arihakdang-mail'); mkdirSync(join(appDir, 'releases', '20261024-114605'), { recursive: true }); mkdirSync(join(appDir, 'data'), { recursive: true }); execFileSync('ln', ['-sfn', join(appDir, 'releases', '20261024-114605'), join(appDir, 'current')]);
|
||||
const db = join(appDir, 'data', 'mail-gateway.sqlite'); { const d = new DatabaseSync(db); d.exec('CREATE TABLE t(x)'); d.exec('INSERT INTO t VALUES(1)'); d.close(); }
|
||||
const envFile = join(e.base, 'gateway.env'); writeFileSync(envFile, `DB_FILE=${db}\nSECRET_X="not-printed"\nMAIL_FROM_NAME=Ari Hak dang\n# 주석\nQUOTED='a b'\n`); // systemd 문법(공백 값·따옴표·주석)
|
||||
e.sys.unitText = async (u) => (u === 'arihakdang-mail' ? `[Service]\nUser=svc-other\nWorkingDirectory=${appDir}/current\nEnvironmentFile=${envFile}\nExecStart=${process.execPath} server/index.mjs\n` : '');
|
||||
// sudo -n -u <계정> /usr/bin/bash <스크립트> … 를 같은 계정으로 직접 실행해(권한 없는 테스트 환경) 스크립트 자체의 동작을 검증한다
|
||||
const origRun = e.sys.run; const sudoCalls = []; e.sys.run = (cmd, args, o) => { if (cmd === 'sudo' && args[3] === '/usr/bin/bash') { sudoCalls.push(args.slice(5)); return origRun('bash', args.slice(4), { ...(o || {}), allowStub: false }); } return origRun(cmd, args, o); };
|
||||
const desc = { name: 'arimail', unit: 'arihakdang-mail', unitTemplate: null, user: null, port: 8790, health: { url: 'http://127.0.0.1:1/api/health', expect: 'ok' }, version: { file: 'package.json' }, layout: 'releases', preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], copyPaths: ['server', 'package.json'] };
|
||||
let r = await runner.execute({ id: 'u1', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1'); eq(r.result.upstreamVersion, '1.0.11'); eq(r.result.runUser, 'svc-other'); eq(r.result.previousVersion, '20261024-114605');
|
||||
eq(readlinkSync(join(appDir, 'current')), join(appDir, 'releases', '0.1.1')); eq(readJson(join(appDir, 'releases', '0.1.1', 'REVISION.json')).version, '0.1.1'); eq(readJson(join(appDir, '.previous.json')).previous, join(appDir, 'releases', '20261024-114605'));
|
||||
ok(['extract', 'write', 'backup-db', 'switch', 'write', 'migrate'].every((s) => sudoCalls.some((c) => c[0] === s)), '실행 계정 하위 명령: ' + sudoCalls.map((c) => c[0]).join(','));
|
||||
ok(existsSync(join(appDir, 'work')) === false, 'appDir 에 work 디렉터리를 만들지 않는다'); ok(existsSync(join(e.roots.srv, 'agent', 'work')));
|
||||
const backups = readdirSync(join(appDir, 'data', 'backups')); eq(backups.length, 1); { const d = new DatabaseSync(join(appDir, 'data', 'backups', backups[0]), { readOnly: true }); eq(d.prepare('SELECT COUNT(*) AS n FROM t').get().n, 1); d.close(); }
|
||||
// 같은 버전 재설치(실행 중인 current 디렉터리 보존 → .prev-<시각> 가 되돌리기 대상)
|
||||
r = await runner.execute({ id: 'u1b', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); ok(/^0\.1\.1\.prev-\d{14}(\.\d+)?$/.test(r.result.previousVersion), '보존된 이전 디렉터리: ' + r.result.previousVersion); eq(readlinkSync(join(appDir, 'current')), join(appDir, 'releases', '0.1.1')); ok(existsSync(join(appDir, 'releases', r.result.previousVersion, 'REVISION.json'))); ok(!existsSync(join(appDir, 'releases', '0.1.1.tmp')));
|
||||
eq(readJson(db + '.migrated').db, db, 'EnvironmentFile 의 DB_FILE 로 마이그레이션'); eq(readJson(db + '.migrated').name, 'Ari Hak dang', '공백 값'); eq(readJson(db + '.migrated').quoted, 'a b', '따옴표 값');
|
||||
// 사후 절차(마이그레이션) 실패 → current 를 이전 릴리스로 되돌린다
|
||||
writeFileSync(envFile, `DB_FILE=${db}\nFAIL_MIGRATE=1\n`); const curBefore = readlinkSync(join(appDir, 'current'));
|
||||
r = await runner.execute({ id: 'u1c', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } });
|
||||
eq(r.status, 'failed'); ok(/migrate boom|migrate 실패/.test(r.error), r.error); eq(readlinkSync(join(appDir, 'current')).replace(/\/$/, ''), r.result?.partial?.rolledBackTo || readlinkSync(join(appDir, 'current')), '되돌림'); ok(/\.prev-\d{14}(\.\d+)?$/.test(readlinkSync(join(appDir, 'current'))), 'current 가 보존된 이전 디렉터리를 가리킨다: ' + readlinkSync(join(appDir, 'current')));
|
||||
writeFileSync(envFile, `DB_FILE=${db}\n`); r = await runner.execute({ id: 'u1d', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } }); eq(r.status, 'done', JSON.stringify(r).slice(0, 300)); void curBefore; eq(readJson(db + '.migrated').cwd, join(appDir, 'releases', '0.1.1'));
|
||||
ok(e.sys.calls.some((c) => c.cmd === 'sudo' && c.args.join(' ').includes('systemctl restart arihakdang-mail-haraka')));
|
||||
// module-as-user.sh 는 모듈 디렉터리 밖 경로를 거부한다
|
||||
const bad = await origRun('bash', [join(ROOT_DIR, 'deploy', 'module-as-user.sh'), 'remove', '--app-dir', appDir, '--dir', join(e.base, 'bundles')], { allowStub: false }); eq(bad.code, 3);
|
||||
// keepExistingUnit: 유닛이 이미 <appDir>/current 를 가리키면 템플릿으로 다시 쓰지 않는다(강화 지시문 보존)
|
||||
const wmApp = join(e.base, 'bundles', 'webmaker'); mkdirSync(wmApp, { recursive: true }); writeFileSync(join(wmApp, 'package.json'), JSON.stringify({ name: 'webmaker', version: '1.7.3' })); const wtgz = join(e.base, 'webmaker-0.1.1.tar.gz'); execFileSync('tar', ['-czf', wtgz, '-C', wmApp, '.']); const wsum = createHash('sha256').update(readFileSync(wtgz)).digest('hex');
|
||||
const wmDir = join(e.roots.srv, 'webmaker'); mkdirSync(join(wmDir, 'releases', 'old'), { recursive: true }); execFileSync('ln', ['-sfn', join(wmDir, 'releases', 'old'), join(wmDir, 'current')]);
|
||||
const wmData = join(e.base, 'wmdata'); mkdirSync(wmData, { recursive: true }); writeFileSync(join(wmData, 'ok.json'), '{}'); writeFileSync(join(wmData, 'unreadable.json'), '{}'); chmodSync(join(wmData, 'unreadable.json'), 0o000); // root 가 남긴 파일처럼 읽을 수 없는 항목
|
||||
e.sys.unitText = async (u) => (u === 'webmaker' ? `[Service]\nUser=${(await import('node:os')).userInfo().username}\nWorkingDirectory=${wmDir}/current\nProtectSystem=strict\nEnvironment=DATA_ROOT=${wmData}\n` : '');
|
||||
const before = e.sys.calls.length; const runner2 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/wm': wtgz } }), dataDir: e.dataDir }); const oldLocale = process.env.LC_ALL; process.env.LC_ALL = 'C';
|
||||
r = await runner2.execute({ id: 'u2', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases', preStop: ['webmaker.backupData'], postStart: [], extraUnits: ['webmaker-setup'] }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
|
||||
if (oldLocale == null) delete process.env.LC_ALL; else process.env.LC_ALL = oldLocale;
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1'); eq(r.result.upstreamVersion, '1.7.3'); eq(r.steps.find((s) => s.id === 'unit').result.skipped, 'existing_unit_kept');
|
||||
{ const pre = r.steps.find((s) => s.id === 'pre:webmaker.backupData').result; ok(pre.backup && existsSync(pre.backup), '데이터 백업 파일'); ok(pre.unreadable && pre.unreadable.length === 1 && /unreadable\.json/.test(pre.unreadable[0]), '읽지 못한 파일은 경고로: ' + JSON.stringify(pre.unreadable)); }
|
||||
ok(!e.sys.calls.slice(before).some((c) => c.cmd === 'sudo' && c.args.includes('tee')), '유닛 파일을 다시 쓰지 않는다'); ok(e.sys.calls.slice(before).some((c) => c.args.join(' ').includes('systemctl restart webmaker-setup')));
|
||||
// 유닛이 다른 곳을 가리키면(또는 없으면) 템플릿으로 쓴다
|
||||
e.sys.unitText = async () => ''; const runner3 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/wm': wtgz } }), dataDir: e.dataDir }); const b2 = e.sys.calls.length;
|
||||
r = await runner3.execute({ id: 'u3', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases' }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.steps.find((s) => s.id === 'unit').result.written, true); ok(e.sys.calls.slice(b2).some((c) => c.cmd === 'sudo' && c.args.includes('tee')) || existsSync('/etc/systemd/system/webmaker.service'));
|
||||
});
|
||||
done();
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,147 @@
|
||||
/** test/hal-mailimport.test.mjs — HAL 기존(legacy) 메일 서비스 가져오기(hal/mailsvc.mjs) 회귀 테스트.
|
||||
* 읽기 전용 discovery 문서 → 검증(비밀값 차단) → dry-run 계획 → 트랜잭션 적용 → 감사 → readback → 되돌리기. 도메인별 active owner 는 정확히 하나. */
|
||||
import { test, eq, ok, rejects, tmpDir, done } from '../lib/test.mjs';
|
||||
import { invoke } from '../lib/http.mjs';
|
||||
import { signHeaders } from '../lib/serviceauth.mjs';
|
||||
import { createHalDeps } from '../hal/deps.mjs';
|
||||
import { buildApp } from '../hal/app.mjs';
|
||||
import * as core from '../hal/core.mjs';
|
||||
import * as ms from '../hal/mailsvc.mjs';
|
||||
import { listAudit } from '../lib/audit.mjs';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function makeHal() {
|
||||
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
|
||||
const 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: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, values: {}, set(p) { Object.assign(this, p); } };
|
||||
const d = createHalDeps({ config, dbFile: ':memory:' }); d.jobsRunning = true; return { d, app: buildApp(d) };
|
||||
}
|
||||
/** 실제 환경(mail.arihakdang.com)과 같은 모양의 discovery 문서. 비밀값 없음, 참조만. */
|
||||
function discovery(over = {}) {
|
||||
return {
|
||||
format: 1, collectedAt: '2026-10-27T05:00:00.000Z', collector: 'scripts/mail-discover.mjs',
|
||||
source: { hostname: 'srv1953374', unit: 'arihakdang-mail.service', appDir: '/srv/arihakdang-mail', currentTarget: '/srv/arihakdang-mail/releases/0.1.1', user: 'arihakdang', nodeBin: '/opt/arihakdang-node/bin/node',
|
||||
unitState: { active: 'active', enabled: 'enabled', nRestarts: 0, mainPid: 1046648, fragmentPath: '/etc/systemd/system/arihakdang-mail.service', environmentFiles: ['/etc/arihakdang-mail/gateway.env'] },
|
||||
revision: { module: 'arimail', version: '0.1.1', upstreamVersion: '1.0.11', installedAt: '2026-10-26T14:09:29.902Z' }, packageVersion: '1.0.11', healthUrl: 'http://127.0.0.1:8790/api/health', health: { ok: true, version: '1.0.11', domains: 2 }, dataDir: '/srv/arihakdang-mail/data' },
|
||||
domains: [
|
||||
{ domain: 'arihakdang.com', enabled: true, primary: true, hosts: ['mail.arihakdang.com'], authMode: 'www', operating: 'active', ariweb: null },
|
||||
{ domain: 'ariweb.net', enabled: true, primary: false, hosts: ['webmail.ariweb.net'], authMode: 'webmaker', operating: 'active', ariweb: null },
|
||||
],
|
||||
listeners: [{ port: 8790, bind: '127.0.0.1', owner: 'unit' }, { port: 587, bind: '0.0.0.0', owner: 'unit' }, { port: 993, bind: '0.0.0.0', owner: 'unit' }],
|
||||
aggregates: { accounts: 4, mailboxes: 4, messages: 195, storageBytes: 545259520, byDomain: { 'arihakdang.com': { accounts: 2, mailboxes: 2, messages: 189, dbBytes: 22863872 }, 'ariweb.net': { accounts: 2, mailboxes: 2, messages: 6, dbBytes: 372736 } } },
|
||||
provider: { kind: 'resend', haraka: { enabled: false, running: false }, configPath: '/srv/arihakdang-mail/data/config.json', configKeys: ['RESEND_API_KEY', 'inbound', 'outbound'] },
|
||||
secretRefs: [{ name: 'gateway.env', ref: 'file:/etc/arihakdang-mail/gateway.env', mode: '600', owner: 'arihakdang' }, { name: 'config.json', ref: 'file:/srv/arihakdang-mail/data/config.json', mode: '600' }, { name: 'tls', ref: 'dir:/etc/arihakdang-mail/tls' }],
|
||||
evidence: { nginx: { vhostFile: '/etc/nginx/sites-available/mail.arihakdang.conf', serverNames: ['mail.arihakdang.com'], proxyPass: 'http://127.0.0.1:8790' }, tls: { host: 'mail.arihakdang.com', subject: 'CN=mail.arihakdang.com', validTo: '2027-01-12T08:26:09.000Z' }, dns: { 'mail.arihakdang.com': { a: ['187.53.133.173'] }, 'arihakdang.com': { mx: ['inbound-smtp.ap-northeast-1.amazonaws.com'] } } },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
const sig = (keyId, secret, method, path, body) => Object.fromEntries(Object.entries(signHeaders(keyId, secret, method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]));
|
||||
|
||||
await test('discovery 검증: 형식·필수 항목·도메인 형식·중복 도메인·비밀값 포함 거부, 참조(secretRefs)만 허용', () => {
|
||||
eq(ms.validateDiscovery(discovery()).ok, true);
|
||||
ok(ms.validateDiscovery({ format: 2 }).errors.some((e) => /format/.test(e)), '형식 버전');
|
||||
ok(ms.validateDiscovery(discovery({ source: null })).errors.some((e) => /source/.test(e)), 'source 필수');
|
||||
ok(ms.validateDiscovery(discovery({ domains: [{ domain: 'Bad Domain', enabled: true, hosts: [] }] })).errors.some((e) => /도메인 형식/.test(e)), '도메인 형식');
|
||||
ok(ms.validateDiscovery(discovery({ domains: [...discovery().domains, discovery().domains[0]] })).errors.some((e) => /중복/.test(e)), '문서 안 중복 도메인');
|
||||
ok(ms.validateDiscovery(discovery({ secretRefs: [{ name: 'gateway.env', ref: 'file:/etc/x', value: 'abc' }] })).errors.some((e) => /비밀/.test(e)), 'secretRefs 에 값 금지');
|
||||
ok(ms.validateDiscovery(discovery({ provider: { kind: 'resend', apiKey: 're_abcdefghijklmnopqrstuvwxyz0123456789' } })).errors.some((e) => /비밀/.test(e)), '비밀 형태 값 거부');
|
||||
ok(ms.validateDiscovery(discovery({ provider: { kind: 'resend', password: 'hunter2hunter2' } })).errors.some((e) => /비밀/.test(e)), '비밀 이름 키의 값 거부');
|
||||
ok(ms.validateDiscovery(discovery({ secretRefs: [{ name: 'x', ref: 'plain-value' }] })).errors.some((e) => /ref/.test(e)), 'ref 는 file:/dir:/vault:/env: 형식');
|
||||
});
|
||||
|
||||
await test('dry-run 계획: 서버 없음·역할·호스트 불일치 거부, 도메인별 create, 비활성 도메인 skip, DB 변경 없음, 계획은 mail_imports 에 기록', async () => {
|
||||
const { d } = makeHal(); const doc = discovery();
|
||||
await rejects(() => ms.applyImport(d, doc, { serverId: 'srv_none', by: 'test', dryRun: true }), 'server_not_found');
|
||||
const web = core.createServer(d, { name: 'web', role: 'web' });
|
||||
await rejects(() => ms.applyImport(d, doc, { serverId: web.id, by: 'test', dryRun: true }), 'bad_role');
|
||||
const srv = core.createServer(d, { name: 'srv1953374 (mail)', role: 'mail', location: 'hostinger' });
|
||||
d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'other-host' }), srv.id);
|
||||
await rejects(() => ms.applyImport(d, doc, { serverId: srv.id, by: 'test', dryRun: true }), 'authority_mismatch');
|
||||
d.store.run('UPDATE servers SET facts_json=NULL WHERE id=?', srv.id);
|
||||
const r = ms.applyImport(d, { ...doc, domains: [...doc.domains, { domain: 'off.example', enabled: false, hosts: ['mail.off.example'] }] }, { serverId: srv.id, by: 'test', dryRun: true });
|
||||
eq(r.mode, 'dry_run'); eq(r.plan.ok, true); eq(r.plan.summary, { create: 2, update: 0, reject: 0, skip: 1, missingInSource: 0 });
|
||||
eq(r.plan.items.find((i) => i.domain === 'off.example').action, 'skip');
|
||||
eq(ms.listMailServices(d).length, 0, 'dry-run 은 mail_services 를 바꾸지 않음');
|
||||
const imp = d.store.get('SELECT * FROM mail_imports WHERE id=?', r.importId); eq(imp.mode, 'dry_run'); eq(imp.status, 'planned'); ok(!imp.applied_at);
|
||||
ok(listAudit(d.store, { action: 'mail_service.import_dry_run' }).length === 1, '감사 기록');
|
||||
});
|
||||
|
||||
await test('적용: 트랜잭션으로 생성, 불변 id, unassigned legacy managed, 도메인별 active owner 1, 비밀 참조만, 감사(immutable), 재적용은 update 멱등', async () => {
|
||||
const { d } = makeHal(); const srv = core.createServer(d, { name: 'srv1953374 (mail)', role: 'mail' }); const doc = discovery();
|
||||
const r = ms.applyImport(d, doc, { serverId: srv.id, by: 'test', dryRun: false });
|
||||
eq(r.mode, 'apply'); eq(r.created.length, 2); eq(r.updated.length, 0);
|
||||
const list = ms.listMailServices(d); eq(list.length, 2);
|
||||
const a = list.find((x) => x.domain === 'arihakdang.com'); ok(/^mls_/.test(a.id)); eq(a.kind, 'legacy_managed'); eq(a.ownership, 'unassigned_legacy_managed'); eq(a.ownerGroupId, null); eq(a.active, true); eq(a.serverId, srv.id);
|
||||
eq(a.authority, 'arimail:srv1953374:arihakdang-mail.service'); eq(a.instance.revision.version, '0.1.1'); eq(a.instance.revision.upstreamVersion, '1.0.11'); eq(a.publicHost, 'mail.arihakdang.com'); eq(a.webUrl, 'https://mail.arihakdang.com/');
|
||||
eq(a.aggregates, { accounts: 2, mailboxes: 2, messages: 189, dbBytes: 22863872 }); eq(a.secretRefs.map((s) => s.ref), ['file:/etc/arihakdang-mail/gateway.env', 'file:/srv/arihakdang-mail/data/config.json', 'dir:/etc/arihakdang-mail/tls']);
|
||||
ok(a.evidence.tls.subject === 'CN=mail.arihakdang.com' && a.evidence.dns['arihakdang.com'].mx[0].includes('amazonaws'), '증거 보존');
|
||||
eq(a.lastSyncSource, 'import'); ok(a.lastSyncAt);
|
||||
const au = listAudit(d.store, { action: 'mail_service.import' }); eq(au.length, 2); ok(au.every((x) => x.immutable === 1 && x.actor_type === 'operator' && x.actor_id === 'test'));
|
||||
ok(!JSON.stringify(d.store.all('SELECT * FROM mail_services')).includes('re_'), 'DB 에 비밀 없음');
|
||||
// 재적용: 같은 authority·서버 → update(id 유지), 집계만 갱신
|
||||
const doc2 = discovery(); doc2.aggregates.byDomain['arihakdang.com'].messages = 190;
|
||||
const r2 = ms.applyImport(d, doc2, { serverId: srv.id, by: 'test', dryRun: false }); eq(r2.created.length, 0); eq(r2.updated.length, 2);
|
||||
const a2 = ms.getMailService(d, a.id); eq(a2.id, a.id); eq(a2.aggregates.messages, 190); eq(ms.listMailServices(d).length, 2, '중복 생성 없음');
|
||||
const rb = ms.readback(d); eq(rb.counts.total, 2); eq(rb.counts.active, 2); eq(rb.duplicates, []); eq(rb.secretLeaks, 0); eq(rb.perServer[srv.id], 2);
|
||||
});
|
||||
|
||||
await test('충돌·누락: 다른 서버/인스턴스가 이미 active 인 도메인은 collision 으로 전체 거부(부분 적용 없음), 원본에서 사라진 도메인은 missing_in_source 로 보고(삭제하지 않음)', async () => {
|
||||
const { d } = makeHal(); const s1 = core.createServer(d, { name: 'h1', role: 'mail' }); const s2 = core.createServer(d, { name: 'h2', role: 'mail' });
|
||||
ms.applyImport(d, discovery(), { serverId: s1.id, by: 'test', dryRun: false });
|
||||
const other = discovery({ source: { ...discovery().source, hostname: 'jitsi', unit: 'arimail.service', appDir: '/srv/arimail' }, domains: [{ domain: 'ibit.kr', enabled: true, primary: true, hosts: ['webmail.ibit.kr'] }, { domain: 'ariweb.net', enabled: true, hosts: ['webmail.ariweb.net'] }] });
|
||||
const r = ms.applyImport(d, other, { serverId: s2.id, by: 'test', dryRun: false });
|
||||
eq(r.mode, 'rejected'); eq(r.plan.ok, false); eq(r.plan.items.find((i) => i.domain === 'ariweb.net').action, 'reject'); ok(/collision/.test(r.plan.items.find((i) => i.domain === 'ariweb.net').reason));
|
||||
eq(ms.listMailServices(d).filter((x) => x.serverId === s2.id).length, 0, '부분 적용 없음(ibit.kr 도 만들지 않음)');
|
||||
eq(d.store.get('SELECT status FROM mail_imports WHERE id=?', r.importId).status, 'rejected');
|
||||
ok(listAudit(d.store, { action: 'mail_service.import_rejected' }).length === 1);
|
||||
// 충돌 도메인을 뺀 문서는 적용된다
|
||||
const okDoc = { ...other, domains: other.domains.slice(0, 1) }; const r2 = ms.applyImport(d, okDoc, { serverId: s2.id, by: 'test', dryRun: false }); eq(r2.created.length, 1);
|
||||
eq(ms.readback(d).duplicates, []); eq(ms.readback(d).counts.active, 3);
|
||||
// 원본에서 ariweb.net 이 사라지면: 보고만, 삭제 없음
|
||||
const shrunk = discovery({ domains: [discovery().domains[0]] }); const r3 = ms.applyImport(d, shrunk, { serverId: s1.id, by: 'test', dryRun: true });
|
||||
eq(r3.plan.summary.missingInSource, 1); eq(r3.plan.missingInSource, ['ariweb.net']); eq(ms.listMailServices(d).length, 3);
|
||||
});
|
||||
|
||||
await test('되돌리기: 적용으로 만든 행 삭제·갱신한 행 복원, 두 번 되돌리기 거부, dry-run 되돌리기 거부', async () => {
|
||||
const { d } = makeHal(); const srv = core.createServer(d, { name: 'h1', role: 'mail' });
|
||||
const r1 = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: false });
|
||||
const before = ms.getMailService(d, r1.created[0]).aggregates.messages;
|
||||
const doc2 = discovery(); doc2.aggregates.byDomain['arihakdang.com'].messages = 999; doc2.domains.push({ domain: 'new.example', enabled: true, hosts: ['mail.new.example'] });
|
||||
const r2 = ms.applyImport(d, doc2, { serverId: srv.id, by: 'test', dryRun: false }); eq(r2.created.length, 1); eq(r2.updated.length, 2);
|
||||
const rb = ms.rollbackImport(d, r2.importId, 'test'); eq(rb.deleted, 1); eq(rb.restored, 2);
|
||||
eq(ms.listMailServices(d).length, 2); eq(ms.getMailService(d, r1.created[0]).aggregates.messages, before, '갱신 전 값 복원');
|
||||
await rejects(() => ms.rollbackImport(d, r2.importId, 'test'), 'already_rolled_back');
|
||||
const dr = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: true }); await rejects(() => ms.rollbackImport(d, dr.importId, 'test'), 'not_applied');
|
||||
ok(listAudit(d.store, { action: 'mail_service.import_rollback' }).length === 1);
|
||||
});
|
||||
|
||||
await test('heartbeat 연동: 서버의 Agent 가 arimail 모듈 상태를 보고하면 last_sync·health 갱신(Data Plane 과 독립: HAL 행이 없어도 heartbeat 정상)', async () => {
|
||||
const { d, app } = makeHal(); const srv = core.createServer(d, { name: 'srv1953374', role: 'mail' });
|
||||
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: srv.id, label: 'm' }); const reg = await invoke(app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'srv1953374' }, version: '0.1.1' } }); ok(reg.json.ok);
|
||||
const hb = (modules) => invoke(app, 'POST', '/agent/v1/heartbeat', { body: { version: '0.1.1', metrics: {}, modules, clockSkewMs: 0 }, headers: sig(`agent:${reg.json.agentId}`, reg.json.secret, 'POST', '/agent/v1/heartbeat', { version: '0.1.1', metrics: {}, modules, clockSkewMs: 0 }) });
|
||||
eq((await hb({ arimail: { version: '0.1.1', active: true, health: 'ok' } })).json.ok, true, 'mail_services 행 없이도 정상');
|
||||
const r = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: false }); const beforeSync = ms.getMailService(d, r.created[0]).lastSyncAt;
|
||||
await new Promise((x) => setTimeout(x, 5)); await hb({ arimail: { version: '0.1.1', active: false, health: 'down' } });
|
||||
const a = ms.getMailService(d, r.created[0]); eq(a.health, 'down'); eq(a.lastSyncSource, 'agent'); ok(a.lastSyncAt > beforeSync); eq(a.instance.observedVersion, '0.1.1');
|
||||
eq(ms.readback(d).services[0].agentId, reg.json.agentId);
|
||||
});
|
||||
|
||||
await test('운영자 API: 목록·상세·dry-run/적용(재인증)·되돌리기, 응답에 비밀 없음; CLI 와 같은 함수', async () => {
|
||||
const { d, app } = makeHal(); const srv = core.createServer(d, { name: 'h1', role: 'mail' });
|
||||
const { createAccount, login } = await import('../hal/ops.mjs'); createAccount(d, { login: 'op1', password: 'operator-pass-1', displayName: 'op' });
|
||||
const l = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'op1', password: 'operator-pass-1' } }); const cookie = l.cookies.aw_ops; const csrf = l.json.csrf; ok(cookie && csrf, '로그인');
|
||||
const opsCall = (m, p, b) => invoke(app, m, p, { body: b, cookies: { aw_ops: cookie }, headers: { 'x-csrf': csrf } });
|
||||
let r = await opsCall('GET', '/ops/api/mail-services'); eq(r.json.ok, true); eq(r.json.services, []); eq(r.json.readback.counts.total, 0);
|
||||
r = await opsCall('POST', '/ops/api/mail-services/import', { discovery: discovery(), serverId: srv.id }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.mode, 'dry_run');
|
||||
r = await opsCall('POST', '/ops/api/mail-services/import', { discovery: discovery(), serverId: srv.id, apply: true }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.mode, 'apply'); eq(r.json.result.created.length, 2);
|
||||
r = await opsCall('GET', '/ops/api/mail-services'); eq(r.json.services.length, 2); ok(!JSON.stringify(r.json).includes('re_abc'));
|
||||
r = await opsCall('GET', `/ops/api/mail-services/${r.json.services[0].id}`); eq(r.json.ok, true); ok(r.json.service.domain); ok(Array.isArray(r.json.audit));
|
||||
r = await opsCall('GET', '/ops/api/mail-services/imports'); eq(r.json.imports.length, 2);
|
||||
const applied = r.json.imports.find((i) => i.mode === 'apply'); r = await opsCall('POST', `/ops/api/mail-services/imports/${applied.id}/rollback`, {}); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.deleted, 2);
|
||||
eq((await opsCall('GET', '/ops/api/mail-services')).json.services.length, 0);
|
||||
// 메일 서비스 화면(HAL 관리형 표 + 포털 중계)
|
||||
ms.applyImport(d, discovery(), { serverId: srv.id, by: 'op1', dryRun: false });
|
||||
const page = await invoke(app, 'GET', '/ops/mail', { cookies: { aw_ops: cookie } }); ok(page.text.includes('arihakdang.com') && page.text.includes('legacy') && page.text.includes('unassigned'), '화면에 HAL 관리형 메일 서비스 표');
|
||||
});
|
||||
|
||||
done();
|
||||
+33
-2
@@ -22,6 +22,12 @@ function makeHal(over = {}) {
|
||||
/** 운영자 계정 + 로그인 세션 */
|
||||
async function loginOp(h) { ops.createAccount(h.d, { login: 'ozops', password: 'password-example-1', displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: 'password-example-1' } }); ok(r.json.ok, JSON.stringify(r.json)); return { cookie: r.cookies.aw_ops, csrf: r.json.csrf, as: (method, path, body, extra = {}) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops, ...(extra.cookies || {}) }, headers: { 'x-csrf': r.json.csrf, ...(extra.headers || {}) }, ip: extra.ip }) }; }
|
||||
|
||||
await test('HAL 루트 주소는 운영자 콘솔 기본 경로로 안내한다', async () => {
|
||||
const h = makeHal();
|
||||
const r = await invoke(h.app, 'GET', '/');
|
||||
eq(r.status, 302, '루트 주소는 404가 아니라 운영자 콘솔로 이동');
|
||||
eq(r.headers.Location, '/ops/', '운영자 콘솔의 표준 경로로 이동');
|
||||
});
|
||||
await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠키 → 로그인 화면, 운영자 세션 → 콘솔(메뉴 14개 + 계정)', async () => {
|
||||
const h = makeHal();
|
||||
let r = await invoke(h.app, 'GET', '/ops/', { ip: '203.0.113.9' }); eq(r.status, 403); eq(r.json.error, 'access_denied');
|
||||
@@ -33,16 +39,41 @@ 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 });
|
||||
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);
|
||||
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1', webmakerInventory: { total: 1, returned: 1, truncated: false, sites: [{ id: 'a'.repeat(32), title: '기존 사이트 <A>', ownerId: 'owner-a', status: '<idle>', domains: ['a.example.test'], deploymentState: 'deployed', bytes: 1234, files: 3, updatedAt: '2026-10-27T00:00:00Z', metadata: 'ok' }] } }, version: '0.1.1' } }); ok(reg.json.ok);
|
||||
h.d.store.run('UPDATE servers SET last_seen_at=?, capacity_json=?, metrics_json=? WHERE id=?', new Date().toISOString(), JSON.stringify({ diskFreeBytes: 10e9, diskTotalBytes: 100e9, sitesHosted: 3, reservedBytes: 1e9 }), JSON.stringify({ cpu: 12, memUsed: 50, memTotal: 100, netIn: 1000, netOut: 2000 }), s.id);
|
||||
core.approveServer(h.d, s.id, 'ozops');
|
||||
h.d.store.run('INSERT INTO module_targets(module, server_id, target_version, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', 'portal', s.id, '0.1.1', '0.1.0', 'ok', 1, new Date().toISOString(), new Date().toISOString());
|
||||
core.registerRelease(h.d, { version: '0.1.1', mainCommit: 'abcdef1234567890', builtAt: new Date().toISOString(), artifacts: { hal: { file: 'hal.tgz', sha256: 'sha256:x', size: 1 } }, checks: { testsPassed: true } });
|
||||
let r = await op.as('GET', '/ops/servers'); eq(r.status, 200); ok(r.text.includes('웹1') && r.text.includes('배치 가능') && r.text.includes('0.1.0') && r.text.includes('0.1.1'), '서버 표에 상태·모듈 차이');
|
||||
r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('모듈(목표 vs 실제)') && r.text.includes('서버 삭제') && r.text.includes('id="metrics"'));
|
||||
r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('모듈(목표 vs 실제)') && r.text.includes('서버 삭제') && r.text.includes('id="metrics"')); ok(r.text.includes('Webmaker 사이트 디렉터리 인벤토리') && r.text.includes('owner-a') && r.text.includes('a.example.test') && r.text.includes('기존 사이트 <A>') && !r.text.includes('기존 사이트 <A>') && r.text.includes('<idle>') && !r.text.includes('<idle>'));
|
||||
h.d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'web1', webmakerInventory: { total: 0, returned: 0, truncated: false, errors: ['sites:EACCES<script>'], errorsTruncated: true, sites: [] } }), s.id); r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('Webmaker 사이트 디렉터리 인벤토리') && r.text.includes('인벤토리 스캔 오류 1건') && r.text.includes('EACCES<script>') && !r.text.includes('EACCES<script>'));
|
||||
h.d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'web1', webmakerInventory: { total: 'bad', returned: {}, errors: '<script>', sites: [null, { id: '<id>', domains: '<img onerror=1>', files: {} }] } }), s.id); r = await op.as('GET', `/ops/servers/${s.id}`); eq(r.status, 200); ok(r.text.includes('<id>') && !r.text.includes('<img onerror=1>'), 'null 등 malformed agent facts도 렌더러를 깨거나 HTML로 실행하지 않음');
|
||||
r = await op.as('GET', '/ops/releases'); ok(r.text.includes('HAL 을 먼저 배포') && r.text.includes('abcdef123456') && r.text.includes('테스트 통과') && r.text.includes('승인 대기'));
|
||||
// 작업: 부모 + 자식(하나 실패) → 부분 실패 표시
|
||||
h.d.jobs.register('ui.demo', { title: '데모', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { await ctx.step('a', () => { if (job.subject.fail) throw new Error('대상 실패'); return 1; }); } });
|
||||
|
||||
@@ -64,6 +64,29 @@ await test('heartbeat → 지표·용량·모듈 버전 저장, 무응답 → of
|
||||
await sleep(80); eq(core.markOffline(h.d).length, 1); eq(core.listServers(h.d)[0].status, 'offline'); await a.hb(); eq(core.listServers(h.d)[0].status, 'ready', 'heartbeat 복귀');
|
||||
h.d.store.run('INSERT INTO metrics_samples(server_id, at, cpu) VALUES(?,?,?)', a.serverId, new Date(Date.now() - 100 * 86400000).toISOString(), 1); eq(core.purgeMetrics(h.d), 1); ok(core.metrics(h.d, a.serverId, { since: new Date(0).toISOString() }).every((m) => Date.now() - Date.parse(m.at) < 91 * 86400000));
|
||||
});
|
||||
await test('Webmaker 인벤토리 배치: 2MiB heartbeat와 분리·순서 검증·완료 시 원자 반영·후속 heartbeat 보존', async () => {
|
||||
const h = makeHal(); const a = await registerAgent(h); const path = '/agent/v1/inventory/webmaker'; const send = (body) => call(h.app, `agent:${a.agentId}`, a.secret, 'POST', path, body);
|
||||
const sites = ['a', 'b', 'c'].map((x) => ({ id: x.repeat(32), title: `site-${x}`, domains: [`${x}.test`], files: 1, bytes: 2 })); sites[0].privateContact = 'do-not-store@example.test'; sites[0].nested = { secret: 'do-not-store' }; const meta = { total: 3, errors: [], truncated: false, scannedAt: '2026-10-27T00:00:00.000Z' };
|
||||
let r = await send({ scanId: 'scan-1', offset: 1, itemTotal: 3, meta, sites: [sites[1]] }); eq(r.status, 400); eq(r.json.error, 'inventory_batch_order');
|
||||
r = await send({ scanId: 'scan-1', offset: 0, itemTotal: 3, meta, sites: sites.slice(0, 2) }); eq(r.status, 200); eq(r.json.complete, false); eq(core.getServer(h.d, a.serverId).facts.webmakerInventory, undefined, '중간 배치는 공개하지 않음');
|
||||
r = await send({ scanId: 'scan-1', offset: 2, itemTotal: 3, meta, sites: sites.slice(2) }); eq(r.status, 200); eq(r.json.complete, true); const stored = core.getServer(h.d, a.serverId).facts.webmakerInventory; eq(stored.sites.map((x) => x.id), sites.map((x) => x.id)); eq(stored.truncated, false); ok(!JSON.stringify(stored).includes('privateContact') && !JSON.stringify(stored).includes('do-not-store'), '허용되지 않은 개인정보·중첩 필드 저장 금지'); eq(h.d.store.get('SELECT COUNT(*) AS n FROM agent_inventory_staging').n, 0);
|
||||
r = await send({ scanId: 'scan-partial', offset: 0, itemTotal: 1, meta: { ...meta, total: 2, truncated: true }, sites: sites.slice(0, 1) }); eq(r.status, 400); eq(r.json.error, 'inventory_incomplete');
|
||||
r = await send({ scanId: 'scan-bad', offset: 0, itemTotal: 1, meta, sites: [{ id: '../bad', title: 'bad' }] }); eq(r.status, 400); eq(r.json.error, 'bad_inventory_batch');
|
||||
r = await send({ scanId: 'scan-too-many', offset: 0, itemTotal: 10001, meta, sites: [] }); eq(r.status, 400); eq(r.json.error, 'bad_inventory_batch');
|
||||
await a.hb({ facts: { hostname: 'web-after' } }); const facts = core.getServer(h.d, a.serverId).facts; eq(facts.hostname, 'web-after'); eq(facts.webmakerInventory.returned, 3, '작은 heartbeat가 완성된 인벤토리를 지우지 않음');
|
||||
r = await send({ scanId: 'scan-cascade', offset: 0, itemTotal: 2, meta: { ...meta, total: 2 }, sites: sites.slice(0, 1) }); eq(r.status, 200); eq(h.d.store.get('SELECT COUNT(*) AS n FROM agent_inventory_staging').n, 1); ok(h.d.store.get("SELECT name FROM sqlite_master WHERE type='trigger' AND name='agent_inventory_staging_agent_cleanup'"), '이전 스키마에도 적용 가능한 Agent 삭제 cleanup trigger'); h.d.store.run('DELETE FROM agents WHERE id=?', a.agentId); eq(h.d.store.get('SELECT COUNT(*) AS n FROM agent_inventory_staging').n, 0, 'Agent 삭제 시 미완료 staging cascade');
|
||||
});
|
||||
await test('setBootstrap: HAL 기동 전(행 없음) CLI bootstrap-release 의 기록이 남는다(실제 환경 결함 수정)', async () => {
|
||||
const h = makeHal(); eq(h.d.store.get('SELECT COUNT(*) AS n FROM bootstrap_state').n, 0); core.setBootstrap(h.d, { release_version: '0.1.1', main_commit: 'abc', artifact_sha256: 'def' });
|
||||
eq(core.bootstrapState(h.d).release_version, '0.1.1'); eq(core.bootstrapState(h.d).main_commit, 'abc');
|
||||
});
|
||||
await test('pickServer: web·backup 역할 서버가 없으면 control 서버를 쓴다(서버 한 대 운영, 실제 환경 결함 수정) · 전용 서버가 있으면 그쪽 우선', async () => {
|
||||
const h = makeHal(); h.d.vault.set('service:portal', portalSecret); bootstrapDone(h.d);
|
||||
const ctl = core.createServer(h.d, { name: 'ctl', role: 'control' }); const t1 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: ctl.id }); const a1 = core.registerAgent(h.d, { token: t1.token, facts: { hostname: 'ctl', nginx: true }, version: '0.1.1' }); core.approveServer(h.d, ctl.id, 'op'); core.heartbeat(h.d, core.getAgent(h.d, a1.agentId), { metrics: { disk: { total: 100e9, used: 10e9 } }, modules: {} });
|
||||
eq(core.pickServer(h.d, { role: 'web' })?.serverId, ctl.id, 'web 요청 → control'); eq(core.pickServer(h.d, { role: 'backup' })?.serverId, ctl.id, 'backup 요청 → control'); eq(core.pickServer(h.d, { role: 'mail' }), null, 'mail 은 대체하지 않는다');
|
||||
const web = core.createServer(h.d, { name: 'web', role: 'web' }); const t2 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: web.id }); const a2 = core.registerAgent(h.d, { token: t2.token, facts: { hostname: 'web', nginx: true }, version: '0.1.1' }); core.approveServer(h.d, web.id, 'op'); core.heartbeat(h.d, core.getAgent(h.d, a2.agentId), { metrics: { disk: { total: 100e9, used: 10e9 } }, modules: {} });
|
||||
eq(core.pickServer(h.d, { role: 'web' })?.serverId, web.id, '전용 web 서버 우선');
|
||||
});
|
||||
await test('부트스트랩 관문: 첫 Agent 승인 전 포털 API 503, 승인·HAL 실행 버전 뒤 완료·수동 설치 잠김', async () => {
|
||||
const h = makeHal(); h.d.vault.set('service:portal', portalSecret);
|
||||
let r = await call(h.app, 'portal', portalSecret, 'GET', '/portal/v1/servers'); eq(r.status, 503); eq(r.json.error, 'bootstrap_incomplete');
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/** test/mail-discover.test.mjs — scripts/mail-discover.mjs(읽기 전용 AriMail discovery) 회귀 테스트.
|
||||
* 가짜 앱 디렉터리(AriMail 1.0.7+ 등록부 형식·실제 스키마 이름의 SQLite)로 문서 모양·집계·비밀 미포함·HAL 검증 통과를 확인한다. systemctl/ss/네트워크는 없는 환경에서도 돈다(--no-net). */
|
||||
process.env.ARIWEB_DISCOVER_NO_MAIN = '1';
|
||||
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
|
||||
import { discover, dbCounts } from '../scripts/mail-discover.mjs';
|
||||
import { validateDiscovery, authorityOf } from '../hal/mailsvc.mjs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function fakeApp() {
|
||||
const app = join(tmpDir('mail-disc-'), 'srv', 'arimail'); const rel = join(app, 'releases', '20261024-204656'); mkdirSync(rel, { recursive: true }); mkdirSync(join(app, 'data', 'domains', 'second.example'), { recursive: true }); mkdirSync(join(app, 'data', 'backups'), { recursive: true });
|
||||
symlinkSync(rel, join(app, 'current')); writeFileSync(join(rel, 'REVISION'), 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924\n'); writeFileSync(join(rel, 'package.json'), JSON.stringify({ name: 'arimail', version: '1.0.11' }));
|
||||
writeFileSync(join(app, 'data', 'domains.json'), JSON.stringify({ _meta: { format: 1 }, primary: 'ibit.kr', domains: { 'ibit.kr': { domain: 'ibit.kr', enabled: true, primary: true, hosts: ['webmail.ibit.kr'], mailbox: 'admin@ibit.kr', auth: { mode: 'local' }, note: '' }, 'second.example': { domain: 'second.example', enabled: true, hosts: ['mail.second.example'], mailbox: 'x@second.example', auth: { mode: 'webmaker' }, ariweb: { kind: 'standalone', siteId: '', groupId: 'g_1', mailServiceId: 'msv_1' }, operating: 'frozen' } } }));
|
||||
writeFileSync(join(app, 'data', 'config.json'), JSON.stringify({ _meta: {}, RESEND_API_KEY: 're_SECRETSECRETSECRETSECRETSECRET1234', inbound: { provider: 'resend' }, outbound: { provider: 'resend' } }), { mode: 0o600 });
|
||||
writeFileSync(join(app, 'data', 'haraka-status.json'), JSON.stringify({ enabled: false, running: false, inbound: false, outbound: false, listen: ['127.0.0.1:2525'], version: '3.3.4', dkim: false }));
|
||||
writeFileSync(join(app, 'data', 'setup-token'), 'tokentokentokentokentoken', { mode: 0o600 });
|
||||
const mk = (f, users, msgs) => { const db = new DatabaseSync(f); db.exec("CREATE TABLE mail_users(id INTEGER PRIMARY KEY, login TEXT, password_hash TEXT, status TEXT); CREATE TABLE messages(id INTEGER PRIMARY KEY, subject TEXT); CREATE TABLE attachments(id INTEGER PRIMARY KEY); CREATE TABLE schema_migrations(id TEXT); CREATE TABLE outbound_queue(id INTEGER PRIMARY KEY)"); for (let i = 0; i < users; i++) db.prepare("INSERT INTO mail_users(login, password_hash, status) VALUES(?, 'HASHHASHHASHHASHHASHHASHHASHHASHHASHHASHHASH', 'active')").run('u' + i); for (let i = 0; i < msgs; i++) db.prepare('INSERT INTO messages(subject) VALUES(?)').run('s' + i); db.close(); };
|
||||
mk(join(app, 'data', 'mail-gateway.sqlite'), 3, 7); mk(join(app, 'data', 'domains', 'second.example', 'mail.sqlite'), 2, 1);
|
||||
const etc = join(app, '..', 'etc'); mkdirSync(join(etc, 'tls'), { recursive: true }); writeFileSync(join(etc, 'gateway.env'), 'MAILBOX=admin@ibit.kr\nRESEND_API_KEY=re_SECRETSECRETSECRETSECRETSECRET1234\nPUBLIC_HOST=webmail.ibit.kr\n', { mode: 0o600 }); writeFileSync(join(etc, 'tls', 'privkey.pem'), '-----BEGIN PRIVATE KEY-----\nxxx\n-----END PRIVATE KEY-----\n');
|
||||
const vhost = join(etc, 'webmail.ibit.kr.conf'); writeFileSync(vhost, 'server { listen 80; server_name webmail.ibit.kr; location / { return 301 https://webmail.ibit.kr$request_uri; } }\nserver { listen 443 ssl; server_name webmail.ibit.kr; ssl_certificate /etc/letsencrypt/live/webmail.ibit.kr/fullchain.pem; location / { proxy_pass http://127.0.0.1:8790; } }');
|
||||
const hook = join(etc, 'arimail.sh'); writeFileSync(hook, '#!/bin/bash\nsystemctl restart arimail || true\n');
|
||||
return { app, etc, vhost, hook };
|
||||
}
|
||||
|
||||
await test('dbCounts: 표 이름·행 수·무결성만(주소·해시는 읽지 않음)', () => {
|
||||
const { app } = fakeApp(); const c = dbCounts(join(app, 'data', 'mail-gateway.sqlite'));
|
||||
eq(c.accounts, 3); eq(c.activeAccounts, 3); eq(c.messages, 7); eq(c.integrity, 'ok'); eq(c.outboundQueue, 0); ok(!JSON.stringify(c).includes('HASH'));
|
||||
eq(dbCounts(join(app, 'data', 'nope.sqlite')), null);
|
||||
});
|
||||
|
||||
await test('discover(--no-net): 등록부 도메인별 집계(기본 도메인은 게이트웨이 DB, 나머지는 테넌트 DB), 리비전, 비밀은 참조만(키 이름만), HAL 검증 통과, authority', async () => {
|
||||
const { app, etc, vhost, hook } = fakeApp();
|
||||
const doc = await discover({ unit: 'arimail.service', appDir: app, etcDir: etc, vhost, hook, net: false, host: 'jitsi' });
|
||||
eq(doc.format, 1); eq(doc.source.unit, 'arimail.service'); eq(doc.source.revision.commit, 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924'); eq(doc.source.packageVersion, '1.0.11'); ok(doc.source.currentTarget.endsWith('releases/20261024-204656')); eq(doc.source.releases, ['20261024-204656']);
|
||||
eq(doc.domains.length, 2); const p = doc.domains.find((x) => x.domain === 'ibit.kr'); eq(p.primary, true); eq(p.hosts, ['webmail.ibit.kr']); eq(p.authMode, 'local'); ok(p.tenantDb.endsWith('mail-gateway.sqlite'));
|
||||
const s = doc.domains.find((x) => x.domain === 'second.example'); eq(s.primary, false); eq(s.ariweb, { kind: 'standalone', siteId: null, groupId: 'g_1', mailServiceId: 'msv_1' }, '빈 값은 null 로 정규화'); eq(s.operating, 'frozen');
|
||||
eq(doc.aggregates.accounts, 5); eq(doc.aggregates.mailboxes, 5); eq(doc.aggregates.messages, 8); eq(doc.aggregates.byDomain['ibit.kr'].accounts, 3); eq(doc.aggregates.byDomain['second.example'].messages, 1); ok(doc.aggregates.storageBytes > 0);
|
||||
eq(doc.provider.kind, 'resend'); eq(doc.provider.configKeys, ['RESEND_API_KEY', 'inbound', 'outbound']); eq(doc.evidence.haraka.enabled, false);
|
||||
const txt = JSON.stringify(doc); ok(!txt.includes('re_SECRET') && !txt.includes('HASHHASH') && !txt.includes('BEGIN PRIVATE') && !txt.includes('tokentoken'), '비밀값 없음'); ok(!txt.includes('admin@ibit.kr'), '메일 주소 없음');
|
||||
const refs = doc.secretRefs.map((r) => r.ref); ok(refs.some((r) => r === `file:${join(app, 'data', 'config.json')}`) && refs.some((r) => r === `dir:${join(etc, 'tls')}`) && refs.some((r) => r.endsWith('setup-token')), JSON.stringify(refs));
|
||||
eq(doc.evidence.nginx.serverNames, ['webmail.ibit.kr']); eq(doc.evidence.nginx.proxyPass, ['http://127.0.0.1:8790']); eq(doc.evidence.nginx.httpRedirect, true); eq(doc.evidence.certbot.restartsUnit, true);
|
||||
const v = validateDiscovery(doc); eq(v.errors, []); eq(authorityOf(doc), 'arimail:jitsi:arimail.service');
|
||||
});
|
||||
|
||||
done();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user