chore: test deploy snapshot
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user