import { spawn } from 'node:child_process'; import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { resolveMainProjectRoot } from './main-project-root-service.js'; const TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS = 20 * 60 * 1000; const TEST_SERVER_DEPLOYMENT_LOG_LIMIT = 4000; const TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS = 15_000; export type TestServerDeploymentStepKey = 'commit-main-worktree' | 'push-origin-main' | 'build-test-app' | 'deploy-test-server'; export type TestServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed'; export type TestServerDeploymentStepSnapshot = { key: TestServerDeploymentStepKey; status: TestServerDeploymentStepStatus; detail: string | null; updatedAt: string | null; }; export type TestServerDeploymentPhase = | 'idle' | 'commit-main-worktree' | 'push-origin-main' | 'build-test-app' | 'deploy-test-server' | 'completed' | 'failed'; export type TestServerDeploymentSnapshot = { status: 'idle' | 'running' | 'completed' | 'failed'; phase: TestServerDeploymentPhase; summary: string | null; startedAt: string | null; updatedAt: string | null; completedAt: string | null; lastError: string | null; logExcerpt: string | null; steps: TestServerDeploymentStepSnapshot[]; }; type TestServerDeploymentStateFilePayload = { status?: unknown; phase?: unknown; summary?: unknown; startedAt?: unknown; updatedAt?: unknown; completedAt?: unknown; lastError?: unknown; logExcerpt?: unknown; steps?: unknown; }; type RestartLockPayload = { startedAt: string; key: 'test'; pid: number; }; const TEST_SERVER_DEPLOYMENT_STEP_KEYS: TestServerDeploymentStepKey[] = [ 'commit-main-worktree', 'push-origin-main', 'build-test-app', 'deploy-test-server', ]; function normalizeDateTimeValue(value: string | null | undefined) { const normalized = value?.trim(); if (!normalized) { return null; } const parsed = new Date(normalized); return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); } function trimPreview(value: string | null | undefined, maxLength = 220) { const normalized = value?.replace(/\s+/g, ' ').trim() ?? ''; if (!normalized) { return null; } return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized; } function getTestServerDeploymentStatePath() { return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-state.json'); } function getTestServerDeploymentLockPath() { return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-in-progress.json'); } function buildEmptyTestServerDeploymentSnapshot(): TestServerDeploymentSnapshot { return { status: 'idle', phase: 'idle', summary: null, startedAt: null, updatedAt: null, completedAt: null, lastError: null, logExcerpt: null, steps: TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({ key, status: 'pending', detail: null, updatedAt: null, })), }; } function normalizeTestServerDeploymentStepKey(value: unknown): TestServerDeploymentStepKey | null { return TEST_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as TestServerDeploymentStepKey) ? (value as TestServerDeploymentStepKey) : null; } function normalizeTestServerDeploymentPhase(value: unknown): TestServerDeploymentPhase { return value === 'commit-main-worktree' || value === 'push-origin-main' || value === 'build-test-app' || value === 'deploy-test-server' || value === 'completed' || value === 'failed' ? value : 'idle'; } function normalizeTestServerDeploymentStatus(value: unknown): TestServerDeploymentSnapshot['status'] { return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle'; } function normalizeTestServerDeploymentSteps(value: unknown) { const fallback = buildEmptyTestServerDeploymentSnapshot().steps; if (!Array.isArray(value)) { return fallback; } const normalizedByKey = new Map(); value.forEach((item) => { if (!item || typeof item !== 'object') { return; } const candidate = item as Record; const key = normalizeTestServerDeploymentStepKey(candidate.key); if (!key) { return; } normalizedByKey.set(key, { key, status: candidate.status === 'running' || candidate.status === 'completed' || candidate.status === 'failed' || candidate.status === 'pending' ? candidate.status : 'pending', detail: typeof candidate.detail === 'string' ? candidate.detail : null, updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null), }); }); return TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!); } function normalizeTestServerDeploymentSnapshot(value: unknown): TestServerDeploymentSnapshot { if (!value || typeof value !== 'object') { return buildEmptyTestServerDeploymentSnapshot(); } const candidate = value as TestServerDeploymentStateFilePayload; return { status: normalizeTestServerDeploymentStatus(candidate.status), phase: normalizeTestServerDeploymentPhase(candidate.phase), summary: typeof candidate.summary === 'string' ? candidate.summary : null, startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null), updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null), completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null), lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null, logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null, steps: normalizeTestServerDeploymentSteps(candidate.steps), }; } export async function readTestServerDeploymentState(): Promise { try { const raw = await readFile(getTestServerDeploymentStatePath(), 'utf8'); const snapshot = normalizeTestServerDeploymentSnapshot(JSON.parse(raw)); return await resolveStaleRunningTestDeployment(snapshot); } catch { return null; } } async function writeTestServerDeploymentState(snapshot: TestServerDeploymentSnapshot) { const statePath = getTestServerDeploymentStatePath(); await mkdir(path.dirname(statePath), { recursive: true }); await writeFile(statePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8'); } async function clearTestServerDeploymentState() { await rm(getTestServerDeploymentStatePath(), { force: true }).catch(() => undefined); } function buildStaleTestServerDeploymentFailure(snapshot: TestServerDeploymentSnapshot) { const stalledAt = snapshot.updatedAt ?? snapshot.startedAt; const stalledLabel = stalledAt ? `마지막 상태 갱신 ${stalledAt}` : '상태 갱신 시각 확인 불가'; return trimPreview(`TEST 배포 상태가 오래 갱신되지 않았고 잠금 파일도 없어 중단된 배포로 처리했습니다. ${stalledLabel}`, 500) ?? 'TEST 배포 상태가 오래 갱신되지 않아 중단된 배포로 처리했습니다.'; } async function finalizeStaleRunningTestDeployment(snapshot: TestServerDeploymentSnapshot) { const failureMessage = buildStaleTestServerDeploymentFailure(snapshot); const now = new Date().toISOString(); const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key; snapshot.status = 'failed'; snapshot.phase = 'failed'; snapshot.summary = buildTestServerDeploymentSummary('failed'); snapshot.completedAt = now; snapshot.updatedAt = now; snapshot.lastError = failureMessage; if (activeStep) { updateTestServerDeploymentStep(snapshot, activeStep, 'failed', failureMessage); } await writeTestServerDeploymentState(snapshot); return snapshot; } async function resolveStaleRunningTestDeployment(snapshot: TestServerDeploymentSnapshot) { if (snapshot.status !== 'running') { return snapshot; } const freshnessSource = snapshot.updatedAt ?? snapshot.startedAt; if (!freshnessSource) { return snapshot; } const staleForMs = Date.now() - Date.parse(freshnessSource); if (!Number.isFinite(staleForMs) || staleForMs < TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) { return snapshot; } const lockPath = getTestServerDeploymentLockPath(); const lockStat = await stat(lockPath).catch(() => null); if (lockStat?.isFile()) { const lockFreshnessSource = normalizeDateTimeValue(lockStat.mtime.toISOString() ?? null); if (lockFreshnessSource && Date.now() - Date.parse(lockFreshnessSource) < TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) { return snapshot; } } await rm(lockPath, { force: true }).catch(() => undefined); return finalizeStaleRunningTestDeployment(snapshot); } async function acquireTestServerDeploymentLock() { const lockPath = getTestServerDeploymentLockPath(); await mkdir(path.dirname(lockPath), { recursive: true }); const startedAt = new Date().toISOString(); try { const handle = await open(lockPath, 'wx'); try { await handle.writeFile(JSON.stringify({ startedAt, key: 'test', pid: process.pid } satisfies RestartLockPayload) + '\n', 'utf8'); } finally { await handle.close(); } return lockPath; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { throw error; } let existingStartedAt: string | null = null; try { const raw = await readFile(lockPath, 'utf8'); const parsed = JSON.parse(raw) as Partial; existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === 'string' ? parsed.startedAt : null); const lockStat = await stat(lockPath).catch(() => null); const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null); if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) { await rm(lockPath, { force: true }).catch(() => undefined); return acquireTestServerDeploymentLock(); } } catch { // ignore read failures and keep conflict response below } const conflictError = new Error( existingStartedAt ? `TEST 배포가 이미 진행 중입니다. 시작 시각 ${existingStartedAt}` : 'TEST 배포가 이미 진행 중입니다.', ); (conflictError as Error & { statusCode?: number }).statusCode = 409; throw conflictError; } } function buildTestServerDeploymentSummary(phase: TestServerDeploymentPhase) { switch (phase) { case 'commit-main-worktree': return 'main 작업트리 커밋 진행 중'; case 'push-origin-main': return 'origin/main 푸시 진행 중'; case 'build-test-app': return '테스트 앱 빌드 진행 중'; case 'deploy-test-server': return '테스트 서버 배포 진행 중'; case 'completed': return 'origin/main 푸시, 테스트 빌드, 테스트 배포가 완료되었습니다.'; case 'failed': return 'TEST 배포에 실패했습니다.'; default: return '테스트 배포 준비 중'; } } function buildTestDeploymentFailureMessage( snapshot: Pick, error: unknown, ) { const failure = error instanceof Error ? (error as Error & { code?: number | string; signal?: string | null }) : null; const exitInfo = [ failure?.code != null ? `exit:${String(failure.code)}` : null, failure?.signal ? `signal:${String(failure.signal)}` : null, ].filter(Boolean).join(' '); const logLines = (snapshot.logExcerpt ?? '') .split('\n') .map((line) => line.trim()) .filter(Boolean); const lastMeaningfulLog = logLines.length > 0 ? logLines[logLines.length - 1] : null; return trimPreview([ lastMeaningfulLog && lastMeaningfulLog !== failure?.message ? lastMeaningfulLog : null, failure?.message || null, exitInfo || null, ].filter(Boolean).join(' | '), 500) ?? 'TEST 배포에 실패했습니다.'; } function appendTestServerDeploymentLog(previous: string | null, chunk: string) { const normalizedChunk = chunk.trim(); if (!normalizedChunk) { return previous; } const combined = [previous, normalizedChunk].filter(Boolean).join('\n'); return combined.length > TEST_SERVER_DEPLOYMENT_LOG_LIMIT ? combined.slice(combined.length - TEST_SERVER_DEPLOYMENT_LOG_LIMIT) : combined; } function updateTestServerDeploymentStep( snapshot: TestServerDeploymentSnapshot, key: TestServerDeploymentStepKey, status: TestServerDeploymentStepStatus, detail?: string | null, ) { const now = new Date().toISOString(); snapshot.steps = snapshot.steps.map((step) => { if (step.key !== key) { return step; } return { ...step, status, detail: detail === undefined ? step.detail : detail, updatedAt: now, }; }); snapshot.updatedAt = now; } function markPreviousRunningStepCompleted(snapshot: TestServerDeploymentSnapshot, nextKey: TestServerDeploymentStepKey) { const previousRunning = snapshot.steps.find((step) => step.status === 'running' && step.key !== nextKey); if (previousRunning) { updateTestServerDeploymentStep(snapshot, previousRunning.key, 'completed'); } } function scheduleTestServerDeploymentCleanup(completedAt: string) { const timer = setTimeout(() => { void (async () => { const snapshot = await readTestServerDeploymentState(); if (snapshot?.status === 'completed' && snapshot.completedAt === completedAt) { await clearTestServerDeploymentState(); } })(); }, TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS); if (typeof timer === 'object' && timer && 'unref' in timer && typeof timer.unref === 'function') { timer.unref(); } } async function runTestServerDeployment( lockPath: string, snapshot: TestServerDeploymentSnapshot, persist: () => Promise, ) { const mainProjectRoot = resolveMainProjectRoot(); const deployScript = path.join(mainProjectRoot, 'etc', 'commands', 'server-command', 'deploy-test.sh'); const moveToStep = (key: TestServerDeploymentStepKey) => { markPreviousRunningStepCompleted(snapshot, key); snapshot.phase = key; snapshot.summary = buildTestServerDeploymentSummary(key); updateTestServerDeploymentStep(snapshot, key, 'running'); void persist(); }; const appendOutput = (line: string) => { snapshot.logExcerpt = appendTestServerDeploymentLog(snapshot.logExcerpt, line); snapshot.updatedAt = new Date().toISOString(); void persist(); }; const fail = async (message: string) => { const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key ?? 'commit-main-worktree'; snapshot.status = 'failed'; snapshot.phase = 'failed'; snapshot.summary = buildTestServerDeploymentSummary('failed'); snapshot.lastError = message; snapshot.updatedAt = new Date().toISOString(); updateTestServerDeploymentStep(snapshot, activeStep, 'failed', message); await persist(); }; try { await new Promise((resolve, reject) => { const child = spawn('sh', [deployScript], { cwd: mainProjectRoot, env: { ...process.env, MAIN_PROJECT_ROOT: mainProjectRoot, REPO_ROOT: mainProjectRoot, }, stdio: ['ignore', 'pipe', 'pipe'], }); let stdoutBuffer = ''; let stderrBuffer = ''; const processLine = (line: string) => { const trimmed = line.trim(); const marker = trimmed.match(/^::step::([a-z-]+)$/); if (marker) { const nextStep = normalizeTestServerDeploymentStepKey(marker[1]); if (nextStep) { moveToStep(nextStep); } return; } appendOutput(line); }; const flushBufferedLines = (buffer: string) => { const normalized = buffer.replace(/\r$/, '').trim(); if (normalized) { processLine(normalized); } }; const attachReader = (stream: NodeJS.ReadableStream | null, target: 'stdout' | 'stderr') => { if (!stream) { return; } stream.setEncoding('utf8'); stream.on('data', (chunk: string) => { if (target === 'stdout') { stdoutBuffer += chunk; while (stdoutBuffer.includes('\n')) { const index = stdoutBuffer.indexOf('\n'); const line = stdoutBuffer.slice(0, index).replace(/\r$/, ''); stdoutBuffer = stdoutBuffer.slice(index + 1); processLine(line); } return; } stderrBuffer += chunk; while (stderrBuffer.includes('\n')) { const index = stderrBuffer.indexOf('\n'); const line = stderrBuffer.slice(0, index).replace(/\r$/, ''); stderrBuffer = stderrBuffer.slice(index + 1); processLine(line); } }); }; attachReader(child.stdout, 'stdout'); attachReader(child.stderr, 'stderr'); child.once('error', reject); child.once('close', (code, signal) => { flushBufferedLines(stdoutBuffer); flushBufferedLines(stderrBuffer); if (code === 0) { resolve(); return; } reject(Object.assign(new Error(`deploy-test exited with ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`), { code, signal, })); }); }); const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key; if (activeStep) { updateTestServerDeploymentStep(snapshot, activeStep, 'completed'); } const completedAt = new Date().toISOString(); snapshot.status = 'completed'; snapshot.phase = 'completed'; snapshot.summary = buildTestServerDeploymentSummary('completed'); snapshot.completedAt = completedAt; snapshot.updatedAt = completedAt; snapshot.lastError = null; await persist(); scheduleTestServerDeploymentCleanup(completedAt); } catch (error) { const message = buildTestDeploymentFailureMessage(snapshot, error); await fail(message); } finally { await rm(lockPath, { force: true }).catch(() => undefined); } } export async function startTestServerDeployment() { const lockPath = await acquireTestServerDeploymentLock(); const startedAt = new Date().toISOString(); const snapshot = buildEmptyTestServerDeploymentSnapshot(); snapshot.status = 'running'; snapshot.phase = 'commit-main-worktree'; snapshot.summary = buildTestServerDeploymentSummary('commit-main-worktree'); snapshot.startedAt = startedAt; snapshot.updatedAt = startedAt; updateTestServerDeploymentStep(snapshot, 'commit-main-worktree', 'running', 'main 작업트리 변경을 커밋합니다.'); await writeTestServerDeploymentState(snapshot); let persistQueue = Promise.resolve(); const persist = async () => { persistQueue = persistQueue.then(() => writeTestServerDeploymentState(snapshot)).catch(() => undefined); await persistQueue; }; void runTestServerDeployment(lockPath, snapshot, persist); return snapshot; }