Files
AriWeb-Suite/test/e2e/flows/80-hal.mjs

70 lines
21 KiB
JavaScript

/** 흐름 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 }; });
// 판정: 일부 실패가 partial 로 요약되고, 건강 확인이 끝나고, 되돌리기가 3단계 확인 없이는 거부되면 로컬 확인. 아니면 결함.
const okAll = d && d.overall === 'partial' && d.status === 'failed' && h && h.health === 'done' && rb && rb.step1 === 'confirm_impact_required' && rb.step2 === 'identifiers_mismatch';
report.flow(30, okAll ? 'verified_local' : 'failed', { steps: s.list, note: okAll ? '대상별 자식 작업·일부 실패 partial 요약(전체 성공으로 묶지 않음)·건강 확인·drift 조정·되돌리기 3단계 확인 거부' : '일부 실패 요약·건강 확인·drift 조정 또는 되돌리기 확인 규칙이 기대와 다름(증거 참조)', 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 }); } }
}