로컬 E2E 검증 하네스(실제 HAL·포털·Agent 4대 + 가짜 Webmaker·AriMail·메일)와 34개 완료 조건 흐름 기록

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 13:10:31 +00:00
co-authored by Claude Fable 5.1
parent 1049f7b95c
commit 7662d06009
19 changed files with 4962 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
/** 흐름 1·2: 가입 → 확인 메일 → 이메일 확인 → 개인 그룹 → 팀 그룹·초대·수락. 이후 흐름이 쓰는 고객 클라이언트(state.alice / state.bob)를 만든다. */
import { steps } from '../report.mjs';
import { waitFor } from '../util.mjs';
export const BUDGET_MS = 40000;
/** 가입 + 이메일 확인 + CSRF 까지 끝낸 고객 클라이언트 */
export async function signupVerified(env, email, name) {
const c = env.customer();
const r = await c.post('/api/auth/signup', { email, displayName: name, password: 'pw-' + email.split('@')[0] + '-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 200) throw new Error(`가입 실패 ${r.status} ${r.text.slice(0, 200)}`);
const cs0 = await c.get('/api/auth/csrf'); c.setCsrf(cs0.json.csrf); // 가입 직후 세션의 CSRF(변경 API 에 필요)
const m = await waitFor(() => env.mail.last(email, /token=/), { timeoutMs: 8000, intervalMs: 200 }); const token = m && m.text ? (m.text.match(/token=([A-Za-z0-9_-]+)/) || [])[1] : null; if (!token) throw new Error('확인 메일 토큰 없음');
const v = await c.post('/api/auth/verify-email', { token }); if (v.status !== 200) throw new Error(`이메일 확인 실패 ${v.text.slice(0, 200)}`);
const cs = await c.get('/api/auth/csrf'); c.setCsrf(cs.json.csrf); c.email = email; return c;
}
export async function run({ env, report, state }) {
const s = steps();
try {
const email = 'alice@e2e.localhost';
const alice = await s.run('가입(이메일·표시 이름·비밀번호·필수 동의) → 세션 발급', async () => { const c = env.customer(); const r = await c.post('/api/auth/signup', { email, displayName: '앨리스', password: 'pw-alice-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); return { status: r.status, hasSession: !!c.cookie('aw_session') }; }) && env.customer();
const r0 = await alice.post('/api/auth/login', { email, password: 'pw-alice-e2e-1' }); const cs0 = await alice.get('/api/auth/csrf'); alice.setCsrf(cs0.json.csrf); alice.email = email;
await s.run('확인 전 유료·운영 기능 차단(그룹 생성 403 email_unverified)', async () => { const r = await alice.post('/api/groups', { name: '팀' }); if (r.status !== 403 || r.json.error !== 'email_unverified') throw new Error(`${r.status} ${r.json?.error}`); return r.json.error; });
const token = await s.run('확인 메일 수신(발신 포획)에서 토큰 추출', async () => { const m = await waitFor(() => env.mail.last(email, /token=/), { timeoutMs: 8000, intervalMs: 200 }); const t = m && m.text ? (m.text.match(/token=([A-Za-z0-9_-]+)/) || [])[1] : null; if (!t) throw new Error('메일 없음'); return { subject: m.subject }; }) && (env.mail.last(email, /token=/).text.match(/token=([A-Za-z0-9_-]+)/) || [])[1];
await s.run('이메일 확인(1회용) → 재사용 거부', async () => { const a = await alice.post('/api/auth/verify-email', { token }); const b = await alice.post('/api/auth/verify-email', { token }); if (a.status !== 200 || b.json?.error !== 'token_used') throw new Error(`${a.status}/${b.json?.error}`); return { first: a.status, second: b.json.error }; });
const me = await s.run('내 정보: 이메일 확인됨 · 개인 그룹 · owner · 별칭 user-01', async () => { const r = await alice.get('/api/auth/me'); const g = await alice.get('/api/group'); if (!r.json.user.email_verified_at || r.json.group.kind !== 'personal') throw new Error(JSON.stringify(r.json).slice(0, 200)); return { verified: true, groupKind: r.json.group.kind, role: r.json.role, alias: g.json.members?.[0]?.alias }; });
await s.run('중복 가입은 존재를 드러내지 않는 안내(409 signup_unavailable)', async () => { const r = await env.customer().post('/api/auth/signup', { email: 'ALICE@e2e.localhost', displayName: 'x', password: 'pw-dup-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 409) throw new Error(String(r.status)); return r.json.error; });
await s.run('로그인 기기 목록 · 다른 세션 종료', async () => { const other = env.customer(); await other.post('/api/auth/login', { email, password: 'pw-alice-e2e-1' }); const l = await alice.get('/api/account/sessions'); const n = l.json.sessions.length; const rv = await alice.post('/api/account/sessions/revoke-others'); const me2 = await other.get('/api/auth/me'); if (!(n >= 2) || me2.json.user) throw new Error('세션 종료 안 됨'); return { sessionsBefore: n, revoked: rv.json.revoked }; });
state.alice = alice; state.aliceEmail = email;
report.flow(1, 'verified_local', { steps: s.list, note: '가입→확인 메일(포획)→1회용 토큰 확인→개인 그룹·별칭→중복 가입 안내→기기 목록·세션 종료', evidence: { me } });
} catch (e) { report.flow(1, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '가입·이메일 확인 흐름 실패', file: 'portal/auth/api.mjs', expected: '가입→확인→개인 그룹', actual: e.message, repro: '10-identity.mjs 흐름 1', flows: [1] }); return; }
const s2 = steps();
try {
const alice = state.alice;
const teamId = await s2.run('팀 그룹 만들기 → 현재 그룹 전환', async () => { const r = await alice.post('/api/groups', { name: 'E2E 팀' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const me = await alice.get('/api/auth/me'); if (me.json.group.id !== r.json.groupId) throw new Error('전환 안 됨'); return r.json.groupId; });
const bob = await signupVerified(env, 'bob@e2e.localhost', '밥');
const link = await s2.run('이메일 초대(7일) → 초대 메일 링크', async () => { const r = await alice.post('/api/group/invites', { email: 'bob@e2e.localhost', role: 'member' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const m = await waitFor(() => env.mail.last('bob@e2e.localhost', /invite\//), { timeoutMs: 8000, intervalMs: 200 }); const t = m && m.text ? (m.text.match(/invite\/([A-Za-z0-9_-]+)/) || [])[1] : null; if (!t) throw new Error('초대 메일 없음'); return t; });
await s2.run('초대 수락(1회용) → 별칭 user-02 · 역할 member', async () => { const r = await bob.post('/api/invites/accept', { token: link }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const g = await bob.get('/api/group'); const again = await bob.post('/api/invites/accept', { token: link }); if (g.json.group.id !== teamId || again.status === 200) throw new Error('수락 상태 이상'); return { alias: g.json.me.alias, role: g.json.me.role, reuse: again.json?.error }; });
await s2.run('권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할', async () => { const r = await bob.patch('/api/group', { name: 'x' }); if (r.status !== 403) throw new Error(String(r.status)); return r.json.detail; });
await s2.run('그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음', async () => { const personal = (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal'); const a = await bob.get('/api/dashboard'); await bob.post('/api/groups/switch', { groupId: personal.id }); const b = await bob.get('/api/dashboard'); await bob.post('/api/groups/switch', { groupId: teamId }); return { team: a.json.summary?.sites, personal: b.json.summary?.sites }; });
state.bob = bob; state.teamId = teamId; await alice.post('/api/groups/switch', { groupId: teamId });
report.flow(2, 'verified_local', { steps: s2.list, note: '팀 그룹 생성·초대 메일·1회용 수락·별칭 user-02·역할 거부·그룹 전환 격리' });
} catch (e) { report.flow(2, 'failed', { steps: s2.list, note: e.message }); report.bug({ title: '그룹·초대 흐름 실패', file: 'portal/groups/api.mjs', expected: '팀 그룹·초대·수락', actual: e.message, repro: '10-identity.mjs 흐름 2', flows: [2] }); }
}
+33
View File
@@ -0,0 +1,33 @@
/** 흐름 3·24: 상품 목록 → 주문(멱등) → 운영자 입금 확인·승인(HAL 중계) → 권리 활성화 → 고객 알림·감사·운영자 화면 일치. */
import { steps } from '../report.mjs';
export const BUDGET_MS = 40000;
/** 주문을 넣고 운영자 중계로 입금 확인·승인까지 끝낸다(다른 흐름이 재사용). */
export async function orderAndApprove(env, client, { productId = 'team', items = [], key }) {
const prods = await client.get('/api/commerce/products'); const pv = prods.json.products.find((p) => p.productId === productId || p.product_id === productId || p.id === productId); if (!pv) throw new Error(`상품 없음: ${productId} (${prods.status})`);
const o = await client.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: key, items }); if (o.status !== 200) throw new Error(`주문 실패 ${o.status} ${o.text.slice(0, 200)}`);
const paid = await env.relay('POST', `/commerce/orders/${o.json.order.id}/paid`, { ref: 'e2e 입금' }); if (paid.status !== 200) throw new Error(`입금 확인 실패 ${paid.status} ${paid.text.slice(0, 200)}`);
const ap = await env.relay('POST', `/commerce/orders/${o.json.order.id}/approve`, {}); if (ap.status !== 200) throw new Error(`승인 실패 ${ap.status} ${ap.text.slice(0, 200)}`);
return { order: o.json.order, pv, approve: ap.json };
}
export async function run({ env, report, state }) {
const s = steps(); const alice = state.alice; if (!alice) { report.flow(3, 'partial', { note: '고객 세션 없음', blockedBy: 1 }); return; }
try {
const pv = await s.run('상품 목록(공개 버전만·가격·한도·미확정 정책 표시)', async () => { const r = await alice.get('/api/commerce/products'); if (r.status !== 200 || !r.json.products.length) throw new Error(r.text.slice(0, 200)); const team = r.json.products.find((p) => p.productId === 'team'); return { count: r.json.products.length, team: { id: team.id, price: team.price, sites: team.limits?.sites }, deferred: team.deferred?.length, paymentMethods: r.json.paymentMethods?.map((m) => m.id) }; }) && (await alice.get('/api/commerce/products')).json.products.find((p) => p.productId === 'team');
const order = await s.run('주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음', async () => { const a = await alice.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'e2e-order-1', items: [{ optionId: 'standalone_mail', qty: 1 }] }); if (a.status !== 200) throw new Error(a.text.slice(0, 300)); const b = await alice.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'e2e-order-1' }); if (b.json.created !== false) throw new Error('멱등 실패'); return { orderNo: a.json.order.order_no, status: a.json.order.status, total: a.json.order.total, dueAt: a.json.order.due_at, hasInstructions: !!a.json.order.payment_instructions }; }) && (await alice.get('/api/commerce/orders')).json.orders[0];
await s.run('입금 확인 전 승인 거부(409 payment_not_confirmed)', async () => { const r = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); if (r.status !== 409) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); return r.json.error; });
await s.run('운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시', async () => { const r = await env.relay('POST', `/commerce/orders/${order.id}/paid`, { ref: 'e2e 입금 확인' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const l = await env.relay('GET', '/commerce/orders?status=awaiting_approval'); const o = l.json.orders.find((x) => x.id === order.id); return { status: r.json.order.status, delta: o?.entitlementDelta?.map((d) => d.resource) }; });
const ap = await s.run('운영자 승인 → 권리 활성화(재승인은 중복 없음)', async () => { const a = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); if (a.status !== 200) throw new Error(a.text.slice(0, 200)); const b = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); return { granted: a.json.granted, already: b.json.already }; });
const bill = await s.run('고객 결제 화면: 활성 상품·권리·다음 청구일', async () => { const r = await alice.get('/api/commerce/billing'); const sites = r.json.entitlements?.find((e) => e.resource === 'sites'); if (!sites || sites.purchased < 1) throw new Error(JSON.stringify(r.json).slice(0, 300)); return { sites: sites.purchased, subs: r.json.subscriptions?.length, nextBilling: r.json.subscriptions?.[0]?.next_billing_at }; });
state.order = order; state.teamProduct = pv;
report.flow(3, 'verified_local', { steps: s.list, note: '상품 목록→주문(멱등·수동 결제)→입금 확인 전 승인 거부→입금 확인→승인→권리 활성화→재승인 중복 없음→청구 화면', evidence: { ap, bill } });
} catch (e) { report.flow(3, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '주문·승인·권리 활성화 흐름 실패', file: 'portal/commerce/api.mjs', route: '/api/commerce/orders, /internal/ops/commerce/orders/:id/{paid,approve}', expected: '승인 뒤 권리 활성화', actual: e.message, repro: '20-commerce.mjs', flows: [3] }); return; }
const s2 = steps();
try {
const alice = state.alice; const order = state.order;
const n = await s2.run('고객 알림 센터에 승인 알림', async () => { const r = await alice.get('/api/notifications'); const x = (r.json.notifications || r.json.items || []).find((y) => /승인/.test(y.title) && (y.subject_id === order.id || /주문/.test(y.title))); if (!x) throw new Error('알림 없음: ' + JSON.stringify(r.json).slice(0, 200)); return { title: x.title, category: x.category, link: x.link }; });
const a = await s2.run('감사 기록: order.approve 는 삭제 불가(immutable)', async () => { const r = await alice.get('/api/audit?action=order.approve'); const x = (r.json.items || [])[0]; if (!x || !x.immutable) throw new Error(JSON.stringify(r.json).slice(0, 200)); return { action: x.action, immutable: x.immutable, actor: x.actor_type }; });
const same = await s2.run('운영자 화면과 고객 주문 화면의 상태 일치', async () => { const o = await env.relay('GET', `/commerce/orders/${order.id}`); const c = await alice.get(`/api/commerce/orders/${order.id}`); if (o.json.order.status !== c.json.order.status) throw new Error(`${o.json.order.status} vs ${c.json.order.status}`); return { status: c.json.order.status, approvedAt: c.json.order.approved_at }; });
report.flow(24, 'verified_local', { steps: s2.list, note: '승인 → 고객 알림·불변 감사 기록·운영자/고객 화면 상태 일치(주문 기준)', evidence: { n, a, same } });
} catch (e) { report.flow(24, 'partial', { steps: s2.list, note: e.message }); }
}
+85
View File
@@ -0,0 +1,85 @@
/** 흐름 4·5·6·7·8·9·23·27: 초안 사이트 → Webmaker SSO·저장본 → 관리형 배포(실제 HAL·Agent) → 도메인 연결 → 외부 확인 → 통계 → 실패 작업 재시도·되돌리기 → 마지막 사이트 수 동시 요청. */
import { steps } from '../report.mjs';
import { waitFor, sleep } from '../util.mjs';
import { signupVerified } from './10-identity.mjs';
import { orderAndApprove } from './20-commerce.mjs';
export const BUDGET_MS = 150000;
/** 포털 작업이 끝날 때까지(최대 ms) 사이트 작업 조회 */
export async function waitSiteJob(client, siteId, jobId, ms = 60000) { return waitFor(async () => { const r = await client.get(`/api/sites/${siteId}/jobs/${jobId}`); const j = r.json?.job || r.json; return j && ['done', 'failed', 'cancelled', 'reverted', 'needs_user', 'awaiting_external'].includes(j.status) ? j : null; }, { timeoutMs: ms, intervalMs: 1000, label: 'site job' }); }
/** 도메인 연결 작업 취소(결함: 외부 대기 중 5초마다 HAL domain.probe 재제출 — 뒤 흐름의 HAL 큐 보호용) */
export async function cancelDomainJob(client, domainId) { const x = await client.get(`/api/domains/${domainId}`); const jid = x.json?.job?.id; if (!jid) return null; const r = await client.post(`/api/domains/${domainId}/jobs/${jid}/cancel`); return r.status; }
export async function createSiteWithVersion(env, client, name, { deployMode = 'managed' } = {}) {
const r = await client.post('/api/sites', { name, purpose: 'e2e', projectMode: 'new', buildMode: 'manual', deployMode }); if (r.status !== 200) throw new Error(`사이트 생성 실패 ${r.status} ${r.text.slice(0, 200)}`);
const site = r.json.site; await env.wm.addVersion(site.webmaker_project_id); return site;
}
export async function run({ env, report, state, log }) {
const alice = state.alice; if (!alice) { for (const n of [4, 5, 6, 7, 8, 9, 23, 27]) report.flow(n, 'partial', { note: '고객 세션 없음', blockedBy: 1 }); return; }
let site = null;
// ---- 4 초안 사이트
{ const s = steps(); try {
site = await s.run('사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내', async () => { const r = await alice.post('/api/sites', { name: 'E2E 사이트', purpose: '검증', projectMode: 'new', buildMode: 'manual', deployMode: 'managed', lang: 'ko', timezone: 'Asia/Seoul' }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); if (r.json.site.lifecycle !== 'draft' || r.json.site.site_count_state !== 'none') throw new Error(JSON.stringify(r.json.site).slice(0, 200)); state._created = r.json.site; return { id: r.json.site.id, lifecycle: r.json.site.lifecycle, projectId: r.json.site.webmaker_project_id, nextSteps: r.json.nextSteps?.map((x) => x.id) }; }) && state._created;
await s.run('사이트 목록 필터(초안)·사용량 요약에 예약 0', async () => { const l = await alice.get('/api/sites?filter=draft'); const u = await alice.get('/api/usage'); const sites = u.json.resources?.find((x) => x.resource === 'sites'); return { drafts: l.json.sites.length, sitesReserved: sites?.reserved, sitesAllocated: sites?.allocated }; });
state.site = site; report.flow(4, 'verified_local', { steps: s.list, note: '초안 생성·Webmaker 프로젝트 연결·사이트 수 미소비·목록 필터' });
} catch (e) { report.flow(4, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '초안 사이트 생성 실패', file: 'portal/sites/api.mjs', route: 'POST /api/sites', expected: 'draft site', actual: e.message, repro: '30-sites.mjs', flows: [4] }); } }
if (!site) { for (const n of [5, 6, 7, 8, 9, 23]) report.flow(n, 'partial', { note: '사이트 없음', blockedBy: 4 }); return; }
// ---- 5 Webmaker 제작(SSO 진입 + 저장본)
{ const s = steps(); try {
const sso = await s.run('제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용)', async () => { const r = await alice.get(`/sso/webmaker?site=${site.id}`); if (r.status !== 302) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const loc = r.headers.location; const w = await fetch(loc, { redirect: 'manual' }); const again = await fetch(loc, { redirect: 'manual' }); if (w.status !== 302) throw new Error(`webmaker ${w.status}`); return { portalRedirect: loc.replace(/ticket=.*/, 'ticket=…'), webmaker: w.status, reuse: again.status }; });
const ver = await s.run('Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과', async () => { await env.wm.addVersion(site.webmaker_project_id); const v = await alice.get(`/api/sites/${site.id}/versions`); const pc = await alice.get(`/api/sites/${site.id}/precheck`); if (!pc.json.precheck?.ok) throw new Error('precheck: ' + JSON.stringify(pc.json.precheck?.items?.filter((i) => i.state === 'block')).slice(0, 300)); return { versions: (v.json.versions || v.json.versionList || []).length, precheckOk: pc.json.precheck.ok }; });
report.flow(5, 'verified_local', { steps: s.list, note: 'SSO 티켓(1회용) 진입·저장본 목록·배포 전 확인(가짜 Webmaker — 실제 편집 UI 는 포함하지 않음)', evidence: { sso, ver } });
} catch (e) { report.flow(5, 'partial', { steps: s.list, note: e.message }); } }
// ---- 6 관리형 배포 (실제 HAL → 실제 Agent)
let deployJob = null; { const s = steps(); try {
const d = await s.run('배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나', async () => { const a = await alice.post(`/api/sites/${site.id}/deploy`, { versionRef: 'current' }); if (a.status !== 200) throw new Error(a.text.slice(0, 300)); const b = await alice.post(`/api/sites/${site.id}/deploy`, { versionRef: 'current' }); const st = (await alice.get(`/api/sites/${site.id}`)).json.site; return { jobId: a.json.job.id, steps: a.json.job.steps.length, again: b.json.created, siteCount: st.site_count_state, server: st.server_id }; });
deployJob = await s.run('HAL·Agent 를 거쳐 작업 완료 대기(최대 40초)', async () => { const j = await waitSiteJob(alice, site.id, d.jobId, 40000); const halAll = (await env.ops.get('/ops/api/jobs?kind=site.deploy&limit=20')).json?.jobs || []; const halJobs = { count: halAll.length, first: halAll[0] ? [halAll[0].status, (halAll[0].error || '').slice(0, 140)] : null }; const cur = (await alice.get(`/api/sites/${site.id}/jobs/${d.jobId}`)).json; const cj = cur.job || cur; if (!j || j.timedOut) throw new Error(`시간 초과 — 포털 작업 ${cj.status}(attempt ${cj.attempt}, ${cj.waitReason || cj.wait_reason || ''}) · HAL site.deploy 작업 ${halJobs.count}개(최근: ${JSON.stringify(halJobs.first)})`); if (j.status !== 'done') throw new Error(`${j.status}: ${j.error} (${j.errorCode}) 단계=${JSON.stringify((j.steps || []).map((x) => x.id + ':' + x.state))}`); return { status: j.status, verify: j.result?.verify, siteUrl: j.result?.siteUrl, steps: j.steps.map((x) => x.id + ':' + x.state) }; });
const after = await s.run('배포 뒤 사이트 상태·버전·건강', async () => { const st = (await alice.get(`/api/sites/${site.id}`)).json.site; const ag = env.servers.web; return { lifecycle: st.lifecycle, version: st.current_version, verify: st.deploy_verify, health: st.health, agentHasSite: (await import('node:fs')).existsSync(`${ag.roots.sites}/${site.id}/current`) || (await import('node:fs')).existsSync(`${env.servers.web2.roots.sites}/${site.id}/current`) }; });
report.flow(6, 'verified_local', { steps: s.list, note: '배포 요청→예약→HAL 작업→Agent(stub) 배포→도메인 설정 필요 상태(대표 도메인 없음)', evidence: { d, deployJob, after } });
} catch (e) {
// 포털 경로가 막힌 경우: HAL·Agent 쪽 배포 경로(로컬 저장본) 만 따로 확인해 결함 위치를 좁힌다
await s.run('[HAL 직접] 같은 Agent 에 로컬 저장본으로 site.deploy → 성공하면 결함은 포털→Agent 번들 전달 구간', async () => { const ag = env.servers.web; const { mkdirSync, writeFileSync, existsSync } = await import('node:fs'); const bdir = `${ag.dir}/bundle-direct`; mkdirSync(bdir, { recursive: true }); writeFileSync(`${bdir}/index.html`, '<h1>direct marker-direct</h1>'); const r = await env.halSigned('POST', '/portal/v1/jobs', { kind: 'site.deploy', target: ag.serverId, payload: { siteId: 'direct_e2e', versionRef: 'v1', bundlePath: bdir, domains: [], expectMarker: 'marker-direct' }, idempotencyKey: 'e2e-direct-deploy' }); const j = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${r.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { status: j?.status, error: j?.error, layout: existsSync(`${ag.roots.sites}/direct_e2e/current`), externalCheck: j?.result?.externalCheck }; }, { soft: true });
const halErr = (await env.ops.get('/ops/api/jobs?kind=site.deploy&limit=3')).json?.jobs?.map((x) => x.error).filter(Boolean)[0] || '';
if (/Failed to parse URL|내려받기 실패/.test(halErr)) report.bug({ 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: halErr, repro: '포털 배포 → HAL site.deploy → Agent: "Failed to parse URL from http://<hal>http://<portal>/internal/bundles/…"', flows: [6, 8, 9, 13, 14, 15, 23] });
const cj0 = (await alice.get(`/api/sites/${site.id}/jobs/${s.list[0]?.detail?.jobId || ''}`)).json; const pj = cj0 && (cj0.job || cj0);
if (pj && pj.status === 'queued' && pj.attempt > 2) report.bug({ 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 ${pj.attempt} (${pj.waitReason || pj.wait_reason || ''}); HAL 에 같은 사이트의 site.deploy 실패 작업이 attempt 수만큼 쌓임`, repro: 'Agent 가 실패하는 배포를 요청하고 /api/sites/:id/jobs/:id 를 지켜보면 attempt 가 5초마다 증가', flows: [6, 23, 13, 20] });
// 선행 결함으로 영원히 진행 중인 배포를 사용자 취소·정리해 뒤 흐름(백업·이전·소유권)이 "진행 중 작업" 에 막히지 않게 한다
await s.run('사용자 취소(진행 중 작업) → 중간 결과 정리·예약 반환', async () => { const jid = s.list[0]?.detail?.jobId; const c = await alice.post(`/api/sites/${site.id}/jobs/${jid}/cancel`); const cl = await alice.post(`/api/sites/${site.id}/cleanup`); const st = (await alice.get(`/api/sites/${site.id}`)).json.site; return { cancel: c.status, cancelError: c.json?.error, cleanup: cl.status, cleanupError: cl.json?.error, siteCount: st.site_count_state, lifecycle: st.lifecycle }; }, { soft: true });
report.flow(6, 'failed', { steps: s.list, note: e.message, evidence: { halLog: env.hal.proc.lines.filter((l) => /site.deploy/.test(l)).slice(-3) } });
report.bug({ 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://<portal>/internal/bundles/<token>)은 HAL base 를 붙이지 않고 그대로 내려받아야 한다', actual: e.message, repro: '사이트 생성→저장본→POST /api/sites/:id/deploy→HAL site.deploy→Agent site.deploy fetch 단계에서 "HAL 내려받기 실패" 또는 잘못된 URL 오류', flows: [6, 8, 9, 13, 14, 15, 23] });
} }
const deployed = deployJob && deployJob.status === 'done';
// ---- 7 도메인 연결
{ const s = steps(); try {
const dm = await s.run('도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)', async () => { const r = await alice.post('/api/domains', { name: 'aw-e2e.localhost', purpose: 'web', siteId: site.id, makePrimary: true }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); const txt = r.json.records?.find((x) => x.type === 'TXT'); const j = await waitFor(async () => { const x = await alice.get(`/api/domains/${r.json.domain.id}`); const jj = x.json?.job; return jj && ['awaiting_external', 'failed', 'done', 'needs_user'].includes(jj.status) ? jj : null; }, { timeoutMs: 30000, intervalMs: 1000 }); return { domainId: r.json.domain.id, txtName: txt?.name, txtValueShape: txt?.value ? txt.value.replace(/=.*/, '=…') : null, jobStatus: j?.status, waitReason: j?.waitReason || j?.wait_reason, copyable: !!txt }; });
await s.run('도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시', async () => { const l = await alice.get('/api/domains'); const d = l.json.domains.find((x) => x.id === dm.domainId); return { ownership: d?.ownership_state, dns: d?.dns_state, cert: d?.cert_state, primary: d?.is_primary }; });
state.domainId = dm.domainId;
await s.run('(정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다', async () => { const x = await alice.get(`/api/domains/${dm.domainId}`); const jid = x.json?.job?.id; const r = jid ? await alice.post(`/api/domains/${dm.domainId}/jobs/${jid}/cancel`) : null; await sleep(1500); const q = (await env.ops.get('/ops/api/jobs?status=queued,running&kind=domain.probe&limit=50')).json?.jobs?.length; if (q > 3) report.bug({ title: '포털 도메인 연결 작업이 외부 대기(awaiting_external) 중에도 5초마다 HAL domain.probe 작업을 새로 만들어 HAL 큐를 채움', file: 'portal/domains/service.mjs (domain.connect: idempotencyKey 에 attempt·30초 버킷 포함, awaitHal 의 retryLater(5s) 마다 새 작업)', route: 'POST /api/domains → HAL /portal/v1/jobs(domain.probe)', expected: '한 번 확인 요청에 HAL 작업 하나; 재확인은 사용자의 "다시 확인" 또는 완만한 주기', actual: `취소 전 HAL 큐에 domain.probe ${q}개(5초 간격)`, repro: '도메인 추가 뒤 /ops/api/jobs?kind=domain.probe 를 보면 5초마다 새 queued 작업', flows: [7, 22, 30] }); return { cancelled: r?.status, queuedProbes: q }; }, { soft: true });
report.needsInfra('도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인은 실제 DNS 와 공인 서버가 필요하다(흐름 7·8·11·14·15)');
report.flow(7, 'partial', { steps: s.list, note: '도메인 추가·복사 가능한 TXT/DNS 값·외부 대기 상태까지 확인. 실 DNS 반영·인증서 발급·대표 도메인 확정은 로컬 확인 불가', evidence: { dm } });
} catch (e) { report.flow(7, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '도메인 연결 흐름 실패', file: 'portal/domains/api.mjs', route: 'POST /api/domains', expected: 'TXT 안내 + 외부 대기', actual: e.message, repro: '30-sites.mjs 흐름 7', flows: [7] }); } }
report.flow(8, deployed ? 'partial' : 'not_verifiable_locally', { note: deployed ? '플랫폼 확인 주소(preview.localhost)로 HAL 외부 확인을 시도했으나 로컬에는 80 포트 웹서버가 없어 외부 확인 결과만 기록됨' : '외부 주소 확인은 공인 서버·DNS 필요', blockedBy: deployed ? null : 6, evidence: deployJob ? { externalCheck: deployJob.externalCheck } : {} });
// ---- 9 사이트 정보·통계
{ const s = steps(); try {
const ov = await s.run('개요: 배포 전/후에 따라 준비 단계 또는 실제 통계', async () => { const r = await alice.get(`/api/sites/${site.id}/overview`); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const o = r.json.overview; return { kind: o.kind, lifecycle: o.lifecycle, hasStats: o.visits24h != null, readiness: o.readiness || o.stage || null, disk: o.disk ? { used: o.disk.used, limit: o.disk.limit } : null }; });
const us = await s.run('사용량 탭(배정·실제 사용·마지막 계측)', async () => { const r = await alice.get(`/api/sites/${site.id}/usage`); return { allocations: (r.json.allocations || []).map((a) => a.resource) }; });
report.flow(9, deployed && ov.hasStats ? 'verified_local' : 'partial', { steps: s.list, note: deployed ? '개요·사용량 조회(Agent stub 가 수집한 값)' : '미배포 사이트는 통계 대신 준비 단계 표시(§8.1) 확인. 실제 접속·네트워크 통계는 배포 뒤 수집', blockedBy: deployed ? null : 6, evidence: { ov, us } });
} catch (e) { report.flow(9, 'partial', { steps: s.list, note: e.message }); } }
// ---- 23 실패 작업 재시도·되돌리기(배포 작업 기준)
{ const s = steps(); try {
const dep = await s.run('배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호', async () => { const r = await alice.get(`/api/sites/${site.id}/deployments`); const d = r.json.deployments?.[0]; return { status: d?.status, failedStep: d?.result?.failedStep, canRetry: d?.job?.canRetry, jobId: d?.job?.id }; });
const rt = await s.run('재시도 요청(POST /api/sites/:id/jobs/:jobId/retry)', async () => { if (!dep.jobId) throw new Error('작업 없음'); const r = await alice.post(`/api/sites/${site.id}/jobs/${dep.jobId}/retry`); if (r.status >= 500) report.bug({ 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: `${r.status} ${r.json?.error}`, repro: '취소된 작업에 retry', flows: [23] }); return { status: r.status, error: r.json?.error, jobStatus: r.json?.job?.status }; }, { soft: true });
if (rt && rt.status === 200) await s.run('재시도 작업 결과 대기', async () => { const j = await waitSiteJob(alice, site.id, dep.jobId, 60000); return { status: j?.status, error: j?.error }; }, { soft: true });
const rb = await s.run('되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부', async () => { const r = await alice.post(`/api/sites/${site.id}/rollback`); return { status: r.status, error: r.json?.error, message: r.json?.message }; });
report.flow(23, deployed ? 'partial' : 'partial', { steps: s.list, note: '실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요', blockedBy: deployed ? null : 6, evidence: { dep, rt, rb } });
} catch (e) { report.flow(23, 'partial', { steps: s.list, note: e.message }); } }
// ---- 27 마지막 사이트 수 동시 요청(밥의 개인 그룹: 베이직 1개)
{ const s = steps(); try {
const bob = state.bob; const personal = (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal'); await bob.post('/api/groups/switch', { groupId: personal.id });
await s.run('밥 개인 그룹: 베이직(사이트 1) 주문·승인', async () => orderAndApprove(env, bob, { productId: 'basic', key: 'e2e-bob-basic' }).then((r) => ({ granted: r.approve.granted })));
const [a, b] = await s.run('사이트 2개 생성·저장본', async () => Promise.all([createSiteWithVersion(env, bob, '동시 A'), createSiteWithVersion(env, bob, '동시 B')])).then((x) => x);
const race = await s.run('두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족)', async () => { const [ra, rb] = await Promise.all([bob.post(`/api/sites/${a.id}/deploy`, {}), bob.post(`/api/sites/${b.id}/deploy`, {})]); const ok = [ra, rb].filter((r) => r.status === 200).length; const bad = [ra, rb].find((r) => r.status !== 200); const good = [ra, rb].find((r) => r.status === 200); if (ok !== 1 || !bad || bad.json?.error !== 'precheck_failed') throw new Error(`${ra.status}/${rb.status} ${bad?.json?.error}`); state.bobDeploy = good ? { siteId: good === ra ? a.id : b.id, jobId: good.json.job?.id } : null; return { ok, blocked: bad.json.error, shortage: bad.json.detail?.items?.find((i) => i.id === 'sites')?.state }; });
await s.run('(정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환', async () => { if (!state.bobDeploy) return null; await sleep(3000); const c = await bob.post(`/api/sites/${state.bobDeploy.siteId}/jobs/${state.bobDeploy.jobId}/cancel`); const cl = await bob.post(`/api/sites/${state.bobDeploy.siteId}/cleanup`); return { cancel: c.status, cleanup: cl.status, unallocated: (await bob.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.unallocated }; }, { soft: true });
state.bobSites = [a, b]; state.bobPersonal = personal.id; report.flow(27, 'verified_local', { steps: s.list, note: '마지막 사이트 수 1개를 두 배포가 동시에 요청 → 하나만 예약 성공, 다른 쪽은 부족 사유(§13.3)', evidence: { race } });
} catch (e) { report.flow(27, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '동시 예약 흐름 실패', file: 'portal/sites/service.mjs', route: 'POST /api/sites/:id/deploy ×2', expected: '하나만 성공', actual: e.message, repro: '30-sites.mjs 흐름 27', flows: [27] }); } }
}
function d_last(s) { return s.list.at(-1); }
+47
View File
@@ -0,0 +1,47 @@
/** 흐름 10·11·12·17·33: 사이트 대표 메일·메일함(가짜 AriMail) → DNS 확인·활성화(외부 대기) → 왕복 확인(로컬 불가) → 독립 메일 → 사용량 경고/100% → 전환 점검표. */
import { steps } from '../report.mjs';
import { waitFor } from '../util.mjs';
import { cancelDomainJob } from './30-sites.mjs';
export const BUDGET_MS = 60000;
const alloc = { mailboxes: 6, storageBytes: 2 * 1024 ** 3, monthlyMessages: 500 };
export async function run({ env, report, state }) {
const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [10, 11, 12, 17, 33]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; }
let svcId = null;
{ const s = steps(); try {
const svc = await s.run('사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시', async () => { const r = await alice.post('/api/mail/services', { kind: 'site_bound', siteId: site.id, domain: 'aw-e2e.localhost', alloc }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const d = env.am.state.domains.get('aw-e2e.localhost'); const cfg = env.am.state.configs.get('aw-e2e.localhost') || {}; return { id: r.json.service.id, status: r.json.service.status, tenant: !!d, ariwebKind: d?.ariweb?.kind, limitAccounts: cfg.LIMIT_ACCOUNTS, siteCountAllocated: (await alice.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.allocated }; });
svcId = svc.id; state.mailServiceId = svcId;
await s.run('같은 사이트에 두 번째 대표 메일 거부', async () => { const r = await alice.post('/api/mail/services', { kind: 'site_bound', siteId: site.id, domain: 'aw-e2e.localhost', alloc: { mailboxes: 1 } }); return r.json?.error; });
const mb = await s.run('메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음', async () => { const r = await alice.post(`/api/mail/services/${svcId}/mailboxes`, { local: 'kim', displayName: '김', quotaBytes: 100 * 1024 ** 2 }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const again = await alice.get(`/api/mail/services/${svcId}/mailboxes/${r.json.mailbox.id}`); const leaked = JSON.stringify(again.json).includes(r.json.initialPassword); return { id: r.json.mailbox.id, address: r.json.mailbox.address, passwordShownOnce: !!r.json.initialPassword, mustChange: r.json.mailbox.mustChangePassword, imapHost: r.json.program?.imap?.host, leakedOnReload: leaked, inArimail: !!(env.am.state.users.get('aw-e2e.localhost') || []).find((u) => u.login === 'kim') }; });
state.mailboxId = mb.id;
await s.run('초대 방식 메일함(링크 만료·재발송·사용 완료 상태)', async () => { const r = await alice.post(`/api/mail/services/${svcId}/mailboxes`, { local: 'lee', mode: 'invite', inviteEmail: 'lee@external.e2e.localhost' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const token = r.json.invite.url.split('/').pop(); const acc = await env.customer().post('/api/mail/invite/accept', { token, password: 'new-password-e2e-12' }); const again = await env.customer().post('/api/mail/invite/accept', { token, password: 'new-password-e2e-12' }); return { inviteState: r.json.mailbox.inviteState, accepted: acc.status, reuse: again.json?.error }; });
const dns = await s.run('DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing)', async () => { const r = await alice.post(`/api/mail/services/${svcId}/dns-check`); return { ok: r.json.result?.ok, missing: r.json.result?.summary?.missing, items: (r.json.result?.items || []).map((i) => i.kind + ':' + i.state) }; });
const act = await s.run('활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등', async () => { const a = await alice.post(`/api/mail/services/${svcId}/activate`); const b = await alice.post(`/api/mail/services/${svcId}/activate`); const j = await waitFor(async () => { const x = await alice.get(`/api/mail/services/${svcId}/job`); return x.json?.job && x.json.job.status !== 'queued' && x.json.job.status !== 'running' ? x.json.job : null; }, { timeoutMs: 20000, intervalMs: 500 }); return { status: j?.status, waitReason: j?.waitReason, sameJob: a.json?.job?.id === b.json?.job?.id, steps: (j?.steps || []).map((x) => x.id + ':' + x.state) }; });
state.mailJobId = act && (await alice.get(`/api/mail/services/${svcId}/job`)).json?.job?.id;
await s.run('보안 보고서(SPF·DKIM·DMARC·MX 상태 + 복사 값)', async () => { const r = await alice.get(`/api/mail/services/${svcId}/security`); return { items: (r.json.report?.items || []).map((i) => i.id + ':' + i.state), records: (r.json.report?.records || []).length }; });
report.needsInfra('메일 DNS(MX·SPF·DKIM·DMARC) 반영과 외부 송신·수신·회신 왕복은 실 도메인·실 AriMail(SMTP 587/수신함)·외부 검증 주소가 필요하다(흐름 10 활성화 완료·11·33 완료)');
report.flow(10, 'partial', { steps: s.list, note: '대표 메일 생성(AriMail 테넌트·보장량·ariweb 표시)·메일함(비밀번호 1회·초대)·DNS 미반영 구분·활성화 작업 외부 대기까지 확인. DNS 반영 뒤 활성화 완료는 실 DNS 필요', evidence: { svc, mb, dns, act } });
} catch (e) { report.flow(10, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '사이트 대표 메일·메일함 흐름 실패', file: 'portal/mail/api.mjs', route: 'POST /api/mail/services, /mailboxes', expected: '서비스·메일함 생성', actual: e.message, repro: '40-mail.mjs', flows: [10] }); } }
report.flow(11, 'not_verifiable_locally', { note: '외부 송신·수신·회신은 실제 SMTP 제출(587)·수신함·외부 자동 회신 주소가 필요하다. 포털은 검증 주소 미설정 시 needs_user, 왕복 실패 시 활성화하지 않는 규칙(단위 테스트 test/mail.test.mjs)으로만 확인됨' });
// ---- 12 독립 메일
{ const s = steps(); try {
const d = await s.run('메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)', async () => { const r = await alice.post('/api/domains', { name: 'solo-e2e.localhost', purpose: 'mail', startCheck: false }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const c = await cancelDomainJob(alice, r.json.domain.id); return { id: r.json.domain.id, ownership: r.json.domain.ownership_state, jobCancelled: c }; });
const r = await s.run('독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)', async () => { const x = await alice.post('/api/mail/services', { kind: 'standalone', domain: 'solo-e2e.localhost', alloc: { mailboxes: 2, storageBytes: 1024 ** 3, monthlyMessages: 100 } }); if (x.status !== 200) throw new Error(`${x.status} ${x.text.slice(0, 300)}`); const u = (await alice.get('/api/usage')).json.resources; return { id: x.json.service.id, kind: x.json.service.kind, mailServicesAllocated: u.find((y) => y.resource === 'mail_services')?.allocated, sitesAllocated: u.find((y) => y.resource === 'sites')?.allocated }; });
state.standaloneMailId = r.id; const list = await alice.get('/api/mail/services');
report.flow(12, 'verified_local', { steps: s.list, note: '독립 메일 서비스 생성: 독립 옵션 1 소비, 운영 사이트 수 미소비, 목록에 백업 미제공 문구', evidence: { r, listCount: list.json.services?.length, noBackupNote: list.json.notes?.noBackup } });
} catch (e) { report.flow(12, 'partial', { steps: s.list, note: e.message }); } }
// ---- 17 사용량 경고·100%(메일 저장 공간·월간 통수)
{ const s = steps(); try {
if (!svcId) throw new Error('메일 서비스 없음');
const warn = await s.run('가짜 AriMail 사용량 95%/월간 100% → 수집(refresh) → 구간·제한 사유·다음 KST 초기화·마지막 계측 표시', async () => { env.am.state.usage.set('aw-e2e.localhost', { storageBytes: Math.round(0.96 * alloc.storageBytes), sent: 300, received: 250, monthReset: '2026-10-31T15:00:00.000Z' }); const kim = (env.am.state.users.get('aw-e2e.localhost') || []).find((u) => u.login === 'kim'); if (kim) kim.bytes = Math.round(0.97 * (kim.quotaBytes || 100 * 1024 ** 2)); const u = (await alice.get(`/api/mail/services/${svcId}/usage?refresh=1`)).json; const ev = await alice.post('/api/usage/evaluate', {}); u._evaluate = { status: ev.status, notified: ev.json?.notified?.length ?? ev.json?.result?.notified?.length, limited: ev.json?.limited?.length ?? ev.json?.result?.limited?.length }; return { storageLevel: u.figures?.storage?.level, monthlyLevel: u.figures?.monthly?.level, nextReset: u.nextReset, measuredAt: !!u.measuredAt, limit100: u.notes?.limit100?.monthly, countingRule: u.lastUsage?.countingRule, evaluate: u._evaluate }; });
const notif = await s.run('알림 센터: 95%·100% 경고가 사건으로 묶여 표시(필수 알림)', async () => { const r = await alice.get('/api/notifications'); const items = r.json.notifications || r.json.items || []; const usage = items.filter((n) => n.category === 'usage' || /%/.test(n.title)); return { count: usage.length, titles: usage.slice(0, 4).map((n) => n.title), mandatory: usage.some((n) => n.mandatory) }; });
const lim = await s.run('사용량 화면 제한 플래그(/api/usage/limits)·그룹 요약', async () => { const r = await alice.get('/api/usage/limits'); const u = await alice.get('/api/usage'); return { limits: (r.json.limits || []).map((l) => l.resource + ':' + l.level), warnLevels: u.json.product?.warnLevels }; });
if (!notif.count) report.bug({ title: '메일 사용량 수집 뒤 서비스 단위 70/80/90/95/100 경고 알림이 생성되지 않음(수집과 경고 엔진 연결 누락 가능)', file: 'portal/mail/service.mjs (collectMailUsage) ↔ portal/usage/warnings.mjs (evaluate)', route: 'GET /api/mail/services/:id/usage?refresh=1 → POST /api/usage/evaluate', expected: '저장 공간 95%·월간 100% 수집 뒤 알림 센터에 경고/제한 알림(필수)', actual: { notifications: notif, limits: lim.limits, evaluate: warn.evaluate }, repro: '가짜 AriMail 사용량을 95%/100% 로 두고 refresh=1 수집 → /api/notifications 에 usage 알림 없음', flows: [17] });
report.flow(17, notif.count ? 'verified_local' : 'partial', { steps: s.list, note: '메일 저장 공간 95%·월간 통수 100% 를 수집해 구간·제한 사유·KST 초기화·알림을 확인(웹 디스크/아웃바운드는 배포가 있어야 수집)', evidence: { warn, notif, lim } });
} catch (e) { report.flow(17, 'partial', { steps: s.list, note: e.message }); } }
// ---- 33 전환 점검표
{ const s = steps(); try {
const ck = await s.run('사이트 대표→독립 전환 점검표(주소·메일함·별칭·전달·비밀번호·DNS·옵션·작업)', async () => { const r = await alice.get(`/api/mail/services/${svcId}/convert`); return { canConvert: r.json.checklist?.canConvert, items: (r.json.checklist?.items || []).map((i) => i.id + ':' + (i.ok ? 'ok' : 'x')) }; });
const tr = await s.run('DNS 미확인 상태에서 전환 요청은 차단', async () => { const r = await alice.post(`/api/mail/services/${svcId}/convert`, { confirm: 'aw-e2e.localhost' }); return { status: r.status, error: r.json?.error }; });
report.flow(33, 'partial', { steps: s.list, note: '전환 점검표 8항목과 미충족 시 차단 확인. 전후 실제 송수신 확인·DNS 유지 검증은 실 DNS/AriMail 필요(단위 테스트 test/mail.test.mjs 에서 가짜로 전환 완료·되돌리기 확인)', evidence: { ck, tr } });
} catch (e) { report.flow(33, 'partial', { steps: s.list, note: e.message }); } }
}
+35
View File
@@ -0,0 +1,35 @@
/** 흐름 13·14·15: 백업 옵션 구매(사이트별)·첫 백업(실제 HAL→Agent 저장소 분리), 복원(승인·보호 시점·사용자 확인), 서버 이전 경계값·요청. */
import { steps } from '../report.mjs';
import { waitFor } from '../util.mjs';
import { orderAndApprove } from './20-commerce.mjs';
export const BUDGET_MS = 90000;
async function waitJob(client, path, ms = 60000) { return waitFor(async () => { const r = await client.get(path); const j = r.json?.job || r.json?.restore?.job || null; return j && !['queued', 'running'].includes(j.status) ? j : null; }, { timeoutMs: ms, intervalMs: 1000 }); }
export async function run({ env, report, state }) {
const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [13, 14, 15]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; }
const cur = (await alice.get(`/api/sites/${site.id}`)).json.site; const deployed = !!cur.current_version;
{ const s = steps(); try {
await s.run('백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리', async () => orderAndApprove(env, alice, { productId: 'team', items: [{ optionId: 'backup', qty: 1, targetId: site.id }], key: 'e2e-backup-opt' }).then((r) => ({ granted: r.approve.granted, orderStatus: r.approve.order?.status })));
const el = await s.run('백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서)', async () => { const r = await alice.get(`/api/backup/sites/${site.id}`); return { eligible: r.json.purchase?.eligible, reasons: r.json.eligibility?.reasons, deferred: r.json.purchase?.deferred, policy: r.json.purchase?.policy ? Object.keys(r.json.purchase.policy) : null, status: r.status }; });
const opt = await s.run('옵션 활성화(확인 필수) → 첫 백업 예정 시각', async () => { const r = await alice.post(`/api/backup/sites/${site.id}/option`, { confirm: true }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); return { status: r.json.option.status, firstDue: r.json.option.first_backup_due_at, nextRun: r.json.option.next_run_at }; });
const run1 = await s.run('지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증', async () => { const r = await alice.post(`/api/backup/sites/${site.id}/run`); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const j = await waitFor(async () => { const x = await alice.get(`/api/backup/sites/${site.id}`); const b = (x.json.backups || [])[0]; const jj = x.json.option?.lastJob || x.json.lastJob || null; return (b && b.status !== 'running') || (jj && !['queued', 'running'].includes(jj.status)) ? { backup: b, job: jj, option: x.json.option } : null; }, { timeoutMs: 25000, intervalMs: 1500 }); const bj = (await alice.get(`/api/backup/sites/${site.id}`)).json; return { timedOut: !!j?.timedOut, optionStatus: bj.option?.status, lastJob: bj.option?.lastJob || bj.lastJob || null, siteServer: (await alice.get(`/api/sites/${site.id}`)).json.site?.server_id || null, backup: j?.backup ? { status: j.backup.status, verify: j.backup.verify_state, tier: j.backup.tier, location: j.backup.location ? j.backup.location.split(':')[0] : null } : null, job: j?.job ? { status: j.job.status, error: j.job.error } : null, optionFailures: j?.option?.consecutive_failures }; });
state.backupOk = run1.backup && run1.backup.status === 'done';
report.flow(13, state.backupOk ? 'verified_local' : 'partial', { steps: s.list, note: state.backupOk ? '옵션 구매·활성화·첫 백업(HAL→운영 Agent 생성→저장 Agent 보관→검증)' : (deployed ? '옵션 구매·활성화까지 확인, 백업 실행 결과는 증거 참조' : '옵션 구매·활성화까지 확인. 실제 백업 생성은 배포된 사이트 파일이 있어야 함(흐름 6 결함에 막힘)'), blockedBy: state.backupOk || deployed ? null : 6, evidence: { el, opt, run1 } });
} catch (e) { report.flow(13, 'partial', { steps: s.list, note: e.message, blockedBy: deployed ? null : 6 }); } }
{ const s = steps(); try {
const b = (await alice.get(`/api/backup/sites/${site.id}`)).json.backups?.find((x) => x.status === 'done');
if (!b) throw new Error('완료된 백업이 없어 복원을 요청할 수 없음');
const info = await s.run('복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식)', async () => (await alice.get(`/api/backup/backups/${b.id}`)).json.restoreInfo);
const rq = await s.run('복원 요청 → 운영자 승인 대기 · 중복 요청 없음', async () => { const r = await alice.post(`/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); const r2 = await alice.post(`/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); return { id: r.json.restore?.id, status: r.json.restore?.status, dup: r2.json?.created, finalConfirmation: r.json.finalConfirmation }; });
const ap = await s.run('운영자 최종 확인(그룹·사이트·백업 시각) 불일치 거부 → 일치 승인(멱등)', async () => { const bad = await env.relay('POST', `/backup/restores/${rq.id}/approve`, { operator: 'e2e', confirm: { groupId: 'wrong', siteId: site.id, backupAt: b.created_at } }); const ok = await env.relay('POST', `/backup/restores/${rq.id}/approve`, { operator: 'e2e', confirm: { groupId: site.group_id, siteId: site.id, backupAt: b.created_at } }); return { bad: bad.json?.error, ok: ok.status, created: ok.json?.created }; });
const j = await s.run('9단계 작업 진행 → 사용자 확인 필요(보호 시점·되돌리기 가능)', async () => { const jj = await waitJob(alice, `/api/backup/restores/${rq.id}`, 60000); return { status: jj?.status, steps: (jj?.steps || []).map((x) => x.id + ':' + x.state), actions: jj?.actions?.map((a) => a.id), error: jj?.error }; });
if (j.status === 'needs_user') await s.run('사용자 확인 → 완료', async () => { const r = await alice.post(`/api/backup/restores/${rq.id}/decision`, { action: 'confirm' }); const jj = await waitJob(alice, `/api/backup/restores/${rq.id}`, 30000); return { status: jj?.status }; });
report.flow(14, j.status === 'done' || j.status === 'needs_user' ? 'partial' : 'partial', { steps: s.list, note: '복원 요청·운영자 최종 확인(불일치 거부·승인 멱등)·단계 진행까지. 외부 사이트 확인은 공인 서버 필요', evidence: { info, rq, ap, j } });
} catch (e) { report.flow(14, 'partial', { steps: s.list, note: e.message, blockedBy: state.backupOk ? null : 13 }); } }
{ const s = steps(); try {
const b = await s.run('이전 화면 경계값: 취소 가능 마지막 단계·쓰기 제한·목표 중단 시간·되돌림 기간·이전 위치 보존·비용(플랫폼 사유 0원)', async () => { const r = await alice.get(`/api/relocation/sites/${site.id}`); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); return { auto: r.json.auto, boundaries: r.json.boundaries ? { lastCancel: r.json.boundaries.customer?.lastCancelLabel, revertHours: r.json.boundaries.customer?.revertHours, cost: r.json.boundaries.customer?.cost, platformCost: r.json.boundaries.platform?.cost } : null, servers: r.json.servers?.length }; });
const req = await s.run('고객 요청(유료·승인 대기) 또는 조건 미충족 사유', async () => { const r = await alice.post(`/api/relocation/sites/${site.id}`, { confirm: true, reason: 'e2e' }); return { status: r.status, error: r.json?.error, relocation: r.json?.relocation ? { status: r.json.relocation.status, kind: r.json.relocation.kind, cost: r.json.relocation.cost } : null, message: r.json?.message }; });
const cur2 = req.relocation ? (await alice.get(`/api/relocation/sites/${site.id}`)).json : null; const relId = cur2?.current?.id || cur2?.relocation?.id || cur2?.relocations?.[0]?.id || null; state.relocationId = relId;
if (relId) await s.run('전환 전 고객 취소 가능(요청 취소 → 소유권 이전 등 다른 작업 차단 해제)', async () => { const r = await alice.post(`/api/relocation/${relId}/cancel`); return { status: r.status, rstatus: r.json?.relocation?.status, error: r.json?.error }; }, { soft: true });
report.flow(15, 'partial', { steps: s.list, note: '이전 경계값·요청 규칙 확인. 실제 두 Agent 간 사전 복사·전환·외부 확인은 배포된 사이트(흐름 6)와 공인 확인 주소가 필요', blockedBy: deployed ? null : 6, evidence: { b, req } });
} catch (e) { report.flow(15, 'partial', { steps: s.list, note: e.message }); } }
}
+47
View File
@@ -0,0 +1,47 @@
/** 흐름 16·28: 외부 배포 사이트 — 동의(버전 기록) → Agent 토큰(1회) → 실제 외부 Agent 프로세스 등록·연결 → 배포 → 재배포 → 제거 → 실패 정리. */
import { steps } from '../report.mjs';
import { waitFor, sleep } from '../util.mjs';
import { startAgent } from '../boot.mjs';
import { createSiteWithVersion, waitSiteJob } from './30-sites.mjs';
export const BUDGET_MS = 120000;
export async function run({ env, report, state }) {
const alice = state.alice; if (!alice) { report.flow(16, 'partial', { note: '선행 흐름 없음', blockedBy: 1 }); report.flow(28, 'partial', { note: '선행 흐름 없음', blockedBy: 1 }); return; }
const s = steps(); let site = null; let tok = null; let agent = null;
try {
site = await s.run('외부 배포 사이트 생성(초안) + 저장본', async () => createSiteWithVersion(env, alice, 'E2E 외부 사이트', { deployMode: 'external' }));
const mode = await s.run('배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전', async () => { const r = await alice.get(`/api/sites/${site.id}/mode`); return { notProvided: r.json.external?.notProvided, consentVersion: r.json.consent?.version, agreed: r.json.consent?.agreed, cost: r.json.external?.cost }; });
await s.run('동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전)', async () => { const a = await alice.post(`/api/sites/${site.id}/external`, {}); const c = await alice.post(`/api/sites/${site.id}/consent`, { version: mode.consentVersion, agree: true }); return { before: a.json?.error, consent: c.status }; });
tok = await s.run('외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음)', async () => { const r = await alice.post(`/api/sites/${site.id}/external`, { label: '고객 서버' }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)} | portal: ${env.portal.proc.lines.filter((l) => /external 500/.test(l)).slice(-1).join('').slice(0, 300)}`); return { token: r.json.token, install: r.json.installCommand?.replace(/--token \S+/, '--token …'), expiresAt: r.json.expiresAt, status: r.json.agent?.status }; }, { soft: true });
if (!tok) {
report.bug({ 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: env.portal.proc.lines.filter((l) => /external 500/.test(l)).slice(-1).join('').slice(0, 300) || 'ERR_SQLITE_ERROR', repro: '외부 배포 사이트 → 동의 → POST /api/sites/:id/external → 500 "NOT NULL constraint failed: external_agents.hal_agent_id"', flows: [16, 28] });
// 포털 결함을 우회하지 않고, HAL·Agent 쪽 외부 Agent 경로만 별도로 확인한다(흐름 28 의 Agent 연결·배포·제거·실패 정리)
const halTok = await s.run('[HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키)', async () => { const r = await env.halSigned('POST', '/portal/v1/external-agents/token', { groupId: site.group_id, siteId: site.id, label: 'e2e' }); if (r.status !== 200) throw new Error(r.text?.slice(0, 200)); return { token: r.json.token, expiresAt: r.json.expiresAt }; }, { soft: true });
if (halTok) {
agent = await s.run('[HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected', async () => { const a = await startAgent(env, { name: 'ext-1', role: 'web', token: halTok.token, external: true, siteId: site.id }); const st = await waitFor(async () => { const r = await env.halSigned('GET', `/portal/v1/external-agents/by-site/${site.id}`); return r.json?.agent?.status === 'connected' ? r.json.agent : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { agentId: a.agentId, status: st?.status, siteId: st?.siteId }; }, { soft: true }) && env.agents.at(-1);
const run = async (kind, payload) => { const r = await env.halSigned('POST', '/portal/v1/jobs', { kind, target: agent.agentId, payload, idempotencyKey: `e2e:${kind}:${Date.now()}` }); const j = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${r.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { status: j?.status, error: j?.error, errorCode: j?.errorCode, result: j?.result && { version: j.result.version, leftovers: j.result.leftovers, removed: j.result.removed } }; };
if (agent) {
const { mkdirSync, writeFileSync, existsSync } = await import('node:fs'); const bdir = `${agent.dir}/bundle-v1`; mkdirSync(bdir, { recursive: true }); writeFileSync(`${bdir}/index.html`, '<h1>ext marker-ext-1</h1>');
const d1 = await s.run('[HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성', async () => ({ ...(await run('site.deploy', { siteId: site.id, versionRef: 'v1', bundlePath: bdir, domains: [], expectMarker: 'marker-ext-1' })), layout: existsSync(`${agent.roots.sites}/${site.id}/current`) }), { soft: true });
const d2 = await s.run('[HAL 직접] 재배포(v2) → 이전 버전 기록', async () => run('site.deploy', { siteId: site.id, versionRef: 'v2', bundlePath: bdir, domains: [], expectMarker: 'marker-ext-1' }), { soft: true });
const d3 = await s.run('[HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고', async () => run('site.deploy', { siteId: site.id, versionRef: 'v3', bundlePath: bdir, domains: [], expectMarker: 'no-such-marker' }), { soft: true });
const d4 = await s.run('[HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부', async () => { const a = await env.halSigned('POST', '/portal/v1/jobs', { kind: 'site.deploy', target: agent.agentId, payload: { siteId: 'other', bundlePath: bdir }, idempotencyKey: 'e2e-mismatch' }); const ja = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${a.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 10000, intervalMs: 1000 }); const b = await env.halSigned('POST', '/portal/v1/jobs', { kind: 'backup.create', target: agent.agentId, payload: { siteId: site.id }, idempotencyKey: 'e2e-kind' }); const jb = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${b.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 10000, intervalMs: 1000 }); return { mismatch: ja?.errorCode, kind: jb?.errorCode }; }, { soft: true });
const d5 = await s.run('[HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거', async () => ({ ...(await run('site.remove', { siteId: site.id, keepData: false })), layoutAfter: existsSync(`${agent.roots.sites}/${site.id}/current`) }), { soft: true });
const okAll = d1?.status === 'done' && d2?.status === 'done' && d3?.status === 'failed' && d5?.status === 'done';
report.flow(28, okAll ? 'partial' : 'partial', { steps: s.list, note: 'HAL·외부 Agent 경로(등록·연결·배포·재배포·실패 시 이전 버전 유지와 남은 파일·다른 사이트/허용되지 않는 종류 거부·제거)는 확인. 포털 화면 경로는 토큰 발급 결함으로 막힘', evidence: { halTok: { expiresAt: halTok.expiresAt }, d1, d2, d3, d4, d5 } });
}
}
report.flow(16, 'failed', { steps: s.list, note: '동의(버전)·토큰 발급 거부/허용 규칙까지 확인했으나 토큰 발급 API 가 DB 제약으로 500 — 결함 항목 참조', evidence: { mode } }); return;
}
agent = await s.run('고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속)', async () => { const a = await startAgent(env, { name: 'ext-1', role: 'web', token: tok.token, external: true, siteId: site.id }); return { agentId: a.agentId }; }) && env.agents.at(-1);
const conn = await s.run('포털 연결 확인(외부 Agent 상태 connected)', async () => { const r = await waitFor(async () => { const x = await alice.get(`/api/sites/${site.id}/external`); return x.json?.agent?.status === 'connected' ? x.json : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { status: r?.agent?.status, stale: r?.agent?.stale, staleReason: r?.agent?.staleReason, halAgentId: r?.agent?.hal_agent_id }; });
if (conn.status !== 'connected') { report.bug({ title: '외부 Agent 가 HAL 에 등록되어도 포털 화면은 연결 상태를 알지 못함', file: 'portal/sites/service.mjs (createExternalAgent/refreshExternalAgent)', route: 'GET /api/sites/:id/external', expected: 'Agent 등록 뒤 connected', actual: conn, repro: '외부 토큰 발급 → agent register --external --site <id> → GET /api/sites/:id/external. HAL 토큰 발급 응답의 agentId 가 null 이라 포털 external_agents.hal_agent_id 가 비어 getExternalAgent(null) 로 조회됨 — by-site 조회(getExternalAgentBySite)로 보완 필요', flows: [16, 28] }); }
const pc = await s.run('배포 전 확인(외부: 관리형 용량 확인 없음)', async () => { const r = await alice.get(`/api/sites/${site.id}/precheck`); return { ok: r.json.precheck?.ok, blocks: (r.json.precheck?.items || []).filter((i) => i.state === 'block').map((i) => i.id) }; });
const dep = await s.run('외부 배포 → 외부 Agent 가 site.deploy 수행', async () => { const r = await alice.post(`/api/sites/${site.id}/deploy`, {}); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const j = await waitSiteJob(alice, site.id, r.json.job.id, 60000); return { status: j?.status, error: j?.error, errorCode: j?.errorCode, steps: (j?.steps || []).map((x) => x.id + ':' + x.state) }; }, { soft: true });
const ov = await s.run('외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과', async () => { const r = await alice.get(`/api/sites/${site.id}/overview`); const o = r.json.overview; return { kind: o.kind, disk: o.disk, health: o.health, notProvided: o.notProvided, agent: o.agent?.status, lastDeployOk: o.lastDeployResult?.ok }; });
const rm = await s.run('배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리)', async () => { const r = await alice.post(`/api/sites/${site.id}/external/remove`); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const j = await waitSiteJob(alice, site.id, r.json.job?.id || r.json.id, 40000); return { status: j?.status, error: j?.error }; }, { soft: true });
const rv = await s.run('Agent 연결 해제(revoke) → 상태 없음', async () => { const r = await alice.del(`/api/sites/${site.id}/external`); const x = await alice.get(`/api/sites/${site.id}/external`); return { status: r.status, agent: x.json?.agent?.status || null }; }, { soft: true });
const deployOk = dep && dep.status === 'done';
report.flow(16, deployOk ? 'verified_local' : 'partial', { steps: s.list, note: deployOk ? '동의→토큰→실제 외부 Agent 등록·연결→배포→개요(제공되지 않음)→제거' : '동의(버전)·토큰 1회·실제 외부 Agent 등록·개요의 제공되지 않음 표기까지 확인. 배포 결과는 증거 참조', evidence: { mode, tok: { install: tok?.install, expiresAt: tok?.expiresAt }, conn, pc, dep, ov } });
report.flow(28, deployOk && rm && rm.status === 'done' ? 'verified_local' : 'partial', { steps: s.list, note: '외부 Agent 연결·제거·해제 API 확인. 재배포·실패 정리는 배포 성공이 필요(증거 참조)', evidence: { conn, dep, rm, rv } });
} catch (e) { report.flow(16, 'failed', { steps: s.list, note: e.message }); report.flow(28, 'partial', { steps: s.list, note: e.message }); report.bug({ title: '외부 배포 흐름 실패', file: 'portal/sites/api.mjs', route: '/api/sites/:id/external', expected: '동의→토큰→연결→배포', actual: e.message, repro: '60-external.mjs', flows: [16, 28] }); }
}
+24
View File
@@ -0,0 +1,24 @@
/** 흐름 18(+13.2/13.3 보조): 상품 축소 미리보기 → 보류(정리 목록) → 배정 축소 정리 → 재검증 → 축소 주문·승인 → 권리 교체. 배정 변경 동시성도 확인. */
import { steps } from '../report.mjs';
import { signupVerified } from './10-identity.mjs';
import { orderAndApprove } from './20-commerce.mjs';
import { cancelDomainJob } from './30-sites.mjs';
export const BUDGET_MS = 40000;
export async function run({ env, report, state }) {
const alice = state.alice; if (!alice || !state.mailServiceId) { report.flow(18, 'partial', { note: '선행 흐름 없음', blockedBy: 10 }); return; }
const s = steps();
try {
const basic = (await alice.get('/api/commerce/products')).json.products.find((p) => p.productId === 'basic');
const alloc = await s.run('배정 화면 수치(구매·내 배정·다른 서비스·예약·미배정·변경 후 최대) + 마지막 단위 동시 요청', async () => { const sub = `mail_service:${state.mailServiceId}`; const r = await alice.get(`/api/usage/allocate/${sub}`); const mb = r.json.resources?.find((x) => x.resource === 'mailboxes'); const [a, b] = await Promise.all([alice.post(`/api/usage/allocate/${sub}`, { resource: 'mailboxes', amount: mb.purchased }), alice.post(`/api/usage/allocate/${sub}`, { resource: 'mailboxes', amount: mb.purchased })]); return { purchased: mb?.purchased, mine: mb?.mine, others: mb?.others, unallocated: mb?.unallocated, race: [a.status, b.status].sort(), mineAfter: (await alice.get(`/api/usage/allocate/${sub}`)).json.resources?.find((x) => x.resource === 'mailboxes')?.mine }; });
// 축소는 별도 고객(캐럴: 팀 상품 + 대표 메일 6개 배정)으로 — 앨리스 그룹의 백업·독립 메일 보존 상태와 섞이지 않게
const carol = await signupVerified(env, 'carol@e2e.localhost', '캐럴'); await orderAndApprove(env, carol, { productId: 'team', key: 'e2e-carol-team' });
const csite = (await carol.post('/api/sites', { name: '캐럴 사이트', projectMode: 'none' })).json.site; const cd = await carol.post('/api/domains', { name: 'carol-e2e.localhost', purpose: 'web', siteId: csite.id, startCheck: false }); if (cd.json?.domain?.id) await cancelDomainJob(carol, cd.json.domain.id);
const cm = await carol.post('/api/mail/services', { kind: 'site_bound', siteId: csite.id, domain: 'carol-e2e.localhost', alloc: { mailboxes: 6, storageBytes: 1024 ** 3, monthlyMessages: 100 } }); if (cm.status !== 200) throw new Error('캐럴 메일 생성 실패 ' + cm.text.slice(0, 200));
const pv = await s.run('축소 미리보기(팀→베이직): 줄여야 할 항목(메일함 6→5) 구체 표시', async () => { const r = await carol.post('/api/commerce/downsize/preview', { productVersionId: basic.id }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); return { fits: r.json.fits, shortages: (r.json.shortages || []).map((x) => x.resource + ':' + x.reduceBy) }; });
const hold = await s.run('축소 요청 → 보류(사용 중 권리 즉시 회수 없음)', async () => { const r = await carol.post('/api/commerce/downsize/request', { productVersionId: basic.id, acceptTerms: true }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); const ent = (await carol.get('/api/commerce/billing')).json.entitlements?.find((e) => e.resource === 'sites'); return { fits: r.json.fits, hold: r.json.hold?.status, sitesStillPurchased: ent?.purchased }; });
const clean = await s.run('정리: 메일함 배정 6→5 축소 → 재검증 적합 → 축소 주문 생성', async () => { const a = await carol.post(`/api/usage/allocate/mail_service:${cm.json.service.id}`, { resource: 'mailboxes', amount: 5 }); const r = await carol.post('/api/commerce/downsize/revalidate'); return { allocate: a.status, allocateError: a.json?.error, fits: r.json.fits, shortages: (r.json.shortages || []).map((x) => x.resource + ':' + x.reduceBy), order: r.json.order?.status }; });
let applied = null;
if (clean.fits && clean.order) { applied = await s.run('축소 주문 입금 확인·승인 → 권리 교체(베이직)', async () => { const o = (await carol.get('/api/commerce/orders')).json.orders.find((x) => x.kind === 'downsize'); await env.relay('POST', `/commerce/orders/${o.id}/paid`, { ref: 'e2e' }); const ap = await env.relay('POST', `/commerce/orders/${o.id}/approve`, {}); const b = await carol.get('/api/commerce/billing'); return { approve: ap.status, sites: b.json.entitlements?.find((e) => e.resource === 'sites')?.purchased, mailboxes: b.json.entitlements?.find((e) => e.resource === 'mailboxes')?.purchased, product: b.json.subscriptions?.[0]?.product_name || b.json.subscriptions?.[0]?.product_version_id }; }); state.downsized = true; }
report.flow(18, applied ? 'verified_local' : 'partial', { steps: s.list, note: applied ? '축소 미리보기→보류→정리(배정 축소·독립 메일 해지)→재검증 적합→축소 주문·승인→권리 교체' : '축소 보류와 정리 목록까지 확인(재검증 결과는 증거 참조)', evidence: { alloc, pv, hold, clean, applied } });
} catch (e) { report.flow(18, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '상품 축소 보류·재검증 흐름 실패', file: 'portal/commerce/api.mjs', route: '/api/commerce/downsize/*', expected: '보류→정리→적용', actual: e.message, repro: '65-usage.mjs', flows: [18] }); }
}
+46
View File
@@ -0,0 +1,46 @@
/** 흐름 19·20·21·32·34: 사이트 수 반환 점검표·대표 메일 전환 조건, 소유권 이전(요청→수락→보류→재확인→심사·승인→원자적 완료), 중지·해지·보존 표시, Source 옵션 만료 읽기 전용. */
import { steps } from '../report.mjs';
import { orderAndApprove } from './20-commerce.mjs';
export const BUDGET_MS = 60000;
export async function run({ env, report, state }) {
const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [19, 20, 21, 32, 34]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; }
const cur = (await alice.get(`/api/sites/${site.id}`)).json.site; const live = ['live', 'needs_domain'].includes(cur.lifecycle);
// ---- 32 Source 옵션
{ const s = steps(); try {
await s.run('Source 옵션 주문(대상 사이트)·승인 → source 권리', async () => orderAndApprove(env, alice, { productId: 'team', items: [{ optionId: 'source', qty: 1, targetId: site.id }], key: 'e2e-source-opt' }).then((r) => ({ granted: r.approve.granted })), { soft: true });
const act = await s.run('Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보', async () => { const a = await alice.post(`/api/source/${site.id}/activate`); const r = await alice.get(`/api/source/${site.id}`); return { activate: a.status, error: a.json?.error, status: r.json.option?.status, provider: r.json.source?.provider, changes: r.json.source?.changes?.length, clone: !!r.json.cloneInfo }; });
const ex = await s.run('export(저장본 내려받기)', async () => { const r = await alice.get(`/api/source/${site.id}/export`); return { status: r.status, type: r.headers['content-type'] }; });
const un = await s.run('옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단', async () => { const r = await alice.post(`/api/source/${site.id}/unlink`); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); return { status: r.json.option?.status, deleteAfter: r.json.option?.delete_after, readonlyUntil: r.json.option?.readonly_until }; });
const sweep = await s.run('삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음', async () => { const r = await env.relay('POST', '/source/sweep', {}); const x = await alice.get(`/api/source/${site.id}`); return { sweep: r.status, statusAfter: x.json.option?.status }; });
report.flow(32, 'partial', { steps: s.list, note: '활성화·export·해제→읽기 전용(삭제 예정일)까지 확인. 30일 뒤 삭제 완료는 시간 경과가 필요(단위 테스트 test/sites.test.mjs 에서 sweep 로 확인)', evidence: { act, ex, un, sweep } });
} catch (e) { report.flow(32, 'partial', { steps: s.list, note: e.message }); } }
// ---- 21·34 생명주기 표시
{ const s = steps(); try {
const v = await s.run('§23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림', async () => { const r = await alice.get(`/api/lifecycle/sites/${site.id}`); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const view = r.json.view || {}; return { state: view.site?.label, siteCount: view.siteCountConsumed, publicAccess: view.publicAccess, retention: view.retentionPolicy, pauseImpactKeys: Object.keys(r.json.pauseImpact || {}), cancelImpactKeys: Object.keys(r.json.cancelImpact || {}), steps14: r.json.siteCount?.steps?.length }; });
const pause = await s.run('일시 중지(확인 필수) 또는 현재 상태에서 거부 사유', async () => { const r0 = await alice.post(`/api/lifecycle/sites/${site.id}/pause`, {}); const r = await alice.post(`/api/lifecycle/sites/${site.id}/pause`, { confirm: true }); return { noConfirm: r0.json?.error, status: r.status, error: r.json?.error, lifecycle: r.json?.site?.lifecycle }; });
if (pause.status === 200) await s.run('재개', async () => { const r = await alice.post(`/api/lifecycle/sites/${site.id}/resume`); return { status: r.status, lifecycle: r.json?.site?.lifecycle }; }, { soft: true });
const mailLc = await s.run('메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공)', async () => { if (!state.standaloneMailId) return { skipped: true }; const r = await alice.get(`/api/lifecycle/mail/${state.standaloneMailId}`); return { status: r.status, sendReceive: r.json.view?.sendReceive, retentionUntil: !!r.json.view?.retentionUntil, deleteAfter: !!r.json.view?.deleteAfter, backupNote: r.json.view?.backupNote }; });
report.flow(21, 'partial', { steps: s.list, note: live ? '중지·재개·생명주기 화면 확인' : '생명주기 화면·중지/해지 영향 안내·보존 정책 표시 확인. 운영 중 사이트의 실제 중지·해지는 배포 성공(흐름 6) 필요', blockedBy: live ? null : 6, evidence: { v, pause, mailLc } });
report.flow(34, 'partial', { steps: s.list, note: '해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인)', blockedBy: live ? null : 6, evidence: { v, mailLc } });
} catch (e) { report.flow(21, 'partial', { steps: s.list, note: e.message }); report.flow(34, 'partial', { steps: s.list, note: e.message }); } }
// ---- 19 사이트 수 반환 점검표
{ const s = steps(); try {
const ck = await s.run('§14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션)', async () => { const r = await alice.get(`/api/lifecycle/sites/${site.id}/site-count-return`); return { ok: r.json.checklist?.ok, blocking: r.json.checklist?.blocking, steps: r.json.checklist?.steps?.length }; });
const keep = await s.run('메일 유지 선택: 독립 옵션 부족이면 반환하지 않음 / 종료 선택 규칙', async () => { const r = await alice.post(`/api/lifecycle/sites/${site.id}/site-count-return`, { mailChoice: 'keep', acknowledge: true, confirm: true }); return { status: r.status, error: r.json?.error, detail: r.json?.detail ? Object.keys(r.json.detail) : null }; });
report.flow(19, 'partial', { steps: s.list, note: '반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인)', blockedBy: live ? null : 6, evidence: { ck, keep } });
} catch (e) { report.flow(19, 'partial', { steps: s.list, note: e.message }); } }
// ---- 20 소유권 이전 (앨리스 팀 → 밥 개인)
{ const s = steps(); try {
const bob = state.bob; if (!bob) throw new Error('밥 없음'); await bob.post('/api/groups/switch', { groupId: state.bobPersonal || (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal').id });
const pv = await s.run('이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목)', async () => { const r = await alice.post('/api/transfer/preview', { siteIds: [site.id] }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); return { sites: r.json.scope?.sites?.length, domains: r.json.scope?.sites?.[0]?.domains?.length, siteMail: r.json.scope?.sites?.[0]?.siteMail?.length, needed: r.json.scope?.needed, excluded: r.json.scope?.excluded?.length, jobs: r.json.scope?.jobs }; });
const tsite = (await alice.post('/api/sites', { name: '이전 대상 사이트', projectMode: 'none' })).json.site;
const rq = await s.run('요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림', async () => { const r = await alice.post('/api/transfer', { toEmail: 'bob@e2e.localhost', siteIds: [tsite.id], confirm: true }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const n = await bob.get('/api/notifications'); return { id: r.json.transfer.id, status: r.json.transfer.status, expiresAt: r.json.transfer.expires_at, bobNotified: (n.json.notifications || n.json.items || []).some((x) => /이전|소유권/.test(x.title)) }; });
await s.run('(정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개)', async () => { const e = await alice.post(`/api/backup/sites/${site.id}/option/end`, { confirm: true }); const a = state.mailServiceId ? await alice.post(`/api/usage/allocate/mail_service:${state.mailServiceId}`, { resource: 'mailboxes', amount: 5 }) : null; return { endBackup: e.status, endError: e.json?.error, alloc: a?.status, allocError: a?.json?.error }; }, { soft: true });
const acc = await s.run('받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내)', async () => { const r = await bob.post(`/api/transfer/${rq.id}/accept`, { confirm: true }); return { status: r.status, tstatus: r.json?.transfer?.status, hold: r.json?.transfer?.hold }; });
if (acc.tstatus === 'on_hold') await s.run('받는 쪽 정리(실패 배포의 예약 반환) → 재확인 → 심사 단계', async () => { for (const bs of state.bobSites || []) await bob.post(`/api/sites/${bs.id}/cleanup`); const r = await bob.post(`/api/transfer/${rq.id}/recheck`); return { status: r.json?.transfer?.status, hold: r.json?.transfer?.hold }; }, { soft: true });
const ap = await s.run('운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각', async () => { const bad = await env.relay('POST', `/transfer/${rq.id}/approve`, { operator: 'e2e', review: {}, confirm: { fromGroupId: tsite.group_id, toGroupId: 'x' } }); const to = (await alice.get(`/api/transfer/${rq.id}`)).json.transfer?.to_group_id; const ok = await env.relay('POST', `/transfer/${rq.id}/approve`, { operator: 'e2e', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: tsite.group_id, toGroupId: to } }); return { bad: bad.json?.error, ok: ok.status, status: ok.json?.transfer?.status, cutover: ok.json?.transfer?.billing_cutover_at, error: ok.json?.error }; });
const after = await s.run('완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사', async () => { const a = await alice.get(`/api/sites/${tsite.id}`); const b = await bob.get(`/api/sites/${tsite.id}`); const au = await bob.get('/api/audit?action=ownership.complete'); return { aliceSees: a.status, bobSees: b.status, bobUsageSites: (await bob.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.allocated, audit: (au.json.items || [])[0]?.immutable }; });
report.flow(20, ap.status === 'completed' ? 'verified_local' : 'partial', { steps: s.list, note: ap.status === 'completed' ? '미리보기→요청·알림→수락(보류·재확인)→심사·최종 확인→원자적 완료→접근 전환·감사' : '요청·수락·보류·심사 단계 확인(완료 여부는 증거 참조)', evidence: { pv, rq, acc, ap, after } });
} catch (e) { report.flow(20, 'partial', { steps: s.list, note: e.message }); } }
}
+67
View File
@@ -0,0 +1,67 @@
/** 흐름 22·25·26·29·30·31: HAL 우선 배포 순서·모듈 배포(대상별 결과)·재해복구 훈련·HAL/포털 재시작 뒤 상태 유지·HAL 장애 중 포털 표시·긴급 복구 거부/정상 경로. */
import { steps } from '../report.mjs';
import { waitFor, sleep } from '../util.mjs';
export const BUDGET_MS = 120000;
async function waitOpsJob(env, id, ms = 40000) { return waitFor(async () => { const r = await env.ops.get(`/ops/api/jobs/${id}`); const j = r.json?.job; return j && !['queued', 'running'].includes(j.status) && !(r.json.children?.children || []).some((c) => ['queued', 'running'].includes(c.status)) ? r.json : null; }, { timeoutMs: ms, intervalMs: 800 }); }
export async function run({ env, report, state }) {
const ctl = env.servers.control; const web = env.servers.web; const bk = env.servers.backup;
/** 진단: HAL 작업 엔진에 멈춘 작업이 있는지(앞선 흐름이 남긴 큐·실행 중 작업) */
const stuck = async () => { const r = await env.ops.get('/ops/api/jobs?status=queued,running&limit=20'); return (r.json?.jobs || []).map((j) => ({ id: j.id, kind: j.kind, status: j.status, attempt: j.attempt, step: j.currentStep?.id, detail: (j.currentStep?.detail || j.waitReason || '').slice(0, 120), updatedAt: j.updatedAt })); };
const stuckBefore = await stuck();
// 앞 흐름이 남긴 queued HAL 작업(도메인 확인 재제출 결함)을 운영자 취소로 비워 모듈 배포가 큐에서 차례를 받게 한다. 실행 중(running) 작업은 끝날 때까지 기다린다(엔진이 한 번에 하나만 실행).
for (const j of stuckBefore.filter((x) => x.status === 'queued' && x.kind !== 'module.deploy')) await env.ops.post(`/ops/api/jobs/${j.id}/cancel`, {});
await waitFor(async () => (await stuck()).filter((x) => x.status === 'running').length === 0 ? true : null, { timeoutMs: 45000, intervalMs: 1000 });
report.flow(22, 'partial', { note: '시작 전 진단', evidence: { stuckBefore } });
// ---- 22 HAL 우선 배포
{ const s = steps(); try {
const rel = await s.run('릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서', async () => { const r = await env.ops.get('/ops/api/releases'); const h = await env.ops.get('/api/health'); return { releases: r.json.releases.map((x) => x.version + ':' + x.status), running: h.json.bootstrap, order: r.json.deployOrder }; });
const hf = await s.run('HAL 이 아직 0.1.2 가 아닐 때 다른 모듈 0.1.2 배포 → hal_first 거부', async () => { const r = await env.ops.post('/ops/api/modules/portal/deploy', { version: '0.1.2', serverIds: [ctl.serverId], nonce: 'e2e-hf' }); const j = await waitOpsJob(env, r.json.job.id, 20000); return { status: j?.job?.status, error: j?.job?.errorCode || j?.job?.error }; });
const na = await s.run('승인되지 않은 릴리스는 거부 / 수동 설치 잠김 표시', async () => { const h = await env.ops.get('/api/health'); return { manualInstallLocked: h.json.bootstrap.manualInstallLocked, complete: h.json.bootstrap.complete }; });
const dep = await s.run('HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인)', async () => { const r = await env.ops.post('/ops/api/modules/hal/deploy', { version: '0.1.1', serverIds: [ctl.serverId], nonce: 'e2e-hal' }); const j = await waitOpsJob(env, r.json.job.id, 10000); const x = await env.ops.get(`/ops/api/jobs/${r.json.job.id}`); return { status: x.json?.job?.status, steps: x.json?.job?.steps?.map((y) => y.id + ':' + y.state), error: x.json?.job?.error, children: x.json?.children?.children?.map((c) => c.status + ':' + (c.error || '').slice(0, 120)), agentJobs: x.json?.agentJobs?.map((a) => a.kind + ':' + a.status + ':' + (a.error || '').slice(0, 120)), timedOut: !!j?.timedOut }; });
if (dep.timedOut) report.bug({ title: 'HAL 작업 엔진이 작업을 한 번에 하나씩만 실행(tick 이 execute 를 await)해 느린 작업이 큐 전체를 막고, module.deploy 부모가 자식 완료를 기다리면 자식이 실행될 수 없어 교착', file: 'lib/jobs.mjs (createJobEngine.start/tick) / hal/jobs.mjs (fanout 이 자식 완료를 폴링)', route: 'POST /ops/api/modules/:name/deploy', expected: '엔진은 running 수가 concurrency 미만이면 실행을 분리(detach)해 여러 작업을 동시에 진행해야 한다(부모가 자식을 기다려도 자식이 실행됨)', actual: dep, repro: '(1) Agent 가 수십 초 걸리는 domain.probe 가 queued 로 쌓이면 다른 모든 HAL 작업이 기다린다 (2) 큐가 비어도 모듈 배포는 부모 collect:running + 자식 queued 로 영원히 멈춘다(test/hal.test.mjs 는 pump() 로 tick 을 동시에 돌려 통과)', flows: [22, 30] });
else if (dep.status !== 'done') report.bug({ title: '모듈 설치가 ARIWEB_SRV_ROOT 를 무시하고 절대 경로(/srv/ariweb/<module>)에 쓰려 해 일반 계정·검증 환경에서 실패', file: 'hal/jobs.mjs (module.deploy.target: appDir 기본값) / agent/jobs/module.mjs', route: 'POST /ops/api/modules/:name/deploy', expected: 'Agent 가 payload.appDir 가 없거나 루트 override 가 있으면 roots.srv 아래에 설치', actual: dep, repro: 'ARIWEB_SRV_ROOT 를 둔 Agent 에 module.deploy → mkdir /srv/ariweb/hal 권한 오류', flows: [22, 30] });
const diag = dep.status === 'done' ? null : { stuckNow: await stuck(), halLog: env.hal.proc.lines.slice(-6), ctlAgentLog: ctl.proc.lines.slice(-4) };
report.flow(22, dep.status === 'done' ? 'verified_local' : 'failed', { steps: s.list, note: dep.status === 'done' ? 'HAL 우선 순서·릴리스 승인·모듈 설치' : 'HAL 우선 순서(hal_first 거부)·릴리스 승인·부트스트랩 잠김은 확인. 모듈 배포 작업은 완료되지 않음(진단 증거 참조)', evidence: { rel, hf, na, dep, diag } });
} catch (e) { report.flow(22, 'failed', { steps: s.list, note: e.message }); } }
// ---- 30 일부 서버 실패·대상별 결과
{ const s = steps(); try {
const d = await s.run('agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed)', async () => { const r = await env.ops.post('/ops/api/modules/agent/deploy', { version: '0.1.1', serverIds: [web.serverId, bk.serverId, 'srv_missing_e2e'], nonce: 'e2e-partial' }); const j = await waitOpsJob(env, r.json.job.id, 10000); const x = await env.ops.get(`/ops/api/jobs/${r.json.job.id}`); return { status: x.json?.job?.status, errorCode: x.json?.job?.errorCode, overall: x.json?.children?.overall, children: x.json?.children?.children?.map((c) => (c.subject?.serverId || '') + ':' + c.status + ':' + (c.error || '').slice(0, 80)), timedOut: !!j?.timedOut }; });
const h = await s.run('모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업', async () => { const r = await env.ops.post('/ops/api/modules/agent/health', { serverIds: [web.serverId] }); const j = await waitOpsJob(env, r.json.job.id, 5000); const ov = await env.ops.get('/ops/api/overview'); const rc = await env.ops.post('/ops/api/modules/reconcile', { module: 'agent' }); return { health: j?.job?.status || 'timeout', drift: ov.json.drift?.length, reconcile: rc.status, overviewComponents: ov.json.components?.map((c) => c.name + ':' + c.status) }; });
const rb = await s.run('되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부', async () => { const a = await env.ops.post('/ops/api/modules/agent/rollback', { serverIds: [web.serverId] }); const b = await env.ops.post('/ops/api/modules/agent/rollback', { serverIds: [web.serverId], confirmImpact: true, confirmIdentifiers: ['wrong'] }); return { step1: a.json?.error, step2: b.json?.error }; });
report.flow(30, 'failed', { steps: s.list, note: '대상별 자식 작업 생성·되돌리기 3단계 확인 거부는 확인. 일부 실패 요약·건강 확인·drift 조정은 작업 엔진 교착(흐름 22 결함)으로 완료되지 않음', blockedBy: 22, evidence: { d, h, rb } });
} catch (e) { report.flow(30, 'partial', { steps: s.list, note: e.message }); } }
// ---- 25 재해복구
{ const s = steps(); try {
const r0 = await s.run('설정 전 준비 상태(미설정 항목)', async () => (await env.ops.get('/ops/api/dr')).json.readiness);
const cfg = await s.run('RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정', async () => { const r = await env.ops.put('/ops/api/dr/config', { rto_minutes: 60, rpo_minutes: 30, snapshot_interval_minutes: 60, retention_count: 3, external_store: `${env.base}/dr-external` }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); return r.json.config; });
const snap = await s.run('복구 사본 생성·검증·외부 보관(vault.key 제외)', async () => { const r = await env.ops.post('/ops/api/dr/snapshots'); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); return { verify: r.json.snapshot.verify_state, external: !!r.json.snapshot.external_copied_at, sha: !!r.json.snapshot.sha256 }; });
const rs = await s.run('복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료', async () => { const sid = (await env.ops.get('/ops/api/dr')).json.snapshots[0].id; const rq = await env.ops.post('/ops/api/dr/restores', { snapshotId: sid, note: 'e2e 훈련' }); const id = rq.json.restore.id; const early = await env.ops.post(`/ops/api/dr/restores/${id}/start`, { oldControlStopped: true, confirmText: '기존 제어 위치를 중지했습니다' }); await env.ops.post(`/ops/api/dr/restores/${id}/approve`); const noStop = await env.ops.post(`/ops/api/dr/restores/${id}/start`, { oldControlStopped: false }); const st = await env.ops.post(`/ops/api/dr/restores/${id}/start`, { oldControlStopped: true, confirmText: '기존 제어 위치를 중지했습니다' }); const inc = await env.ops.post(`/ops/api/dr/restores/${id}/finish`, { checks: { operatorLogin: true } }); const fin = await env.ops.post(`/ops/api/dr/restores/${id}/finish`, { checks: { operatorLogin: true, halControl: true, https: true, customerService: true } }); return { early: early.json?.error, noStop: noStop.json?.error, command: !!st.json?.command, incomplete: inc.json?.error, final: fin.json?.restore?.status }; });
const drill = await s.run('복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료', async () => { const sid = (await env.ops.get('/ops/api/dr')).json.snapshots[0].id; const d1 = await env.ops.post('/ops/api/dr/drills', { snapshotId: sid }); const f1 = await env.ops.post(`/ops/api/dr/drills/${d1.json.drill.id}/finish`, { steps: { hal: true, operatorLogin: true, vault: true, certBase: true, httpsCustomer: false }, measuredRtoMinutes: 20, measuredRpoMinutes: 10 }); const d2 = await env.ops.post('/ops/api/dr/drills', {}); const f2 = await env.ops.post(`/ops/api/dr/drills/${d2.json.drill.id}/finish`, { steps: { hal: true, operatorLogin: true, vault: true, certBase: true, httpsCustomer: true }, measuredRtoMinutes: 20, measuredRpoMinutes: 10 }); const rd = (await env.ops.get('/ops/api/dr')).json.readiness; return { first: f1.json?.drill?.result, second: f2.json?.drill?.result, ready: rd.ready, measured: rd.lastDrill ? { rto: rd.lastDrill.measured_rto_minutes, rpo: rd.lastDrill.measured_rpo_minutes } : null }; });
report.needsInfra('재해복구의 실제 복원(hal-restore.sh 로 다른 호스트에 HAL 재기동)·실제 운영자 로그인·HTTPS·고객 서비스 외부 확인은 실 서버가 필요하다(흐름 25 기록·훈련 절차만 로컬 확인)');
report.flow(25, 'partial', { steps: s.list, note: 'DR 설정(RTO/RPO/주기/보존)·사본 생성·검증·외부 보관·복원 승인/기존 제어 중지 확인/완료 항목·훈련 실패/성공→준비 완료 기록까지 확인. 실제 다른 호스트 복원은 실 서버 필요', evidence: { r0, cfg, snap, rs, drill } });
} catch (e) { report.flow(25, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '재해복구 운영 API 흐름 실패', file: 'hal/dr.mjs', route: '/ops/api/dr/*', expected: '설정→사본→복원 기록→훈련', actual: e.message, repro: '80-hal.mjs 흐름 25', flows: [25] }); } }
// ---- 31 긴급 복구
{ const s = steps(); try {
const healthy = await s.run('HAL 정상 상태에서 선언 거부(hal_healthy)', async () => { const r = await env.ops.post('/ops/api/emergency', { incidentKey: 'INC-E2E', reason: 'HAL DB 손상으로 작업 엔진 사용 불가', scope: ['hal', 'hal_data'] }); return r.json?.error; });
const sess = await s.run('critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)', async () => { await env.ops.post('/ops/api/incidents', { key: 'INC-E2E', severity: 'critical', title: 'HAL 장애(e2e)', customerImpact: '없음' }); const d = await env.ops.post('/ops/api/emergency', { incidentKey: 'INC-E2E', reason: 'HAL DB 손상으로 작업 엔진 사용 불가', scope: ['hal', 'hal_data'] }); if (d.status !== 200) throw new Error(d.text.slice(0, 200)); const id = d.json.session.id; const noCred = await env.ops.post(`/ops/api/emergency/${id}/credential`); const wrongStep = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 2, identifiers: ['hal'] }); await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 1, impactText: 'HAL 제어 평면 중단, 고객 서비스는 유지' }); const badIds = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 2, identifiers: ['hal'] }); await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 2, identifiers: ['hal_data', 'hal'] }); const badText = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 3, finalConfirm: '아무거나', revertible: true }); const ok = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 3, finalConfirm: '복구 실행 INC-E2E', revertible: true }); const cred = await env.ops.post(`/ops/api/emergency/${id}/credential`); return { id, noCred: noCred.json?.error, wrongStep: wrongStep.json?.error, badIds: badIds.json?.error, badText: badText.json?.error, approved: ok.json?.session?.status, approvals: ok.json?.session?.approvals?.length, credential: cred.json?.credential, expiresAt: cred.json?.expiresAt }; });
const perf = await s.run('일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료', async () => { const id = sess.id; const c = sess.credential; const forb = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'module.deploy' }); const oos = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'cert.reissue_internal' }); const bad = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: 'bad', action: 'hal.restart' }); const ok = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'vault.verify' }); const v = await env.ops.post(`/ops/api/emergency/${id}/verify`, { verify: { vaultOk: true } }); const after = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'vault.verify' }); const rc = await env.ops.post(`/ops/api/emergency/${id}/reconcile`); const cl = await env.ops.post(`/ops/api/emergency/${id}/close`, { note: 'e2e' }); return { forbidden: forb.json?.error, outOfScope: oos.json?.error, badCred: bad.json?.error, performed: ok.status, revoked: !!v.json?.session?.credential_revoked_at, afterRevoke: after.json?.error, reconcile: rc.status, closed: cl.json?.session?.status }; });
report.flow(31, 'partial', { steps: s.list, note: '정상 상태 선언 거부·3단계 승인(순서/식별정보/문구 거부)·자격 1회 발급·일반 배포/범위 밖/잘못된 자격 거부·수행·폐기 뒤 거부·조정·종료 확인. 자격 만료 거부는 유효 시간(분 단위 설정) 때문에 로컬 실행에서 생략(단위 테스트 test/hal.test.mjs 로 확인)', evidence: { healthy, sess: { ...sess, credential: undefined }, perf } });
} catch (e) { report.flow(31, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '긴급 복구 흐름 실패', file: 'hal/emergency.mjs', route: '/ops/api/emergency/*', expected: '3단계 승인·거부 규칙', actual: e.message, repro: '80-hal.mjs 흐름 31', flows: [31] }); } }
// ---- 29 HAL 장애 중 포털
{ const s = steps(); const alice = state.alice; try {
if (!alice || !state.site) throw new Error('고객 세션/사이트 없음');
const client = alice; const fresh = (await client.post('/api/sites', { name: 'HAL 장애 확인용', projectMode: 'new', buildMode: 'manual', deployMode: 'managed' })).json.site; await env.wm.addVersion(fresh.webmaker_project_id); const sid = fresh.id;
env.hal.proc.kill('SIGKILL'); await sleep(800);
const during = await s.run('HAL 중단 중: 기존 조회(사이트 목록·개요)는 제공, 배포 전 확인/배포는 정보 확인 지연·차단', async () => { const l = await client.get('/api/sites'); const ov = await client.get(`/api/sites/${sid}/overview`); const pc = await client.get(`/api/sites/${sid}/precheck`); const dep = await client.post(`/api/sites/${sid}/deploy`, {}); return { list: l.status, overview: ov.status, precheck: { status: pc.status, blocks: (pc.json?.precheck?.items || []).filter((i) => i.state === 'block').map((i) => i.id + ':' + (i.text || '').slice(0, 40)) }, deploy: { status: dep.status, error: dep.json?.error, message: dep.json?.message } }; });
const dash = await s.run('대시보드/알림에 중앙 상태 확인 지연 표시(가능하면)', async () => { const d = await client.get('/api/dashboard'); return { status: d.status, halState: d.json?.summary?.hal || d.json?.platform || d.json?.stale || null }; }, { soft: true });
const back = await s.run('HAL 재기동(같은 호스트의 죽은 인스턴스 임대 이어받기) → 서버·Agent 상태 복귀', async () => { const h = await env.hal.restart(); if (!h || h.timedOut) { const h2 = await env.hal.restart({ takeover: true }); if (!h2 || h2.timedOut) throw new Error('HAL 재기동 실패: ' + env.hal.proc.lines.slice(-4).join(' | ')); return { takeoverNeeded: true, bootstrap: h2.bootstrap?.complete }; } await env.loginOps(); const srv = await waitFor(async () => { const r = await env.ops.get('/ops/api/servers'); return r.json?.servers?.filter((x) => x.status === 'ready').length >= 4 ? r : null; }, { timeoutMs: 15000, intervalMs: 500 }); return { takeoverNeeded: false, bootstrap: h.bootstrap?.complete, readyServers: srv.json?.servers?.filter((x) => x.status === 'ready').length }; });
report.flow(29, 'verified_local', { steps: s.list, note: 'HAL 중단 중 포털 조회 유지·신규 배포 차단(정보 확인 지연), HAL 재기동 뒤 서버·Agent 복귀', evidence: { during, dash, back } });
} catch (e) { report.flow(29, 'partial', { steps: s.list, note: e.message }); try { if (env.hal.proc.exited) await env.hal.restart({ takeover: true }).then(() => env.loginOps()); } catch (_) {} } }
// ---- 26 재시작 뒤 상태 유지
{ const s = steps(); const alice = state.alice; try {
const before = await s.run('재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록', async () => { const m = state.mailServiceId ? await alice.get(`/api/mail/services/${state.mailServiceId}/job`) : null; const j = await env.ops.get('/ops/api/jobs?limit=50'); return { mailJob: m?.json?.job ? { id: m.json.job.id, status: m.json.job.status } : null, halJobs: j.json?.jobs?.length }; });
const pr = await s.run('포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지', async () => { await env.portal.restart(); const me = await alice.get('/api/auth/me'); const m = state.mailServiceId ? await alice.get(`/api/mail/services/${state.mailServiceId}/job`) : null; return { sessionKept: !!me.json?.user, mailJob: m?.json?.job ? { id: m.json.job.id, status: m.json.job.status } : null, same: m?.json?.job?.id === before.mailJob?.id }; });
const hr = await s.run('HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지', async () => { await env.hal.restart(); const me = await env.ops.get('/ops/api/me'); const j = await env.ops.get('/ops/api/jobs?limit=50'); return { opsSessionKept: !!me.json?.operator, halJobs: j.json?.jobs?.length, sameCount: j.json?.jobs?.length === before.halJobs }; });
report.flow(26, pr.sessionKept && pr.same ? 'verified_local' : 'partial', { steps: s.list, note: '포털·HAL 재시작 뒤 세션·진행 작업(외부 대기)·작업 목록 유지 확인. 진행 중(running) 배포의 단계 재개는 배포 성공(흐름 6) 뒤 확인 필요', evidence: { before, pr, hr } });
} catch (e) { report.flow(26, 'partial', { steps: s.list, note: e.message }); } }
}