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