Files

86 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 흐름 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); }