Compare commits
15
Commits
@@ -91,6 +91,34 @@
|
||||
- 긴급 복구(§33): 사건 번호·사유·대상·허용 작업·짧은 유효 시간, 재인증 + 서로 다른 내용의 3단계 승인(한 명도 가능, 단계별 시각 기록), 모든 작업 기록, 자격 1회 표시·정상화 후 만료, 허용 범위(HAL·HAL 저장 정보·비밀정보·인증서 기반·artifact 저장소·최초 Agent 연결) 밖 작업 거부, HAL 정상화 뒤 실제/원하는 상태 비교·조정.
|
||||
- 플랫폼 재해복구(§34): HAL 운영 정보·진행 작업·로그인·비밀정보·인증서 기반·배치·서버 등록 정보의 복구 사본·외부 보관·검증·훈련 기록, RTO/RPO/주기/보존 설정과 측정값 비교, 복구 시작 별도 승인과 기존 제어 위치 중지 확인, 하나의 HAL 만 운영(임대, 같은 호스트의 죽은 프로세스는 이어받음), 훈련 성공 전 정식 운영 준비 미표시.
|
||||
|
||||
### 실제 환경 배포에서 발견한 결함 수정(2026-10-26, ariweb.net 호스트 첫 배포)
|
||||
- **Agent 실행 계정 모드**(`agent/jobs/module.mjs`, `deploy/module-as-user.sh`, `deploy/sudoers/ariweb-agent`): 도입 모듈 AriMail 은 서버에서 다른 계정(`arihakdang`)으로 운영되어 `/srv/arihakdang-mail` 을 Agent(master)가 쓸 수 없었다. 유닛의 `User=` 가 Agent 계정과 다르면 풀기·REVISION·current 전환·정리·DB 백업·마이그레이션을 `sudo -n -u <계정> bash deploy/module-as-user.sh` 로 그 계정이 수행한다(sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용, 경로는 모듈 디렉터리 안으로 제한). artifact 는 `<srv>/agent/work` 에 받는다.
|
||||
- **AriMail 마이그레이션 DB 경로**: `server/db-migrate.mjs` 는 `DB_FILE` 환경이 없으면 릴리스 디렉터리 안에 빈 DB 를 만든다. 유닛의 `EnvironmentFile`(`/etc/<유닛>/gateway.env`)을 실행 계정으로 읽어 실행하도록 고쳤다(직접 모드도 같은 파일을 읽는다).
|
||||
- **도입 모듈 실제 버전**: Webmaker(`package.json` 1.7.3)·AriMail(1.0.11)의 상류 버전이 HAL 의 목표 버전(0.1.1)과 달라 `version_mismatch` 로 배포가 실패했을 것이다. 풀 때 적는 `REVISION.json` 의 Suite 릴리스 버전을 실제 버전으로 읽고 상류 버전은 `upstreamVersion` 으로 함께 보고한다.
|
||||
- **기존 유닛 보존**(`hal/modules/index.mjs` `keepExistingUnit`): Webmaker 의 운영 유닛은 `ProtectSystem=strict`·`ReadWritePaths` 등 강화 지시문을 갖는데 템플릿이 Environment 줄만 보존하고 나머지를 지웠을 것이다. 유닛이 이미 `<appDir>/current` 를 가리키면 다시 쓰지 않는다.
|
||||
- **포털 nginx 접근 경로**(`deploy/bootstrap-hal.sh`): `deploy/nginx/ariweb-portal.conf.tmpl` 이 어디서도 적용되지 않아 `my.ariweb.net` 이 열리지 않았다. 부트스트랩 4단계(접근 경로 준비)에서 HAL 과 함께 설치한다.
|
||||
- **부트스트랩 인증서 확인**(`deploy/bootstrap-hal.sh`): `/etc/letsencrypt/live` 는 root 만 읽을 수 있어 일반 계정의 `[ -f ]` 가 항상 거짓이었다(실제 부트스트랩이 4단계에서 중단). `sudo -n test -f` 로 확인한다.
|
||||
- **포털 artifact 에 Catalog 누락**(`scripts/release.mjs`): 상품정책 시드 파일 `catalog/release-0.1.1.json` 이 portal artifact 에 들어가지 않아 운영자 시드(`/internal/ops/commerce/catalog/seed`)가 실서버에서 실패했을 것이다. portal 경로에 `catalog` 를 넣었다.
|
||||
- **artifact 디렉터리 모드**(`scripts/release.mjs`): 개발 PC 의 umask(077)가 artifact 에 0700 디렉터리로 들어가 서버에서 다른 실행 계정이 Agent 의 `deploy/module-as-user.sh` 와 풀린 릴리스를 읽지 못했다. `tar --mode=u=rwX,go=rX --owner=0 --group=0` 로 정규화한다. `deploy/agent-install.sh` 도 호출자의 umask 와 무관하게 코드 트리를 `a+rX` 로 맞춘다.
|
||||
- **실행 계정 모드 보완**(`agent/jobs/module.mjs`): 내려받은 artifact(0600)를 실행 계정이 읽도록 0644 로 바꾸고, 도우미 스크립트는 sudoers 규칙과 같은 `<srv>/agent/current/deploy/module-as-user.sh` 경로로 부른다(ESM 의 `import.meta.url` 은 `releases/<ver>` 실제 경로로 풀린다).
|
||||
- **같은 버전 재설치**(`agent/jobs/module.mjs`, `module-as-user.sh rename`): 실행 중인 current 디렉터리를 지우고 다시 풀던 것을, 임시 디렉터리에 풀고 기존 디렉터리를 `<ver>.prev-<시각>` 으로 보존(되돌리기 대상)한 뒤 제자리에 두도록 바꿨다. DB 백업 파일 이름은 같은 초에 겹치지 않게 한다.
|
||||
- **Webmaker 데이터 백업**(`agent/jobs/module.mjs`): 운영 데이터 디렉터리의 읽을 수 없는 파일(root 가 남긴 마이그레이션 기록)이 tar 를 실패시켜 배포가 막혔다. `--ignore-failed-read` 로 경고만 남기고(결과 `unreadable` 목록) 진행한다. ariweb.net 호스트의 해당 파일(`/data/webmaker/migrations`)은 소유권을 master 로 바로잡았다.
|
||||
- **부트스트랩 릴리스 기록**(`hal/core.mjs setBootstrap`): HAL 기동 전 CLI `bootstrap-release` 가 쓴 UPDATE 가 행(id=1)이 없어 무효였다(건강 응답의 `releaseVersion` 이 null). 행을 먼저 만든다(`test/hal.test.mjs` 회귀 테스트).
|
||||
- **AriMail 마이그레이션의 EnvironmentFile 읽기**(`deploy/module-as-user.sh migrate`): systemd 문법(`MAIL_FROM_NAME=Ari Hak dang` 처럼 따옴표 없는 공백 값)을 bash 로 소스하다 실패했다. 줄 단위로 읽어 따옴표를 벗기고 export 한다.
|
||||
- **사후 절차 실패 시 되돌리기**(`agent/jobs/module.mjs`): current 전환 뒤 마이그레이션이 실패하면 current 가 새 릴리스를 가리킨 채 남았다(다음 재시작 때 마이그레이션 없는 새 코드가 뜬다). 실패하면 이전 릴리스로 되돌리고 재시작한다.
|
||||
- **AriMail 전체 설정 API 주소**(`portal/mail/arimail.mjs` 주석·운영 안내): AriMail 은 설정 API(`/api/setup/*`)를 기본 도메인 호스트(`req.headers.host`, 예: `mail.arihakdang.com`)에서만 응답하고 다른 테넌트 호스트로는 404 를 준다. Node fetch 는 Host 헤더를 바꿀 수 없으므로(시도했다가 되돌림) 포털의 설정 주소(`setup_url`)는 기본 도메인 이름이어야 하고, 그 이름이 설정 포트(loopback 전용 8791)로 풀려야 한다 — ariweb.net 호스트에서는 `/etc/hosts` 에 `127.0.0.1 mail.arihakdang.com` 을 두었다(알려진 제한에 기록).
|
||||
- **Agent heartbeat 의 모듈 버전**(`agent/index.mjs`): `current/package.json` 버전(상류 1.7.3·1.0.11)을 보고해 HAL 이 배포 직후 "목표와 다름(drift)" 으로 표시했다. `REVISION.json` 의 Suite 버전을 먼저 읽는다.
|
||||
- **새 유닛 enable**(`agent/jobs/module.mjs`): 템플릿으로 만든 유닛(포털)을 enable 하지 않아 재부팅 뒤 뜨지 않았을 것이다. 유닛을 쓸 때 `systemctl enable` 한다.
|
||||
- **서버 한 대 운영의 역할 대체**(`hal/core.mjs pickServer`, `portal/backup/service.mjs pickStoreServer`): 첫 서버는 역할이 `control` 하나뿐이라 사이트 배포(web)·백업 저장(backup) 대상 서버를 고를 수 없었다. 요청 역할의 서버가 없으면 control 서버를 web·backup 으로도 쓴다(전용 서버가 생기면 우선). 같은 서버에 백업을 두는 것은 여전히 운영자 override 가 필요하다.
|
||||
- **초안 사이트의 백업 중지 사유**(`portal/backup/service.mjs`): 미배포(초안) 사이트의 옵션을 정기 실행이 `site_cancelled`(해지) 로 중지해 표시가 틀렸다. `site_not_deployed`(사이트 미배포) 로 구분한다.
|
||||
- 회귀 테스트: `test/agent.test.mjs` 에 실행 계정 모드(도우미 스크립트 실제 실행·경로 제한 거부)·REVISION 버전·기존 유닛 보존 검사를 추가했다.
|
||||
|
||||
|
||||
### HAL 전용 호스트 부트스트랩(2026-10-27, bear `hal21.ariweb.net`)
|
||||
- **`deploy/bootstrap-hal.sh --portal-host none`**: 포털을 다른 호스트에서 운영하는 HAL 전용 호스트에는 `my.ariweb.net` nginx 접근 경로를 만들지 않는다(이전에는 항상 만들어 포털이 없는 호스트에도 죽은 vhost 가 남았다). 기본값(`my.ariweb.net`)의 동작은 그대로다.
|
||||
- bear(Ubuntu 24.04, nginx 1.24, Node 24.22, certbot nginx 플러그인 운영)에 HAL 만 최초 부트스트랩: 인증서는 호스트별 certbot(webroot `/var/www/html`, ECDSA, 갱신 훅 `systemctl reload nginx`) — `--cert /etc/letsencrypt/live/hal21.ariweb.net --acme /var/www/html`. 기존 Webmaker·Gitea·Matrix·기본 서버는 건드리지 않았다. 절차·검증·되돌리기는 `docs/RUNBOOK-0.1.1.md` §7. 배포 artifact: main `ab75b7d`, hal sha256 `f574d875…`(deploy/ 변경으로 ariweb.net 호스트의 `e7741899…` 와 다름). 알려진 사소한 결함: 부트스트랩 5단계의 HTTPS 자체 확인이 nginx reload 직후 실행되어 거짓 경고가 날 수 있다(결과에 영향 없음).
|
||||
- **HAL 기본 주소 안내**(`hal/app.mjs`): `https://hal21.ariweb.net/` 접속이 JSON 404를 반환하던 동작을 운영자 콘솔 표준 경로 `/ops/`로 이동하도록 수정했다. `/ops`의 기존 사설망/VPN·기기 접근권 관문과 별도 운영자 로그인은 그대로 적용된다.
|
||||
|
||||
### 도입 소스
|
||||
- 가져오기: `node scripts/import-sources.mjs` 가 고정 커밋(Webmaker `3b10859884ef2fd0f2af11934ab6c16370fd64b8`, AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`)을 검사하고 실행에 필요 없는 문서·보고서·샘플을 제외한 전체를 `modules/webmaker`(176 파일)·`modules/arimail`(88 파일)로 복사, 파일별 원본 경로·대상 경로·수정 여부·원본 해시·라이선스 검토를 `IMPORTED_SOURCES.md`/`.json` 에 기록한다.
|
||||
- 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
|
||||
@@ -99,11 +127,15 @@
|
||||
|
||||
### 알려진 제한
|
||||
- 로컬 E2E 검증(`docs/verification/local-e2e-2026-10-26.md`, 실제 HAL·포털·Node Agent 4대 + 가짜 Webmaker·AriMail·메일): 34개 완료 조건 중 19개 로컬 확인, 14개 부분 확인, 1개(외부 메일 왕복) 로컬 확인 불가, 결함 0. 부분 확인 항목은 실제 DNS·공인 서버·실 AriMail 왕복·시간 경과(30일 보존)가 필요한 부분이며, 실제 환경 확인 결과는 `docs/verification/` 에 별도 기록한다.
|
||||
- ariweb.net 호스트의 `/etc/hosts` 에 `127.0.0.1 hal.ariweb.net my.ariweb.net webmail.ariweb.net mail.arihakdang.com` 이 있다: 앞의 둘은 공개 DNS 생성 전 같은 서버 안의 Agent·포털이 HAL 에 닿기 위한 임시 항목이고, `mail.arihakdang.com` 은 AriMail 전체 설정 포트(8791, loopback 전용)에 기본 도메인 이름으로 접속하기 위한 항목이다(cloud-init 이 `/etc/hosts` 를 관리하므로 재부팅 뒤 확인 필요).
|
||||
- 실제 환경 검증(`docs/verification/real-env-0.1.1.md`, ariweb.net 호스트, 2026-10-26): 34개 중 실제 확인 7(가입·확인 메일, 그룹·초대, 주문·승인·권리, 초안 사이트, 독립 메일 서비스, HAL 우선 배포·HAL 작업 배포, HAL 재시작 유지), 부분 확인 7(Webmaker 진입, 관리형 배포 요청, 사이트 화면, 대표 메일·메일함, 백업 옵션, 모듈 갱신, 감사 일치), 미수행 20(공개 DNS·실 도메인·수신 주소·다중 서버·시간 경과 필요), 결함 0. 출시 완료는 선언하지 않는다.
|
||||
- DNS: `ariweb.net` 은 hosting.co.kr 네임서버(웹 패널)라 `hal.ariweb.net`·`my.ariweb.net` A 레코드는 운영자가 패널에서 만들어야 한다. 레코드가 생기기 전에는 로컬 `--resolve` 로만 확인된다.
|
||||
- 실제 외부 메일 왕복 검증은 운영자가 검증용 수신 주소(자동 응답 메일함)를 설정해야 완료된다.
|
||||
- AI Wallet 은 Webmaker credits 를 읽어 표시만 하며, 초과 사용 과금 라인 생성은 하지 않는다(표시·제한 해제만).
|
||||
- 메일 서비스 서버 이전은 AriMail `operating` 상태 전환 도우미(setMigrating) 가 포털에 없어 `설정 필요` 로 안내된다.
|
||||
- 별칭·전달 전용 주소 생성, 대량 .eml 내보내기(IMAP/POP3·원문 다운로드 안내로 대체), Gitea 브랜치 자동 삭제(상태만), 외부 메일 배포, 자동 결제수단은 이번 버전에서 제공하지 않는다.
|
||||
- 실제 환경(ariweb.net 호스트)은 서버 한 대(역할 control)로 운영된다. 관리형 사이트 배포·백업 저장은 control 서버를 대체 사용하고, 백업을 같은 서버에 두려면 운영자 override 가 필요하다. 서버 간 이전·외부 Agent·다른 호스트 재해복구는 서버가 늘어난 뒤 실환경에서 확인한다.
|
||||
- Webmaker 의 페이지 제작(AI 생성·저장)은 사람이 제작 화면에서 수행해야 하므로 실환경 검증에서 저장본 없는 초안 사이트까지만 자동 확인했다(관리형 배포·백업 실행·복원·외부 확인은 저장본이 생긴 뒤 수행).
|
||||
- Agent 의 사용량 수집은 Agent 가 배포한 사이트의 nginx `awstats` 로그만 집계한다(Webmaker 자체 호스팅 사이트는 Webmaker 통계 유지).
|
||||
|
||||
### 미확정 정책값
|
||||
|
||||
+2
-2
@@ -36,8 +36,8 @@ if (cmd === 'register') {
|
||||
const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
|
||||
const runner = createRunner({ kinds: KINDS, roots: cfg.roots, sys, client, dataDir: cfg.dataDir, log });
|
||||
let stopping = false; let running = 0; let lastFacts = null; let restartUnit = null;
|
||||
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/package.json 버전 */
|
||||
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); const cur = join(appDir, 'current', 'package.json'); let version = null; try { version = JSON.parse(readFileSync(cur, 'utf8')).version; } catch (_) {} const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
|
||||
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/REVISION.json(없으면 package.json) 버전 */
|
||||
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); let version = null; try { version = JSON.parse(readFileSync(join(appDir, 'current', 'REVISION.json'), 'utf8')).version; } catch (_) {} if (!version) { try { version = JSON.parse(readFileSync(join(appDir, 'current', 'package.json'), 'utf8')).version; } catch (_) {} } /* 도입 모듈은 REVISION.json 의 Suite 버전(상류 package.json 버전은 목표와 다르다 — 실제 환경 결함 수정) */ const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
|
||||
async function heartbeat() { try { if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: facts(sys) }; const sites = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))).length : 0; await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: lastFacts.f, sitesHosted: sites, jobs: { running } }); } catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } }
|
||||
async function execute(job) { running++; try { const out = await runner.execute(job); if (out?.result?.restartAfterReport) restartUnit = out.result.restartAfterReport; } finally { running--; } if (restartUnit) { const u = restartUnit; restartUnit = null; log(`[agent] 자기 갱신 — ${u} 재시작`); setTimeout(() => sys.systemctl('restart', u, { optional: true }), 1500); } }
|
||||
// 재시작 뒤: 보고 못 한 결과 전송, 미완료 작업 재개
|
||||
|
||||
+109
-30
@@ -5,29 +5,90 @@
|
||||
* 절차(원본 remote-update.sh / deploy.sh 의 순서를 따른다): artifact 내려받기·sha256 확인 → releases/<ver> 에 풀기 → preStop 절차(데이터 백업) → 유닛 갱신(템플릿이 있을 때) → current 전환 → postStart 절차(마이그레이션 등) → 재시작 → 건강 확인 → 실제 버전 읽기.
|
||||
* 실패하면 current 를 이전 릴리스로 되돌리고 재시작한 뒤 오류를 보고한다(명령 성공만으로 완료하지 않는다).
|
||||
* 자기 자신(agent)·HAL 갱신: 결과를 먼저 보고한 뒤 재시작하도록 restartAfterReport 를 돌려준다.
|
||||
*
|
||||
* [실제 환경 결함 수정 0.1.1] 실행 계정이 다른 도입 모듈: 유닛의 User= 가 Agent 계정과 다르면(예: AriMail 의 arihakdang) 그 디렉터리에 Agent 가 직접 쓸 수 없다.
|
||||
* 이때는 모든 파일 작업(풀기·REVISION·current 전환·정리·DB 백업·마이그레이션)을 `sudo -n -u <계정> bash deploy/module-as-user.sh …` 로 그 계정이 수행한다
|
||||
* (sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용). artifact 는 Agent 의 작업 디렉터리(<srv>/agent/work)에 받아 그 계정이 읽게 한다.
|
||||
* 마이그레이션은 유닛의 EnvironmentFile(DB_FILE 등)을 같은 계정으로 읽어 실행한다 — 없으면 릴리스 디렉터리 안에 빈 DB 를 만드는 잘못된 동작이 된다.
|
||||
* [실제 환경 결함 수정 0.1.1] 실제 버전: 도입 모듈의 package.json 버전(Webmaker 1.7.3·AriMail 1.0.11)은 Suite 릴리스 버전(0.1.1)과 다르다.
|
||||
* HAL 은 "목표 릴리스 버전 = 실제 버전" 으로 판정하므로, 풀 때 적은 REVISION.json 의 version 을 실제 버전으로 읽고 package.json 버전은 upstreamVersion 으로 함께 보고한다.
|
||||
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
|
||||
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, copyFileSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { userInfo } from 'node:os';
|
||||
import http from 'node:http';
|
||||
import { readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
|
||||
import { ROOT, readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
|
||||
|
||||
/** 내부 건강 확인 URL 호출 */
|
||||
function httpGet(url, timeoutMs = 8000) { return new Promise((resolve) => { const t0 = Date.now(); const req = http.get(url, (res) => { let body = ''; res.on('data', (c) => { if (body.length < 100000) body += c; }); res.on('end', () => resolve({ status: res.statusCode, body, ms: Date.now() - t0 })); }); req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout'))); req.on('error', (e) => resolve({ status: 0, error: e.message, ms: Date.now() - t0 })); }); }
|
||||
async function health(ctx, desc) { if (ctx.sys.stub) return { ok: true, stubbed: true }; if (!desc?.health?.url) return { ok: true, skipped: 'no_health_url' }; let last = null; for (let i = 0; i < 20; i++) { const r = await httpGet(desc.health.url); last = r; if (r.status && r.status < 500 && (desc.health.expect !== 'ok' || /"ok"\s*:\s*true|ok/.test(r.body))) return { ok: true, status: r.status, ms: r.ms }; await new Promise((s) => setTimeout(s, 1500)); } return { ok: false, status: last?.status, error: last?.error || `상태 ${last?.status}` }; }
|
||||
async function detectVersion(ctx, desc, appDir) { const cur = readLink(join(appDir, 'current')); if (desc?.version?.http && !ctx.sys.stub) { const r = await httpGet(desc.version.http); try { const j = JSON.parse(r.body); if (j[desc.version.field]) return j[desc.version.field]; } catch (_) {} } const pkg = cur ? readJson(join(cur, 'package.json'), null) : null; return pkg?.version || (cur ? cur.split('/').pop() : null); }
|
||||
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
|
||||
async function writeUnit(ctx, desc, unit, appDir, version) {
|
||||
if (!ctx.payload.unitTemplate) return { skipped: 'no_template' };
|
||||
const old = await ctx.sys.unitText(unit); const envLines = old.split('\n').filter((l) => /^Environment=/.test(l) && !/^Environment=(NODE_ENV|HOST|PORT|ARIWEB_[A-Z_]+_DATA|NODE_OPTIONS)=/.test(l)).join('\n');
|
||||
const user = (/^User=(.+)$/m.exec(old) || [])[1] || desc.user || process.env.USER || 'master'; const envFile = (/^EnvironmentFile=(.+)$/m.exec(old) || [])[1] || ''; const node = (/^ExecStart=(\S+)/m.exec(old) || [])[1] || process.execPath;
|
||||
const txt = render(ctx.payload.unitTemplate, { APP_DIR: appDir, USER: user, NODE: /node$/.test(node) ? node : process.execPath, ENV_LINES: envLines, VERSION: version, UNIT: unit, ENV_FILE: envFile });
|
||||
await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); return { written: true, preservedEnv: envLines.split('\n').filter(Boolean).length };
|
||||
/**
|
||||
* 실제 버전 읽기. 우선순위: 건강 응답의 version(HAL·포털) → current/REVISION.json 의 Suite 릴리스 버전(도입 모듈) → package.json → 디렉터리 이름.
|
||||
* 도입 모듈은 package.json 이 상류 버전이라 upstreamVersion 으로 따로 돌려준다(화면·영수증 표시용).
|
||||
*/
|
||||
async function detectVersion(ctx, desc, appDir) {
|
||||
const cur = readLink(join(appDir, 'current')); const pkg = cur ? readJson(join(cur, 'package.json'), null) : null; const rev = cur ? readJson(join(cur, 'REVISION.json'), null) : null;
|
||||
if (desc?.version?.http && !ctx.sys.stub) { const r = await httpGet(desc.version.http); try { const j = JSON.parse(r.body); if (j[desc.version.field]) return { version: j[desc.version.field], upstreamVersion: pkg?.version || null }; } catch (_) {} }
|
||||
if (rev?.version) return { version: rev.version, upstreamVersion: pkg?.version && pkg.version !== rev.version ? pkg.version : null };
|
||||
return { version: pkg?.version || (cur ? cur.split('/').pop() : null), upstreamVersion: null };
|
||||
}
|
||||
/** 유닛에서 실행 계정·EnvironmentFile·node 경로를 읽는다. 계정이 Agent 계정과 다르면 asUser 모드(파일 작업을 그 계정으로). */
|
||||
async function serviceContext(ctx, unit) {
|
||||
const txt = unit ? await ctx.sys.unitText(unit) : ''; const me = (() => { try { return userInfo().username; } catch (_) { return process.env.USER || ''; } })();
|
||||
const user = (/^User=(.+)$/m.exec(txt) || [])[1]?.trim() || null; const envFile = ((/^EnvironmentFile=(.+)$/m.exec(txt) || [])[1] || '').replace(/^-/, '').trim() || null; const node = (/^ExecStart=(\S+)/m.exec(txt) || [])[1] || null;
|
||||
return { unitText: txt, user, envFile, nodeBin: node && /node$/.test(node) ? node : null, asUser: !!(user && user !== me && user !== 'root'), me };
|
||||
}
|
||||
/** 실행 계정으로 파일 작업(deploy/module-as-user.sh). 실패하면 stderr 를 포함한 오류를 던진다. */
|
||||
async function asUser(ctx, svc, appDir, sub, args, { input = null } = {}) {
|
||||
// sudoers 규칙은 current 심링크 경로(<srv>/agent/current/deploy/module-as-user.sh)로 허용한다 — ESM 의 import.meta.url 은 실제 경로(releases/<ver>)로 풀리므로 current 경로를 우선 쓴다.
|
||||
const viaCurrent = join(ctx.roots.srv, 'agent', 'current', 'deploy', 'module-as-user.sh'); const script = existsSync(viaCurrent) ? viaCurrent : join(ROOT, 'deploy', 'module-as-user.sh');
|
||||
const r = await ctx.sys.run('sudo', ['-n', '-u', svc.user, '/usr/bin/bash', script, sub, '--app-dir', appDir, ...args], { input, timeoutMs: 600000 });
|
||||
if (r.code !== 0) throw Object.assign(new Error(`실행 계정(${svc.user})으로 ${sub} 실패: ${(r.stderr || r.stdout || '').trim().slice(0, 300) || `코드 ${r.code}`}`), { code: r.code === 1 && /sudo|password/i.test(r.stderr || '') ? 'sudo_denied' : 'as_user_failed', hint: 'deploy/sudoers/ariweb-agent 의 module-as-user.sh 규칙이 설치되어 있어야 합니다' });
|
||||
return r;
|
||||
}
|
||||
/** 파일 쓰기·심링크 전환·삭제를 모드(직접/실행 계정)에 맞게 수행 */
|
||||
const fsOps = (ctx, svc, appDir) => ({
|
||||
writeJson: async (p, v) => (svc.asUser ? asUser(ctx, svc, appDir, 'write', ['--file', p], { input: JSON.stringify(v, null, 1) }) : writeJson(p, v)),
|
||||
switchLink: async (link, target) => (svc.asUser ? asUser(ctx, svc, appDir, 'switch', ['--link', link, '--target', target]) : switchLink(link, target)),
|
||||
remove: async (dir) => (svc.asUser ? asUser(ctx, svc, appDir, 'remove', ['--dir', dir]) : rmrf(dir)),
|
||||
rename: async (from, to) => (svc.asUser ? asUser(ctx, svc, appDir, 'rename', ['--dir', from, '--target', to]) : renameSync(from, to)),
|
||||
});
|
||||
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
|
||||
async function writeUnit(ctx, desc, unit, appDir, version, svc) {
|
||||
if (!ctx.payload.unitTemplate) return { skipped: 'no_template' };
|
||||
const old = svc.unitText;
|
||||
if (desc.keepExistingUnit && new RegExp(`^WorkingDirectory=${appDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/current/?$`, 'm').test(old)) return { skipped: 'existing_unit_kept', user: svc.user };
|
||||
const envLines = old.split('\n').filter((l) => /^Environment=/.test(l) && !/^Environment=(NODE_ENV|HOST|PORT|ARIWEB_[A-Z_]+_DATA|NODE_OPTIONS)=/.test(l)).join('\n');
|
||||
const user = svc.user || desc.user || process.env.USER || 'master'; const envFile = svc.envFile || ''; const node = svc.nodeBin || process.execPath;
|
||||
const txt = render(ctx.payload.unitTemplate, { APP_DIR: appDir, USER: user, NODE: node, ENV_LINES: envLines, VERSION: version, UNIT: unit, ENV_FILE: envFile });
|
||||
await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); const en = await ctx.sys.systemctl('enable', unit, { optional: true }); /* 새로 만든 유닛은 재부팅 뒤에도 뜨도록 enable(실제 환경 결함 수정: 포털 유닛이 disabled 였다) */ return { written: true, enabled: en.code === 0, preservedEnv: envLines.split('\n').filter(Boolean).length };
|
||||
}
|
||||
/** EnvironmentFile(KEY=값 줄) 읽기 — 직접 모드의 마이그레이션에 싣는다. 값은 돌려주되 로그에는 쓰지 않는다. */
|
||||
function readEnvFile(p) { const out = {}; if (!p || !existsSync(p)) return out; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); if (!m) continue; let v = m[2].trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); out[m[1]] = v; } return out; }
|
||||
/** preStop/postStart 절차 */
|
||||
async function hook(ctx, name, { appDir, cur, unit }) {
|
||||
if (name === 'webmaker.backupData') { const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(await ctx.sys.unitText(unit)) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true }); await ctx.sys.tar(['-czf', out, '-C', dataRoot, '--exclude=./backups', '.']); const old = readdirSync(join(appDir, 'backups')).sort(); for (const f of old.slice(0, Math.max(0, old.length - 5))) rmSync(join(appDir, 'backups', f), { force: true }); return { backup: out }; }
|
||||
if (name === 'arimail.backupDb') { const db = join(appDir, 'data', 'mail-gateway.sqlite'); if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out}'`); } finally { d.close(); } return { backup: out }; }
|
||||
if (name === 'arimail.migrate') { const db = join(appDir, 'data', 'mail-gateway.sqlite'); const r = await ctx.sys.run(process.execPath, [join(cur, 'server', 'db-migrate.mjs')], { allowStub: false, timeoutMs: 120000 }).catch((e) => ({ code: 1, stderr: e.message })); if (!ctx.sys.stub && !existsSync(join(cur, 'server', 'db-migrate.mjs'))) return { skipped: 'no_migrate' }; if (r.code !== 0 && !ctx.sys.stub) throw Object.assign(new Error(`AriMail 마이그레이션 실패: ${(r.stderr || '').slice(0, 300)}`), { code: 'migrate_failed' }); return { migrated: true, db }; }
|
||||
async function hook(ctx, name, { appDir, cur, unit, svc }) {
|
||||
if (name === 'webmaker.backupData') {
|
||||
const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(svc.unitText) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true });
|
||||
// [실제 환경 결함 수정] 운영 데이터 디렉터리에는 다른 계정(root)이 남긴 파일이나 백업 중 바뀌는 파일이 있을 수 있다. 읽지 못한 파일은 경고로 남기고 배포를 막지 않는다(tar 종료 코드 2 = 치명적 오류만 실패).
|
||||
const r = await ctx.sys.run('tar', ['-czf', out, '-C', dataRoot, '--exclude=./backups', '--ignore-failed-read', '--warning=no-file-changed', '--warning=no-file-removed', '.'], { allowStub: false });
|
||||
if (r.code !== 0 && r.code !== 1) throw new Error(`데이터 백업 실패: ${(r.stderr || '').slice(0, 300)}`);
|
||||
const warnings = (r.stderr || '').split('\n').filter((l) => /Cannot open|Permission denied|Cannot stat/.test(l)).map((l) => l.replace(/^tar: /, '')).slice(0, 20);
|
||||
if (warnings.length) ctx.log(`데이터 백업에서 읽지 못한 파일 ${warnings.length}개(경고): ${warnings[0]}`);
|
||||
const old = readdirSync(join(appDir, 'backups')).sort(); for (const f of old.slice(0, Math.max(0, old.length - 5))) rmSync(join(appDir, 'backups', f), { force: true }); return { backup: out, unreadable: warnings.length ? warnings : undefined };
|
||||
}
|
||||
if (name === 'arimail.backupDb') {
|
||||
const env = svc.asUser ? {} : readEnvFile(svc.envFile); const db = env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite'); let out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); for (let n = 1; existsSync(out); n++) out = out.replace(/(-\d+)?\.sqlite$/, `-${n}.sqlite`); // 같은 초에 두 번 설치해도 VACUUM INTO 대상이 겹치지 않게
|
||||
if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'backup-db', ['--db', db, '--out', out, ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_db' } : { backup: out, via: svc.user }; }
|
||||
if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`); } finally { d.close(); } return { backup: out };
|
||||
}
|
||||
if (name === 'arimail.migrate') {
|
||||
const script = join('server', 'db-migrate.mjs'); if (!ctx.sys.stub && !existsSync(join(cur, script))) return { skipped: 'no_migrate' };
|
||||
if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'migrate', ['--cwd', cur, '--script', script, ...(svc.envFile ? ['--env-file', svc.envFile] : []), ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_migrate' } : { migrated: true, via: svc.user, envFile: svc.envFile || null }; }
|
||||
const env = readEnvFile(svc.envFile); const r = await ctx.sys.run(svc.nodeBin || process.execPath, [join(cur, script)], { allowStub: false, timeoutMs: 120000, env: { ...process.env, ...env } }).catch((e) => ({ code: 1, stderr: e.message }));
|
||||
if (r.code !== 0 && !ctx.sys.stub) throw Object.assign(new Error(`AriMail 마이그레이션 실패: ${(r.stderr || '').slice(0, 300)}`), { code: 'migrate_failed' }); return { migrated: true, db: env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite') };
|
||||
}
|
||||
if (name === 'arimail.haraka') { const r = await ctx.sys.systemctl('restart', `${unit}-haraka`, { optional: true }); return { haraka: r.code === 0 ? 'restarted' : 'skipped' }; }
|
||||
if (name === 'webmaker.unitCheck') { const r = await ctx.sys.run(process.execPath, [join(cur, 'scripts', 'unit-check.js')], { allowStub: true, timeoutMs: 60000 }).catch(() => ({ code: 1 })); return { unitCheck: r.code === 0 ? 'ok' : 'warn' }; }
|
||||
return { skipped: `unknown hook ${name}` };
|
||||
@@ -42,36 +103,54 @@ function resolveAppDir(ctx, p) {
|
||||
if (srv !== '/srv/ariweb' && String(given).startsWith('/srv/ariweb/')) return join(srv, String(given).slice('/srv/ariweb/'.length));
|
||||
return given;
|
||||
}
|
||||
async function installRelease(ctx, { module, version, appDir, desc, unit, action, toVersion }) {
|
||||
async function installRelease(ctx, { module, version, appDir, desc, action, toVersion, svc }) {
|
||||
const relDir = join(appDir, 'releases', action === 'rollback' ? (toVersion || '') : version); const prev = readLink(join(appDir, 'current'));
|
||||
if (action === 'rollback') { const target = toVersion ? relDir : readJson(join(appDir, '.previous.json'), {}).previous; if (!target || !existsSync(target)) throw Object.assign(new Error('되돌릴 이전 릴리스가 없습니다'), { code: 'no_previous_release' }); return { relDir: target, prev }; }
|
||||
await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(join(appDir, 'work'), { recursive: true }); const f = join(appDir, 'work', `${module}-${version}.tar.gz`); const r = await ctx.client.download(ctx.payload.artifactUrl, f, ctx.payload.sha256 || null); return { file: f, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증');
|
||||
await ctx.step('extract', async () => { rmrf(relDir); mkdirSync(relDir, { recursive: true }); await untar(ctx, join(appDir, 'work', `${module}-${version}.tar.gz`), relDir); if (desc.copyPaths && !existsSync(join(relDir, 'package.json'))) throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); writeJson(join(relDir, 'REVISION.json'), { module, version, installedAt: new Date().toISOString() }); rmSync(join(appDir, 'work', `${module}-${version}.tar.gz`), { force: true }); return { dir: relDir }; }, '릴리스 풀기');
|
||||
return { relDir, prev };
|
||||
// 실행 계정 모드에서는 appDir 에 쓸 수 없으므로 Agent 의 작업 디렉터리에 받는다(그 계정이 읽을 수 있게 0644).
|
||||
const workDir = svc.asUser ? join(ctx.roots.srv, 'agent', 'work') : join(appDir, 'work'); const tgz = join(workDir, `${module}-${version}.tar.gz`);
|
||||
await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(workDir, { recursive: true }); const r = await ctx.client.download(ctx.payload.artifactUrl, tgz, ctx.payload.sha256 || null); if (svc.asUser) chmodSync(tgz, 0o644); /* 내려받기는 0600 으로 쓰므로 실행 계정이 읽게 한다(artifact 는 비밀이 아니다) */ return { file: tgz, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증');
|
||||
// [실제 환경 결함 수정] 같은 버전 재설치: 실행 중인 current 가 가리키는 디렉터리를 지우고 다시 풀면 실행 중인 서비스가 잠깐 코드를 잃는다(지연 require 실패) 데다 되돌릴 곳도 없다.
|
||||
// 임시 디렉터리에 먼저 풀고, 기존 디렉터리는 <ver>.prev-<시각> 으로 이름을 바꿔 보존한 뒤(이것이 되돌리기 대상) 새 디렉터리를 제자리에 둔다.
|
||||
let prevDir = prev;
|
||||
await ctx.step('extract', async () => {
|
||||
const ops = fsOps(ctx, svc, appDir); const tmp = `${relDir}.tmp`;
|
||||
if (svc.asUser) await asUser(ctx, svc, appDir, 'extract', ['--tgz', tgz, '--dest', tmp]); else { rmrf(tmp); mkdirSync(tmp, { recursive: true }); await untar(ctx, tgz, tmp); }
|
||||
if (desc.copyPaths && !existsSync(join(tmp, 'package.json'))) { await ops.remove(tmp).catch(() => {}); throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); }
|
||||
await ops.writeJson(join(tmp, 'REVISION.json'), { module, version, upstreamVersion: readJson(join(tmp, 'package.json'), {}).version || null, installedAt: new Date().toISOString(), by: svc.asUser ? svc.user : svc.me });
|
||||
let kept = null;
|
||||
if (existsSync(relDir)) { if (prev && prev.replace(/\/$/, '') === relDir) { const base = `${relDir}.prev-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; kept = base; for (let n = 1; existsSync(kept); n++) kept = `${base}.${n}`; await ops.rename(relDir, kept); prevDir = kept; } else await ops.remove(relDir); }
|
||||
await ops.rename(tmp, relDir);
|
||||
rmSync(tgz, { force: true }); return { dir: relDir, asUser: svc.asUser ? svc.user : null, replacedRunning: kept };
|
||||
}, '릴리스 풀기');
|
||||
return { relDir, prev: prevDir };
|
||||
}
|
||||
export const kinds = {
|
||||
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
|
||||
'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } },
|
||||
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); return { module: p.module, actualVersion: await detectVersion(ctx, desc, appDir), active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
|
||||
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); const v = await detectVersion(ctx, desc, appDir); return { module: p.module, actualVersion: v.version, upstreamVersion: v.upstreamVersion, active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
|
||||
};
|
||||
async function installOrRollback(ctx, action) {
|
||||
const p = ctx.payload; const { module, version, descriptor: desc = {}, toVersion = null } = p; const appDir = resolveAppDir(ctx, p); const unit = (desc && desc.unit) || `ariweb-${module}`;
|
||||
mkdirSync(join(appDir, 'releases'), { recursive: true });
|
||||
const { relDir, prev } = await ctx.step('place', () => installRelease(ctx, { module, version, appDir, desc, unit, action, toVersion }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치');
|
||||
const svc = await serviceContext(ctx, unit); const ops = fsOps(ctx, svc, appDir);
|
||||
if (!svc.asUser) mkdirSync(join(appDir, 'releases'), { recursive: true });
|
||||
if (svc.asUser) ctx.log(`실행 계정 모드: 유닛 ${unit} 의 User=${svc.user} 로 파일 작업을 수행합니다`);
|
||||
const { relDir, prev } = await ctx.step('place', () => installRelease(ctx, { module, version, appDir, desc, action, toVersion, svc }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치');
|
||||
await ctx.progress(40);
|
||||
for (const h of desc.preStop || []) await ctx.step(`pre:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit }), `사전 절차 ${h}`);
|
||||
await ctx.step('unit', () => writeUnit(ctx, desc, unit, appDir, version), '실행 유닛 갱신');
|
||||
await ctx.step('switch', () => { switchLink(join(appDir, 'current'), relDir); writeJson(join(appDir, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { from: prev, to: relDir }; }, 'current 전환');
|
||||
for (const h of desc.postStart || []) await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit }), `사후 절차 ${h}`);
|
||||
for (const h of desc.preStop || []) await ctx.step(`pre:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사전 절차 ${h}`);
|
||||
await ctx.step('unit', () => writeUnit(ctx, desc, unit, appDir, version, svc), '실행 유닛 갱신');
|
||||
await ctx.step('switch', async () => { await ops.switchLink(join(appDir, 'current'), relDir); await ops.writeJson(join(appDir, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { from: prev, to: relDir }; }, 'current 전환');
|
||||
const self = module === 'agent'; let healthRes = { ok: true };
|
||||
const revert = async () => { if (prev && existsSync(prev)) { await ops.switchLink(join(appDir, 'current'), prev).catch(() => {}); await ctx.sys.systemctl('restart', unit, { optional: true }); } };
|
||||
// 사후 절차(마이그레이션 등)가 실패하면 current 를 이전 릴리스로 되돌린다 — 그대로 두면 다음 재시작 때 마이그레이션 없는 새 코드가 뜬다(실제 환경 결함 수정).
|
||||
for (const h of desc.postStart || []) { try { await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사후 절차 ${h}`); } catch (e) { await revert(); throw Object.assign(e, { code: e.code || 'post_hook_failed', partial: { rolledBackTo: prev, hook: h } }); } }
|
||||
if (!self && desc.layout !== 'agent-feature') {
|
||||
try { await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '서비스 재시작'); for (const u of desc.extraUnits || []) await ctx.sys.systemctl('restart', u, { optional: true }); }
|
||||
catch (e) { if (prev && existsSync(prev)) { switchLink(join(appDir, 'current'), prev); await ctx.sys.systemctl('restart', unit, { optional: true }); } throw Object.assign(e, { code: e.code || 'restart_failed', partial: { rolledBackTo: prev } }); }
|
||||
catch (e) { await revert(); throw Object.assign(e, { code: e.code || 'restart_failed', partial: { rolledBackTo: prev } }); }
|
||||
healthRes = await ctx.step('health', () => health(ctx, desc), '건강 확인');
|
||||
if (!healthRes.ok) { if (prev && existsSync(prev)) { switchLink(join(appDir, 'current'), prev); await ctx.sys.systemctl('restart', unit, { optional: true }); } throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); }
|
||||
if (!healthRes.ok) { await revert(); throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); }
|
||||
}
|
||||
const actual = self ? readJson(join(relDir, 'package.json'), {}).version || version : await ctx.step('version', () => detectVersion(ctx, desc, appDir), '실제 버전 읽기');
|
||||
const ver = self ? { version: readJson(join(relDir, 'package.json'), {}).version || version, upstreamVersion: null } : await ctx.step('version', () => detectVersion(ctx, desc, appDir), '실제 버전 읽기');
|
||||
// 오래된 릴리스 정리(최근 3개 보존)
|
||||
const rels = readdirSync(join(appDir, 'releases')).sort(); for (const r of rels.slice(0, Math.max(0, rels.length - 3))) { const d = join(appDir, 'releases', r); if (d !== relDir && d !== prev) rmrf(d); }
|
||||
return { module, actualVersion: actual, previousVersion: prev ? prev.split('/').pop() : null, health: healthRes, active: true, restartAfterReport: self ? unit : null, releaseDir: relDir };
|
||||
const rels = readdirSync(join(appDir, 'releases')).sort(); for (const r of rels.slice(0, Math.max(0, rels.length - 3))) { const d = join(appDir, 'releases', r); if (d !== relDir && d !== prev) await ops.remove(d).catch((e) => ctx.log(`오래된 릴리스 정리 실패(${r}): ${e.message}`)); }
|
||||
return { module, actualVersion: ver.version, upstreamVersion: ver.upstreamVersion, previousVersion: prev ? prev.split('/').pop() : null, health: healthRes, active: true, restartAfterReport: self ? unit : null, releaseDir: relDir, runUser: svc.user || svc.me };
|
||||
}
|
||||
|
||||
+2
-2
@@ -13,10 +13,10 @@ export class NeedsUser extends Error { constructor(message, hint) { super(messag
|
||||
export function createSys({ stub = process.env.ARIWEB_AGENT_STUB_CMDS === '1', log = () => {} } = {}) {
|
||||
const calls = [];
|
||||
/** 명령 실행. 실패해도 예외 대신 { code, stdout, stderr } 를 돌려준다(호출자가 판단). */
|
||||
function run(cmd, args = [], { input = null, timeoutMs = 600000, allowStub = true } = {}) {
|
||||
function run(cmd, args = [], { input = null, timeoutMs = 600000, allowStub = true, env = null } = {}) {
|
||||
calls.push({ cmd, args });
|
||||
if (stub && allowStub && cmd !== 'tar' && cmd !== 'node') return Promise.resolve({ code: 0, stdout: '', stderr: '', stubbed: true });
|
||||
return new Promise((resolve) => { const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); let out = '', err = ''; const t = setTimeout(() => p.kill('SIGKILL'), timeoutMs); p.stdout.on('data', (c) => { out += c; }); p.stderr.on('data', (c) => { err += c; }); p.on('error', (e) => { clearTimeout(t); resolve({ code: 127, stdout: out, stderr: e.message }); }); p.on('close', (code) => { clearTimeout(t); resolve({ code, stdout: out, stderr: err }); }); if (input != null) p.stdin.end(input); else p.stdin.end(); });
|
||||
return new Promise((resolve) => { const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: env || process.env }); let out = '', err = ''; const t = setTimeout(() => p.kill('SIGKILL'), timeoutMs); p.stdout.on('data', (c) => { out += c; }); p.stderr.on('data', (c) => { err += c; }); p.on('error', (e) => { clearTimeout(t); resolve({ code: 127, stdout: out, stderr: e.message }); }); p.on('close', (code) => { clearTimeout(t); resolve({ code, stdout: out, stderr: err }); }); if (input != null) p.stdin.end(input); else p.stdin.end(); });
|
||||
}
|
||||
const sudo = (args, o) => run('sudo', ['-n', ...args], o);
|
||||
async function hasSudo() { if (stub) return true; const r = await run('sudo', ['-n', 'true']); return r.code === 0; }
|
||||
|
||||
@@ -17,6 +17,8 @@ if [ ! -f "$APP_DIR/releases/$VERSION/agent/index.mjs" ]; then
|
||||
# 등록 전에는 서명을 만들 수 없으므로 부트스트랩 전용 공개 경로가 아니라 등록 토큰으로 1회 받는다: HAL 의 install.sh 는 토큰과 함께 artifact 를 함께 보낸다(아래 등록 뒤 재시도 경로).
|
||||
if [ -n "$TOKEN" ]; then curl -fsS -H "X-Registration-Token: $TOKEN" "$HAL/agent/v1/bootstrap-artifact?module=agent&version=$VERSION" -o /tmp/ariweb-agent-$$.tgz; tar -xzf /tmp/ariweb-agent-$$.tgz -C "$APP_DIR/releases/$VERSION"; rm -f /tmp/ariweb-agent-$$.tgz; else echo "코드가 없고 토큰도 없습니다" >&2; exit 1; fi
|
||||
fi
|
||||
# 실행 계정이 다른 도입 모듈 설치(deploy/module-as-user.sh)를 위해 코드 트리는 다른 계정도 읽을 수 있어야 한다 — 호출자의 umask 와 무관하게 맞춘다(실제 환경 결함 수정)
|
||||
chmod 755 "$APP_DIR" "$APP_DIR/releases"; chmod -R a+rX "$APP_DIR/releases/$VERSION"
|
||||
ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"; cd "$APP_DIR/current"
|
||||
export ARIWEB_AGENT_DATA="$APP_DIR/data"
|
||||
if [ ! -f "$APP_DIR/data/agent.json" ]; then [ -n "$TOKEN" ] || { echo "등록되지 않았고 --token 도 없습니다" >&2; exit 1; }; echo "== 등록"; if [ "$EXTERNAL" = "1" ]; then $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --external --site "$SITE" --label "$LABEL"; else $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --role "$ROLE" --label "$LABEL"; fi; else echo "== 이미 등록됨($(cat "$APP_DIR/data/agent.json" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>console.log(JSON.parse(s).id))")) — 코드·유닛만 갱신"; fi
|
||||
|
||||
+12
-4
@@ -4,7 +4,8 @@
|
||||
# 그 뒤 모든 모듈(포털·Webmaker·AriMail·Agent 갱신 등)은 HAL 작업으로만 설치한다. 이 스크립트는 HAL 데이터에 부트스트랩이 완료된 흔적이 있으면 실행을 거부한다.
|
||||
#
|
||||
# 사용(대상 서버에서, sudo 가능한 일반 계정으로):
|
||||
# bash deploy/bootstrap-hal.sh --dist <dist/<version> 디렉터리(manifest.json·artifact)> --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net [--portal-host my.ariweb.net] [--app-dir /srv/ariweb/hal]
|
||||
# bash deploy/bootstrap-hal.sh --dist <dist/<version> 디렉터리(manifest.json·artifact)> --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net [--portal-host my.ariweb.net | none] [--app-dir /srv/ariweb/hal]
|
||||
# --portal-host none : 포털 접근 경로(nginx my.ariweb.net → 127.0.0.1:8910)를 만들지 않는다 — HAL 만 두는 호스트(예: hal21.ariweb.net)용.
|
||||
# 출력: 운영자 최초 설정 토큰(1회)과 첫 Agent 설치 명령. 비밀값은 그 밖에 출력하지 않는다.
|
||||
set -euo pipefail
|
||||
DIST=""; HOST="hal.ariweb.net"; CERT="/etc/letsencrypt/live/wh-wild-ariweb.net"; APP_DIR="/srv/ariweb/hal"; PORTAL_HOST="my.ariweb.net"; ACME="/var/www/letsencrypt"; USER_NAME="$(id -un)"
|
||||
@@ -37,10 +38,17 @@ echo "== 3/6 systemd 유닛 ariweb-hal"
|
||||
sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" deploy/units/ariweb-hal.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-hal.service >/dev/null
|
||||
sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent; sudo -n visudo -c >/dev/null
|
||||
sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-hal >/dev/null; sleep 2; systemctl is-active --quiet ariweb-hal || { echo "ariweb-hal 기동 실패 — journalctl -u ariweb-hal" >&2; exit 1; }
|
||||
echo "== 4/6 nginx $HOST (인증서 $CERT)"
|
||||
[ -f "$CERT/fullchain.pem" ] || { echo "인증서 $CERT/fullchain.pem 이 없습니다" >&2; exit 1; }
|
||||
echo "== 4/6 nginx $HOST$([ "$PORTAL_HOST" != none ] && echo " · $PORTAL_HOST") (인증서 $CERT)"
|
||||
sudo -n test -f "$CERT/fullchain.pem" || { echo "인증서 $CERT/fullchain.pem 이 없습니다" >&2; exit 1; } # /etc/letsencrypt/live 는 root 만 읽을 수 있어 sudo 로 확인한다(실제 환경 결함 수정)
|
||||
sed -e "s#{{HAL_HOST}}#$HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8900#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-hal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-hal.conf >/dev/null
|
||||
sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf; sudo -n nginx -t && sudo -n systemctl reload nginx
|
||||
sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf
|
||||
# 포털 접근 경로(my.ariweb.net → 127.0.0.1:8910)도 여기서 준비한다 — 포털 자체는 HAL 작업으로 설치되지만 nginx 접근 경로는 "접근 경로 준비"(§32.2 2단계)에 속한다(실제 환경 결함 수정: 이전에는 어디서도 만들지 않았다).
|
||||
# --portal-host none 이면 포털 접근 경로를 만들지 않는다(HAL 만 두는 호스트 — 포털은 다른 호스트에서 운영).
|
||||
if [ "$PORTAL_HOST" != "none" ]; then
|
||||
sed -e "s#{{PORTAL_HOST}}#$PORTAL_HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8910#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-portal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-portal.conf >/dev/null
|
||||
sudo -n ln -sf /etc/nginx/sites-available/ariweb-portal.conf /etc/nginx/sites-enabled/ariweb-portal.conf
|
||||
fi
|
||||
sudo -n nginx -t && sudo -n systemctl reload nginx
|
||||
echo "== 5/6 HAL 자체 건강 확인"
|
||||
curl -fsS http://127.0.0.1:8900/api/health | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const h=JSON.parse(s);console.log(' HAL',h.version,'db',h.db,'jobs',h.jobs,'bootstrap',JSON.stringify(h.bootstrap))})"
|
||||
curl -fsS --resolve "$HOST:443:127.0.0.1" "https://$HOST/api/health" >/dev/null && echo " https://$HOST/api/health OK (로컬 resolve)" || echo " 경고: https://$HOST 확인 실패 — DNS A 레코드($HOST → 이 서버)와 인증서를 확인하세요"
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# deploy/module-as-user.sh — 도입 모듈을 "그 모듈의 실행 계정" 으로 설치하기 위한 최소 파일 작업 도우미(§32).
|
||||
#
|
||||
# 왜 — Node Agent 는 master 계정으로 돌지만 도입 모듈(AriMail 등)은 서버에 이미 다른 계정(arihakdang 등)으로 설치되어 있고
|
||||
# 그 디렉터리(/srv/<모듈>/releases, data)는 그 계정만 쓸 수 있다. Agent 가 직접 쓰면 EACCES 로 끝나므로(실제 환경 검증에서 발견),
|
||||
# sudoers(deploy/sudoers/ariweb-agent)에 "이 스크립트만 root 가 아닌 임의 계정으로" 허용하고 Agent 가 `sudo -n -u <계정> bash <이 파일> <하위 명령>` 으로 부른다.
|
||||
# 하위 명령은 모듈 배치에 필요한 것만 두고, 모든 경로 인자는 --app-dir 아래(또는 명시한 artifact)로 제한한다. 비밀값은 다루지 않고 출력하지 않는다.
|
||||
#
|
||||
# 사용: module-as-user.sh <하위 명령> --app-dir <모듈 디렉터리> [인자…]
|
||||
# extract --tgz <artifact.tar.gz> --dest <appDir/releases/<ver>> 릴리스 디렉터리를 비우고 artifact 를 푼다
|
||||
# write --file <appDir 아래 파일> 표준 입력을 파일로 쓴다(REVISION.json·.previous.json)
|
||||
# switch --link <appDir/current> --target <릴리스 디렉터리> 심링크 원자 교체(ln → mv -T)
|
||||
# rename --dir <releases 아래 디렉터리> --target <releases 아래 새 이름> 디렉터리 이름 바꾸기(같은 버전 재설치 때 실행 중인 디렉터리 보존)
|
||||
# remove --dir <appDir/releases/<old>> 오래된 릴리스 삭제(releases 아래만)
|
||||
# backup-db --db <sqlite 파일> --out <백업 파일> [--node <node>] SQLite 온라인 백업(VACUUM INTO)
|
||||
# migrate --cwd <릴리스 디렉터리> --script <상대 경로> [--env-file <f>] [--node <node>] EnvironmentFile 을 읽어 마이그레이션 실행
|
||||
set -euo pipefail
|
||||
SUB="${1:-}"; shift || true
|
||||
APP_DIR=""; TGZ=""; DEST=""; FILE=""; LINK=""; TARGET=""; DIR=""; DB=""; OUT=""; NODE_BIN=""; CWD=""; SCRIPT=""; ENV_FILE=""
|
||||
while [ $# -gt 0 ]; do case "$1" in
|
||||
--app-dir) APP_DIR="$2"; shift 2;; --tgz) TGZ="$2"; shift 2;; --dest) DEST="$2"; shift 2;; --file) FILE="$2"; shift 2;; --link) LINK="$2"; shift 2;; --target) TARGET="$2"; shift 2;;
|
||||
--dir) DIR="$2"; shift 2;; --db) DB="$2"; shift 2;; --out) OUT="$2"; shift 2;; --node) NODE_BIN="$2"; shift 2;; --cwd) CWD="$2"; shift 2;; --script) SCRIPT="$2"; shift 2;; --env-file) ENV_FILE="$2"; shift 2;;
|
||||
*) echo "알 수 없는 인자 $1" >&2; exit 2;; esac; done
|
||||
[ -n "$APP_DIR" ] && [ -d "$APP_DIR" ] || { echo "--app-dir <존재하는 디렉터리> 가 필요합니다" >&2; exit 2; }
|
||||
APP_DIR="$(cd "$APP_DIR" && pwd -P)"
|
||||
# 경로가 appDir 아래인지(심링크를 따라간 실제 경로 기준, 아직 없는 경로는 부모 기준) 확인한다 — 다른 디렉터리를 건드리지 못하게.
|
||||
inside() { local p="$1" d real; d="$(dirname "$p")"; while [ ! -d "$d" ] && [ "$d" != "/" ]; do d="$(dirname "$d")"; done; real="$(cd "$d" && pwd -P)"; case "$real" in "$APP_DIR"|"$APP_DIR"/*) return 0;; esac; echo "경로가 모듈 디렉터리($APP_DIR) 밖입니다: $p" >&2; exit 3; }
|
||||
[ -n "$NODE_BIN" ] || NODE_BIN="$(command -v node || true)"
|
||||
case "$SUB" in
|
||||
extract)
|
||||
[ -f "$TGZ" ] || { echo "artifact 가 없습니다: $TGZ" >&2; exit 2; }; inside "$DEST"; case "$DEST" in "$APP_DIR"/releases/*) ;; *) echo "--dest 는 releases 아래여야 합니다" >&2; exit 3;; esac
|
||||
rm -rf "$DEST"; mkdir -p "$DEST"; tar -xzf "$TGZ" -C "$DEST"; echo "extracted $DEST";;
|
||||
write)
|
||||
inside "$FILE"; mkdir -p "$(dirname "$FILE")"; tmp="$FILE.tmp-$$"; cat > "$tmp"; mv -f "$tmp" "$FILE"; echo "written $FILE";;
|
||||
switch)
|
||||
inside "$LINK"; inside "$TARGET"; [ -d "$TARGET" ] || { echo "대상 디렉터리가 없습니다: $TARGET" >&2; exit 2; }
|
||||
ln -sfn "$TARGET" "$LINK.tmp-$$"; mv -Tf "$LINK.tmp-$$" "$LINK"; echo "switched $LINK -> $TARGET";;
|
||||
rename)
|
||||
inside "$DIR"; inside "$TARGET"; case "$DIR" in "$APP_DIR"/releases/*) ;; *) echo "--dir 는 releases 아래여야 합니다" >&2; exit 3;; esac; case "$TARGET" in "$APP_DIR"/releases/*) ;; *) echo "--target 은 releases 아래여야 합니다" >&2; exit 3;; esac
|
||||
mv -T "$DIR" "$TARGET"; echo "renamed $DIR -> $TARGET";;
|
||||
remove)
|
||||
inside "$DIR"; case "$DIR" in "$APP_DIR"/releases/*) ;; *) echo "--dir 는 releases 아래여야 합니다" >&2; exit 3;; esac; rm -rf "$DIR"; echo "removed $DIR";;
|
||||
backup-db)
|
||||
inside "$OUT"; [ -f "$DB" ] || { echo "skipped no_db"; exit 0; }; mkdir -p "$(dirname "$OUT")"
|
||||
# node:sqlite 의 VACUUM INTO 로 WAL 포함 일관된 사본을 만든다(원본 deploy.sh 의 python backup 과 같은 목적).
|
||||
"$NODE_BIN" --no-warnings -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync(process.argv[1],{readOnly:true});try{d.exec(\"VACUUM INTO '\"+process.argv[2].replace(/'/g,\"''\")+\"'\")}finally{d.close()}" "$DB" "$OUT"; echo "backup $OUT";;
|
||||
migrate)
|
||||
inside "$CWD"; [ -f "$CWD/$SCRIPT" ] || { echo "skipped no_migrate"; exit 0; }
|
||||
# systemd 유닛의 EnvironmentFile(DB_FILE·DATA_DIR 등)을 같은 계정으로 읽어 환경에 싣는다 — 없으면 모듈 기본 경로(릴리스 디렉터리 안)를 쓰게 되어 운영 DB 가 아닌 빈 DB 를 만드는 문제를 막는다.
|
||||
# systemd EnvironmentFile 문법(KEY=값, 값에 공백·따옴표 가능)은 bash 소스가 아니다 — 줄 단위로 읽어 따옴표를 벗기고 export 한다(실제 환경: MAIL_FROM_NAME=Ari Hak dang 에서 소스 실패).
|
||||
if [ -n "$ENV_FILE" ]; then [ -r "$ENV_FILE" ] || { echo "EnvironmentFile 을 읽을 수 없습니다: $ENV_FILE" >&2; exit 2; }
|
||||
while IFS= read -r line || [ -n "$line" ]; do line="${line%$'\r'}"; case "$line" in ''|'#'*) continue;; esac; key="${line%%=*}"; val="${line#*=}"; key="${key#export }"; key="${key// /}"
|
||||
case "$key" in *[!A-Za-z0-9_]*|'') continue;; esac; case "$val" in \"*\") val="${val#\"}"; val="${val%\"}";; \'*\') val="${val#\'}"; val="${val%\'}";; esac; export "$key=$val"; done < "$ENV_FILE"; fi
|
||||
cd "$CWD"; exec "$NODE_BIN" --no-warnings "$SCRIPT";;
|
||||
*) echo "알 수 없는 하위 명령: $SUB" >&2; exit 2;;
|
||||
esac
|
||||
@@ -7,3 +7,6 @@ master ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/sites-available/aw-*, /usr/b
|
||||
master ALL=(root) NOPASSWD: /usr/bin/ln -sf /etc/nginx/sites-available/aw-* /etc/nginx/sites-enabled/*, /usr/bin/ln -sf /etc/nginx/sites-available/ariweb-* /etc/nginx/sites-enabled/*, /usr/bin/rm -f /etc/nginx/sites-enabled/aw-*, /usr/bin/rm -f /etc/nginx/sites-available/aw-*, /usr/bin/rm -f /etc/systemd/system/ariweb-site-*
|
||||
master ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/nginx/*, /usr/bin/mkdir -p /var/www/*, /usr/bin/chown master\:master /var/www/*, /usr/bin/chmod * /etc/nginx/sites-available/aw-*, /usr/bin/chmod * /etc/systemd/system/ariweb-*, /usr/bin/chmod * /etc/nginx/conf.d/awstats.conf
|
||||
master ALL=(root) NOPASSWD: /usr/bin/true
|
||||
# 실행 계정이 다른 도입 모듈(AriMail 의 arihakdang 등) 설치: 이 도우미 스크립트만 root 가 아닌 계정으로 허용(agent/jobs/module.mjs 실행 계정 모드)
|
||||
Runas_Alias ARIWEB_SVC = ALL, !root
|
||||
master ALL=(ARIWEB_SVC) NOPASSWD: /usr/bin/bash /srv/ariweb/agent/current/deploy/module-as-user.sh *
|
||||
|
||||
+46
-3
@@ -23,18 +23,30 @@ bash /tmp/ariweb-hal-src/deploy/bootstrap-hal.sh --dist /tmp/ariweb-dist-0.1.1 -
|
||||
출력된 **운영자 설정 토큰(1회)** 으로 첫 운영자를 만든다: `POST https://hal.ariweb.net/ops/api/setup {setupToken, login, password}` (허용 네트워크 밖이면 `node hal/index.mjs grant --login <id>` 로 접근권 URL 발급).
|
||||
확인: `curl -s https://hal.ariweb.net/api/health` 의 `version`·`db`·`jobs`·`bootstrap.halRunningVersion`.
|
||||
|
||||
## 3. 첫 Node Agent(같은 서버, 역할 control+web)
|
||||
DNS 레코드가 아직 없으면 같은 서버의 Agent·포털이 `https://hal.ariweb.net` 에 닿도록 `/etc/hosts` 에 `127.0.0.1 hal.ariweb.net my.ariweb.net` 을 임시로 둔다(cloud-init 이 `/etc/hosts` 를 관리하는 호스트는 재부팅 때 지워질 수 있다 — DNS 레코드가 생기면 필요 없다).
|
||||
부트스트랩은 `my.ariweb.net` nginx 접근 경로(→ 127.0.0.1:8910)도 함께 만든다. HAL 만 두는 호스트(예: bear `hal21.ariweb.net`, 포털은 다른 호스트)는 `--portal-host none` 으로 포털 접근 경로를 만들지 않고, 인증서가 호스트별 certbot(webroot `/var/www/html`)이면 `--cert /etc/letsencrypt/live/<호스트> --acme /var/www/html` 을 준다(§7 참조).
|
||||
|
||||
## 3. 첫 Node Agent(같은 서버, 역할 control)
|
||||
운영자 콘솔 › 서버 › 새 서버(역할 control, 위치 ariweb.net) → 등록 토큰(10분·1회) →
|
||||
```bash
|
||||
curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token <토큰> --role control
|
||||
```
|
||||
운영자 승인 → `bootstrap.complete=true`, 수동 설치 잠김. 포털 연동 키: `node hal/index.mjs link-portal` (출력 값은 포털 보관소에 1회 입력).
|
||||
운영자 승인 → `bootstrap.complete=true`, 수동 설치 잠김. 모듈 기술서는 서버 역할을 배포 조건으로 두지 않으므로 한 대(control)에 portal·webmaker·arimail 을 모두 둘 수 있다.
|
||||
포털 연동 키: `node hal/index.mjs link-portal` 의 출력 값을 포털 보관소에 1회 입력한다. 포털 데이터 디렉터리(`/srv/ariweb/portal/data`)는 배포 전에 만들어 보관소를 먼저 채워도 된다(같은 `lib/vault.mjs`). 값을 화면·로그에 남기지 않으려면 서버 안에서 파이프로 바로 넣는다.
|
||||
|
||||
## 4. 모듈 배포(HAL 작업)
|
||||
운영자 콘솔 › 배포 릴리스 › 0.1.1 승인 확인 → 모듈 배포 순서: `portal` → `webmaker` → `arimail` → (`agent` 갱신). 각 작업의 대상별 결과·실제 버전·건강 상태를 확인한다. HAL 자체 건강이 실패하면 나머지를 배포하지 않는다.
|
||||
- portal: `/srv/ariweb/portal`, 유닛 `ariweb-portal`, nginx `my.ariweb.net`(와일드카드 인증서). 포털 보관소에 `service:hal`(link-portal 출력), `sso:webmaker`, `arimail:*` 키 입력.
|
||||
- webmaker: 기존 `/srv/webmaker` + `/data/webmaker` 유지(HAL 모듈 설명자가 기존 유닛명·데이터 경로를 발견해 보존), 설정 `ariweb.portalUrl`·`ariweb.secret`.
|
||||
- arimail: 기존 `/srv/arihakdang-mail` 유지, 마이그레이션 `0009_ariweb_queue.sql` 적용, 테넌트 `ariweb.net` 에 `ARIWEB_SSO_SECRET`.
|
||||
- arimail: 기존 `/srv/arihakdang-mail` 유지(실행 계정 `arihakdang` — Agent 가 `deploy/module-as-user.sh` 로 그 계정이 파일 작업을 하게 한다), 마이그레이션 `0009_ariweb_queue.sql` 적용(유닛의 `EnvironmentFile` 의 `DB_FILE`). 포털 › 운영자 › 메일 서버 등록(`POST /internal/ops/mail/servers`)에 setup-token(`<appDir>/data/setup-token`)·usage-key(기본 도메인 설정의 `USAGE_API_KEY`)를 넣고, 고객 도메인의 `ARIWEB_SSO_SECRET` 은 포털이 서비스 생성 때 스스로 넣는다.
|
||||
- 도입 모듈의 실제 버전은 `REVISION.json` 의 Suite 버전(0.1.1)이고 상류 버전(1.7.3·1.0.11)은 `upstreamVersion` 으로 함께 보인다.
|
||||
|
||||
## 4-1. 실제 배포(2026-10-26)에서 확인한 운영 사실
|
||||
- 서버가 한 대(역할 control)면 사이트 배포·백업 저장은 control 서버가 대체한다(`pickServer`). 백업을 같은 서버에 두려면 운영자 override(`/internal/ops/backup/options/:id/override-same-server`)가 필요하다.
|
||||
- 포털 메일 서버 등록(`POST /ops/api/portal/mail/servers`): `baseUrl https://webmail.ariweb.net`, `internalUrl http://127.0.0.1:8790`, `setupUrl https://mail.arihakdang.com:8791`(AriMail 전체 설정 API 는 기본 도메인 호스트에서만 응답하므로 기본 도메인 이름을 쓰고, 설정 포트가 loopback 전용이라 서버 `/etc/hosts` 에 `127.0.0.1 mail.arihakdang.com` 을 둔다), 비밀은 `setup-token`(`<appDir>/data/setup-token`)·`usage-key`(없으면 `POST /api/setup/usage-key {action:'generate'}` 로 발급, 기본 도메인 Host 로).
|
||||
- 발신 메일: 포털 보관소 `mailer:resend-key`(Webmaker 보관소의 Resend 키 재사용), 설정 `mailer: resend`, `mailFrom no-reply@ariweb.net`. 검증 때는 `resendBase` 를 로컬 포획 서버로 돌려 링크를 꺼냈고, 끝난 뒤 되돌렸다.
|
||||
- 수정 artifact 를 같은 릴리스(0.1.1)로 다시 내려면 `node hal/index.mjs register-release --manifest <manifest> --approve` 로 재등록(기존 버전은 artifact·커밋이 갱신된다)한 뒤 운영자 콘솔에서 `agent` → `hal` → 바뀐 모듈 순으로 배포한다. `bootstrap-release --commit` 으로 건강 응답의 main 커밋도 맞춘다.
|
||||
- 검증 데이터(`*@check.ariweb.net` 계정, `check-*/solo-*.ariweb.net` 도메인·메일 서비스)는 운영자 취소로 정리했고 보존 기간 뒤 삭제된다.
|
||||
|
||||
## 5. 검증(Release 0.1.1 완료 조건 34개)
|
||||
`docs/verification/` 의 로컬 E2E 결과와 실제 환경 확인 기록을 함께 둔다. 각 흐름은 버튼 성공이 아니라 최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전으로 판정한다. 충족하지 못한 흐름은 완료로 표시하지 않는다.
|
||||
@@ -42,3 +54,34 @@ curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https:/
|
||||
## 6. 되돌리기
|
||||
- 모듈: 운영자 콘솔 › 모듈 › 되돌리기(이전 릴리스 디렉터리 유지, 3개 보관).
|
||||
- HAL: `deploy/hal-restore.sh` + 재해복구 절차(§34, 기존 제어 위치 중지 확인 뒤 `--takeover`).
|
||||
|
||||
## 7. HAL 전용 호스트 부트스트랩 — bear `hal21.ariweb.net` (2026-10-27)
|
||||
대상: bear(`ssh bear`, 192.168.20.23 / 공인 121.152.149.45, master 계정, sudo NOPASSWD, Ubuntu 24.04, nginx 1.24, Node v24.22(엔진 요구 ≥22.13 충족), certbot 2.9 nginx 플러그인·`certbot.timer`). 기존 운영(Webmaker `webmaker.ibit.kr`·`hs-company.ariweb.net`, Gitea `source.ibit.kr`, Matrix, board/file/office/xmap, 기본 서버 `wh-default.conf`)은 건드리지 않는다. 이 호스트에는 **HAL 만** 둔다(포털·Webmaker·AriMail·Agent 는 다음 단계).
|
||||
|
||||
```bash
|
||||
# 개발 PC: main 이 깨끗한 상태(미추적 파일 없음)에서 빌드 — 전체 테스트 뒤 dist/0.1.1 (manifest.mainCommit = HEAD, dirty=false)
|
||||
node scripts/release.mjs build && node scripts/release.mjs verify --dist dist/0.1.1
|
||||
scp -r dist/0.1.1 bear:ariweb-deploy-0.1.1/dist
|
||||
|
||||
# bear: 호스트별 인증서(기존 인증서는 ibit.kr 7개뿐, ariweb.net 와일드카드 없음). 기본 서버가 /.well-known/acme-challenge/ 를 /var/www/html 로 서비스하므로 webroot 로 발급한다(nginx 설정을 건드리지 않음)
|
||||
sudo certbot certonly --webroot -w /var/www/html -d hal21.ariweb.net --non-interactive --keep-until-expiring
|
||||
sudo certbot reconfigure --cert-name hal21.ariweb.net --deploy-hook "systemctl reload nginx" # 갱신 뒤 reload(installer 가 없으므로)
|
||||
# bear: 부트스트랩(출력에 1회 설정 토큰이 있으므로 0600 파일로만 받는다)
|
||||
cd ~/ariweb-deploy-0.1.1 && mkdir -p -m 700 src && tar -xzf dist/hal-0.1.1.tar.gz -C src
|
||||
umask 077; bash src/deploy/bootstrap-hal.sh --dist ~/ariweb-deploy-0.1.1/dist --host hal21.ariweb.net \
|
||||
--cert /etc/letsencrypt/live/hal21.ariweb.net --acme /var/www/html --portal-host none > bootstrap.log 2>&1
|
||||
```
|
||||
- `--acme /var/www/html`: HAL vhost 의 acme 경로를 webroot 발급 경로와 같게 둔다(아니면 vhost 가 생긴 뒤 갱신 실패).
|
||||
- 만든 것: `/srv/ariweb/hal/{releases/0.1.1,current,data(0700)}`, `/var/www/ariweb-sites`, 유닛 `ariweb-hal`(enabled, 127.0.0.1:8900), nginx `sites-available/ariweb-hal.conf`(+enabled 링크), `conf.d/awstats.conf`(log_format awstats — 기존 wmstats 와 이름이 다름), `/etc/sudoers.d/ariweb-agent`(master 는 이미 NOPASSWD ALL 이라 추가 권한 없음). 포털 vhost 는 만들지 않았다.
|
||||
- 비밀값: 설정 토큰은 `/srv/ariweb/hal/data/setup.token`(0600)과 `~/ariweb-deploy-0.1.1/bootstrap.log`(0600)에만 있다. 첫 운영자 생성(`POST /ops/api/setup`)은 이 단계에서 하지 않았다 — `/ops` 는 허용 네트워크(기본 사설망 CIDR) 또는 접근권이 필요하다.
|
||||
- 알려진 사소한 결함: 5단계의 `https://<host>` 자체 확인은 `nginx reload` 직후 바로 실행되어 새 vhost 가 아직 적용되기 전에 "unrecognized name" 경고가 날 수 있다(거짓 경고, 몇 초 뒤 정상). 다음 릴리스에서 reload 뒤 짧은 대기를 넣는다.
|
||||
|
||||
검증(2026-10-27 03:08~03:12 UTC, 모두 통과): `systemctl is-active/is-enabled ariweb-hal` active/enabled · 127.0.0.1:8900 리슨(MainPID) · `nginx -t` 성공 후 reload, 기존 nginx 설정 파일 12개 sha256 변화 없음 · `https://hal21.ariweb.net/api/health` 외부(LAN 밖 fetch·LAN NAT 양쪽)에서 200, 인증서 CN=hal21.ariweb.net(Let's Encrypt, 2027-01-25 만료), HTTP→HTTPS 301 · 건강 응답 version 0.1.1·mainCommit ab75b7d·artifactSha256 f574d875…·bootstrap.complete=false(첫 Agent 전) · `systemctl restart ariweb-hal` 뒤 재기동·건강 ok(NRestarts 0, enabled 로 재부팅 지속; 서버 재부팅은 기존 서비스 중단이라 하지 않음) · 기존 서비스 회귀: nginx/webmaker/webmaker-setup/webmaker-gitea-reverse-tunnel/docker/certbot.timer active, 기존 vhost 8개 응답 코드 배포 전과 동일 · `certbot reconfigure` 의 모의 갱신 성공, `renew_hook = systemctl reload nginx`.
|
||||
|
||||
되돌리기(HAL 만 제거, 기존 서비스 영향 없음):
|
||||
```bash
|
||||
sudo systemctl disable --now ariweb-hal && sudo rm /etc/systemd/system/ariweb-hal.service && sudo systemctl daemon-reload
|
||||
sudo rm /etc/nginx/sites-enabled/ariweb-hal.conf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/conf.d/awstats.conf && sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo rm /etc/sudoers.d/ariweb-agent; sudo rm -rf /srv/ariweb /var/www/ariweb-sites # 데이터 포함 — 보관이 필요하면 /srv/ariweb/hal/data 먼저 복사
|
||||
sudo certbot delete --cert-name hal21.ariweb.net # 인증서까지 지울 때만
|
||||
```
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"generatedAt": "2026-10-26T13:18:11.518Z",
|
||||
"startedAt": "2026-10-26T13:15:47.881Z",
|
||||
"generatedAt": "2026-10-26T14:29:49.315Z",
|
||||
"startedAt": "2026-10-26T14:27:30.390Z",
|
||||
"env": {
|
||||
"agents": 4,
|
||||
"ports": {
|
||||
"hal": 42511,
|
||||
"portal": 32983,
|
||||
"wm": 35307,
|
||||
"am": 33813,
|
||||
"mail": 41473
|
||||
"hal": 40961,
|
||||
"portal": 39713,
|
||||
"wm": 37209,
|
||||
"am": 46099,
|
||||
"mail": 45135
|
||||
},
|
||||
"mailServerRegistered": true,
|
||||
"node": "v24.22.1"
|
||||
@@ -99,12 +99,12 @@
|
||||
{
|
||||
"name": "팀 그룹 만들기 → 현재 그룹 전환",
|
||||
"ok": true,
|
||||
"detail": "g_01a1785a66113e63a08c972cc5"
|
||||
"detail": "g_01a1789c0ba4cc346b9a816d13"
|
||||
},
|
||||
{
|
||||
"name": "이메일 초대(7일) → 초대 메일 링크",
|
||||
"ok": true,
|
||||
"detail": "uVdQrHwu9UjZmbh1PlCVMIPsJFxbHnvo"
|
||||
"detail": "92k9hZNmEVZ5s11c5YD_AKFXC9VLtCw2"
|
||||
},
|
||||
{
|
||||
"name": "초대 수락(1회용) → 별칭 user-02 · 역할 member",
|
||||
@@ -162,7 +162,7 @@
|
||||
"detail": {
|
||||
"count": 2,
|
||||
"team": {
|
||||
"id": "pv_01a1785a6358a08c3194ac03c2",
|
||||
"id": "pv_01a1789c0a26a92faf63434d5a",
|
||||
"price": 49000,
|
||||
"sites": 3
|
||||
},
|
||||
@@ -179,7 +179,7 @@
|
||||
"orderNo": "AW-20261026-0001",
|
||||
"status": "awaiting_payment",
|
||||
"total": 63800,
|
||||
"dueAt": "2026-11-02T13:15:52.839Z",
|
||||
"dueAt": "2026-11-02T14:27:35.064Z",
|
||||
"hasInstructions": true
|
||||
}
|
||||
},
|
||||
@@ -221,7 +221,7 @@
|
||||
"detail": {
|
||||
"sites": 3,
|
||||
"subs": 1,
|
||||
"nextBilling": "2026-11-26T13:15:52.905Z"
|
||||
"nextBilling": "2026-11-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -233,7 +233,7 @@
|
||||
"bill": {
|
||||
"sites": 3,
|
||||
"subs": 1,
|
||||
"nextBilling": "2026-11-26T13:15:52.905Z"
|
||||
"nextBilling": "2026-11-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -248,7 +248,7 @@
|
||||
"name": "사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "s_01a1785a670945da067e955da4",
|
||||
"id": "s_01a1789c0c9a313add86e28440",
|
||||
"lifecycle": "draft",
|
||||
"projectId": "00000000000000000000000000000001",
|
||||
"nextSteps": [
|
||||
@@ -283,7 +283,7 @@
|
||||
"name": "제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용)",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"portalRedirect": "http://127.0.0.1:35307/api/auth/sso?ticket=…",
|
||||
"portalRedirect": "http://127.0.0.1:37209/api/auth/sso?ticket=…",
|
||||
"webmaker": 302,
|
||||
"reuse": 401
|
||||
}
|
||||
@@ -299,7 +299,7 @@
|
||||
],
|
||||
"evidence": {
|
||||
"sso": {
|
||||
"portalRedirect": "http://127.0.0.1:35307/api/auth/sso?ticket=…",
|
||||
"portalRedirect": "http://127.0.0.1:37209/api/auth/sso?ticket=…",
|
||||
"webmaker": 302,
|
||||
"reuse": 401
|
||||
},
|
||||
@@ -320,7 +320,7 @@
|
||||
"name": "배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8",
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d",
|
||||
"steps": 10,
|
||||
"again": false,
|
||||
"siteCount": "reserved",
|
||||
@@ -351,7 +351,7 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"lifecycle": "needs_domain",
|
||||
"version": "20261026131553-rev-c1-000000",
|
||||
"version": "20261026142736-rev-c1-000000",
|
||||
"verify": "needs_domain",
|
||||
"health": "ok",
|
||||
"agentHasSite": true
|
||||
@@ -360,7 +360,7 @@
|
||||
],
|
||||
"evidence": {
|
||||
"d": {
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8",
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d",
|
||||
"steps": 10,
|
||||
"again": false,
|
||||
"siteCount": "reserved",
|
||||
@@ -383,7 +383,7 @@
|
||||
},
|
||||
"after": {
|
||||
"lifecycle": "needs_domain",
|
||||
"version": "20261026131553-rev-c1-000000",
|
||||
"version": "20261026142736-rev-c1-000000",
|
||||
"verify": "needs_domain",
|
||||
"health": "ok",
|
||||
"agentHasSite": true
|
||||
@@ -401,7 +401,7 @@
|
||||
"name": "도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"domainId": "d_01a1785a772932b8936927ad0a",
|
||||
"domainId": "d_01a1789c1cb665247a22f3d74a",
|
||||
"txtName": "_ariweb-verify.aw-e2e.localhost",
|
||||
"txtValueShape": "ariweb-verify=…",
|
||||
"copyable": true
|
||||
@@ -422,7 +422,7 @@
|
||||
],
|
||||
"evidence": {
|
||||
"dm": {
|
||||
"domainId": "d_01a1785a772932b8936927ad0a",
|
||||
"domainId": "d_01a1789c1cb665247a22f3d74a",
|
||||
"txtName": "_ariweb-verify.aw-e2e.localhost",
|
||||
"txtValueShape": "ariweb-verify=…",
|
||||
"copyable": true
|
||||
@@ -500,7 +500,7 @@
|
||||
"name": "사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "msv_01a1785affa395a9602c99bbb5",
|
||||
"id": "msv_01a1789ca5510f0181ed01486e",
|
||||
"status": "needs_setup",
|
||||
"tenant": true,
|
||||
"ariwebKind": "site_bound",
|
||||
@@ -517,7 +517,7 @@
|
||||
"name": "메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "mb_01a1785affda99dffac745d1e9",
|
||||
"id": "mb_01a1789ca58c25f8651b6813b4",
|
||||
"address": "kim@aw-e2e.localhost",
|
||||
"passwordShownOnce": "[redacted]",
|
||||
"mustChange": true,
|
||||
@@ -584,7 +584,7 @@
|
||||
],
|
||||
"evidence": {
|
||||
"svc": {
|
||||
"id": "msv_01a1785affa395a9602c99bbb5",
|
||||
"id": "msv_01a1789ca5510f0181ed01486e",
|
||||
"status": "needs_setup",
|
||||
"tenant": true,
|
||||
"ariwebKind": "site_bound",
|
||||
@@ -592,7 +592,7 @@
|
||||
"siteCountAllocated": 0
|
||||
},
|
||||
"mb": {
|
||||
"id": "mb_01a1785affda99dffac745d1e9",
|
||||
"id": "mb_01a1789ca58c25f8651b6813b4",
|
||||
"address": "kim@aw-e2e.localhost",
|
||||
"passwordShownOnce": "[redacted]",
|
||||
"mustChange": true,
|
||||
@@ -644,7 +644,7 @@
|
||||
"name": "메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "d_01a1785b2be9c7ee6d0d94a95c",
|
||||
"id": "d_01a1789cbfd52a398c63de3b05",
|
||||
"jobCancelled": null
|
||||
}
|
||||
},
|
||||
@@ -652,7 +652,7 @@
|
||||
"name": "독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "msv_01a1785b2bf14cefa9e1f81fd7",
|
||||
"id": "msv_01a1789cbfddf212586ba4b18d",
|
||||
"kind": "standalone",
|
||||
"mailServicesAllocated": 1,
|
||||
"sitesAllocated": 0
|
||||
@@ -661,7 +661,7 @@
|
||||
],
|
||||
"evidence": {
|
||||
"r": {
|
||||
"id": "msv_01a1785b2bf14cefa9e1f81fd7",
|
||||
"id": "msv_01a1789cbfddf212586ba4b18d",
|
||||
"kind": "standalone",
|
||||
"mailServicesAllocated": 1,
|
||||
"sitesAllocated": 0
|
||||
@@ -701,8 +701,8 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"status": "active",
|
||||
"firstDue": "2026-10-27T13:16:43.530Z",
|
||||
"nextRun": "2026-10-26T13:16:43.530Z"
|
||||
"firstDue": "2026-10-27T14:28:21.235Z",
|
||||
"nextRun": "2026-10-26T14:28:21.235Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -712,7 +712,7 @@
|
||||
"timedOut": false,
|
||||
"optionStatus": "active",
|
||||
"lastJob": null,
|
||||
"siteServer": "srv_01a1785a5d26219326f7ad26ec",
|
||||
"siteServer": "srv_01a1789c03fc9ee146184fa07c",
|
||||
"backup": {
|
||||
"status": "done",
|
||||
"tier": "monthly",
|
||||
@@ -733,14 +733,14 @@
|
||||
},
|
||||
"opt": {
|
||||
"status": "active",
|
||||
"firstDue": "2026-10-27T13:16:43.530Z",
|
||||
"nextRun": "2026-10-26T13:16:43.530Z"
|
||||
"firstDue": "2026-10-27T14:28:21.235Z",
|
||||
"nextRun": "2026-10-26T14:28:21.235Z"
|
||||
},
|
||||
"run1": {
|
||||
"timedOut": false,
|
||||
"optionStatus": "active",
|
||||
"lastJob": null,
|
||||
"siteServer": "srv_01a1785a5d26219326f7ad26ec",
|
||||
"siteServer": "srv_01a1789c03fc9ee146184fa07c",
|
||||
"backup": {
|
||||
"status": "done",
|
||||
"tier": "monthly",
|
||||
@@ -763,19 +763,19 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"backup": {
|
||||
"id": "bk_01a1785b465fad18eaeb8a679e",
|
||||
"createdAt": "2026-10-26T13:16:43.538Z",
|
||||
"id": "bk_01a1789cd5b1cc3ce60f2f4349",
|
||||
"createdAt": "2026-10-26T14:28:21.242Z",
|
||||
"scope": {
|
||||
"files": true,
|
||||
"db": true,
|
||||
"uploads": true,
|
||||
"config": true,
|
||||
"storeServerId": "srv_01a1785a5dd874fe86b67f0f38",
|
||||
"storeServerId": "srv_01a1789c04ba2129a08801860c",
|
||||
"verifyMethod": "sha256 + 격리 환경 읽기 확인"
|
||||
},
|
||||
"sizeBytes": 1896,
|
||||
"verifyState": "verified",
|
||||
"expiresAt": "2027-01-27T13:16:50.144Z",
|
||||
"expiresAt": "2027-01-27T14:28:26.674Z",
|
||||
"tier": "monthly"
|
||||
},
|
||||
"included": [
|
||||
@@ -809,7 +809,7 @@
|
||||
"name": "복원 요청 → 운영자 승인 대기 · 중복 요청 없음",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "rs_01a1785b4a2808cc16818090d9",
|
||||
"id": "rs_01a1789cd82e8d48adf5b09f5a",
|
||||
"status": "awaiting_approval",
|
||||
"dup": false,
|
||||
"finalConfirmation": {
|
||||
@@ -838,19 +838,19 @@
|
||||
"evidence": {
|
||||
"info": {
|
||||
"backup": {
|
||||
"id": "bk_01a1785b465fad18eaeb8a679e",
|
||||
"createdAt": "2026-10-26T13:16:43.538Z",
|
||||
"id": "bk_01a1789cd5b1cc3ce60f2f4349",
|
||||
"createdAt": "2026-10-26T14:28:21.242Z",
|
||||
"scope": {
|
||||
"files": true,
|
||||
"db": true,
|
||||
"uploads": true,
|
||||
"config": true,
|
||||
"storeServerId": "srv_01a1785a5dd874fe86b67f0f38",
|
||||
"storeServerId": "srv_01a1789c04ba2129a08801860c",
|
||||
"verifyMethod": "sha256 + 격리 환경 읽기 확인"
|
||||
},
|
||||
"sizeBytes": 1896,
|
||||
"verifyState": "verified",
|
||||
"expiresAt": "2027-01-27T13:16:50.144Z",
|
||||
"expiresAt": "2027-01-27T14:28:26.674Z",
|
||||
"tier": "monthly"
|
||||
},
|
||||
"included": [
|
||||
@@ -880,7 +880,7 @@
|
||||
"deferred": []
|
||||
},
|
||||
"rq": {
|
||||
"id": "rs_01a1785b4a2808cc16818090d9",
|
||||
"id": "rs_01a1789cd82e8d48adf5b09f5a",
|
||||
"status": "awaiting_approval",
|
||||
"dup": false,
|
||||
"finalConfirmation": {
|
||||
@@ -970,8 +970,8 @@
|
||||
"name": "외부 배포 사이트 생성(초안) + 저장본",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "s_01a1785c35f8d649e375f0c391",
|
||||
"group_id": "g_01a1785a66113e63a08c972cc5",
|
||||
"id": "s_01a1789dc3ada89ee43472fe26",
|
||||
"group_id": "g_01a1789c0ba4cc346b9a816d13",
|
||||
"name": "E2E 외부 사이트",
|
||||
"purpose": "e2e",
|
||||
"webmaker_project_id": "00000000000000000000000000000004",
|
||||
@@ -999,9 +999,9 @@
|
||||
"retention_until": null,
|
||||
"delete_after": null,
|
||||
"deleted_at": null,
|
||||
"created_by": "u_01a1785a65614c4deeec61ea3d",
|
||||
"created_at": "2026-10-26T13:17:51.480Z",
|
||||
"updated_at": "2026-10-26T13:17:51.485Z",
|
||||
"created_by": "u_01a1789c0afae66fc53b01be8a",
|
||||
"created_at": "2026-10-26T14:29:27.597Z",
|
||||
"updated_at": "2026-10-26T14:29:27.603Z",
|
||||
"runtime": "static",
|
||||
"consent_version": null,
|
||||
"pending_cleanup_json": null,
|
||||
@@ -1042,8 +1042,8 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"token": "[redacted]",
|
||||
"install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391",
|
||||
"expiresAt": "2026-10-26T13:27:51.761Z",
|
||||
"install": "curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26",
|
||||
"expiresAt": "2026-10-26T14:39:27.642Z",
|
||||
"status": "pending"
|
||||
}
|
||||
},
|
||||
@@ -1126,8 +1126,8 @@
|
||||
"agreed": false
|
||||
},
|
||||
"tok": {
|
||||
"install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391",
|
||||
"expiresAt": "2026-10-26T13:27:51.761Z"
|
||||
"install": "curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26",
|
||||
"expiresAt": "2026-10-26T14:39:27.642Z"
|
||||
},
|
||||
"conn": {
|
||||
"status": "connected",
|
||||
@@ -1311,7 +1311,7 @@
|
||||
"approve": 200,
|
||||
"sites": 1,
|
||||
"mailboxes": 5,
|
||||
"product": "pv_01a1785a6358a0946ad508ee8d"
|
||||
"product": "pv_01a1789c0a2573b629e03f3013"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1347,7 +1347,7 @@
|
||||
"approve": 200,
|
||||
"sites": 1,
|
||||
"mailboxes": 5,
|
||||
"product": "pv_01a1785a6358a0946ad508ee8d"
|
||||
"product": "pv_01a1789c0a2573b629e03f3013"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1430,9 +1430,9 @@
|
||||
"name": "요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "tr_01a1785c58c47561320ed6f007",
|
||||
"id": "tr_01a1789de6a46d148f37fda029",
|
||||
"status": "requested",
|
||||
"expiresAt": "2026-11-02T13:18:00.388Z",
|
||||
"expiresAt": "2026-11-02T14:29:36.548Z",
|
||||
"bobNotified": true
|
||||
}
|
||||
},
|
||||
@@ -1460,7 +1460,7 @@
|
||||
"bad": "final_confirmation_mismatch",
|
||||
"ok": 200,
|
||||
"status": "completed",
|
||||
"cutover": "2026-10-26T13:18:00.432Z"
|
||||
"cutover": "2026-10-26T14:29:36.598Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1489,9 +1489,9 @@
|
||||
"excluded": 3
|
||||
},
|
||||
"rq": {
|
||||
"id": "tr_01a1785c58c47561320ed6f007",
|
||||
"id": "tr_01a1789de6a46d148f37fda029",
|
||||
"status": "requested",
|
||||
"expiresAt": "2026-11-02T13:18:00.388Z",
|
||||
"expiresAt": "2026-11-02T14:29:36.548Z",
|
||||
"bobNotified": true
|
||||
},
|
||||
"acc": {
|
||||
@@ -1503,7 +1503,7 @@
|
||||
"bad": "final_confirmation_mismatch",
|
||||
"ok": 200,
|
||||
"status": "completed",
|
||||
"cutover": "2026-10-26T13:18:00.432Z"
|
||||
"cutover": "2026-10-26T14:29:36.598Z"
|
||||
},
|
||||
"after": {
|
||||
"aliceSees": 404,
|
||||
@@ -1664,9 +1664,9 @@
|
||||
"running": {
|
||||
"complete": true,
|
||||
"firstInstall": false,
|
||||
"releaseVersion": null,
|
||||
"mainCommit": null,
|
||||
"artifactSha256": null,
|
||||
"releaseVersion": "0.1.1",
|
||||
"mainCommit": "[redacted]",
|
||||
"artifactSha256": "[redacted]",
|
||||
"halRunningVersion": "0.1.1",
|
||||
"firstAgent": "[redacted]",
|
||||
"manualInstallLocked": true
|
||||
@@ -1726,9 +1726,9 @@
|
||||
"running": {
|
||||
"complete": true,
|
||||
"firstInstall": false,
|
||||
"releaseVersion": null,
|
||||
"mainCommit": null,
|
||||
"artifactSha256": null,
|
||||
"releaseVersion": "0.1.1",
|
||||
"mainCommit": "[redacted]",
|
||||
"artifactSha256": "[redacted]",
|
||||
"halRunningVersion": "0.1.1",
|
||||
"firstAgent": "[redacted]",
|
||||
"manualInstallLocked": true
|
||||
@@ -1781,7 +1781,7 @@
|
||||
"detail": {
|
||||
"status": "done",
|
||||
"canRetry": false,
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8"
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1806,7 +1806,7 @@
|
||||
"dep": {
|
||||
"status": "done",
|
||||
"canRetry": false,
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8"
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d"
|
||||
},
|
||||
"rt": {
|
||||
"status": 400,
|
||||
@@ -1832,7 +1832,7 @@
|
||||
"detail": {
|
||||
"title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다",
|
||||
"category": "billing",
|
||||
"link": "/orders/o_01a1785a6686ffeba5d2781add"
|
||||
"link": "/orders/o_01a1789c0c18393dba2213c118"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1848,7 +1848,7 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"status": "approved",
|
||||
"approvedAt": "2026-10-26T13:15:52.905Z"
|
||||
"approvedAt": "2026-10-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1856,7 +1856,7 @@
|
||||
"n": {
|
||||
"title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다",
|
||||
"category": "billing",
|
||||
"link": "/orders/o_01a1785a6686ffeba5d2781add"
|
||||
"link": "/orders/o_01a1789c0c18393dba2213c118"
|
||||
},
|
||||
"a": {
|
||||
"action": "order.approve",
|
||||
@@ -1864,7 +1864,7 @@
|
||||
},
|
||||
"same": {
|
||||
"status": "approved",
|
||||
"approvedAt": "2026-10-26T13:15:52.905Z"
|
||||
"approvedAt": "2026-10-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1907,9 +1907,9 @@
|
||||
"rpo_minutes": 30,
|
||||
"snapshot_interval_minutes": 60,
|
||||
"retention_count": 3,
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external",
|
||||
"updated_at": "2026-10-26T13:18:08.570Z",
|
||||
"updated_by": "op_01a1785a5c27caed1a96f25d99"
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026142730/dr-external",
|
||||
"updated_at": "2026-10-26T14:29:46.345Z",
|
||||
"updated_by": "op_01a1789c02fd72d51f469e4d25"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1969,9 +1969,9 @@
|
||||
"rpo_minutes": 30,
|
||||
"snapshot_interval_minutes": 60,
|
||||
"retention_count": 3,
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external",
|
||||
"updated_at": "2026-10-26T13:18:08.570Z",
|
||||
"updated_by": "op_01a1785a5c27caed1a96f25d99"
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026142730/dr-external",
|
||||
"updated_at": "2026-10-26T14:29:46.345Z",
|
||||
"updated_by": "op_01a1789c02fd72d51f469e4d25"
|
||||
},
|
||||
"snap": {
|
||||
"verify": "ok",
|
||||
@@ -2005,7 +2005,7 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"halJobs": 18
|
||||
@@ -2017,7 +2017,7 @@
|
||||
"detail": {
|
||||
"sessionKept": "[redacted]",
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"same": true
|
||||
@@ -2036,7 +2036,7 @@
|
||||
"evidence": {
|
||||
"before": {
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"halJobs": 18
|
||||
@@ -2044,7 +2044,7 @@
|
||||
"pr": {
|
||||
"sessionKept": "[redacted]",
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"same": true
|
||||
@@ -2075,8 +2075,8 @@
|
||||
"ok": true,
|
||||
"detail": [
|
||||
{
|
||||
"id": "s_01a1785af3ab8d0d00e0063124",
|
||||
"group_id": "g_01a1785a663da04c2c5802e990",
|
||||
"id": "s_01a1789c994d3878d056f3d2fc",
|
||||
"group_id": "g_01a1789c0bd144ad600dd3bc46",
|
||||
"name": "동시 A",
|
||||
"purpose": "e2e",
|
||||
"webmaker_project_id": "00000000000000000000000000000002",
|
||||
@@ -2104,9 +2104,9 @@
|
||||
"retention_until": null,
|
||||
"delete_after": null,
|
||||
"deleted_at": null,
|
||||
"created_by": "u_01a1785a663df67cc036d6bc02",
|
||||
"created_at": "2026-10-26T13:16:28.971Z",
|
||||
"updated_at": "2026-10-26T13:16:28.978Z",
|
||||
"created_by": "u_01a1789c0bd1e6e269d0df3589",
|
||||
"created_at": "2026-10-26T14:28:11.213Z",
|
||||
"updated_at": "2026-10-26T14:28:11.225Z",
|
||||
"runtime": "static",
|
||||
"consent_version": null,
|
||||
"pending_cleanup_json": null,
|
||||
@@ -2125,8 +2125,8 @@
|
||||
"nextJob": null
|
||||
},
|
||||
{
|
||||
"id": "s_01a1785af3aeacb2a4c3e486f3",
|
||||
"group_id": "g_01a1785a663da04c2c5802e990",
|
||||
"id": "s_01a1789c9951c330ce1ad58f13",
|
||||
"group_id": "g_01a1789c0bd144ad600dd3bc46",
|
||||
"name": "동시 B",
|
||||
"purpose": "e2e",
|
||||
"webmaker_project_id": "00000000000000000000000000000003",
|
||||
@@ -2154,9 +2154,9 @@
|
||||
"retention_until": null,
|
||||
"delete_after": null,
|
||||
"deleted_at": null,
|
||||
"created_by": "u_01a1785a663df67cc036d6bc02",
|
||||
"created_at": "2026-10-26T13:16:28.974Z",
|
||||
"updated_at": "2026-10-26T13:16:28.980Z",
|
||||
"created_by": "u_01a1789c0bd1e6e269d0df3589",
|
||||
"created_at": "2026-10-26T14:28:11.217Z",
|
||||
"updated_at": "2026-10-26T14:28:11.228Z",
|
||||
"runtime": "static",
|
||||
"consent_version": null,
|
||||
"pending_cleanup_json": null,
|
||||
@@ -2214,8 +2214,8 @@
|
||||
"name": "외부 배포 사이트 생성(초안) + 저장본",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "s_01a1785c35f8d649e375f0c391",
|
||||
"group_id": "g_01a1785a66113e63a08c972cc5",
|
||||
"id": "s_01a1789dc3ada89ee43472fe26",
|
||||
"group_id": "g_01a1789c0ba4cc346b9a816d13",
|
||||
"name": "E2E 외부 사이트",
|
||||
"purpose": "e2e",
|
||||
"webmaker_project_id": "00000000000000000000000000000004",
|
||||
@@ -2243,9 +2243,9 @@
|
||||
"retention_until": null,
|
||||
"delete_after": null,
|
||||
"deleted_at": null,
|
||||
"created_by": "u_01a1785a65614c4deeec61ea3d",
|
||||
"created_at": "2026-10-26T13:17:51.480Z",
|
||||
"updated_at": "2026-10-26T13:17:51.485Z",
|
||||
"created_by": "u_01a1789c0afae66fc53b01be8a",
|
||||
"created_at": "2026-10-26T14:29:27.597Z",
|
||||
"updated_at": "2026-10-26T14:29:27.603Z",
|
||||
"runtime": "static",
|
||||
"consent_version": null,
|
||||
"pending_cleanup_json": null,
|
||||
@@ -2286,8 +2286,8 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"token": "[redacted]",
|
||||
"install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391",
|
||||
"expiresAt": "2026-10-26T13:27:51.761Z",
|
||||
"install": "curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26",
|
||||
"expiresAt": "2026-10-26T14:39:27.642Z",
|
||||
"status": "pending"
|
||||
}
|
||||
},
|
||||
@@ -2483,8 +2483,8 @@
|
||||
"overall": "partial",
|
||||
"children": [
|
||||
"srv_missing_e2e:failed:서버가 없습니다",
|
||||
"srv_01a1785a5d26219326f7ad26ec:done:",
|
||||
"srv_01a1785a5dd874fe86b67f0f38:done:"
|
||||
"srv_01a1789c04ba2129a08801860c:done:",
|
||||
"srv_01a1789c03fc9ee146184fa07c:done:"
|
||||
],
|
||||
"timedOut": false
|
||||
}
|
||||
@@ -2522,8 +2522,8 @@
|
||||
"overall": "partial",
|
||||
"children": [
|
||||
"srv_missing_e2e:failed:서버가 없습니다",
|
||||
"srv_01a1785a5d26219326f7ad26ec:done:",
|
||||
"srv_01a1785a5dd874fe86b67f0f38:done:"
|
||||
"srv_01a1789c04ba2129a08801860c:done:",
|
||||
"srv_01a1789c03fc9ee146184fa07c:done:"
|
||||
],
|
||||
"timedOut": false
|
||||
},
|
||||
@@ -2562,7 +2562,7 @@
|
||||
"name": "critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"id": "em_01a1785c79d6aa634fc6516b7b",
|
||||
"id": "em_01a1789e0e13d2c9660482e88c",
|
||||
"noCred": "approvals_insufficient",
|
||||
"wrongStep": "wrong_step",
|
||||
"badIds": "identifiers_mismatch",
|
||||
@@ -2570,7 +2570,7 @@
|
||||
"approved": "approved",
|
||||
"approvals": 3,
|
||||
"credential": "[redacted]",
|
||||
"expiresAt": "2026-10-26T13:48:08.882Z"
|
||||
"expiresAt": "2026-10-26T14:59:46.671Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2591,14 +2591,14 @@
|
||||
"evidence": {
|
||||
"healthy": "hal_healthy",
|
||||
"sess": {
|
||||
"id": "em_01a1785c79d6aa634fc6516b7b",
|
||||
"id": "em_01a1789e0e13d2c9660482e88c",
|
||||
"noCred": "approvals_insufficient",
|
||||
"wrongStep": "wrong_step",
|
||||
"badIds": "identifiers_mismatch",
|
||||
"badText": "final_confirm_mismatch",
|
||||
"approved": "approved",
|
||||
"approvals": 3,
|
||||
"expiresAt": "2026-10-26T13:48:08.882Z"
|
||||
"expiresAt": "2026-10-26T14:59:46.671Z"
|
||||
},
|
||||
"perf": {
|
||||
"forbidden": "action_forbidden",
|
||||
@@ -2650,8 +2650,8 @@
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"status": "readonly",
|
||||
"deleteAfter": "2026-11-25T13:18:00.329Z",
|
||||
"readonlyUntil": "2026-11-25T13:18:00.329Z"
|
||||
"deleteAfter": "2026-11-25T14:29:36.479Z",
|
||||
"readonlyUntil": "2026-11-25T14:29:36.479Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2677,8 +2677,8 @@
|
||||
},
|
||||
"un": {
|
||||
"status": "readonly",
|
||||
"deleteAfter": "2026-11-25T13:18:00.329Z",
|
||||
"readonlyUntil": "2026-11-25T13:18:00.329Z"
|
||||
"deleteAfter": "2026-11-25T14:29:36.479Z",
|
||||
"readonlyUntil": "2026-11-25T14:29:36.479Z"
|
||||
},
|
||||
"sweep": {
|
||||
"sweep": 200,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 로컬 종단 간 검증 기록 — 2026-10-26
|
||||
|
||||
생성: 2026-10-26T13:18:11.518Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/)
|
||||
생성: 2026-10-26T14:29:49.315Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/)
|
||||
|
||||
실행 환경: 실제 HAL·고객 포털·Node Agent(명령 stub 모드) 프로세스 4대 + 가짜 Webmaker/AriMail/발신 메일 서버(127.0.0.1). 실 DNS·실 메일 왕복·실 서버는 포함하지 않는다.
|
||||
|
||||
@@ -88,20 +88,20 @@
|
||||
|
||||
### 2. 개인 사용자 그룹 생성과 다른 사용자 초대
|
||||
|
||||
- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a1785a66113e63a08c972cc5
|
||||
- ✔ 이메일 초대(7일) → 초대 메일 링크 — uVdQrHwu9UjZmbh1PlCVMIPsJFxbHnvo
|
||||
- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a1789c0ba4cc346b9a816d13
|
||||
- ✔ 이메일 초대(7일) → 초대 메일 링크 — 92k9hZNmEVZ5s11c5YD_AKFXC9VLtCw2
|
||||
- ✔ 초대 수락(1회용) → 별칭 user-02 · 역할 member — {"alias":"user-02","role":"member","reuse":"invite_invalid"}
|
||||
- ✔ 권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할 — {"requiredRole":["owner","admin"],"yourRole":"member"}
|
||||
- ✔ 그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음 — {"team":{"live":0,"purchased":0,"inUse":0,"reserved":0,"unallocated":0},"personal":{"live":0,"purchased":0,"inUse":0,"reserved":0,"unallocated":0}}
|
||||
|
||||
### 3. 상품 주문, 결제 확인, 권리 활성화
|
||||
|
||||
- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a1785a6358a08c3194ac03c2","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]}
|
||||
- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T13:15:52.839Z","hasInstructions":true}
|
||||
- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a1789c0a26a92faf63434d5a","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]}
|
||||
- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T14:27:35.064Z","hasInstructions":true}
|
||||
- ✔ 입금 확인 전 승인 거부(409 payment_not_confirmed) — payment_not_confirmed
|
||||
- ✔ 운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시 — {"status":"awaiting_approval","delta":["sites","web_disk","web_out","mail_storage","mail_msgs","mailboxes","mail_services","mail_storage","mail_msgs","mailboxes"]}
|
||||
- ✔ 운영자 승인 → 권리 활성화(재승인은 중복 없음) — {"granted":10,"already":true}
|
||||
- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T13:15:52.905Z"}
|
||||
- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T14:27:35.130Z"}
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -112,25 +112,25 @@
|
||||
"bill": {
|
||||
"sites": 3,
|
||||
"subs": 1,
|
||||
"nextBilling": "2026-11-26T13:15:52.905Z"
|
||||
"nextBilling": "2026-11-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 초안 사이트 생성
|
||||
|
||||
- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a1785a670945da067e955da4","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]}
|
||||
- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a1789c0c9a313add86e28440","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]}
|
||||
- ✔ 사이트 목록 필터(초안)·사용량 요약에 예약 0 — {"drafts":1,"sitesReserved":0,"sitesAllocated":0}
|
||||
|
||||
### 5. Webmaker에서 페이지 제작
|
||||
|
||||
- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:35307/api/auth/sso?ticket=…","webmaker":302,"reuse":401}
|
||||
- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:37209/api/auth/sso?ticket=…","webmaker":302,"reuse":401}
|
||||
- ✔ Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과 — {"versions":1,"precheckOk":true}
|
||||
|
||||
```json
|
||||
{
|
||||
"sso": {
|
||||
"portalRedirect": "http://127.0.0.1:35307/api/auth/sso?ticket=…",
|
||||
"portalRedirect": "http://127.0.0.1:37209/api/auth/sso?ticket=…",
|
||||
"webmaker": 302,
|
||||
"reuse": 401
|
||||
},
|
||||
@@ -143,14 +143,14 @@
|
||||
|
||||
### 6. 관리형 배포
|
||||
|
||||
- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a1785a6757555a33d6269bd8","steps":10,"again":false,"siteCount":"reserved","server":null}
|
||||
- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a1789c0ce4dcffc59011e15d","steps":10,"again":false,"siteCount":"reserved","server":null}
|
||||
- ✔ HAL·Agent 를 거쳐 작업 완료 대기(최대 40초) — {"status":"done","steps":["accept:done","prepare_version:done","prepare_target:done","apply:done","data_check:done","start:done","internal_check:done","external_check:done","domain_https:done","done:done"]}
|
||||
- ✔ 배포 뒤 사이트 상태·버전·건강 — {"lifecycle":"needs_domain","version":"20261026131553-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true}
|
||||
- ✔ 배포 뒤 사이트 상태·버전·건강 — {"lifecycle":"needs_domain","version":"20261026142736-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true}
|
||||
|
||||
```json
|
||||
{
|
||||
"d": {
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8",
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d",
|
||||
"steps": 10,
|
||||
"again": false,
|
||||
"siteCount": "reserved",
|
||||
@@ -173,7 +173,7 @@
|
||||
},
|
||||
"after": {
|
||||
"lifecycle": "needs_domain",
|
||||
"version": "20261026131553-rev-c1-000000",
|
||||
"version": "20261026142736-rev-c1-000000",
|
||||
"verify": "needs_domain",
|
||||
"health": "ok",
|
||||
"agentHasSite": true
|
||||
@@ -183,14 +183,14 @@
|
||||
|
||||
### 7. 도메인 연결과 HTTPS 발급
|
||||
|
||||
- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1785a772932b8936927ad0a","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true}
|
||||
- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1789c1cb665247a22f3d74a","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true}
|
||||
- ✔ 도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시 — {}
|
||||
- ✔ (정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다 — {"queuedProbes":0}
|
||||
|
||||
```json
|
||||
{
|
||||
"dm": {
|
||||
"domainId": "d_01a1785a772932b8936927ad0a",
|
||||
"domainId": "d_01a1789c1cb665247a22f3d74a",
|
||||
"txtName": "_ariweb-verify.aw-e2e.localhost",
|
||||
"txtValueShape": "ariweb-verify=…",
|
||||
"copyable": true
|
||||
@@ -233,9 +233,9 @@
|
||||
|
||||
### 10. 사이트 대표 메일과 메일함 생성
|
||||
|
||||
- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1785affa395a9602c99bbb5","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0}
|
||||
- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1789ca5510f0181ed01486e","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0}
|
||||
- ✔ 같은 사이트에 두 번째 대표 메일 거부 — mail_domain_in_use
|
||||
- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a1785affda99dffac745d1e9","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true}
|
||||
- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a1789ca58c25f8651b6813b4","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true}
|
||||
- ✔ 초대 방식 메일함(링크 만료·재발송·사용 완료 상태) — {"inviteState":"pending","accepted":200,"reuse":"invite_used"}
|
||||
- ✔ DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing) — {"ok":false,"missing":3,"items":["mx:missing","spf:missing","dkim:missing","dmarc:recommended"]}
|
||||
- ✔ 활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등 — {"status":"awaiting_external","waitReason":"DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요","sameJob":true,"steps":["entitlement:done","dns:waiting","prepare:pending","verify:pending","activate:pending"]}
|
||||
@@ -244,7 +244,7 @@
|
||||
```json
|
||||
{
|
||||
"svc": {
|
||||
"id": "msv_01a1785affa395a9602c99bbb5",
|
||||
"id": "msv_01a1789ca5510f0181ed01486e",
|
||||
"status": "needs_setup",
|
||||
"tenant": true,
|
||||
"ariwebKind": "site_bound",
|
||||
@@ -252,7 +252,7 @@
|
||||
"siteCountAllocated": 0
|
||||
},
|
||||
"mb": {
|
||||
"id": "mb_01a1785affda99dffac745d1e9",
|
||||
"id": "mb_01a1789ca58c25f8651b6813b4",
|
||||
"address": "kim@aw-e2e.localhost",
|
||||
"passwordShownOnce": "[redacted]",
|
||||
"mustChange": true,
|
||||
@@ -287,13 +287,13 @@
|
||||
|
||||
### 12. 독립 메일 서비스 생성
|
||||
|
||||
- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a1785b2be9c7ee6d0d94a95c","jobCancelled":null}
|
||||
- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1785b2bf14cefa9e1f81fd7","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0}
|
||||
- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a1789cbfd52a398c63de3b05","jobCancelled":null}
|
||||
- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1789cbfddf212586ba4b18d","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0}
|
||||
|
||||
```json
|
||||
{
|
||||
"r": {
|
||||
"id": "msv_01a1785b2bf14cefa9e1f81fd7",
|
||||
"id": "msv_01a1789cbfddf212586ba4b18d",
|
||||
"kind": "standalone",
|
||||
"mailServicesAllocated": 1,
|
||||
"sitesAllocated": 0
|
||||
@@ -307,8 +307,8 @@
|
||||
|
||||
- ✔ 백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리 — {"granted":7,"orderStatus":"approved"}
|
||||
- ✔ 백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서) — {"eligible":true,"reasons":[],"deferred":[],"policy":null,"status":200}
|
||||
- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T13:16:43.530Z","nextRun":"2026-10-26T13:16:43.530Z"}
|
||||
- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a1785a5d26219326f7ad26ec","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0}
|
||||
- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T14:28:21.235Z","nextRun":"2026-10-26T14:28:21.235Z"}
|
||||
- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a1789c03fc9ee146184fa07c","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0}
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -321,14 +321,14 @@
|
||||
},
|
||||
"opt": {
|
||||
"status": "active",
|
||||
"firstDue": "2026-10-27T13:16:43.530Z",
|
||||
"nextRun": "2026-10-26T13:16:43.530Z"
|
||||
"firstDue": "2026-10-27T14:28:21.235Z",
|
||||
"nextRun": "2026-10-26T14:28:21.235Z"
|
||||
},
|
||||
"run1": {
|
||||
"timedOut": false,
|
||||
"optionStatus": "active",
|
||||
"lastJob": null,
|
||||
"siteServer": "srv_01a1785a5d26219326f7ad26ec",
|
||||
"siteServer": "srv_01a1789c03fc9ee146184fa07c",
|
||||
"backup": {
|
||||
"status": "done",
|
||||
"tier": "monthly",
|
||||
@@ -342,8 +342,8 @@
|
||||
|
||||
### 14. 백업 복원과 외부 사이트 확인
|
||||
|
||||
- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a1785b465fad18eaeb8a679e","createdAt":"2026-10-26T13:16:43.538Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a1785a5dd874fe86b67f0f38","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1896,"verifyState":"verified","expiresAt":"2027-01-2
|
||||
- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a1785b4a2808cc16818090d9","status":"awaiting_approval","dup":false,"finalConfirmation":{"group":"E2E 팀","site":"E2E 사이트","domain":null}}
|
||||
- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a1789cd5b1cc3ce60f2f4349","createdAt":"2026-10-26T14:28:21.242Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a1789c04ba2129a08801860c","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1896,"verifyState":"verified","expiresAt":"2027-01-2
|
||||
- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a1789cd82e8d48adf5b09f5a","status":"awaiting_approval","dup":false,"finalConfirmation":{"group":"E2E 팀","site":"E2E 사이트","domain":null}}
|
||||
- ✔ 운영자 최종 확인(그룹·사이트·백업 시각) 불일치 거부 → 일치 승인(멱등) — {"bad":"final_confirmation_mismatch","ok":400}
|
||||
- ✔ 9단계 작업 진행 → 사용자 확인 필요(보호 시점·되돌리기 가능) — {"steps":[]}
|
||||
|
||||
@@ -351,19 +351,19 @@
|
||||
{
|
||||
"info": {
|
||||
"backup": {
|
||||
"id": "bk_01a1785b465fad18eaeb8a679e",
|
||||
"createdAt": "2026-10-26T13:16:43.538Z",
|
||||
"id": "bk_01a1789cd5b1cc3ce60f2f4349",
|
||||
"createdAt": "2026-10-26T14:28:21.242Z",
|
||||
"scope": {
|
||||
"files": true,
|
||||
"db": true,
|
||||
"uploads": true,
|
||||
"config": true,
|
||||
"storeServerId": "srv_01a1785a5dd874fe86b67f0f38",
|
||||
"storeServerId": "srv_01a1789c04ba2129a08801860c",
|
||||
"verifyMethod": "sha256 + 격리 환경 읽기 확인"
|
||||
},
|
||||
"sizeBytes": 1896,
|
||||
"verifyState": "verified",
|
||||
"expiresAt": "2027-01-27T13:16:50.144Z",
|
||||
"expiresAt": "2027-01-27T14:28:26.674Z",
|
||||
"tier": "monthly"
|
||||
},
|
||||
"included": [
|
||||
@@ -393,7 +393,7 @@
|
||||
"deferred": []
|
||||
},
|
||||
"rq": {
|
||||
"id": "rs_01a1785b4a2808cc16818090d9",
|
||||
"id": "rs_01a1789cd82e8d48adf5b09f5a",
|
||||
"status": "awaiting_approval",
|
||||
"dup": false,
|
||||
"finalConfirmation": {
|
||||
@@ -445,10 +445,10 @@
|
||||
|
||||
### 16. 외부 배포 사이트 생성과 배포
|
||||
|
||||
- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1785c35f8d649e375f0c391","group_id":"g_01a1785a66113e63a08c972cc5","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou
|
||||
- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1789dc3ada89ee43472fe26","group_id":"g_01a1789c0ba4cc346b9a816d13","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou
|
||||
- ✔ 배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전 — {"notProvided":"[redacted]","consentVersion":"external-deploy-2026-10-0.1.1","agreed":false}
|
||||
- ✔ 동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전) — {"before":"consent_required","consent":200}
|
||||
- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391","expiresAt":"2026-10-26T13:27:51.761Z","status":"pending"}
|
||||
- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26","expiresAt":"2026-10-26T14:39:27.642Z","status":"pending"}
|
||||
- ✔ 고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속) — {"agentId":"[redacted]"}
|
||||
- ✔ 포털 연결 확인(외부 Agent 상태 connected) — {"status":"connected","stale":false,"halAgentId":"[redacted]"}
|
||||
- ✔ 배포 전 확인(외부: 관리형 용량 확인 없음) — {"ok":true,"blocks":[]}
|
||||
@@ -465,8 +465,8 @@
|
||||
"agreed": false
|
||||
},
|
||||
"tok": {
|
||||
"install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391",
|
||||
"expiresAt": "2026-10-26T13:27:51.761Z"
|
||||
"install": "curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26",
|
||||
"expiresAt": "2026-10-26T14:39:27.642Z"
|
||||
},
|
||||
"conn": {
|
||||
"status": "connected",
|
||||
@@ -554,7 +554,7 @@
|
||||
- ✔ 축소 미리보기(팀→베이직): 줄여야 할 항목(메일함 6→5) 구체 표시 — {"shortages":["mailboxes:1"]}
|
||||
- ✔ 축소 요청 → 보류(사용 중 권리 즉시 회수 없음) — {"fits":false,"hold":"holding","sitesStillPurchased":3}
|
||||
- ✔ 정리: 메일함 배정 6→5 축소 → 재검증 적합 → 축소 주문 생성 — {"allocate":200,"fits":true,"shortages":[],"order":"awaiting_payment"}
|
||||
- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a1785a6358a0946ad508ee8d"}
|
||||
- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a1789c0a2573b629e03f3013"}
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -589,7 +589,7 @@
|
||||
"approve": 200,
|
||||
"sites": 1,
|
||||
"mailboxes": 5,
|
||||
"product": "pv_01a1785a6358a0946ad508ee8d"
|
||||
"product": "pv_01a1789c0a2573b629e03f3013"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -623,10 +623,10 @@
|
||||
### 20. 사이트 소유권 이전
|
||||
|
||||
- ✔ 이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목) — {"sites":1,"domains":1,"siteMail":1,"needed":{"backup":1,"web_disk":1073741824,"mail_msgs":500,"mail_storage":2147483648,"mailboxes":6},"excluded":3}
|
||||
- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a1785c58c47561320ed6f007","status":"requested","expiresAt":"2026-11-02T13:18:00.388Z","bobNotified":true}
|
||||
- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a1789de6a46d148f37fda029","status":"requested","expiresAt":"2026-11-02T14:29:36.548Z","bobNotified":true}
|
||||
- ✔ (정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개) — {"endBackup":200,"alloc":200}
|
||||
- ✔ 받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내) — {"status":200,"tstatus":"reviewing","hold":null}
|
||||
- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T13:18:00.432Z"}
|
||||
- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T14:29:36.598Z"}
|
||||
- ✔ 완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사 — {"aliceSees":404,"bobSees":200,"bobUsageSites":0,"audit":true}
|
||||
|
||||
```json
|
||||
@@ -645,9 +645,9 @@
|
||||
"excluded": 3
|
||||
},
|
||||
"rq": {
|
||||
"id": "tr_01a1785c58c47561320ed6f007",
|
||||
"id": "tr_01a1789de6a46d148f37fda029",
|
||||
"status": "requested",
|
||||
"expiresAt": "2026-11-02T13:18:00.388Z",
|
||||
"expiresAt": "2026-11-02T14:29:36.548Z",
|
||||
"bobNotified": true
|
||||
},
|
||||
"acc": {
|
||||
@@ -659,7 +659,7 @@
|
||||
"bad": "final_confirmation_mismatch",
|
||||
"ok": 200,
|
||||
"status": "completed",
|
||||
"cutover": "2026-10-26T13:18:00.432Z"
|
||||
"cutover": "2026-10-26T14:29:36.598Z"
|
||||
},
|
||||
"after": {
|
||||
"aliceSees": 404,
|
||||
@@ -738,7 +738,7 @@
|
||||
|
||||
### 22. HAL 우선 배포 후 HAL을 통한 전체 모듈 배포
|
||||
|
||||
- ✔ 릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서 — {"releases":["0.1.2:approved","0.1.1:approved"],"running":{"complete":true,"firstInstall":false,"releaseVersion":null,"mainCommit":null,"artifactSha256":null,"halRunningVersion":"0.1.1","firstAgent":"[redacted]","manualInstallLocked":true},"order":["hal","agent","portal","webmaker","arimail","backup
|
||||
- ✔ 릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서 — {"releases":["0.1.2:approved","0.1.1:approved"],"running":{"complete":true,"firstInstall":false,"releaseVersion":"0.1.1","mainCommit":"[redacted]","artifactSha256":"[redacted]","halRunningVersion":"0.1.1","firstAgent":"[redacted]","manualInstallLocked":true},"order":["hal","agent","portal","webmaker
|
||||
- ✔ HAL 이 아직 0.1.2 가 아닐 때 다른 모듈 0.1.2 배포 → hal_first 거부 — {"status":"failed","error":"hal_first"}
|
||||
- ✔ 승인되지 않은 릴리스는 거부 / 수동 설치 잠김 표시 — {"manualInstallLocked":true,"complete":true}
|
||||
- ✔ HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인) — {"status":"done","steps":["precheck:done","fanout:done","collect:done"],"error":null,"children":["done:"],"agentJobs":[],"timedOut":false}
|
||||
@@ -753,9 +753,9 @@
|
||||
"running": {
|
||||
"complete": true,
|
||||
"firstInstall": false,
|
||||
"releaseVersion": null,
|
||||
"mainCommit": null,
|
||||
"artifactSha256": null,
|
||||
"releaseVersion": "0.1.1",
|
||||
"mainCommit": "[redacted]",
|
||||
"artifactSha256": "[redacted]",
|
||||
"halRunningVersion": "0.1.1",
|
||||
"firstAgent": "[redacted]",
|
||||
"manualInstallLocked": true
|
||||
@@ -798,7 +798,7 @@
|
||||
|
||||
### 23. 실패 작업의 재시도와 되돌리기
|
||||
|
||||
- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a1785a6757555a33d6269bd8"}
|
||||
- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a1789c0ce4dcffc59011e15d"}
|
||||
- ✔ 재시도 요청(POST /api/sites/:id/jobs/:jobId/retry) — {"status":400,"error":"job_not_retryable"}
|
||||
- ✔ 되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부 — {"status":409,"error":"no_previous","message":"되돌릴 이전 버전이 없습니다"}
|
||||
|
||||
@@ -807,7 +807,7 @@
|
||||
"dep": {
|
||||
"status": "done",
|
||||
"canRetry": false,
|
||||
"jobId": "j_01a1785a6757555a33d6269bd8"
|
||||
"jobId": "j_01a1789c0ce4dcffc59011e15d"
|
||||
},
|
||||
"rt": {
|
||||
"status": 400,
|
||||
@@ -823,16 +823,16 @@
|
||||
|
||||
### 24. 운영자 승인, 감사 기록, 고객 알림의 일치
|
||||
|
||||
- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a1785a6686ffeba5d2781add"}
|
||||
- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a1789c0c18393dba2213c118"}
|
||||
- ✔ 감사 기록: order.approve 는 삭제 불가(immutable) — {"action":"order.approve","immutable":true}
|
||||
- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T13:15:52.905Z"}
|
||||
- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T14:27:35.130Z"}
|
||||
|
||||
```json
|
||||
{
|
||||
"n": {
|
||||
"title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다",
|
||||
"category": "billing",
|
||||
"link": "/orders/o_01a1785a6686ffeba5d2781add"
|
||||
"link": "/orders/o_01a1789c0c18393dba2213c118"
|
||||
},
|
||||
"a": {
|
||||
"action": "order.approve",
|
||||
@@ -840,7 +840,7 @@
|
||||
},
|
||||
"same": {
|
||||
"status": "approved",
|
||||
"approvedAt": "2026-10-26T13:15:52.905Z"
|
||||
"approvedAt": "2026-10-26T14:27:35.130Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -848,7 +848,7 @@
|
||||
### 25. HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련
|
||||
|
||||
- ✔ 설정 전 준비 상태(미설정 항목) — {"ready":false,"missingConfig":["rto_minutes","rpo_minutes","snapshot_interval_minutes","retention_count"],"lastDrill":null,"lastSnapshot":null,"config":{"rto_minutes":null,"rpo_minutes":null,"snapshot_interval_minutes":null,"retention_count":null,"external_store":null},"label":"정식 운영 준비 전(복구 훈련 또는
|
||||
- ✔ RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정 — {"id":1,"rto_minutes":60,"rpo_minutes":30,"snapshot_interval_minutes":60,"retention_count":3,"external_store":"/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external","updated_at":"2026-10-26T13:18:08.570Z","updated_by":"op_01a1785a5c27caed1a96f25d99"}
|
||||
- ✔ RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정 — {"id":1,"rto_minutes":60,"rpo_minutes":30,"snapshot_interval_minutes":60,"retention_count":3,"external_store":"/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026142730/dr-external","updated_at":"2026-10-26T14:29:46.345Z","updated_by":"op_01a1789c02fd72d51f469e4d25"}
|
||||
- ✔ 복구 사본 생성·검증·외부 보관(vault.key 제외) — {"verify":"ok","external":true,"sha":true}
|
||||
- ✔ 복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료 — {"early":"restore_not_approved","noStop":"old_control_not_confirmed","command":true,"incomplete":"checks_incomplete","final":"completed"}
|
||||
- ✔ 복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료 — {"first":"failed","second":"success","ready":true,"measured":{}}
|
||||
@@ -880,9 +880,9 @@
|
||||
"rpo_minutes": 30,
|
||||
"snapshot_interval_minutes": 60,
|
||||
"retention_count": 3,
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external",
|
||||
"updated_at": "2026-10-26T13:18:08.570Z",
|
||||
"updated_by": "op_01a1785a5c27caed1a96f25d99"
|
||||
"external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026142730/dr-external",
|
||||
"updated_at": "2026-10-26T14:29:46.345Z",
|
||||
"updated_by": "op_01a1789c02fd72d51f469e4d25"
|
||||
},
|
||||
"snap": {
|
||||
"verify": "ok",
|
||||
@@ -907,15 +907,15 @@
|
||||
|
||||
### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지
|
||||
|
||||
- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a1785b141b2aba663738d139","status":"awaiting_external"},"halJobs":18}
|
||||
- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a1785b141b2aba663738d139","status":"awaiting_external"},"same":true}
|
||||
- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a1789cb9c0e6f752db2a47b6","status":"awaiting_external"},"halJobs":18}
|
||||
- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a1789cb9c0e6f752db2a47b6","status":"awaiting_external"},"same":true}
|
||||
- ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":18,"sameCount":true}
|
||||
|
||||
```json
|
||||
{
|
||||
"before": {
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"halJobs": 18
|
||||
@@ -923,7 +923,7 @@
|
||||
"pr": {
|
||||
"sessionKept": "[redacted]",
|
||||
"mailJob": {
|
||||
"id": "j_01a1785b141b2aba663738d139",
|
||||
"id": "j_01a1789cb9c0e6f752db2a47b6",
|
||||
"status": "awaiting_external"
|
||||
},
|
||||
"same": true
|
||||
@@ -939,7 +939,7 @@
|
||||
### 27. 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공
|
||||
|
||||
- ✔ 밥 개인 그룹: 베이직(사이트 1) 주문·승인 — {"granted":6}
|
||||
- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1785af3ab8d0d00e0063124","group_id":"g_01a1785a663da04c2c5802e990","name":"동시 A","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000002","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_sta
|
||||
- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1789c994d3878d056f3d2fc","group_id":"g_01a1789c0bd144ad600dd3bc46","name":"동시 A","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000002","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_sta
|
||||
- ✔ 두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족) — {"ok":1,"blocked":"precheck_failed","shortage":"block"}
|
||||
- ✔ (정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환 — {"cancel":200,"cleanup":200,"unallocated":0}
|
||||
|
||||
@@ -955,10 +955,10 @@
|
||||
|
||||
### 28. 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리
|
||||
|
||||
- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1785c35f8d649e375f0c391","group_id":"g_01a1785a66113e63a08c972cc5","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou
|
||||
- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1789dc3ada89ee43472fe26","group_id":"g_01a1789c0ba4cc346b9a816d13","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou
|
||||
- ✔ 배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전 — {"notProvided":"[redacted]","consentVersion":"external-deploy-2026-10-0.1.1","agreed":false}
|
||||
- ✔ 동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전) — {"before":"consent_required","consent":200}
|
||||
- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391","expiresAt":"2026-10-26T13:27:51.761Z","status":"pending"}
|
||||
- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:40961/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40961 --token … --external --site s_01a1789dc3ada89ee43472fe26","expiresAt":"2026-10-26T14:39:27.642Z","status":"pending"}
|
||||
- ✔ 고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속) — {"agentId":"[redacted]"}
|
||||
- ✔ 포털 연결 확인(외부 Agent 상태 connected) — {"status":"connected","stale":false,"halAgentId":"[redacted]"}
|
||||
- ✔ 배포 전 확인(외부: 관리형 용량 확인 없음) — {"ok":true,"blocks":[]}
|
||||
@@ -1039,7 +1039,7 @@
|
||||
|
||||
### 30. 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정
|
||||
|
||||
- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"failed","errorCode":"partial_failure","overall":"partial","children":["srv_missing_e2e:failed:서버가 없습니다","srv_01a1785a5d26219326f7ad26ec:done:","srv_01a1785a5dd874fe86b67f0f38:done:"],"timedOut":false}
|
||||
- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"failed","errorCode":"partial_failure","overall":"partial","children":["srv_missing_e2e:failed:서버가 없습니다","srv_01a1789c04ba2129a08801860c:done:","srv_01a1789c03fc9ee146184fa07c:done:"],"timedOut":false}
|
||||
- ✔ 모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업 — {"health":"done","drift":0,"reconcile":200,"overviewComponents":["HAL:ok","portal:unknown","webmaker:unknown","arimail:unknown","backup:ok","observability:ok"]}
|
||||
- ✔ 되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부 — {"step1":"confirm_impact_required","step2":"identifiers_mismatch"}
|
||||
|
||||
@@ -1051,8 +1051,8 @@
|
||||
"overall": "partial",
|
||||
"children": [
|
||||
"srv_missing_e2e:failed:서버가 없습니다",
|
||||
"srv_01a1785a5d26219326f7ad26ec:done:",
|
||||
"srv_01a1785a5dd874fe86b67f0f38:done:"
|
||||
"srv_01a1789c04ba2129a08801860c:done:",
|
||||
"srv_01a1789c03fc9ee146184fa07c:done:"
|
||||
],
|
||||
"timedOut": false
|
||||
},
|
||||
@@ -1079,21 +1079,21 @@
|
||||
### 31. 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구
|
||||
|
||||
- ✔ HAL 정상 상태에서 선언 거부(hal_healthy) — hal_healthy
|
||||
- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a1785c79d6aa634fc6516b7b","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T13:48:08.882Z"}
|
||||
- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a1789e0e13d2c9660482e88c","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T14:59:46.671Z"}
|
||||
- ✔ 일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료 — {"forbidden":"action_forbidden","outOfScope":"out_of_scope","badCred":"bad_credential","performed":400,"revoked":true,"afterRevoke":"credential_revoked","reconcile":200,"closed":"closed"}
|
||||
|
||||
```json
|
||||
{
|
||||
"healthy": "hal_healthy",
|
||||
"sess": {
|
||||
"id": "em_01a1785c79d6aa634fc6516b7b",
|
||||
"id": "em_01a1789e0e13d2c9660482e88c",
|
||||
"noCred": "approvals_insufficient",
|
||||
"wrongStep": "wrong_step",
|
||||
"badIds": "identifiers_mismatch",
|
||||
"badText": "final_confirm_mismatch",
|
||||
"approved": "approved",
|
||||
"approvals": 3,
|
||||
"expiresAt": "2026-10-26T13:48:08.882Z"
|
||||
"expiresAt": "2026-10-26T14:59:46.671Z"
|
||||
},
|
||||
"perf": {
|
||||
"forbidden": "action_forbidden",
|
||||
@@ -1113,7 +1113,7 @@
|
||||
- ✔ Source 옵션 주문(대상 사이트)·승인 → source 권리 — {"granted":7}
|
||||
- ✔ Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보 — {"activate":200,"status":"active","provider":"gitea","changes":1,"clone":true}
|
||||
- ✔ export(저장본 내려받기) — {"status":200,"type":"application/gzip"}
|
||||
- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T13:18:00.329Z","readonlyUntil":"2026-11-25T13:18:00.329Z"}
|
||||
- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T14:29:36.479Z","readonlyUntil":"2026-11-25T14:29:36.479Z"}
|
||||
- ✔ 삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음 — {"sweep":200,"statusAfter":"readonly"}
|
||||
|
||||
```json
|
||||
@@ -1131,8 +1131,8 @@
|
||||
},
|
||||
"un": {
|
||||
"status": "readonly",
|
||||
"deleteAfter": "2026-11-25T13:18:00.329Z",
|
||||
"readonlyUntil": "2026-11-25T13:18:00.329Z"
|
||||
"deleteAfter": "2026-11-25T14:29:36.479Z",
|
||||
"readonlyUntil": "2026-11-25T14:29:36.479Z"
|
||||
},
|
||||
"sweep": {
|
||||
"sweep": 200,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,810 @@
|
||||
# 0.1.1 실제 환경 배포·검증 기록 (ariweb.net 호스트, 2026-10-26)
|
||||
|
||||
생성: 2026-10-26T14:33:19.592Z · 검증기 `real-env-check.mjs`(서버에서 실행, 실제 HAL·포털·Node Agent·Webmaker·AriMail 대상) · 배포 기준 main `3951be5`
|
||||
|
||||
## 상태 요약
|
||||
|
||||
- 배포: HAL 부트스트랩(`deploy/bootstrap-hal.sh`) → 첫 운영자 → 첫 Node Agent(역할 control, 승인 → 부트스트랩 완료·수동 설치 잠김) → `link-portal` → HAL 작업으로 portal → webmaker → arimail 배포, 이후 수정 artifact 를 같은 릴리스 0.1.1 로 재등록해 HAL 작업으로 agent·hal·portal 갱신. 모든 모듈의 실제 버전 0.1.1·건강 ok(아래 재검증).
|
||||
- 공개 DNS: `hal.ariweb.net`·`my.ariweb.net` A 레코드는 아직 없다(이번 작업은 공개 DNS 를 바꾸지 않았다). 서버 `/etc/hosts` 임시 항목으로 같은 서버 안 통신만 확인했고, 외부 주소 접속(흐름 8 등)은 레코드 생성 뒤 확인한다.
|
||||
- 실제 외부 발신 1건: 포털 mailer(Resend, `no-reply@ariweb.net`)로 `icqmomo@gmail.com` 에 확인 메일 발송 — Resend 수락 id `01a17899-8c6e-7574-b85b-3c1015fe94b5`(14:24 UTC). 수신함 도착은 사람이 확인한다.
|
||||
- 흐름 판정(34개): 실제 확인 7 · 부분 확인 7 · 미수행 20 · 실패 0. **0.1.1 출시 완료는 선언하지 않는다** — 지시서 기준(최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전) 중 외부 접속·메일 왕복·Webmaker 페이지 제작이 남아 있다.
|
||||
|
||||
## 사람이 해야 남는 일
|
||||
|
||||
1. hosting.co.kr 패널에 `hal.ariweb.net`·`my.ariweb.net` A 레코드(187.53.133.173) 생성 → 흐름 8·외부 접속 확인.
|
||||
2. 메일 왕복 검증용 수신 주소(자동 응답 메일함) 설정(`PUT /internal/ops/mail/policy {verifier}`) → 흐름 11.
|
||||
3. Webmaker 제작 화면에서 검증 사이트의 페이지를 만들어 저장 → 흐름 5·6·13·14 의 나머지(관리형 배포·백업 실행·복원·외부 확인).
|
||||
|
||||
## 배포 경과와 발견한 결함(모두 코드로 수정해 main 에 반영, VERSION_HISTORY "실제 환경 배포에서 발견한 결함 수정")
|
||||
|
||||
1. 부트스트랩 4단계 중단 — `/etc/letsencrypt/live` 는 root 전용이라 일반 계정의 인증서 존재 확인이 거짓 → `sudo -n test -f`.
|
||||
2. 포털 artifact 에 상품정책 시드(`catalog/`) 누락 → 릴리스 빌더 경로 추가.
|
||||
3. AriMail 은 다른 계정(arihakdang)으로 운영 → Agent 실행 계정 모드(`deploy/module-as-user.sh`, sudoers 최소 규칙). 내려받은 artifact 0600·현재 경로 심링크·umask 077 로 만들어진 0700 디렉터리(릴리스 빌더 모드 정규화) 등 권한 문제 4건.
|
||||
4. AriMail 마이그레이션이 `DB_FILE` 없이 릴리스 디렉터리에 빈 DB 를 만들 뻔함 → 유닛 EnvironmentFile 을 실행 계정으로 읽어 실행(공백 값 파싱 포함). 사후 절차 실패 시 current 되돌리기.
|
||||
5. 도입 모듈 실제 버전이 상류 버전(1.7.3·1.0.11)으로 보고돼 HAL 판정·drift 표시 오류 → REVISION.json 의 Suite 버전 사용(설치 결과·heartbeat 모두).
|
||||
6. Webmaker 운영 유닛의 강화 지시문이 템플릿에 지워질 뻔함 → keepExistingUnit. 데이터 백업이 root 소유 파일(이전 마이그레이션 기록)에 막힘 → 읽기 실패 관대 처리(+서버의 해당 파일 소유권 정정).
|
||||
7. 같은 버전 재설치가 실행 중인 디렉터리를 지움 → 임시 풀기·`<ver>.prev-<시각>` 보존. 새 유닛(포털) enable 누락. 부트스트랩 릴리스 기록 행 없음(UPDATE 무효).
|
||||
8. my.ariweb.net nginx 접근 경로가 어디서도 만들어지지 않음 → 부트스트랩이 설치.
|
||||
9. 서버 한 대(control)라 web·backup 역할 서버를 고를 수 없음 → control 대체. AriMail 설정 API 는 기본 도메인 호스트에서만 응답(Node fetch 는 Host 변경 불가) → 설정 주소를 기본 도메인 이름으로, 서버 hosts 항목.
|
||||
|
||||
## Release 0.1.1 완료 조건 34개 — 실제 환경 판정
|
||||
|
||||
| # | 흐름 | 판정 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 1 | 신규 고객 가입과 이메일 확인 | 실제 확인 | 실제 포털·실제 mailer 경로(포획 서버) — 외부 수신함 도착은 별도 1건으로 확인 |
|
||||
| 2 | 개인 사용자 그룹 생성과 다른 사용자 초대 | 실제 확인 | |
|
||||
| 3 | 상품 주문, 결제 확인, 권리 활성화 | 실제 확인 | |
|
||||
| 4 | 초안 사이트 생성 | 실제 확인 | 실제 Webmaker 에 프로젝트 생성(내부 API 서명 호출) |
|
||||
| 5 | Webmaker에서 페이지 제작 | 부분 확인 | 제작 화면 진입(SSO)은 실제 Webmaker 로 확인 · 페이지 제작(AI 생성·저장)은 사람이 Webmaker 화면에서 수행해야 하므로 미수행 |
|
||||
| 6 | 관리형 배포 | 부분 확인 | 배포 요청이 거부됨: precheck_failed — 배포 전 확인에서 차단된 항목이 있습니다 (저장본이 없어 배포할 내용이 없음; 사람이 Webmaker 에서 페이지를 저장한 뒤 수행) |
|
||||
| 7 | 도메인 연결과 HTTPS 발급 | 미수행 | 실제 소유 도메인과 공개 DNS(TXT·A) 변경이 필요 — 이번 작업은 공개 DNS 를 바꾸지 않음 |
|
||||
| 8 | 외부 주소에서 사이트 확인 | 미수행 | 공개 DNS(my/hal/사이트 도메인) 생성 전 — 외부 주소 접속은 레코드 생성 뒤 확인 |
|
||||
| 9 | 사이트 정보와 접속·사용량 통계 확인 | 부분 확인 | 화면 데이터 제공 확인 · 실제 접속 통계(awstats 로그 집계)는 공개 DNS 로 외부 방문이 생긴 뒤 확인 가능 |
|
||||
| 10 | 사이트 대표 메일과 메일함 생성 | 부분 확인 | 실제 AriMail 테넌트·메일함 생성 확인 · 활성화는 도메인 DNS(MX/SPF/DKIM) 반영이 필요해 외부 대기 |
|
||||
| 11 | 외부 송신, 수신, 회신 확인 | 미수행 | 검증용 수신 주소(자동 응답 메일함)와 고객 도메인 MX 반영 필요 |
|
||||
| 12 | 독립 메일 서비스 생성 | 실제 확인 | |
|
||||
| 13 | 백업 옵션 구매와 첫 백업 확인 | 부분 확인 | 백업 옵션 주문·승인·활성화는 실제 확인. 수동 백업 실행은 사이트가 초안(미배포)이라 정기 실행기가 옵션을 site_not_deployed 로 중지해 Agent 백업까지 가지 않음(설계대로) — Webmaker 에서 페이지를 저장·배포한 뒤 다시 수행. 판정은 검증기의 시간 초과를 운영자가 DB 상태(옵션 paused: site_not_deployed)로 확인해 부분 확인으로 정정 |
|
||||
| 14 | 백업 복원과 외부 사이트 확인 | 미수행 | 복원 승인(3단계)·외부 사이트 확인은 공개 DNS 와 복원 대상 배포본 필요(흐름 6 선행) |
|
||||
| 15 | 관리형 사이트 서버 이전과 외부 확인 | 미수행 | 관리 서버가 1대뿐이라 서버 간 이전 미수행 |
|
||||
| 16 | 외부 배포 사이트 생성과 배포 | 미수행 | 외부 Linux 서버 없음 |
|
||||
| 17 | 사용량 경고와 한도 동작 확인 | 미수행 | 실제 사용량이 한도에 이르려면 운영 트래픽·시간 경과 필요 |
|
||||
| 18 | 상품 축소 보류와 정리 후 적용 | 미수행 | 축소 보류는 실제 사용 중 리소스가 필요(로컬 E2E 확인) |
|
||||
| 19 | 사이트 수 반환과 대표 메일의 독립 전환 | 미수행 | 사이트 수 반환·독립 전환은 흐름 10 활성화(외부 DNS) 뒤 가능 |
|
||||
| 20 | 사이트 소유권 이전 | 미수행 | 두 번째 소유자 그룹의 심사 절차를 포함해 로컬 E2E 로 확인, 실환경 미수행 |
|
||||
| 21 | 중지, 해지, 보존 예정일 표시 | 미수행 | 보존 예정일 표시는 시간 경과 필요(로컬 E2E 확인) |
|
||||
| 22 | HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 | 실제 확인 | HAL 부트스트랩 → 첫 Agent → HAL 작업으로 portal·webmaker·arimail·agent 배포, 실제 버전 0.1.1·건강 ok |
|
||||
| 23 | 실패 작업의 재시도와 되돌리기 | 미수행 | 실패 작업 재시도·되돌리기는 배포 중 실제로 2회 발생(백업 실패→재시도 성공, 마이그레이션 실패→되돌리기) — 기록은 VERSION_HISTORY·이 문서의 배포 경과 참고 |
|
||||
| 24 | 운영자 승인, 감사 기록, 고객 알림의 일치 | 부분 확인 | |
|
||||
| 25 | HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련 | 미수행 | 재해복구 훈련은 다른 호스트 복원이 필요(로컬 E2E 부분 확인) |
|
||||
| 26 | 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 | 실제 확인 | |
|
||||
| 27 | 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 | 미수행 | 동시 요청 경합은 로컬 E2E 로 확인, 실환경 미수행 |
|
||||
| 28 | 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 | 미수행 | 외부 Linux 서버 없음 |
|
||||
| 29 | HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시 | 미수행 | HAL 장애 상태 표시는 로컬 E2E 로 확인(흐름 26 에서 재시작 중 복구만 실환경 확인) |
|
||||
| 30 | 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 | 부분 확인 | 모듈 갱신(수정 artifact 재등록 → HAL 작업으로 agent·hal 갱신) 4건 완료 확인 · 일부 서버 실패·대상별 되돌리기는 서버 1대뿐이라 미수행 |
|
||||
| 31 | 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 | 미수행 | 긴급 복구 절차는 로컬 E2E 로 확인 |
|
||||
| 32 | Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료 | 미수행 | Source 옵션 30일 만료는 시간 경과 필요 |
|
||||
| 33 | 메일 서비스의 사이트 대표→독립 전환 후 항목 유지 | 미수행 | 흐름 19 와 같은 외부 DNS 선행 조건 |
|
||||
| 34 | 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 | 미수행 | 시간 경과 필요(로컬 E2E 확인) |
|
||||
|
||||
## 흐름별 단계와 증거
|
||||
|
||||
### 1. 신규 고객 가입과 이메일 확인 — 실제 확인
|
||||
|
||||
실제 포털·실제 mailer 경로(포획 서버) — 외부 수신함 도착은 별도 1건으로 확인
|
||||
|
||||
- ✔ 가입 → 세션 발급(실제 포털 https://my.ariweb.net) — {"status":200}
|
||||
- ✔ 확인 전 그룹 생성 차단(403 email_unverified) — "email_unverified"
|
||||
- ✔ 확인 메일이 실제 발신 경로(mailer resend → 포획 서버)로 나감 · 토큰 추출 — {"subject":"[Ariweb] 이메일 주소를 확인해 주세요","from":"no-reply@ariweb.net","linkHost":"https://my.ariweb.net"}
|
||||
- ✔ 이메일 확인(1회용) → 재사용 거부 — {"first":200,"second":"token_used"}
|
||||
- ✔ 내 정보: 확인됨 · 개인 그룹 · owner — {"verifiedAt":"2026-10-26T14:31:15.078Z","group":"personal","role":"owner"}
|
||||
- ✔ 중복 가입은 존재를 드러내지 않는 안내(409) — "signup_unavailable"
|
||||
- ✔ 로그인 화면(로그인 상태면 302, 아니면 200)·기기(세션) 목록 — {"loggedIn":302,"anonymous":200,"sessions":"[redacted]"}
|
||||
|
||||
```json
|
||||
{
|
||||
"verifiedAt": "2026-10-26T14:31:15.078Z",
|
||||
"group": "personal",
|
||||
"role": "owner"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 개인 사용자 그룹 생성과 다른 사용자 초대 — 실제 확인
|
||||
|
||||
- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — "g_01a1789f67b12a216acd1955fa"
|
||||
- ✔ 이메일 초대 → 초대 메일(포획)에서 링크 — "upaCVcL43bFzzZaGTtGZi_tU2J64oJEA"
|
||||
- ✔ 초대 수락(1회용) → member · 재사용 거부 — {"reuse":"invite_invalid"}
|
||||
- ✔ 권한 없는 구성원의 변경 API → 403 — {"requiredRole":["owner","admin"],"yourRole":"member"}
|
||||
|
||||
### 3. 상품 주문, 결제 확인, 권리 활성화 — 실제 확인
|
||||
|
||||
- ✔ 상품 목록(공개 버전·가격·한도) — {"count":2,"id":"pv_01a178834fac072b5600ce1e60","name":"팀","price":49000,"sites":3}
|
||||
- ✔ 주문 제출(수동 결제, 독립 메일 옵션 포함) → 결제 대기 — {"id":"o_01a1789f68c3298aec8ad337e8","group_id":"g_01a1789f67b12a216acd1955fa","order_no":"AW-20261026-0003","kind":"new","product_version_id":"pv_01a178834fac072b5600ce1e60","items_json":"[{\"type\":\"product\",\"id\":\"team\",\"name\":\"팀\",\"qty\":1,\"unit_price\":49000,\"amount\":49000,\"tax\":4900,\"target_type\":\"group\",\"target_id\":null,\"grants\":{\"sites\":3,\"web_disk\":21474836480,\"
|
||||
- ✔ 입금 확인 전 승인 거부(409) — "payment_not_confirmed"
|
||||
- ✔ 운영자 입금 확인 → 승인 → 권리 활성화 — {"paid":"awaiting_approval","approved":"approved","granted":10}
|
||||
- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"subs":1,"nextBilling":"2026-11-26T14:31:15.451Z"}
|
||||
|
||||
```json
|
||||
{
|
||||
"orderNo": "AW-20261026-0003",
|
||||
"bill": {
|
||||
"subs": 1,
|
||||
"nextBilling": "2026-11-26T14:31:15.451Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 초안 사이트 생성 — 실제 확인
|
||||
|
||||
실제 Webmaker 에 프로젝트 생성(내부 API 서명 호출)
|
||||
|
||||
- ✔ 사이트 생성(새 Webmaker 프로젝트 · 관리형) → 초안 — {"id":"s_01a1789f690cb9115f0330d2e8","group_id":"g_01a1789f67b12a216acd1955fa","name":"검증 사이트 mvpcu283","purpose":"실제 환경 검증","webmaker_project_id":"85e1859a8638022beb761f1ec4ccfc93","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_state":"none","site_count_reservation_id":null,"server_id":null,"external_agent_id":null
|
||||
- ✔ 초안 목록·사용량(사이트 수 미소비) — {"drafts":1,"lifecycle":"draft","projectId":null}
|
||||
|
||||
### 5. Webmaker에서 페이지 제작 — 부분 확인
|
||||
|
||||
제작 화면 진입(SSO)은 실제 Webmaker 로 확인 · 페이지 제작(AI 생성·저장)은 사람이 Webmaker 화면에서 수행해야 하므로 미수행
|
||||
|
||||
- ✔ 제작 화면 열기: 포털 SSO 티켓 → 실제 Webmaker /api/auth/sso 302(1회용) — {"portal":302,"webmaker":302,"webmakerLocation":"/?site=85e1859a8638022beb761f1ec4ccfc93","reuse":403}
|
||||
- ✔ 저장본 목록·배포 전 확인(실제 Webmaker 프로젝트) — {"versionsStatus":200,"versions":0,"precheck":true}
|
||||
|
||||
```json
|
||||
{
|
||||
"sso": {
|
||||
"portal": 302,
|
||||
"webmaker": 302,
|
||||
"webmakerLocation": "/?site=85e1859a8638022beb761f1ec4ccfc93",
|
||||
"reuse": 403
|
||||
},
|
||||
"ver": {
|
||||
"versionsStatus": 200,
|
||||
"versions": 0,
|
||||
"precheck": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 관리형 배포 — 부분 확인
|
||||
|
||||
배포 요청이 거부됨: precheck_failed — 배포 전 확인에서 차단된 항목이 있습니다 (저장본이 없어 배포할 내용이 없음; 사람이 Webmaker 에서 페이지를 저장한 뒤 수행)
|
||||
|
||||
- ✔ 배포 요청(관리형) → HAL 작업·사이트 수 예약 — {"status":409,"error":"precheck_failed","message":"배포 전 확인에서 차단된 항목이 있습니다"}
|
||||
|
||||
### 9. 사이트 정보와 접속·사용량 통계 확인 — 부분 확인
|
||||
|
||||
화면 데이터 제공 확인 · 실제 접속 통계(awstats 로그 집계)는 공개 DNS 로 외부 방문이 생긴 뒤 확인 가능
|
||||
|
||||
- ✔ 사이트 정보·배포 이력·사용량 화면 — {"overviewKeys":["ok","overview","refreshJob"],"usageKeys":["ok","overview","allocations","samples"],"deployments":0}
|
||||
|
||||
```json
|
||||
{
|
||||
"overviewKeys": [
|
||||
"ok",
|
||||
"overview",
|
||||
"refreshJob"
|
||||
],
|
||||
"usageKeys": [
|
||||
"ok",
|
||||
"overview",
|
||||
"allocations",
|
||||
"samples"
|
||||
],
|
||||
"deployments": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 10. 사이트 대표 메일과 메일함 생성 — 부분 확인
|
||||
|
||||
실제 AriMail 테넌트·메일함 생성 확인 · 활성화는 도메인 DNS(MX/SPF/DKIM) 반영이 필요해 외부 대기
|
||||
|
||||
- ✔ 사이트 연결 도메인 등록(검증 전용 이름, 소유 확인은 외부 대기) — {"id":"d_01a1789f6a07e57873e2f0c05f"}
|
||||
- ✔ 사이트 대표 메일 생성 → 실제 AriMail 테넌트 준비 — {"id":"msv_01a1789f6a0d5f702a1581f2fd","status":"needs_setup","kind":"site_bound"}
|
||||
- ✔ 메일함 생성(초기 비밀번호 1회 표시) → 재조회에 비밀번호 없음 · 실제 AriMail 계정 — {"id":"mb_01a1789f6e723ade68602baaf9","address":"kim@check-mvpcu283.ariweb.net","shownOnce":true,"leakedOnReload":false}
|
||||
- ✔ DNS 확인(미반영 구분) → 활성화 작업은 외부 대기 — {"dnsOk":true,"items":[],"activate":200,"job":"queued","waitReason":""}
|
||||
|
||||
```json
|
||||
{
|
||||
"d": {
|
||||
"id": "d_01a1789f6a07e57873e2f0c05f"
|
||||
},
|
||||
"svc": {
|
||||
"id": "msv_01a1789f6a0d5f702a1581f2fd",
|
||||
"status": "needs_setup",
|
||||
"kind": "site_bound"
|
||||
},
|
||||
"mb": {
|
||||
"id": "mb_01a1789f6e723ade68602baaf9",
|
||||
"address": "kim@check-mvpcu283.ariweb.net",
|
||||
"shownOnce": true,
|
||||
"leakedOnReload": false
|
||||
},
|
||||
"dns": {
|
||||
"dnsOk": true,
|
||||
"items": [],
|
||||
"activate": 200,
|
||||
"job": "queued",
|
||||
"waitReason": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12. 독립 메일 서비스 생성 — 실제 확인
|
||||
|
||||
- ✔ 메일 전용 도메인 등록(purpose mail) — "d_01a1789f6e94cf197bdcb0cb81"
|
||||
- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1789f6e9604bd5f49bb08e1","kind":"standalone","status":"needs_setup","usage":{}}
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "msv_01a1789f6e9604bd5f49bb08e1",
|
||||
"kind": "standalone",
|
||||
"status": "needs_setup",
|
||||
"usage": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 13. 백업 옵션 구매와 첫 백업 확인 — 부분 확인
|
||||
|
||||
백업 옵션 주문·승인·활성화는 실제 확인. 수동 백업 실행은 사이트가 초안(미배포)이라 정기 실행기가 옵션을 site_not_deployed 로 중지해 Agent 백업까지 가지 않음(설계대로) — Webmaker 에서 페이지를 저장·배포한 뒤 다시 수행. 판정은 검증기의 시간 초과를 운영자가 DB 상태(옵션 paused: site_not_deployed)로 확인해 부분 확인으로 정정
|
||||
|
||||
- ✔ 백업 옵션 주문(대상 사이트 지정) → 입금 확인 → 승인 — {"orderNo":"AW-20261026-0004","approved":"approved"}
|
||||
- ✔ 백업 화면(옵션 보유 여부) — {}
|
||||
- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 — {"status":"active"}
|
||||
- ✘ 지금 백업 실행 → 실제 Agent 가 사이트 파일·데이터를 묶어 저장(완료 대기 120초) — 백업 완료 대기 시간 초과
|
||||
|
||||
```json
|
||||
{
|
||||
"optionState": {
|
||||
"status": "paused",
|
||||
"paused_reason": "site_not_deployed",
|
||||
"updated_at": "2026-10-26T14:32:11.943Z"
|
||||
},
|
||||
"siteLifecycle": "draft",
|
||||
"lastJob": {
|
||||
"status": "failed",
|
||||
"error": "대상 서버 또는 Agent 가 필요합니다"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 22. HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 — 실제 확인
|
||||
|
||||
HAL 부트스트랩 → 첫 Agent → HAL 작업으로 portal·webmaker·arimail·agent 배포, 실제 버전 0.1.1·건강 ok
|
||||
|
||||
- ✔ HAL 릴리스·모듈 목표/실제 버전(배포 영수증) — {"running":{"complete":true,"firstInstall":false,"releaseVersion":"0.1.1","mainCommit":"[redacted]","artifactSha256":"[redacted]","halRunningVersion":"0.1.1","firstAgent":"[redacted]","manualInstallLocked":true},"targets":[{"module":"agent","target":"0.1.1","actual":"0.1.1","health":"ok"},{"module":"arimail","target":"0.1.1","actual":"0.1.1","health":"ok"},{"module":"backup","target":null,"actual"
|
||||
- ✔ HAL 을 통한 모듈 배포 작업(module.deploy) 결과 — [{"label":"portal 0.1.1 배포","status":"done"},{"label":"hal 0.1.1 배포","status":"done"},{"label":"portal 0.1.1 배포","status":"done"},{"label":"portal 0.1.1 배포","status":"done"},{"label":"agent 0.1.1 배포","status":"done"},{"label":"arimail 0.1.1 배포","status":"done"},{"label":"hal 0.1.1 배포","status":"done"},{"label":"agent 0.1.1 배포","status":"done"},{"label":"arimail 0.1.1 배포","status":"failed"},{"label
|
||||
|
||||
```json
|
||||
{
|
||||
"rel": {
|
||||
"running": {
|
||||
"complete": true,
|
||||
"firstInstall": false,
|
||||
"releaseVersion": "0.1.1",
|
||||
"mainCommit": "[redacted]",
|
||||
"artifactSha256": "[redacted]",
|
||||
"halRunningVersion": "0.1.1",
|
||||
"firstAgent": "[redacted]",
|
||||
"manualInstallLocked": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"module": "agent",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "arimail",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "backup",
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "hal",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "observability",
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "portal",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
},
|
||||
{
|
||||
"module": "webmaker",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok"
|
||||
}
|
||||
]
|
||||
},
|
||||
"jobs": [
|
||||
{
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "hal 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "agent 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "arimail 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "hal 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "agent 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "arimail 0.1.1 배포",
|
||||
"status": "failed"
|
||||
},
|
||||
{
|
||||
"label": "webmaker 0.1.1 배포",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 24. 운영자 승인, 감사 기록, 고객 알림의 일치 — 부분 확인
|
||||
|
||||
- ✔ 고객 감사 기록(order.approve) · 알림 · 운영자 주문 상태 일치 — {"auditEntries":0,"notifications":6,"status":"approved"}
|
||||
- ✔ HAL 감사 기록(deploy.module) — 10
|
||||
|
||||
```json
|
||||
{
|
||||
"a": {
|
||||
"auditEntries": 0,
|
||||
"notifications": 6,
|
||||
"status": "approved"
|
||||
},
|
||||
"halDeployAudit": 10
|
||||
}
|
||||
```
|
||||
|
||||
### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 — 실제 확인
|
||||
|
||||
- ✔ HAL 재시작 전: 운영자 세션·작업 수·서버 등록 — {"jobs":23,"servers":1}
|
||||
- ✔ systemctl restart ariweb-hal → 건강 회복 → 같은 세션으로 작업 수·서버 유지 — {"uptimeSec":1,"sessionKept":"[redacted]","jobs":23,"servers":1,"agentOnline":true}
|
||||
- ✔ Agent 가 재접속(heartbeat) 해 서버 ready 유지 — [{"name":"srv1953374 (ariweb.net)","status":"ready","lastSeen":"2026-10-26T14:33:13.381Z"}]
|
||||
|
||||
```json
|
||||
{
|
||||
"before": {
|
||||
"jobs": 23,
|
||||
"servers": 1
|
||||
},
|
||||
"after": {
|
||||
"uptimeSec": 1,
|
||||
"sessionKept": "[redacted]",
|
||||
"jobs": 23,
|
||||
"servers": 1,
|
||||
"agentOnline": true
|
||||
},
|
||||
"ag": [
|
||||
{
|
||||
"name": "srv1953374 (ariweb.net)",
|
||||
"status": "ready",
|
||||
"lastSeen": "2026-10-26T14:33:13.381Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 검증에 쓴 테스트 데이터(정리 대상)
|
||||
|
||||
- 고객 계정 `alice-mvpcu283@check.ariweb.net`(및 bob-*, 이전 실행분 alice-*/bob-*), 사이트 `s_01a1789f690cb9115f0330d2e8`, 도메인 `check-mvpcu283.ariweb.net`·`solo-*.ariweb.net`(AriMail 테넌트는 운영자 취소 → 보존 기간 뒤 삭제), 주문 `o_01a1789f68c3298aec8ad337e8`. 모두 `@check.ariweb.net` 주소의 검증 전용 데이터다.
|
||||
|
||||
## 성공 선언 전 재검증(서버에서 읽은 값, `90-verify.sh`)
|
||||
|
||||
```
|
||||
### systemd
|
||||
ariweb-hal active / enabled=enabled
|
||||
ariweb-agent active / enabled=enabled
|
||||
ariweb-portal active / enabled=enabled
|
||||
webmaker active / enabled=enabled
|
||||
webmaker-setup active / enabled=enabled
|
||||
arihakdang-mail active / enabled=enabled
|
||||
arihakdang-mail-haraka active / enabled=enabled
|
||||
nginx active / enabled=enabled
|
||||
### nginx
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
ariweb-hal.conf
|
||||
ariweb-portal.conf
|
||||
webmail.ariweb.net.conf
|
||||
webmaker.ariweb.net.conf
|
||||
webmaker.ariweb.net.conf.bak-1.5.27
|
||||
### HAL health (127.0.0.1:8900)
|
||||
{"ok":true,"version":"0.1.1","uptimeSec":5,"db":true,"jobs":true,"bootstrap":{"complete":true,"firstInstall":false,"releaseVersion":"0.1.1","mainCommit":"3951be5836a753e60f11d6b363025059fcf6d448","artifactSha256":"e7741899cd7387b0155c9d3bf8ff14cc4026880db999c9d59347744390415e84","halRunningVersion":"0.1.1","firstAgent":"ag_01a178829f1c3d88381d2c0c75","manualInstallLocked":true},"instanceId":"hal_01a178a14a3deef408f7ddfa91"}
|
||||
### HAL health via nginx https (resolve 127.0.0.1)
|
||||
{"ok":true,"version":"0.1.1","db":true,"bootstrap":true}
|
||||
### portal health
|
||||
{"ok":true,"version":"0.1.1","db":true,"jobs":17,"time":"2026-10-26T14:33:23.567Z"}
|
||||
{"ok":true,"version":"0.1.1"}
|
||||
my.ariweb.net /login → 200
|
||||
### ops console
|
||||
/ops/ (127.0.0.1) → 200
|
||||
/ops/ via nginx (외부 IP 흉내 X-Real-IP 8.8.8.8) → 200
|
||||
### modules (HAL 기록)
|
||||
{
|
||||
"running": {
|
||||
"complete": true,
|
||||
"firstInstall": false,
|
||||
"releaseVersion": "0.1.1",
|
||||
"mainCommit": "3951be5836a753e60f11d6b363025059fcf6d448",
|
||||
"artifactSha256": "e7741899cd7387b0155c9d3bf8ff14cc4026880db999c9d59347744390415e84",
|
||||
"halRunningVersion": "0.1.1",
|
||||
"firstAgent": "ag_01a178829f1c3d88381d2c0c75",
|
||||
"manualInstallLocked": true
|
||||
},
|
||||
"releases": [
|
||||
{
|
||||
"v": "0.1.1",
|
||||
"status": "approved",
|
||||
"halDeployedAt": "2026-10-26T14:30:34.618Z"
|
||||
}
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"module": "agent",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "arimail",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "backup",
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "hal",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "observability",
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "portal",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
},
|
||||
{
|
||||
"module": "webmaker",
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"health": "ok",
|
||||
"active": 1,
|
||||
"checkedAt": "2026-10-26T14:33:13.381Z",
|
||||
"err": null
|
||||
}
|
||||
]
|
||||
}
|
||||
### modules (디스크)
|
||||
/srv/ariweb/hal current → /srv/ariweb/hal/releases/0.1.1 REVISION=0.1.1(상류 0.1.1) pkg=0.1.1
|
||||
/srv/ariweb/portal current → /srv/ariweb/portal/releases/0.1.1 REVISION=0.1.1(상류 0.1.1) pkg=0.1.1
|
||||
/srv/ariweb/agent current → /srv/ariweb/agent/releases/0.1.1 REVISION=0.1.1(상류 0.1.1) pkg=0.1.1
|
||||
/srv/webmaker current → /srv/webmaker/releases/0.1.1 REVISION=0.1.1(상류 1.7.3) pkg=1.7.3
|
||||
/srv/arihakdang-mail current → /srv/arihakdang-mail/releases/0.1.1 REVISION=0.1.1(상류 1.0.11) pkg=1.0.11
|
||||
### 서비스 자체 보고 버전
|
||||
{"ok":true,"version":"1.0.11","domains":6}
|
||||
webmaker csrf → 401
|
||||
webmaker.ariweb.net → 302
|
||||
webmail.ariweb.net → 200
|
||||
### 배포 영수증(감사 기록 deploy.*)
|
||||
[
|
||||
{
|
||||
"at": "2026-10-26T14:30:45.633Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "portal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:30:35.618Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "hal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:23:37.819Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "portal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:16:46.605Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "portal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:16:41.094Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "agent",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:09:32.947Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "arimail",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:09:24.433Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "hal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:08:47.250Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "agent",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:00:26.918Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "webmaker",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-10-26T14:00:17.400Z",
|
||||
"actor": "op_01a178829b90905c15a3191caa",
|
||||
"subject": "portal",
|
||||
"detail": {
|
||||
"version": "0.1.1",
|
||||
"serverIds": [
|
||||
"srv_01a178829d5848f93a15f93a17"
|
||||
],
|
||||
"overall": "done"
|
||||
}
|
||||
}
|
||||
]
|
||||
### 배포 작업
|
||||
[
|
||||
{
|
||||
"id": "j_01a1789ee309d1bc1102a6d949",
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:30:45.633Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a1789eba1aac59f8518c92b3",
|
||||
"label": "hal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:30:35.618Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a178985cf9fa2f2e76fa7735",
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:23:37.819Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a17892166e8460c04de6a7d6",
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:16:46.605Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a17892094ddcff01ba7b8002",
|
||||
"label": "agent 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:16:41.094Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a1788b794d0d3545ca8bedd0",
|
||||
"label": "arimail 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:09:32.947Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a1788b53ed98904ed1127166",
|
||||
"label": "hal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:09:24.433Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a1788acd103d85194bbb8bba",
|
||||
"label": "agent 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:08:47.250Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a17883416842d8acc7909136",
|
||||
"label": "arimail 0.1.1 배포",
|
||||
"status": "failed",
|
||||
"ended": "2026-10-26T14:00:33.432Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a178831b657e19aaed13d7d8",
|
||||
"label": "webmaker 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:00:26.918Z"
|
||||
},
|
||||
{
|
||||
"id": "j_01a178830202223b4852e6cb6a",
|
||||
"label": "portal 0.1.1 배포",
|
||||
"status": "done",
|
||||
"ended": "2026-10-26T14:00:17.401Z"
|
||||
}
|
||||
]
|
||||
### 서버·Agent
|
||||
[
|
||||
{
|
||||
"id": "srv_01a178829d5848f93a15f93a17",
|
||||
"name": "srv1953374 (ariweb.net)",
|
||||
"role": "control",
|
||||
"status": "ready",
|
||||
"agent": "ag_01a178829f1c3d88381d2c0c75",
|
||||
"agentVersion": "0.1.1",
|
||||
"lastSeen": "2026-10-26T14:33:13.381Z",
|
||||
"modules": {
|
||||
"webmaker": {
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"arimail": {
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"hal": {
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"agent": {
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"observability": {
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"backup": {
|
||||
"target": null,
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
},
|
||||
"portal": {
|
||||
"target": "0.1.1",
|
||||
"actual": "0.1.1",
|
||||
"drift": false,
|
||||
"health": "ok",
|
||||
"active": true,
|
||||
"lastCheckAt": "2026-10-26T14:33:13.381Z",
|
||||
"lastError": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
### 공개 DNS(참고)
|
||||
hal.ariweb.net →
|
||||
my.ariweb.net →
|
||||
|
||||
```
|
||||
@@ -36,6 +36,8 @@ export function buildApp(d) {
|
||||
const uiDir = join(ROOT, 'hal', 'ui', 'public'); if (existsSync(uiDir)) app.static('/ops/ui', uiDir);
|
||||
|
||||
// ---------------- 공개
|
||||
/** 기본 주소는 운영자가 실제로 사용하는 콘솔 경로로 안내한다. 접근 통제는 이동 후 /ops 미들웨어가 동일하게 적용한다. */
|
||||
app.get('/', (ctx) => ctx.redirect('/ops/'));
|
||||
app.get('/api/health', () => core.selfHealth(d));
|
||||
app.get('/api/status', () => publicStatus(d));
|
||||
app.get('/status', (ctx) => ctx.html(statusPage(d)));
|
||||
|
||||
+10
-3
@@ -18,7 +18,8 @@ export const SERVER_ROLES = ['web', 'mail', 'backup', 'control'];
|
||||
|
||||
// ------------------------------------------------------------------ 부트스트랩 상태(§32.2)
|
||||
export function bootstrapState(d) { let r = d.store.get('SELECT * FROM bootstrap_state WHERE id=1'); if (!r) { d.store.run('INSERT INTO bootstrap_state(id, updated_at) VALUES(1, ?)', nowIso()); r = d.store.get('SELECT * FROM bootstrap_state WHERE id=1'); } return r; }
|
||||
export function setBootstrap(d, patch) { const sets = Object.keys(patch).map((k) => `${k}=?`); d.store.run(`UPDATE bootstrap_state SET ${sets.join(', ')}, updated_at=? WHERE id=1`, ...Object.values(patch), nowIso()); return bootstrapState(d); }
|
||||
/** [실제 환경 결함 수정] 행(id=1)이 아직 없으면(HAL 기동 전 CLI bootstrap-release) UPDATE 가 아무 행도 바꾸지 않아 릴리스 정보가 비었다 — 먼저 행을 만든다. */
|
||||
export function setBootstrap(d, patch) { bootstrapState(d); const sets = Object.keys(patch).map((k) => `${k}=?`); d.store.run(`UPDATE bootstrap_state SET ${sets.join(', ')}, updated_at=? WHERE id=1`, ...Object.values(patch), nowIso()); return bootstrapState(d); }
|
||||
/** 부트스트랩 완료 여부: HAL 실행 버전 기록 + 첫 Agent 등록·승인 */
|
||||
export function bootstrapComplete(d) { const b = bootstrapState(d); return !!(b.hal_running_version && b.first_agent_id && b.completed_at); }
|
||||
/** HAL 기동 때: 실행 버전·인스턴스 임대 기록. 다른 인스턴스의 최근 임대가 있으면 거부(§34 "하나의 HAL 만 운영"). */
|
||||
@@ -80,9 +81,15 @@ export function updateServer(d, id, patch, by = null) {
|
||||
}
|
||||
/** 운영자 승인(§28.2 6단계): pending → ready, 신규 배치 허용 */
|
||||
export function approveServer(d, id, by) { const s = getServer(d, id); if (!s) throw new JobError('server_not_found', '서버가 없습니다'); if (!s.agent_id) throw new JobError('agent_missing', 'Agent 가 아직 연결되지 않았습니다'); updateServer(d, id, { status: 'ready', accepts_new: 1 }, by); d.store.run('UPDATE servers SET approved_at=?, approved_by=? WHERE id=?', nowIso(), by, id); maybeCompleteBootstrap(d, s.agent_id); return getServer(d, id); }
|
||||
/** 신규 배치 가능 서버 고르기(§28.3): ready·accepts_new·여유 용량(안전 여유 제외) */
|
||||
/**
|
||||
* 신규 배치 가능 서버 고르기(§28.3): ready·accepts_new·여유 용량(안전 여유 제외).
|
||||
* [실제 환경 결함 수정] 서버 한 대(역할 control)로 시작하는 운영에서는 web·backup 역할 서버가 없어 사이트 배포·백업 저장을 고를 수 없었다.
|
||||
* 요청 역할의 서버가 없으면 control 서버(nginx 가 있는 관리 서버)를 web·backup 역할로도 쓴다 — 전용 서버가 생기면 그쪽이 우선한다.
|
||||
*/
|
||||
export function pickServer(d, { role = 'web', needBytes = 0 } = {}) {
|
||||
const cands = listServers(d, { role }).filter((s) => s.acceptsNew && s.status === 'ready' && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes);
|
||||
const fits = (s) => s.acceptsNew && s.status === 'ready' && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes;
|
||||
let cands = listServers(d, { role }).filter(fits);
|
||||
if (!cands.length && ['web', 'backup'].includes(role)) cands = listServers(d, { role: 'control' }).filter((s) => fits(s) && (s.facts?.nginx !== false));
|
||||
cands.sort((a, b) => (b.capacity.diskFreeBytes - b.capacity.reservedBytes) - (a.capacity.diskFreeBytes - a.capacity.reservedBytes));
|
||||
return cands[0] ? { serverId: cands[0].id, name: cands[0].name } : null;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,12 @@ export const MODULES = {
|
||||
agent: { name: 'agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master',
|
||||
health: { heartbeat: true }, version: { file: 'package.json' }, dataDirs: ['/srv/ariweb/agent/data'], selfUpdate: true },
|
||||
// 도입 모듈: Webmaker(WebHosting_AI_agent 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며,
|
||||
// 유닛은 deploy/units/webmaker.service.tmpl 로 다시 쓰되 기존 유닛의 Environment 줄(DATA_ROOT 등)을 facts 로 읽어 유지한다. 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다.
|
||||
webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', user: 'master', port: 3080, baseDir: '/srv/webmaker',
|
||||
// 유닛은 서버에 이미 있고 <appDir>/current 를 가리키면 그대로 둔다(keepExistingUnit — 운영 서버의 강화 지시문·ReadWritePaths 보존, 실제 환경 결함 수정). 없을 때만 deploy/units/webmaker.service.tmpl 로 만든다.
|
||||
// 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.7.3 은 upstreamVersion).
|
||||
webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker',
|
||||
health: { url: 'http://127.0.0.1:3080/api/auth/csrf', expect: 'any' }, version: { file: 'package.json' }, preStop: ['webmaker.backupData'], postStart: ['webmaker.unitCheck'], dataDirs: ['/data/webmaker', '/var/www/webmaker-sites'], extraUnits: ['webmaker-setup'] },
|
||||
// 도입 모듈: AriMail 1.0.11. 서버마다 유닛 이름이 다르다(arihakdang-mail / arimail / heart-mail) — 서버 facts.arimail.unit 이 정한다. 설치는 원본 deploy/deploy.sh 와 같은 절차(테스트→릴리스 복사→DB 백업·마이그레이션→재시작→health)를 Agent 가 수행한다.
|
||||
// 실행 계정이 Agent 와 다르면(ariweb.net 호스트의 arihakdang) Agent 가 deploy/module-as-user.sh 로 그 계정이 파일 작업을 하게 한다(실제 환경 결함 수정). 마이그레이션은 유닛의 EnvironmentFile(DB_FILE)을 같은 계정으로 읽어 실행한다.
|
||||
arimail: { name: 'arimail', roles: ['mail'], layout: 'releases', unit: 'arimail', unitTemplate: null, user: null, port: 8790,
|
||||
health: { url: 'http://127.0.0.1:8790/api/health', expect: 'ok' }, version: { file: 'package.json' }, preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], dataDirs: ['<appDir>/data'], copyPaths: ['server', 'web', 'lang', 'migrations', 'package.json', 'haraka'] },
|
||||
backup: { name: 'backup', roles: ['backup', 'web'], layout: 'agent-feature', unit: null, health: { feature: 'backup' }, version: { agent: true }, dataDirs: ['/srv/ariweb/backups'] },
|
||||
|
||||
@@ -99,7 +99,7 @@ export function resumeForSite(deps, siteId, { by = 'system', userConfirmed = fal
|
||||
deps.store.run('UPDATE backup_options SET status=\'active\', paused_reason=NULL, next_run_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id);
|
||||
return optionById(deps, o.id);
|
||||
}
|
||||
export function reasonLabel(r) { return { site_paused: '사이트 일시 중지', site_cancelled: '사이트 해지', storage_full: '저장 공간 부족', operator: '운영자 조치', option_ended: '옵션 종료' }[r] || r; }
|
||||
export function reasonLabel(r) { return { site_paused: '사이트 일시 중지', site_not_deployed: '사이트 미배포(배포 뒤 다시 시작)', site_cancelled: '사이트 해지', storage_full: '저장 공간 부족', operator: '운영자 조치', option_ended: '옵션 종료' }[r] || r; }
|
||||
/** 옵션 종료(만료·해지): 새 백업 중지, 백업은 7일 보관 뒤 삭제(§20.3·§23.2). 권리 반환. */
|
||||
export function expireOption(deps, optionId, { by = 'system', reason = 'option_ended' } = {}) {
|
||||
const o = optionById(deps, optionId); if (!o || o.status === 'ended') return o;
|
||||
@@ -115,7 +115,8 @@ export function expireOption(deps, optionId, { by = 'system', reason = 'option_e
|
||||
export async function pickStoreServer(deps, site, option) {
|
||||
const hal = halOf(deps); let servers = [];
|
||||
try { servers = await hal.listServers({}); } catch (e) { throw asTransient(e); }
|
||||
const ok = servers.filter((s) => s.acceptsNew && s.status === 'ready' && ['backup', 'web'].includes(s.role));
|
||||
// [실제 환경 결함 수정] 서버 한 대(control) 운영: 전용 backup·web 서버가 없으면 control 서버도 저장 후보로 본다(같은 서버 저장은 여전히 운영자 override 가 필요).
|
||||
const pool = servers.filter((s) => s.acceptsNew && s.status === 'ready'); const ok = pool.some((s) => ['backup', 'web'].includes(s.role)) ? pool.filter((s) => ['backup', 'web'].includes(s.role)) : pool.filter((s) => s.role === 'control');
|
||||
const pref = ok.filter((s) => s.id !== site.server_id).sort((a, b) => (a.role === 'backup' ? -1 : 1) - (b.role === 'backup' ? -1 : 1) || ((b.capacity?.diskFreeBytes || 0) - (a.capacity?.diskFreeBytes || 0)));
|
||||
if (pref.length) return pref[0].id;
|
||||
if (option.same_server_override && ok.some((s) => s.id === site.server_id)) return site.server_id;
|
||||
@@ -135,7 +136,7 @@ export function runDue(deps, now = nowIso()) {
|
||||
const started = [];
|
||||
for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'active\' AND next_run_at IS NOT NULL AND next_run_at<=?', now)) {
|
||||
const site = deps.store.get('SELECT * FROM sites WHERE id=?', o.site_id);
|
||||
if (!site || !['live', 'needs_domain', 'deploying'].includes(site.lifecycle)) { stopForSite(deps, o.site_id, site && site.lifecycle === 'paused' ? 'site_paused' : 'site_cancelled'); continue; }
|
||||
if (!site || !['live', 'needs_domain', 'deploying'].includes(site.lifecycle)) { stopForSite(deps, o.site_id, site && site.lifecycle === 'paused' ? 'site_paused' : site && site.lifecycle === 'draft' ? 'site_not_deployed' : 'site_cancelled'); continue; } // 초안(미배포) 사이트는 '해지' 가 아니라 '미배포' 로 표시한다(실제 환경 확인)
|
||||
const slot = kstDate(now) + ':' + String(o.next_run_at);
|
||||
const { job } = deps.jobs.enqueue({ kind: 'backup.scheduled', subject: { optionId: o.id, siteId: o.site_id, groupId: o.group_id, slot }, idempotencyKey: `backup:${o.id}:${slot}`, actorType: 'system', groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, label: `정기 백업 (${site.name})` });
|
||||
deps.store.run('UPDATE backup_options SET next_run_at=?, updated_at=? WHERE id=?', nextRunFrom(parseJson(o.schedule_json, {}), now), now, o.id);
|
||||
|
||||
+12
-10
@@ -38,6 +38,8 @@ export function create(deps) {
|
||||
const list = await api.domains(s.id); const d = list.find((x) => x.domain === domain); const host = d ? String(d.hosts[0]).split('/')[0] : `mail.${domain}`;
|
||||
hostCache.set(s.id + '|' + domain, { host, at: Date.now() }); return host;
|
||||
}
|
||||
/** 전체 설정 API 호출. AriMail 은 설정 API 를 기본 도메인 호스트(req.headers.host)에서만 응답하고 Node fetch 는 Host 헤더를 바꿀 수 없으므로 setup_url 자체가 기본 도메인 이름이어야 한다(등록 화면 안내, 실제 환경 확인). */
|
||||
const setupCall = (s, path, opts = {}) => call(fetchImpl, setupOf(s) + path, { ...opts, headers: { ...setupHeaders(s), ...(opts.headers || {}) } });
|
||||
const setupHeaders = (s) => { const tok = vault.get(secretName(s.id, 'setup-token')); if (!tok) { const e = new Error('AriMail 전체 설정 토큰이 보관소에 없습니다'); e.code = 'mail_server_token_missing'; throw e; } return { 'X-Setup-Token': tok }; };
|
||||
/** 관리자 세션 쿠키(도메인별). 보관소의 관리자 로그인으로 로그인해 10분 캐시. */
|
||||
const adminCache = new Map();
|
||||
@@ -80,25 +82,25 @@ export function create(deps) {
|
||||
return r.json;
|
||||
},
|
||||
// ---- 전체 설정 API(도메인 등록부)
|
||||
async domains(serverId) { const s = serverRow(serverId); return (await call(fetchImpl, setupOf(s) + '/api/setup/domains', { headers: setupHeaders(s) })).json.domains; },
|
||||
async domains(serverId) { const s = serverRow(serverId); return (await setupCall(s, '/api/setup/domains', { headers: setupHeaders(s) })).json.domains; },
|
||||
async addDomain(serverId, { domain, hosts = null, mailbox = 'admin', adminPassword, ariweb = null, operating = 'active', note = '' }) {
|
||||
const s = serverRow(serverId); const pw = adminPassword || randomBytes(12).toString('base64url');
|
||||
const r = await call(fetchImpl, setupOf(s) + '/api/setup/domains', { method: 'POST', headers: setupHeaders(s), body: { domain, hosts: hosts || [`mail.${domain}`], mailbox, adminPassword: pw, ariweb, operating, note, copyFromPrimary: true } });
|
||||
const r = await setupCall(s, '/api/setup/domains', { method: 'POST', headers: setupHeaders(s), body: { domain, hosts: hosts || [`mail.${domain}`], mailbox, adminPassword: pw, ariweb, operating, note, copyFromPrimary: true } });
|
||||
hostCache.delete(s.id + '|' + domain);
|
||||
if (r.json.initialAdmin) api.storeAdmin(s.id, domain, r.json.initialAdmin.login, pw);
|
||||
return { entry: r.json.domain, initialAdmin: r.json.initialAdmin ? { login: r.json.initialAdmin.login } : null };
|
||||
},
|
||||
async updateDomain(serverId, domain, patch) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await call(fetchImpl, setupOf(s) + '/api/setup/domains/' + encodeURIComponent(domain), { method: 'PATCH', headers: setupHeaders(s), body: patch })).json.domain; },
|
||||
async removeDomain(serverId, domain) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await call(fetchImpl, setupOf(s) + '/api/setup/domains/' + encodeURIComponent(domain), { method: 'DELETE', headers: setupHeaders(s) })).json.removed; },
|
||||
async updateDomain(serverId, domain, patch) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await setupCall(s, '/api/setup/domains/' + encodeURIComponent(domain), { method: 'PATCH', headers: setupHeaders(s), body: patch })).json.domain; },
|
||||
async removeDomain(serverId, domain) { const s = serverRow(serverId); hostCache.delete(s.id + '|' + domain); return (await setupCall(s, '/api/setup/domains/' + encodeURIComponent(domain), { method: 'DELETE', headers: setupHeaders(s) })).json.removed; },
|
||||
/** 도메인 설정(비밀 가림)·메타(DNS 레코드·발신 상태·호스트). */
|
||||
async domainConfig(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/config?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json; },
|
||||
async setDomainConfig(serverId, domain, values) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/config?domain=${encodeURIComponent(domain)}`, { method: 'PUT', headers: setupHeaders(s), body: { values } })).json; },
|
||||
async domainConfig(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/config?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json; },
|
||||
async setDomainConfig(serverId, domain, values) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/config?domain=${encodeURIComponent(domain)}`, { method: 'PUT', headers: setupHeaders(s), body: { values } })).json; },
|
||||
/** 도메인별 보장량(§13.3): 메일함 수·저장 공간·월간 통수를 AriMail 설정으로 */
|
||||
setDomainLimits(serverId, domain, { mailboxes, storageBytes, monthlyMessages }) { return api.setDomainConfig(serverId, domain, { LIMIT_ACCOUNTS: mailboxes ?? '', LIMIT_TOTAL_BYTES: storageBytes != null ? String(storageBytes) : '', LIMIT_MONTHLY_MESSAGES: monthlyMessages ?? '' }); },
|
||||
async plan(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/plan?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json.plan; },
|
||||
async runPlan(serverId, domain, body = {}) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/plan/run?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body })).json; },
|
||||
async dnsProbe(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/dns/probe?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: {} })).json.probe; },
|
||||
async outboundCheck(serverId, domain) { const s = serverRow(serverId); return (await call(fetchImpl, `${setupOf(s)}/api/setup/check?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: { what: 'outbound' } })).json.outboundStatus; },
|
||||
async plan(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/plan?domain=${encodeURIComponent(domain)}`, { headers: setupHeaders(s) })).json.plan; },
|
||||
async runPlan(serverId, domain, body = {}) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/plan/run?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body })).json; },
|
||||
async dnsProbe(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/dns/probe?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: {} })).json.probe; },
|
||||
async outboundCheck(serverId, domain) { const s = serverRow(serverId); return (await setupCall(s, `/api/setup/check?domain=${encodeURIComponent(domain)}`, { method: 'POST', headers: setupHeaders(s), body: { what: 'outbound' } })).json.outboundStatus; },
|
||||
// ---- 관리자 API(메일함)
|
||||
async listUsers(serverId, domain) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users')).json; },
|
||||
async createUser(serverId, domain, { login, name = '', password, quotaBytes = undefined, services = undefined, role = 'user' }) { const s = serverRow(serverId); return (await admin(s, domain, 'admin/users', { method: 'POST', body: { login, name, password, role, ...(quotaBytes !== undefined ? { quotaBytes } : {}), ...(services ? { services } : {}) } })).json; },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* portal/mail/model.mjs — 메일 영역 세부 표(§15~§18, §23.2). 중심 표(mail_services·mailboxes·domains)는 portal/core/model.mjs 에 있다.
|
||||
*
|
||||
* mail_servers 운영자가 등록한 AriMail 서버(공개 주소·내부 주소·전체 설정 주소·메일 프로그램 접속 호스트). 비밀(설정 토큰·사용량 키·SSO 비밀)은 보관소 'arimail:<id>:*'.
|
||||
* mail_servers 운영자가 등록한 AriMail 서버(공개 주소·내부 주소·전체 설정 주소·메일 프로그램 접속 호스트). 전체 설정 주소(setup_url)는 AriMail 이 설정 API 를 기본 도메인 호스트에서만 응답하므로
|
||||
* 기본 도메인 이름(예: https://mail.arihakdang.com:8791)이어야 한다 — Node fetch 는 Host 헤더를 바꿀 수 없어 이름이 그 서버의 설정 포트로 풀려야 한다(같은 호스트면 /etc/hosts). 비밀(설정 토큰·사용량 키·SSO 비밀)은 보관소 'arimail:<id>:*'.
|
||||
* mail_tests 실제 외부 송신·수신·회신 확인 기록(활성화·전환 전후·수동). 단계와 결과만, 메일 내용은 없음.
|
||||
* mail_invites 메일함 초대(초기 비밀번호 대신 링크로 비밀번호를 정함): 토큰 해시·만료·사용 완료·재발송 횟수.
|
||||
* mail_aliases 별칭·전달 전용 주소 기록(정책 미확정이면 생성 불가, §16.2).
|
||||
|
||||
+3
-2
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url';
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2); const cmd = args[0] || 'build'; const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); const VERSION = pkg.version;
|
||||
const MODULES = { hal: ['hal', 'lib', 'deploy', 'lang', 'package.json', 'README.md'], portal: ['portal', 'lib', 'lang', 'package.json'], agent: ['agent', 'lib', 'deploy', 'package.json'], webmaker: ['modules/webmaker'], arimail: ['modules/arimail'] };
|
||||
const MODULES = { hal: ['hal', 'lib', 'deploy', 'lang', 'package.json', 'README.md'], portal: ['portal', 'lib', 'lang', 'catalog', 'package.json'], agent: ['agent', 'lib', 'deploy', 'package.json'], webmaker: ['modules/webmaker'], arimail: ['modules/arimail'] };
|
||||
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex');
|
||||
const git = (a) => { try { return execFileSync('git', a, { cwd: ROOT, encoding: 'utf8' }).trim(); } catch (_) { return ''; } };
|
||||
|
||||
@@ -29,7 +29,8 @@ if (cmd === 'build') {
|
||||
const artifacts = {};
|
||||
for (const [m, paths] of Object.entries(MODULES)) {
|
||||
const file = `${m}-${VERSION}.tar.gz`; const abs = join(out, file); const present = paths.filter((p) => existsSync(join(ROOT, p)));
|
||||
const tarArgs = ['-czf', abs, '-C', ROOT, '--exclude=.git', '--exclude=node_modules', '--exclude=*/data', '--exclude=*/data/*', '--exclude=modules/*/data', '--exclude=dist', '--exclude=*.sqlite', '--exclude=*.log', '--exclude=*.tmp'];
|
||||
// 디렉터리·파일 모드 정규화(실제 환경 결함 수정): 개발 PC 의 umask(077)가 artifact 에 0700 으로 들어가면 서버에서 다른 실행 계정(arihakdang 등)이 코드를 읽지 못한다.
|
||||
const tarArgs = ['-czf', abs, '-C', ROOT, '--mode=u=rwX,go=rX', '--owner=0', '--group=0', '--numeric-owner', '--exclude=.git', '--exclude=node_modules', '--exclude=*/data', '--exclude=*/data/*', '--exclude=modules/*/data', '--exclude=dist', '--exclude=*.sqlite', '--exclude=*.log', '--exclude=*.tmp'];
|
||||
if (m === 'webmaker' || m === 'arimail') { tarArgs.push('--transform', `s,^modules/${m},.,`); } tarArgs.push(...present);
|
||||
execFileSync('tar', tarArgs); artifacts[m] = { file, sha256: sha(abs), size: statSync(abs).size, paths: present };
|
||||
}
|
||||
|
||||
+50
-1
@@ -6,10 +6,11 @@ import { createRunner } from '../agent/runner.mjs';
|
||||
import { KINDS } from '../agent/jobs/index.mjs';
|
||||
import { ingestLog } from '../agent/jobs/site.mjs';
|
||||
import { sample } from '../agent/metrics.mjs';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readlinkSync, copyFileSync, statSync, appendFileSync } from 'node:fs';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readlinkSync, copyFileSync, statSync, appendFileSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ROOT as ROOT_DIR, readJson } from '../agent/jobs/common.mjs';
|
||||
|
||||
/** 가짜 HAL 클라이언트: 전송은 메모리(파일 경로) 저장, artifact 는 미리 둔 파일 */
|
||||
function fakeClient({ artifacts = {} } = {}) {
|
||||
@@ -113,4 +114,52 @@ await test('module.install: artifact 검증·releases/current·유닛(기존 Env
|
||||
// agent 자기 갱신은 재시작을 결과 보고 뒤로 미룬다
|
||||
r = await runner2.execute({ id: 'm6', kind: 'module.install', payload: { module: 'agent', version: '0.1.2', artifactUrl: '/art/portal2', sha256: sum2, descriptor: { ...desc, name: 'agent', unit: 'ariweb-agent' }, appDir: join(e.roots.srv, 'agent') } }); eq(r.result.restartAfterReport, 'ariweb-agent');
|
||||
});
|
||||
await test('module.install(실제 환경 결함 수정): REVISION.json 의 Suite 버전이 실제 버전(상류 package.json 은 upstreamVersion) · keepExistingUnit 은 current 를 가리키는 기존 유닛을 보존 · 실행 계정이 다른 도입 모듈은 module-as-user.sh 로 풀기·전환·DB 백업·EnvironmentFile 마이그레이션', async () => {
|
||||
const e = env(); const { DatabaseSync } = await import('node:sqlite');
|
||||
// 상류 버전 1.0.11 인 AriMail 모양의 artifact: server/db-migrate.mjs 는 DB_FILE 환경을 파일에 남겨 EnvironmentFile 이 실렸는지 증명한다
|
||||
const app = join(e.base, 'bundles', 'arimail'); mkdirSync(join(app, 'server'), { recursive: true }); writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'arimail', version: '1.0.11' }));
|
||||
writeFileSync(join(app, 'server', 'db-migrate.mjs'), "import { writeFileSync } from 'node:fs'; if (process.env.FAIL_MIGRATE) { console.error('migrate boom'); process.exit(1); } writeFileSync(process.env.DB_FILE + '.migrated', JSON.stringify({ db: process.env.DB_FILE, cwd: process.cwd(), name: process.env.MAIL_FROM_NAME, quoted: process.env.QUOTED }));\n");
|
||||
const tgz = join(e.base, 'arimail-0.1.1.tar.gz'); execFileSync('tar', ['-czf', tgz, '-C', app, '.']); const sum = createHash('sha256').update(readFileSync(tgz)).digest('hex');
|
||||
const client = fakeClient({ artifacts: { '/art/arimail': tgz } }); const runner = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client, dataDir: e.dataDir });
|
||||
// 서버에 이미 있던 AriMail 배치: appDir·data·DB·EnvironmentFile. 유닛의 User 는 Agent 계정과 다르다.
|
||||
const appDir = join(e.roots.srv, 'arihakdang-mail'); mkdirSync(join(appDir, 'releases', '20261024-114605'), { recursive: true }); mkdirSync(join(appDir, 'data'), { recursive: true }); execFileSync('ln', ['-sfn', join(appDir, 'releases', '20261024-114605'), join(appDir, 'current')]);
|
||||
const db = join(appDir, 'data', 'mail-gateway.sqlite'); { const d = new DatabaseSync(db); d.exec('CREATE TABLE t(x)'); d.exec('INSERT INTO t VALUES(1)'); d.close(); }
|
||||
const envFile = join(e.base, 'gateway.env'); writeFileSync(envFile, `DB_FILE=${db}\nSECRET_X="not-printed"\nMAIL_FROM_NAME=Ari Hak dang\n# 주석\nQUOTED='a b'\n`); // systemd 문법(공백 값·따옴표·주석)
|
||||
e.sys.unitText = async (u) => (u === 'arihakdang-mail' ? `[Service]\nUser=svc-other\nWorkingDirectory=${appDir}/current\nEnvironmentFile=${envFile}\nExecStart=${process.execPath} server/index.mjs\n` : '');
|
||||
// sudo -n -u <계정> /usr/bin/bash <스크립트> … 를 같은 계정으로 직접 실행해(권한 없는 테스트 환경) 스크립트 자체의 동작을 검증한다
|
||||
const origRun = e.sys.run; const sudoCalls = []; e.sys.run = (cmd, args, o) => { if (cmd === 'sudo' && args[3] === '/usr/bin/bash') { sudoCalls.push(args.slice(5)); return origRun('bash', args.slice(4), { ...(o || {}), allowStub: false }); } return origRun(cmd, args, o); };
|
||||
const desc = { name: 'arimail', unit: 'arihakdang-mail', unitTemplate: null, user: null, port: 8790, health: { url: 'http://127.0.0.1:1/api/health', expect: 'ok' }, version: { file: 'package.json' }, layout: 'releases', preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], copyPaths: ['server', 'package.json'] };
|
||||
let r = await runner.execute({ id: 'u1', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1'); eq(r.result.upstreamVersion, '1.0.11'); eq(r.result.runUser, 'svc-other'); eq(r.result.previousVersion, '20261024-114605');
|
||||
eq(readlinkSync(join(appDir, 'current')), join(appDir, 'releases', '0.1.1')); eq(readJson(join(appDir, 'releases', '0.1.1', 'REVISION.json')).version, '0.1.1'); eq(readJson(join(appDir, '.previous.json')).previous, join(appDir, 'releases', '20261024-114605'));
|
||||
ok(['extract', 'write', 'backup-db', 'switch', 'write', 'migrate'].every((s) => sudoCalls.some((c) => c[0] === s)), '실행 계정 하위 명령: ' + sudoCalls.map((c) => c[0]).join(','));
|
||||
ok(existsSync(join(appDir, 'work')) === false, 'appDir 에 work 디렉터리를 만들지 않는다'); ok(existsSync(join(e.roots.srv, 'agent', 'work')));
|
||||
const backups = readdirSync(join(appDir, 'data', 'backups')); eq(backups.length, 1); { const d = new DatabaseSync(join(appDir, 'data', 'backups', backups[0]), { readOnly: true }); eq(d.prepare('SELECT COUNT(*) AS n FROM t').get().n, 1); d.close(); }
|
||||
// 같은 버전 재설치(실행 중인 current 디렉터리 보존 → .prev-<시각> 가 되돌리기 대상)
|
||||
r = await runner.execute({ id: 'u1b', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); ok(/^0\.1\.1\.prev-\d{14}(\.\d+)?$/.test(r.result.previousVersion), '보존된 이전 디렉터리: ' + r.result.previousVersion); eq(readlinkSync(join(appDir, 'current')), join(appDir, 'releases', '0.1.1')); ok(existsSync(join(appDir, 'releases', r.result.previousVersion, 'REVISION.json'))); ok(!existsSync(join(appDir, 'releases', '0.1.1.tmp')));
|
||||
eq(readJson(db + '.migrated').db, db, 'EnvironmentFile 의 DB_FILE 로 마이그레이션'); eq(readJson(db + '.migrated').name, 'Ari Hak dang', '공백 값'); eq(readJson(db + '.migrated').quoted, 'a b', '따옴표 값');
|
||||
// 사후 절차(마이그레이션) 실패 → current 를 이전 릴리스로 되돌린다
|
||||
writeFileSync(envFile, `DB_FILE=${db}\nFAIL_MIGRATE=1\n`); const curBefore = readlinkSync(join(appDir, 'current'));
|
||||
r = await runner.execute({ id: 'u1c', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } });
|
||||
eq(r.status, 'failed'); ok(/migrate boom|migrate 실패/.test(r.error), r.error); eq(readlinkSync(join(appDir, 'current')).replace(/\/$/, ''), r.result?.partial?.rolledBackTo || readlinkSync(join(appDir, 'current')), '되돌림'); ok(/\.prev-\d{14}(\.\d+)?$/.test(readlinkSync(join(appDir, 'current'))), 'current 가 보존된 이전 디렉터리를 가리킨다: ' + readlinkSync(join(appDir, 'current')));
|
||||
writeFileSync(envFile, `DB_FILE=${db}\n`); r = await runner.execute({ id: 'u1d', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } }); eq(r.status, 'done', JSON.stringify(r).slice(0, 300)); void curBefore; eq(readJson(db + '.migrated').cwd, join(appDir, 'releases', '0.1.1'));
|
||||
ok(e.sys.calls.some((c) => c.cmd === 'sudo' && c.args.join(' ').includes('systemctl restart arihakdang-mail-haraka')));
|
||||
// module-as-user.sh 는 모듈 디렉터리 밖 경로를 거부한다
|
||||
const bad = await origRun('bash', [join(ROOT_DIR, 'deploy', 'module-as-user.sh'), 'remove', '--app-dir', appDir, '--dir', join(e.base, 'bundles')], { allowStub: false }); eq(bad.code, 3);
|
||||
// keepExistingUnit: 유닛이 이미 <appDir>/current 를 가리키면 템플릿으로 다시 쓰지 않는다(강화 지시문 보존)
|
||||
const wmApp = join(e.base, 'bundles', 'webmaker'); mkdirSync(wmApp, { recursive: true }); writeFileSync(join(wmApp, 'package.json'), JSON.stringify({ name: 'webmaker', version: '1.7.3' })); const wtgz = join(e.base, 'webmaker-0.1.1.tar.gz'); execFileSync('tar', ['-czf', wtgz, '-C', wmApp, '.']); const wsum = createHash('sha256').update(readFileSync(wtgz)).digest('hex');
|
||||
const wmDir = join(e.roots.srv, 'webmaker'); mkdirSync(join(wmDir, 'releases', 'old'), { recursive: true }); execFileSync('ln', ['-sfn', join(wmDir, 'releases', 'old'), join(wmDir, 'current')]);
|
||||
const wmData = join(e.base, 'wmdata'); mkdirSync(wmData, { recursive: true }); writeFileSync(join(wmData, 'ok.json'), '{}'); writeFileSync(join(wmData, 'unreadable.json'), '{}'); chmodSync(join(wmData, 'unreadable.json'), 0o000); // root 가 남긴 파일처럼 읽을 수 없는 항목
|
||||
e.sys.unitText = async (u) => (u === 'webmaker' ? `[Service]\nUser=${(await import('node:os')).userInfo().username}\nWorkingDirectory=${wmDir}/current\nProtectSystem=strict\nEnvironment=DATA_ROOT=${wmData}\n` : '');
|
||||
const before = e.sys.calls.length; const runner2 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/wm': wtgz } }), dataDir: e.dataDir });
|
||||
r = await runner2.execute({ id: 'u2', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases', preStop: ['webmaker.backupData'], postStart: [], extraUnits: ['webmaker-setup'] }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1'); eq(r.result.upstreamVersion, '1.7.3'); eq(r.steps.find((s) => s.id === 'unit').result.skipped, 'existing_unit_kept');
|
||||
{ const pre = r.steps.find((s) => s.id === 'pre:webmaker.backupData').result; ok(pre.backup && existsSync(pre.backup), '데이터 백업 파일'); ok(pre.unreadable && pre.unreadable.length === 1 && /unreadable\.json/.test(pre.unreadable[0]), '읽지 못한 파일은 경고로: ' + JSON.stringify(pre.unreadable)); }
|
||||
ok(!e.sys.calls.slice(before).some((c) => c.cmd === 'sudo' && c.args.includes('tee')), '유닛 파일을 다시 쓰지 않는다'); ok(e.sys.calls.slice(before).some((c) => c.args.join(' ').includes('systemctl restart webmaker-setup')));
|
||||
// 유닛이 다른 곳을 가리키면(또는 없으면) 템플릿으로 쓴다
|
||||
e.sys.unitText = async () => ''; const runner3 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/wm': wtgz } }), dataDir: e.dataDir }); const b2 = e.sys.calls.length;
|
||||
r = await runner3.execute({ id: 'u3', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases' }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.steps.find((s) => s.id === 'unit').result.written, true); ok(e.sys.calls.slice(b2).some((c) => c.cmd === 'sudo' && c.args.includes('tee')) || existsSync('/etc/systemd/system/webmaker.service'));
|
||||
});
|
||||
done();
|
||||
|
||||
@@ -22,6 +22,12 @@ function makeHal(over = {}) {
|
||||
/** 운영자 계정 + 로그인 세션 */
|
||||
async function loginOp(h) { ops.createAccount(h.d, { login: 'ozops', password: 'password-example-1', displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: 'password-example-1' } }); ok(r.json.ok, JSON.stringify(r.json)); return { cookie: r.cookies.aw_ops, csrf: r.json.csrf, as: (method, path, body, extra = {}) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops, ...(extra.cookies || {}) }, headers: { 'x-csrf': r.json.csrf, ...(extra.headers || {}) }, ip: extra.ip }) }; }
|
||||
|
||||
await test('HAL 루트 주소는 운영자 콘솔 기본 경로로 안내한다', async () => {
|
||||
const h = makeHal();
|
||||
const r = await invoke(h.app, 'GET', '/');
|
||||
eq(r.status, 302, '루트 주소는 404가 아니라 운영자 콘솔로 이동');
|
||||
eq(r.headers.Location, '/ops/', '운영자 콘솔의 표준 경로로 이동');
|
||||
});
|
||||
await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠키 → 로그인 화면, 운영자 세션 → 콘솔(메뉴 14개 + 계정)', async () => {
|
||||
const h = makeHal();
|
||||
let r = await invoke(h.app, 'GET', '/ops/', { ip: '203.0.113.9' }); eq(r.status, 403); eq(r.json.error, 'access_denied');
|
||||
|
||||
@@ -64,6 +64,17 @@ await test('heartbeat → 지표·용량·모듈 버전 저장, 무응답 → of
|
||||
await sleep(80); eq(core.markOffline(h.d).length, 1); eq(core.listServers(h.d)[0].status, 'offline'); await a.hb(); eq(core.listServers(h.d)[0].status, 'ready', 'heartbeat 복귀');
|
||||
h.d.store.run('INSERT INTO metrics_samples(server_id, at, cpu) VALUES(?,?,?)', a.serverId, new Date(Date.now() - 100 * 86400000).toISOString(), 1); eq(core.purgeMetrics(h.d), 1); ok(core.metrics(h.d, a.serverId, { since: new Date(0).toISOString() }).every((m) => Date.now() - Date.parse(m.at) < 91 * 86400000));
|
||||
});
|
||||
await test('setBootstrap: HAL 기동 전(행 없음) CLI bootstrap-release 의 기록이 남는다(실제 환경 결함 수정)', async () => {
|
||||
const h = makeHal(); eq(h.d.store.get('SELECT COUNT(*) AS n FROM bootstrap_state').n, 0); core.setBootstrap(h.d, { release_version: '0.1.1', main_commit: 'abc', artifact_sha256: 'def' });
|
||||
eq(core.bootstrapState(h.d).release_version, '0.1.1'); eq(core.bootstrapState(h.d).main_commit, 'abc');
|
||||
});
|
||||
await test('pickServer: web·backup 역할 서버가 없으면 control 서버를 쓴다(서버 한 대 운영, 실제 환경 결함 수정) · 전용 서버가 있으면 그쪽 우선', async () => {
|
||||
const h = makeHal(); h.d.vault.set('service:portal', portalSecret); bootstrapDone(h.d);
|
||||
const ctl = core.createServer(h.d, { name: 'ctl', role: 'control' }); const t1 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: ctl.id }); const a1 = core.registerAgent(h.d, { token: t1.token, facts: { hostname: 'ctl', nginx: true }, version: '0.1.1' }); core.approveServer(h.d, ctl.id, 'op'); core.heartbeat(h.d, core.getAgent(h.d, a1.agentId), { metrics: { disk: { total: 100e9, used: 10e9 } }, modules: {} });
|
||||
eq(core.pickServer(h.d, { role: 'web' })?.serverId, ctl.id, 'web 요청 → control'); eq(core.pickServer(h.d, { role: 'backup' })?.serverId, ctl.id, 'backup 요청 → control'); eq(core.pickServer(h.d, { role: 'mail' }), null, 'mail 은 대체하지 않는다');
|
||||
const web = core.createServer(h.d, { name: 'web', role: 'web' }); const t2 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: web.id }); const a2 = core.registerAgent(h.d, { token: t2.token, facts: { hostname: 'web', nginx: true }, version: '0.1.1' }); core.approveServer(h.d, web.id, 'op'); core.heartbeat(h.d, core.getAgent(h.d, a2.agentId), { metrics: { disk: { total: 100e9, used: 10e9 } }, modules: {} });
|
||||
eq(core.pickServer(h.d, { role: 'web' })?.serverId, web.id, '전용 web 서버 우선');
|
||||
});
|
||||
await test('부트스트랩 관문: 첫 Agent 승인 전 포털 API 503, 승인·HAL 실행 버전 뒤 완료·수동 설치 잠김', async () => {
|
||||
const h = makeHal(); h.d.vault.set('service:portal', portalSecret);
|
||||
let r = await call(h.app, 'portal', portalSecret, 'GET', '/portal/v1/servers'); eq(r.status, 503); eq(r.json.error, 'bootstrap_incomplete');
|
||||
|
||||
Reference in New Issue
Block a user