Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
36 lines
8.5 KiB
JavaScript
36 lines
8.5 KiB
JavaScript
/** 흐름 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 }); } }
|
|
}
|