chore: test deploy snapshot

This commit is contained in:
2026-05-27 10:43:01 +09:00
parent c1d0f4c1db
commit 4c4b3c8d2c
78 changed files with 10392 additions and 2301 deletions

View File

@@ -0,0 +1,516 @@
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<TestServerDeploymentStepKey, TestServerDeploymentStepSnapshot>();
value.forEach((item) => {
if (!item || typeof item !== 'object') {
return;
}
const candidate = item as Record<string, unknown>;
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<TestServerDeploymentSnapshot | null> {
try {
const raw = await readFile(getTestServerDeploymentStatePath(), 'utf8');
return normalizeTestServerDeploymentSnapshot(JSON.parse(raw));
} 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);
}
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<RestartLockPayload>;
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<TestServerDeploymentSnapshot, 'logExcerpt'>,
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<void>,
) {
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<void>((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;
}