Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54db663531 | ||
|
|
843b2b1a79 |
@@ -0,0 +1,36 @@
|
||||
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.3.17
|
||||
|
||||
**작성자:** Oz
|
||||
**작성일:** 2026-10-29
|
||||
**이전 버전:** v0.3.10(텍스트 셸 복사, Oz) · v0.3.9 · v0.3.2 · v0.3.1 · v0.3.0 · v0.2.1 · v0.2.0 · v0.1.4 · v0.1.1
|
||||
**이번 개정:** HawkEye 이름 변경, 역할별 통합 배포, 화면 부분 갱신, 배포 릴리스 화면 재구성(HAL 배포 제외), 작업 진행 창, agent 배포 점검, 대상 서버 선택. HAL 0.3.10 → **0.3.17**(지시된 버전). HAL 에만 적용하며 이전 지시의 모든 기준은 그대로다.
|
||||
|
||||
## 1. 사용자 요구(Oz 원문, 약화 금지)
|
||||
1. **observability 모듈 이름 변경 → HawkEye.** 전체 시스템 상황을 점검하는 모듈의 이름을 "HawkEye" 로.
|
||||
2. **통합 배포 과정 추가.** 시스템의 주된 역할은 webmaker·webhosting server·arimail·backup·portal 등이고 백그라운드에서 agent·HawkEye 가 이들을 잇는다. 새로운 서버를 추가하면 역할에 따라 필요한 것들을 한 번에 배포·설정하는 작업을 자동화한다. webmaker 설치(그 서버에서는 webmaker 만) / webhosting 서버 설치(사용자 웹 서비스, webmaker 의 배포 서버) / arimail 서버 설치(웹메일·POP·IMAP) / backup 서버 설치 / portal 서버 설치·이전(이미 설치된 포털이 있으면 추가 설치하지 않고 필요하면 이전). portal 을 제외한 서버는 하나의 기능을 여러 서버가 나눌 수 있으므로 각 서버의 슬롯을 확인하고 요구에 맞게 배치하는 작업은 HAL 이 관리한다.
|
||||
3. **HAL 화면 갱신 주기 변경.** 지속적 전체 갱신으로 화면이 번쩍인다. 갱신이 필요한 정보만 모아 그 부분만 갱신한다.
|
||||
4. **배포 릴리스.** HAL 은 단일 통합 시스템으로 HAL 서버에서 HAL 을 추가 배포하지 않는다(먼저 설치하는 도구, 이중화·분산 미구성) → 배포 릴리스에서 HAL 배포 삭제. 다른 도구는 필요한 곳에 필요한 것만 배포하므로, 최신 버전의 도구별 설치 안내·현재 버전·기능·상호 연결(어떤 모듈이 있어야 무엇이 동작) 설명과 함께 배포하는 화면을 기본 화면으로. 이전 버전은 과거 버전 보기로 보고 되돌릴 수 있게.
|
||||
5. **운영 작업 진행 상황 표시.** 작업창 아래를 나눠 현재 작업 진행 상황과 그동안의 히스토리를 보인다.
|
||||
6. **배포 작업 점검.** agent 배포를 지시했으나 "처리대기"가 오래 지속되고 진행 상황을 알 수 없다. 작업 구성에 문제가 없는지 로그를 참고해 확인·수정.
|
||||
7. **대상 서버 리스팅·선택.** 배포 때 서버를 직접 입력하는 대신 관리 중인 서버 목록을 보여 주고 라디오 버튼으로 선택.
|
||||
|
||||
## 2. 점검(구현 전, bear 운영 DB·로그)
|
||||
- 6번: `module.deploy`(agent 0.3.10) 작업이 `serverIds: []` 로 만들어져 precheck 가 즉시 `no_targets` 로 실패했다(화면 안내 "비우면 역할 전체" 와 backend 불일치). 작업 상태가 릴리스 화면에 보이지 않아 "처리 대기"로 보였다. Agent 2대는 정상 연결.
|
||||
|
||||
## 3. 필수 동작(계약)
|
||||
1. 모듈 `hawkeye`(label HawkEye): 기술서·배포 순서·운영 현황·공개 상태·AI 스킬·도구 enum. 옛 이름 observability 는 별칭으로 받고, DB 행은 마이그레이션으로 바꾼다.
|
||||
2. 서버 프로필(`SERVER_PROFILES`): webmaker(agent·webmaker·hawkeye, web) · webhosting(agent·hawkeye, web, sites 슬롯) · arimail(agent·arimail·hawkeye, mail) · backup(agent·backup·hawkeye, backup) · portal(agent·portal·hawkeye, control, 하나만). 작업 `server.provision`: 준비된 서버에 프로필 모듈을 순서대로 배포하고 역할·프로필·슬롯을 기록, 실패 시 중단. 포털이 이미 있으면 `portal_exists`(API·작업), 작업 `portal.relocate`(새 서버 설치 → 운영자 전환 확인 → 옛 포털 비활성). 슬롯: 서버 `slots`(PATCH), `slotsFree`, `pickServer` 가 여유 슬롯을 본다.
|
||||
3. 주기 갱신(운영 현황·서버·작업 상세·AI 대화 기록·dock)은 노드 단위 diff 로 바뀐 부분만 바꾼다(`dom-patch.mjs`).
|
||||
4. HAL 은 배포 대상이 아니다(`hal_not_deployable`, 배포 순서·화면에서 제외). HAL 먼저 규칙은 "HAL 실행 버전 ≥ 릴리스 버전". 릴리스 화면 기본은 모듈별 최신 승인 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·배포 단추, 상호 연결, 프로필; 과거 버전은 접힌 표(이 버전 배포·되돌리기).
|
||||
5. 공통 틀 아래 작업 진행 창: 진행 중(단계·진행률·오류)·히스토리, 5초 갱신, 접기.
|
||||
6. 배포 대상을 비우면 역할에 맞는 준비된 서버 전체, 없으면 API 에서 즉시 `no_targets`.
|
||||
7. 배포·통합 배포·포털 이전 창은 서버 목록(라디오/체크, 준비 안 된 서버 비활성)에서 고른다.
|
||||
|
||||
## 4. TDD·검증
|
||||
- `test/hal-0317.test.mjs` 를 먼저 쓰고 RED 확인 뒤 구현(HawkEye·마이그레이션 / 릴리스 API·HAL 제외·HAL 먼저 완화·기본 대상·no_targets / 통합 배포 순서·프로필·포털 싱글턴·이전·슬롯 배치 / diff·dock·서버 선택·정적 계약 / 버전·문서). 기존 테스트(HAL 자체 배포 계약 갱신 포함)·전체 Suite. 배포 뒤 bear read-back(마이그레이션·운영 현황 hawkeye·릴리스 API).
|
||||
|
||||
## 5. 버전·배포
|
||||
- HAL 0.3.17: package.json·README·VERSION_HISTORY·개발 지시. 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 만 배포 → read-back. 다른 모듈은 배포하지 않는다.
|
||||
|
||||
## 6. 완료 조건
|
||||
점검 결과(6번 원인), 변경 파일, RED/GREEN·전체 테스트, 커밋·병합 커밋, artifact sha256, 배포 read-back, 운영자 조치, 알려진 제한을 보고한다.
|
||||
@@ -3,11 +3,11 @@
|
||||
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
||||
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
||||
|
||||
- 버전: **0.3.10** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
||||
- 버전: **0.3.17** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
||||
- HAL AI 채팅룸(연결·방·도구·MCP, 0.3.0): [docs/HAL-AI.md](docs/HAL-AI.md)
|
||||
- 설계·개발 규약: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · 포털↔HAL 작업 계약: [docs/HAL-JOBS.md](docs/HAL-JOBS.md) · HAL↔Agent 프로토콜: [docs/HAL-AGENT.md](docs/HAL-AGENT.md)
|
||||
- 도입 소스(Webmaker 1.8.0 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`)
|
||||
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) · `Ariweb-Suitle_Dev-Order_v0.3.0.md`(HAL AI 채팅룸, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.1.md`(서버 설치 CLI 자동 탐색, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.2.md`(AI 연동 편의성·도구 기본 설정·승인 요청, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.9.md`(로그인 터미널·텍스트 셸·모델 선택·슬래시 명령, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.10.md`(텍스트 셸 복사·주소 추출, Oz)
|
||||
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) · `Ariweb-Suitle_Dev-Order_v0.3.0.md`(HAL AI 채팅룸, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.1.md`(서버 설치 CLI 자동 탐색, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.2.md`(AI 연동 편의성·도구 기본 설정·승인 요청, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.9.md`(로그인 터미널·텍스트 셸·모델 선택·슬래시 명령, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.10.md`(텍스트 셸 복사·주소 추출, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.17.md`(HawkEye·역할별 통합 배포·릴리스 화면·작업 진행 창·서버 선택, Oz)
|
||||
|
||||
## 구성
|
||||
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
# VERSION_HISTORY
|
||||
|
||||
## 0.3.17 (2026-10-29)
|
||||
|
||||
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.17.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-provision-v0.3.17`, 기준 main `c460f57` = 0.3.10). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
|
||||
- **이전 버전**: 0.3.10(2026-10-29). 지시된 버전 **0.3.17**(독립 개선 7항목). HAL 에만 적용, 다른 모듈 변경 없음(배포하지 않음).
|
||||
|
||||
### 요구(사용자 요구 — 약화 없이 반영)
|
||||
1. observability 모듈 이름 → HawkEye. 2. 역할별 통합 배포(webmaker·webhosting·arimail·backup·portal 설치/이전, 슬롯 기반 배치는 HAL 이 관리). 3. 화면 갱신 때 전체를 다시 그리지 않고 바뀐 부분만. 4. 배포 릴리스에서 HAL 배포 삭제, 최신 버전 도구별 설치 안내·버전·기능·상호 연결을 기본 화면으로, 과거 버전 보기와 되돌리기. 5. 작업창 아래에 진행 상황·히스토리. 6. agent 배포 "처리 대기" 지속 원인 점검·수정. 7. 배포 때 서버 목록을 라디오로 선택.
|
||||
|
||||
### 점검 결과(6번, bear 운영 DB·로그)
|
||||
- 작업 `j_01a185952656159ecceb02ddd5`(agent 0.3.10 배포, 02:55:06Z): subject `serverIds: []` → precheck 가 1초 만에 `no_targets`("대상 서버가 없습니다")로 **실패**. 화면의 배포 창이 "비우면 해당 역할 전체"라고 안내했지만 backend 는 빈 목록을 거부했고, 릴리스 화면은 작업 상태를 보여 주지 않아 "처리 대기"로 보였다. Agent 2대는 heartbeat·long-poll 정상(0.1.1).
|
||||
- 수정: (a) 대상을 비우면 역할에 맞는 준비된 서버 전체로 풀고, 하나도 없으면 작업을 만들지 않고 API 에서 바로 `no_targets` 와 이유를 보인다 (b) 배포 창은 서버 목록(체크)에서 고른다(7번) (c) 작업 진행 창(5번)이 상태·단계·오류를 바로 보인다.
|
||||
|
||||
### 수정한 기존 기능
|
||||
- `hal/modules/index.mjs`: `hawkeye`(label HawkEye, `legacyNames: ['observability']`), `LEGACY_MODULE_NAMES`·`canonicalModuleName`·`descriptor()` 별칭, `DEPLOY_ORDER = [agent, portal, webmaker, arimail, backup, hawkeye]`(HAL 제외), `DEPLOYABLE_MODULES`, 모듈 `label`, `MODULE_GUIDES`(설명·역할·requires·provides·설치 방법·singleton), `MODULE_LINKS`(상호 연결), `SERVER_PROFILES`(webmaker·webhosting·arimail·backup·portal — 모듈 순서·역할·슬롯·싱글턴).
|
||||
- `hal/schema.mjs`: `hal_0008_hawkeye` — `module_targets`·`modules` 의 observability 행을 hawkeye 로(이미 있으면 정리), `servers.profile`·`servers.slots_json`.
|
||||
- `hal/core.mjs`: `getServer/publicServer` 에 `profile·slots·slotsFree`(사이트는 capacity.sitesHosted, 메일 서비스는 등록부 수), `updateServer` 허용 키에 profile·slots_json, `pickServer` 가 역할별 슬롯 여유를 본다, `defaultTargets(d, module)`, `versionGte`.
|
||||
- `hal/jobs.mjs`: `precheckModule` — `hal` → `hal_not_deployable`, HAL 먼저 규칙을 "HAL 실행 버전 ≥ 릴리스 버전"으로 완화(HAL 은 별도 설치), agent-feature 모듈(backup·hawkeye)의 `module.deploy.target` 은 Agent 기능 확인(module.health)으로 배치 기록; 새 작업 `server.provision`(precheck → 역할·프로필·슬롯 기록 → 모듈 순서대로 자식 `module.deploy.target`, 실패 시 중단·partial 상세), `portal.relocate`(새 서버 설치 → `needs_user`(데이터·DNS 전환 확인) → 재개 → 옛 포털 비활성).
|
||||
- `hal/app.mjs`: `POST /modules/:name/deploy` — HAL 400 `hal_not_deployable`, 빈 대상 → 기본 대상, 없으면 400 `no_targets`, 응답에 `targets`; `GET /releases` → `releasesData`(latest·guides·links·profiles·servers·targets, HAL 제외); `GET /provision/profiles`, `POST /provision`(재인증, 준비 안 된 서버 409 `server_not_ready`, 포털 싱글턴 409 `portal_exists`), `POST /portal/relocate`(재인증 + 3단계, 식별정보 = 두 서버 id); `PATCH /servers/:id` 에 `slots`; 운영 현황·공개 상태 구성요소 hawkeye(HawkEye(모니터링)).
|
||||
- 화면: `hal/ui/render.mjs` `releasesView` 재구성(최신 릴리스 배포 카드 — 설명·필요한 것·제공·역할·설치 방법·현재 배치·배포 단추, 역할별 통합 배포, 상호 연결, 프로필 표, 과거 버전 보기(접힘, 이 버전 배포·되돌리기), 배치 상태), `jobsDockHtml`(진행 중·히스토리, 진행 막대), `serverChoiceHtml`(라디오/체크, 준비 안 된 서버 비활성, 역할·프로필·Agent 버전·모듈 표시), 서버 표에 프로필·슬롯. `hal/ui.mjs` 공통 틀에 작업 진행 창 `#ops-dock`(탭·접기), 서버 화면에 "역할별 통합 배포"·"포털 이전". 새 `hal/ui/public/dom-patch.mjs`(순수 `splitTopLevel`·`diffChildren` + `patchHtml`). `ops.js`: 주기 갱신(운영 현황·서버·작업 상세)이 바뀐 노드만, 배포 창은 서버 체크 목록(역할에 맞는 준비 서버 미리 체크), `provision`·`portal-relocate` 동작, dock 5초 갱신·탭·접기 저장, `ask()` 에 radio/checks 필드. `ai.js`: 대화 기록·상태 줄도 부분 갱신. `ops.css`.
|
||||
- `hal/ai/skill.md`·`tools.mjs`: HawkEye·배포 순서·HAL 배포 불가·통합 배포 설명, 배포 도구의 모듈 enum 은 배포 가능 모듈만.
|
||||
- 테스트: `test/hal.test.mjs` 의 "HAL 자체 배포" 계약을 "배포 불가(hal_not_deployable)"로 갱신. 버전 0.3.17(package.json·README·버전 검사 테스트).
|
||||
|
||||
### 고객 화면
|
||||
- 변경 없음(공개 상태 페이지의 구성요소 이름만 "HawkEye(모니터링)").
|
||||
|
||||
### 운영자 화면
|
||||
- 배포 릴리스: 기본 화면이 모듈별 최신 릴리스 카드(설명·필요한 것·제공·설치 방법·현재 배치·"배포(서버 선택)")와 상호 연결·역할별 프로필이며, HAL 은 배포 대상이 아니라고 명시. 과거 버전은 "과거 버전 보기"에서.
|
||||
- 서버와 용량: "역할별 통합 배포(새 서버)"(프로필·릴리스·서버 라디오) · "포털 이전"(새 서버 라디오 → 3단계 확인). 서버 표에 프로필·슬롯(여유/전체).
|
||||
- 모든 화면 아래 작업 진행 창: 진행 중(단계·진행률·오류)·히스토리, 5초마다 바뀐 부분만 갱신, 접기.
|
||||
- 배포 창: 대상 서버를 체크 목록에서 고른다(역할에 맞는 준비된 서버 미리 체크). 운영 현황 등 주기 갱신이 번쩍이지 않는다.
|
||||
|
||||
### 보안 결정
|
||||
- 통합 배포·포털 이전은 기존 배포와 같은 경계: 재인증 필수, 포털 이전은 3단계 확인(두 서버 id). 프로필 모듈은 `precheckModule`(릴리스 승인·artifact·HAL 건강·부트스트랩·HAL 버전) 그대로. 포털 싱글턴은 API 와 작업 양쪽에서 검사.
|
||||
- HAL 먼저 규칙 완화는 "HAL 이 더 새롭거나 같을 때"만 통과한다(HAL 이 오래되면 여전히 `hal_first`). HAL 자체 배포 경로는 닫혔다.
|
||||
- 화면 부분 갱신은 같은 렌더러의 HTML 을 노드 단위로 비교해 바꾸므로 이스케이프·내용은 그대로다.
|
||||
|
||||
### 테스트
|
||||
- RED(구현 전): 새 `test/hal-0317.test.mjs` 는 `DEPLOYABLE_MODULES`·`SERVER_PROFILES` 가 없어 import 단계에서 실패(0/5).
|
||||
- GREEN: `test/hal-0317.test.mjs` **5/5** — HawkEye 기술서·별칭·배포 순서(HAL 제외)·마이그레이션 SQL 재실행(observability 행 → hawkeye)·운영 현황/공개 상태/화면 이름 / 릴리스 API(modules·latest·guides·profiles)·HAL 배포 400·빈 대상 → 역할 전체(portal→웹1, agent→전체)·없으면 400 `no_targets`·HAL 0.3.17 이 0.3.10 배포 허용·HAL 0.3.9 는 `hal_first`·릴리스 화면 문구 / 통합 배포(webmaker: agent→webmaker→hawkeye 순서, 서버 profile·role·slots, 배치 상태 3행, 자식 3개)·포털 싱글턴 409·포털 이전(새 서버 설치 → needs_user(DNS·데이터) → 재개 → 옛 비활성)·슬롯 배치(pickServer)·모르는 프로필 400·준비 안 된 서버 409 / diff 순수 함수·ops.js 정적 계약·dock 렌더·서버 선택 렌더·공통 틀 dock / 버전·문서.
|
||||
- 구현 중 테스트가 잡은 결함 3건: ① 마이그레이션 재실행 검사에서 ALTER 가 겹침 → hal_0008(멱등 UPDATE/DELETE)·hal_0009(ALTER) 분리 ② 부모·자식 작업은 `drain()` 으로는 자식이 시작되지 않음(테스트 러너를 tick 방식으로) ③ 운영 현황 카드가 모듈 id(hawkeye)만 보여 표시 이름(HawkEye) 추가.
|
||||
- 기존: `test/hal.test.mjs` 16/16(HAL 자체 배포 계약 → `hal_not_deployable`), `test/hal-ui.test.mjs` 7/7(릴리스 화면 문구 갱신), AI 테스트 12/12·6/6·6/6·4/4, `hal-cli` 3/3. 전체 Suite `node test/run.mjs`: **30개 파일 실패 0, 204건 통과**. secret-like 스캔: 새 코드 0건(`test/hal.test.mjs` 의 `password-example-1` 은 0.1.1 부터 있던 기존 예시 값, 이번에는 그 파일의 HAL 배포 계약 1줄만 수정).
|
||||
|
||||
### 배포(bear `hal21.ariweb.net`, HAL 만)
|
||||
- 2026-10-29 03:36 UTC, 코드 릴리스 + 마이그레이션(`hal_0008_hawkeye`: 배치 행 observability → hawkeye, `hal_0009_server_profile`: servers.profile·slots_json 열 추가; data/config/Vault 파일 무변경). HAL 만 배포, 다른 모듈 미변경·미배포(agent 0.1.1 은 운영자가 릴리스 화면에서 배포).
|
||||
- 저장소: 기능 커밋 `843b2b1a7987b4aac07171cfb44e5f47d6006929`(브랜치 `feature/hal-provision-v0.3.17` → `main` fast-forward c460f57..843b2b1, read-back `origin/main = 843b2b1`). 릴리스 빌드(그 커밋, 전체 테스트 재실행 30파일 실패 0): manifest `0.3.17 · mainCommit 843b2b1… · testsPassed=true · dirty=false · provenanceClean=true`, `verify` 5 artifact ✔. hal artifact sha256 `92d9321735e5f73fe35c0c5d7a571859d18048a0150b0663110649c9cc085b73`.
|
||||
- 적용(`~/oz-deploy/bear-apply.sh 0.3.17 …`, dist 5개 artifact 업로드): 해시 OK → 사전 백업 `/srv/ariweb/hal/backups/pre-v0.3.17-20261029T033614Z/`(hal.sqlite·-wal·-shm·config·vault, SHA256SUMS) → stage 추출·검증 → `/srv/ariweb/hal/releases/0.3.17-843b2b1` → `register-release --approve --by oz-dev-order-v0.3.17` → `bootstrap-release` → current 전환 → 재시작. restart → `/api/health` 200 **500ms**, active, `version 0.3.17 · bootstrap complete · releaseVersion 0.3.17 · mainCommit 843b2b1… · artifactSha256 92d93217…`. 보존 sha256 전=후(config.json `ae5963d2…`, vault.json `0e2dc987…`, vault.key `345d92e7…`), 백업 sums OK, 이전 릴리스 디렉터리 13개 보존. journal 오류 0.
|
||||
- read-back: 운영 DB `_migrations` 에 `hal_0008_hawkeye`·`hal_0009_server_profile`, `module_targets` 의 observability 행 0건·hawkeye 행 있음(서버 2대), releases 9행 approved(0.3.17 포함), bootstrap 0.3.17, 서버 2대 ready(profile 은 아직 null — 운영자가 통합 배포/PATCH 로 지정). 배포 코드 harness: 릴리스 API `modules=[agent, portal, webmaker, arimail, backup, hawkeye]`(HAL 없음), 프로필 5종, 릴리스 화면 "최신 릴리스·과거 버전 보기·HawkEye·HAL 은 배포 대상이 아닙니다·역할별 통합 배포", 운영 현황 구성요소 `hawkeye`, `POST /modules/hal/deploy` → 400 `hal_not_deployable`, 공통 틀 dock. 공개 `/api/status` 구성요소에 "HawkEye(모니터링)". 외부 `https://hal21.ariweb.net` `/api/health` `/ops/login` `/ops/releases` `/ops/servers` `/ops/ui/dom-patch.mjs` `/api/status` 모두 200. (부수 관찰: bear 의 claude CLI 가 0.3.9 텍스트 셸로 로그인되어 `loggedIn: true`(claude.ai) 로 바뀌어 있음.)
|
||||
- 운영자 조치: 배포 릴리스 화면에서 Node Agent 0.3.17 을 "배포(서버 선택)"로 두 서버에 배포(대상을 체크 목록에서 고름). 그 뒤 필요한 모듈을 같은 방식으로. 새 서버는 "역할별 통합 배포". 작업 진행은 화면 아래 작업 진행 창에서 본다.
|
||||
- 되돌리기(코드만): `ln -sfn /srv/ariweb/hal/releases/0.3.10-72b5e9c /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal`(0.3.10 코드는 profile·slots_json 열과 hawkeye 행을 모르지만 건드리지 않는다; 운영 현황의 observability 카드는 "배치 없음"으로 보인다). 데이터까지: 위 백업.
|
||||
|
||||
### 알려진 제한
|
||||
- 통합 배포는 같은 서버에 모듈을 순서대로 배포한다(여러 서버 동시 분할은 프로필을 서버마다 적용). 포털 이전의 데이터·DNS 전환은 운영자가 직접 수행하고 재개한다(자동 복제 없음).
|
||||
- 슬롯 사용량은 사이트 수(capacity.sitesHosted)와 메일 서비스 수만 자동 계산한다(backups·webmaker 슬롯은 선언값).
|
||||
- 실제 브라우저의 부분 갱신·dock 동작은 순수 diff·정적 계약으로 검증(자동 브라우저 테스트 없음).
|
||||
|
||||
### 미확정 정책값
|
||||
- 프로필별 기본 슬롯(webhosting sites 50, arimail mailServices 20, backup backups 500)은 운영자가 서버 PATCH `slots` 로 바꾼다. dock 갱신 주기 5초, 화면 갱신 15초.
|
||||
|
||||
## 0.3.10 (2026-10-29)
|
||||
|
||||
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.10.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-shell-copy-v0.3.10`, 기준 main `bed038f` = 0.3.9). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@
|
||||
|---|---|---|
|
||||
| 읽기(read) | 모델이 부르면 즉시 | `hal_skill` `hal_health` `hal_overview` `hal_servers_list` `hal_server_get` `hal_server_metrics` `hal_releases_list` `hal_jobs_list` `hal_job_get` `hal_incidents_list` `hal_audit_list` `hal_mail_services_list` `hal_emergency_list` `hal_dr_status` `hal_changelog` |
|
||||
| 쓰기(write) | 제안 → 운영자 승인(재인증) | `hal_server_create` `hal_server_approve` `hal_server_maintenance` `hal_incident_open` `hal_incident_resolve` `hal_job_control` `hal_module_health` `hal_dr_snapshot` |
|
||||
| 고위험(high) | 제안 → 승인(재인증 + 3단계 확인, 식별정보 = 도구가 정한 identifiers) | `hal_server_isolate` `hal_server_delete` `hal_module_deploy` `hal_module_rollback` `hal_module_reconcile` `hal_release_approve` `hal_job_revert` `hal_config_set` |
|
||||
| 고위험(high) | 제안 → 승인(재인증 + 3단계 확인, 식별정보 = 도구가 정한 identifiers) | `hal_server_isolate` `hal_server_delete` `hal_module_deploy`(HAL 제외, 0.3.17) `hal_module_rollback` `hal_module_reconcile` `hal_release_approve` `hal_job_revert` `hal_config_set` |
|
||||
|
||||
- 인자는 `validateArgs`(모르는 키·필수·형식·enum·길이)로 다시 검사한다. 모르는 도구·인자 오류는 실행하지 않고 모델에게 오류 결과로 돌려준다. 임의 셸 명령·경로·네트워크 도구는 없다.
|
||||
- 제안(`ai_actions`): `proposed` → `executing` → `executed` | `failed`, 또는 `rejected` / `cancelled`. `POST /ops/api/ai/actions/:id/approve`(write: `requireRecent`, high: `requireRecent` + `threeStep(confirmImpact, confirmIdentifiers, confirmExecute)`), `POST …/reject {reason}`. 승인은 한 번만 실행된다(상태를 먼저 `executing` 으로 바꿔 동시 클릭을 막음). 실행 결과는 도구 결과로 대화에 들어가고 모델이 이어서 답한다. 거부는 모델을 자동으로 부르지 않는다.
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@
|
||||
## 4. 릴리스·모듈 배포 (`hal_releases_list`, `hal_release_approve`, `hal_module_deploy`, `hal_module_rollback`)
|
||||
- 릴리스 `status` : `registered`(등록만) · `approved`(승인 — 배포 가능). 승인은 `checks.testsPassed === true`·`dirty === false`·`provenanceClean === true` 가 모두 기록된 릴리스만 가능하다(fail-closed).
|
||||
- `mainCommit` : 정본 저장소 main 커밋. `artifacts[모듈].sha256` : artifact 해시. `halDeployedAt` : HAL 자체가 그 버전으로 배포된 시각(CLI 부트스트랩 경로로 바꾸면 null 일 수 있다).
|
||||
- 배포 순서(`deployOrder`): **HAL 먼저** → agent → portal → webmaker → arimail → backup → observability. HAL 이 그 버전으로 실행 중이 아니면 다른 모듈 배포는 `hal_first` 로 거부된다.
|
||||
- 배포 순서(`deployOrder`): agent → portal → webmaker → arimail → backup → hawkeye(HawkEye, 전체 시스템 상황 점검 — 옛 이름 observability). **HAL 자체는 배포 대상이 아니다**(먼저 설치하는 도구, `hal_not_deployable`). HAL 실행 버전이 릴리스보다 오래되면 모듈 배포는 `hal_first` 로 거부된다. 역할별 통합 배포(`server.provision`: webmaker·webhosting·arimail·backup·portal 프로필)와 포털 이전(`portal.relocate`)이 있다. 배포 대상 서버를 비우면 역할에 맞는 준비된 서버 전체로 간다.
|
||||
- `module_targets.health` : `ok` · `down` · `unknown`. `lastCheckAt` 이 10분 넘게 오래되면 "정보 지연(stale)"이지 정상이 아니다.
|
||||
|
||||
## 5. 사건·알림 (`hal_incidents_list`, `hal_incident_open`, `hal_incident_resolve`)
|
||||
|
||||
+4
-4
@@ -19,7 +19,7 @@ import * as core from '../core.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 { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES } from '../modules/index.mjs';
|
||||
import { overviewLocal, changelog } from '../ui.mjs';
|
||||
|
||||
/** 스킬 문서(상태·로그의 뜻). system prompt 와 hal_skill 도구가 같은 내용을 쓴다. */
|
||||
@@ -43,7 +43,7 @@ export const TOOLS = [
|
||||
{ name: 'hal_servers_list', risk: 'read', description: '관리 서버 목록(상태·역할·위치·Agent·용량·지표·모듈 목표/실제 버전).', schema: S({ role: str('역할로 거르기(web·mail·backup·control)', { enum: ['web', 'mail', 'backup', 'control'] }) }), run: (d, a) => ({ servers: core.listServers(d, { role: a.role || null }) }) },
|
||||
{ name: 'hal_server_get', risk: 'read', description: '서버 하나의 상세와 최근 작업(최대 50건).', schema: S({ serverId: SERVER_ID }, ['serverId']), run: (d, a) => { const s = core.getServer(d, a.serverId); if (!s) throw notFound('server_not_found', '서버가 없습니다'); return { server: core.publicServer(d, s), jobs: d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(jobView) }; } },
|
||||
{ name: 'hal_server_metrics', risk: 'read', description: '서버 지표 표본(cpu·메모리·디스크·inode·네트워크). 기본 최근 24시간, 최대 2000개.', schema: S({ serverId: SERVER_ID, hours: num('조회 범위(시간, 기본 24, 최대 2160)'), limit: num('최대 표본 수(기본 500, 최대 2000)') }, ['serverId']), run: (d, a) => ({ samples: core.metrics(d, a.serverId, { since: new Date(Date.now() - Math.min(2160, a.hours || 24) * 3600000).toISOString(), limit: Math.min(2000, a.limit || 500) }) }) },
|
||||
{ name: 'hal_releases_list', risk: 'read', description: '릴리스 등록부(status registered/approved, mainCommit, artifact sha256, checks)와 실행 중 버전·배포 순서·모듈 배치 상태.', schema: S({}), run: (d) => ({ 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') }) },
|
||||
{ name: 'hal_releases_list', risk: 'read', description: '릴리스 등록부(status registered/approved, mainCommit, artifact sha256, checks)와 실행 중 버전·배포 순서·모듈 배치 상태.', schema: S({}), run: (d) => ({ releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: DEPLOYABLE_MODULES, deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }) },
|
||||
{ name: 'hal_jobs_list', risk: 'read', description: 'HAL 작업 목록. status 는 쉼표 구분(queued,running,awaiting_external,needs_user,failed …).', schema: S({ status: str('상태 필터(쉼표 구분)'), kind: str('종류 필터(예: module.deploy)'), limit: num('최대 건수(기본 100, 최대 500)') }), run: (d, a) => ({ jobs: d.jobs.list({ status: a.status ? String(a.status).split(',').map((x) => x.trim()).filter(Boolean) : null, kind: a.kind || null, limit: Math.min(500, a.limit || 100) }).map(jobView) }) },
|
||||
{ name: 'hal_job_get', risk: 'read', description: '작업 하나의 단계·기록·결과·자식 요약·Agent 작업.', schema: S({ jobId: str('작업 id(j_…)') }, ['jobId']), run: (d, a) => { const j = d.jobs.get(a.jobId); if (!j) throw notFound('job_not_found', '작업이 없습니다'); return { job: { ...jobView(j), result: redact(j.result), log: j.log }, children: d.jobs.childrenSummary(j.id), agentJobs: d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at', j.id) }; } },
|
||||
{ name: 'hal_incidents_list', risk: 'read', description: '사건 목록(열린 것 먼저, 최대 200)과 최근 알림.', schema: S({ openOnly: bool('열린 사건만') }), run: (d, a) => ({ incidents: d.store.all(`SELECT * FROM incidents ${a.openOnly ? 'WHERE resolved_at IS NULL ' : ''}ORDER BY resolved_at IS NOT NULL, started_at DESC LIMIT 200`).map((i) => ({ ...i, notes: parseJson(i.notes_json, []) })) }) },
|
||||
@@ -66,8 +66,8 @@ export const TOOLS = [
|
||||
// ---------------- 고위험(high): 승인 + 재인증 + 3단계 확인
|
||||
{ name: 'hal_server_isolate', risk: 'high', description: '서버를 격리(isolated)한다 — 작업 배정이 멈춘다. 되돌리려면 운영자가 상태를 바꿔야 한다.', schema: S({ serverId: SERVER_ID, reason: str('격리 사유') }, ['serverId', 'reason']), identifiers: (a) => [a.serverId], run: (d, a, actor) => { if (!core.getServer(d, a.serverId)) throw notFound('server_not_found', '서버가 없습니다'); return { server: core.publicServer(d, core.updateServer(d, a.serverId, { status: 'isolated', isolated_reason: a.reason }, actor.operatorId)) }; } },
|
||||
{ name: 'hal_server_delete', risk: 'high', description: '서버를 삭제하고 Agent 를 해지한다. 서비스가 배치된 서버는 force 없이는 거부된다(먼저 이전).', schema: S({ serverId: SERVER_ID, force: bool('배치된 서비스가 있어도 삭제') }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => { const s = core.getServer(d, a.serverId); if (!s) throw notFound('server_not_found', '서버가 없습니다'); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !a.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, actor.operatorId); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); return { deleted: s.id }; } },
|
||||
{ name: 'hal_module_deploy', risk: 'high', description: '승인된 릴리스의 모듈을 대상 서버에 배포하는 작업(module.deploy)을 큐에 넣는다. HAL 먼저 규칙·릴리스 승인·부트스트랩 전제는 작업 precheck 가 검사한다.', schema: S({ module: str('모듈 이름', { enum: Object.keys(MODULES) }), version: str('릴리스 버전'), serverIds: arr('대상 서버 id 목록'), nonce: str('멱등 구분값(같은 배포를 다시 넣을 때)') }, ['module', 'version', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => { const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: a.module, version: a.version, serverIds: a.serverIds || [] }, idempotencyKey: `module.deploy:${a.module}:${a.version}:${(a.serverIds || []).slice().sort().join(',')}:${a.nonce || ''}`, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} ${a.version} 배포` }); return { job: jobView(r.job), created: r.created }; } },
|
||||
{ name: 'hal_module_rollback', risk: 'high', description: '모듈을 이전 버전(toVersion 생략 시 직전 목표)으로 되돌리는 작업(module.rollback)을 큐에 넣는다.', schema: S({ module: str('모듈 이름', { enum: Object.keys(MODULES) }), toVersion: str('되돌릴 버전'), serverIds: arr('대상 서버 id 목록') }, ['module', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.rollback', subject: { module: a.module, toVersion: a.toVersion || null, serverIds: a.serverIds || [] }, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} 되돌리기` }).job) }) },
|
||||
{ name: 'hal_module_deploy', risk: 'high', description: '승인된 릴리스의 모듈을 대상 서버에 배포하는 작업(module.deploy)을 큐에 넣는다. HAL 먼저 규칙·릴리스 승인·부트스트랩 전제는 작업 precheck 가 검사한다.', schema: S({ module: str('모듈 이름(HAL 은 배포 대상 아님)', { enum: DEPLOYABLE_MODULES }), version: str('릴리스 버전'), serverIds: arr('대상 서버 id 목록'), nonce: str('멱등 구분값(같은 배포를 다시 넣을 때)') }, ['module', 'version', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => { const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: a.module, version: a.version, serverIds: a.serverIds || [] }, idempotencyKey: `module.deploy:${a.module}:${a.version}:${(a.serverIds || []).slice().sort().join(',')}:${a.nonce || ''}`, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} ${a.version} 배포` }); return { job: jobView(r.job), created: r.created }; } },
|
||||
{ name: 'hal_module_rollback', risk: 'high', description: '모듈을 이전 버전(toVersion 생략 시 직전 목표)으로 되돌리는 작업(module.rollback)을 큐에 넣는다.', schema: S({ module: str('모듈 이름', { enum: DEPLOYABLE_MODULES }), toVersion: str('되돌릴 버전'), serverIds: arr('대상 서버 id 목록') }, ['module', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.rollback', subject: { module: a.module, toVersion: a.toVersion || null, serverIds: a.serverIds || [] }, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} 되돌리기` }).job) }) },
|
||||
{ name: 'hal_module_reconcile', risk: 'high', description: '목표 버전과 실제 버전이 다른 대상만 다시 설치하는 작업(module.reconcile)을 큐에 넣는다.', schema: S({ module: str('모듈 이름(생략 시 전체)', { enum: Object.keys(MODULES) }) }), identifiers: (a) => [a.module || 'all'], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.reconcile', subject: { module: a.module || null }, createdBy: actor.operatorId, actorType: 'operator', label: '목표·실제 상태 맞추기' }).job) }) },
|
||||
{ name: 'hal_release_approve', risk: 'high', description: '등록된 릴리스를 승인한다(testsPassed·dirty=false·provenanceClean 이 모두 기록된 릴리스만 가능).', schema: S({ version: str('릴리스 버전') }, ['version']), identifiers: (a) => [a.version], run: (d, a, actor) => ({ release: core.approveRelease(d, a.version, actor.operatorId) }) },
|
||||
{ name: 'hal_job_revert', risk: 'high', description: '끝난 작업을 되돌린다(revert 를 지원하는 종류만).', schema: S({ jobId: str('작업 id') }, ['jobId']), identifiers: (a) => [a.jobId], run: async (d, a, actor) => ({ job: jobView(await d.jobs.revert(a.jobId, actor.operatorId)) }) },
|
||||
|
||||
+24
-6
@@ -26,7 +26,7 @@ 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 { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES, MODULE_GUIDES, MODULE_LINKS, SERVER_PROFILES, descriptor, canonicalModuleName } from './modules/index.mjs';
|
||||
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
|
||||
import { registerAiRoutes } from './ai/routes.mjs'; // 0.3.0 AI 채팅룸 운영자 API(연결·방·제안·도구)
|
||||
|
||||
@@ -127,7 +127,8 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/servers', wrap(async (ctx) => { const b = await ctx.body(); const s = core.createServer(d, { ...b, by: ctx.operator.id }); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||
app.get('/ops/api/servers/:id', wrap((ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); return { ok: true, server: core.publicServer(d, s), jobs: d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe), agentJobs: d.store.all('SELECT id, kind, status, progress, created_at, ended_at, error FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || '') }; }));
|
||||
app.get('/ops/api/servers/:id/metrics', (ctx) => ({ ok: true, samples: core.metrics(d, ctx.params.id, { since: ctx.query.since, until: ctx.query.until, limit: Number(ctx.query.limit || 5000) }) }));
|
||||
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
|
||||
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); if (b.slots !== undefined) { if (b.slots && typeof b.slots === 'object') { b.slots_json = toJson(Object.fromEntries(Object.entries(b.slots).filter(([k, v]) => ['sites', 'mailServices', 'backups', 'webmaker'].includes(k) && Number.isFinite(Number(v))).map(([k, v]) => [k, Number(v)]))); } delete b.slots; } // [0.3.17] 슬롯
|
||||
return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
|
||||
app.post('/ops/api/servers/:id/token', wrap((ctx) => ({ ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id }) })));
|
||||
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) }; }));
|
||||
@@ -139,9 +140,19 @@ export function buildApp(d) {
|
||||
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.get('/ops/api/releases', () => releasesData(d)); // [0.3.17] 모듈별 최신 릴리스·안내·프로필 포함(HAL 제외)
|
||||
// [0.3.17] 역할별 통합 배포
|
||||
app.get('/ops/api/provision/profiles', () => ({ ok: true, profiles: Object.values(SERVER_PROFILES), guides: MODULE_GUIDES, links: MODULE_LINKS }));
|
||||
app.post('/ops/api/provision', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const prof = SERVER_PROFILES[b.profile]; if (!prof) throw new HttpError(400, 'bad_profile', `모르는 프로필: ${String(b.profile).slice(0, 30)}`); const srv = core.getServer(d, b.serverId); if (!srv) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); if (srv.status !== 'ready' || !srv.agent_id) throw new HttpError(409, 'server_not_ready', `서버 ${srv.name} 이(가) 준비(Agent 연결·승인) 상태가 아닙니다(${srv.status})`);
|
||||
const rel = core.getRelease(d, b.version); if (!rel || rel.status !== 'approved') throw new HttpError(400, 'release_not_approved', `승인된 릴리스가 아닙니다: ${b.version}`); if (prof.singleton) { const owner = d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1 AND server_id<>? LIMIT 1', srv.id); if (owner) throw new HttpError(409, 'portal_exists', `포털이 이미 다른 서버(${(core.getServer(d, owner.server_id) || {}).name || owner.server_id})에 설치되어 있습니다 — 추가 설치 대신 "포털 이전"을 쓰세요`); }
|
||||
const r = d.jobs.enqueue({ kind: 'server.provision', subject: { serverId: srv.id, profile: prof.id, version: b.version }, idempotencyKey: `provision:${srv.id}:${prof.id}:${b.version}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'server', subjectId: srv.id, label: `${prof.label} 통합 배포 ${b.version} — ${srv.name}` }); return { ok: true, job: describe(r.job), created: r.created, modules: prof.modules }; }));
|
||||
app.post('/ops/api/portal/relocate', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); threeStep(b, [b.fromServerId, b.toServerId]); const from = core.getServer(d, b.fromServerId); const to = core.getServer(d, b.toServerId); if (!from || !to) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); const r = d.jobs.enqueue({ kind: 'portal.relocate', subject: { fromServerId: from.id, toServerId: to.id, version: b.version }, idempotencyKey: `portal.relocate:${from.id}:${to.id}:${b.version}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: 'portal', label: `포털 이전 ${from.name} → ${to.name} (${b.version})` }); return { ok: true, job: describe(r.job), created: r.created }; }));
|
||||
app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; }));
|
||||
app.post('/ops/api/modules/:name/deploy', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: ctx.params.name, version: b.version, serverIds: b.serverIds || [] }, idempotencyKey: b.idempotencyKey || `module.deploy:${ctx.params.name}:${b.version}:${(b.serverIds || []).slice().sort().join(',')}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} ${b.version} 배포` }); return { ok: true, job: describe(r.job), created: r.created }; }));
|
||||
app.post('/ops/api/modules/:name/deploy', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const module = canonicalModuleName(ctx.params.name); descriptor(module);
|
||||
if (module === 'hal') throw new HttpError(400, 'hal_not_deployable', 'HAL 은 릴리스 배포 대상이 아닙니다 — HAL 은 먼저 설치하는 도구이며 별도 절차로 갱신합니다'); // [0.3.17]
|
||||
// [0.3.17 배포 점검] 대상을 비우면 역할에 맞는 준비된 서버 전체로 푼다. 하나도 없으면 작업을 만들지 않고 바로 알린다(옛 동작: 작업이 1초 만에 no_targets 로 실패해 원인이 보이지 않았다)
|
||||
let serverIds = Array.isArray(b.serverIds) ? b.serverIds.filter(Boolean) : []; if (!serverIds.length) { serverIds = core.defaultTargets(d, module); if (!serverIds.length) throw new HttpError(400, 'no_targets', `대상 서버가 없습니다 — ${module} 은(는) 역할 ${descriptor(module).roles.join('·')} 의 준비된(Agent 연결·승인) 서버가 필요합니다`); }
|
||||
const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version: b.version, serverIds }, idempotencyKey: b.idempotencyKey || `module.deploy:${module}:${b.version}:${serverIds.slice().sort().join(',')}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: module, label: `${module} ${b.version} 배포` }); return { ok: true, job: describe(r.job), created: r.created, targets: serverIds }; }));
|
||||
app.post('/ops/api/modules/:name/rollback', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); threeStep(b, b.serverIds || []); const r = d.jobs.enqueue({ kind: 'module.rollback', subject: { module: ctx.params.name, toVersion: b.toVersion || null, serverIds: b.serverIds || [] }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 되돌리기` }); return { ok: true, job: describe(r.job) }; }));
|
||||
app.post('/ops/api/modules/:name/health', wrap(async (ctx) => { const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.health', subject: { module: ctx.params.name, serverIds: b.serverIds || null }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 건강 확인` }); return { ok: true, job: describe(r.job) }; }));
|
||||
app.post('/ops/api/modules/reconcile', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.reconcile', subject: { module: b.module || null }, createdBy: ctx.operator.id, actorType: 'operator', label: '목표·실제 상태 맞추기' }); return { ok: true, job: describe(r.job) }; }));
|
||||
@@ -181,13 +192,20 @@ export function buildApp(d) {
|
||||
return app;
|
||||
}
|
||||
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
|
||||
/** [0.3.17] 릴리스 화면 데이터: 릴리스 목록 + 모듈(HAL 제외)별 최신 승인 릴리스·안내·상호 연결·프로필·배치 상태·서버 이름 */
|
||||
export function releasesData(d) {
|
||||
const releases = core.listReleases(d); const approved = releases.filter((r) => r.status === 'approved'); const latest = {};
|
||||
for (const m of DEPLOYABLE_MODULES) { const r = approved.find((x) => MODULES[m].layout === 'agent-feature' ? true : !!x.artifacts[m]); latest[m] = r ? r.version : null; }
|
||||
const servers = Object.fromEntries(core.listServers(d).map((s) => [s.id, { name: s.name, role: s.role, status: s.status, profile: s.profile }]));
|
||||
return { ok: true, releases, running: core.selfHealth(d).bootstrap, modules: DEPLOYABLE_MODULES, deployOrder: DEPLOY_ORDER, latest, guides: MODULE_GUIDES, links: MODULE_LINKS, profiles: Object.values(SERVER_PROFILES), servers, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') };
|
||||
}
|
||||
/** 운영 현황(§26.1) */
|
||||
function overview(d) {
|
||||
const servers = core.listServers(d); const health = core.selfHealth(d);
|
||||
const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 });
|
||||
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
|
||||
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'observability'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); }); // [0.3.17] observability → hawkeye
|
||||
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
|
||||
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
|
||||
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
|
||||
@@ -195,7 +213,7 @@ function overview(d) {
|
||||
/** 공개 상태(§31.3): 내부 이름·고객 정보 없음. 고객 영향이 없는 내부 경고는 올리지 않는다. */
|
||||
function publicStatus(d) {
|
||||
const o = overview(d);
|
||||
const label = { HAL: '관리 플랫폼', portal: '고객 포털', webmaker: 'Webmaker', arimail: '메일', backup: '백업', observability: '모니터링' };
|
||||
const label = { HAL: '관리 플랫폼', portal: '고객 포털', webmaker: 'Webmaker', arimail: '메일', backup: '백업', hawkeye: 'HawkEye(모니터링)' };
|
||||
return { ok: true, generatedAt: o.generatedAt, components: o.components.map((c) => ({ name: label[c.name] || c.name, status: c.status === 'ok' ? 'ok' : c.status === 'down' ? 'down' : 'unknown', label: c.status === 'ok' ? '정상' : c.status === 'down' ? '장애' : '확인 중' })), incidents: o.incidents.filter((i) => d.store.get('SELECT public FROM incidents WHERE id=?', i.id)?.public).map((i) => ({ severity: i.severity, title: i.title, startedAt: i.started_at, note: d.store.get('SELECT public_note FROM incidents WHERE id=?', i.id)?.public_note || '' })), maintenance: d.store.all('SELECT name, maintenance_note FROM servers WHERE status=\'maintenance\'').map((s) => ({ note: s.maintenance_note || '계획된 유지보수' })) };
|
||||
}
|
||||
function statusPage(d) { const s = publicStatus(d); return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ariweb 서비스 상태</title><style>body{font-family:-apple-system,"Noto Sans KR",sans-serif;max-width:720px;margin:40px auto;padding:0 16px;color:#1f2933}li{padding:8px 0;border-bottom:1px solid #eee}.ok{color:#1a7f4b}.down{color:#b3261e}.unknown{color:#b26a00}</style></head><body><h1>Ariweb 서비스 상태</h1><p>갱신 ${esc(s.generatedAt)}</p><ul>${s.components.map((c) => `<li><b class="${c.status}">${c.status === 'ok' ? '●' : c.status === 'down' ? '✘' : '◔'} ${esc(c.label)}</b> — ${esc(c.name)}</li>`).join('')}</ul><h2>진행 중인 사건</h2>${s.incidents.length ? `<ul>${s.incidents.map((i) => `<li><b>${esc(i.title)}</b> (${esc(i.startedAt)})<br>${esc(i.note)}</li>`).join('')}</ul>` : '<p>진행 중인 사건이 없습니다.</p>'}<h2>계획된 유지보수</h2>${s.maintenance.length ? `<ul>${s.maintenance.map((m) => `<li>${esc(m.note)}</li>`).join('')}</ul>` : '<p>예정된 유지보수가 없습니다.</p>'}</body></html>`; }
|
||||
|
||||
+16
-4
@@ -11,6 +11,7 @@ import { notify } from '../lib/notify.mjs';
|
||||
import { redact, safeError } from '../lib/redact.mjs';
|
||||
import { serviceFetch } from '../lib/serviceauth.mjs';
|
||||
import { JobError } from '../lib/jobs.mjs';
|
||||
import { descriptor } from './modules/index.mjs';
|
||||
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { syncFromHeartbeat } from './mailsvc.mjs';
|
||||
@@ -60,7 +61,7 @@ export function createServer(d, { name, role, location = '', by = null }) {
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.create', subjectType: 'server', subjectId: id, detail: { name, role, location } });
|
||||
return getServer(d, id);
|
||||
}
|
||||
export function getServer(d, id) { const s = d.store.get('SELECT * FROM servers WHERE id=?', id); return s ? { ...s, metrics: parseJson(s.metrics_json, null), capacity: parseJson(s.capacity_json, null), facts: parseJson(s.facts_json, {}) } : null; }
|
||||
export function getServer(d, id) { const s = d.store.get('SELECT * FROM servers WHERE id=?', id); return s ? { ...s, metrics: parseJson(s.metrics_json, null), capacity: parseJson(s.capacity_json, null), facts: parseJson(s.facts_json, {}), slots: parseJson(s.slots_json, {}) || {} } : null; }
|
||||
export function listServers(d, { role = null } = {}) { return d.store.all(`SELECT * FROM servers ${role ? 'WHERE role=?' : ''} ORDER BY created_at`, ...(role ? [role] : [])).map((s) => publicServer(d, s)); }
|
||||
/** 화면·포털용 서버 요약(비밀 없음). offline 판정은 last_seen 기준(§28.1 heartbeat). */
|
||||
export function publicServer(d, s) {
|
||||
@@ -68,14 +69,17 @@ export function publicServer(d, s) {
|
||||
const status = s.status === 'pending' ? 'pending' : offline ? 'offline' : s.status;
|
||||
const mods = d.store.all('SELECT module, target_version, actual_version, health, active, last_check_at, last_error FROM module_targets WHERE server_id=?', s.id);
|
||||
const capacity = parseJson(s.capacity_json, {}) || {}; const metrics = parseJson(s.metrics_json, null);
|
||||
return { id: s.id, name: s.name, role: s.role, location: s.location, status, rawStatus: s.status, acceptsNew: !!s.accepts_new && status === 'ready', approvedAt: s.approved_at, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at, stale: offline, clockSkewMs: s.clock_skew_ms,
|
||||
// [0.3.17] 프로필·슬롯: 슬롯은 서버가 맡을 수 있는 수, 여유 = 슬롯 − 사용(사이트는 capacity.sitesHosted, 메일 서비스는 등록부 수). 슬롯이 없으면(null) 제한 없음.
|
||||
const slots = parseJson(s.slots_json, {}) || {}; const mailUsed = (() => { try { return d.store.get('SELECT COUNT(*) AS n FROM mail_services WHERE server_id=? AND status=\'active\'', s.id).n; } catch (_) { return 0; } })();
|
||||
const slotsFree = { sites: slots.sites != null ? Math.max(0, slots.sites - (capacity.sitesHosted || 0)) : null, mailServices: slots.mailServices != null ? Math.max(0, slots.mailServices - mailUsed) : null, backups: slots.backups ?? null, webmaker: slots.webmaker ?? null };
|
||||
return { id: s.id, name: s.name, role: s.role, location: s.location, status, rawStatus: s.status, acceptsNew: !!s.accepts_new && status === 'ready', approvedAt: s.approved_at, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at, stale: offline, clockSkewMs: s.clock_skew_ms, profile: s.profile || null, slots, slotsFree,
|
||||
health: status === 'offline' ? 'down' : mods.some((m) => m.health === 'down') ? 'partial_outage' : mods.some((m) => m.health === 'degraded') ? 'degraded' : status === 'ready' ? 'ok' : 'unknown',
|
||||
metrics, capacity: { diskFreeBytes: capacity.diskFreeBytes || 0, diskTotalBytes: capacity.diskTotalBytes || 0, memFreeBytes: capacity.memFreeBytes || 0, cpuLoad: capacity.cpuLoad || 0, sitesHosted: capacity.sitesHosted || 0, reservedBytes: capacity.reservedBytes || 0, safetyMarginBytes: capacity.safetyMarginBytes || Math.round((capacity.diskTotalBytes || 0) * 0.1), exhaustEta: capacity.exhaustEta || null },
|
||||
modules: Object.fromEntries(mods.map((m) => [m.module, { target: m.target_version, actual: m.actual_version, drift: !!(m.target_version && m.actual_version && m.target_version !== m.actual_version), health: m.health, active: !!m.active, lastCheckAt: m.last_check_at, lastError: m.last_error }])),
|
||||
maintenanceNote: s.maintenance_note, isolatedReason: s.isolated_reason, facts: parseJson(s.facts_json, {}) };
|
||||
}
|
||||
export function updateServer(d, id, patch, by = null) {
|
||||
const allowed = ['name', 'location', 'status', 'accepts_new', 'maintenance_note', 'isolated_reason', 'role'];
|
||||
const allowed = ['name', 'location', 'status', 'accepts_new', 'maintenance_note', 'isolated_reason', 'role', 'profile', 'slots_json']; // [0.3.17] 프로필·슬롯
|
||||
const keys = Object.keys(patch).filter((k) => allowed.includes(k)); if (!keys.length) return getServer(d, id);
|
||||
d.store.run(`UPDATE servers SET ${keys.map((k) => `${k}=?`).join(', ')}, updated_at=? WHERE id=?`, ...keys.map((k) => patch[k]), nowIso(), id);
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.update', subjectType: 'server', subjectId: id, detail: patch });
|
||||
@@ -89,12 +93,20 @@ export function approveServer(d, id, by) { const s = getServer(d, id); if (!s) t
|
||||
* 요청 역할의 서버가 없으면 control 서버(nginx 가 있는 관리 서버)를 web·backup 역할로도 쓴다 — 전용 서버가 생기면 그쪽이 우선한다.
|
||||
*/
|
||||
export function pickServer(d, { role = 'web', needBytes = 0 } = {}) {
|
||||
const fits = (s) => s.acceptsNew && s.status === 'ready' && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes;
|
||||
// [0.3.17] 슬롯: 역할별 슬롯(web→sites, mail→mailServices, backup→backups)이 정해진 서버는 여유가 있어야 한다
|
||||
const slotKey = { web: 'sites', mail: 'mailServices', backup: 'backups' }[role]; const slotOk = (s) => !slotKey || s.slotsFree[slotKey] == null || s.slotsFree[slotKey] > 0;
|
||||
const fits = (s) => s.acceptsNew && s.status === 'ready' && slotOk(s) && (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;
|
||||
}
|
||||
/** [0.3.17] 배포 대상 서버를 비웠을 때의 기본값: 모듈 역할에 맞고 Agent 가 있는 ready 서버 전체(agent 모듈은 역할 무관). */
|
||||
export function defaultTargets(d, module) {
|
||||
const desc = descriptor(module); return listServers(d).filter((s) => s.status === 'ready' && s.agentId && (module === 'agent' || desc.roles.includes(s.role))).map((s) => s.id);
|
||||
}
|
||||
/** [0.3.17] 버전 비교(숫자 점 구분). a >= b 이면 true. 비교 불가(없음)면 false. */
|
||||
export function versionGte(a, b) { if (!a || !b) return false; const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0); const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const x = pa[i] || 0; const y = pb[i] || 0; if (x !== y) return x > y; } return true; }
|
||||
/** 일회용 등록 토큰(10분). 토큰 원문은 이 반환값에서 한 번만 보인다. */
|
||||
export function issueRegistrationToken(d, { kind = 'managed', serverId = null, groupId = null, siteId = null, label = '', by = null, ttlMs = 10 * 60 * 1000 }) {
|
||||
if (kind === 'managed' && serverId && !getServer(d, serverId)) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
|
||||
+43
-2
@@ -5,6 +5,7 @@
|
||||
* 1) 포털이 요청하는 작업(docs/HAL-JOBS.md): site.*, domain.*, backup.* — 대상 서버(또는 외부 Agent)에 Agent 작업을 배정하고 결과를 기다린 뒤 돌려준다.
|
||||
* 복합 작업(backup.create 의 원격 저장, site.relocate, backup.restore)은 여러 Agent 작업을 단계로 잇는다. 모든 단계는 ctx.step 으로 기록되어 HAL 재시작 뒤 이어서 실행된다.
|
||||
* 2) 운영자 작업(§32·§29): module.deploy / module.rollback / module.health / module.reconcile — 대상 서버마다 자식 작업을 만들고 대상별 결과를 요약한다. 일부 실패는 'partial' 이며 성공으로 보이지 않는다.
|
||||
* 3) [0.3.17] server.provision(역할별 통합 배포: 프로필의 모듈을 한 서버에 순서대로) / portal.relocate(포털 이전: 새 서버 설치 → 운영자 전환 확인 → 옛 서버 비활성). HAL 자체는 배포 대상이 아니다.
|
||||
*
|
||||
* 대상(target) 해석: 서버 id(srv_…) → 그 서버의 Agent, Agent id(ag_…) → 그 Agent(외부 Agent 포함).
|
||||
*/
|
||||
@@ -17,7 +18,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as core from './core.mjs';
|
||||
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES } from './modules/index.mjs';
|
||||
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES, SERVER_PROFILES } from './modules/index.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -115,12 +116,14 @@ export function registerKinds(engine, d) {
|
||||
const unitTemplate = (name) => { const f = name ? join(ROOT, 'deploy', 'units', name) : null; return f && existsSync(f) ? readFileSync(f, 'utf8') : null; };
|
||||
/** 배포 전제: 릴리스 승인, artifact 존재, HAL 먼저(다른 모듈은 HAL 이 그 릴리스로 실행 중이어야), 부트스트랩 완료 */
|
||||
function precheckModule(module, version) {
|
||||
if (module === 'hal') throw new JobError('hal_not_deployable', 'HAL 은 릴리스 배포 대상이 아닙니다 — HAL 은 Suite 를 설치하기 전에 먼저 설치하는 도구이며 HAL 서버에서 다시 배포하지 않습니다(별도 절차로 갱신)'); // [0.3.17]
|
||||
const rel = core.getRelease(d, version); if (!rel) throw new JobError('release_not_found', `릴리스 ${version} 이(가) 없습니다`);
|
||||
if (rel.status !== 'approved') throw new JobError('release_not_approved', `릴리스 ${version} 은(는) 승인되지 않았습니다`);
|
||||
if (ARTIFACT_MODULES.includes(module) && !core.artifactPath(d, version, module)) throw new JobError('artifact_missing', `릴리스 ${version} 의 ${module} artifact 가 없습니다`);
|
||||
const health = core.selfHealth(d); if (!health.ok) throw new JobError('hal_unhealthy', 'HAL 자체 건강 확인에 실패해 배포하지 않습니다');
|
||||
if (!core.bootstrapComplete(d)) throw new JobError('bootstrap_incomplete', '부트스트랩(HAL 설치·첫 Agent 등록)이 끝나지 않았습니다');
|
||||
if (module !== 'hal' && health.bootstrap.halRunningVersion !== version) throw new JobError('hal_first', `HAL 이 아직 ${version} 으로 실행되고 있지 않습니다(현재 ${health.bootstrap.halRunningVersion}). HAL 을 먼저 배포하세요`);
|
||||
// [0.3.17] HAL 먼저 규칙: HAL 은 별도 설치되므로 "같은 버전" 이 아니라 "릴리스보다 오래되지 않음" 을 요구한다(HAL 0.3.17 이 0.3.10 모듈을 배포하는 것은 허용, HAL 0.3.9 가 0.3.10 을 배포하는 것은 거부)
|
||||
if (!core.versionGte(health.bootstrap.halRunningVersion, version)) throw new JobError('hal_first', `HAL(현재 ${health.bootstrap.halRunningVersion || '확인 전'})이 릴리스 ${version} 보다 오래되었습니다 — HAL 을 먼저 갱신하세요`);
|
||||
return rel;
|
||||
}
|
||||
engine.register('module.deploy.target', { title: '모듈 배포(대상)', steps: [{ id: 'dispatch', title: 'Agent 에 설치 지시' }, { id: 'await', title: '설치·재시작' }, { id: 'verify', title: '실제 버전·건강 확인' }],
|
||||
@@ -129,6 +132,11 @@ export function registerKinds(engine, d) {
|
||||
const art = core.artifactPath(d, version, module);
|
||||
d.store.run('INSERT INTO module_targets(module, server_id, target_version, last_job_id, updated_at) VALUES(?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET target_version=excluded.target_version, last_job_id=excluded.last_job_id, updated_at=excluded.updated_at', module, serverId, version, job.id, nowIso());
|
||||
const facts = srv.facts || {}; const unitName = (facts.units && facts.units[module]) || desc.unit;
|
||||
if (desc.layout === 'agent-feature') { // [0.3.17] Agent 안의 기능(backup·hawkeye): 설치할 artifact 가 없다 — Agent 에 기능 확인(module.health)을 시켜 켜져 있으면 그 릴리스 버전으로 기록한다
|
||||
const dispF = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: 'module.health', title: 'Agent 기능 확인', payload: { module, descriptor: desc, unit: null } });
|
||||
const rf = await ctx.step('await', () => core.awaitAgentJob(d, ctx, dispF.agentJobId));
|
||||
return ctx.step('verify', () => { const ok = rf && rf.health?.ok !== false; d.store.run('UPDATE module_targets SET actual_version=?, health=?, active=1, last_check_at=?, last_error=?, updated_at=? WHERE module=? AND server_id=?', ok ? version : null, ok ? 'ok' : 'down', nowIso(), ok ? null : `Agent 기능 확인 실패`, nowIso(), module, serverId); if (!ok) throw new JobError('feature_unhealthy', `${srv.name}: Agent 기능 ${module} 확인에 실패했습니다`, { detail: rf }); return { actualVersion: version, health: rf.health || { ok: true }, feature: true }; });
|
||||
}
|
||||
const disp = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: action === 'rollback' ? 'module.rollback' : 'module.install', title: 'Agent 에 설치 지시', payload: { module, version, action, artifactUrl: art ? `/agent/v1/releases/${version}/artifact?module=${module}` : null, sha256: art?.sha256, size: art?.size, descriptor: { ...desc, unit: unitName }, unitTemplate: unitTemplate(desc.unitTemplate), appDir: (facts.appDirs && facts.appDirs[module]) || (desc.baseDir || `/srv/ariweb/${module}`), toVersion: job.subject.toVersion || null } });
|
||||
const r = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
|
||||
const v = await ctx.step('verify', () => {
|
||||
@@ -162,6 +170,39 @@ export function registerKinds(engine, d) {
|
||||
engine.register('module.reconcile', { title: '목표·실제 상태 맞추기', steps: [{ id: 'diff', title: '차이 확인' }, { id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
|
||||
async run(job, ctx) { const diff = await ctx.step('diff', () => d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)' + (job.subject.module ? ' AND module=?' : ''), ...(job.subject.module ? [job.subject.module] : []))); if (!diff.length) return { drift: [], overall: 'done' }; const sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'module.deploy.target', serverIds: diff.map((x) => x.server_id), mk: (serverId) => { const x = diff.find((y) => y.server_id === serverId); return { module: x.module, version: x.target_version, serverId, action: 'install' }; } })); return { drift: diff, ...sum }; } });
|
||||
|
||||
// ---------------- [0.3.17] 역할별 통합 배포(server.provision): 프로필의 모듈을 순서대로 같은 서버에 배포하고 서버 역할·프로필·슬롯을 기록한다
|
||||
async function waitChild(ctx, id, maxMs = d.config.fanoutTimeoutMs || 12 * 3600 * 1000) { const t0 = Date.now(); while (true) { const j = engine.get(id); if (!j) throw new JobError('child_missing', '자식 작업이 없습니다'); if (['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) return j; if (Date.now() - t0 > maxMs) throw new JobError('child_timeout', '자식 작업이 제한 시간 안에 끝나지 않았습니다'); ctx.checkCancel(); await new Promise((r) => setTimeout(r, d.config.agentPollMs || 1000)); } }
|
||||
/** 포털이 다른 서버에 이미 설치되어 있으면 그 서버 id(싱글턴 검사) */
|
||||
function portalOwner(exceptServerId = null) { const r = d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1' + (exceptServerId ? ' AND server_id<>?' : '') + ' ORDER BY updated_at DESC LIMIT 1', ...(exceptServerId ? [exceptServerId] : [])); return r ? r.server_id : null; }
|
||||
engine.register('server.provision', { title: '역할별 통합 배포', steps: [{ id: 'precheck', title: '서버·프로필·릴리스 확인' }, { id: 'profile', title: '역할·프로필·슬롯 기록' }, { id: 'deploy', title: '모듈 순서대로 배포' }],
|
||||
async run(job, ctx) {
|
||||
const { serverId, profile, version } = job.subject; const prof = SERVER_PROFILES[profile]; if (!prof) throw new JobError('bad_profile', `모르는 프로필: ${profile}`);
|
||||
const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
await ctx.step('precheck', () => { if (srv.status !== 'ready' || !srv.agent_id) throw new JobError('server_not_ready', `서버 ${srv.name} 이(가) 준비(Agent 연결·승인) 상태가 아닙니다(${srv.status})`); for (const m of prof.modules) precheckModule(m, version); if (prof.singleton && portalOwner(serverId)) throw new JobError('portal_exists', `포털이 이미 다른 서버에 설치되어 있습니다 — 추가 설치 대신 "포털 이전"을 쓰세요`); return { modules: prof.modules, role: prof.role }; });
|
||||
await ctx.step('profile', () => { core.updateServer(d, serverId, { role: prof.role, profile: prof.id, slots_json: toJson(prof.slots || {}) }, job.created_by); return { role: prof.role, profile: prof.id, slots: prof.slots }; });
|
||||
const results = await ctx.step('deploy', async () => {
|
||||
const out = []; let i = 0;
|
||||
for (const m of prof.modules) {
|
||||
const child = engine.enqueue({ kind: 'module.deploy.target', subject: { module: m, version, serverId, action: 'install' }, parentId: job.id, groupId: null, subjectType: 'server', subjectId: serverId, idempotencyKey: `${job.id}:${m}`, createdBy: job.created_by, actorType: job.actor_type, label: `${job.label} — ${m}` }).job;
|
||||
ctx.log(`${m} 배포 시작(${child.id})`); const r = await waitChild(ctx, child.id); out.push({ module: m, jobId: child.id, status: r.status, error: r.error || null }); ctx.progress(Math.round(100 * (++i) / prof.modules.length));
|
||||
if (r.status !== 'done') throw new JobError('provision_failed', `${m} 배포가 ${r.status} 로 끝났습니다: ${r.error || ''} — 이후 모듈은 배포하지 않았습니다`, { detail: { results: out } });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'deploy.provision', subjectType: 'server', subjectId: serverId, detail: { profile: prof.id, version, modules: prof.modules } });
|
||||
return { profile: prof.id, role: prof.role, version, modules: results };
|
||||
} });
|
||||
/** [0.3.17] 포털 이전: 새 서버에 포털 설치 → 운영자가 데이터·DNS 전환을 마치고 재개 → 옛 서버 포털을 비활성으로 */
|
||||
engine.register('portal.relocate', { title: '포털 이전', steps: [{ id: 'precheck', title: '현재 포털·대상 서버 확인' }, { id: 'install', title: '새 서버에 포털 설치' }, { id: 'handover', title: '데이터·DNS 전환(운영자 확인)' }, { id: 'switch', title: '옛 포털 비활성' }],
|
||||
async run(job, ctx) {
|
||||
const { fromServerId, toServerId, version } = job.subject; const to = core.getServer(d, toServerId); const from = core.getServer(d, fromServerId);
|
||||
await ctx.step('precheck', () => { if (!to || !from) throw new JobError('server_not_found', '서버가 없습니다'); if (to.status !== 'ready' || !to.agent_id) throw new JobError('server_not_ready', `대상 서버 ${to.name} 이(가) 준비 상태가 아닙니다`); if (portalOwner() !== fromServerId) throw new JobError('portal_not_here', `서버 ${from.name} 에 활성 포털이 없습니다`); precheckModule('portal', version); return { from: from.name, to: to.name }; });
|
||||
await ctx.step('install', async () => { const child = engine.enqueue({ kind: 'module.deploy.target', subject: { module: 'portal', version, serverId: toServerId, action: 'install' }, parentId: job.id, subjectType: 'server', subjectId: toServerId, idempotencyKey: `${job.id}:portal`, createdBy: job.created_by, actorType: job.actor_type, label: `${job.label} — 새 서버 설치` }).job; const r = await waitChild(ctx, child.id); if (r.status !== 'done') throw new JobError('install_failed', `새 서버 포털 설치 실패: ${r.error || r.status}`); return { jobId: child.id }; });
|
||||
const hs = (job.steps || []).find((x) => x.id === 'handover');
|
||||
await ctx.step('handover', () => { if (hs && hs.state === 'needs_user') return { confirmedBy: 'operator', at: nowIso() }; return ctx.needsUser(`새 서버(${to.name})에 포털이 설치되었습니다. 포털 데이터(DB·업로드·Vault 연동 키) 이전과 nginx/DNS(my.ariweb.net) 전환을 마친 뒤 "재개"를 누르세요 — 재개하면 옛 서버(${from.name})의 포털을 비활성으로 표시합니다.`, [{ id: 'resume', label: '전환 완료 — 재개' }]); });
|
||||
await ctx.step('switch', () => { d.store.run('UPDATE module_targets SET active=0, health=\'unknown\', last_error=?, updated_at=? WHERE module=\'portal\' AND server_id=?', `포털 이전으로 비활성(→ ${to.name})`, nowIso(), fromServerId); d.store.run('UPDATE module_targets SET active=1, updated_at=? WHERE module=\'portal\' AND server_id=?', nowIso(), toServerId); audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'deploy.portal.relocate', subjectType: 'module', subjectId: 'portal', detail: { from: fromServerId, to: toServerId, version } }); return { from: fromServerId, to: toServerId }; });
|
||||
return { from: fromServerId, to: toServerId, version };
|
||||
} });
|
||||
// ---------------- 포털 이벤트: 포털이 만든 작업(subject._portal)의 상태 변화를 outbox 에 넣는다
|
||||
engine.onChange((job) => { if (job.subject && job.subject._portal) core.queuePortalEvent(d, { halJobId: job.id, kind: job.kind, status: job.status, steps: job.steps, progress: job.progress, result: job.result, error: job.error, errorCode: job.error_code, userMessage: job.user_message, updatedAt: job.updated_at, subjectType: job.subject_type, subjectId: job.subject_id }); });
|
||||
}
|
||||
|
||||
+56
-14
@@ -1,41 +1,83 @@
|
||||
/**
|
||||
* hal/modules/index.mjs — 모듈 기술서(descriptor) 모음(§32).
|
||||
* hal/modules/index.mjs — 모듈 기술서(descriptor) 모음(§32) + [0.3.17] 역할별 서버 프로필·모듈 안내.
|
||||
*
|
||||
* HAL 이 어떤 모듈을 어떤 서버에 어떻게 설치·갱신·건강 확인하는지를 데이터로 둔다. Agent 는 이 기술서를 작업 payload 로 받아 그대로 수행하므로
|
||||
* Agent 바이너리는 모듈별 지식을 갖지 않는다(새 모듈을 더할 때 HAL 만 바꾸면 된다).
|
||||
*
|
||||
* 기술서 모양
|
||||
* name 모듈 이름(릴리스 artifact 이름과 같다: <name>-<version>.tar.gz)
|
||||
* label 화면 표시 이름
|
||||
* roles 설치할 수 있는 서버 역할
|
||||
* layout 'releases'(= /srv/ariweb/<name>/releases/<ver> + current) — 도입 모듈도 같은 틀을 쓰되 데이터 경로는 기존 것을 유지한다
|
||||
* layout 'releases'(= /srv/ariweb/<name>/releases/<ver> + current) — 도입 모듈도 같은 틀을 쓰되 데이터 경로는 기존 것을 유지한다 / 'agent-feature'(Agent 안의 기능, artifact 없음)
|
||||
* unit systemd 유닛 이름(도입 모듈은 서버에 이미 있는 이름을 쓸 수 있어 server.facts.units 로 덮어쓴다)
|
||||
* unitTemplate deploy/units/<file>.tmpl — 없으면 기존 유닛을 그대로 두고 WorkingDirectory 만 current 심링크가 가리키게 한다
|
||||
* health { url, expect } 내부 건강 확인(127.0.0.1)
|
||||
* version 실제 실행 버전을 읽는 방법: { file:'package.json' } (current/package.json 의 version) 또는 { http:'/api/health', field:'version' }
|
||||
* preStop/postStart Agent 가 실행할 추가 절차 이름(agent/jobs/module.mjs 가 아는 것만)
|
||||
* dataDirs 절대 건드리지 않는 데이터 경로(검증·백업 대상 안내용)
|
||||
*
|
||||
* [0.3.17] 이름 변경: observability → hawkeye(HawkEye, 전체 시스템 상황 점검). 옛 이름은 descriptor() 별칭으로 받고 DB 행은 hal_0008_hawkeye 마이그레이션이 바꾼다.
|
||||
* [0.3.17] HAL 은 배포 대상이 아니다: HAL 은 Suite 전체를 설치하기 전에 먼저 설치하는 도구이며 HAL 서버에서 HAL 을 다시 배포하지 않는다(이중화·분산 미구성). DEPLOY_ORDER·DEPLOYABLE_MODULES 에서 뺀다.
|
||||
*/
|
||||
export const MODULES = {
|
||||
hal: { name: 'hal', roles: ['control'], layout: 'releases', unit: 'ariweb-hal', unitTemplate: 'ariweb-hal.service.tmpl', user: 'master', port: 8900,
|
||||
health: { url: 'http://127.0.0.1:8900/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8900/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/hal/data'], selfUpdate: true },
|
||||
portal: { name: 'portal', roles: ['control', 'web'], layout: 'releases', unit: 'ariweb-portal', unitTemplate: 'ariweb-portal.service.tmpl', user: 'master', port: 8910,
|
||||
hal: { name: 'hal', label: 'HAL', roles: ['control'], layout: 'releases', unit: 'ariweb-hal', unitTemplate: 'ariweb-hal.service.tmpl', user: 'master', port: 8900,
|
||||
health: { url: 'http://127.0.0.1:8900/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8900/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/hal/data'], selfUpdate: true, deployable: false },
|
||||
portal: { name: 'portal', label: '고객 포털', roles: ['control', 'web'], layout: 'releases', unit: 'ariweb-portal', unitTemplate: 'ariweb-portal.service.tmpl', user: 'master', port: 8910,
|
||||
health: { url: 'http://127.0.0.1:8910/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8910/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/portal/data'] },
|
||||
agent: { name: 'agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master',
|
||||
agent: { name: 'agent', label: 'Node 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.8.0 — 0.2.0 에서 직접 편집(시각 편집기) 포함; 0.1.1 은 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며,
|
||||
// 유닛은 서버에 이미 있고 <appDir>/current 를 가리키면 그대로 둔다(keepExistingUnit — 운영 서버의 강화 지시문·ReadWritePaths 보존, 실제 환경 결함 수정). 없을 때만 deploy/units/webmaker.service.tmpl 로 만든다.
|
||||
// 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.8.0 은 upstreamVersion).
|
||||
webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker',
|
||||
webmaker: { name: 'webmaker', label: '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,
|
||||
arimail: { name: 'arimail', label: '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'] },
|
||||
observability: { name: 'observability', roles: ['control', 'web', 'mail', 'backup'], layout: 'agent-feature', unit: null, health: { feature: 'metrics' }, version: { agent: true }, dataDirs: [] },
|
||||
backup: { name: 'backup', label: '백업', roles: ['backup', 'web'], layout: 'agent-feature', unit: null, health: { feature: 'backup' }, version: { agent: true }, dataDirs: ['/srv/ariweb/backups'] },
|
||||
hawkeye: { name: 'hawkeye', label: 'HawkEye', roles: ['control', 'web', 'mail', 'backup'], layout: 'agent-feature', unit: null, health: { feature: 'metrics' }, version: { agent: true }, dataDirs: [], legacyNames: ['observability'] },
|
||||
};
|
||||
/** 릴리스 artifact 로 배포되는 모듈(agent-feature 는 Agent 안의 기능이라 별도 artifact 가 없다) */
|
||||
/** 옛 이름 → 새 이름(요청·설정·DB 에 남은 observability 를 받아 준다) */
|
||||
export const LEGACY_MODULE_NAMES = { observability: 'hawkeye' };
|
||||
export const canonicalModuleName = (name) => LEGACY_MODULE_NAMES[name] || name;
|
||||
/** 릴리스 artifact 로 배포되는 모듈(agent-feature 는 Agent 안의 기능이라 별도 artifact 가 없다). HAL 도 artifact 는 있지만 배포 대상은 아니다. */
|
||||
export const ARTIFACT_MODULES = Object.values(MODULES).filter((m) => m.layout === 'releases').map((m) => m.name);
|
||||
/** HAL 이 가장 먼저이고, 그다음 agent → portal → 도입 모듈 순으로 배포한다(§32.2) */
|
||||
export const DEPLOY_ORDER = ['hal', 'agent', 'portal', 'webmaker', 'arimail', 'backup', 'observability'];
|
||||
export function descriptor(name) { const d = MODULES[name]; if (!d) throw Object.assign(new Error(`알 수 없는 모듈: ${name}`), { code: 'unknown_module', status: 404 }); return d; }
|
||||
/** [0.3.17] 배포 순서(HAL 제외): agent 가 가장 먼저(다른 모듈 설치를 수행하는 주체) → portal → 도입 모듈 → 백업 → HawkEye */
|
||||
export const DEPLOY_ORDER = ['agent', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'];
|
||||
/** [0.3.17] 릴리스 화면·배포 API 가 다루는 모듈(HAL 제외) */
|
||||
export const DEPLOYABLE_MODULES = DEPLOY_ORDER.slice();
|
||||
export function descriptor(name) { const d = MODULES[canonicalModuleName(name)]; if (!d) throw Object.assign(new Error(`알 수 없는 모듈: ${name}`), { code: 'unknown_module', status: 404 }); return d; }
|
||||
|
||||
/**
|
||||
* [0.3.17] 모듈 안내(릴리스 화면·AI 스킬): 무엇을 하는지, 어떤 역할 서버에, 무엇이 먼저 있어야 하는지(requires), 무엇을 제공하는지(provides), 설치 방법.
|
||||
*/
|
||||
export const MODULE_GUIDES = {
|
||||
agent: { label: 'Node Agent', description: '각 관리 서버에서 HAL 의 작업(설치·갱신·건강 확인·백업·지표)을 수행하는 상주 프로세스. 다른 모든 모듈은 Agent 를 통해 배포된다.', roles: MODULES.agent.roles, requires: ['hal'], provides: ['module.install', 'heartbeat', 'metrics', 'backup'], install: '서버 등록 → 일회용 등록 정보로 Agent 설치(bootstrap) → 승인. 갱신은 이 화면의 배포(자기 갱신: 설치 뒤 자동 재시작).', singleton: false },
|
||||
portal: { label: '고객 포털', description: '고객 가입·상품·사이트·메일·도메인·백업·결제를 하나로 잇는 사용자 접근 화면과 API. HAL 과 서비스 키로 연결되어 작업을 HAL 에 맡긴다.', roles: MODULES.portal.roles, requires: ['agent', 'hal 연동 키(link-portal)'], provides: ['고객 화면 my.ariweb.net', '/internal/ops 운영 중계'], install: '포털 서버 프로필로 통합 배포하거나 이 화면에서 portal 배포. 포털은 **하나만** 운영한다 — 이미 있으면 설치 대신 "포털 이전"을 쓴다(새 서버 설치 → 데이터·DNS 전환 확인 → 옛 서버 비활성).', singleton: true },
|
||||
webmaker: { label: 'Webmaker', description: 'AI 사이트 제작 도구(도입 모듈). 제작 화면·직접 편집·배포 요청을 제공하고, 사용자 사이트는 웹호스팅 서버로 배포된다.', roles: MODULES.webmaker.roles, requires: ['agent', 'portal(SSO·배포 연동)', 'webhosting 서버 1대 이상(배포 대상)'], provides: ['사이트 제작·편집', '배포 요청'], install: 'Webmaker 서버 프로필(이 서버에서는 Webmaker 만 동작)로 통합 배포하거나 web 역할 서버에 배포.', singleton: false },
|
||||
arimail: { label: 'AriMail', description: '메일 서비스(도입 모듈): 웹메일 접속, POP·IMAP·SMTP, 도메인별 메일함. HAL 관리형 메일 서비스 등록부와 연결된다.', roles: MODULES.arimail.roles, requires: ['agent', 'mail 역할 서버', 'DNS(MX·SPF·DKIM)'], provides: ['webmail', 'POP/IMAP/SMTP'], install: 'AriMail 서버 프로필로 통합 배포하거나 mail 역할 서버에 배포. 기존 AriMail 인스턴스는 mail-import 로 가져온다.', singleton: false },
|
||||
backup: { label: '백업', description: 'Agent 안의 백업 기능: 사이트·메일 백업 생성·저장·검증·복원. 별도 artifact 없이 Agent 와 함께 설치된다.', roles: MODULES.backup.roles, requires: ['agent'], provides: ['backup.create/verify/restore'], install: '백업 서버 프로필로 통합 배포(backup 역할). 운영 서버와 분리된 저장 서버를 권장.', singleton: false },
|
||||
hawkeye: { label: 'HawkEye', description: '전체 시스템 상황 점검(옛 이름 observability): 서버 지표·시계 차이·디스크 고갈·모듈 건강을 heartbeat 로 모아 운영 현황과 사건으로 보인다. Agent 안의 기능.', roles: MODULES.hawkeye.roles, requires: ['agent'], provides: ['지표 수집', '건강·사건 감지'], install: '모든 프로필에 포함되어 Agent 와 함께 설치된다. 별도 artifact 없음.', singleton: false },
|
||||
};
|
||||
/** [0.3.17] 모듈 사이의 연결(릴리스 화면 "상호 연결" 설명) */
|
||||
export const MODULE_LINKS = [
|
||||
'HAL ↔ Agent: 모든 서버 작업(설치·건강·백업·지표)은 Agent 가 HAL 에서 받아 수행한다. Agent 없이는 어떤 모듈도 배포되지 않는다.',
|
||||
'HAL ↔ 포털: 서비스 키(link-portal)로 연결. 포털의 사이트 배포·백업·이전 요청은 HAL 작업으로 실행된다. 포털이 없으면 고객 화면이 없다.',
|
||||
'포털 ↔ Webmaker: SSO·제작 화면 열기·배포 요청. Webmaker 가 만든 사이트는 웹호스팅 서버(web 역할, Agent 설치)로 배포된다.',
|
||||
'포털 ↔ AriMail / HAL 메일 서비스 등록부: 메일 서비스·메일함 생성은 포털이 요청하고 AriMail(mail 역할)이 제공한다.',
|
||||
'백업(Agent 기능) ↔ 포털: 백업 옵션·복원 요청은 포털이, 생성·저장·검증은 Agent 백업 기능이 한다.',
|
||||
'HawkEye(Agent 기능) → HAL: 서버 지표·모듈 건강을 heartbeat 로 올려 운영 현황·사건·공개 상태 페이지에 반영한다.',
|
||||
];
|
||||
/**
|
||||
* [0.3.17] 역할별 서버 프로필(통합 배포). 새 서버를 추가할 때 역할을 고르면 필요한 모듈을 한 번에 순서대로 배포하고 서버의 역할·슬롯을 기록한다.
|
||||
* slots: 그 서버가 맡을 수 있는 수(배치 때 HAL 이 본다). 포털은 singleton(하나만).
|
||||
*/
|
||||
export const SERVER_PROFILES = {
|
||||
webmaker: { id: 'webmaker', label: 'Webmaker 서버', role: 'web', modules: ['agent', 'webmaker', 'hawkeye'], description: '이 서버에서는 Webmaker 만 동작합니다(제작 화면·직접 편집).', slots: { webmaker: 1 } },
|
||||
webhosting: { id: 'webhosting', label: '웹호스팅 서버', role: 'web', modules: ['agent', 'hawkeye'], description: '사용자의 웹 서비스를 제공합니다. Webmaker 의 배포 대상 서버가 됩니다(사이트 슬롯).', slots: { sites: 50 } },
|
||||
arimail: { id: 'arimail', label: 'AriMail 서버', role: 'mail', modules: ['agent', 'arimail', 'hawkeye'], description: '사용자에게 메일 서비스(웹메일·POP·IMAP·SMTP)를 제공합니다.', slots: { mailServices: 20 } },
|
||||
backup: { id: 'backup', label: '백업 서버', role: 'backup', modules: ['agent', 'backup', 'hawkeye'], description: '사용자 서비스의 백업을 저장·검증합니다. 운영 서버와 분리를 권장합니다.', slots: { backups: 500 } },
|
||||
portal: { id: 'portal', label: '포털 서버', role: 'control', modules: ['agent', 'portal', 'hawkeye'], description: '사용자 접근을 위한 포털. 하나만 운영하며, 이미 있으면 설치 대신 이전(relocate)합니다.', slots: {}, singleton: true },
|
||||
};
|
||||
export const profileOf = (id) => SERVER_PROFILES[id] || null;
|
||||
|
||||
@@ -127,4 +127,12 @@ CREATE TABLE IF NOT EXISTS ai_oauth_states (state_hash TEXT PRIMARY KEY, connect
|
||||
` },
|
||||
// 0.3.2: 연결별 도구 정책(tools_json: {preset, enabled}) · 제안 출처(source: chat | mcp)
|
||||
{ id: 'hal_0007_ai_tools_policy', sql: `ALTER TABLE ai_connections ADD COLUMN tools_json TEXT; ALTER TABLE ai_actions ADD COLUMN source TEXT NOT NULL DEFAULT 'chat';` },
|
||||
// 0.3.17: observability → hawkeye(HawkEye) 이름 변경(배치 상태·목표 버전 행), 서버 프로필(역할별 통합 배포)·슬롯
|
||||
{ id: 'hal_0008_hawkeye', sql: `
|
||||
UPDATE module_targets SET module='hawkeye' WHERE module='observability' AND NOT EXISTS (SELECT 1 FROM module_targets t2 WHERE t2.module='hawkeye' AND t2.server_id=module_targets.server_id);
|
||||
DELETE FROM module_targets WHERE module='observability';
|
||||
INSERT OR IGNORE INTO modules(name, target_version, updated_at) SELECT 'hawkeye', target_version, updated_at FROM modules WHERE name='observability';
|
||||
DELETE FROM modules WHERE name='observability';
|
||||
` },
|
||||
{ id: 'hal_0009_server_profile', sql: `ALTER TABLE servers ADD COLUMN profile TEXT; ALTER TABLE servers ADD COLUMN slots_json TEXT;` },
|
||||
];
|
||||
|
||||
+6
-4
@@ -26,6 +26,7 @@ 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 { releasesData } from './app.mjs';
|
||||
import * as R from './ui/render.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -54,7 +55,8 @@ export function registerUi(app, d) {
|
||||
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="ai-toggle" aria-pressed="true" aria-controls="ai-panel" title="AI 채팅룸 열기/닫기">AI</button><button class="btn small" data-act="logout">로그아웃</button></nav></header>
|
||||
<div class="shell"><aside class="side" id="side"><nav aria-label="운영자 메뉴">${MENU.map(([k, href, label]) => `<a class="nav${nav === k ? ' on' : ''}" href="${href}">${esc(label)}</a>`).join('')}</nav></aside>
|
||||
<main id="content" class="main" data-page="${esc(page)}" data-id="${esc(id)}"><h1>${esc(title)}</h1><div class="alerts">${banner.join('')}</div><div id="body">${body || ''}</div></main>
|
||||
<main id="content" class="main" data-page="${esc(page)}" data-id="${esc(id)}"><h1>${esc(title)}</h1><div class="alerts">${banner.join('')}</div><div id="body">${body || ''}</div>
|
||||
<section id="ops-dock" class="ops-dock" aria-label="운영 작업 진행 상황"><header class="ops-dock-head"><button class="ai-tab on" data-act="dock-tab" data-tab="active">진행 중 <span id="ops-dock-count" class="badge neutral">0</span></button><button class="ai-tab" data-act="dock-tab" data-tab="history">히스토리</button><span class="muted small">배포·복원 등 운영 작업의 현재 진행 상황과 최근 기록 — 5초마다 바뀐 부분만 갱신</span><button class="btn small" data-act="dock-toggle" aria-expanded="true" title="접기/펼치기">▾</button></header><div id="ops-dock-body" class="ops-dock-body">${R.statePanel('loading', '작업 상황을 불러오는 중')}</div></section></main>
|
||||
<aside id="ai-panel" class="ai-panel" aria-label="AI 채팅룸" data-operator="${esc(ctx.operator.login)}"><div class="ai-resize" title="너비 조절"></div><div class="ai-head"><div class="ai-tabs" role="tablist" id="ai-tabs"><span class="muted small">AI 채팅룸 불러오는 중</span></div></div><div class="ai-status" id="ai-status"></div><div class="ai-log" id="ai-log" aria-live="polite"></div><form class="ai-compose" id="ai-compose"><textarea id="ai-input" rows="2" placeholder="지시를 입력하세요. /help 로 명령 목록(/status /order …), 통합 방에서는 @이름 으로 특정 모델만 부를 수 있습니다" aria-label="AI 지시"></textarea><div class="ai-compose-row"><label class="small"><input type="checkbox" id="ai-ctx" checked> 현재 화면 문맥 포함</label><button class="btn small" type="button" data-act="ai-reset" title="이 방의 기록을 비웁니다">새 대화</button><button class="btn primary small" type="submit">보내기</button></div></form></aside></div>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div><div id="modal" class="modal" hidden></div>
|
||||
<script type="module" src="/ops/ui/ops.js"></script><script type="module" src="/ops/ui/ai.js"></script>${scripts.map((s) => `<script type="module" src="${esc(s)}"></script>`).join('')}</body></html>`);
|
||||
@@ -74,12 +76,12 @@ export function registerUi(app, d) {
|
||||
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>` }));
|
||||
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> <button class="btn" data-act="provision">역할별 통합 배포(새 서버)</button> <button class="btn" data-act="portal-relocate">포털 이전</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
|
||||
app.get('/ops/servers/:id', (ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) return shell(ctx, { title: '서버', nav: 'servers', page: 'server', body: R.statePanel('failed', '서버가 없습니다') }); const ps = core.publicServer(d, s); const jobs = d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe); const agentJobs = d.store.all('SELECT id, agent_id, kind, status, progress, created_at, ended_at, error, user_message FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || '');
|
||||
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.bootstrapState(d), 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({ ...releasesData(d), running: core.bootstrapState(d) }, changelog()) })); // [0.3.17] 모듈별 최신 릴리스·안내 중심, HAL 제외
|
||||
// ---------------- 작업(§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>`;
|
||||
@@ -130,7 +132,7 @@ export function overviewLocal(d) {
|
||||
const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 });
|
||||
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
|
||||
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'observability'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
|
||||
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
|
||||
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
|
||||
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@
|
||||
import * as A from './ai-render.mjs';
|
||||
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
||||
import { Term, renderHtml, keyToBytes, extractUrls, plainText, copyDecision } from './term.mjs';
|
||||
import { patchHtml } from './dom-patch.mjs'; // [0.3.17] 대화 기록은 바뀐 메시지만 갱신
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
||||
async function api(method, path, body) {
|
||||
@@ -61,8 +62,8 @@ async function loadMessages({ full = false } = {}) {
|
||||
if (full) { msgs = r.messages; } else if (r.messages.length) { msgs = msgs.concat(r.messages); }
|
||||
lastSeq = msgs.length ? msgs[msgs.length - 1].seq : 0;
|
||||
const log = $('#ai-log'); const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
||||
log.innerHTML = msgs.length ? msgs.map((m) => A.messageHtml(m, { actions })).join('') : `<div class="aw-state">${room() && room().kind === 'group' ? '통합 방입니다. 연결된 모든 활성 모델이 함께 듣습니다.' : '아직 대화가 없습니다.'}</div>`;
|
||||
const proposed = (r.actions || []).filter((a) => a.status === 'proposed').length; $('#ai-status').innerHTML = A.statusLine(room(), r.pending) + (proposed ? ` <span class="badge warn">승인 대기 ${proposed}</span>` : '');
|
||||
patchHtml(log, msgs.length ? msgs.map((m) => A.messageHtml(m, { actions })).join('') : `<div class="aw-state">${room() && room().kind === 'group' ? '통합 방입니다. 연결된 모든 활성 모델이 함께 듣습니다.' : '아직 대화가 없습니다.'}</div>`);
|
||||
const proposed = (r.actions || []).filter((a) => a.status === 'proposed').length; patchHtml($('#ai-status'), A.statusLine(room(), r.pending) + (proposed ? ` <span class="badge warn">승인 대기 ${proposed}</span>` : ''));
|
||||
if (atBottom || full) log.scrollTop = log.scrollHeight;
|
||||
schedule(r.pending ? 1000 : 4000);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* hal/ui/public/dom-patch.mjs — 화면 부분 갱신(0.3.17, 개발 지시 v0.3.17 §3). 주기 갱신이 화면 전체를 innerHTML 로 바꾸면 번쩍이고 선택·포커스·스크롤이 깨진다.
|
||||
* 새 HTML 을 최상위 노드 단위로 나누어 바뀐 노드만 바꾼다. 순수 함수(splitTopLevel·diffChildren)는 Node 테스트가 검사하고, patchHtml 만 DOM 을 쓴다.
|
||||
*/
|
||||
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
||||
/** HTML 문자열을 최상위 노드(요소·글) 문자열 배열로 나눈다. 깊이를 세어 요소 경계를 찾는다(스크립트·주석은 쓰지 않는 화면용). */
|
||||
export function splitTopLevel(html) {
|
||||
const s = String(html || ''); const out = []; let i = 0; let depth = 0; let start = 0; let textStart = 0;
|
||||
const tagRe = /<\/?([a-zA-Z][a-zA-Z0-9-]*)[^>]*?(\/?)>/g;
|
||||
while (i < s.length) {
|
||||
tagRe.lastIndex = i; const m = tagRe.exec(s); if (!m) break;
|
||||
if (depth === 0 && m.index > textStart) { const t = s.slice(textStart, m.index); if (t.trim()) out.push(t); }
|
||||
const closing = m[0][1] === '/'; const name = m[1].toLowerCase(); const selfClose = m[2] === '/' || VOID.has(name);
|
||||
if (!closing) { if (depth === 0) start = m.index; if (!selfClose) depth++; if (depth === 0 && selfClose) { out.push(s.slice(start, m.index + m[0].length)); textStart = m.index + m[0].length; } }
|
||||
else { depth = Math.max(0, depth - 1); if (depth === 0) { out.push(s.slice(start, m.index + m[0].length)); textStart = m.index + m[0].length; } }
|
||||
i = m.index + m[0].length;
|
||||
}
|
||||
if (depth === 0 && textStart < s.length) { const t = s.slice(textStart); if (t.trim()) out.push(t); } else if (depth > 0) out.push(s.slice(start));
|
||||
return out;
|
||||
}
|
||||
/** 옛 노드 문자열 배열 → 새 배열로 바꾸는 최소 작업 목록: replace(오름차순) → append(오름차순) → remove(내림차순). */
|
||||
export function diffChildren(oldList, newList) {
|
||||
const ops = []; const n = Math.min(oldList.length, newList.length);
|
||||
for (let i = 0; i < n; i++) if (oldList[i] !== newList[i]) ops.push({ i, op: 'replace', html: newList[i] });
|
||||
for (let i = n; i < newList.length; i++) ops.push({ i, op: 'append', html: newList[i] });
|
||||
for (let i = oldList.length - 1; i >= n; i--) ops.push({ i, op: 'remove' });
|
||||
return ops;
|
||||
}
|
||||
const nodeHtml = (n) => (n.nodeType === 1 ? n.outerHTML : n.textContent);
|
||||
/** 컨테이너의 자식을 새 HTML 로 맞춘다(바뀐 노드만). 같은 HTML 이면 아무것도 하지 않는다. 브라우저 전용. */
|
||||
export function patchHtml(el, html) {
|
||||
if (!el) return false; if (el.__lastHtml === html) return false;
|
||||
const tpl = document.createElement('template'); tpl.innerHTML = html; const fresh = [...tpl.content.childNodes].filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));
|
||||
const cur = [...el.childNodes].filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));
|
||||
const ops = diffChildren(cur.map(nodeHtml), fresh.map(nodeHtml));
|
||||
for (const o of ops) { if (o.op === 'replace') el.replaceChild(fresh[o.i].cloneNode(true), cur[o.i]); else if (o.op === 'append') el.appendChild(fresh[o.i].cloneNode(true)); else if (o.op === 'remove') cur[o.i].remove(); }
|
||||
el.__lastHtml = html; return ops.length > 0;
|
||||
}
|
||||
@@ -46,3 +46,8 @@ body.login{display:grid;place-items:center;min-height:100vh}.login-box{backgroun
|
||||
.ai-term{background:#101826;color:#e6edf3;font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.25;padding:8px;border-radius:8px;min-height:240px;max-height:55vh;overflow:auto;white-space:pre;outline:none;margin:0}.ai-term:focus{box-shadow:0 0 0 2px var(--pri)}.term-cur{background:#e6edf3;color:#101826}.ai-keys{gap:4px}.ai-model-pick{width:auto;max-width:220px;padding:3px 6px;font-size:12px}
|
||||
/* 0.3.10 셸 복사·주소 */
|
||||
.ai-term{user-select:text;cursor:text}.ai-urls{list-style:none;padding:0;margin:4px 0}.ai-urls li{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:3px 0}.ai-url{word-break:break-all;background:#f7f9fc;border:1px solid var(--line);border-radius:6px;padding:2px 6px;font-size:12px}.ai-shell-tools{margin:6px 0}.ai-shell-textview{width:100%;max-width:none;font-family:ui-monospace,Menlo,monospace;font-size:12px;margin-top:6px}
|
||||
/* 0.3.17 작업 진행 창(dock)·서버 선택 목록·릴리스 카드 */
|
||||
.ops-dock{margin-top:18px;border:1px solid var(--line);border-radius:var(--rad);background:#fff}.ops-dock-head{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}.ops-dock-head .muted{margin-left:auto}.ops-dock-body{padding:8px 10px;max-height:260px;overflow:auto}.ops-dock.closed .ops-dock-body{display:none}
|
||||
.dock-job{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:5px 0;border-bottom:1px solid var(--line)}.dock-job:last-child{border-bottom:0}.dock-bar{display:inline-block;width:120px;height:8px;background:#eef1f5;border-radius:4px;overflow:hidden;vertical-align:middle}.dock-bar-in{display:block;height:100%;background:var(--pri)}
|
||||
.choice-list{display:flex;flex-direction:column;gap:4px;max-height:280px;overflow:auto;border:1px solid var(--line);border-radius:8px;padding:6px}.choice{display:flex;gap:8px;align-items:center;font-weight:400;margin:0;padding:4px 6px;border-radius:6px}.choice:hover{background:#f7f9fc}.choice.off{opacity:.55}
|
||||
.rel-cards{grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.rel-card h3{margin-top:0}.rel-history{margin:12px 0}
|
||||
|
||||
+24
-7
@@ -2,10 +2,11 @@
|
||||
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 고위험 안내가 붙은 비밀번호 창 → 성공 시 1회 재시도(reauth.mjs), 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
|
||||
* - [0.2.1] 고위험 재인증 안내는 전역 배너가 아니라 서버가 거부한 그 때의 재인증 창 안에만 보인다. 계정 화면의 수동 재인증 창에는 붙지 않는다.
|
||||
* - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다
|
||||
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다.
|
||||
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 읽되 [0.3.17] 바뀐 노드만 바꾼다(dom-patch.mjs — 번쩍임·선택 유지). 아래 작업 진행 창(dock)은 5초마다. 화면을 떠나도 작업은 HAL 이 계속한다.
|
||||
*/
|
||||
import * as R from './render.mjs';
|
||||
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
||||
import { patchHtml } from './dom-patch.mjs'; // [0.3.17] 주기 갱신은 바뀐 부분만
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
||||
async function api(method, path, body) {
|
||||
@@ -22,9 +23,9 @@ function modal(html) { const m = $('#modal'); m.innerHTML = `<div class="box" ro
|
||||
/** 입력 창. fields: [{name,label,type,options,required,value,hint}] → 값 객체 또는 null */
|
||||
function ask(title, fields, { okLabel = '확인', danger = false, note = '' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const m = modal(`<h2>${R.esc(title)}</h2>${note ? `<p class="muted">${note}</p>` : ''}<form id="askf">${fields.map((f) => f.type === 'select' ? `<label>${R.esc(f.label)}<select name="${f.name}">${(f.options || []).map((o) => `<option value="${R.esc(o.value ?? o)}"${(o.value ?? o) == f.value ? ' selected' : ''}>${R.esc(o.label ?? o)}</option>`).join('')}</select></label>` : f.type === 'checkbox' ? `<label><input type="checkbox" name="${f.name}"${f.value ? ' checked' : ''}> ${R.esc(f.label)}</label>` : f.type === 'textarea' ? `<label>${R.esc(f.label)}<textarea name="${f.name}" rows="3">${R.esc(f.value || '')}</textarea></label>` : `<label>${R.esc(f.label)}<input name="${f.name}" type="${f.type || 'text'}" value="${R.esc(f.value || '')}"${f.required ? ' required' : ''} autocomplete="off"></label>`).join('') + (fields.some((f) => f.hint) ? `<p class="muted small">${fields.filter((f) => f.hint).map((f) => R.esc(f.hint)).join(' · ')}</p>` : '')}<div class="actions"><button type="button" class="btn" id="askc">취소</button><button class="btn ${danger ? 'danger' : 'primary'}">${R.esc(okLabel)}</button></div></form>`);
|
||||
const m = modal(`<h2>${R.esc(title)}</h2>${note ? `<p class="muted">${note}</p>` : ''}<form id="askf">${fields.map((f) => f.type === 'radio' || f.type === 'checks' ? `<label>${R.esc(f.label)}</label>${f.html || ''}` : f.type === 'select' ? `<label>${R.esc(f.label)}<select name="${f.name}">${(f.options || []).map((o) => `<option value="${R.esc(o.value ?? o)}"${(o.value ?? o) == f.value ? ' selected' : ''}>${R.esc(o.label ?? o)}</option>`).join('')}</select></label>` : f.type === 'checkbox' ? `<label><input type="checkbox" name="${f.name}"${f.value ? ' checked' : ''}> ${R.esc(f.label)}</label>` : f.type === 'textarea' ? `<label>${R.esc(f.label)}<textarea name="${f.name}" rows="3">${R.esc(f.value || '')}</textarea></label>` : `<label>${R.esc(f.label)}<input name="${f.name}" type="${f.type || 'text'}" value="${R.esc(f.value || '')}"${f.required ? ' required' : ''} autocomplete="off"></label>`).join('') + (fields.some((f) => f.hint) ? `<p class="muted small">${fields.filter((f) => f.hint).map((f) => R.esc(f.hint)).join(' · ')}</p>` : '')}<div class="actions"><button type="button" class="btn" id="askc">취소</button><button class="btn ${danger ? 'danger' : 'primary'}">${R.esc(okLabel)}</button></div></form>`);
|
||||
$('#askc', m.el).onclick = () => { m.close(); resolve(null); };
|
||||
$('#askf', m.el).onsubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const out = {}; for (const f of fields) out[f.name] = f.type === 'checkbox' ? fd.get(f.name) === 'on' : fd.get(f.name); m.close(); resolve(out); };
|
||||
$('#askf', m.el).onsubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const out = {}; for (const f of fields) out[f.name] = f.type === 'checkbox' ? fd.get(f.name) === 'on' : f.type === 'checks' ? fd.getAll(f.name) : fd.get(f.name); if (fields.some((f) => f.type === 'radio' && f.required && !out[f.name])) return toast('서버를 선택하세요'); m.close(); resolve(out); };
|
||||
});
|
||||
}
|
||||
/** 1회 표시 값(등록 토큰·자격·접근권 URL·복구 코드): 닫으면 다시 볼 수 없음을 안내 */
|
||||
@@ -47,7 +48,7 @@ async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message ||
|
||||
const body = () => $('#body');
|
||||
async function relay(path) { try { return await api('GET', '/ops/api/portal' + path); } catch (e) { throw Object.assign(e, { relay: true }); } }
|
||||
const PAGES = {
|
||||
overview: async () => { try { const o = await api('GET', '/ops/api/overview'); body().innerHTML = R.overviewView(o); } catch (e) { body().innerHTML = R.relayError(e); return; }
|
||||
overview: async () => { try { const o = await api('GET', '/ops/api/overview'); patchHtml(body(), R.overviewView(o)); } catch (e) { patchHtml(body(), R.relayError(e)); return; }
|
||||
const el = $('#pending-approvals'); if (!el) return; const partial = []; const count = async (p, pick) => { try { const r = await relay(p); return pick(r); } catch (e) { partial.push(p); return 0; } };
|
||||
const [orders, restores, relocations, transfers] = await Promise.all([count('/commerce/orders?status=awaiting_approval', (r) => (r.orders || []).length), count('/backup/restores', (r) => (r.restores || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/relocation', (r) => (r.relocations || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/transfer', (r) => (r.transfers || []).filter((x) => ['reviewing', 'accepted'].includes(x.status)).length)]);
|
||||
el.innerHTML = partial.length === 4 ? R.relayError({ code: 'portal_unreachable' }) : R.pendingApprovals({ orders, restores, relocations, transfers, partial: partial.length ? partial : null }); },
|
||||
@@ -61,9 +62,9 @@ const PAGES = {
|
||||
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); } },
|
||||
servers: async () => { try { const r = await api('GET', '/ops/api/servers'); const b = body(); const a = b.querySelector('.actions'); b.innerHTML = (a ? a.outerHTML : '') + R.serversTable(r.servers) + '<p class="muted">마지막 갱신 ' + R.fmt(new Date().toISOString()) + '</p>'; } catch (e) { toast('서버 목록 갱신 실패: ' + e.message); } },
|
||||
servers: async () => { try { const r = await api('GET', '/ops/api/servers'); const b = body(); const a = b.querySelector('.actions'); patchHtml(b, (a ? a.outerHTML : '') + R.serversTable(r.servers) + '<p class="muted">마지막 갱신 ' + R.fmt(new Date().toISOString()) + '</p>'); } catch (e) { toast('서버 목록 갱신 실패: ' + e.message); } },
|
||||
server: async (id) => { const draw = async () => { const key = $('#metric-key')?.value || 'cpu'; const days = Number($('#metric-days')?.value || 30); try { const r = await api('GET', `/ops/api/servers/${id}/metrics?since=${encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString())}&limit=5000`); $('#metrics').innerHTML = R.metricsSvg(r.samples, key); } catch (e) { $('#metrics').innerHTML = R.relayError(e); } }; $('#metric-key')?.addEventListener('change', draw); $('#metric-days')?.addEventListener('change', draw); await draw(); },
|
||||
job: async (id) => { try { const r = await api('GET', '/ops/api/jobs/' + id); const j = r.job; if (!['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) { const jb = $('.job', body()); if (jb) jb.outerHTML = R.jobBlock(j); } } catch (_) {} },
|
||||
job: async (id) => { try { const r = await api('GET', '/ops/api/jobs/' + id); const j = r.job; if (!['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) { const jb = $('.job', body()); if (jb && jb.outerHTML !== R.jobBlock(j)) jb.outerHTML = R.jobBlock(j); } } catch (_) {} },
|
||||
recovery: async () => { document.querySelectorAll('[data-tab]').forEach((a) => a.onclick = (e) => { e.preventDefault(); document.querySelectorAll('[data-tab]').forEach((x) => x.classList.remove('on')); a.classList.add('on'); document.querySelectorAll('main section').forEach((s) => { s.hidden = s.id !== a.dataset.tab; }); }); if (location.hash === '#dr') $('[data-tab=dr]')?.click(); },
|
||||
login: () => { const f = $('#login-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { const r = await api('POST', '/ops/api/login', { login: fd.get('login'), password: fd.get('password'), totp: fd.get('totp') || undefined }); if (r.mfaRequired) { $('#totp-row').hidden = false; $('#msg').textContent = r.message || '인증 코드를 입력하세요'; return; } location.href = '/ops/'; } catch (err) { $('#msg').textContent = err.message; } }; },
|
||||
setup: () => { const f = $('#setup-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { await api('POST', '/ops/api/setup', Object.fromEntries(fd)); toast('첫 운영자를 만들었습니다. 로그인하세요'); location.href = '/ops/login'; } catch (err) { $('#msg').textContent = err.message; } }; },
|
||||
@@ -73,7 +74,17 @@ const ACT = {
|
||||
'toggle-nav': () => $('#side')?.classList.toggle('open'), logout: async () => { await api('POST', '/ops/api/logout'); location.href = '/ops/login'; }, reauth: () => reauth(),
|
||||
job: async (b) => { await call('POST', `/ops/api/jobs/${b.id}/${b.op}`); toast('요청했습니다'); reload(); },
|
||||
'release-approve': async (b) => { if (!(await ask('릴리스 승인', [{ name: 'ok', label: `릴리스 ${b.v} 를 승인합니다(검사 실패 릴리스는 거부됨)`, type: 'checkbox' }]))) return; await call('POST', `/ops/api/releases/${b.v}/approve`); toast('승인했습니다'); reload(); },
|
||||
'module-deploy': async (b) => { const sv = await api('GET', '/ops/api/servers'); const v = await ask(`${b.m} ${b.v} 배포`, [{ name: 'serverIds', label: '대상 서버 id(쉼표, 비우면 해당 역할 전체)', hint: sv.servers.map((s) => `${s.id}=${s.name}(${s.role},${s.status})`).join(' ') }], { note: 'HAL 이 먼저 배포되어 있어야 하며(§32.2), 대상별로 결과가 따로 보입니다. 일부 실패는 전체 성공으로 보이지 않습니다.' }); if (!v) return; const r = await call('POST', `/ops/api/modules/${b.m}/deploy`, { version: b.v, serverIds: v.serverIds ? v.serverIds.split(',').map((s) => s.trim()).filter(Boolean) : [], nonce: Date.now() }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-deploy': async (b) => { const [sv, rel] = await Promise.all([api('GET', '/ops/api/servers'), api('GET', '/ops/api/releases')]); const roles = (rel.guides && rel.guides[b.m] && rel.guides[b.m].roles) || []; const eligible = sv.servers.filter((x) => x.status === 'ready' && x.agentId && (b.m === 'agent' || roles.includes(x.role))).map((x) => x.id);
|
||||
const v = await ask(`${b.m} ${b.v} 배포 — 대상 서버 선택`, [{ name: 'serverIds', label: `대상 서버(역할 ${roles.join('·') || '전체'}, 준비된 서버만 선택 가능)`, type: 'checks', html: R.serverChoiceHtml(sv.servers, { name: 'serverIds', multi: true, eligible }) }], { note: 'HAL 이 릴리스보다 오래되지 않아야 하며(HAL 은 배포 대상이 아님), 대상별로 결과가 따로 보입니다. 일부 실패는 전체 성공으로 보이지 않습니다. 아래 작업 진행 창에서 진행 상황을 볼 수 있습니다.' }); if (!v) return;
|
||||
if (!v.serverIds || !v.serverIds.length) return toast('대상 서버를 하나 이상 선택하세요'); const r = await call('POST', `/ops/api/modules/${b.m}/deploy`, { version: b.v, serverIds: v.serverIds, nonce: Date.now() }); toast(`배포 작업을 만들었습니다(대상 ${r.targets ? r.targets.length : v.serverIds.length}대)`); location.href = '/ops/jobs/' + r.job.id; },
|
||||
// [0.3.17] 역할별 통합 배포: 프로필·릴리스·서버(라디오)
|
||||
provision: async () => { const [sv, pr, rel] = await Promise.all([api('GET', '/ops/api/servers'), api('GET', '/ops/api/provision/profiles'), api('GET', '/ops/api/releases')]); const versions = (rel.releases || []).filter((x) => x.status === 'approved').map((x) => x.version); if (!versions.length) return toast('승인된 릴리스가 없습니다');
|
||||
const v = await ask('역할별 통합 배포(새 서버)', [{ name: 'profile', label: '서버 역할(프로필)', type: 'select', options: pr.profiles.map((x) => ({ value: x.id, label: `${x.label} — ${x.modules.join(' → ')}` })) }, { name: 'version', label: '릴리스', type: 'select', options: versions }, { name: 'serverId', label: '대상 서버(준비된 서버만)', type: 'radio', required: true, html: R.serverChoiceHtml(sv.servers, { name: 'serverId' }) }], { note: '프로필의 모듈을 순서대로(agent → … → HawkEye) 같은 서버에 배포하고 역할·프로필·슬롯을 기록합니다. 포털은 하나만 — 이미 있으면 "포털 이전"을 쓰세요.' }); if (!v) return;
|
||||
const r = await call('POST', '/ops/api/provision', { ...v, nonce: Date.now() }); toast(`통합 배포 작업을 만들었습니다(${r.modules.join(' → ')})`); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'portal-relocate': async () => { const [sv, rel] = await Promise.all([api('GET', '/ops/api/servers'), api('GET', '/ops/api/releases')]); const cur = (rel.targets || []).find((t) => t.module === 'portal' && t.active !== 0); if (!cur) return toast('활성 포털이 없습니다 — 통합 배포(포털 서버)로 설치하세요'); const versions = (rel.releases || []).filter((x) => x.status === 'approved').map((x) => x.version);
|
||||
const v = await ask(`포털 이전 — 현재 ${(rel.servers && rel.servers[cur.server_id] && rel.servers[cur.server_id].name) || cur.server_id}`, [{ name: 'version', label: '릴리스', type: 'select', options: versions }, { name: 'toServerId', label: '새 포털 서버', type: 'radio', required: true, html: R.serverChoiceHtml(sv.servers.filter((x) => x.id !== cur.server_id), { name: 'toServerId' }) }], { danger: true, note: '새 서버에 포털을 설치한 뒤 데이터·DNS 전환을 운영자가 마치고 재개하면 옛 포털을 비활성으로 표시합니다.' }); if (!v) return;
|
||||
const c = await threeStep({ title: '포털 이전', impacts: [`새 서버 ${v.toServerId} 에 포털 ${v.version} 을 설치합니다`, '데이터 이전·DNS 전환은 운영자가 직접 수행한 뒤 작업을 재개합니다', `옛 서버 ${cur.server_id} 의 포털은 재개 뒤 비활성으로 표시됩니다`], identifiers: [cur.server_id, v.toServerId], revertible: '전환 전에는 옛 포털이 그대로 동작합니다' }); if (!c) return;
|
||||
const r = await call('POST', '/ops/api/portal/relocate', { fromServerId: cur.server_id, toServerId: v.toServerId, version: v.version, ...c }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-health': async (b) => { const r = await call('POST', `/ops/api/modules/${b.m}/health`, { serverIds: b.s ? [b.s] : null }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-rollback': async (b) => { const c = await threeStep({ title: `${b.m} 되돌리기`, impacts: [`서버 ${b.s} 의 ${b.m} 을(를) 이전 릴리스로 되돌립니다`, '되돌리는 동안 해당 모듈이 잠시 중단될 수 있습니다', '이전 릴리스 디렉터리가 남아 있어야 합니다(최근 3개 보관)'], identifiers: [b.s], revertible: '되돌린 뒤 다시 배포하면 복구할 수 있습니다' }); if (!c) return; const r = await call('POST', `/ops/api/modules/${b.m}/rollback`, { ...c, serverIds: [b.s] }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-reconcile': async () => { const r = await call('POST', '/ops/api/modules/reconcile', {}); location.href = '/ops/jobs/' + r.job.id; },
|
||||
@@ -142,6 +153,12 @@ const ACT = {
|
||||
};
|
||||
document.addEventListener('click', (e) => { const el = e.target.closest('[data-act]'); if (!el) return; e.preventDefault(); const fn = ACT[el.dataset.act]; if (fn) guarded(() => fn(el.dataset)); });
|
||||
document.addEventListener('submit', (e) => { const f = e.target.closest('[data-form=customer-search]'); if (f) { e.preventDefault(); location.href = '/ops/customers?q=' + encodeURIComponent(new FormData(f).get('q') || ''); } });
|
||||
// ---------------- [0.3.17] 작업 진행 창(dock): 5초마다 진행 중·히스토리를 받아 바뀐 부분만 갱신
|
||||
let dockTab = 'active';
|
||||
async function loadDock() { const body = $('#ops-dock-body'); if (!body) return; try { const [a, h] = await Promise.all([api('GET', '/ops/api/jobs?status=queued,running,awaiting_external,needs_user,reverting,verifying,paused&limit=50'), api('GET', '/ops/api/jobs?limit=30')]); const active = a.jobs || []; const done = (h.jobs || []).filter((j) => ['done', 'failed', 'cancelled', 'reverted'].includes(j.status)); patchHtml(body, R.jobsDockHtml({ active, history: done, tab: dockTab })); const c = $('#ops-dock-count'); if (c && c.textContent !== String(active.length)) { c.textContent = String(active.length); c.className = 'badge ' + (active.length ? 'running' : 'neutral'); } } catch (e) { patchHtml(body, R.relayError(e)); } }
|
||||
ACT['dock-tab'] = (b) => { dockTab = b.tab; document.querySelectorAll('[data-act=dock-tab]').forEach((x) => x.classList.toggle('on', x.dataset.tab === dockTab)); document.querySelectorAll('#ops-dock .dock-list').forEach((x) => { x.hidden = x.dataset.tab !== dockTab; }); };
|
||||
ACT['dock-toggle'] = () => { const d = $('#ops-dock'); d.classList.toggle('closed'); const b = $('[data-act=dock-toggle]'); if (b) b.setAttribute('aria-expanded', String(!d.classList.contains('closed'))); try { localStorage.setItem('aw_ops_dock', d.classList.contains('closed') ? 'closed' : 'open'); } catch (_) {} };
|
||||
if ($('#ops-dock')) { try { if (localStorage.getItem('aw_ops_dock') === 'closed') $('#ops-dock').classList.add('closed'); } catch (_) {} loadDock(); setInterval(() => { if (!document.hidden) loadDock(); }, 5000); }
|
||||
// ---------------- 부팅
|
||||
const main = document.querySelector('[data-page]'); const page = main?.dataset.page; const id = main?.dataset.id;
|
||||
if (page && PAGES[page]) { guarded(() => PAGES[page](id)); if (['overview', 'jobs', 'job', 'servers'].includes(page)) setInterval(() => { if (!document.hidden) guarded(() => PAGES[page](id)); }, 15000); }
|
||||
|
||||
+36
-14
@@ -42,7 +42,7 @@ export function agentJobsTable(rows) { return table([{ l: 'Agent 작업', f: (r)
|
||||
/** 서버 표(§28.1) */
|
||||
export function serversTable(servers) {
|
||||
return table([
|
||||
{ l: '서버', f: (s) => `<a href="/ops/servers/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.role)} · ${esc(s.location || '위치 미입력')}</span>` },
|
||||
{ l: '서버', f: (s) => `<a href="/ops/servers/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(s.location || '위치 미입력')}</span>${s.slots && Object.keys(s.slots).length ? `<br><span class="muted small">슬롯 ${esc(Object.entries(s.slots).map(([k, v]) => `${k} ${s.slotsFree && s.slotsFree[k] != null ? s.slotsFree[k] + '/' : ''}${v}`).join(', '))}</span>` : ''}` },
|
||||
{ l: '연결', f: (s) => `${badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : s.status === 'pending' ? 'waiting' : 'warn', SERVER_LABEL[s.status] || s.status)}<br><span class="muted">마지막 확인 ${esc(ago(s.lastSeenAt))}${s.stale ? ' · 정보 지연' : ''}</span>` },
|
||||
{ l: '건강', f: (s) => healthBadge(s.stale ? 'stale' : s.health) },
|
||||
{ l: 'CPU·메모리', f: (s) => s.metrics ? `${esc(String(s.metrics.cpu ?? '—'))}% · ${esc(String(s.metrics.memPct ?? pct(s.metrics.memUsed, s.metrics.memTotal) ?? '—'))}%` : '정보 없음' },
|
||||
@@ -81,21 +81,42 @@ export function metricsSvg(samples, key = 'cpu', { w = 720, h = 160 } = {}) {
|
||||
return `<svg viewBox="0 0 ${w} ${h}" class="chart" role="img" aria-label="${esc(label)} 추이 ${rows.length}개 표본"><text x="4" y="12" class="lbl">${esc(label)}</text><line x1="30" y1="${h - 20}" x2="${w - 10}" y2="${h - 20}" class="axis"/><line x1="30" y1="20" x2="30" y2="${h - 20}" class="axis"/><text x="2" y="24" class="lbl">${key === 'net' ? bytes(max) : '100%'}</text><polyline points="${pts}" fill="none" class="line"/><text x="30" y="${h - 4}" class="lbl">${fmt(rows[0].at)}</text><text x="${w - 150}" y="${h - 4}" class="lbl">${fmt(rows[rows.length - 1].at)}</text></svg>`;
|
||||
}
|
||||
/** 릴리스 화면(§32.1) */
|
||||
/**
|
||||
* [0.3.17] 배포 릴리스 화면: 기본은 "모듈별 최신 릴리스 배포" — 모듈마다 최신 승인 릴리스·기능 설명·필요한 것(requires)·제공(provides)·설치 방법·현재 배치, 상호 연결, 역할별 통합 배포.
|
||||
* HAL 은 배포 대상이 아니다(먼저 설치하는 도구). 과거 버전은 접힌 표에서 보고 대상별 되돌리기로 돌아간다.
|
||||
*/
|
||||
export function releasesView(data, changelog = {}) {
|
||||
const running = data.running || {}; const targets = data.targets || [];
|
||||
const byModule = (v) => (data.modules || []).map((m) => { const ts = targets.filter((t) => t.module === m); const onV = ts.filter((t) => t.actual_version === v).length; return `${esc(m)}: ${ts.length ? `${onV}/${ts.length}` : '<span class="muted">배치 없음</span>'}`; }).join(' · ');
|
||||
return `<p class="muted">배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')} — <b>HAL 을 먼저 배포하고 HAL 자체 건강·실제 실행 버전을 확인한 뒤에만 나머지를 배포합니다(§32.2)</b>. HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'}</p>
|
||||
${table([
|
||||
{ l: '버전', f: (r) => `<b>${esc(r.version)}</b>${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 중') : ''}` }, { l: 'main 커밋', f: (r) => `<span class="mono">${esc(String(r.main_commit).slice(0, 12))}</span>` }, { l: '빌드', f: (r) => fmt(r.built_at) },
|
||||
const running = data.running || {}; const targets = data.targets || []; const servers = data.servers || {}; const latest = data.latest || {}; const guides = data.guides || {}; const modules = data.modules || []; const approved = (data.releases || []).filter((r) => r.status === 'approved');
|
||||
const sname = (id) => (servers[id] && servers[id].name) || id;
|
||||
const placed = (m) => { const ts = targets.filter((t) => t.module === m); return ts.length ? ts.map((t) => `<a href="/ops/servers/${esc(t.server_id)}">${esc(sname(t.server_id))}</a> ${t.actual_version ? `<code>${esc(t.actual_version)}</code>` : '<span class="muted">버전 없음</span>'}${t.target_version && t.actual_version !== t.target_version ? ` <span class="err">(목표 ${esc(t.target_version)})</span>` : ''}${t.active === 0 ? ' <span class="badge neutral">비활성</span>' : ''} ${healthBadge(t.health)}`).join('<br>') : '<span class="muted">배치 없음</span>'; };
|
||||
const cards = modules.map((m) => { const g = guides[m] || { label: m, description: '', requires: [], provides: [], install: '' }; const v = latest[m]; return `<div class="card rel-card" data-module="${esc(m)}"><h3>${esc(g.label)} <span class="muted small">(${esc(m)})</span> ${v ? badge('ok', `최신 ${v}`) : badge('warn', '승인된 릴리스 없음')}</h3><p class="small">${esc(g.description)}</p>
|
||||
<dl class="kv small"><dt>필요한 것</dt><dd>${(g.requires || []).map(esc).join(', ') || '—'}</dd><dt>제공</dt><dd>${(g.provides || []).map(esc).join(', ') || '—'}</dd><dt>서버 역할</dt><dd>${(g.roles || []).map(esc).join('·')}</dd><dt>설치 방법</dt><dd>${esc(g.install)}</dd><dt>현재 배치</dt><dd>${placed(m)}</dd></dl>
|
||||
<div class="actions">${v ? `<button class="btn primary small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(v)}">${esc(g.label)} ${esc(v)} 배포(서버 선택)</button>` : ''}${g.singleton ? ` <button class="btn small" data-act="portal-relocate">포털 이전</button>` : ''}</div></div>`; }).join('');
|
||||
const history = table([
|
||||
{ l: '버전', f: (r) => `<b>${esc(r.version)}</b>${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 버전') : ''}` }, { l: 'main 커밋', f: (r) => `<span class="mono">${esc(String(r.main_commit).slice(0, 12))}</span>` }, { l: '빌드', f: (r) => fmt(r.built_at) },
|
||||
{ l: '검사', f: (r) => r.checks ? (r.checks.testsPassed === false ? badge('failed', '테스트 실패') : r.checks.testsPassed ? badge('ok', '테스트 통과') : badge('neutral', '검사 없음')) + (r.checks.dirty ? ' ' + badge('warn', '수정본(dirty)') : '') : badge('neutral', '검사 없음') },
|
||||
{ l: '승인', f: (r) => r.status === 'approved' ? badge('ok', `승인 ${r.approved_by || ''}`) + `<br><span class="muted">${fmt(r.approved_at)}</span>` : badge('waiting', '승인 대기') + ` <button class="btn" data-act="release-approve" data-v="${esc(r.version)}">승인</button>` },
|
||||
{ l: 'HAL 배포', f: (r) => r.hal_deployed_at ? badge('done', fmt(r.hal_deployed_at)) : badge('neutral', '아직') },
|
||||
{ l: '모듈별 배치', f: (r) => byModule(r.version) },
|
||||
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">VERSION_HISTORY 항목 없음</span>' },
|
||||
{ l: '배포', f: (r) => r.status === 'approved' ? (data.modules || []).map((m) => `<button class="btn small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(r.version)}">${esc(m)}</button>`).join(' ') : '<span class="muted">승인 후</span>' },
|
||||
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' })}
|
||||
<h2>모듈 배치 상태(대상별)</h2>${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `<a href="/ops/servers/${esc(t.server_id)}" class="mono">${esc(t.server_id)}</a>` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `<span class="err">${esc(t.actual_version || '없음')}</span>` : esc(t.actual_version || '—') }, { l: '건강', f: (t) => healthBadge(t.health) }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `<span class="err">${esc(t.last_error || '')}</span>` }, { l: '', f: (t) => `<button class="btn small" data-act="module-health" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">건강 확인</button> <button class="btn small danger" data-act="module-rollback" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">되돌리기</button>` }], targets, { empty: '배치된 모듈이 없습니다' })}
|
||||
<div class="actions"><button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>`;
|
||||
{ l: '모듈별 배치', f: (r) => modules.map((m) => { const ts = targets.filter((t) => t.module === m); const onV = ts.filter((t) => t.actual_version === r.version).length; return `${esc(m)}: ${ts.length ? `${onV}/${ts.length}` : '<span class="muted">—</span>'}`; }).join(' · ') },
|
||||
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">항목 없음</span>' },
|
||||
{ l: '이 버전 배포', f: (r) => r.status === 'approved' ? modules.filter((m) => r.artifacts && (r.artifacts[m] || (guides[m] && /Agent 안의/.test(guides[m].description)))).map((m) => `<button class="btn small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(r.version)}">${esc(m)}</button>`).join(' ') : '<span class="muted">승인 후</span>' },
|
||||
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' });
|
||||
return `<p class="muted">HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'} — <b>HAL 은 배포 대상이 아닙니다</b>(Suite 를 설치하기 전에 먼저 설치하는 도구이며 별도 절차로 갱신). 모듈 배포는 HAL 이 릴리스보다 오래되지 않으면 진행됩니다. 배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')}.</p>
|
||||
<h2>최신 릴리스 배포(모듈별)</h2><div class="cards rel-cards">${cards}</div>
|
||||
<div class="actions"><button class="btn primary" data-act="provision">역할별 통합 배포(새 서버)</button> <button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>
|
||||
<h2>상호 연결(어떤 모듈이 있어야 무엇이 동작하는가)</h2><ul class="small">${(data.links || []).map((l) => `<li>${esc(l)}</li>`).join('')}</ul>
|
||||
<h2>역할별 서버 프로필</h2>${table([{ l: '프로필', f: (p) => `<b>${esc(p.label)}</b> <span class="muted small">(${esc(p.id)})</span>` }, { l: '역할', k: 'role' }, { l: '배포 모듈(순서)', f: (p) => p.modules.map(esc).join(' → ') }, { l: '설명', f: (p) => esc(p.description) + (p.singleton ? ' <span class="badge warn">하나만</span>' : '') }, { l: '슬롯', f: (p) => esc(Object.entries(p.slots || {}).map(([k, v]) => `${k} ${v}`).join(', ') || '—') }], data.profiles || [], { empty: '프로필 없음' })}
|
||||
<details class="rel-history"><summary><b>과거 버전 보기</b> — 등록된 릴리스 ${(data.releases || []).length}개(승인 ${approved.length}). 이전 버전으로 돌아가려면 아래 배치 상태의 "되돌리기" 또는 그 버전의 배포 단추를 쓰세요.</summary>${history}</details>
|
||||
<h2>모듈 배치 상태(대상별)</h2>${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `<a href="/ops/servers/${esc(t.server_id)}">${esc(sname(t.server_id))}</a> <span class="muted small mono">${esc(t.server_id)}</span>` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `<span class="err">${esc(t.actual_version || '없음')}</span>` : esc(t.actual_version || '—') }, { l: '건강', f: (t) => healthBadge(t.health) + (t.active === 0 ? ' ' + badge('neutral', '비활성') : '') }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `<span class="err">${esc(t.last_error || '')}</span>` }, { l: '', f: (t) => `<button class="btn small" data-act="module-health" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">건강 확인</button> <button class="btn small danger" data-act="module-rollback" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">되돌리기</button>` }], targets, { empty: '배치된 모듈이 없습니다' })}`;
|
||||
}
|
||||
/** [0.3.17] 작업 진행 창(dock): 진행 중 작업과 최근 히스토리. 두 목록을 모두 그리고 탭은 hidden 으로 전환한다. */
|
||||
export function jobsDockHtml({ active = [], history = [], tab = 'active' } = {}) {
|
||||
const row = (j) => `<div class="dock-job" data-job="${esc(j.id)}"><a href="/ops/jobs/${esc(j.id)}"><b>${esc(j.label || j.kind)}</b></a> <span class="muted small">${esc(j.kind)}</span> ${jobBadge(j.status)}${j.currentStep ? ` <span class="small">${esc(j.currentStep.title)}</span>` : ''}${j.progress != null && !['done', 'failed', 'cancelled'].includes(j.status) ? ` <span class="dock-bar"><span class="dock-bar-in" style="width:${Math.max(0, Math.min(100, Number(j.progress) || 0))}%"></span></span> <span class="small">${Math.max(0, Math.min(100, Number(j.progress) || 0))}%</span>` : ''}${j.error ? ` <span class="err small">${esc(j.error)}</span>` : ''} <span class="muted small">${esc(ago(j.updatedAt))}</span></div>`;
|
||||
return `<div class="dock-list" data-tab="active"${tab !== 'active' ? ' hidden' : ''}>${active.length ? active.map(row).join('') : '<div class="muted small">진행 중인 작업이 없습니다.</div>'}</div><div class="dock-list" data-tab="history"${tab !== 'history' ? ' hidden' : ''}>${history.length ? history.map(row).join('') : '<div class="muted small">최근 작업 기록이 없습니다.</div>'}</div>`;
|
||||
}
|
||||
/** [0.3.17] 대상 서버 선택 목록: 라디오(단일) 또는 체크(여러 개). 준비(ready)되지 않은 서버는 고를 수 없다. eligible 이 있으면 그 서버만 미리 체크. */
|
||||
export function serverChoiceHtml(servers, { name = 'serverId', multi = false, eligible = null } = {}) {
|
||||
if (!servers || !servers.length) return '<div class="aw-state">선택할 서버가 없습니다 — 먼저 서버를 등록하고 Agent 를 연결·승인하세요.</div>';
|
||||
return `<div class="choice-list">${servers.map((s) => { const ok = s.status === 'ready'; const checked = multi && ok && (!eligible || eligible.includes(s.id)); return `<label class="choice${ok ? '' : ' off'}"><input type="${multi ? 'checkbox' : 'radio'}" name="${esc(name)}" value="${esc(s.id)}"${ok ? '' : ' disabled'}${checked ? ' checked' : ''}> <b>${esc(s.name)}</b> <span class="muted small">${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(SERVER_LABEL[s.status] || s.status)}${s.agentVersion ? ' · Agent ' + esc(s.agentVersion) : ' · Agent 없음'}${s.modules && Object.keys(s.modules).length ? ' · ' + Object.entries(s.modules).map(([m, x]) => `${esc(m)} ${esc(x.actual || '—')}`).join(', ') : ''}</span></label>`; }).join('')}</div>`;
|
||||
}
|
||||
export function incidentsView(incidents, notifications = []) {
|
||||
return `<h2>사건(같은 원인은 하나로 묶임)</h2>${table([{ l: '사건', f: (i) => `<b>${esc(i.title)}</b><br><span class="mono muted">${esc(i.key)}</span>` }, { l: '심각도', f: (i) => badge(i.severity === 'critical' ? 'failed' : i.severity === 'warning' ? 'warn' : 'info', i.severity) }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음(내부)') }, { l: '시작·종료', f: (i) => `${fmt(i.started_at)}<br>${i.resolved_at ? '종료 ' + fmt(i.resolved_at) + (i.verified_at ? ' (사용자 경로 확인)' : '') : badge('running', '대응 중')}` }, { l: '담당', k: 'assignee' }, { l: '공개', f: (i) => i.public ? badge('info', '공개 상태 페이지') + `<br><span class="muted">${esc(i.public_note || '')}</span>` : '비공개' }, { l: '', f: (i) => i.resolved_at ? '' : `<button class="btn small" data-act="incident-note" data-id="${esc(i.id)}">메모</button> <button class="btn small" data-act="incident-public" data-id="${esc(i.id)}">고객 공지</button> <button class="btn small" data-act="incident-resolve" data-id="${esc(i.id)}">종료(사용자 경로 확인 후)</button>` }], incidents, { empty: '진행 중이거나 기록된 사건이 없습니다' })}
|
||||
@@ -129,7 +150,8 @@ export function accountsView(data) {
|
||||
}
|
||||
/** 운영 현황(§26.1) */
|
||||
export function overviewView(o) {
|
||||
const comp = (c) => `<div class="card comp"><b>${esc(c.name)}</b> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
|
||||
const COMP_LABEL = { HAL: 'HAL', portal: '고객 포털', webmaker: 'Webmaker', arimail: 'AriMail', backup: '백업', hawkeye: 'HawkEye' }; // [0.3.17] 구성요소 표시 이름(observability → HawkEye)
|
||||
const comp = (c) => `<div class="card comp"><b>${esc(COMP_LABEL[c.name] || c.name)}</b> <span class="muted small">${COMP_LABEL[c.name] && COMP_LABEL[c.name] !== c.name ? esc(c.name) : ''}</span> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
|
||||
const jobs = o.jobs || {}; const srv = o.servers || {};
|
||||
return `<div class="cards">${(o.components || []).map(comp).join('')}</div>
|
||||
<div class="cards"><div class="card"><h2>서버</h2>${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `<span class="err">${srv.offline}대</span>` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `<span class="err">${srv.diskWarning}대</span>` : '없음']])}<a class="btn" href="/ops/servers">서버와 용량</a></div>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ariweb-suite",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.17",
|
||||
"private": true,
|
||||
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* test/hal-0317.test.mjs — Ariweb Suite 0.3.17(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.17.md`, Oz 2026-10-29).
|
||||
*
|
||||
* 계약(요약)
|
||||
* 1) observability → HawkEye(`hawkeye`): 모듈 기술서·배포 순서·운영 현황 구성요소 이름 변경, 옛 이름은 별칭으로 받고 DB 행은 마이그레이션으로 바꾼다.
|
||||
* 2) 역할별 통합 배포(`server.provision`): 프로필(webmaker·webhosting·arimail·backup·portal)에 따라 필요한 모듈을 한 번에 순서대로 배포한다. 포털은 하나만(이미 있으면 설치 거부, 이전 작업 `portal.relocate`).
|
||||
* 서버 슬롯(slots)을 두고 배치(pickServer)가 여유 슬롯을 본다.
|
||||
* 3) 화면 갱신: 주기 갱신은 바뀐 부분만 바꾼다(순수 diff + ops.js 정적 계약).
|
||||
* 4) 배포 릴리스: HAL 은 배포 대상이 아니다(`hal_not_deployable`), HAL 먼저 규칙은 "HAL 이 릴리스보다 오래되지 않으면" 통과. 기본 화면은 모듈별 최신 릴리스·설치 안내·상호 연결·배치 상태, 과거 버전은 접어서.
|
||||
* 5) 작업 진행 창(dock): 공통 틀 아래에 진행 중·히스토리.
|
||||
* 6) 배포 점검: 대상 서버를 비우면 역할에 맞는 준비된 서버 전체로 풀고, 하나도 없으면 API 에서 바로 `no_targets`.
|
||||
* 7) 대상 서버 목록·선택(라디오/체크) 렌더러.
|
||||
*/
|
||||
import { test, eq, ok, tmpDir, done, sleep } from '../lib/test.mjs';
|
||||
import { invoke } from '../lib/http.mjs';
|
||||
import { createHalDeps } from '../hal/deps.mjs';
|
||||
import { buildApp } from '../hal/app.mjs';
|
||||
import * as core from '../hal/core.mjs';
|
||||
import * as ops from '../hal/ops.mjs';
|
||||
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES, descriptor, SERVER_PROFILES } from '../hal/modules/index.mjs';
|
||||
import { MIGRATIONS } from '../hal/schema.mjs';
|
||||
import * as R from '../hal/ui/render.mjs';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const rnd = (p = 'fx') => `${p}-${randomBytes(12).toString('hex')}`;
|
||||
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, agentPollMs: 20, agentWaitTimeoutMs: 4000, fanoutTimeoutMs: 6000, values: {}, set(p) { Object.assign(this, p); } };
|
||||
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => ({ ok: true, status: 200, text: async () => 'ok', json: async () => ({ ok: true }) }) }); d.jobsRunning = true;
|
||||
return { d, app: buildApp(d), dataDir };
|
||||
}
|
||||
async function loginOp(h) { const pw = rnd('pw'); ops.createAccount(h.d, { login: 'ozops', password: pw, displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } }); ok(r.json.ok); return { pw, as: (m, p, b) => invoke(h.app, m, p, { body: b, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) }; }
|
||||
/** 서버 + Agent(등록·승인·heartbeat) */
|
||||
async function addServer(h, { name, role, sitesHosted = 0 }) {
|
||||
const s = core.createServer(h.d, { name, role }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: s.id });
|
||||
const r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: name, nginx: true }, version: '0.1.1' } }); ok(r.json.ok, JSON.stringify(r.json));
|
||||
h.d.store.run('UPDATE servers SET last_seen_at=?, capacity_json=? WHERE id=?', new Date().toISOString(), JSON.stringify({ diskFreeBytes: 50e9, diskTotalBytes: 100e9, sitesHosted, reservedBytes: 0 }), s.id); core.approveServer(h.d, s.id, 'op');
|
||||
return { serverId: s.id, agentId: r.json.agentId };
|
||||
}
|
||||
function bootstrapDone(d, version) { core.claimLive(d, { instanceId: d.instanceId, version }); core.setBootstrap(d, { release_version: version, main_commit: 'abc', artifact_sha256: 'x', first_agent_id: d.store.get('SELECT id FROM agents LIMIT 1')?.id || 'ag_x', first_agent_registered_at: new Date().toISOString(), completed_at: new Date().toISOString() }); }
|
||||
function addRelease(h, version, modules = ['agent', 'portal', 'webmaker', 'arimail']) {
|
||||
const art = join(h.dataDir, 'artifacts', version); mkdirSync(art, { recursive: true }); const artifacts = {};
|
||||
for (const m of modules) { const f = `${m}-${version}.tar.gz`; writeFileSync(join(art, f), m + version); artifacts[m] = { file: f, sha256: createHash('sha256').update(m + version).digest('hex'), size: (m + version).length }; }
|
||||
core.registerRelease(h.d, { version, mainCommit: 'abc', builtAt: new Date().toISOString(), artifacts, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] }, approve: true, by: 'op' });
|
||||
}
|
||||
/** Agent 흉내: 모든 Agent 의 작업을 받아 결과를 낸다(부모 작업이 끝날 때까지) */
|
||||
async function runAgents(h, agents, result = (job) => ({ status: 'done', result: { module: job.payload.module, actualVersion: job.payload.version || '0.1.1', health: { ok: true }, active: true } }), until = () => false, maxMs = 8000) {
|
||||
const t0 = Date.now(); const ps = [];
|
||||
while (Date.now() - t0 < maxMs) { ps.push(h.d.jobs.tick().catch(() => {})); for (const a of agents) { const j = core.nextAgentJob(h.d, a.agentId); if (j) core.agentJobResult(h.d, j.id, a.agentId, result(j)); } if (until()) break; if (!h.d.jobs.list({ status: ['queued', 'running'], limit: 5 }).length) { await sleep(30); if (!h.d.jobs.list({ status: ['queued', 'running'], limit: 5 }).length) break; } await sleep(15); }
|
||||
await Promise.all(ps);
|
||||
}
|
||||
|
||||
await test('1) observability → HawkEye: 기술서·배포 순서·별칭·운영 현황 구성요소·DB 마이그레이션', async () => {
|
||||
ok(MODULES.hawkeye && MODULES.hawkeye.label === 'HawkEye' && MODULES.hawkeye.layout === 'agent-feature', JSON.stringify(MODULES.hawkeye)); ok(!MODULES.observability, '옛 키 없음');
|
||||
eq(descriptor('observability').name, 'hawkeye', '옛 이름 별칭'); eq(DEPLOY_ORDER[DEPLOY_ORDER.length - 1], 'hawkeye'); ok(!DEPLOY_ORDER.includes('hal') && DEPLOY_ORDER[0] === 'agent', 'HAL 은 배포 순서에 없고 agent 가 먼저');
|
||||
eq(DEPLOYABLE_MODULES.includes('hal'), false); ok(DEPLOYABLE_MODULES.includes('hawkeye'));
|
||||
const h = makeHal(); const op = await loginOp(h);
|
||||
// 마이그레이션: observability 행 → hawkeye (새 DB 에는 이미 적용되었으므로 옛 행을 넣고 그 마이그레이션 SQL 만 다시 실행)
|
||||
const mig = MIGRATIONS.find((m) => m.id === 'hal_0008_hawkeye'); ok(mig && /hawkeye/.test(mig.sql));
|
||||
h.d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?)', 'observability', 'srv_old', '0.1.1', 'ok', 1, 'x', 'x'); h.d.store.run('INSERT INTO modules(name, target_version, updated_at) VALUES(?,?,?)', 'observability', '0.1.1', 'x');
|
||||
h.d.store.exec(mig.sql); eq(h.d.store.all('SELECT module FROM module_targets WHERE server_id=?', 'srv_old').map((r) => r.module), ['hawkeye']); eq(h.d.store.get('SELECT name FROM modules WHERE name=?', 'hawkeye').name, 'hawkeye'); ok(!h.d.store.get('SELECT name FROM modules WHERE name=?', 'observability'));
|
||||
const r = await op.as('GET', '/ops/api/overview'); ok(r.json.components.some((c) => c.name === 'hawkeye') && !r.json.components.some((c) => c.name === 'observability'), JSON.stringify(r.json.components.map((c) => c.name)));
|
||||
const st = await invoke(h.app, 'GET', '/api/status'); ok(st.json.components.some((c) => /HawkEye/.test(c.name)), JSON.stringify(st.json.components));
|
||||
const ov = await op.as('GET', '/ops/'); ok(ov.text.includes('HawkEye'));
|
||||
});
|
||||
|
||||
await test('4·6) 배포 릴리스: HAL 은 배포 대상 아님, HAL 먼저 규칙은 "HAL 이 더 오래되지 않으면" 통과, 대상 서버 비우면 역할 전체로, 없으면 즉시 no_targets', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h); const web = await addServer(h, { name: '웹1', role: 'web' }); const mail = await addServer(h, { name: '메일1', role: 'mail' }); bootstrapDone(h.d, '0.3.17'); addRelease(h, '0.3.10'); addRelease(h, '0.3.17');
|
||||
let r = await op.as('GET', '/ops/api/releases'); ok(!r.json.modules.includes('hal') && r.json.modules.includes('hawkeye'), JSON.stringify(r.json.modules)); eq(r.json.deployOrder[0], 'agent');
|
||||
ok(r.json.latest && r.json.latest.portal === '0.3.17' && r.json.latest.agent === '0.3.17', JSON.stringify(r.json.latest)); ok(r.json.guides && r.json.guides.webmaker && /agent/.test(r.json.guides.webmaker.requires.join(',')) && r.json.guides.portal.singleton === true, JSON.stringify(r.json.guides && r.json.guides.webmaker));
|
||||
ok(r.json.profiles && r.json.profiles.some((p) => p.id === 'webhosting'), '프로필');
|
||||
r = await op.as('POST', '/ops/api/modules/hal/deploy', { version: '0.3.17', serverIds: [web.serverId] }); eq(r.status, 400); eq(r.json.error, 'hal_not_deployable');
|
||||
// 대상 비움 → 역할에 맞는 서버 전체(portal: control·web → 웹1), agent: 전체
|
||||
r = await op.as('POST', '/ops/api/modules/portal/deploy', { version: '0.3.17', serverIds: [] }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.job.subject ? r.json.job.subject.serverIds : r.json.targets, [web.serverId]); ok(r.json.targets && r.json.targets.length === 1);
|
||||
r = await op.as('POST', '/ops/api/modules/agent/deploy', { version: '0.3.17', serverIds: [], nonce: '2' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.targets.sort(), [web.serverId, mail.serverId].sort());
|
||||
h.d.store.run('UPDATE servers SET status=? WHERE id=?', 'maintenance', mail.serverId); // mail 서버가 준비 상태가 아니면 arimail 의 기본 대상이 없다
|
||||
r = await op.as('POST', '/ops/api/modules/arimail/deploy', { version: '0.3.17', serverIds: [] }); eq(r.status, 400); eq(r.json.error, 'no_targets'); ok(/arimail/.test(r.json.message)); h.d.store.run('UPDATE servers SET status=? WHERE id=?', 'ready', mail.serverId);
|
||||
// HAL 먼저: HAL 0.3.17 실행 중에 0.3.10 릴리스(더 오래됨) 배포는 precheck 통과(그 뒤 Agent 결과로 진행)
|
||||
const j = h.d.jobs.enqueue({ kind: 'module.deploy', subject: { module: 'portal', version: '0.3.10', serverIds: [web.serverId] }, createdBy: 'op', actorType: 'operator' }).job;
|
||||
await runAgents(h, [web]); const jd = h.d.jobs.get(j.id); ok(jd.error_code !== 'hal_first', `hal_first 아님: ${jd.error_code} ${jd.error}`); eq(jd.status, 'done', JSON.stringify(jd.error));
|
||||
// HAL 이 더 오래됐으면 hal_first
|
||||
core.setBootstrap(h.d, { hal_running_version: '0.3.9' }); const j2 = h.d.jobs.enqueue({ kind: 'module.deploy', subject: { module: 'portal', version: '0.3.10', serverIds: [web.serverId] }, createdBy: 'op', actorType: 'operator', idempotencyKey: 'x2' }).job; await h.d.jobs.drain(); eq(h.d.jobs.get(j2.id).error_code, 'hal_first');
|
||||
// 화면: 기본은 모듈별 최신 릴리스·안내·상호 연결, 과거 버전은 접힘, HAL 배포 단추 없음
|
||||
r = await op.as('GET', '/ops/releases'); eq(r.status, 200); for (const s of ['최신 릴리스', '과거 버전 보기', 'HawkEye', '상호 연결', 'HAL 은 배포 대상이 아닙니다']) ok(r.text.includes(s), '릴리스 화면 ' + s); ok(!r.text.includes('data-m="hal"'), 'HAL 배포 단추 없음'); ok(r.text.includes('data-act="module-deploy"') && r.text.includes('data-m="portal"'));
|
||||
});
|
||||
|
||||
await test('2) 역할별 통합 배포: 프로필대로 모듈을 순서대로 배포하고 서버에 프로필·역할·슬롯을 기록, 포털은 하나만(이미 있으면 거부) + 포털 이전 작업, 슬롯 기반 배치', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h); const s1 = await addServer(h, { name: '새서버', role: 'web' }); const s2 = await addServer(h, { name: '컨트롤', role: 'control' }); bootstrapDone(h.d, '0.3.17'); addRelease(h, '0.3.17', ['agent', 'portal', 'webmaker', 'arimail']);
|
||||
ok(SERVER_PROFILES.webmaker && SERVER_PROFILES.webmaker.modules.includes('webmaker') && SERVER_PROFILES.webmaker.modules.includes('hawkeye') && SERVER_PROFILES.webmaker.modules[0] === 'agent', JSON.stringify(SERVER_PROFILES.webmaker));
|
||||
let r = await op.as('GET', '/ops/api/provision/profiles'); eq(r.status, 200); ok(r.json.profiles.map((p) => p.id).sort().join(',') === 'arimail,backup,portal,webhosting,webmaker');
|
||||
// webmaker 프로필을 새서버에: agent → webmaker → hawkeye 순서로 자식 작업, 서버 profile·role·slots 기록
|
||||
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'webmaker', version: '0.3.17' }); eq(r.status, 200, JSON.stringify(r.json)); const pj = r.json.job;
|
||||
const seen = []; await runAgents(h, [s1, s2], (job) => { seen.push(job.payload.module); return { status: 'done', result: { module: job.payload.module, actualVersion: job.payload.version || '0.1.1', health: { ok: true }, active: true } }; });
|
||||
const done1 = h.d.jobs.get(pj.id); eq(done1.status, 'done', JSON.stringify({ e: done1.error, c: done1.error_code, steps: done1.steps })); eq(seen, ['agent', 'webmaker', 'hawkeye'], '순서');
|
||||
const srv = core.getServer(h.d, s1.serverId); eq(srv.profile, 'webmaker'); eq(srv.role, 'web'); ok(srv.slots && srv.slots.webmaker === 1, JSON.stringify(srv.slots));
|
||||
eq(h.d.store.all('SELECT module, actual_version FROM module_targets WHERE server_id=? ORDER BY module', s1.serverId).map((t) => t.module + ':' + t.actual_version), ['agent:0.3.17', 'hawkeye:0.3.17', 'webmaker:0.3.17']);
|
||||
ok(h.d.jobs.list({ parentId: pj.id, limit: 10 }).length === 3, '자식 작업 3개');
|
||||
// 포털: 컨트롤 서버에 설치 → 다른 서버에 또 설치는 거부(portal_exists)
|
||||
r = await op.as('POST', '/ops/api/provision', { serverId: s2.serverId, profile: 'portal', version: '0.3.17' }); eq(r.status, 200, JSON.stringify(r.json)); await runAgents(h, [s1, s2]); eq(h.d.jobs.get(r.json.job.id).status, 'done');
|
||||
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'portal', version: '0.3.17' }); eq(r.status, 409); eq(r.json.error, 'portal_exists'); ok(/이전/.test(r.json.message));
|
||||
// 포털 이전: 새 서버에 설치 → 운영자 확인(데이터 이전·DNS) 대기 → 재개 → 옛 서버 비활성
|
||||
stale: r = await op.as('POST', '/ops/api/portal/relocate', { fromServerId: s2.serverId, toServerId: s1.serverId, version: '0.3.17', confirmImpact: true, confirmIdentifiers: [s2.serverId, s1.serverId], confirmExecute: true }); eq(r.status, 200, JSON.stringify(r.json)); const rj = r.json.job;
|
||||
await runAgents(h, [s1, s2], undefined, () => h.d.jobs.get(rj.id).status === 'needs_user'); for (let i = 0; i < 100 && h.d.jobs.get(rj.id).status !== 'needs_user'; i++) await sleep(20);
|
||||
eq(h.d.jobs.get(rj.id).status, 'needs_user', JSON.stringify(h.d.jobs.get(rj.id).error)); ok(/DNS|데이터/.test(h.d.jobs.get(rj.id).user_message));
|
||||
eq(h.d.store.get('SELECT actual_version FROM module_targets WHERE module=? AND server_id=?', 'portal', s1.serverId).actual_version, '0.3.17', '새 서버에 설치됨');
|
||||
r = await op.as('POST', `/ops/api/jobs/${rj.id}/resume`); eq(r.status, 200); await runAgents(h, [s1, s2]); eq(h.d.jobs.get(rj.id).status, 'done', JSON.stringify(h.d.jobs.get(rj.id).error));
|
||||
eq(h.d.store.get('SELECT active FROM module_targets WHERE module=? AND server_id=?', 'portal', s2.serverId).active, 0, '옛 포털 비활성'); eq(h.d.store.get('SELECT active FROM module_targets WHERE module=? AND server_id=?', 'portal', s1.serverId).active, 1);
|
||||
// 슬롯 기반 배치: web 서버 두 대 중 슬롯이 찬 서버는 제외
|
||||
const w2 = await addServer(h, { name: '웹2', role: 'web', sitesHosted: 1 }); await op.as('PATCH', `/ops/api/servers/${w2.serverId}`, { slots: { sites: 1 } }); const w3 = await addServer(h, { name: '웹3', role: 'web' }); await op.as('PATCH', `/ops/api/servers/${w3.serverId}`, { slots: { sites: 10 } });
|
||||
await op.as('PATCH', `/ops/api/servers/${s1.serverId}`, { accepts_new: 0 });
|
||||
const pick = core.pickServer(h.d, { role: 'web' }); eq(pick.serverId, w3.serverId, '여유 슬롯 있는 서버'); ok(core.publicServer(h.d, core.getServer(h.d, w2.serverId)).slotsFree.sites === 0);
|
||||
// 모르는 프로필·준비 안 된 서버
|
||||
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'nope', version: '0.3.17' }); eq(r.status, 400);
|
||||
const pend = core.createServer(h.d, { name: '대기', role: 'web' }); r = await op.as('POST', '/ops/api/provision', { serverId: pend.id, profile: 'webhosting', version: '0.3.17' }); eq(r.status, 409); eq(r.json.error, 'server_not_ready');
|
||||
});
|
||||
|
||||
await test('3·5·7) 화면: 부분 갱신 diff(순수)·ops.js 정적 계약, 작업 진행 창(dock), 서버 선택 목록 렌더러', async () => {
|
||||
const P = await import('../hal/ui/public/dom-patch.mjs');
|
||||
eq(P.diffChildren(['<p>a</p>', '<p>b</p>'], ['<p>a</p>', '<p>b</p>']), [], '같으면 변경 없음');
|
||||
eq(P.diffChildren(['<p>a</p>', '<p>b</p>'], ['<p>a</p>', '<p>B</p>', '<p>c</p>']), [{ i: 1, op: 'replace', html: '<p>B</p>' }, { i: 2, op: 'append', html: '<p>c</p>' }]);
|
||||
eq(P.diffChildren(['<p>a</p>', '<p>b</p>', '<p>c</p>'], ['<p>a</p>']), [{ i: 1, op: 'remove' }, { i: 2, op: 'remove' }].reverse(), '뒤에서부터 제거');
|
||||
eq(P.splitTopLevel('<div>x<p>y</p></div><p>z</p>text'), ['<div>x<p>y</p></div>', '<p>z</p>', 'text']);
|
||||
const js = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ops.js'), 'utf8'); ok(js.includes("from './dom-patch.mjs'") && js.includes('patchHtml('), 'ops.js 가 부분 갱신을 쓴다'); ok(!/overview: async \(\) => \{ try \{ const o = await api\('GET', '\/ops\/api\/overview'\); body\(\)\.innerHTML/.test(js), '운영 현황 주기 갱신이 통째로 innerHTML 을 바꾸지 않는다');
|
||||
const ai = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ai.js'), 'utf8'); ok(ai.includes('patchHtml(') || ai.includes('patchMessages('), 'AI 패널도 부분 갱신');
|
||||
// dock
|
||||
const dock = R.jobsDockHtml({ active: [{ id: 'j_1', kind: 'module.deploy', label: 'portal 0.3.17 배포', status: 'running', progress: 40, currentStep: { title: '대상별 결과 확인' }, updatedAt: new Date().toISOString() }], history: [{ id: 'j_0', kind: 'module.health', label: 'x <b>', status: 'done', updatedAt: new Date().toISOString() }] });
|
||||
ok(dock.includes('j_1') && dock.includes('40%') && dock.includes('대상별 결과 확인') && dock.includes('x <b>') && !dock.includes('x <b>'), dock.slice(0, 300));
|
||||
ok(R.jobsDockHtml({ active: [], history: [] }).includes('진행 중인 작업이 없습니다'));
|
||||
// 서버 선택 목록(라디오/체크): 역할·상태·버전 표시, 준비 안 된 서버는 비활성
|
||||
const servers = [{ id: 'srv_a', name: '웹1', role: 'web', status: 'ready', agentVersion: '0.3.10', profile: 'webhosting', modules: { agent: { actual: '0.3.10' } } }, { id: 'srv_b', name: '대기<', role: 'mail', status: 'pending', agentVersion: null, modules: {} }];
|
||||
const radio = R.serverChoiceHtml(servers, { name: 'serverId' }); ok(radio.includes('type="radio"') && radio.includes('value="srv_a"') && radio.includes('대기<') && /disabled/.test(radio) && radio.includes('webhosting'), radio.slice(0, 400));
|
||||
const checks = R.serverChoiceHtml(servers, { name: 'serverIds', multi: true, eligible: ['srv_a'] }); ok(checks.includes('type="checkbox"') && /value="srv_a"[^>]*checked/.test(checks) && !/value="srv_b"[^>]*checked/.test(checks));
|
||||
ok(R.serverChoiceHtml([], { name: 'serverId' }).includes('서버가 없습니다'));
|
||||
// 공통 틀: dock 과 스크립트
|
||||
const h = makeHal(); const op = await loginOp(h); const r = await op.as('GET', '/ops/servers'); for (const s of ['id="ops-dock"', '진행 중', '히스토리', 'data-act="provision"']) ok(r.text.includes(s), '틀 ' + s);
|
||||
const sv = await op.as('GET', '/ops/api/servers'); ok(Array.isArray(sv.json.servers));
|
||||
});
|
||||
|
||||
await test('버전·문서(0.3.17)', async () => {
|
||||
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.17.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.17'));
|
||||
ok(readFileSync(join(ROOT, 'hal', 'ai', 'skill.md'), 'utf8').includes('HawkEye') && !/observability\b(?! 는 옛 이름)/.test(readFileSync(join(ROOT, 'hal', 'ai', 'skill.md'), 'utf8').replace('옛 이름 observability', '')), '스킬 문서도 HawkEye');
|
||||
});
|
||||
done();
|
||||
@@ -58,7 +58,7 @@ await test('버전·문서(0.3.10)', async () => {
|
||||
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('no net'); } }); const app = buildApp(d);
|
||||
const pw = 'pw-' + randomBytes(12).toString('hex'); ops.createAccount(d, { login: 'ozops', password: pw, displayName: '오즈' }); const l = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } });
|
||||
const r = await invoke(app, 'GET', '/ops/ai', { cookies: { aw_ops: l.cookies.aw_ops } }); ok(r.text.includes('복사'), '셸 안내에 복사 방법');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(d.version, '0.3.10');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(d.version, '0.3.17');
|
||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.10.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.10'));
|
||||
});
|
||||
done();
|
||||
|
||||
@@ -192,7 +192,7 @@ await test('화면·버전·문서(0.3.2): AI 연동 화면에 프리셋·사전
|
||||
for (const s of ['OpenRouter', 'Gemini', 'ChatGPT', '기타', '사전 준비', '텍스트 셸', 'MCP', '도구 정책']) ok(r.text.includes(s), '화면 문구 ' + s);
|
||||
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.shellHtml === 'function' && typeof AR.guideHtml === 'function' && typeof AR.toolPolicyHtml === 'function');
|
||||
ok(AR.shellHtml({ id: 's1', status: 'running', tool: 'claude', command: 'login' }, 'Open <https://x> paste').includes('<https://x>'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(h.d.version, '0.3.10');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.2.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.2'));
|
||||
});
|
||||
done();
|
||||
|
||||
@@ -162,7 +162,7 @@ await test('화면·버전·문서(0.3.9): 셸 화면에 원시 키·단말 에
|
||||
eq((await invoke(h.app, 'GET', '/ops/ui/term.mjs')).status, 200);
|
||||
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.modelSelectHtml === 'function' && typeof AR.shellSessionsHtml === 'function' && typeof AR.commandsHelpHtml === 'function');
|
||||
ok(AR.modelSelectHtml({ id: 'c1', model: 'a', models: ['a', 'b<'] }).includes('b<') && AR.modelSelectHtml({ id: 'c1', model: 'a', models: [] }).includes('확인'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(h.d.version, '0.3.10');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.9.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.9'));
|
||||
});
|
||||
done();
|
||||
|
||||
@@ -302,7 +302,7 @@ await test('화면·버전·문서: 공통 틀에 AI 패널(통합 탭 고정)·
|
||||
r = await invoke(h.app, 'GET', '/ops/ui/ai.js'); eq(r.status, 200); r = await invoke(h.app, 'GET', '/ops/ui/ai-render.mjs'); eq(r.status, 200);
|
||||
const AR = await import('../hal/ui/public/ai-render.mjs'); const tabs = AR.tabsHtml([{ id: 'room_group', kind: 'group', title: '통합', fixed: true }, { id: 'r2', kind: 'direct', title: '<네오>', connection: { status: 'active' } }], 'r2'); ok(tabs.indexOf('통합') < tabs.indexOf('<네오>') && !tabs.includes('<네오>'), '통합이 먼저·이스케이프');
|
||||
ok(AR.messageHtml({ role: 'assistant', author: '네오', content: [{ type: 'text', text: '<b>x</b>' }], createdAt: new Date().toISOString() }).includes('<b>'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(h.d.version, '0.3.10');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
r = await op.as('GET', '/ops/api/ai/skill'); eq(r.status, 200); ok(r.json.skill.includes('pending') && r.json.skill.includes('needs_user') && r.json.skill.includes('reauth_required'), '상태·로그 의미 설명');
|
||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.0.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.0'); ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.1.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.1'); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.1'));
|
||||
ok(readFileSync(join(ROOT, 'docs', 'HAL-AI.md'), 'utf8').includes('hal_servers_list'), 'AI 도구 문서');
|
||||
@@ -315,7 +315,7 @@ await test('MCP(stdio) 서버: initialize·tools/list·읽기 도구 call 은
|
||||
const send = (o) => child.stdin.write(JSON.stringify(o) + '\n'); const waitFor = async (id) => { for (let i = 0; i < 200; i++) { const m = out.find((x) => x.id === id); if (m) return m; await sleep(25); } throw new Error('MCP 응답 없음 ' + id); };
|
||||
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test' } } }); const init = await waitFor(1); ok(init.result.serverInfo.name.includes('HAL') && init.result.capabilities.tools);
|
||||
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const tl = await waitFor(2); ok(tl.result.tools.some((t) => t.name === 'hal_servers_list' && t.inputSchema)); ok(!tl.result.tools.some((t) => /shell|exec/.test(t.name)));
|
||||
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.3.10');
|
||||
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.3.17');
|
||||
send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hal_incident_open', arguments: { key: 'k', title: 't', severity: 'warning' } } }); const wc = await waitFor(4); ok(!wc.result.isError && /승인/.test(wc.result.content[0].text) && JSON.parse(wc.result.content[0].text).approvalRequested === true, JSON.stringify(wc)); // [0.3.2] 오류가 아니라 승인 요청(제안)으로 올라간다
|
||||
send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'nope', arguments: {} } }); const nc = await waitFor(5); ok(nc.error || nc.result.isError);
|
||||
child.kill(); await sleep(50);
|
||||
|
||||
@@ -74,7 +74,7 @@ await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버
|
||||
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('승인 대기'));
|
||||
r = await op.as('GET', '/ops/releases'); ok(r.text.includes('HAL 은 배포 대상이 아닙니다') && r.text.includes('abcdef123456') && r.text.includes('테스트 통과') && r.text.includes('승인 대기'), '[0.3.17] 릴리스 화면: HAL 제외 안내·과거 버전 표');
|
||||
// 작업: 부모 + 자식(하나 실패) → 부분 실패 표시
|
||||
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; }); } });
|
||||
const p = h.d.jobs.enqueue({ kind: 'ui.demo', initialStatus: 'awaiting_external', label: '부모' }).job; h.d.jobs.enqueue({ kind: 'ui.demo', parentId: p.id, label: '대상1' }); h.d.jobs.enqueue({ kind: 'ui.demo', parentId: p.id, label: '대상2', subject: { fail: true } }); await h.d.jobs.drain();
|
||||
|
||||
+2
-3
@@ -127,9 +127,8 @@ await test('모듈 배포: 릴리스 승인·HAL 먼저·대상별 자식 작업
|
||||
let j = enq('portal'); await h.d.jobs.drain(); eq(h.d.jobs.get(j.id).error_code, 'release_not_approved');
|
||||
core.approveRelease(h.d, '0.1.1', 'op'); core.setBootstrap(h.d, { hal_running_version: '0.1.0' });
|
||||
j = enq('portal'); await h.d.jobs.drain(); eq(h.d.jobs.get(j.id).error_code, 'hal_first', 'HAL 이 먼저');
|
||||
// HAL 자체 배포 → 실행 버전 갱신
|
||||
j = enq('hal', '0.1.1', [a1.serverId]); let pp = pump(h, j.id); await serveOne(a1, () => ({ status: 'done', result: { actualVersion: '0.1.1', health: { ok: true }, active: true, previousVersion: '0.1.0' } })); await pp;
|
||||
eq(h.d.jobs.get(j.id).status, 'done', JSON.stringify(h.d.jobs.get(j.id).error)); eq(core.bootstrapState(h.d).hal_running_version, '0.1.1'); ok(core.getRelease(h.d, '0.1.1').hal_deployed_at);
|
||||
// [0.3.17] HAL 자체는 배포 대상이 아니다(먼저 설치하는 도구) — HAL 은 별도 절차로 갱신되고 실행 버전은 기동 때 기록된다
|
||||
j = enq('hal', '0.1.1', [a1.serverId]); await h.d.jobs.drain(); eq(h.d.jobs.get(j.id).error_code, 'hal_not_deployable'); core.setBootstrap(h.d, { hal_running_version: '0.1.1' }); let pp;
|
||||
// 포털을 두 서버에: w1 성공, w2 실패 → partial
|
||||
j = enq('portal'); pp = pump(h, j.id); await serveOne(a1, () => ({ status: 'done', result: { actualVersion: '0.1.1', health: { ok: true }, active: true } })); await serveOne(a2, () => ({ status: 'failed', error: 'health failed', errorCode: 'health_failed' })); await pp;
|
||||
const parent = h.d.jobs.get(j.id); eq(parent.status, 'failed', JSON.stringify(parent.error)); eq(parent.error_code, 'partial_failure'); const sum2 = h.d.jobs.childrenSummary(j.id); eq(sum2.overall, 'partial'); eq(sum2.total, 2);
|
||||
|
||||
@@ -19,10 +19,10 @@ const require = createRequire(import.meta.url);
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WM = join(ROOT, 'modules', 'webmaker');
|
||||
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.10(HAL AI 셸 복사, 도입 모듈 변경 없음) 이다', () => {
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.17(HawkEye·통합 배포·릴리스 화면, 도입 모듈 변경 없음) 이다', () => {
|
||||
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
|
||||
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.8.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17');
|
||||
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
||||
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
|
||||
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
|
||||
|
||||
Reference in New Issue
Block a user