From 98f4182bbbf13c4280dabf8acf57a930ad66d7f5 Mon Sep 17 00:00:00 2001 From: zzabisagent-lab Date: Mon, 26 Oct 2026 13:13:39 +0000 Subject: [PATCH] =?UTF-8?q?E2E=20=EA=B2=B0=ED=95=A8=20=EC=88=98=EC=A0=95:?= =?UTF-8?q?=20Agent=20=EC=A0=88=EB=8C=80=20=EB=B2=88=EB=93=A4=20URL,=20HAL?= =?UTF-8?q?=20=EC=9E=91=EC=97=85=20=EC=9E=AC=EC=82=AC=EC=9A=A9(=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=88=A8=EA=B9=80=20=EB=B0=A9=EC=A7=80),=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=83=90=EC=B9=A8=20=EC=9E=AC?= =?UTF-8?q?=EA=B0=9C=20=ED=82=A4,=20=EC=9E=91=EC=97=85=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=20=EB=8F=99=EC=8B=9C=20=EC=8B=A4=ED=96=89(=EB=B6=80?= =?UTF-8?q?=EB=AA=A8/=EC=9E=90=EC=8B=9D=20=EA=B5=90=EC=B0=A9=20=ED=95=B4?= =?UTF-8?q?=EC=86=8C),=20=EC=99=B8=EB=B6=80=20Agent=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EC=9E=90,=20JobError=204xx,=20=ED=8F=AC?= =?UTF-8?q?=ED=84=B8=20HAL=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=C2=B7health=20?= =?UTF-8?q?=EC=88=98=EC=8B=A0,=20link-portal=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- agent/client.mjs | 4 +- docs/verification/local-e2e-2026-10-26.json | 1132 ++++++++++--------- docs/verification/local-e2e-2026-10-26.md | 629 ++++++----- hal/index.mjs | 5 +- lib/jobs.mjs | 26 +- portal/domains/service.mjs | 14 +- portal/sites/service.mjs | 8 +- portal/webhooks/api.mjs | 26 + 8 files changed, 991 insertions(+), 853 deletions(-) create mode 100644 portal/webhooks/api.mjs diff --git a/agent/client.mjs b/agent/client.mjs index 12145a9..3056d37 100644 --- a/agent/client.mjs +++ b/agent/client.mjs @@ -21,7 +21,9 @@ export function createClient({ halUrl, agentId, secret, fetchImpl = fetch }) { } /** 파일 내려받기 + sha256 검증(expected 가 있으면) */ async function download(path, dest, expected = null) { - const r = await fetchImpl(base + path, { headers: signHeaders(keyId, secret, 'GET', path, undefined), signal: AbortSignal.timeout(3600000) }); skew(r); + // [E2E 결함 수정] 포털이 주는 번들 URL 은 절대 주소(토큰으로 보호)다. HAL 상대 경로일 때만 HAL 서명 헤더를 붙인다. + const absolute = /^https?:\/\//i.test(path); + const r = await fetchImpl(absolute ? path : base + path, { headers: absolute ? {} : signHeaders(keyId, secret, 'GET', path, undefined), signal: AbortSignal.timeout(3600000) }); if (!absolute) skew(r); if (!r.ok) { const e = new Error(`HAL 내려받기 실패 ${path} → ${r.status}`); e.code = `http_${r.status}`; throw e; } const hash = createHash('sha256'); const src = Readable.fromWeb(r.body); src.on('data', (c) => hash.update(c)); await pipeline(src, createWriteStream(dest, { mode: 0o600 })); const sum = hash.digest('hex'); const want = String(expected || r.headers.get('x-sha256') || '').replace(/^sha256:/i, '').toLowerCase(); if (want && want !== sum) throw Object.assign(new Error(`내려받은 파일 해시가 다릅니다(기대 ${want.slice(0, 12)}… 실제 ${sum.slice(0, 12)}…)`), { code: 'checksum_mismatch' }); diff --git a/docs/verification/local-e2e-2026-10-26.json b/docs/verification/local-e2e-2026-10-26.json index 9e9eceb..398972b 100644 --- a/docs/verification/local-e2e-2026-10-26.json +++ b/docs/verification/local-e2e-2026-10-26.json @@ -1,23 +1,23 @@ { - "generatedAt": "2026-10-26T13:02:10.786Z", - "startedAt": "2026-10-26T12:57:29.622Z", + "generatedAt": "2026-10-26T13:12:56.197Z", + "startedAt": "2026-10-26T13:10:36.090Z", "env": { "agents": 4, "ports": { - "hal": 35361, - "portal": 35287, - "wm": 43619, - "am": 33137, - "mail": 40085 + "hal": 40927, + "portal": 43283, + "wm": 40985, + "am": 42921, + "mail": 34497 }, "mailServerRegistered": true, "node": "v24.22.1" }, "summary": { - "verified_local": 13, - "partial": 15, - "not_verifiable_locally": 2, - "failed": 4, + "verified_local": 17, + "partial": 14, + "not_verifiable_locally": 1, + "failed": 2, "unrecorded": 0 }, "flows": [ @@ -99,12 +99,12 @@ { "name": "팀 그룹 만들기 → 현재 그룹 전환", "ok": true, - "detail": "g_01a17849a2c2dec27ee9f14777" + "detail": "g_01a17855a4277ac98f458d3057" }, { "name": "이메일 초대(7일) → 초대 메일 링크", "ok": true, - "detail": "RAXi6X0pnmr-nCEKiIX63pk26vTbIB5d" + "detail": "Y4dXy0GV5b7pvqunRITvwVbgXFNedpwd" }, { "name": "초대 수락(1회용) → 별칭 user-02 · 역할 member", @@ -162,7 +162,7 @@ "detail": { "count": 2, "team": { - "id": "pv_01a17849a127747d9c2bbbb24c", + "id": "pv_01a17855a17f402b30366a2d5a", "price": 49000, "sites": 3 }, @@ -179,7 +179,7 @@ "orderNo": "AW-20261026-0001", "status": "awaiting_payment", "total": 63800, - "dueAt": "2026-11-02T12:57:34.263Z", + "dueAt": "2026-11-02T13:10:41.050Z", "hasInstructions": true } }, @@ -221,7 +221,7 @@ "detail": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T12:57:34.327Z" + "nextBilling": "2026-11-26T13:10:41.118Z" } } ], @@ -233,7 +233,7 @@ "bill": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T12:57:34.327Z" + "nextBilling": "2026-11-26T13:10:41.118Z" } } }, @@ -248,7 +248,7 @@ "name": "사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내", "ok": true, "detail": { - "id": "s_01a17849a3be6f07d129dbdb60", + "id": "s_01a17855a51f4c70a0da049568", "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:43619/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:40985/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 } @@ -299,7 +299,7 @@ ], "evidence": { "sso": { - "portalRedirect": "http://127.0.0.1:43619/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:40985/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 }, @@ -312,15 +312,15 @@ { "no": 6, "title": "관리형 배포", - "verdict": "failed", - "note": "시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: [\"failed\",\"Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni\"])", + "verdict": "verified_local", + "note": "배포 요청→예약→HAL 작업→Agent(stub) 배포→도메인 설정 필요 상태(대표 도메인 없음)", "blockedBy": null, "steps": [ { "name": "배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나", "ok": true, "detail": { - "jobId": "j_01a17849a4096e33f476b17d1b", + "jobId": "j_01a17855a56c302b142ccac6a7", "steps": 10, "again": false, "siteCount": "reserved", @@ -329,42 +329,65 @@ }, { "name": "HAL·Agent 를 거쳐 작업 완료 대기(최대 40초)", - "ok": false, - "detail": "시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: [\"failed\",\"Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni\"])" - }, - { - "name": "[HAL 직접] 같은 Agent 에 로컬 저장본으로 site.deploy → 성공하면 결함은 포털→Agent 번들 전달 구간", "ok": true, "detail": { "status": "done", - "error": null, - "layout": true, - "externalCheck": { - "ok": false, - "status": 404, - "ms": 19, - "url": "http://direct_e2e.preview.localhost/", - "markerFound": false - } + "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" + ] } }, { - "name": "사용자 취소(진행 중 작업) → 중간 결과 정리·예약 반환", + "name": "배포 뒤 사이트 상태·버전·건강", "ok": true, "detail": { - "cancel": 200, - "cleanup": 200, - "siteCount": "reserved", - "lifecycle": "building" + "lifecycle": "needs_domain", + "version": "20261026131041-rev-c1-000000", + "verify": "needs_domain", + "health": "ok", + "agentHasSite": true } } ], "evidence": { - "halLog": [ - "2026-10-26T12:58:01.231Z [jobs] site.deploy j_01a1784a0781bd4cb2d160762e 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni", - "2026-10-26T12:58:06.242Z [jobs] site.deploy j_01a1784a1b1b6362b7ba3a604c 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni", - "2026-10-26T12:58:11.248Z [jobs] site.deploy j_01a1784a2eb913f75494650303 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni" - ] + "d": { + "jobId": "j_01a17855a56c302b142ccac6a7", + "steps": 10, + "again": false, + "siteCount": "reserved", + "server": null + }, + "deployJob": { + "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" + ] + }, + "after": { + "lifecycle": "needs_domain", + "version": "20261026131041-rev-c1-000000", + "verify": "needs_domain", + "health": "ok", + "agentHasSite": true + } } }, { @@ -378,7 +401,7 @@ "name": "도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)", "ok": true, "detail": { - "domainId": "d_01a1784a491476e7f378e8217c", + "domainId": "d_01a17855b5405b38f36942a6e1", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -393,13 +416,13 @@ "name": "(정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다", "ok": true, "detail": { - "queuedProbes": 1 + "queuedProbes": 0 } } ], "evidence": { "dm": { - "domainId": "d_01a1784a491476e7f378e8217c", + "domainId": "d_01a17855b5405b38f36942a6e1", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -409,9 +432,9 @@ { "no": 8, "title": "외부 주소에서 사이트 확인", - "verdict": "not_verifiable_locally", - "note": "외부 주소 확인은 공인 서버·DNS 필요", - "blockedBy": 6, + "verdict": "partial", + "note": "플랫폼 확인 주소(preview.localhost)로 HAL 외부 확인을 시도했으나 로컬에는 80 포트 웹서버가 없어 외부 확인 결과만 기록됨", + "blockedBy": null, "steps": [], "evidence": {} }, @@ -419,50 +442,21 @@ "no": 9, "title": "사이트 정보와 접속·사용량 통계 확인", "verdict": "partial", - "note": "미배포 사이트는 통계 대신 준비 단계 표시(§8.1) 확인. 실제 접속·네트워크 통계는 배포 뒤 수집", - "blockedBy": 6, + "note": "개요·사용량 조회(Agent stub 가 수집한 값)", + "blockedBy": null, "steps": [ { "name": "개요: 배포 전/후에 따라 준비 단계 또는 실제 통계", "ok": true, "detail": { - "kind": "not_deployed", - "lifecycle": "building", + "kind": "managed", + "lifecycle": "needs_domain", "hasStats": false, - "readiness": [ - { - "id": "build", - "label": "Webmaker 에서 사이트 제작", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", - "done": true - }, - { - "id": "import", - "label": "콘텐츠 가져오기", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", - "done": false, - "unsupported": "이번 버전에서 지원하지 않음" - }, - { - "id": "domain", - "label": "도메인 연결", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=domains", - "done": false - }, - { - "id": "mode", - "label": "배포 방식 선택", - "href": "/sites/s_01a17849a3be6f07d129dbdb60/deploy/mode", - "done": true - }, - { - "id": "perms", - "label": "구성원 권한 설정", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=settings", - "done": false - } - ], - "disk": null + "readiness": null, + "disk": { + "used": null, + "limit": 1073741824 + } } }, { @@ -478,43 +472,14 @@ ], "evidence": { "ov": { - "kind": "not_deployed", - "lifecycle": "building", + "kind": "managed", + "lifecycle": "needs_domain", "hasStats": false, - "readiness": [ - { - "id": "build", - "label": "Webmaker 에서 사이트 제작", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", - "done": true - }, - { - "id": "import", - "label": "콘텐츠 가져오기", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", - "done": false, - "unsupported": "이번 버전에서 지원하지 않음" - }, - { - "id": "domain", - "label": "도메인 연결", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=domains", - "done": false - }, - { - "id": "mode", - "label": "배포 방식 선택", - "href": "/sites/s_01a17849a3be6f07d129dbdb60/deploy/mode", - "done": true - }, - { - "id": "perms", - "label": "구성원 권한 설정", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=settings", - "done": false - } - ], - "disk": null + "readiness": null, + "disk": { + "used": null, + "limit": 1073741824 + } }, "us": { "allocations": [ @@ -535,7 +500,7 @@ "name": "사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시", "ok": true, "detail": { - "id": "msv_01a1784ad1a3c31d4ea6cb7447", + "id": "msv_01a178563dd6f7ac39957c38a1", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -552,7 +517,7 @@ "name": "메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음", "ok": true, "detail": { - "id": "mb_01a1784ad1df7ea80529a8cf8a", + "id": "mb_01a178563e14cf15ea191a4de5", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -619,7 +584,7 @@ ], "evidence": { "svc": { - "id": "msv_01a1784ad1a3c31d4ea6cb7447", + "id": "msv_01a178563dd6f7ac39957c38a1", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -627,7 +592,7 @@ "siteCountAllocated": 0 }, "mb": { - "id": "mb_01a1784ad1df7ea80529a8cf8a", + "id": "mb_01a178563e14cf15ea191a4de5", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -679,7 +644,7 @@ "name": "메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)", "ok": true, "detail": { - "id": "d_01a1784af801ae5cf98ef55e1f", + "id": "d_01a17856662c41b1d8aaf0c851", "jobCancelled": null } }, @@ -687,7 +652,7 @@ "name": "독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)", "ok": true, "detail": { - "id": "msv_01a1784af809f14b89bc606f73", + "id": "msv_01a178566634f50d5e0c8ce0c7", "kind": "standalone", "mailServicesAllocated": 1, "sitesAllocated": 0 @@ -696,7 +661,7 @@ ], "evidence": { "r": { - "id": "msv_01a1784af809f14b89bc606f73", + "id": "msv_01a178566634f50d5e0c8ce0c7", "kind": "standalone", "mailServicesAllocated": 1, "sitesAllocated": 0 @@ -708,9 +673,9 @@ { "no": 13, "title": "백업 옵션 구매와 첫 백업 확인", - "verdict": "partial", - "note": "옵션 구매·활성화까지 확인. 실제 백업 생성은 배포된 사이트 파일이 있어야 함(흐름 6 결함에 막힘)", - "blockedBy": 6, + "verdict": "verified_local", + "note": "옵션 구매·활성화·첫 백업(HAL→운영 Agent 생성→저장 Agent 보관→검증)", + "blockedBy": null, "steps": [ { "name": "백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리", @@ -736,20 +701,25 @@ "ok": true, "detail": { "status": "active", - "firstDue": "2026-10-27T12:59:01.665Z", - "nextRun": "2026-10-26T12:59:01.665Z" + "firstDue": "2026-10-27T13:11:30.755Z", + "nextRun": "2026-10-26T13:11:30.755Z" } }, { "name": "지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증", "ok": true, "detail": { - "timedOut": true, + "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": null, - "backup": null, - "job": null + "siteServer": "srv_01a178559b3ef8b19bacf9e7bf", + "backup": { + "status": "done", + "tier": "monthly", + "location": null + }, + "job": null, + "optionFailures": 0 } } ], @@ -763,16 +733,21 @@ }, "opt": { "status": "active", - "firstDue": "2026-10-27T12:59:01.665Z", - "nextRun": "2026-10-26T12:59:01.665Z" + "firstDue": "2026-10-27T13:11:30.755Z", + "nextRun": "2026-10-26T13:11:30.755Z" }, "run1": { - "timedOut": true, + "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": null, - "backup": null, - "job": null + "siteServer": "srv_01a178559b3ef8b19bacf9e7bf", + "backup": { + "status": "done", + "tier": "monthly", + "location": null + }, + "job": null, + "optionFailures": 0 } } }, @@ -780,17 +755,155 @@ "no": 14, "title": "백업 복원과 외부 사이트 확인", "verdict": "partial", - "note": "완료된 백업이 없어 복원을 요청할 수 없음", - "blockedBy": 13, - "steps": [], - "evidence": {} + "note": "복원 요청·운영자 최종 확인(불일치 거부·승인 멱등)·단계 진행까지. 외부 사이트 확인은 공인 서버 필요", + "blockedBy": null, + "steps": [ + { + "name": "복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식)", + "ok": true, + "detail": { + "backup": { + "id": "bk_01a1785678cb538e124484669b", + "createdAt": "2026-10-26T13:11:30.762Z", + "scope": { + "files": true, + "db": true, + "uploads": true, + "config": true, + "storeServerId": "srv_01a178559bee1c3a73f5640021", + "verifyMethod": "sha256 + 격리 환경 읽기 확인" + }, + "sizeBytes": 1909, + "verifyState": "verified", + "expiresAt": "2027-01-27T13:11:35.372Z", + "tier": "monthly" + }, + "included": [ + "현재 배포본(파일)", + "데이터베이스", + "업로드 데이터", + "복원에 필요한 사이트 설정" + ], + "excluded": [ + "메일 원문", + "소스 저장소", + "Webmaker 프로젝트 이력", + "외부 배포 데이터", + "플랫폼 내부 복구 사본" + ], + "impact": "복원 중 사이트가 일시적으로 이전 상태로 바뀌며, 복원 뒤 생성된 데이터는 보호 시점으로만 되돌릴 수 있습니다", + "estimatedDowntimeMin": null, + "cost": 0, + "currency": "KRW", + "costDeferred": false, + "completionMode": "user_confirm", + "protectPointDays": 7, + "revertHours": 72, + "approvalRequired": true, + "conflicts": [], + "canRequest": true, + "deferred": [] + } + }, + { + "name": "복원 요청 → 운영자 승인 대기 · 중복 요청 없음", + "ok": true, + "detail": { + "id": "rs_01a178567e7ec8dd46d83c957c", + "status": "awaiting_approval", + "dup": false, + "finalConfirmation": { + "group": "E2E 팀", + "site": "E2E 사이트", + "domain": null + } + } + }, + { + "name": "운영자 최종 확인(그룹·사이트·백업 시각) 불일치 거부 → 일치 승인(멱등)", + "ok": true, + "detail": { + "bad": "final_confirmation_mismatch", + "ok": 400 + } + }, + { + "name": "9단계 작업 진행 → 사용자 확인 필요(보호 시점·되돌리기 가능)", + "ok": true, + "detail": { + "steps": [] + } + } + ], + "evidence": { + "info": { + "backup": { + "id": "bk_01a1785678cb538e124484669b", + "createdAt": "2026-10-26T13:11:30.762Z", + "scope": { + "files": true, + "db": true, + "uploads": true, + "config": true, + "storeServerId": "srv_01a178559bee1c3a73f5640021", + "verifyMethod": "sha256 + 격리 환경 읽기 확인" + }, + "sizeBytes": 1909, + "verifyState": "verified", + "expiresAt": "2027-01-27T13:11:35.372Z", + "tier": "monthly" + }, + "included": [ + "현재 배포본(파일)", + "데이터베이스", + "업로드 데이터", + "복원에 필요한 사이트 설정" + ], + "excluded": [ + "메일 원문", + "소스 저장소", + "Webmaker 프로젝트 이력", + "외부 배포 데이터", + "플랫폼 내부 복구 사본" + ], + "impact": "복원 중 사이트가 일시적으로 이전 상태로 바뀌며, 복원 뒤 생성된 데이터는 보호 시점으로만 되돌릴 수 있습니다", + "estimatedDowntimeMin": null, + "cost": 0, + "currency": "KRW", + "costDeferred": false, + "completionMode": "user_confirm", + "protectPointDays": 7, + "revertHours": 72, + "approvalRequired": true, + "conflicts": [], + "canRequest": true, + "deferred": [] + }, + "rq": { + "id": "rs_01a178567e7ec8dd46d83c957c", + "status": "awaiting_approval", + "dup": false, + "finalConfirmation": { + "group": "E2E 팀", + "site": "E2E 사이트", + "domain": null + } + }, + "ap": { + "bad": "final_confirmation_mismatch", + "ok": 400 + }, + "j": { + "steps": [] + } + } }, { "no": 15, "title": "관리형 사이트 서버 이전과 외부 확인", "verdict": "partial", "note": "이전 경계값·요청 규칙 확인. 실제 두 Agent 간 사전 복사·전환·외부 확인은 배포된 사이트(흐름 6)와 공인 확인 주소가 필요", - "blockedBy": 6, + "blockedBy": null, "steps": [ { "name": "이전 화면 경계값: 취소 가능 마지막 단계·쓰기 제한·목표 중단 시간·되돌림 기간·이전 위치 보존·비용(플랫폼 사유 0원)", @@ -799,7 +912,8 @@ "auto": { "ok": false, "reasons": [ - "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다" + "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다", + "진행 중인 배포·복원·이전이 있습니다" ] }, "boundaries": { @@ -814,20 +928,10 @@ "name": "고객 요청(유료·승인 대기) 또는 조건 미충족 사유", "ok": true, "detail": { - "status": 200, - "relocation": { - "status": "awaiting_approval", - "kind": "customer_paid", - "cost": 10000 - } - } - }, - { - "name": "전환 전 고객 취소 가능(요청 취소 → 소유권 이전 등 다른 작업 차단 해제)", - "ok": true, - "detail": { - "status": 200, - "rstatus": "cancelled" + "status": 409, + "error": "conflict", + "relocation": null, + "message": "진행 중인 배포·복원·이전이 끝나야 이전을 시작할 수 있습니다" } } ], @@ -836,7 +940,8 @@ "auto": { "ok": false, "reasons": [ - "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다" + "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다", + "진행 중인 배포·복원·이전이 있습니다" ] }, "boundaries": { @@ -847,28 +952,26 @@ } }, "req": { - "status": 200, - "relocation": { - "status": "awaiting_approval", - "kind": "customer_paid", - "cost": 10000 - } + "status": 409, + "error": "conflict", + "relocation": null, + "message": "진행 중인 배포·복원·이전이 끝나야 이전을 시작할 수 있습니다" } } }, { "no": 16, "title": "외부 배포 사이트 생성과 배포", - "verdict": "failed", - "note": "동의(버전)·토큰 발급 거부/허용 규칙까지 확인했으나 토큰 발급 API 가 DB 제약으로 500 — 결함 항목 참조", + "verdict": "verified_local", + "note": "동의→토큰→실제 외부 Agent 등록·연결→배포→개요(제공되지 않음)→제거", "blockedBy": null, "steps": [ { "name": "외부 배포 사이트 생성(초안) + 저장본", "ok": true, "detail": { - "id": "s_01a1784b5d03e229abe92a46a1", - "group_id": "g_01a17849a2c2dec27ee9f14777", + "id": "s_01a1785769fa0becc28ee2efbc", + "group_id": "g_01a17855a4277ac98f458d3057", "name": "E2E 외부 사이트", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000004", @@ -896,9 +999,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a17849a21df78a8debb70c18", - "created_at": "2026-10-26T12:59:27.363Z", - "updated_at": "2026-10-26T12:59:27.368Z", + "created_by": "u_01a17855a37efae6dc46b789fa", + "created_at": "2026-10-26T13:12:37.114Z", + "updated_at": "2026-10-26T13:12:37.124Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -936,73 +1039,83 @@ }, { "name": "외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음)", - "ok": false, - "detail": "500 {\"ok\":false,\"error\":\"ERR_SQLITE_ERROR\",\"message\":\"일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요\"} | portal: 2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ar" - }, - { - "name": "[HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키)", "ok": true, "detail": { "token": "[redacted]", - "expiresAt": "2026-10-26T13:09:27.408Z" + "install": "curl -fsSL http://127.0.0.1:40927/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40927 --token … --external --site s_01a1785769fa0becc28ee2efbc", + "expiresAt": "2026-10-26T13:22:37.404Z", + "status": "pending" } }, { - "name": "[HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected", + "name": "고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속)", + "ok": true, + "detail": { + "agentId": "[redacted]" + } + }, + { + "name": "포털 연결 확인(외부 Agent 상태 connected)", "ok": true, "detail": { - "agentId": "[redacted]", "status": "connected", - "siteId": "s_01a1784b5d03e229abe92a46a1" + "stale": false, + "halAgentId": "[redacted]" } }, { - "name": "[HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성", + "name": "배포 전 확인(외부: 관리형 용량 확인 없음)", + "ok": true, + "detail": { + "ok": true, + "blocks": [] + } + }, + { + "name": "외부 배포 → 외부 Agent 가 site.deploy 수행", "ok": true, "detail": { "status": "done", "error": null, "errorCode": null, - "result": { - "version": "20261026125937-v1", - "leftovers": [] - }, - "layout": true + "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" + ] } }, { - "name": "[HAL 직접] 재배포(v2) → 이전 버전 기록", + "name": "외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과", + "ok": true, + "detail": { + "kind": "external", + "notProvided": "[redacted]", + "agent": "connected", + "lastDeployOk": true + } + }, + { + "name": "배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리)", "ok": true, "detail": { "status": "done", - "error": null, - "errorCode": null, - "result": { - "version": "20261026125950-v2", - "leftovers": [] - } + "error": null } }, { - "name": "[HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고", + "name": "Agent 연결 해제(revoke) → 상태 없음", "ok": true, "detail": { - "status": "failed", - "error": "내부 확인 실패(404) — 이전 버전을 유지합니다", - "errorCode": "internal_check_failed", - "result": null - } - }, - { - "name": "[HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부", - "ok": true, - "detail": {} - }, - { - "name": "[HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거", - "ok": true, - "detail": { - "layoutAfter": false + "status": 200, + "agent": null } } ], @@ -1011,6 +1124,42 @@ "notProvided": "[redacted]", "consentVersion": "external-deploy-2026-10-0.1.1", "agreed": false + }, + "tok": { + "install": "curl -fsSL http://127.0.0.1:40927/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40927 --token … --external --site s_01a1785769fa0becc28ee2efbc", + "expiresAt": "2026-10-26T13:22:37.404Z" + }, + "conn": { + "status": "connected", + "stale": false, + "halAgentId": "[redacted]" + }, + "pc": { + "ok": true, + "blocks": [] + }, + "dep": { + "status": "done", + "error": null, + "errorCode": null, + "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" + ] + }, + "ov": { + "kind": "external", + "notProvided": "[redacted]", + "agent": "connected", + "lastDeployOk": true } } }, @@ -1162,7 +1311,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a17849a126e838aa8bb17065" + "product": "pv_01a17855a17f31a67b1ac8d3f0" } } ], @@ -1198,7 +1347,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a17849a126e838aa8bb17065" + "product": "pv_01a17855a17f31a67b1ac8d3f0" } } }, @@ -1207,7 +1356,7 @@ "title": "사이트 수 반환과 대표 메일의 독립 전환", "verdict": "partial", "note": "반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인)", - "blockedBy": 6, + "blockedBy": null, "steps": [ { "name": "§14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션)", @@ -1215,7 +1364,9 @@ "detail": { "ok": false, "blocking": [ + "public_stopped", "domains_unrouted", + "no_jobs", "mail_choice", "mail_converted" ], @@ -1227,13 +1378,8 @@ "ok": true, "detail": { "status": 409, - "error": "quota_insufficient", - "detail": [ - "resource", - "needed", - "available", - "link" - ] + "error": "site_public", + "detail": null } } ], @@ -1241,7 +1387,9 @@ "ck": { "ok": false, "blocking": [ + "public_stopped", "domains_unrouted", + "no_jobs", "mail_choice", "mail_converted" ], @@ -1249,13 +1397,8 @@ }, "keep": { "status": 409, - "error": "quota_insufficient", - "detail": [ - "resource", - "needed", - "available", - "link" - ] + "error": "site_public", + "detail": null } } }, @@ -1287,9 +1430,9 @@ "name": "요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림", "ok": true, "detail": { - "id": "tr_01a1784c7a9e2007160a39f536", + "id": "tr_01a1785788f3921de196eb902e", "status": "requested", - "expiresAt": "2026-11-02T13:00:40.478Z", + "expiresAt": "2026-11-02T13:12:45.043Z", "bobNotified": true } }, @@ -1317,7 +1460,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T13:00:40.527Z" + "cutover": "2026-10-26T13:12:45.093Z" } }, { @@ -1346,9 +1489,9 @@ "excluded": 3 }, "rq": { - "id": "tr_01a1784c7a9e2007160a39f536", + "id": "tr_01a1785788f3921de196eb902e", "status": "requested", - "expiresAt": "2026-11-02T13:00:40.478Z", + "expiresAt": "2026-11-02T13:12:45.043Z", "bobNotified": true }, "acc": { @@ -1360,7 +1503,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T13:00:40.527Z" + "cutover": "2026-10-26T13:12:45.093Z" }, "after": { "aliceSees": 404, @@ -1374,16 +1517,16 @@ "no": 21, "title": "중지, 해지, 보존 예정일 표시", "verdict": "partial", - "note": "생명주기 화면·중지/해지 영향 안내·보존 정책 표시 확인. 운영 중 사이트의 실제 중지·해지는 배포 성공(흐름 6) 필요", - "blockedBy": 6, + "note": "중지·재개·생명주기 화면 확인", + "blockedBy": null, "steps": [ { "name": "§23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림", "ok": true, "detail": { - "state": "제작 중", - "siteCount": false, - "publicAccess": "중지됨", + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", "retention": { "site_days": 30, "mail_days": 30, @@ -1430,7 +1573,7 @@ "detail": { "noConfirm": "confirmation_required", "status": 409, - "error": "lifecycle_transition" + "error": "conflict" } }, { @@ -1447,9 +1590,9 @@ ], "evidence": { "v": { - "state": "제작 중", - "siteCount": false, - "publicAccess": "중지됨", + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", "retention": { "site_days": 30, "mail_days": 30, @@ -1492,7 +1635,7 @@ "pause": { "noConfirm": "confirmation_required", "status": 409, - "error": "lifecycle_transition" + "error": "conflict" }, "mailLc": { "status": 200, @@ -1559,16 +1702,18 @@ "name": "HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인)", "ok": true, "detail": { - "status": "queued", + "status": "failed", "steps": [ - "precheck:pending", - "fanout:pending", - "collect:pending" + "precheck:done", + "fanout:done", + "collect:failed" + ], + "error": "1대 중 1대 실패·대기 — 대상별 결과를 확인하세요", + "children": [ + "failed:EACCES: permission denied, mkdir '/srv/ariweb/hal/releases'" ], - "error": null, - "children": [], "agentJobs": [], - "timedOut": true + "timedOut": false } } ], @@ -1607,75 +1752,29 @@ "complete": true }, "dep": { - "status": "queued", + "status": "failed", "steps": [ - "precheck:pending", - "fanout:pending", - "collect:pending" + "precheck:done", + "fanout:done", + "collect:failed" + ], + "error": "1대 중 1대 실패·대기 — 대상별 결과를 확인하세요", + "children": [ + "failed:EACCES: permission denied, mkdir '/srv/ariweb/hal/releases'" ], - "error": null, - "children": [], "agentJobs": [], - "timedOut": true + "timedOut": false }, "diag": { - "stuckNow": [ - { - "id": "j_01a1784d7f02143ed47d1fcaf9", - "kind": "domain.probe", - "status": "queued", - "attempt": 0, - "step": "dispatch", - "detail": "", - "updatedAt": "2026-10-26T13:01:47.138Z" - }, - { - "id": "j_01a1784d6b6a20949d03d2dee8", - "kind": "domain.probe", - "status": "queued", - "attempt": 0, - "step": "dispatch", - "detail": "", - "updatedAt": "2026-10-26T13:01:42.122Z" - }, - { - "id": "j_01a1784d671c8f951049094a53", - "kind": "module.deploy", - "status": "queued", - "attempt": 0, - "step": "precheck", - "detail": "", - "updatedAt": "2026-10-26T13:01:41.020Z" - }, - { - "id": "j_01a1784d57cf28f7528a1a8ccb", - "kind": "domain.probe", - "status": "queued", - "attempt": 0, - "step": "dispatch", - "detail": "", - "updatedAt": "2026-10-26T13:01:37.103Z" - }, - { - "id": "j_01a1784d44338bf61b6691d5a8", - "kind": "domain.probe", - "status": "running", - "attempt": 1, - "step": "await", - "detail": "", - "updatedAt": "2026-10-26T13:01:46.885Z" - } - ], + "stuckNow": [], "halLog": [ - "2026-10-26T12:58:56.345Z [jobs] site.deploy j_01a1784ac7d0815275cc3e88aa 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/lIw5d3l2igVGOIlJa1BCaKwXYo7r2bJJ", - "2026-10-26T12:59:11.375Z [jobs] backup.create j_01a1784afb5dd2de92b7a63b4f 실패: 대상 서버 또는 Agent 가 필요합니다", - "2026-10-26T13:00:04.476Z [jobs] site.deploy j_01a1784bbc086436dc87f4b997 실패: 내부 확인 실패(404) — 이전 버전을 유지합니다", - "2026-10-26T13:00:18.498Z [jobs] site.deploy j_01a1784befea6c15eb6d7782eb 실패: 이 외부 Agent 는 다른 사이트에 귀속되어 있습니다", - "2026-10-26T13:00:31.517Z [jobs] backup.create j_01a1784c1721827ff3a7fd81e1 실패: 외부 Agent 는 backup.create 작업을 받을 수 없습니다", - "2026-10-26T13:01:40.630Z [jobs] module.deploy j_01a1784d2b6df1c41a7815b555 실패: HAL 이 아직 0.1.2 으로 실행되고 있지 않습니다(현재 0.1.1). HAL 을 먼저 배포하세요" + "2026-10-26T13:10:38.150Z [hal] Ariweb HAL v0.1.1 — http://127.0.0.1:40927 (공개 http://127.0.0.1:40927, 데이터 /home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131036/hal, 부트스트랩 진행 중)", + "2026-10-26T13:12:45.488Z [jobs] module.deploy j_01a17857894f044d5d9552218a 실패: HAL 이 아직 0.1.2 으로 실행되고 있지 않습니다(현재 0.1.1). HAL 을 먼저 배포하세요", + "2026-10-26T13:12:47.496Z [jobs] module.deploy.target j_01a178578ca73b9fe8276f58e3 실패: EACCES: permission denied, mkdir '/srv/ariweb/hal/releases'", + "2026-10-26T13:12:47.996Z [jobs] module.deploy j_01a178578c7e94c12a61d85f10 실패: 1대 중 1대 실패·대기 — 대상별 결과를 확인하세요" ], "ctlAgentLog": [ - "2026-10-26T12:57:32.329Z [agent] Ariweb Node Agent v0.1.1 ag_01a178499afa793eb4b22d9b01 (managed) → http://127.0.0.1:35361" + "2026-10-26T13:10:38.789Z [agent] Ariweb Node Agent v0.1.1 ag_01a178559b15aed8b0e25f47a5 (managed) → http://127.0.0.1:40927" ] } } @@ -1685,23 +1784,22 @@ "title": "실패 작업의 재시도와 되돌리기", "verdict": "partial", "note": "실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요", - "blockedBy": 6, + "blockedBy": null, "steps": [ { "name": "배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호", "ok": true, "detail": { - "status": "cancelled", - "failedStep": null, + "status": "done", "canRetry": false, - "jobId": "j_01a17849a4096e33f476b17d1b" + "jobId": "j_01a17855a56c302b142ccac6a7" } }, { "name": "재시도 요청(POST /api/sites/:id/jobs/:jobId/retry)", "ok": true, "detail": { - "status": 500, + "status": 400, "error": "job_not_retryable" } }, @@ -1717,13 +1815,12 @@ ], "evidence": { "dep": { - "status": "cancelled", - "failedStep": null, + "status": "done", "canRetry": false, - "jobId": "j_01a17849a4096e33f476b17d1b" + "jobId": "j_01a17855a56c302b142ccac6a7" }, "rt": { - "status": 500, + "status": 400, "error": "job_not_retryable" }, "rb": { @@ -1746,7 +1843,7 @@ "detail": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a17849a33601ca26016fa94a" + "link": "/orders/o_01a17855a499a9b2c94a482c83" } }, { @@ -1762,7 +1859,7 @@ "ok": true, "detail": { "status": "approved", - "approvedAt": "2026-10-26T12:57:34.327Z" + "approvedAt": "2026-10-26T13:10:41.118Z" } } ], @@ -1770,7 +1867,7 @@ "n": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a17849a33601ca26016fa94a" + "link": "/orders/o_01a17855a499a9b2c94a482c83" }, "a": { "action": "order.approve", @@ -1778,7 +1875,7 @@ }, "same": { "status": "approved", - "approvedAt": "2026-10-26T12:57:34.327Z" + "approvedAt": "2026-10-26T13:10:41.118Z" } } }, @@ -1821,9 +1918,9 @@ "rpo_minutes": 30, "snapshot_interval_minutes": 60, "retention_count": 3, - "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026125729/dr-external", - "updated_at": "2026-10-26T13:02:07.823Z", - "updated_by": "op_01a178499a27f1ba88344e1145" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131036/dr-external", + "updated_at": "2026-10-26T13:12:53.244Z", + "updated_by": "op_01a178559a40f4802a91a5fb1a" } }, { @@ -1843,7 +1940,7 @@ "noStop": "old_control_not_confirmed", "command": true, "incomplete": "checks_incomplete", - "final": "completed_with_findings" + "final": "completed" } }, { @@ -1883,9 +1980,9 @@ "rpo_minutes": 30, "snapshot_interval_minutes": 60, "retention_count": 3, - "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026125729/dr-external", - "updated_at": "2026-10-26T13:02:07.823Z", - "updated_by": "op_01a178499a27f1ba88344e1145" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131036/dr-external", + "updated_at": "2026-10-26T13:12:53.244Z", + "updated_by": "op_01a178559a40f4802a91a5fb1a" }, "snap": { "verify": "ok", @@ -1897,7 +1994,7 @@ "noStop": "old_control_not_confirmed", "command": true, "incomplete": "checks_incomplete", - "final": "completed_with_findings" + "final": "completed" }, "drill": { "first": "failed", @@ -1919,10 +2016,10 @@ "ok": true, "detail": { "mailJob": { - "id": "j_01a1784ae61b14370f923139a1", + "id": "j_01a178565252141cf5e3859e6c", "status": "awaiting_external" }, - "halJobs": 50 + "halJobs": 18 } }, { @@ -1931,7 +2028,7 @@ "detail": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a1784ae61b14370f923139a1", + "id": "j_01a178565252141cf5e3859e6c", "status": "awaiting_external" }, "same": true @@ -1942,7 +2039,7 @@ "ok": true, "detail": { "opsSessionKept": "[redacted]", - "halJobs": 50, + "halJobs": 18, "sameCount": true } } @@ -1950,22 +2047,22 @@ "evidence": { "before": { "mailJob": { - "id": "j_01a1784ae61b14370f923139a1", + "id": "j_01a178565252141cf5e3859e6c", "status": "awaiting_external" }, - "halJobs": 50 + "halJobs": 18 }, "pr": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a1784ae61b14370f923139a1", + "id": "j_01a178565252141cf5e3859e6c", "status": "awaiting_external" }, "same": true }, "hr": { "opsSessionKept": "[redacted]", - "halJobs": 50, + "halJobs": 18, "sameCount": true } } @@ -1989,8 +2086,8 @@ "ok": true, "detail": [ { - "id": "s_01a1784ac5a389af03b57b32a3", - "group_id": "g_01a17849a2ee454ab48b65c13d", + "id": "s_01a1785631d62a2fbdf27a60d6", + "group_id": "g_01a17855a453ba2b2c5bf88ecd", "name": "동시 A", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000002", @@ -2018,9 +2115,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a17849a2eef6c9267cb0d204", - "created_at": "2026-10-26T12:58:48.611Z", - "updated_at": "2026-10-26T12:58:48.619Z", + "created_by": "u_01a17855a4526739e73bc5adbf", + "created_at": "2026-10-26T13:11:17.206Z", + "updated_at": "2026-10-26T13:11:17.215Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -2039,8 +2136,8 @@ "nextJob": null }, { - "id": "s_01a1784ac5a7fa23018c39de03", - "group_id": "g_01a17849a2ee454ab48b65c13d", + "id": "s_01a1785631da943306e342eba7", + "group_id": "g_01a17855a453ba2b2c5bf88ecd", "name": "동시 B", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000003", @@ -2068,9 +2165,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a17849a2eef6c9267cb0d204", - "created_at": "2026-10-26T12:58:48.615Z", - "updated_at": "2026-10-26T12:58:48.622Z", + "created_by": "u_01a17855a4526739e73bc5adbf", + "created_at": "2026-10-26T13:11:17.210Z", + "updated_at": "2026-10-26T13:11:17.218Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -2120,16 +2217,16 @@ { "no": 28, "title": "외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리", - "verdict": "partial", - "note": "HAL·외부 Agent 경로(등록·연결·배포·재배포·실패 시 이전 버전 유지와 남은 파일·다른 사이트/허용되지 않는 종류 거부·제거)는 확인. 포털 화면 경로는 토큰 발급 결함으로 막힘", + "verdict": "verified_local", + "note": "외부 Agent 연결·제거·해제 API 확인. 재배포·실패 정리는 배포 성공이 필요(증거 참조)", "blockedBy": null, "steps": [ { "name": "외부 배포 사이트 생성(초안) + 저장본", "ok": true, "detail": { - "id": "s_01a1784b5d03e229abe92a46a1", - "group_id": "g_01a17849a2c2dec27ee9f14777", + "id": "s_01a1785769fa0becc28ee2efbc", + "group_id": "g_01a17855a4277ac98f458d3057", "name": "E2E 외부 사이트", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000004", @@ -2157,9 +2254,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a17849a21df78a8debb70c18", - "created_at": "2026-10-26T12:59:27.363Z", - "updated_at": "2026-10-26T12:59:27.368Z", + "created_by": "u_01a17855a37efae6dc46b789fa", + "created_at": "2026-10-26T13:12:37.114Z", + "updated_at": "2026-10-26T13:12:37.124Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -2197,108 +2294,116 @@ }, { "name": "외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음)", - "ok": false, - "detail": "500 {\"ok\":false,\"error\":\"ERR_SQLITE_ERROR\",\"message\":\"일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요\"} | portal: 2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ar" - }, - { - "name": "[HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키)", "ok": true, "detail": { "token": "[redacted]", - "expiresAt": "2026-10-26T13:09:27.408Z" + "install": "curl -fsSL http://127.0.0.1:40927/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40927 --token … --external --site s_01a1785769fa0becc28ee2efbc", + "expiresAt": "2026-10-26T13:22:37.404Z", + "status": "pending" } }, { - "name": "[HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected", + "name": "고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속)", + "ok": true, + "detail": { + "agentId": "[redacted]" + } + }, + { + "name": "포털 연결 확인(외부 Agent 상태 connected)", "ok": true, "detail": { - "agentId": "[redacted]", "status": "connected", - "siteId": "s_01a1784b5d03e229abe92a46a1" + "stale": false, + "halAgentId": "[redacted]" } }, { - "name": "[HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성", + "name": "배포 전 확인(외부: 관리형 용량 확인 없음)", + "ok": true, + "detail": { + "ok": true, + "blocks": [] + } + }, + { + "name": "외부 배포 → 외부 Agent 가 site.deploy 수행", "ok": true, "detail": { "status": "done", "error": null, "errorCode": null, - "result": { - "version": "20261026125937-v1", - "leftovers": [] - }, - "layout": true + "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" + ] } }, { - "name": "[HAL 직접] 재배포(v2) → 이전 버전 기록", + "name": "외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과", + "ok": true, + "detail": { + "kind": "external", + "notProvided": "[redacted]", + "agent": "connected", + "lastDeployOk": true + } + }, + { + "name": "배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리)", "ok": true, "detail": { "status": "done", - "error": null, - "errorCode": null, - "result": { - "version": "20261026125950-v2", - "leftovers": [] - } + "error": null } }, { - "name": "[HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고", + "name": "Agent 연결 해제(revoke) → 상태 없음", "ok": true, "detail": { - "status": "failed", - "error": "내부 확인 실패(404) — 이전 버전을 유지합니다", - "errorCode": "internal_check_failed", - "result": null - } - }, - { - "name": "[HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부", - "ok": true, - "detail": {} - }, - { - "name": "[HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거", - "ok": true, - "detail": { - "layoutAfter": false + "status": 200, + "agent": null } } ], "evidence": { - "halTok": { - "expiresAt": "2026-10-26T13:09:27.408Z" + "conn": { + "status": "connected", + "stale": false, + "halAgentId": "[redacted]" }, - "d1": { + "dep": { "status": "done", "error": null, "errorCode": null, - "result": { - "version": "20261026125937-v1", - "leftovers": [] - }, - "layout": true + "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" + ] }, - "d2": { + "rm": { "status": "done", - "error": null, - "errorCode": null, - "result": { - "version": "20261026125950-v2", - "leftovers": [] - } + "error": null }, - "d3": { - "status": "failed", - "error": "내부 확인 실패(404) — 이전 버전을 유지합니다", - "errorCode": "internal_check_failed", - "result": null - }, - "d4": {}, - "d5": { - "layoutAfter": false + "rv": { + "status": 200, + "agent": null } } }, @@ -2384,19 +2489,23 @@ "name": "agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed)", "ok": true, "detail": { - "status": "queued", - "errorCode": null, - "overall": "none", - "children": [], - "timedOut": true + "status": "failed", + "errorCode": "all_failed", + "overall": "failed", + "children": [ + "srv_missing_e2e:failed:서버가 없습니다", + "srv_01a178559bee1c3a73f5640021:failed:EACCES: permission denied, mkdir '/srv/ariweb/agent/releases'", + "srv_01a178559b3ef8b19bacf9e7bf:failed:EACCES: permission denied, mkdir '/srv/ariweb/agent/releases'" + ], + "timedOut": false } }, { "name": "모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업", "ok": true, "detail": { - "health": "timeout", - "drift": 0, + "health": "done", + "drift": 1, "reconcile": 200, "overviewComponents": [ "HAL:ok", @@ -2419,15 +2528,19 @@ ], "evidence": { "d": { - "status": "queued", - "errorCode": null, - "overall": "none", - "children": [], - "timedOut": true + "status": "failed", + "errorCode": "all_failed", + "overall": "failed", + "children": [ + "srv_missing_e2e:failed:서버가 없습니다", + "srv_01a178559bee1c3a73f5640021:failed:EACCES: permission denied, mkdir '/srv/ariweb/agent/releases'", + "srv_01a178559b3ef8b19bacf9e7bf:failed:EACCES: permission denied, mkdir '/srv/ariweb/agent/releases'" + ], + "timedOut": false }, "h": { - "health": "timeout", - "drift": 0, + "health": "done", + "drift": 1, "reconcile": 200, "overviewComponents": [ "HAL:ok", @@ -2460,7 +2573,7 @@ "name": "critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)", "ok": true, "detail": { - "id": "em_01a1784dd0ec3f08f6ddf4281a", + "id": "em_01a17857aa1262003c4dfd08d9", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", @@ -2468,7 +2581,7 @@ "approved": "approved", "approvals": 3, "credential": "[redacted]", - "expiresAt": "2026-10-26T13:32:08.135Z" + "expiresAt": "2026-10-26T13:42:53.551Z" } }, { @@ -2489,14 +2602,14 @@ "evidence": { "healthy": "hal_healthy", "sess": { - "id": "em_01a1784dd0ec3f08f6ddf4281a", + "id": "em_01a17857aa1262003c4dfd08d9", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", "badText": "final_confirm_mismatch", "approved": "approved", "approvals": 3, - "expiresAt": "2026-10-26T13:32:08.135Z" + "expiresAt": "2026-10-26T13:42:53.551Z" }, "perf": { "forbidden": "action_forbidden", @@ -2548,8 +2661,8 @@ "ok": true, "detail": { "status": "readonly", - "deleteAfter": "2026-11-25T13:00:40.412Z", - "readonlyUntil": "2026-11-25T13:00:40.412Z" + "deleteAfter": "2026-11-25T13:12:44.974Z", + "readonlyUntil": "2026-11-25T13:12:44.974Z" } }, { @@ -2575,8 +2688,8 @@ }, "un": { "status": "readonly", - "deleteAfter": "2026-11-25T13:00:40.412Z", - "readonlyUntil": "2026-11-25T13:00:40.412Z" + "deleteAfter": "2026-11-25T13:12:44.974Z", + "readonlyUntil": "2026-11-25T13:12:44.974Z" }, "sweep": { "sweep": 200, @@ -2642,15 +2755,15 @@ "title": "일시 중지·해지 예약·보존·삭제 단계의 표시 일치", "verdict": "partial", "note": "해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인)", - "blockedBy": 6, + "blockedBy": null, "steps": [ { "name": "§23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림", "ok": true, "detail": { - "state": "제작 중", - "siteCount": false, - "publicAccess": "중지됨", + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", "retention": { "site_days": 30, "mail_days": 30, @@ -2697,7 +2810,7 @@ "detail": { "noConfirm": "confirmation_required", "status": 409, - "error": "lifecycle_transition" + "error": "conflict" } }, { @@ -2714,9 +2827,9 @@ ], "evidence": { "v": { - "state": "제작 중", - "siteCount": false, - "publicAccess": "중지됨", + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", "retention": { "site_days": 30, "mail_days": 30, @@ -2768,94 +2881,25 @@ ], "bugs": [ { - "title": "Agent 가 포털 저장본(bundleUrl, 절대 URL)을 내려받지 못함(HAL 주소를 앞에 덧붙임)", - "file": "agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle)", - "route": "HAL site.deploy → Agent fetch 단계", - "expected": "bundleUrl 이 절대 URL 이면 그대로 내려받는다(서명은 HAL 전용이므로 포털 번들 URL 에는 붙이지 않음)", - "actual": "Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni", - "repro": "포털 배포 → HAL site.deploy → Agent: \"Failed to parse URL from http://http:///internal/bundles/…\"", - "flows": [ - 6, - 8, - 9, - 13, - 14, - 15, - 23 - ] - }, - { - "title": "포털 배포 작업이 실패한 HAL 작업을 실패로 보지 않고 5초마다 새 HAL 작업을 무한 재제출(배포가 영원히 \"진행 중\")", - "file": "portal/sites/service.mjs (halJobFor: prev HAL 작업이 failed/cancelled 면 fresh 가 아니어도 새 작업을 만든다 → awaitHal 이 실패를 보지 못함)", - "route": "POST /api/sites/:id/deploy", - "expected": "HAL 작업이 failed 면 포털 작업도 failed(이전 버전 유지·재시도 버튼), 새 HAL 작업은 사용자 재시도(fresh) 때만", - "actual": "portal job queued attempt 9 (배포 서버 작업 진행 중); HAL 에 같은 사이트의 site.deploy 실패 작업이 attempt 수만큼 쌓임", - "repro": "Agent 가 실패하는 배포를 요청하고 /api/sites/:id/jobs/:id 를 지켜보면 attempt 가 5초마다 증가", - "flows": [ - 6, - 23, - 13, - 20 - ] - }, - { - "title": "관리형 배포 종단 흐름 실패(포털→HAL→Agent 저장본 전달)", - "file": "agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle: client.download(p.bundleUrl))", - "route": "site.deploy → Agent fetch 단계", - "expected": "절대 URL(bundleUrl=http:///internal/bundles/)은 HAL base 를 붙이지 않고 그대로 내려받아야 한다", - "actual": "시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: [\"failed\",\"Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni\"])", - "repro": "사이트 생성→저장본→POST /api/sites/:id/deploy→HAL site.deploy→Agent site.deploy fetch 단계에서 \"HAL 내려받기 실패\" 또는 잘못된 URL 오류", - "flows": [ - 6, - 8, - 9, - 13, - 14, - 15, - 23 - ] - }, - { - "title": "취소된 배포 작업의 재시도 요청이 500(서버 오류)으로 응답(작업 상태 오류는 4xx 여야 함)", - "file": "portal/sites/api.mjs (jobs/:jobId/:action → lib/jobs JobError 를 HttpError 로 바꾸지 않음)", - "route": "POST /api/sites/:id/jobs/:jobId/retry", - "expected": "409 job_not_retryable + 안내", - "actual": "500 job_not_retryable", - "repro": "취소된 작업에 retry", - "flows": [ - 23 - ] - }, - { - "title": "외부 Agent 토큰 발급이 DB 제약(external_agents.hal_agent_id NOT NULL)으로 500", - "file": "portal/sites/service.mjs (createExternalAgent) / portal/sites/model.mjs (external_agents.hal_agent_id)", - "route": "POST /api/sites/:id/external", - "expected": "HAL 토큰 발급 응답의 agentId 가 null(등록 전)이어도 토큰·설치 명령을 돌려주고, Agent 등록 뒤 by-site 조회로 hal_agent_id 를 채운다", - "actual": "2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ariweb-suite/lib/db.mjs:52:41) | at Object.createExternalAgent (file:///home/master/work/Ariweb", - "repro": "외부 배포 사이트 → 동의 → POST /api/sites/:id/external → 500 \"NOT NULL constraint failed: external_agents.hal_agent_id\"", - "flows": [ - 16, - 28 - ] - }, - { - "title": "HAL 작업 엔진이 작업을 한 번에 하나씩만 실행(tick 이 execute 를 await)해 느린 작업이 큐 전체를 막고, module.deploy 부모가 자식 완료를 기다리면 자식이 실행될 수 없어 교착", - "file": "lib/jobs.mjs (createJobEngine.start/tick) / hal/jobs.mjs (fanout 이 자식 완료를 폴링)", + "title": "모듈 설치가 ARIWEB_SRV_ROOT 를 무시하고 절대 경로(/srv/ariweb/)에 쓰려 해 일반 계정·검증 환경에서 실패", + "file": "hal/jobs.mjs (module.deploy.target: appDir 기본값) / agent/jobs/module.mjs", "route": "POST /ops/api/modules/:name/deploy", - "expected": "엔진은 running 수가 concurrency 미만이면 실행을 분리(detach)해 여러 작업을 동시에 진행해야 한다(부모가 자식을 기다려도 자식이 실행됨)", + "expected": "Agent 가 payload.appDir 가 없거나 루트 override 가 있으면 roots.srv 아래에 설치", "actual": { - "status": "queued", + "status": "failed", "steps": [ - "precheck:pending", - "fanout:pending", - "collect:pending" + "precheck:done", + "fanout:done", + "collect:failed" + ], + "error": "1대 중 1대 실패·대기 — 대상별 결과를 확인하세요", + "children": [ + "failed:EACCES: permission denied, mkdir '/srv/ariweb/hal/releases'" ], - "error": null, - "children": [], "agentJobs": [], - "timedOut": true + "timedOut": false }, - "repro": "(1) Agent 가 수십 초 걸리는 domain.probe 가 queued 로 쌓이면 다른 모든 HAL 작업이 기다린다 (2) 큐가 비어도 모듈 배포는 부모 collect:running + 자식 queued 로 영원히 멈춘다(test/hal.test.mjs 는 pump() 로 tick 을 동시에 돌려 통과)", + "repro": "ARIWEB_SRV_ROOT 를 둔 Agent 에 module.deploy → mkdir /srv/ariweb/hal 권한 오류", "flows": [ 22, 30 diff --git a/docs/verification/local-e2e-2026-10-26.md b/docs/verification/local-e2e-2026-10-26.md index 0adee5b..15bfe4d 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-26T13:02:10.786Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/) +생성: 2026-10-26T13:12:56.197Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/) 실행 환경: 실제 HAL·고객 포털·Node Agent(명령 stub 모드) 프로세스 4대 + 가짜 Webmaker/AriMail/발신 메일 서버(127.0.0.1). 실 DNS·실 메일 왕복·실 서버는 포함하지 않는다. @@ -8,10 +8,10 @@ | 판정 | 수 | |---|---| -| 로컬 확인 | 13 | -| 부분 확인 | 15 | -| 로컬 확인 불가 | 2 | -| 실패(결함) | 4 | +| 로컬 확인 | 17 | +| 부분 확인 | 14 | +| 로컬 확인 불가 | 1 | +| 실패(결함) | 2 | | unrecorded | 0 | ## 흐름별 판정(Release 0.1.1 완료 조건 34개) @@ -23,62 +23,42 @@ | 3 | 상품 주문, 결제 확인, 권리 활성화 | 로컬 확인 | 상품 목록→주문(멱등·수동 결제)→입금 확인 전 승인 거부→입금 확인→승인→권리 활성화→재승인 중복 없음→청구 화면 | | 4 | 초안 사이트 생성 | 로컬 확인 | 초안 생성·Webmaker 프로젝트 연결·사이트 수 미소비·목록 필터 | | 5 | Webmaker에서 페이지 제작 | 로컬 확인 | SSO 티켓(1회용) 진입·저장본 목록·배포 전 확인(가짜 Webmaker — 실제 편집 UI 는 포함하지 않음) | -| 6 | 관리형 배포 | 실패(결함) | 시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: ["failed","Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni"]) | +| 6 | 관리형 배포 | 로컬 확인 | 배포 요청→예약→HAL 작업→Agent(stub) 배포→도메인 설정 필요 상태(대표 도메인 없음) | | 7 | 도메인 연결과 HTTPS 발급 | 부분 확인 | 도메인 추가·복사 가능한 TXT/DNS 값·외부 대기 상태까지 확인. 실 DNS 반영·인증서 발급·대표 도메인 확정은 로컬 확인 불가 | -| 8 | 외부 주소에서 사이트 확인 | 로컬 확인 불가 | 외부 주소 확인은 공인 서버·DNS 필요 (선행 흐름 6 에 막힘) | -| 9 | 사이트 정보와 접속·사용량 통계 확인 | 부분 확인 | 미배포 사이트는 통계 대신 준비 단계 표시(§8.1) 확인. 실제 접속·네트워크 통계는 배포 뒤 수집 (선행 흐름 6 에 막힘) | +| 8 | 외부 주소에서 사이트 확인 | 부분 확인 | 플랫폼 확인 주소(preview.localhost)로 HAL 외부 확인을 시도했으나 로컬에는 80 포트 웹서버가 없어 외부 확인 결과만 기록됨 | +| 9 | 사이트 정보와 접속·사용량 통계 확인 | 부분 확인 | 개요·사용량 조회(Agent stub 가 수집한 값) | | 10 | 사이트 대표 메일과 메일함 생성 | 부분 확인 | 대표 메일 생성(AriMail 테넌트·보장량·ariweb 표시)·메일함(비밀번호 1회·초대)·DNS 미반영 구분·활성화 작업 외부 대기까지 확인. DNS 반영 뒤 활성화 완료는 실 DNS 필요 | | 11 | 외부 송신, 수신, 회신 확인 | 로컬 확인 불가 | 외부 송신·수신·회신은 실제 SMTP 제출(587)·수신함·외부 자동 회신 주소가 필요하다. 포털은 검증 주소 미설정 시 needs_user, 왕복 실패 시 활성화하지 않는 규칙(단위 테스트 test/mail.test.mjs)으로만 확인됨 | | 12 | 독립 메일 서비스 생성 | 로컬 확인 | 독립 메일 서비스 생성: 독립 옵션 1 소비, 운영 사이트 수 미소비, 목록에 백업 미제공 문구 | -| 13 | 백업 옵션 구매와 첫 백업 확인 | 부분 확인 | 옵션 구매·활성화까지 확인. 실제 백업 생성은 배포된 사이트 파일이 있어야 함(흐름 6 결함에 막힘) (선행 흐름 6 에 막힘) | -| 14 | 백업 복원과 외부 사이트 확인 | 부분 확인 | 완료된 백업이 없어 복원을 요청할 수 없음 (선행 흐름 13 에 막힘) | -| 15 | 관리형 사이트 서버 이전과 외부 확인 | 부분 확인 | 이전 경계값·요청 규칙 확인. 실제 두 Agent 간 사전 복사·전환·외부 확인은 배포된 사이트(흐름 6)와 공인 확인 주소가 필요 (선행 흐름 6 에 막힘) | -| 16 | 외부 배포 사이트 생성과 배포 | 실패(결함) | 동의(버전)·토큰 발급 거부/허용 규칙까지 확인했으나 토큰 발급 API 가 DB 제약으로 500 — 결함 항목 참조 | +| 13 | 백업 옵션 구매와 첫 백업 확인 | 로컬 확인 | 옵션 구매·활성화·첫 백업(HAL→운영 Agent 생성→저장 Agent 보관→검증) | +| 14 | 백업 복원과 외부 사이트 확인 | 부분 확인 | 복원 요청·운영자 최종 확인(불일치 거부·승인 멱등)·단계 진행까지. 외부 사이트 확인은 공인 서버 필요 | +| 15 | 관리형 사이트 서버 이전과 외부 확인 | 부분 확인 | 이전 경계값·요청 규칙 확인. 실제 두 Agent 간 사전 복사·전환·외부 확인은 배포된 사이트(흐름 6)와 공인 확인 주소가 필요 | +| 16 | 외부 배포 사이트 생성과 배포 | 로컬 확인 | 동의→토큰→실제 외부 Agent 등록·연결→배포→개요(제공되지 않음)→제거 | | 17 | 사용량 경고와 한도 동작 확인 | 로컬 확인 | 메일 저장 공간 95%·월간 통수 100% 를 수집해 구간·제한 사유·KST 초기화·알림을 확인(웹 디스크/아웃바운드는 배포가 있어야 수집) | | 18 | 상품 축소 보류와 정리 후 적용 | 로컬 확인 | 축소 미리보기→보류→정리(배정 축소·독립 메일 해지)→재검증 적합→축소 주문·승인→권리 교체 | -| 19 | 사이트 수 반환과 대표 메일의 독립 전환 | 부분 확인 | 반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인) (선행 흐름 6 에 막힘) | +| 19 | 사이트 수 반환과 대표 메일의 독립 전환 | 부분 확인 | 반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인) | | 20 | 사이트 소유권 이전 | 로컬 확인 | 미리보기→요청·알림→수락(보류·재확인)→심사·최종 확인→원자적 완료→접근 전환·감사 | -| 21 | 중지, 해지, 보존 예정일 표시 | 부분 확인 | 생명주기 화면·중지/해지 영향 안내·보존 정책 표시 확인. 운영 중 사이트의 실제 중지·해지는 배포 성공(흐름 6) 필요 (선행 흐름 6 에 막힘) | +| 21 | 중지, 해지, 보존 예정일 표시 | 부분 확인 | 중지·재개·생명주기 화면 확인 | | 22 | HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 | 실패(결함) | HAL 우선 순서(hal_first 거부)·릴리스 승인·부트스트랩 잠김은 확인. 모듈 배포 작업은 완료되지 않음(진단 증거 참조) | -| 23 | 실패 작업의 재시도와 되돌리기 | 부분 확인 | 실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요 (선행 흐름 6 에 막힘) | +| 23 | 실패 작업의 재시도와 되돌리기 | 부분 확인 | 실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요 | | 24 | 운영자 승인, 감사 기록, 고객 알림의 일치 | 로컬 확인 | 승인 → 고객 알림·불변 감사 기록·운영자/고객 화면 상태 일치(주문 기준) | | 25 | HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련 | 부분 확인 | DR 설정(RTO/RPO/주기/보존)·사본 생성·검증·외부 보관·복원 승인/기존 제어 중지 확인/완료 항목·훈련 실패/성공→준비 완료 기록까지 확인. 실제 다른 호스트 복원은 실 서버 필요 | | 26 | 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 | 로컬 확인 | 포털·HAL 재시작 뒤 세션·진행 작업(외부 대기)·작업 목록 유지 확인. 진행 중(running) 배포의 단계 재개는 배포 성공(흐름 6) 뒤 확인 필요 | | 27 | 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 | 로컬 확인 | 마지막 사이트 수 1개를 두 배포가 동시에 요청 → 하나만 예약 성공, 다른 쪽은 부족 사유(§13.3) | -| 28 | 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 | 부분 확인 | HAL·외부 Agent 경로(등록·연결·배포·재배포·실패 시 이전 버전 유지와 남은 파일·다른 사이트/허용되지 않는 종류 거부·제거)는 확인. 포털 화면 경로는 토큰 발급 결함으로 막힘 | +| 28 | 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 | 로컬 확인 | 외부 Agent 연결·제거·해제 API 확인. 재배포·실패 정리는 배포 성공이 필요(증거 참조) | | 29 | HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시 | 로컬 확인 | HAL 중단 중 포털 조회 유지·신규 배포 차단(정보 확인 지연), HAL 재기동 뒤 서버·Agent 복귀 | | 30 | 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 | 실패(결함) | 대상별 자식 작업 생성·되돌리기 3단계 확인 거부는 확인. 일부 실패 요약·건강 확인·drift 조정은 작업 엔진 교착(흐름 22 결함)으로 완료되지 않음 (선행 흐름 22 에 막힘) | | 31 | 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 | 부분 확인 | 정상 상태 선언 거부·3단계 승인(순서/식별정보/문구 거부)·자격 1회 발급·일반 배포/범위 밖/잘못된 자격 거부·수행·폐기 뒤 거부·조정·종료 확인. 자격 만료 거부는 유효 시간(분 단위 설정) 때문에 로컬 실행에서 생략(단위 테스트 test/hal.test.mjs 로 확인) | | 32 | Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료 | 부분 확인 | 활성화·export·해제→읽기 전용(삭제 예정일)까지 확인. 30일 뒤 삭제 완료는 시간 경과가 필요(단위 테스트 test/sites.test.mjs 에서 sweep 로 확인) | | 33 | 메일 서비스의 사이트 대표→독립 전환 후 항목 유지 | 부분 확인 | 전환 점검표 8항목과 미충족 시 차단 확인. 전후 실제 송수신 확인·DNS 유지 검증은 실 DNS/AriMail 필요(단위 테스트 test/mail.test.mjs 에서 가짜로 전환 완료·되돌리기 확인) | -| 34 | 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 | 부분 확인 | 해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인) (선행 흐름 6 에 막힘) | +| 34 | 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 | 부분 확인 | 해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인) | ## 발견한 결함(재현 정보) -- **Agent 가 포털 저장본(bundleUrl, 절대 URL)을 내려받지 못함(HAL 주소를 앞에 덧붙임)** — `agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle)` (HAL site.deploy → Agent fetch 단계) - - 기대: bundleUrl 이 절대 URL 이면 그대로 내려받는다(서명은 HAL 전용이므로 포털 번들 URL 에는 붙이지 않음) - - 실제: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni - - 재현: 포털 배포 → HAL site.deploy → Agent: "Failed to parse URL from http://http:///internal/bundles/…" · 영향 흐름 6, 8, 9, 13, 14, 15, 23 -- **포털 배포 작업이 실패한 HAL 작업을 실패로 보지 않고 5초마다 새 HAL 작업을 무한 재제출(배포가 영원히 "진행 중")** — `portal/sites/service.mjs (halJobFor: prev HAL 작업이 failed/cancelled 면 fresh 가 아니어도 새 작업을 만든다 → awaitHal 이 실패를 보지 못함)` (POST /api/sites/:id/deploy) - - 기대: HAL 작업이 failed 면 포털 작업도 failed(이전 버전 유지·재시도 버튼), 새 HAL 작업은 사용자 재시도(fresh) 때만 - - 실제: portal job queued attempt 9 (배포 서버 작업 진행 중); HAL 에 같은 사이트의 site.deploy 실패 작업이 attempt 수만큼 쌓임 - - 재현: Agent 가 실패하는 배포를 요청하고 /api/sites/:id/jobs/:id 를 지켜보면 attempt 가 5초마다 증가 · 영향 흐름 6, 23, 13, 20 -- **관리형 배포 종단 흐름 실패(포털→HAL→Agent 저장본 전달)** — `agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle: client.download(p.bundleUrl))` (site.deploy → Agent fetch 단계) - - 기대: 절대 URL(bundleUrl=http:///internal/bundles/)은 HAL base 를 붙이지 않고 그대로 내려받아야 한다 - - 실제: 시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: ["failed","Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni"]) - - 재현: 사이트 생성→저장본→POST /api/sites/:id/deploy→HAL site.deploy→Agent site.deploy fetch 단계에서 "HAL 내려받기 실패" 또는 잘못된 URL 오류 · 영향 흐름 6, 8, 9, 13, 14, 15, 23 -- **취소된 배포 작업의 재시도 요청이 500(서버 오류)으로 응답(작업 상태 오류는 4xx 여야 함)** — `portal/sites/api.mjs (jobs/:jobId/:action → lib/jobs JobError 를 HttpError 로 바꾸지 않음)` (POST /api/sites/:id/jobs/:jobId/retry) - - 기대: 409 job_not_retryable + 안내 - - 실제: 500 job_not_retryable - - 재현: 취소된 작업에 retry · 영향 흐름 23 -- **외부 Agent 토큰 발급이 DB 제약(external_agents.hal_agent_id NOT NULL)으로 500** — `portal/sites/service.mjs (createExternalAgent) / portal/sites/model.mjs (external_agents.hal_agent_id)` (POST /api/sites/:id/external) - - 기대: HAL 토큰 발급 응답의 agentId 가 null(등록 전)이어도 토큰·설치 명령을 돌려주고, Agent 등록 뒤 by-site 조회로 hal_agent_id 를 채운다 - - 실제: 2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ariweb-suite/lib/db.mjs:52:41) | at Object.createExternalAgent (file:///home/master/work/Ariweb - - 재현: 외부 배포 사이트 → 동의 → POST /api/sites/:id/external → 500 "NOT NULL constraint failed: external_agents.hal_agent_id" · 영향 흐름 16, 28 -- **HAL 작업 엔진이 작업을 한 번에 하나씩만 실행(tick 이 execute 를 await)해 느린 작업이 큐 전체를 막고, module.deploy 부모가 자식 완료를 기다리면 자식이 실행될 수 없어 교착** — `lib/jobs.mjs (createJobEngine.start/tick) / hal/jobs.mjs (fanout 이 자식 완료를 폴링)` (POST /ops/api/modules/:name/deploy) - - 기대: 엔진은 running 수가 concurrency 미만이면 실행을 분리(detach)해 여러 작업을 동시에 진행해야 한다(부모가 자식을 기다려도 자식이 실행됨) - - 실제: {"status":"queued","steps":["precheck:pending","fanout:pending","collect:pending"],"error":null,"children":[],"agentJobs":[],"timedOut":true} - - 재현: (1) Agent 가 수십 초 걸리는 domain.probe 가 queued 로 쌓이면 다른 모든 HAL 작업이 기다린다 (2) 큐가 비어도 모듈 배포는 부모 collect:running + 자식 queued 로 영원히 멈춘다(test/hal.test.mjs 는 pump() 로 tick 을 동시에 돌려 통과) · 영향 흐름 22, 30 +- **모듈 설치가 ARIWEB_SRV_ROOT 를 무시하고 절대 경로(/srv/ariweb/)에 쓰려 해 일반 계정·검증 환경에서 실패** — `hal/jobs.mjs (module.deploy.target: appDir 기본값) / agent/jobs/module.mjs` (POST /ops/api/modules/:name/deploy) + - 기대: Agent 가 payload.appDir 가 없거나 루트 override 가 있으면 roots.srv 아래에 설치 + - 실제: {"status":"failed","steps":["precheck:done","fanout:done","collect:failed"],"error":"1대 중 1대 실패·대기 — 대상별 결과를 확인하세요","children":["failed:EACCES: permission denied, mkdir '/srv/ariweb/hal/releases'"],"agentJobs":[],"timedOut":false} + - 재현: ARIWEB_SRV_ROOT 를 둔 Agent 에 module.deploy → mkdir /srv/ariweb/hal 권한 오류 · 영향 흐름 22, 30 ## 실제 인프라가 필요한 항목 @@ -111,20 +91,20 @@ ### 2. 개인 사용자 그룹 생성과 다른 사용자 초대 -- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a17849a2c2dec27ee9f14777 -- ✔ 이메일 초대(7일) → 초대 메일 링크 — RAXi6X0pnmr-nCEKiIX63pk26vTbIB5d +- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a17855a4277ac98f458d3057 +- ✔ 이메일 초대(7일) → 초대 메일 링크 — Y4dXy0GV5b7pvqunRITvwVbgXFNedpwd - ✔ 초대 수락(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_01a17849a127747d9c2bbbb24c","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]} -- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T12:57:34.263Z","hasInstructions":true} +- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a17855a17f402b30366a2d5a","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]} +- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T13:10:41.050Z","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-26T12:57:34.327Z"} +- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T13:10:41.118Z"} ```json { @@ -135,25 +115,25 @@ "bill": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T12:57:34.327Z" + "nextBilling": "2026-11-26T13:10:41.118Z" } } ``` ### 4. 초안 사이트 생성 -- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a17849a3be6f07d129dbdb60","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]} +- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a17855a51f4c70a0da049568","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:43619/api/auth/sso?ticket=…","webmaker":302,"reuse":401} +- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:40985/api/auth/sso?ticket=…","webmaker":302,"reuse":401} - ✔ Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과 — {"versions":1,"precheckOk":true} ```json { "sso": { - "portalRedirect": "http://127.0.0.1:43619/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:40985/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 }, @@ -166,31 +146,54 @@ ### 6. 관리형 배포 -- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a17849a4096e33f476b17d1b","steps":10,"again":false,"siteCount":"reserved","server":null} -- ✘ HAL·Agent 를 거쳐 작업 완료 대기(최대 40초) — 시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: ["failed","Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni"]) -- ✔ [HAL 직접] 같은 Agent 에 로컬 저장본으로 site.deploy → 성공하면 결함은 포털→Agent 번들 전달 구간 — {"status":"done","error":null,"layout":true,"externalCheck":{"ok":false,"status":404,"ms":19,"url":"http://direct_e2e.preview.localhost/","markerFound":false}} -- ✔ 사용자 취소(진행 중 작업) → 중간 결과 정리·예약 반환 — {"cancel":200,"cleanup":200,"siteCount":"reserved","lifecycle":"building"} +- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a17855a56c302b142ccac6a7","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":"20261026131041-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true} ```json { - "halLog": [ - "2026-10-26T12:58:01.231Z [jobs] site.deploy j_01a1784a0781bd4cb2d160762e 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni", - "2026-10-26T12:58:06.242Z [jobs] site.deploy j_01a1784a1b1b6362b7ba3a604c 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni", - "2026-10-26T12:58:11.248Z [jobs] site.deploy j_01a1784a2eb913f75494650303 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni" - ] + "d": { + "jobId": "j_01a17855a56c302b142ccac6a7", + "steps": 10, + "again": false, + "siteCount": "reserved", + "server": null + }, + "deployJob": { + "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" + ] + }, + "after": { + "lifecycle": "needs_domain", + "version": "20261026131041-rev-c1-000000", + "verify": "needs_domain", + "health": "ok", + "agentHasSite": true + } } ``` ### 7. 도메인 연결과 HTTPS 발급 -- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1784a491476e7f378e8217c","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true} +- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a17855b5405b38f36942a6e1","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true} - ✔ 도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시 — {} -- ✔ (정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다 — {"queuedProbes":1} +- ✔ (정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다 — {"queuedProbes":0} ```json { "dm": { - "domainId": "d_01a1784a491476e7f378e8217c", + "domainId": "d_01a17855b5405b38f36942a6e1", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -198,51 +201,29 @@ } ``` +### 8. 외부 주소에서 사이트 확인 + + +```json +{} +``` + ### 9. 사이트 정보와 접속·사용량 통계 확인 -- ✔ 개요: 배포 전/후에 따라 준비 단계 또는 실제 통계 — {"kind":"not_deployed","lifecycle":"building","hasStats":false,"readiness":[{"id":"build","label":"Webmaker 에서 사이트 제작","href":"/sites/s_01a17849a3be6f07d129dbdb60?tab=build","done":true},{"id":"import","label":"콘텐츠 가져오기","href":"/sites/s_01a17849a3be6f07d129dbdb60?tab=build","done":false,"unsupporte +- ✔ 개요: 배포 전/후에 따라 준비 단계 또는 실제 통계 — {"kind":"managed","lifecycle":"needs_domain","hasStats":false,"readiness":null,"disk":{"used":null,"limit":1073741824}} - ✔ 사용량 탭(배정·실제 사용·마지막 계측) — {"allocations":["web_disk","web_out"]} ```json { "ov": { - "kind": "not_deployed", - "lifecycle": "building", + "kind": "managed", + "lifecycle": "needs_domain", "hasStats": false, - "readiness": [ - { - "id": "build", - "label": "Webmaker 에서 사이트 제작", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", - "done": true - }, - { - "id": "import", - "label": "콘텐츠 가져오기", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", - "done": false, - "unsupported": "이번 버전에서 지원하지 않음" - }, - { - "id": "domain", - "label": "도메인 연결", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=domains", - "done": false - }, - { - "id": "mode", - "label": "배포 방식 선택", - "href": "/sites/s_01a17849a3be6f07d129dbdb60/deploy/mode", - "done": true - }, - { - "id": "perms", - "label": "구성원 권한 설정", - "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=settings", - "done": false - } - ], - "disk": null + "readiness": null, + "disk": { + "used": null, + "limit": 1073741824 + } }, "us": { "allocations": [ @@ -255,9 +236,9 @@ ### 10. 사이트 대표 메일과 메일함 생성 -- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1784ad1a3c31d4ea6cb7447","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0} +- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a178563dd6f7ac39957c38a1","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0} - ✔ 같은 사이트에 두 번째 대표 메일 거부 — mail_domain_in_use -- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a1784ad1df7ea80529a8cf8a","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true} +- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a178563e14cf15ea191a4de5","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"]} @@ -266,7 +247,7 @@ ```json { "svc": { - "id": "msv_01a1784ad1a3c31d4ea6cb7447", + "id": "msv_01a178563dd6f7ac39957c38a1", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -274,7 +255,7 @@ "siteCountAllocated": 0 }, "mb": { - "id": "mb_01a1784ad1df7ea80529a8cf8a", + "id": "mb_01a178563e14cf15ea191a4de5", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -309,13 +290,13 @@ ### 12. 독립 메일 서비스 생성 -- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a1784af801ae5cf98ef55e1f","jobCancelled":null} -- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1784af809f14b89bc606f73","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0} +- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a17856662c41b1d8aaf0c851","jobCancelled":null} +- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a178566634f50d5e0c8ce0c7","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0} ```json { "r": { - "id": "msv_01a1784af809f14b89bc606f73", + "id": "msv_01a178566634f50d5e0c8ce0c7", "kind": "standalone", "mailServicesAllocated": 1, "sitesAllocated": 0 @@ -329,8 +310,8 @@ - ✔ 백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리 — {"granted":7,"orderStatus":"approved"} - ✔ 백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서) — {"eligible":true,"reasons":[],"deferred":[],"policy":null,"status":200} -- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T12:59:01.665Z","nextRun":"2026-10-26T12:59:01.665Z"} -- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":true,"optionStatus":"active","lastJob":null,"siteServer":null,"backup":null,"job":null} +- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T13:11:30.755Z","nextRun":"2026-10-26T13:11:30.755Z"} +- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a178559b3ef8b19bacf9e7bf","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0} ```json { @@ -343,25 +324,101 @@ }, "opt": { "status": "active", - "firstDue": "2026-10-27T12:59:01.665Z", - "nextRun": "2026-10-26T12:59:01.665Z" + "firstDue": "2026-10-27T13:11:30.755Z", + "nextRun": "2026-10-26T13:11:30.755Z" }, "run1": { - "timedOut": true, + "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": null, - "backup": null, - "job": null + "siteServer": "srv_01a178559b3ef8b19bacf9e7bf", + "backup": { + "status": "done", + "tier": "monthly", + "location": null + }, + "job": null, + "optionFailures": 0 + } +} +``` + +### 14. 백업 복원과 외부 사이트 확인 + +- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a1785678cb538e124484669b","createdAt":"2026-10-26T13:11:30.762Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a178559bee1c3a73f5640021","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1909,"verifyState":"verified","expiresAt":"2027-01-2 +- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a178567e7ec8dd46d83c957c","status":"awaiting_approval","dup":false,"finalConfirmation":{"group":"E2E 팀","site":"E2E 사이트","domain":null}} +- ✔ 운영자 최종 확인(그룹·사이트·백업 시각) 불일치 거부 → 일치 승인(멱등) — {"bad":"final_confirmation_mismatch","ok":400} +- ✔ 9단계 작업 진행 → 사용자 확인 필요(보호 시점·되돌리기 가능) — {"steps":[]} + +```json +{ + "info": { + "backup": { + "id": "bk_01a1785678cb538e124484669b", + "createdAt": "2026-10-26T13:11:30.762Z", + "scope": { + "files": true, + "db": true, + "uploads": true, + "config": true, + "storeServerId": "srv_01a178559bee1c3a73f5640021", + "verifyMethod": "sha256 + 격리 환경 읽기 확인" + }, + "sizeBytes": 1909, + "verifyState": "verified", + "expiresAt": "2027-01-27T13:11:35.372Z", + "tier": "monthly" + }, + "included": [ + "현재 배포본(파일)", + "데이터베이스", + "업로드 데이터", + "복원에 필요한 사이트 설정" + ], + "excluded": [ + "메일 원문", + "소스 저장소", + "Webmaker 프로젝트 이력", + "외부 배포 데이터", + "플랫폼 내부 복구 사본" + ], + "impact": "복원 중 사이트가 일시적으로 이전 상태로 바뀌며, 복원 뒤 생성된 데이터는 보호 시점으로만 되돌릴 수 있습니다", + "estimatedDowntimeMin": null, + "cost": 0, + "currency": "KRW", + "costDeferred": false, + "completionMode": "user_confirm", + "protectPointDays": 7, + "revertHours": 72, + "approvalRequired": true, + "conflicts": [], + "canRequest": true, + "deferred": [] + }, + "rq": { + "id": "rs_01a178567e7ec8dd46d83c957c", + "status": "awaiting_approval", + "dup": false, + "finalConfirmation": { + "group": "E2E 팀", + "site": "E2E 사이트", + "domain": null + } + }, + "ap": { + "bad": "final_confirmation_mismatch", + "ok": 400 + }, + "j": { + "steps": [] } } ``` ### 15. 관리형 사이트 서버 이전과 외부 확인 -- ✔ 이전 화면 경계값: 취소 가능 마지막 단계·쓰기 제한·목표 중단 시간·되돌림 기간·이전 위치 보존·비용(플랫폼 사유 0원) — {"auto":{"ok":false,"reasons":["사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다"]},"boundaries":{"lastCancel":"새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다","revertHours":72,"cost":10000,"platformCost":0}} -- ✔ 고객 요청(유료·승인 대기) 또는 조건 미충족 사유 — {"status":200,"relocation":{"status":"awaiting_approval","kind":"customer_paid","cost":10000}} -- ✔ 전환 전 고객 취소 가능(요청 취소 → 소유권 이전 등 다른 작업 차단 해제) — {"status":200,"rstatus":"cancelled"} +- ✔ 이전 화면 경계값: 취소 가능 마지막 단계·쓰기 제한·목표 중단 시간·되돌림 기간·이전 위치 보존·비용(플랫폼 사유 0원) — {"auto":{"ok":false,"reasons":["사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다","진행 중인 배포·복원·이전이 있습니다"]},"boundaries":{"lastCancel":"새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다","revertHours":72,"cost":10000,"platformCost":0}} +- ✔ 고객 요청(유료·승인 대기) 또는 조건 미충족 사유 — {"status":409,"error":"conflict","relocation":null,"message":"진행 중인 배포·복원·이전이 끝나야 이전을 시작할 수 있습니다"} ```json { @@ -369,7 +426,8 @@ "auto": { "ok": false, "reasons": [ - "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다" + "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다", + "진행 중인 배포·복원·이전이 있습니다" ] }, "boundaries": { @@ -380,29 +438,27 @@ } }, "req": { - "status": 200, - "relocation": { - "status": "awaiting_approval", - "kind": "customer_paid", - "cost": 10000 - } + "status": 409, + "error": "conflict", + "relocation": null, + "message": "진행 중인 배포·복원·이전이 끝나야 이전을 시작할 수 있습니다" } } ``` ### 16. 외부 배포 사이트 생성과 배포 -- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1784b5d03e229abe92a46a1","group_id":"g_01a17849a2c2dec27ee9f14777","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_01a1785769fa0becc28ee2efbc","group_id":"g_01a17855a4277ac98f458d3057","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 일회용 토큰·설치 명령(토큰은 저장하지 않음) — 500 {"ok":false,"error":"ERR_SQLITE_ERROR","message":"일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요"} | portal: 2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ar -- ✔ [HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키) — {"token":"[redacted]","expiresAt":"2026-10-26T13:09:27.408Z"} -- ✔ [HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected — {"agentId":"[redacted]","status":"connected","siteId":"s_01a1784b5d03e229abe92a46a1"} -- ✔ [HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성 — {"status":"done","error":null,"errorCode":null,"result":{"version":"20261026125937-v1","leftovers":[]},"layout":true} -- ✔ [HAL 직접] 재배포(v2) → 이전 버전 기록 — {"status":"done","error":null,"errorCode":null,"result":{"version":"20261026125950-v2","leftovers":[]}} -- ✔ [HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고 — {"status":"failed","error":"내부 확인 실패(404) — 이전 버전을 유지합니다","errorCode":"internal_check_failed","result":null} -- ✔ [HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부 — {} -- ✔ [HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거 — {"layoutAfter":false} +- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:40927/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40927 --token … --external --site s_01a1785769fa0becc28ee2efbc","expiresAt":"2026-10-26T13:22:37.404Z","status":"pending"} +- ✔ 고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속) — {"agentId":"[redacted]"} +- ✔ 포털 연결 확인(외부 Agent 상태 connected) — {"status":"connected","stale":false,"halAgentId":"[redacted]"} +- ✔ 배포 전 확인(외부: 관리형 용량 확인 없음) — {"ok":true,"blocks":[]} +- ✔ 외부 배포 → 외부 Agent 가 site.deploy 수행 — {"status":"done","error":null,"errorCode":null,"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"]} +- ✔ 외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과 — {"kind":"external","notProvided":"[redacted]","agent":"connected","lastDeployOk":true} +- ✔ 배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리) — {"status":"done","error":null} +- ✔ Agent 연결 해제(revoke) → 상태 없음 — {"status":200,"agent":null} ```json { @@ -410,6 +466,42 @@ "notProvided": "[redacted]", "consentVersion": "external-deploy-2026-10-0.1.1", "agreed": false + }, + "tok": { + "install": "curl -fsSL http://127.0.0.1:40927/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40927 --token … --external --site s_01a1785769fa0becc28ee2efbc", + "expiresAt": "2026-10-26T13:22:37.404Z" + }, + "conn": { + "status": "connected", + "stale": false, + "halAgentId": "[redacted]" + }, + "pc": { + "ok": true, + "blocks": [] + }, + "dep": { + "status": "done", + "error": null, + "errorCode": null, + "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" + ] + }, + "ov": { + "kind": "external", + "notProvided": "[redacted]", + "agent": "connected", + "lastDeployOk": true } } ``` @@ -465,7 +557,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_01a17849a126e838aa8bb17065"} +- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a17855a17f31a67b1ac8d3f0"} ```json { @@ -500,22 +592,24 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a17849a126e838aa8bb17065" + "product": "pv_01a17855a17f31a67b1ac8d3f0" } } ``` ### 19. 사이트 수 반환과 대표 메일의 독립 전환 -- ✔ §14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션) — {"ok":false,"blocking":["domains_unrouted","mail_choice","mail_converted"],"steps":8} -- ✔ 메일 유지 선택: 독립 옵션 부족이면 반환하지 않음 / 종료 선택 규칙 — {"status":409,"error":"quota_insufficient","detail":["resource","needed","available","link"]} +- ✔ §14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션) — {"ok":false,"blocking":["public_stopped","domains_unrouted","no_jobs","mail_choice","mail_converted"],"steps":8} +- ✔ 메일 유지 선택: 독립 옵션 부족이면 반환하지 않음 / 종료 선택 규칙 — {"status":409,"error":"site_public","detail":null} ```json { "ck": { "ok": false, "blocking": [ + "public_stopped", "domains_unrouted", + "no_jobs", "mail_choice", "mail_converted" ], @@ -523,13 +617,8 @@ }, "keep": { "status": 409, - "error": "quota_insufficient", - "detail": [ - "resource", - "needed", - "available", - "link" - ] + "error": "site_public", + "detail": null } } ``` @@ -537,10 +626,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_01a1784c7a9e2007160a39f536","status":"requested","expiresAt":"2026-11-02T13:00:40.478Z","bobNotified":true} +- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a1785788f3921de196eb902e","status":"requested","expiresAt":"2026-11-02T13:12:45.043Z","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:00:40.527Z"} +- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T13:12:45.093Z"} - ✔ 완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사 — {"aliceSees":404,"bobSees":200,"bobUsageSites":0,"audit":true} ```json @@ -559,9 +648,9 @@ "excluded": 3 }, "rq": { - "id": "tr_01a1784c7a9e2007160a39f536", + "id": "tr_01a1785788f3921de196eb902e", "status": "requested", - "expiresAt": "2026-11-02T13:00:40.478Z", + "expiresAt": "2026-11-02T13:12:45.043Z", "bobNotified": true }, "acc": { @@ -573,7 +662,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T13:00:40.527Z" + "cutover": "2026-10-26T13:12:45.093Z" }, "after": { "aliceSees": 404, @@ -586,16 +675,16 @@ ### 21. 중지, 해지, 보존 예정일 표시 -- ✔ §23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림 — {"state":"제작 중","siteCount":false,"publicAccess":"중지됨","retention":{"site_days":30,"mail_days":30,"source_days":30,"backup_days":7,"delete_grace_days":1,"notice_before_days":3,"readonly_retention_days":30,"delete_after_days":30,"pause_keeps_site_count":true,"cancel_effective":"next_billing_date","so -- ✔ 일시 중지(확인 필수) 또는 현재 상태에서 거부 사유 — {"noConfirm":"confirmation_required","status":409,"error":"lifecycle_transition"} +- ✔ §23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림 — {"state":"도메인 설정 필요","siteCount":true,"publicAccess":"제공 중","retention":{"site_days":30,"mail_days":30,"source_days":30,"backup_days":7,"delete_grace_days":1,"notice_before_days":3,"readonly_retention_days":30,"delete_after_days":30,"pause_keeps_site_count":true,"cancel_effective":"next_billing_date +- ✔ 일시 중지(확인 필수) 또는 현재 상태에서 거부 사유 — {"noConfirm":"confirmation_required","status":409,"error":"conflict"} - ✔ 메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공) — {"status":200,"sendReceive":"송수신 가능","retentionUntil":false,"deleteAfter":false,"backupNote":"고객용 메일 백업·복원은 제공하지 않습니다(§17.4)"} ```json { "v": { - "state": "제작 중", - "siteCount": false, - "publicAccess": "중지됨", + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", "retention": { "site_days": 30, "mail_days": 30, @@ -638,7 +727,7 @@ "pause": { "noConfirm": "confirmation_required", "status": 409, - "error": "lifecycle_transition" + "error": "conflict" }, "mailLc": { "status": 200, @@ -655,7 +744,7 @@ - ✔ 릴리스 화면: 버전·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 - ✔ 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":"queued","steps":["precheck:pending","fanout:pending","collect:pending"],"error":null,"children":[],"agentJobs":[],"timedOut":true} +- ✔ HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인) — {"status":"failed","steps":["precheck:done","fanout:done","collect:failed"],"error":"1대 중 1대 실패·대기 — 대상별 결과를 확인하세요","children":["failed:EACCES: permission denied, mkdir '/srv/ariweb/hal/releases'"],"agentJobs":[],"timedOut":false} ```json { @@ -693,81 +782,49 @@ "complete": true }, "dep": { - "status": "queued", + "status": "failed", "steps": [ - "precheck:pending", - "fanout:pending", - "collect:pending" + "precheck:done", + "fanout:done", + "collect:failed" + ], + "error": "1대 중 1대 실패·대기 — 대상별 결과를 확인하세요", + "children": [ + "failed:EACCES: permission denied, mkdir '/srv/ariweb/hal/releases'" ], - "error": null, - "children": [], "agentJobs": [], - "timedOut": true + "timedOut": false }, "diag": { - "stuckNow": [ - { - "id": "j_01a1784d7f02143ed47d1fcaf9", - "kind": "domain.probe", - "status": "queued", - "attempt": 0, - "step": "dispatch", - "detail": "", - "updatedAt": "2026-10-26T13:01:47.138Z" - }, - { - "id": "j_01a1784d6b6a20949d03d2dee8", - "kind": "domain.probe", - "status": "queued", - "attempt": 0, - "step": "dispatch", - "detail": "", - "updatedAt": "2026-10-26T13:01:42.122Z" - }, - { - "id": "j_01a1784d671c8f951049094a53", - "kind": "module.deploy", - "status": "queued", - "attempt": 0, - "step": "precheck", - "detail": "", - "updatedAt": "2026-10-26T13:01:41.020Z" - }, - { - "id": "j_01a1784d57cf28f7528a1a8ccb", - "kind": "domain.probe", - "status": "queued", - "attempt": 0, - "step": "dispatch", - "detail": "", - "updatedAt": "2026-10-26T13:01:37.103Z" - }, - { - "id": "j_01a1784d44338bf61b6691d5a8", - "kind": "domain.probe", - "status": "running", - "attempt": 1, - "step": "await", - "detail": "", - "updatedAt": "2026-10-26T13:01:4 + "stuckNow": [], + "halLog": [ + "2026-10-26T13:10:38.150Z [hal] Ariweb HAL v0.1.1 — http://127.0.0.1:40927 (공개 http://127.0.0.1:40927, 데이터 /home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131036/hal, 부트스트랩 진행 중)", + "2026-10-26T13:12:45.488Z [jobs] module.deploy j_01a17857894f044d5d9552218a 실패: HAL 이 아직 0.1.2 으로 실행되고 있지 않습니다(현재 0.1.1). HAL 을 먼저 배포하세요", + "2026-10-26T13:12:47.496Z [jobs] module.deploy.target j_01a178578ca73b9fe8276f58e3 실패: EACCES: permission denied, mkdir '/srv/ariweb/hal/releases'", + "2026-10-26T13:12:47.996Z [jobs] module.deploy j_01a178578c7e94c12a61d85f10 실패: 1대 중 1대 실패·대기 — 대상별 결과를 확인하세요" + ], + "ctlAgentLog": [ + "2026-10-26T13:10:38.789Z [agent] Ariweb Node Agent v0.1.1 ag_01a178559b15aed8b0e25f47a5 (managed) → http://127.0.0.1:40927" + ] + } +} ``` ### 23. 실패 작업의 재시도와 되돌리기 -- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"cancelled","failedStep":null,"canRetry":false,"jobId":"j_01a17849a4096e33f476b17d1b"} -- ✔ 재시도 요청(POST /api/sites/:id/jobs/:jobId/retry) — {"status":500,"error":"job_not_retryable"} +- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a17855a56c302b142ccac6a7"} +- ✔ 재시도 요청(POST /api/sites/:id/jobs/:jobId/retry) — {"status":400,"error":"job_not_retryable"} - ✔ 되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부 — {"status":409,"error":"no_previous","message":"되돌릴 이전 버전이 없습니다"} ```json { "dep": { - "status": "cancelled", - "failedStep": null, + "status": "done", "canRetry": false, - "jobId": "j_01a17849a4096e33f476b17d1b" + "jobId": "j_01a17855a56c302b142ccac6a7" }, "rt": { - "status": 500, + "status": 400, "error": "job_not_retryable" }, "rb": { @@ -780,16 +837,16 @@ ### 24. 운영자 승인, 감사 기록, 고객 알림의 일치 -- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a17849a33601ca26016fa94a"} +- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a17855a499a9b2c94a482c83"} - ✔ 감사 기록: order.approve 는 삭제 불가(immutable) — {"action":"order.approve","immutable":true} -- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T12:57:34.327Z"} +- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T13:10:41.118Z"} ```json { "n": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a17849a33601ca26016fa94a" + "link": "/orders/o_01a17855a499a9b2c94a482c83" }, "a": { "action": "order.approve", @@ -797,7 +854,7 @@ }, "same": { "status": "approved", - "approvedAt": "2026-10-26T12:57:34.327Z" + "approvedAt": "2026-10-26T13:10:41.118Z" } } ``` @@ -805,9 +862,9 @@ ### 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-20261026125729/dr-external","updated_at":"2026-10-26T13:02:07.823Z","updated_by":"op_01a178499a27f1ba88344e1145"} +- ✔ 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-20261026131036/dr-external","updated_at":"2026-10-26T13:12:53.244Z","updated_by":"op_01a178559a40f4802a91a5fb1a"} - ✔ 복구 사본 생성·검증·외부 보관(vault.key 제외) — {"verify":"ok","external":true,"sha":true} -- ✔ 복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료 — {"early":"restore_not_approved","noStop":"old_control_not_confirmed","command":true,"incomplete":"checks_incomplete","final":"completed_with_findings"} +- ✔ 복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료 — {"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":{}} ```json @@ -837,9 +894,9 @@ "rpo_minutes": 30, "snapshot_interval_minutes": 60, "retention_count": 3, - "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026125729/dr-external", - "updated_at": "2026-10-26T13:02:07.823Z", - "updated_by": "op_01a178499a27f1ba88344e1145" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131036/dr-external", + "updated_at": "2026-10-26T13:12:53.244Z", + "updated_by": "op_01a178559a40f4802a91a5fb1a" }, "snap": { "verify": "ok", @@ -851,7 +908,7 @@ "noStop": "old_control_not_confirmed", "command": true, "incomplete": "checks_incomplete", - "final": "completed_with_findings" + "final": "completed" }, "drill": { "first": "failed", @@ -864,30 +921,30 @@ ### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 -- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a1784ae61b14370f923139a1","status":"awaiting_external"},"halJobs":50} -- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a1784ae61b14370f923139a1","status":"awaiting_external"},"same":true} -- ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":50,"sameCount":true} +- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a178565252141cf5e3859e6c","status":"awaiting_external"},"halJobs":18} +- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a178565252141cf5e3859e6c","status":"awaiting_external"},"same":true} +- ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":18,"sameCount":true} ```json { "before": { "mailJob": { - "id": "j_01a1784ae61b14370f923139a1", + "id": "j_01a178565252141cf5e3859e6c", "status": "awaiting_external" }, - "halJobs": 50 + "halJobs": 18 }, "pr": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a1784ae61b14370f923139a1", + "id": "j_01a178565252141cf5e3859e6c", "status": "awaiting_external" }, "same": true }, "hr": { "opsSessionKept": "[redacted]", - "halJobs": 50, + "halJobs": 18, "sameCount": true } } @@ -896,7 +953,7 @@ ### 27. 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 - ✔ 밥 개인 그룹: 베이직(사이트 1) 주문·승인 — {"granted":6} -- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1784ac5a389af03b57b32a3","group_id":"g_01a17849a2ee454ab48b65c13d","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_01a1785631d62a2fbdf27a60d6","group_id":"g_01a17855a453ba2b2c5bf88ecd","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} @@ -912,51 +969,49 @@ ### 28. 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 -- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1784b5d03e229abe92a46a1","group_id":"g_01a17849a2c2dec27ee9f14777","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_01a1785769fa0becc28ee2efbc","group_id":"g_01a17855a4277ac98f458d3057","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 일회용 토큰·설치 명령(토큰은 저장하지 않음) — 500 {"ok":false,"error":"ERR_SQLITE_ERROR","message":"일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요"} | portal: 2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ar -- ✔ [HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키) — {"token":"[redacted]","expiresAt":"2026-10-26T13:09:27.408Z"} -- ✔ [HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected — {"agentId":"[redacted]","status":"connected","siteId":"s_01a1784b5d03e229abe92a46a1"} -- ✔ [HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성 — {"status":"done","error":null,"errorCode":null,"result":{"version":"20261026125937-v1","leftovers":[]},"layout":true} -- ✔ [HAL 직접] 재배포(v2) → 이전 버전 기록 — {"status":"done","error":null,"errorCode":null,"result":{"version":"20261026125950-v2","leftovers":[]}} -- ✔ [HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고 — {"status":"failed","error":"내부 확인 실패(404) — 이전 버전을 유지합니다","errorCode":"internal_check_failed","result":null} -- ✔ [HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부 — {} -- ✔ [HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거 — {"layoutAfter":false} +- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:40927/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:40927 --token … --external --site s_01a1785769fa0becc28ee2efbc","expiresAt":"2026-10-26T13:22:37.404Z","status":"pending"} +- ✔ 고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속) — {"agentId":"[redacted]"} +- ✔ 포털 연결 확인(외부 Agent 상태 connected) — {"status":"connected","stale":false,"halAgentId":"[redacted]"} +- ✔ 배포 전 확인(외부: 관리형 용량 확인 없음) — {"ok":true,"blocks":[]} +- ✔ 외부 배포 → 외부 Agent 가 site.deploy 수행 — {"status":"done","error":null,"errorCode":null,"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"]} +- ✔ 외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과 — {"kind":"external","notProvided":"[redacted]","agent":"connected","lastDeployOk":true} +- ✔ 배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리) — {"status":"done","error":null} +- ✔ Agent 연결 해제(revoke) → 상태 없음 — {"status":200,"agent":null} ```json { - "halTok": { - "expiresAt": "2026-10-26T13:09:27.408Z" + "conn": { + "status": "connected", + "stale": false, + "halAgentId": "[redacted]" }, - "d1": { + "dep": { "status": "done", "error": null, "errorCode": null, - "result": { - "version": "20261026125937-v1", - "leftovers": [] - }, - "layout": true + "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" + ] }, - "d2": { + "rm": { "status": "done", - "error": null, - "errorCode": null, - "result": { - "version": "20261026125950-v2", - "leftovers": [] - } + "error": null }, - "d3": { - "status": "failed", - "error": "내부 확인 실패(404) — 이전 버전을 유지합니다", - "errorCode": "internal_check_failed", - "result": null - }, - "d4": {}, - "d5": { - "layoutAfter": false + "rv": { + "status": 200, + "agent": null } } ``` @@ -998,22 +1053,26 @@ ### 30. 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 -- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"queued","errorCode":null,"overall":"none","children":[],"timedOut":true} -- ✔ 모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업 — {"health":"timeout","drift":0,"reconcile":200,"overviewComponents":["HAL:ok","portal:unknown","webmaker:unknown","arimail:unknown","backup:ok","observability:ok"]} +- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"failed","errorCode":"all_failed","overall":"failed","children":["srv_missing_e2e:failed:서버가 없습니다","srv_01a178559bee1c3a73f5640021:failed:EACCES: permission denied, mkdir '/srv/ariweb/agent/releases'","srv_01a178559b3ef8b19bacf9e7bf:failed:EACCES: permission denied, mkdir '/srv/ariweb/agen +- ✔ 모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업 — {"health":"done","drift":1,"reconcile":200,"overviewComponents":["HAL:ok","portal:unknown","webmaker:unknown","arimail:unknown","backup:ok","observability:ok"]} - ✔ 되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부 — {"step1":"confirm_impact_required","step2":"identifiers_mismatch"} ```json { "d": { - "status": "queued", - "errorCode": null, - "overall": "none", - "children": [], - "timedOut": true + "status": "failed", + "errorCode": "all_failed", + "overall": "failed", + "children": [ + "srv_missing_e2e:failed:서버가 없습니다", + "srv_01a178559bee1c3a73f5640021:failed:EACCES: permission denied, mkdir '/srv/ariweb/agent/releases'", + "srv_01a178559b3ef8b19bacf9e7bf:failed:EACCES: permission denied, mkdir '/srv/ariweb/agent/releases'" + ], + "timedOut": false }, "h": { - "health": "timeout", - "drift": 0, + "health": "done", + "drift": 1, "reconcile": 200, "overviewComponents": [ "HAL:ok", @@ -1034,21 +1093,21 @@ ### 31. 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 - ✔ HAL 정상 상태에서 선언 거부(hal_healthy) — hal_healthy -- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a1784dd0ec3f08f6ddf4281a","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T13:32:08.135Z"} +- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a17857aa1262003c4dfd08d9","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T13:42:53.551Z"} - ✔ 일반 배포(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_01a1784dd0ec3f08f6ddf4281a", + "id": "em_01a17857aa1262003c4dfd08d9", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", "badText": "final_confirm_mismatch", "approved": "approved", "approvals": 3, - "expiresAt": "2026-10-26T13:32:08.135Z" + "expiresAt": "2026-10-26T13:42:53.551Z" }, "perf": { "forbidden": "action_forbidden", @@ -1068,7 +1127,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:00:40.412Z","readonlyUntil":"2026-11-25T13:00:40.412Z"} +- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T13:12:44.974Z","readonlyUntil":"2026-11-25T13:12:44.974Z"} - ✔ 삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음 — {"sweep":200,"statusAfter":"readonly"} ```json @@ -1086,8 +1145,8 @@ }, "un": { "status": "readonly", - "deleteAfter": "2026-11-25T13:00:40.412Z", - "readonlyUntil": "2026-11-25T13:00:40.412Z" + "deleteAfter": "2026-11-25T13:12:44.974Z", + "readonlyUntil": "2026-11-25T13:12:44.974Z" }, "sweep": { "sweep": 200, @@ -1125,16 +1184,16 @@ ### 34. 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 -- ✔ §23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림 — {"state":"제작 중","siteCount":false,"publicAccess":"중지됨","retention":{"site_days":30,"mail_days":30,"source_days":30,"backup_days":7,"delete_grace_days":1,"notice_before_days":3,"readonly_retention_days":30,"delete_after_days":30,"pause_keeps_site_count":true,"cancel_effective":"next_billing_date","so -- ✔ 일시 중지(확인 필수) 또는 현재 상태에서 거부 사유 — {"noConfirm":"confirmation_required","status":409,"error":"lifecycle_transition"} +- ✔ §23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림 — {"state":"도메인 설정 필요","siteCount":true,"publicAccess":"제공 중","retention":{"site_days":30,"mail_days":30,"source_days":30,"backup_days":7,"delete_grace_days":1,"notice_before_days":3,"readonly_retention_days":30,"delete_after_days":30,"pause_keeps_site_count":true,"cancel_effective":"next_billing_date +- ✔ 일시 중지(확인 필수) 또는 현재 상태에서 거부 사유 — {"noConfirm":"confirmation_required","status":409,"error":"conflict"} - ✔ 메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공) — {"status":200,"sendReceive":"송수신 가능","retentionUntil":false,"deleteAfter":false,"backupNote":"고객용 메일 백업·복원은 제공하지 않습니다(§17.4)"} ```json { "v": { - "state": "제작 중", - "siteCount": false, - "publicAccess": "중지됨", + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", "retention": { "site_days": 30, "mail_days": 30, diff --git a/hal/index.mjs b/hal/index.mjs index ff7795f..7756d81 100644 --- a/hal/index.mjs +++ b/hal/index.mjs @@ -37,8 +37,9 @@ if (cmd === 'setup-token') { const r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size }])), checks: mf.checks || null, approve: !!opt('approve'), by: opt('by', 'cli') }); console.log(`릴리스 ${r.version} 등록(${r.status}) — artifact ${Object.keys(r.artifacts).join(', ')}`); } else if (cmd === 'link-portal') { - const p = randomToken(32), h = randomToken(32); d.vault.set('service:portal', p, { kind: 'service-key' }); d.vault.set('service:hal', h, { kind: 'service-key' }); - console.log(`포털 보관소에 넣을 값(1회 표시):\n service:hal = ${p} (포털이 HAL 을 부를 때 쓰는 키 — 포털 vault 이름 'service:hal')\n service:portal = ${h} (HAL 이 포털을 부를 때 쓰는 키 — 포털 vault 이름 'service:portal')`); + // [E2E 결함 수정] 포털은 양방향 모두 보관소 이름 'service:hal' 하나를 쓴다(포털→HAL 서명, HAL→포털 검증). 그래서 공유 비밀 하나를 HAL 의 두 이름(service:portal·service:hal)에 같이 둔다. + const shared = randomToken(32); d.vault.set('service:portal', shared, { kind: 'service-key' }); d.vault.set('service:hal', shared, { kind: 'service-key' }); + console.log(`포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${shared}\n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)`); } else if (cmd === 'bootstrap-release') { core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다'); } else if (cmd === 'serve') { diff --git a/lib/jobs.mjs b/lib/jobs.mjs index a801b48..e74c5fa 100644 --- a/lib/jobs.mjs +++ b/lib/jobs.mjs @@ -35,7 +35,7 @@ CREATE INDEX IF NOT EXISTS jobs_parent ON jobs(parent_id);` }]; /** run() 안에서 흐름을 바꾸는 신호(예외로 전달) */ class Signal extends Error { constructor(type, payload) { super(type); this.signal = type; this.payload = payload || {}; } } -export class JobError extends Error { constructor(code, message, { retryable = false, detail } = {}) { super(message || code); this.code = code; this.retryable = retryable; this.detail = detail; } } +export class JobError extends Error { constructor(code, message, { retryable = false, detail, status = 400 } = {}) { super(message || code); this.code = code; this.retryable = retryable; this.detail = detail; this.status = status; /* API 로 새면 400 계열 — 500 으로 숨기지 않는다 */ } } const BACKOFF_BASE_MS = 30 * 1000; const BACKOFF_MAX_MS = 60 * 60 * 1000; const STALE_LOCK_MS = 10 * 60 * 1000; @@ -58,6 +58,15 @@ export function createJobEngine(store, opts = {}) { store.run(`UPDATE jobs SET ${sets.join(', ')} WHERE id=?`, ...vals, id); const j = engine.get(id); emit(j); return j; } + /** 실행 가능한 작업 하나를 잠근다(큐 → 실행 중). 없으면 null */ + function claim() { + if (running >= concurrency) return null; + const now = nowIso(); + const cand = store.get('SELECT id FROM jobs WHERE status=? AND (next_run_at IS NULL OR next_run_at<=?) ORDER BY next_run_at ASC, created_at ASC LIMIT 1', S.QUEUED, now); + if (!cand) return null; + const got = store.run('UPDATE jobs SET status=?, locked_by=?, locked_at=?, started_at=COALESCE(started_at, ?), attempt=attempt+1, updated_at=? WHERE id=? AND status=?', S.RUNNING, workerId, now, now, now, cand.id, S.QUEUED).changes; + return got ? cand.id : null; + } const engine = { workerId, register(kind, def) { kinds.set(kind, { maxAttempts: 1, steps: [], ...def }); return engine; }, @@ -122,19 +131,12 @@ export function createJobEngine(store, opts = {}) { return stale.length; }, /** 실행 가능한 작업 하나를 집어 실행(테스트에서도 직접 부름). 돌아온 값: 실행한 작업 또는 null */ - async tick() { - if (running >= concurrency) return null; - const now = nowIso(); - const cand = store.get('SELECT id FROM jobs WHERE status=? AND (next_run_at IS NULL OR next_run_at<=?) ORDER BY next_run_at ASC, created_at ASC LIMIT 1', S.QUEUED, now); - if (!cand) return null; - const got = store.run('UPDATE jobs SET status=?, locked_by=?, locked_at=?, started_at=COALESCE(started_at, ?), attempt=attempt+1, updated_at=? WHERE id=? AND status=?', S.RUNNING, workerId, now, now, now, cand.id, S.QUEUED).changes; - if (!got) return null; - running++; - try { return await execute(cand.id); } finally { running--; } - }, + async tick() { const id = claim(); if (!id) return null; running++; try { return await execute(id); } finally { running--; } }, + /** 루프용: 작업을 집어 **기다리지 않고** 실행을 시작한다(느린 작업이 다른 작업을 막지 않게, 부모/자식 작업 교착 방지). 집은 작업 id 또는 null */ + dispatch() { const id = claim(); if (!id) return null; running++; execute(id).catch((e) => log('[jobs] execute error', safeError(e))).finally(() => { running--; }); return id; }, /** 모든 큐 작업을 비울 때까지 tick(테스트용) */ async drain(max = 100) { let n = 0; while (n < max) { const r = await engine.tick(); if (!r) break; n++; } return n; }, - start() { stopped = false; engine.recover(); const loop = async () => { if (stopped) return; try { let n = 0; while (n < concurrency && (await engine.tick())) n++; } catch (e) { log('[jobs] tick error', safeError(e)); } timer = setTimeout(loop, opts.pollMs || 1000); }; loop(); return engine; }, + start() { stopped = false; engine.recover(); const loop = () => { if (stopped) return; try { let n = 0; while (running < concurrency && n < concurrency && engine.dispatch()) n++; } catch (e) { log('[jobs] dispatch error', safeError(e)); } timer = setTimeout(loop, opts.pollMs || 1000); }; loop(); return engine; }, stop() { stopped = true; if (timer) clearTimeout(timer); }, }; function appendLog(job, line) { const l = (job.log || []).slice(-200); l.push({ at: nowIso(), line: String(line).slice(0, 500) }); return l; } diff --git a/portal/domains/service.mjs b/portal/domains/service.mjs index 859cef0..e28624b 100644 --- a/portal/domains/service.mjs +++ b/portal/domains/service.mjs @@ -83,18 +83,22 @@ export function createDomainsService(deps) { async function probeTarget(d) { if (d.site_id) { const s = store.get('SELECT server_id, deploy_mode, external_agent_id FROM sites WHERE id=?', d.site_id); if (s && s.deploy_mode === 'managed' && s.server_id) return { target: s.server_id, external: false, agent: null }; const a = s && s.deploy_mode === 'external' ? store.get('SELECT hal_agent_id FROM external_agents WHERE site_id=? AND revoked_at IS NULL AND status=\'connected\'', d.site_id) : null; const srv = (await hal().listServers({ role: 'web' }))[0]; return { target: srv ? srv.id : null, external: s && s.deploy_mode === 'external', agent: a ? a.hal_agent_id : null }; } const srv = (await hal().listServers({ role: 'web' }))[0]; return { target: srv ? srv.id : null, external: false, agent: null }; } /** 단계에 묶인 HAL 작업 보장(portal/sites/service.mjs 의 halJobFor 와 같은 규칙): 재진입 때 같은 작업, 실패 뒤 재시도 때만 새 작업 */ async function halJobFor(ctx, job, stepId, factory) { - const st = ctx.steps().find((x) => x.id === stepId); const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null; + let st = ctx.steps().find((x) => x.id === stepId); if (!st) { st = { id: stepId, title: stepId, state: 'pending' }; ctx.steps().push(st); } // 단계 밖 탐침도 단계 결과에 HAL 작업 id 를 보관 + const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null; const fresh = (job.steps || []).some((x) => x.id === stepId && x.state === 'failed'); // 이 단계가 실패한 뒤의 재시도면 새 HAL 작업 - if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } if (!['failed', 'cancelled'].includes(hj.status)) return prev; } - const r = await factory(`portal:${job.id}:${stepId}${prev ? ':r' + job.attempt : ''}`); if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); } return r.halJobId; + if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } return prev; } // [E2E 결함 수정] 실패·취소된 HAL 작업도 돌려줘 awaitHal 이 실패를 드러낸다(새 작업은 단계 실패 뒤 재시도 때만) + const seq = (st && st.result && st.result.seq) || 0; + const r = await factory(`portal:${job.id}:${stepId}${seq ? ':s' + seq : ''}${prev ? ':r' + job.attempt : ''}`); if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); } return r.halJobId; } + /** [E2E 결함 수정] 외부 대기(사용자 DNS 변경) 직전에 단계에 적어 둔 탐침 작업 id 를 지워, 재개 때 새 탐침을 만들게 한다(옛 결과 재사용 금지). 단계가 없으면 만들어 둔다 */ + function probeReset(ctx, stepId) { const st = ctx.steps().find((x) => x.id === stepId); if (st && st.result) { st.result = { ...st.result, halJobId: null, seq: (st.result.seq || 0) + 1 }; ctx.flush(); } } // seq 로 멱등 키를 바꿔 HAL 이 옛 탐침을 돌려주지 않게 한다 async function awaitHal(ctx, halJobId) { let hj; try { hj = await hal().getJob(halJobId); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } if (hj.status === 'done') return hj; if (['failed', 'cancelled'].includes(hj.status)) throw new JobError(hj.errorCode || 'hal_job_failed', hj.error || '서버 작업 실패'); ctx.retryLater(5000, '서버 작업 진행 중'); } jobs.register('domain.connect', { title: '도메인 연결 확인', steps: CONNECT_STEPS, maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable', async run(job, ctx) { const d0 = get(job.subject.domainId); if (!d0) throw new JobError('domain_missing', '도메인이 없습니다'); const pt = await probeTarget(d0); if (!pt.target) throw new JobError('no_server', '확인에 쓸 서버가 없습니다', { retryable: true }); // 1 소유 확인 — TXT 값이 보일 때까지 외부 작업 대기(사용자가 DNS 를 넣는다) const d1 = get(d0.id); - if (d1.ownership_state !== 'verified') { const r = await hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: `portal:${job.id}:own:${job.attempt}:${Date.now() / 30000 | 0}`, payload: { name: `_ariweb-verify.${d1.name}`, expectTxt: { name: `_ariweb-verify.${d1.name}`, value: `ariweb-verify=${d1.ownership_token}` } } }); const hj = await awaitHal(ctx, r.halJobId); const ok = !!(hj.result && hj.result.txtOk); record(d1.id, 'ownership', ok, hj.result); if (!ok) { save(d1.id, { last_checked_at: nowIso(), check_json: { stage: 'ownership', detail: hj.result && hj.result.txt ? `TXT 값이 다릅니다(지금 값: ${String(hj.result.txt).slice(0, 60)})` : 'TXT 레코드가 아직 보이지 않습니다' } }); ctx.waitExternal(hj.result && hj.result.txt ? `소유 확인 TXT 값이 다릅니다 — 안내된 값으로 수정한 뒤 "다시 확인"` : `소유 확인 TXT 레코드가 아직 반영되지 않았습니다 — DNS 에 넣은 뒤 "다시 확인"`); } save(d1.id, { ownership_state: 'verified', last_checked_at: nowIso() }); } + if (d1.ownership_state !== 'verified') { const hid = await halJobFor(ctx, job, 'ownership', (key) => hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: key, payload: { name: `_ariweb-verify.${d1.name}`, expectTxt: { name: `_ariweb-verify.${d1.name}`, value: `ariweb-verify=${d1.ownership_token}` } } })); const hj = await awaitHal(ctx, hid); const ok = !!(hj.result && hj.result.txtOk); record(d1.id, 'ownership', ok, hj.result); if (!ok) { save(d1.id, { last_checked_at: nowIso(), check_json: { stage: 'ownership', detail: hj.result && hj.result.txt ? `TXT 값이 다릅니다(지금 값: ${String(hj.result.txt).slice(0, 60)})` : 'TXT 레코드가 아직 보이지 않습니다' } }); probeReset(ctx, 'ownership'); ctx.waitExternal(hj.result && hj.result.txt ? `소유 확인 TXT 값이 다릅니다 — 안내된 값으로 수정한 뒤 "다시 확인"` : `소유 확인 TXT 레코드가 아직 반영되지 않았습니다 — DNS 에 넣은 뒤 "다시 확인"`); } save(d1.id, { ownership_state: 'verified', last_checked_at: nowIso() }); } await ctx.step('ownership', () => ({ verified: true })); // 2 DNS 반영(웹 용도만). 미반영(레코드 없음) 과 잘못된 값(다른 IP) 을 구분 const d2 = get(d0.id); @@ -102,7 +106,7 @@ export function createDomainsService(deps) { const recs = await requiredRecords(d2); const a = recs.find((x) => x.type === 'A'); if (pt.external) { save(d2.id, { dns_state: 'customer', dns_detail_json: toJson({ note: '외부 배포: DNS 값은 고객이 관리하며 포털이 검증하지 않습니다' }) }); } else if (!a.value) { save(d2.id, { dns_state: 'pending', check_json: { stage: 'dns', detail: '관리형 배포를 시작해 서버가 정해진 뒤 A 레코드 값을 안내합니다' } }); ctx.waitExternal('사이트를 먼저 관리형으로 배포하면 A 레코드 값을 안내합니다'); } - else { const r = await hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: `portal:${job.id}:dns:${job.attempt}:${Date.now() / 30000 | 0}`, payload: { name: d2.name, expectA: [a.value] } }); const hj = await awaitHal(ctx, r.halJobId); const got = (hj.result && hj.result.a) || []; const state = !got.length ? 'pending' : got.includes(a.value) ? 'ok' : 'wrong'; record(d2.id, 'dns', state === 'ok', { expected: a.value, got }); save(d2.id, { dns_state: state, dns_detail_json: toJson({ expected: a.value, got, resolvedAt: hj.result && hj.result.resolvedAt }), last_checked_at: nowIso() }); if (state !== 'ok') ctx.waitExternal(state === 'wrong' ? `A 레코드 값이 다릅니다 (지금 ${got.join(', ')} / 필요 ${a.value}) — 수정 뒤 "다시 확인"` : `A 레코드가 아직 반영되지 않았습니다 (필요 ${a.value}) — 반영 뒤 "다시 확인"`); } + else { const hid = await halJobFor(ctx, job, 'dns', (key) => hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: key, payload: { name: d2.name, expectA: [a.value] } })); const hj = await awaitHal(ctx, hid); const got = (hj.result && hj.result.a) || []; const state = !got.length ? 'pending' : got.includes(a.value) ? 'ok' : 'wrong'; record(d2.id, 'dns', state === 'ok', { expected: a.value, got }); save(d2.id, { dns_state: state, dns_detail_json: toJson({ expected: a.value, got, resolvedAt: hj.result && hj.result.resolvedAt }), last_checked_at: nowIso() }); if (state !== 'ok') { probeReset(ctx, 'dns'); ctx.waitExternal(state === 'wrong' ? `A 레코드 값이 다릅니다 (지금 ${got.join(', ')} / 필요 ${a.value}) — 수정 뒤 "다시 확인"` : `A 레코드가 아직 반영되지 않았습니다 (필요 ${a.value}) — 반영 뒤 "다시 확인"`); } } } else save(d2.id, { dns_state: 'n/a' }); await ctx.step('dns', () => ({ state: get(d0.id).dns_state })); // 3 인증서(관리형 웹 도메인만). 외부 배포는 고객 관리 diff --git a/portal/sites/service.mjs b/portal/sites/service.mjs index 5dccf65..706e9ee 100644 --- a/portal/sites/service.mjs +++ b/portal/sites/service.mjs @@ -252,7 +252,7 @@ export function createSitesService(deps) { async function halJobFor(ctx, job, stepId, factory, { fresh = false } = {}) { const st = ctx.steps().find((x) => x.id === stepId); const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null; // fresh: 사용자 재시도(이전 실행이 실패로 끝남) — 끝난 HAL 작업의 결과를 다시 쓰지 않고 새 작업을 만든다. 아니면 진행 중·완료된 이전 작업을 그대로 본다 - if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL(중앙 관리) 연결 대기 — 정보 확인 지연'); throw e; } if (!['failed', 'cancelled'].includes(hj.status)) return prev; } + if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL(중앙 관리) 연결 대기 — 정보 확인 지연'); throw e; } return prev; } // [E2E 결함 수정] 실패·취소된 HAL 작업도 그대로 돌려줘 awaitHal 이 실패를 드러낸다(새 작업은 fresh 일 때만) const r = await factory(`portal:${job.id}:${stepId}${prev ? ':r' + job.attempt : ''}`); if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); } return r.halJobId; @@ -364,13 +364,13 @@ export function createSitesService(deps) { const r = await hal().issueExternalAgentToken({ groupId: site.group_id, siteId: site.id, label }); if (prev) store.run('UPDATE external_agents SET revoked_at=?, status=\'revoked\' WHERE id=?', nowIso(), prev.id); const id = newId('xa_'); const now = nowIso(); - store.run('INSERT INTO external_agents(id, site_id, group_id, hal_agent_id, label, status, token_expires_at, install_command, created_at, updated_at) VALUES(?,?,?,?,?,\'pending\',?,?,?,?)', id, site.id, site.group_id, r.agentId, label || null, r.expiresAt, r.installCommand, now, now); + store.run('INSERT INTO external_agents(id, site_id, group_id, hal_agent_id, label, status, token_expires_at, install_command, created_at, updated_at) VALUES(?,?,?,?,?,\'pending\',?,?,?,?)', id, site.id, site.group_id, r.agentId || `pending:${id}` /* [E2E 결함 수정] HAL 은 Agent 가 실제 등록될 때 id 를 만든다 — 그 전에는 자리표시자 */, label || null, r.expiresAt, r.installCommand, now, now); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.issue', detail: { agentId: r.agentId, label } }); // 토큰 원문은 이 응답에만 — 저장하지 않는다(§28.2 등록 정보 재표시 금지와 같은 원칙) return { agent: externalAgentFor(site.id), token: r.token, expiresAt: r.expiresAt, installCommand: r.installCommand.replace('<토큰>', r.token) }; } - async function refreshExternalAgent(site) { const a = externalAgentFor(site.id); if (!a) return null; try { const s = await hal().getExternalAgent(a.hal_agent_id); store.run('UPDATE external_agents SET status=?, last_seen_at=?, agent_version=?, last_deploy_json=COALESCE(?, last_deploy_json), updated_at=? WHERE id=?', s.status, s.lastSeenAt || a.last_seen_at, s.version || a.agent_version, s.lastDeploy ? toJson(s.lastDeploy) : null, nowIso(), a.id); return { ...externalAgentFor(site.id), stale: false }; } catch (e) { return { ...a, stale: true, staleReason: 'HAL 에 연결할 수 없습니다(정보 확인 지연)' }; } } - async function revokeExternalAgent(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(404, 'not_found', '외부 서버 Agent 가 없습니다'); try { await hal().revokeExternalAgent(a.hal_agent_id); } catch (e) { if (e.code !== 'service_unreachable') throw e; } store.run('UPDATE external_agents SET status=\'revoked\', revoked_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), a.id); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.revoke', detail: { agentId: a.hal_agent_id } }); return true; } + async function refreshExternalAgent(site) { const a = externalAgentFor(site.id); if (!a) return null; try { let s; if (String(a.hal_agent_id).startsWith('pending:')) { s = hal().getExternalAgentBySite ? await hal().getExternalAgentBySite(site.id) : null; if (!s) return { ...a, stale: false }; store.run('UPDATE external_agents SET hal_agent_id=? WHERE id=?', s.id, a.id); a.hal_agent_id = s.id; } else s = await hal().getExternalAgent(a.hal_agent_id); store.run('UPDATE external_agents SET status=?, last_seen_at=?, agent_version=?, last_deploy_json=COALESCE(?, last_deploy_json), updated_at=? WHERE id=?', s.status, s.lastSeenAt || a.last_seen_at, s.version || a.agent_version, s.lastDeploy ? toJson(s.lastDeploy) : null, nowIso(), a.id); return { ...externalAgentFor(site.id), stale: false }; } catch (e) { return { ...a, stale: true, staleReason: 'HAL 에 연결할 수 없습니다(정보 확인 지연)' }; } } + async function revokeExternalAgent(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(404, 'not_found', '외부 서버 Agent 가 없습니다'); try { if (!String(a.hal_agent_id).startsWith('pending:')) await hal().revokeExternalAgent(a.hal_agent_id); } catch (e) { if (e.code !== 'service_unreachable') throw e; } store.run('UPDATE external_agents SET status=\'revoked\', revoked_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), a.id); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.revoke', detail: { agentId: a.hal_agent_id } }); return true; } /** 외부 서버의 배포 제거(실패 잔여 파일 정리 포함) */ function removeExternalDeploy(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(409, 'agent_missing', '외부 서버 Agent 가 없습니다'); const { job } = jobs.enqueue({ kind: 'site.remove_external', subject: { siteId: site.id, agentId: a.hal_agent_id }, idempotencyKey: `site.remove_external:${site.id}:${Date.now() / 60000 | 0}`, createdBy: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: '외부 서버 배포 제거' }); return describeJob(job); } jobs.register('site.remove_external', { title: '외부 서버 배포 제거', steps: [{ id: 'remove', title: '외부 서버에서 파일·실행 상태 제거' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable', async run(job, ctx) { const { siteId, agentId } = job.subject; const r = await ctx.step('remove', async () => { const id = await halJobFor(ctx, job, 'remove', (key) => hal().createJob({ kind: 'site.remove', target: agentId, idempotencyKey: key, payload: { siteId, keepData: false } }), { fresh: (job.steps || []).some((x) => x.id === 'remove' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; }); const a = externalAgentFor(siteId); if (a) store.run('UPDATE external_agents SET last_deploy_json=?, updated_at=? WHERE id=?', toJson({ removedAt: nowIso(), leftovers: (r && r.leftovers) || [] }), nowIso(), a.id); save(siteId, { current_version: null, deploy_verify: null, lifecycle: 'building', pending_cleanup_json: null }); return r; } }); diff --git a/portal/webhooks/api.mjs b/portal/webhooks/api.mjs new file mode 100644 index 0000000..e7a8188 --- /dev/null +++ b/portal/webhooks/api.mjs @@ -0,0 +1,26 @@ +/** + * portal/webhooks/api.mjs — HAL → 포털 이벤트 수신과 포털 건강 확인. + * + * POST /internal/hal-events HAL 이 작업 상태 변화를 밀어 넣는다(lib/serviceauth, 키 id 'hal'). 포털 쪽 작업은 5초 주기로 HAL 을 다시 묻는 구조이므로 + * 여기서는 "그 HAL 작업을 기다리는 포털 작업" 의 next_run_at 을 지금으로 당겨 바로 깨운다(둘 중 빠른 쪽, docs/HAL-JOBS.md §6). + * GET /api/health HAL 모듈 설명자가 부르는 건강 확인(버전·DB·작업 엔진). + */ +import { verifyCtx } from '../../lib/serviceauth.mjs'; +import { HttpError } from '../../lib/http.mjs'; +import { nowIso } from '../../lib/db.mjs'; + +export async function register(app, deps) { + const { store } = deps; + app.get('/api/health', (ctx) => { let db = false; try { db = !!store.get('SELECT 1 AS ok').ok; } catch (_) {} return { ok: db, version: deps.version, db, jobs: deps.jobs?.kinds().length || 0, time: nowIso() }; }); + app.post('/internal/hal-events', async (ctx) => { + const r = await verifyCtx(ctx, (id) => (id === 'hal' ? deps.vault.get('service:hal') : null)); + if (!r.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증 실패(${r.reason})`); + const ev = await ctx.body(); const halJobId = String(ev.halJobId || ev.id || ''); if (!halJobId) return { ok: true, woken: 0 }; + // 기다리는 포털 작업: 단계 결과에 halJobId 를 적어 둔 작업(halJobFor) 또는 deployments.hal_job_id 로 연결된 작업 + const now = nowIso(); let woken = 0; + woken += store.run('UPDATE jobs SET next_run_at=?, updated_at=? WHERE status=\'queued\' AND next_run_at>? AND steps_json LIKE ?', now, now, now, `%${halJobId}%`).changes; + const dep = store.get('SELECT job_id FROM deployments WHERE hal_job_id=?', halJobId); + if (dep && dep.job_id) woken += store.run('UPDATE jobs SET next_run_at=?, updated_at=? WHERE id=? AND status=\'queued\' AND next_run_at>?', now, now, dep.job_id, now).changes; + return { ok: true, woken }; + }); +}