diff --git a/VERSION_HISTORY.md b/VERSION_HISTORY.md index b07ef74..3da4e0e 100644 --- a/VERSION_HISTORY.md +++ b/VERSION_HISTORY.md @@ -109,6 +109,8 @@ - **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 버전·기존 유닛 보존 검사를 추가했다. ### 도입 소스 @@ -125,6 +127,8 @@ - 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 통계 유지). ### 미확정 정책값 diff --git a/docs/verification/local-e2e-2026-10-26.json b/docs/verification/local-e2e-2026-10-26.json index acdb1d9..51c96ca 100644 --- a/docs/verification/local-e2e-2026-10-26.json +++ b/docs/verification/local-e2e-2026-10-26.json @@ -1,14 +1,14 @@ { - "generatedAt": "2026-10-26T14:15:56.105Z", - "startedAt": "2026-10-26T14:13:34.958Z", + "generatedAt": "2026-10-26T14:22:13.542Z", + "startedAt": "2026-10-26T14:19:54.258Z", "env": { "agents": 4, "ports": { - "hal": 39783, - "portal": 33049, - "wm": 39757, - "am": 37585, - "mail": 37765 + "hal": 42095, + "portal": 46233, + "wm": 34303, + "am": 41929, + "mail": 36337 }, "mailServerRegistered": true, "node": "v24.22.1" @@ -99,12 +99,12 @@ { "name": "팀 그룹 만들기 → 현재 그룹 전환", "ok": true, - "detail": "g_01a1788f4c480f46fa7b303c98" + "detail": "g_01a17895158846031051e25505" }, { "name": "이메일 초대(7일) → 초대 메일 링크", "ok": true, - "detail": "0uEwoEn8HNWakp1RJSuhiZHW1kzEJAvm" + "detail": "akfdWdWvppAkoWrVUJnYkIdG2WcBGy8L" }, { "name": "초대 수락(1회용) → 별칭 user-02 · 역할 member", @@ -162,7 +162,7 @@ "detail": { "count": 2, "team": { - "id": "pv_01a1788f4ad4610eee820f3401", + "id": "pv_01a17895142780400bdd2c40c1", "price": 49000, "sites": 3 }, @@ -179,7 +179,7 @@ "orderNo": "AW-20261026-0001", "status": "awaiting_payment", "total": 63800, - "dueAt": "2026-11-02T14:13:39.644Z", + "dueAt": "2026-11-02T14:19:58.848Z", "hasInstructions": true } }, @@ -221,7 +221,7 @@ "detail": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T14:13:39.701Z" + "nextBilling": "2026-11-26T14:19:58.916Z" } } ], @@ -233,7 +233,7 @@ "bill": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T14:13:39.701Z" + "nextBilling": "2026-11-26T14:19:58.916Z" } } }, @@ -248,7 +248,7 @@ "name": "사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내", "ok": true, "detail": { - "id": "s_01a1788f4d33a5901ff0037e56", + "id": "s_01a1789516843da274b3515e56", "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:39757/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:34303/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 } @@ -299,7 +299,7 @@ ], "evidence": { "sso": { - "portalRedirect": "http://127.0.0.1:39757/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:34303/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 }, @@ -320,7 +320,7 @@ "name": "배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나", "ok": true, "detail": { - "jobId": "j_01a1788f4d7e2248b54de0dfb1", + "jobId": "j_01a1789516d0014fae08329a93", "steps": 10, "again": false, "siteCount": "reserved", @@ -351,7 +351,7 @@ "ok": true, "detail": { "lifecycle": "needs_domain", - "version": "20261026141340-rev-c1-000000", + "version": "20261026142000-rev-c1-000000", "verify": "needs_domain", "health": "ok", "agentHasSite": true @@ -360,7 +360,7 @@ ], "evidence": { "d": { - "jobId": "j_01a1788f4d7e2248b54de0dfb1", + "jobId": "j_01a1789516d0014fae08329a93", "steps": 10, "again": false, "siteCount": "reserved", @@ -383,7 +383,7 @@ }, "after": { "lifecycle": "needs_domain", - "version": "20261026141340-rev-c1-000000", + "version": "20261026142000-rev-c1-000000", "verify": "needs_domain", "health": "ok", "agentHasSite": true @@ -401,7 +401,7 @@ "name": "도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)", "ok": true, "detail": { - "domainId": "d_01a1788f5d569a950de7ed6bab", + "domainId": "d_01a1789526a4d3b7920a84ba47", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -422,7 +422,7 @@ ], "evidence": { "dm": { - "domainId": "d_01a1788f5d569a950de7ed6bab", + "domainId": "d_01a1789526a4d3b7920a84ba47", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -500,7 +500,7 @@ "name": "사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시", "ok": true, "detail": { - "id": "msv_01a1788fe5e1155387871ce5b9", + "id": "msv_01a17895af3560200993a9a392", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -517,7 +517,7 @@ "name": "메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음", "ok": true, "detail": { - "id": "mb_01a1788fe61eae41ba06ef9e4c", + "id": "mb_01a17895af6fdd3d34bd384db5", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -584,7 +584,7 @@ ], "evidence": { "svc": { - "id": "msv_01a1788fe5e1155387871ce5b9", + "id": "msv_01a17895af3560200993a9a392", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -592,7 +592,7 @@ "siteCountAllocated": 0 }, "mb": { - "id": "mb_01a1788fe61eae41ba06ef9e4c", + "id": "mb_01a17895af6fdd3d34bd384db5", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -644,7 +644,7 @@ "name": "메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)", "ok": true, "detail": { - "id": "d_01a178900c338e70a4bb86cd6f", + "id": "d_01a17895c5ce20bce855525e0c", "jobCancelled": null } }, @@ -652,7 +652,7 @@ "name": "독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)", "ok": true, "detail": { - "id": "msv_01a178900c3bc1b6dc766d01b2", + "id": "msv_01a17895c5d6f2d5021f63bddd", "kind": "standalone", "mailServicesAllocated": 1, "sitesAllocated": 0 @@ -661,7 +661,7 @@ ], "evidence": { "r": { - "id": "msv_01a178900c3bc1b6dc766d01b2", + "id": "msv_01a17895c5d6f2d5021f63bddd", "kind": "standalone", "mailServicesAllocated": 1, "sitesAllocated": 0 @@ -701,8 +701,8 @@ "ok": true, "detail": { "status": "active", - "firstDue": "2026-10-27T14:14:28.820Z", - "nextRun": "2026-10-26T14:14:28.820Z" + "firstDue": "2026-10-27T14:20:44.016Z", + "nextRun": "2026-10-26T14:20:44.016Z" } }, { @@ -712,7 +712,7 @@ "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": "srv_01a1788f44c213e5c17081909b", + "siteServer": "srv_01a178950dff5cea2276c1a427", "backup": { "status": "done", "tier": "monthly", @@ -733,14 +733,14 @@ }, "opt": { "status": "active", - "firstDue": "2026-10-27T14:14:28.820Z", - "nextRun": "2026-10-26T14:14:28.820Z" + "firstDue": "2026-10-27T14:20:44.016Z", + "nextRun": "2026-10-26T14:20:44.016Z" }, "run1": { "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": "srv_01a1788f44c213e5c17081909b", + "siteServer": "srv_01a178950dff5cea2276c1a427", "backup": { "status": "done", "tier": "monthly", @@ -763,19 +763,19 @@ "ok": true, "detail": { "backup": { - "id": "bk_01a17890221d1906f53694f75d", - "createdAt": "2026-10-26T14:14:28.828Z", + "id": "bk_01a17895dfaa66220d595c048d", + "createdAt": "2026-10-26T14:20:44.023Z", "scope": { "files": true, "db": true, "uploads": true, "config": true, - "storeServerId": "srv_01a1788f456f9457904975ca45", + "storeServerId": "srv_01a178950eacfe3242171975e8", "verifyMethod": "sha256 + 격리 환경 읽기 확인" }, - "sizeBytes": 1898, + "sizeBytes": 1896, "verifyState": "verified", - "expiresAt": "2027-01-27T14:14:34.270Z", + "expiresAt": "2027-01-27T14:20:50.475Z", "tier": "monthly" }, "included": [ @@ -809,7 +809,7 @@ "name": "복원 요청 → 운영자 승인 대기 · 중복 요청 없음", "ok": true, "detail": { - "id": "rs_01a1789024905bbeee711715c7", + "id": "rs_01a17895e40cc31b5b957071f0", "status": "awaiting_approval", "dup": false, "finalConfirmation": { @@ -838,19 +838,19 @@ "evidence": { "info": { "backup": { - "id": "bk_01a17890221d1906f53694f75d", - "createdAt": "2026-10-26T14:14:28.828Z", + "id": "bk_01a17895dfaa66220d595c048d", + "createdAt": "2026-10-26T14:20:44.023Z", "scope": { "files": true, "db": true, "uploads": true, "config": true, - "storeServerId": "srv_01a1788f456f9457904975ca45", + "storeServerId": "srv_01a178950eacfe3242171975e8", "verifyMethod": "sha256 + 격리 환경 읽기 확인" }, - "sizeBytes": 1898, + "sizeBytes": 1896, "verifyState": "verified", - "expiresAt": "2027-01-27T14:14:34.270Z", + "expiresAt": "2027-01-27T14:20:50.475Z", "tier": "monthly" }, "included": [ @@ -880,7 +880,7 @@ "deferred": [] }, "rq": { - "id": "rs_01a1789024905bbeee711715c7", + "id": "rs_01a17895e40cc31b5b957071f0", "status": "awaiting_approval", "dup": false, "finalConfirmation": { @@ -970,8 +970,8 @@ "name": "외부 배포 사이트 생성(초안) + 저장본", "ok": true, "detail": { - "id": "s_01a17891100c4d7a376f63d2b9", - "group_id": "g_01a1788f4c480f46fa7b303c98", + "id": "s_01a17896cf836296e6cf8802ab", + "group_id": "g_01a17895158846031051e25505", "name": "E2E 외부 사이트", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000004", @@ -999,9 +999,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1788f4ba6284a77cd7cbd45", - "created_at": "2026-10-26T14:15:35.180Z", - "updated_at": "2026-10-26T14:15:35.186Z", + "created_by": "u_01a1789514e318307a9166fe78", + "created_at": "2026-10-26T14:21:51.876Z", + "updated_at": "2026-10-26T14:21:51.880Z", "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:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9", - "expiresAt": "2026-10-26T14:25:35.492Z", + "install": "curl -fsSL http://127.0.0.1:42095/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42095 --token … --external --site s_01a17896cf836296e6cf8802ab", + "expiresAt": "2026-10-26T14:31:51.915Z", "status": "pending" } }, @@ -1126,8 +1126,8 @@ "agreed": false }, "tok": { - "install": "curl -fsSL http://127.0.0.1:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9", - "expiresAt": "2026-10-26T14:25:35.492Z" + "install": "curl -fsSL http://127.0.0.1:42095/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42095 --token … --external --site s_01a17896cf836296e6cf8802ab", + "expiresAt": "2026-10-26T14:31:51.915Z" }, "conn": { "status": "connected", @@ -1311,7 +1311,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a1788f4ad4ee11f68c599438" + "product": "pv_01a178951427477eb7bc895885" } } ], @@ -1347,7 +1347,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a1788f4ad4ee11f68c599438" + "product": "pv_01a178951427477eb7bc895885" } } }, @@ -1430,9 +1430,9 @@ "name": "요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림", "ok": true, "detail": { - "id": "tr_01a178913319cb58067d72957a", + "id": "tr_01a17896f2625044dea12cf4e9", "status": "requested", - "expiresAt": "2026-11-02T14:15:44.153Z", + "expiresAt": "2026-11-02T14:22:00.802Z", "bobNotified": true } }, @@ -1460,7 +1460,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T14:15:44.203Z" + "cutover": "2026-10-26T14:22:00.850Z" } }, { @@ -1489,9 +1489,9 @@ "excluded": 3 }, "rq": { - "id": "tr_01a178913319cb58067d72957a", + "id": "tr_01a17896f2625044dea12cf4e9", "status": "requested", - "expiresAt": "2026-11-02T14:15:44.153Z", + "expiresAt": "2026-11-02T14:22:00.802Z", "bobNotified": true }, "acc": { @@ -1503,7 +1503,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T14:15:44.203Z" + "cutover": "2026-10-26T14:22:00.850Z" }, "after": { "aliceSees": 404, @@ -1781,7 +1781,7 @@ "detail": { "status": "done", "canRetry": false, - "jobId": "j_01a1788f4d7e2248b54de0dfb1" + "jobId": "j_01a1789516d0014fae08329a93" } }, { @@ -1806,7 +1806,7 @@ "dep": { "status": "done", "canRetry": false, - "jobId": "j_01a1788f4d7e2248b54de0dfb1" + "jobId": "j_01a1789516d0014fae08329a93" }, "rt": { "status": 400, @@ -1832,7 +1832,7 @@ "detail": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a1788f4cbcc8ea43f877bfc4" + "link": "/orders/o_01a178951600a2dd5f8363a606" } }, { @@ -1848,7 +1848,7 @@ "ok": true, "detail": { "status": "approved", - "approvedAt": "2026-10-26T14:13:39.701Z" + "approvedAt": "2026-10-26T14:19:58.916Z" } } ], @@ -1856,7 +1856,7 @@ "n": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a1788f4cbcc8ea43f877bfc4" + "link": "/orders/o_01a178951600a2dd5f8363a606" }, "a": { "action": "order.approve", @@ -1864,7 +1864,7 @@ }, "same": { "status": "approved", - "approvedAt": "2026-10-26T14:13:39.701Z" + "approvedAt": "2026-10-26T14:19:58.916Z" } } }, @@ -1907,9 +1907,9 @@ "rpo_minutes": 30, "snapshot_interval_minutes": 60, "retention_count": 3, - "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026141334/dr-external", - "updated_at": "2026-10-26T14:15:53.151Z", - "updated_by": "op_01a1788f43bf7bccb5fba30c59" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026141954/dr-external", + "updated_at": "2026-10-26T14:22:10.592Z", + "updated_by": "op_01a178950d042b39ebdc74f543" } }, { @@ -1969,9 +1969,9 @@ "rpo_minutes": 30, "snapshot_interval_minutes": 60, "retention_count": 3, - "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026141334/dr-external", - "updated_at": "2026-10-26T14:15:53.151Z", - "updated_by": "op_01a1788f43bf7bccb5fba30c59" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026141954/dr-external", + "updated_at": "2026-10-26T14:22:10.592Z", + "updated_by": "op_01a178950d042b39ebdc74f543" }, "snap": { "verify": "ok", @@ -2005,7 +2005,7 @@ "ok": true, "detail": { "mailJob": { - "id": "j_01a1788ff65e15ccfd1505fea6", + "id": "j_01a17895c3aa79c0b0eb5bac38", "status": "awaiting_external" }, "halJobs": 18 @@ -2017,7 +2017,7 @@ "detail": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a1788ff65e15ccfd1505fea6", + "id": "j_01a17895c3aa79c0b0eb5bac38", "status": "awaiting_external" }, "same": true @@ -2036,7 +2036,7 @@ "evidence": { "before": { "mailJob": { - "id": "j_01a1788ff65e15ccfd1505fea6", + "id": "j_01a17895c3aa79c0b0eb5bac38", "status": "awaiting_external" }, "halJobs": 18 @@ -2044,7 +2044,7 @@ "pr": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a1788ff65e15ccfd1505fea6", + "id": "j_01a17895c3aa79c0b0eb5bac38", "status": "awaiting_external" }, "same": true @@ -2075,8 +2075,8 @@ "ok": true, "detail": [ { - "id": "s_01a1788fd9df3235dfa7d0461c", - "group_id": "g_01a1788f4c75bbf31637a86691", + "id": "s_01a17895a33370c86d2578bdfa", + "group_id": "g_01a1789515b8d48acf24ef196d", "name": "동시 A", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000002", @@ -2104,9 +2104,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1788f4c74ff0259e7da3335", - "created_at": "2026-10-26T14:14:15.775Z", - "updated_at": "2026-10-26T14:14:15.784Z", + "created_by": "u_01a1789515b89dff9e86481ccb", + "created_at": "2026-10-26T14:20:34.995Z", + "updated_at": "2026-10-26T14:20:35.004Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -2125,8 +2125,8 @@ "nextJob": null }, { - "id": "s_01a1788fd9e3f2709aee825315", - "group_id": "g_01a1788f4c75bbf31637a86691", + "id": "s_01a17895a3378265a5de54a98d", + "group_id": "g_01a1789515b8d48acf24ef196d", "name": "동시 B", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000003", @@ -2154,9 +2154,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1788f4c74ff0259e7da3335", - "created_at": "2026-10-26T14:14:15.779Z", - "updated_at": "2026-10-26T14:14:15.786Z", + "created_by": "u_01a1789515b89dff9e86481ccb", + "created_at": "2026-10-26T14:20:34.999Z", + "updated_at": "2026-10-26T14:20:35.006Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -2214,8 +2214,8 @@ "name": "외부 배포 사이트 생성(초안) + 저장본", "ok": true, "detail": { - "id": "s_01a17891100c4d7a376f63d2b9", - "group_id": "g_01a1788f4c480f46fa7b303c98", + "id": "s_01a17896cf836296e6cf8802ab", + "group_id": "g_01a17895158846031051e25505", "name": "E2E 외부 사이트", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000004", @@ -2243,9 +2243,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1788f4ba6284a77cd7cbd45", - "created_at": "2026-10-26T14:15:35.180Z", - "updated_at": "2026-10-26T14:15:35.186Z", + "created_by": "u_01a1789514e318307a9166fe78", + "created_at": "2026-10-26T14:21:51.876Z", + "updated_at": "2026-10-26T14:21:51.880Z", "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:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9", - "expiresAt": "2026-10-26T14:25:35.492Z", + "install": "curl -fsSL http://127.0.0.1:42095/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42095 --token … --external --site s_01a17896cf836296e6cf8802ab", + "expiresAt": "2026-10-26T14:31:51.915Z", "status": "pending" } }, @@ -2483,8 +2483,8 @@ "overall": "partial", "children": [ "srv_missing_e2e:failed:서버가 없습니다", - "srv_01a1788f456f9457904975ca45:done:", - "srv_01a1788f44c213e5c17081909b:done:" + "srv_01a178950eacfe3242171975e8:done:", + "srv_01a178950dff5cea2276c1a427:done:" ], "timedOut": false } @@ -2522,8 +2522,8 @@ "overall": "partial", "children": [ "srv_missing_e2e:failed:서버가 없습니다", - "srv_01a1788f456f9457904975ca45:done:", - "srv_01a1788f44c213e5c17081909b:done:" + "srv_01a178950eacfe3242171975e8:done:", + "srv_01a178950dff5cea2276c1a427:done:" ], "timedOut": false }, @@ -2562,7 +2562,7 @@ "name": "critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)", "ok": true, "detail": { - "id": "em_01a178915754f8b9d8ef0ffc24", + "id": "em_01a1789719b470be87b0b500f7", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", @@ -2570,7 +2570,7 @@ "approved": "approved", "approvals": 3, "credential": "[redacted]", - "expiresAt": "2026-10-26T14:45:53.458Z" + "expiresAt": "2026-10-26T14:52:10.897Z" } }, { @@ -2591,14 +2591,14 @@ "evidence": { "healthy": "hal_healthy", "sess": { - "id": "em_01a178915754f8b9d8ef0ffc24", + "id": "em_01a1789719b470be87b0b500f7", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", "badText": "final_confirm_mismatch", "approved": "approved", "approvals": 3, - "expiresAt": "2026-10-26T14:45:53.458Z" + "expiresAt": "2026-10-26T14:52:10.897Z" }, "perf": { "forbidden": "action_forbidden", @@ -2650,8 +2650,8 @@ "ok": true, "detail": { "status": "readonly", - "deleteAfter": "2026-11-25T14:15:44.084Z", - "readonlyUntil": "2026-11-25T14:15:44.084Z" + "deleteAfter": "2026-11-25T14:22:00.732Z", + "readonlyUntil": "2026-11-25T14:22:00.732Z" } }, { @@ -2677,8 +2677,8 @@ }, "un": { "status": "readonly", - "deleteAfter": "2026-11-25T14:15:44.084Z", - "readonlyUntil": "2026-11-25T14:15:44.084Z" + "deleteAfter": "2026-11-25T14:22:00.732Z", + "readonlyUntil": "2026-11-25T14:22:00.732Z" }, "sweep": { "sweep": 200, diff --git a/docs/verification/local-e2e-2026-10-26.md b/docs/verification/local-e2e-2026-10-26.md index 627d60c..97726a8 100644 --- a/docs/verification/local-e2e-2026-10-26.md +++ b/docs/verification/local-e2e-2026-10-26.md @@ -1,6 +1,6 @@ # 로컬 종단 간 검증 기록 — 2026-10-26 -생성: 2026-10-26T14:15:56.105Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/) +생성: 2026-10-26T14:22:13.542Z · `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_01a1788f4c480f46fa7b303c98 -- ✔ 이메일 초대(7일) → 초대 메일 링크 — 0uEwoEn8HNWakp1RJSuhiZHW1kzEJAvm +- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a17895158846031051e25505 +- ✔ 이메일 초대(7일) → 초대 메일 링크 — akfdWdWvppAkoWrVUJnYkIdG2WcBGy8L - ✔ 초대 수락(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_01a1788f4ad4610eee820f3401","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]} -- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T14:13:39.644Z","hasInstructions":true} +- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a17895142780400bdd2c40c1","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]} +- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T14:19:58.848Z","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-26T14:13:39.701Z"} +- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T14:19:58.916Z"} ```json { @@ -112,25 +112,25 @@ "bill": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T14:13:39.701Z" + "nextBilling": "2026-11-26T14:19:58.916Z" } } ``` ### 4. 초안 사이트 생성 -- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a1788f4d33a5901ff0037e56","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]} +- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a1789516843da274b3515e56","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:39757/api/auth/sso?ticket=…","webmaker":302,"reuse":401} +- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:34303/api/auth/sso?ticket=…","webmaker":302,"reuse":401} - ✔ Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과 — {"versions":1,"precheckOk":true} ```json { "sso": { - "portalRedirect": "http://127.0.0.1:39757/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:34303/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 }, @@ -143,14 +143,14 @@ ### 6. 관리형 배포 -- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a1788f4d7e2248b54de0dfb1","steps":10,"again":false,"siteCount":"reserved","server":null} +- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a1789516d0014fae08329a93","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":"20261026141340-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true} +- ✔ 배포 뒤 사이트 상태·버전·건강 — {"lifecycle":"needs_domain","version":"20261026142000-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true} ```json { "d": { - "jobId": "j_01a1788f4d7e2248b54de0dfb1", + "jobId": "j_01a1789516d0014fae08329a93", "steps": 10, "again": false, "siteCount": "reserved", @@ -173,7 +173,7 @@ }, "after": { "lifecycle": "needs_domain", - "version": "20261026141340-rev-c1-000000", + "version": "20261026142000-rev-c1-000000", "verify": "needs_domain", "health": "ok", "agentHasSite": true @@ -183,14 +183,14 @@ ### 7. 도메인 연결과 HTTPS 발급 -- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1788f5d569a950de7ed6bab","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true} +- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1789526a4d3b7920a84ba47","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true} - ✔ 도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시 — {} - ✔ (정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다 — {"queuedProbes":0} ```json { "dm": { - "domainId": "d_01a1788f5d569a950de7ed6bab", + "domainId": "d_01a1789526a4d3b7920a84ba47", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -233,9 +233,9 @@ ### 10. 사이트 대표 메일과 메일함 생성 -- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1788fe5e1155387871ce5b9","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0} +- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a17895af3560200993a9a392","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0} - ✔ 같은 사이트에 두 번째 대표 메일 거부 — mail_domain_in_use -- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a1788fe61eae41ba06ef9e4c","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true} +- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a17895af6fdd3d34bd384db5","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_01a1788fe5e1155387871ce5b9", + "id": "msv_01a17895af3560200993a9a392", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -252,7 +252,7 @@ "siteCountAllocated": 0 }, "mb": { - "id": "mb_01a1788fe61eae41ba06ef9e4c", + "id": "mb_01a17895af6fdd3d34bd384db5", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -287,13 +287,13 @@ ### 12. 독립 메일 서비스 생성 -- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a178900c338e70a4bb86cd6f","jobCancelled":null} -- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a178900c3bc1b6dc766d01b2","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0} +- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a17895c5ce20bce855525e0c","jobCancelled":null} +- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a17895c5d6f2d5021f63bddd","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0} ```json { "r": { - "id": "msv_01a178900c3bc1b6dc766d01b2", + "id": "msv_01a17895c5d6f2d5021f63bddd", "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-27T14:14:28.820Z","nextRun":"2026-10-26T14:14:28.820Z"} -- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a1788f44c213e5c17081909b","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0} +- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T14:20:44.016Z","nextRun":"2026-10-26T14:20:44.016Z"} +- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a178950dff5cea2276c1a427","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0} ```json { @@ -321,14 +321,14 @@ }, "opt": { "status": "active", - "firstDue": "2026-10-27T14:14:28.820Z", - "nextRun": "2026-10-26T14:14:28.820Z" + "firstDue": "2026-10-27T14:20:44.016Z", + "nextRun": "2026-10-26T14:20:44.016Z" }, "run1": { "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": "srv_01a1788f44c213e5c17081909b", + "siteServer": "srv_01a178950dff5cea2276c1a427", "backup": { "status": "done", "tier": "monthly", @@ -342,8 +342,8 @@ ### 14. 백업 복원과 외부 사이트 확인 -- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a17890221d1906f53694f75d","createdAt":"2026-10-26T14:14:28.828Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a1788f456f9457904975ca45","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1898,"verifyState":"verified","expiresAt":"2027-01-2 -- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a1789024905bbeee711715c7","status":"awaiting_approval","dup":false,"finalConfirmation":{"group":"E2E 팀","site":"E2E 사이트","domain":null}} +- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a17895dfaa66220d595c048d","createdAt":"2026-10-26T14:20:44.023Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a178950eacfe3242171975e8","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1896,"verifyState":"verified","expiresAt":"2027-01-2 +- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a17895e40cc31b5b957071f0","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_01a17890221d1906f53694f75d", - "createdAt": "2026-10-26T14:14:28.828Z", + "id": "bk_01a17895dfaa66220d595c048d", + "createdAt": "2026-10-26T14:20:44.023Z", "scope": { "files": true, "db": true, "uploads": true, "config": true, - "storeServerId": "srv_01a1788f456f9457904975ca45", + "storeServerId": "srv_01a178950eacfe3242171975e8", "verifyMethod": "sha256 + 격리 환경 읽기 확인" }, - "sizeBytes": 1898, + "sizeBytes": 1896, "verifyState": "verified", - "expiresAt": "2027-01-27T14:14:34.270Z", + "expiresAt": "2027-01-27T14:20:50.475Z", "tier": "monthly" }, "included": [ @@ -393,7 +393,7 @@ "deferred": [] }, "rq": { - "id": "rs_01a1789024905bbeee711715c7", + "id": "rs_01a17895e40cc31b5b957071f0", "status": "awaiting_approval", "dup": false, "finalConfirmation": { @@ -445,10 +445,10 @@ ### 16. 외부 배포 사이트 생성과 배포 -- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a17891100c4d7a376f63d2b9","group_id":"g_01a1788f4c480f46fa7b303c98","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_01a17896cf836296e6cf8802ab","group_id":"g_01a17895158846031051e25505","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:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9","expiresAt":"2026-10-26T14:25:35.492Z","status":"pending"} +- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:42095/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42095 --token … --external --site s_01a17896cf836296e6cf8802ab","expiresAt":"2026-10-26T14:31:51.915Z","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:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9", - "expiresAt": "2026-10-26T14:25:35.492Z" + "install": "curl -fsSL http://127.0.0.1:42095/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42095 --token … --external --site s_01a17896cf836296e6cf8802ab", + "expiresAt": "2026-10-26T14:31:51.915Z" }, "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_01a1788f4ad4ee11f68c599438"} +- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a178951427477eb7bc895885"} ```json { @@ -589,7 +589,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a1788f4ad4ee11f68c599438" + "product": "pv_01a178951427477eb7bc895885" } } ``` @@ -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_01a178913319cb58067d72957a","status":"requested","expiresAt":"2026-11-02T14:15:44.153Z","bobNotified":true} +- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a17896f2625044dea12cf4e9","status":"requested","expiresAt":"2026-11-02T14:22:00.802Z","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-26T14:15:44.203Z"} +- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T14:22:00.850Z"} - ✔ 완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사 — {"aliceSees":404,"bobSees":200,"bobUsageSites":0,"audit":true} ```json @@ -645,9 +645,9 @@ "excluded": 3 }, "rq": { - "id": "tr_01a178913319cb58067d72957a", + "id": "tr_01a17896f2625044dea12cf4e9", "status": "requested", - "expiresAt": "2026-11-02T14:15:44.153Z", + "expiresAt": "2026-11-02T14:22:00.802Z", "bobNotified": true }, "acc": { @@ -659,7 +659,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T14:15:44.203Z" + "cutover": "2026-10-26T14:22:00.850Z" }, "after": { "aliceSees": 404, @@ -798,7 +798,7 @@ ### 23. 실패 작업의 재시도와 되돌리기 -- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a1788f4d7e2248b54de0dfb1"} +- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a1789516d0014fae08329a93"} - ✔ 재시도 요청(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_01a1788f4d7e2248b54de0dfb1" + "jobId": "j_01a1789516d0014fae08329a93" }, "rt": { "status": 400, @@ -823,16 +823,16 @@ ### 24. 운영자 승인, 감사 기록, 고객 알림의 일치 -- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a1788f4cbcc8ea43f877bfc4"} +- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a178951600a2dd5f8363a606"} - ✔ 감사 기록: order.approve 는 삭제 불가(immutable) — {"action":"order.approve","immutable":true} -- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T14:13:39.701Z"} +- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T14:19:58.916Z"} ```json { "n": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a1788f4cbcc8ea43f877bfc4" + "link": "/orders/o_01a178951600a2dd5f8363a606" }, "a": { "action": "order.approve", @@ -840,7 +840,7 @@ }, "same": { "status": "approved", - "approvedAt": "2026-10-26T14:13:39.701Z" + "approvedAt": "2026-10-26T14:19:58.916Z" } } ``` @@ -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-20261026141334/dr-external","updated_at":"2026-10-26T14:15:53.151Z","updated_by":"op_01a1788f43bf7bccb5fba30c59"} +- ✔ 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-20261026141954/dr-external","updated_at":"2026-10-26T14:22:10.592Z","updated_by":"op_01a178950d042b39ebdc74f543"} - ✔ 복구 사본 생성·검증·외부 보관(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-20261026141334/dr-external", - "updated_at": "2026-10-26T14:15:53.151Z", - "updated_by": "op_01a1788f43bf7bccb5fba30c59" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026141954/dr-external", + "updated_at": "2026-10-26T14:22:10.592Z", + "updated_by": "op_01a178950d042b39ebdc74f543" }, "snap": { "verify": "ok", @@ -907,15 +907,15 @@ ### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 -- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a1788ff65e15ccfd1505fea6","status":"awaiting_external"},"halJobs":18} -- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a1788ff65e15ccfd1505fea6","status":"awaiting_external"},"same":true} +- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a17895c3aa79c0b0eb5bac38","status":"awaiting_external"},"halJobs":18} +- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a17895c3aa79c0b0eb5bac38","status":"awaiting_external"},"same":true} - ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":18,"sameCount":true} ```json { "before": { "mailJob": { - "id": "j_01a1788ff65e15ccfd1505fea6", + "id": "j_01a17895c3aa79c0b0eb5bac38", "status": "awaiting_external" }, "halJobs": 18 @@ -923,7 +923,7 @@ "pr": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a1788ff65e15ccfd1505fea6", + "id": "j_01a17895c3aa79c0b0eb5bac38", "status": "awaiting_external" }, "same": true @@ -939,7 +939,7 @@ ### 27. 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 - ✔ 밥 개인 그룹: 베이직(사이트 1) 주문·승인 — {"granted":6} -- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1788fd9df3235dfa7d0461c","group_id":"g_01a1788f4c75bbf31637a86691","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_01a17895a33370c86d2578bdfa","group_id":"g_01a1789515b8d48acf24ef196d","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_01a17891100c4d7a376f63d2b9","group_id":"g_01a1788f4c480f46fa7b303c98","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_01a17896cf836296e6cf8802ab","group_id":"g_01a17895158846031051e25505","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:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9","expiresAt":"2026-10-26T14:25:35.492Z","status":"pending"} +- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:42095/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42095 --token … --external --site s_01a17896cf836296e6cf8802ab","expiresAt":"2026-10-26T14:31:51.915Z","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_01a1788f456f9457904975ca45:done:","srv_01a1788f44c213e5c17081909b:done:"],"timedOut":false} +- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"failed","errorCode":"partial_failure","overall":"partial","children":["srv_missing_e2e:failed:서버가 없습니다","srv_01a178950eacfe3242171975e8:done:","srv_01a178950dff5cea2276c1a427: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_01a1788f456f9457904975ca45:done:", - "srv_01a1788f44c213e5c17081909b:done:" + "srv_01a178950eacfe3242171975e8:done:", + "srv_01a178950dff5cea2276c1a427:done:" ], "timedOut": false }, @@ -1079,21 +1079,21 @@ ### 31. 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 - ✔ HAL 정상 상태에서 선언 거부(hal_healthy) — hal_healthy -- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a178915754f8b9d8ef0ffc24","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T14:45:53.458Z"} +- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a1789719b470be87b0b500f7","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T14:52:10.897Z"} - ✔ 일반 배포(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_01a178915754f8b9d8ef0ffc24", + "id": "em_01a1789719b470be87b0b500f7", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", "badText": "final_confirm_mismatch", "approved": "approved", "approvals": 3, - "expiresAt": "2026-10-26T14:45:53.458Z" + "expiresAt": "2026-10-26T14:52:10.897Z" }, "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-25T14:15:44.084Z","readonlyUntil":"2026-11-25T14:15:44.084Z"} +- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T14:22:00.732Z","readonlyUntil":"2026-11-25T14:22:00.732Z"} - ✔ 삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음 — {"sweep":200,"statusAfter":"readonly"} ```json @@ -1131,8 +1131,8 @@ }, "un": { "status": "readonly", - "deleteAfter": "2026-11-25T14:15:44.084Z", - "readonlyUntil": "2026-11-25T14:15:44.084Z" + "deleteAfter": "2026-11-25T14:22:00.732Z", + "readonlyUntil": "2026-11-25T14:22:00.732Z" }, "sweep": { "sweep": 200, diff --git a/hal/core.mjs b/hal/core.mjs index 7ae1937..6bbbf96 100644 --- a/hal/core.mjs +++ b/hal/core.mjs @@ -81,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; } diff --git a/portal/backup/service.mjs b/portal/backup/service.mjs index 4ad6dfa..429d75d 100644 --- a/portal/backup/service.mjs +++ b/portal/backup/service.mjs @@ -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); diff --git a/test/hal.test.mjs b/test/hal.test.mjs index a0b681f..516adbc 100644 --- a/test/hal.test.mjs +++ b/test/hal.test.mjs @@ -68,6 +68,13 @@ await test('setBootstrap: HAL 기동 전(행 없음) CLI bootstrap-release 의 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');