feat(gateway): stream release progress logs

This commit is contained in:
2026-08-09 09:50:06 +00:00
parent 73edd0230f
commit d01be27828
15 changed files with 619 additions and 31 deletions
+30
View File
@@ -1351,6 +1351,36 @@ export const adminRouter = router({
list: releaseAdminProcedure
.input(z.object({ limit: z.number().int().min(1).max(200).optional() }).optional())
.query(({ ctx, input }) => ctx.releases.listOperations(input?.limit)),
logs: releaseAdminProcedure
.input(
z.object({
id: z.string().uuid(),
afterCursor: z.string().regex(/^\d+$/u).optional(),
limit: z.number().int().min(1).max(500).default(200),
timeoutMs: z.number().int().min(0).max(25_000).default(20_000),
})
)
.query(async ({ ctx, input }) => {
const deadline = Date.now() + input.timeoutMs;
while (true) {
const [operation, entries] = await Promise.all([
ctx.releases.getOperation(input.id),
ctx.releases.listOperationLogs(input.id, input.afterCursor, input.limit),
]);
if (!operation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Gateway release operation not found.' });
}
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status);
if (entries.length || terminal || Date.now() >= deadline) {
return {
operation,
entries,
nextCursor: entries.at(-1)?.cursor ?? input.afterCursor,
};
}
await new Promise<void>((resolve) => setTimeout(resolve, 250));
}
}),
requestGatewayDeploy: releaseAdminProcedure
.input(
z.object({
+46 -13
View File
@@ -13,8 +13,15 @@ export interface BuildResult {
output: string;
}
export type BuildProgressEvent =
| { type: 'COMMAND_START'; command: BuildCommand }
| { type: 'OUTPUT'; stream: 'stdout' | 'stderr'; message: string }
| { type: 'COMMAND_END'; command: BuildCommand; exitCode: number | null };
export type BuildProgressObserver = (event: BuildProgressEvent) => void | Promise<void>;
export interface BuildRunner {
run(commands: BuildCommand[]): Promise<BuildResult>;
run(commands: BuildCommand[], onProgress?: BuildProgressObserver): Promise<BuildResult>;
}
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
@@ -22,41 +29,67 @@ export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
const appendOutputTail = (current: string, chunk: unknown): string =>
`${current}${String(chunk)}`.slice(-MAX_BUILD_OUTPUT_CHARS);
const runCommand = (command: BuildCommand): Promise<BuildResult> =>
const runCommand = (command: BuildCommand, onProgress?: BuildProgressObserver): Promise<BuildResult> =>
new Promise((resolve) => {
let progressQueue = Promise.resolve();
const emit = (event: BuildProgressEvent) => {
if (!onProgress) return;
progressQueue = progressQueue.then(() => onProgress(event)).catch(() => undefined);
};
emit({ type: 'COMMAND_START', command });
const child = spawn(command.command, command.args, {
cwd: command.cwd,
env: command.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
let spawnFailed = false;
const lineBuffers = { stdout: '', stderr: '' };
const emitOutput = (stream: 'stdout' | 'stderr', chunk: unknown, flush = false) => {
if (flush && !lineBuffers[stream]) return;
lineBuffers[stream] += String(chunk);
const lines = lineBuffers[stream].split(/\r?\n/u);
lineBuffers[stream] = flush ? '' : (lines.pop() ?? '');
if (flush && lineBuffers[stream]) lines.push(lineBuffers[stream]);
for (const line of lines) {
for (let offset = 0; offset < line.length || (offset === 0 && line.length === 0); offset += 2_000) {
emit({ type: 'OUTPUT', stream, message: line.slice(offset, offset + 2_000) });
if (line.length === 0) break;
}
}
};
child.stdout.on('data', (chunk) => {
output = appendOutputTail(output, chunk);
emitOutput('stdout', chunk);
});
child.stderr.on('data', (chunk) => {
output = appendOutputTail(output, chunk);
emitOutput('stderr', chunk);
});
child.on('error', (error) => {
resolve({
ok: false,
exitCode: null,
output: appendOutputTail(output, error.message),
});
spawnFailed = true;
output = appendOutputTail(output, error.message);
});
child.on('close', (code) => {
resolve({
ok: code === 0,
exitCode: code,
output,
emitOutput('stdout', '', true);
emitOutput('stderr', '', true);
const exitCode = spawnFailed ? null : code;
emit({ type: 'COMMAND_END', command, exitCode });
void progressQueue.then(() => {
resolve({
ok: !spawnFailed && code === 0,
exitCode,
output,
});
});
});
});
export class PnpmBuildRunner implements BuildRunner {
async run(commands: BuildCommand[]): Promise<BuildResult> {
async run(commands: BuildCommand[], onProgress?: BuildProgressObserver): Promise<BuildResult> {
let mergedOutput = '';
for (const command of commands) {
const result = await runCommand(command);
const result = await runCommand(command, onProgress);
mergedOutput = appendOutputTail(mergedOutput, result.output);
if (!result.ok) {
return {
@@ -46,10 +46,30 @@ export interface GatewayReleaseOperationCreateInput {
requestedBy: string;
}
export const GATEWAY_RELEASE_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const;
export type GatewayReleaseLogLevel = (typeof GATEWAY_RELEASE_LOG_LEVELS)[number];
export interface GatewayReleaseLogRecord {
cursor: string;
operationId: string;
level: GatewayReleaseLogLevel;
phase: string;
message: string;
createdAt: string;
}
export interface GatewayReleaseLogInput {
level: GatewayReleaseLogLevel;
phase: string;
message: string;
}
export interface GatewayReleaseRepository {
getState(): Promise<GatewayReleaseStateRecord>;
listOperations(limit?: number): Promise<GatewayReleaseOperationRecord[]>;
getOperation(id: string): Promise<GatewayReleaseOperationRecord | null>;
listOperationLogs(id: string, afterCursor?: string, limit?: number): Promise<GatewayReleaseLogRecord[]>;
appendOperationLog(id: string, input: GatewayReleaseLogInput): Promise<GatewayReleaseLogRecord>;
createOperation(input: GatewayReleaseOperationCreateInput): Promise<GatewayReleaseOperationRecord>;
claimNextOperation(
now: Date,
@@ -135,6 +155,24 @@ const mapOperation = (row: {
updatedAt: row.updatedAt.toISOString(),
});
const mapLog = (row: {
id: bigint;
operationId: string;
level: string;
phase: string;
message: string;
createdAt: Date;
}): GatewayReleaseLogRecord => ({
cursor: row.id.toString(),
operationId: row.operationId,
level: GATEWAY_RELEASE_LOG_LEVELS.includes(row.level as GatewayReleaseLogLevel)
? (row.level as GatewayReleaseLogLevel)
: 'INFO',
phase: row.phase,
message: row.message,
createdAt: row.createdAt.toISOString(),
});
export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): GatewayReleaseRepository => ({
async getState() {
const row = await prisma.gatewayReleaseState.upsert({
@@ -155,6 +193,28 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
const row = await prisma.gatewayReleaseOperation.findUnique({ where: { id } });
return row ? mapOperation(row) : null;
},
async listOperationLogs(id, afterCursor, limit = 200) {
const rows = await prisma.gatewayReleaseLog.findMany({
where: {
operationId: id,
...(afterCursor ? { id: { gt: BigInt(afterCursor) } } : {}),
},
orderBy: { id: 'asc' },
take: Math.min(Math.max(limit, 1), 500),
});
return rows.map(mapLog);
},
async appendOperationLog(id, input) {
const row = await prisma.gatewayReleaseLog.create({
data: {
operationId: id,
level: input.level,
phase: input.phase.slice(0, 64),
message: input.message.slice(0, 4_000),
},
});
return mapLog(row);
},
async createOperation(input) {
const row = await prisma.gatewayReleaseOperation.create({
data: {
+48 -1
View File
@@ -46,6 +46,16 @@ const buildCaller = async (
const session = await sessions.createSession({ ...admin, roles: adminRoles });
const createdInputs: GatewayOperationCreateInput[] = [];
const createdReleaseInputs: GatewayReleaseOperationCreateInput[] = [];
const releaseLogs = [
{
cursor: '1',
operationId: '44444444-4444-4444-8444-444444444444',
level: 'INFO' as const,
phase: 'build',
message: 'Gateway 구성 요소를 빌드합니다.',
createdAt: '2026-08-01T00:00:01.000Z',
},
];
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
const createdRuntimeActions: Array<Record<string, unknown>> = [];
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
@@ -113,7 +123,27 @@ const buildCaller = async (
updatedAt: '2026-08-01T00:00:00.000Z',
}),
listOperations: async () => [],
getOperation: async () => null,
getOperation: async (id) =>
id === '44444444-4444-4444-8444-444444444444'
? {
id,
type: 'DEPLOY',
status: 'RUNNING',
payload: {},
requestedBy: admin.id,
attempts: 1,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
}
: null,
listOperationLogs: async (_id, afterCursor) =>
releaseLogs.filter((entry) => !afterCursor || BigInt(entry.cursor) > BigInt(afterCursor)),
appendOperationLog: async (_id, input) => ({
cursor: '2',
operationId: '44444444-4444-4444-8444-444444444444',
createdAt: '2026-08-01T00:00:02.000Z',
...input,
}),
createOperation: async (input) => {
createdReleaseInputs.push(input);
return {
@@ -517,6 +547,23 @@ describe('admin operation API', () => {
});
describe('gateway release API', () => {
it('long-polls ordered release logs with the current operation state', async () => {
const harness = await buildCaller(async () => {
throw new Error('not used');
});
await expect(
harness.caller.admin.releases.logs({
id: '44444444-4444-4444-8444-444444444444',
timeoutMs: 0,
})
).resolves.toMatchObject({
nextCursor: '1',
operation: { status: 'RUNNING' },
entries: [{ cursor: '1', phase: 'build', message: 'Gateway 구성 요소를 빌드합니다.' }],
});
});
it('queues a gateway deployment for the external release controller', async () => {
const harness = await buildCaller(async () => {
throw new Error('not used');
+26
View File
@@ -40,4 +40,30 @@ describe('PnpmBuildRunner', () => {
expect(result.output.length).toBe(MAX_BUILD_OUTPUT_CHARS);
expect(result.output.endsWith('tail-marker')).toBe(true);
});
it('streams command boundaries and line-buffered output to an observer', async () => {
const runner = new PnpmBuildRunner();
const events: Array<{ type: string; message?: string }> = [];
const result = await runner.run(
[
{
command: process.execPath,
args: ['-e', "process.stdout.write('first\\npartial');"],
cwd: process.cwd(),
},
],
async (event) => {
events.push({ type: event.type, ...('message' in event ? { message: event.message } : {}) });
}
);
expect(result.ok).toBe(true);
expect(events).toEqual([
{ type: 'COMMAND_START' },
{ type: 'OUTPUT', message: 'first' },
{ type: 'OUTPUT', message: 'partial' },
{ type: 'COMMAND_END' },
]);
});
});
@@ -50,6 +50,17 @@ describeDatabase('gateway release operation persistence', () => {
await expect(repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'a'.repeat(40))).resolves.toBe(
true
);
const firstLog = await repository.appendOperationLog(operation.id, {
level: 'INFO',
phase: 'build',
message: 'build started',
});
const secondLog = await repository.appendOperationLog(operation.id, {
level: 'OUTPUT',
phase: 'build',
message: 'gateway-api build complete',
});
await expect(repository.listOperationLogs(operation.id, firstLog.cursor)).resolves.toEqual([secondLog]);
await expect(
repository.publishRelease(operation.id, 'stale-controller', {
commitSha: 'a'.repeat(40),
@@ -33,6 +33,7 @@ type FixtureState = {
}>;
runtimeRunning: boolean;
requestBodies: Array<{ operation: string; body: unknown }>;
gatewayLogPollCount?: number;
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
};
@@ -128,6 +129,37 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.releases.list') {
return response(state.gatewayOperations);
}
if (name === 'admin.releases.logs') {
const releaseOperation = state.gatewayOperations[0];
if (!releaseOperation) throw new Error('Release operation fixture is missing');
state.gatewayLogPollCount = (state.gatewayLogPollCount ?? 0) + 1;
const completed = state.gatewayLogPollCount > 1;
return response({
operation: { ...releaseOperation, status: completed ? 'SUCCEEDED' : 'RUNNING' },
entries: completed
? [
{
cursor: '2',
operationId: releaseOperation.id,
level: 'OUTPUT',
phase: 'build',
message: 'gateway-frontend build complete',
createdAt: '2026-08-01T02:00:02.000Z',
},
]
: [
{
cursor: '1',
operationId: releaseOperation.id,
level: 'INFO',
phase: 'build',
message: 'Gateway 구성 요소를 빌드합니다.',
createdAt: '2026-08-01T02:00:01.000Z',
},
],
nextCursor: completed ? '2' : '1',
});
}
if (name === 'admin.profiles.listScenarios') {
return response(scenarios);
}
@@ -359,9 +391,23 @@ test('controls gateway deployment and rollback through the external controller q
await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/)).toBeVisible();
await expect(page.getByTestId('gateway-release-table')).toContainText('DEPLOY');
await expect(page.getByTestId('gateway-release-log-panel')).toBeVisible();
await expect(page.getByTestId('gateway-release-log')).toContainText('Gateway 구성 요소를 빌드합니다.');
await expect(page.getByTestId('gateway-release-log')).toContainText('gateway-frontend build complete');
await expect(page.getByTestId('gateway-release-log-status')).toContainText('SUCCEEDED');
expect(state.gatewayLogPollCount).toBeGreaterThanOrEqual(2);
expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayDeploy')).toBe(true);
await page.screenshot({ path: testInfo.outputPath('gateway-release-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
const mobileLogGeometry = await page.getByTestId('gateway-release-log-panel').evaluate((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.x, width: rect.width, viewportWidth: document.documentElement.clientWidth };
});
expect(mobileLogGeometry.x).toBeGreaterThanOrEqual(0);
expect(mobileLogGeometry.x + mobileLogGeometry.width).toBeLessThanOrEqual(mobileLogGeometry.viewportWidth);
await page.screenshot({ path: testInfo.outputPath('gateway-release-mobile.png'), fullPage: true });
state.gatewayOperations = [];
await page.getByTestId('refresh-operations').click();
await page.getByTestId('request-gateway-rollback').click();
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { trpc } from '../utils/trpc';
@@ -80,11 +80,26 @@ type GatewayReleaseOperation = {
completedAt?: string;
};
type GatewayReleaseLog = {
cursor: string;
operationId: string;
level: 'INFO' | 'OUTPUT' | 'ERROR';
phase: string;
message: string;
createdAt: string;
};
const profiles = ref<Profile[]>([]);
const scenarios = ref<Scenario[]>([]);
const operations = ref<Operation[]>([]);
const gatewayReleaseState = ref<GatewayReleaseState | null>(null);
const gatewayReleaseOperations = ref<GatewayReleaseOperation[]>([]);
const selectedGatewayOperationId = ref('');
const gatewayReleaseLogs = ref<GatewayReleaseLog[]>([]);
const gatewayReleaseLogCursor = ref<string>();
const gatewayReleaseLogStatus = ref('');
const gatewayReleaseLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('idle');
const gatewayReleaseLogViewport = ref<HTMLElement>();
const gatewayReleaseAvailable = ref(false);
const selectedProfileName = ref(props.profileName ?? '');
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
@@ -95,6 +110,8 @@ const message = ref('');
const errorMessage = ref('');
let pollTimer: ReturnType<typeof setInterval> | undefined;
let stateRequestInFlight = false;
let releaseLogLoopGeneration = 0;
let componentMounted = false;
const form = reactive({
sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT',
@@ -127,6 +144,10 @@ const gatewayForm = reactive({
reason: '',
});
const selectedGatewayOperation = computed(
() => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null
);
const selectedProfile = computed(
() => profiles.value.find((profile) => profile.profileName === selectedProfileName.value) ?? null
);
@@ -178,6 +199,8 @@ const toIso = (value: string): string | undefined => {
};
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
const formatLogTime = (value: string): string =>
new Date(value).toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const clearStatus = () => {
@@ -200,6 +223,17 @@ const loadState = async (quiet = false) => {
const releaseOperations = await adminClient.releases.list.query({ limit: 30 });
gatewayReleaseState.value = state as GatewayReleaseState;
gatewayReleaseOperations.value = releaseOperations as GatewayReleaseOperation[];
const active = gatewayReleaseOperations.value.find((operation) =>
['QUEUED', 'RUNNING'].includes(operation.status)
);
if (active && selectedGatewayOperationId.value !== active.id) {
selectedGatewayOperationId.value = active.id;
} else if (
!selectedGatewayOperationId.value ||
!gatewayReleaseOperations.value.some((operation) => operation.id === selectedGatewayOperationId.value)
) {
selectedGatewayOperationId.value = gatewayReleaseOperations.value[0]?.id ?? '';
}
gatewayReleaseAvailable.value = true;
} else {
const profileResult = await adminClient.profiles.list.query();
@@ -219,6 +253,57 @@ const loadState = async (quiet = false) => {
}
};
const scrollReleaseLogToEnd = async () => {
await nextTick();
const viewport = gatewayReleaseLogViewport.value;
if (viewport) viewport.scrollTop = viewport.scrollHeight;
};
const pollGatewayReleaseLogs = async (operationId: string, generation: number) => {
while (componentMounted && generation === releaseLogLoopGeneration && selectedGatewayOperationId.value === operationId) {
try {
const result = await adminClient.releases.logs.query({
id: operationId,
afterCursor: gatewayReleaseLogCursor.value,
limit: 200,
timeoutMs: 20_000,
});
if (generation !== releaseLogLoopGeneration || selectedGatewayOperationId.value !== operationId) return;
gatewayReleaseLogConnection.value = 'connected';
const entries = result.entries as GatewayReleaseLog[];
if (entries.length) {
const known = new Set(gatewayReleaseLogs.value.map((entry) => entry.cursor));
gatewayReleaseLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
gatewayReleaseLogs.value = gatewayReleaseLogs.value.slice(-1_000);
gatewayReleaseLogCursor.value = result.nextCursor;
await scrollReleaseLogToEnd();
}
const operation = result.operation as GatewayReleaseOperation;
gatewayReleaseLogStatus.value = operation.status;
const index = gatewayReleaseOperations.value.findIndex((entry) => entry.id === operation.id);
if (index >= 0) gatewayReleaseOperations.value[index] = operation;
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) return;
} catch {
if (generation !== releaseLogLoopGeneration || !componentMounted) return;
gatewayReleaseLogConnection.value = 'reconnecting';
await new Promise<void>((resolve) => setTimeout(resolve, 1_000));
}
}
};
const selectGatewayReleaseOperation = (operationId: string) => {
if (selectedGatewayOperationId.value === operationId) {
releaseLogLoopGeneration += 1;
gatewayReleaseLogs.value = [];
gatewayReleaseLogCursor.value = undefined;
gatewayReleaseLogStatus.value = '';
gatewayReleaseLogConnection.value = 'idle';
void pollGatewayReleaseLogs(operationId, releaseLogLoopGeneration);
return;
}
selectedGatewayOperationId.value = operationId;
};
const requestDeploy = async () => {
clearStatus();
if (!selectedProfile.value || activeOperation.value || !form.sourceRef.trim() || form.sourceMode === 'CURRENT') {
@@ -254,11 +339,12 @@ const requestGatewayDeploy = async () => {
if (!window.confirm(`Gateway 전체를 ${gatewayForm.sourceRef.trim()} 버전으로 전환하시겠습니까?`)) return;
submitting.value = true;
try {
await adminClient.releases.requestGatewayDeploy.mutate({
const operation = await adminClient.releases.requestGatewayDeploy.mutate({
sourceMode: gatewayForm.sourceMode,
sourceRef: gatewayForm.sourceRef.trim(),
reason: gatewayForm.reason.trim() || undefined,
});
selectedGatewayOperationId.value = operation.id;
message.value = 'Gateway 배포 작업을 등록했습니다. 외부 release-controller가 처리합니다.';
await loadState(true);
} catch (error) {
@@ -279,9 +365,10 @@ const requestGatewayRollback = async () => {
return;
submitting.value = true;
try {
await adminClient.releases.requestGatewayRollback.mutate({
const operation = await adminClient.releases.requestGatewayRollback.mutate({
reason: gatewayForm.reason.trim() || undefined,
});
selectedGatewayOperationId.value = operation.id;
message.value = 'Gateway rollback 작업을 등록했습니다.';
await loadState(true);
} catch (error) {
@@ -420,13 +507,25 @@ watch(selectedProfileName, () => {
}
});
watch(selectedGatewayOperationId, (operationId) => {
releaseLogLoopGeneration += 1;
gatewayReleaseLogs.value = [];
gatewayReleaseLogCursor.value = undefined;
gatewayReleaseLogStatus.value = '';
gatewayReleaseLogConnection.value = operationId ? 'connected' : 'idle';
if (operationId && componentMounted) void pollGatewayReleaseLogs(operationId, releaseLogLoopGeneration);
});
onMounted(async () => {
componentMounted = true;
await loadState();
if (props.mode === 'scenario') await loadScenarios();
pollTimer = setInterval(() => void loadState(true), 3000);
});
onBeforeUnmount(() => {
componentMounted = false;
releaseLogLoopGeneration += 1;
if (pollTimer) {
clearInterval(pollTimer);
}
@@ -888,6 +987,57 @@ onBeforeUnmount(() => {
<div v-if="gatewayReleaseState?.lastError" class="text-sm text-red-300">
{{ gatewayReleaseState.lastError }}
</div>
<section
v-if="selectedGatewayOperationId"
class="overflow-hidden rounded border border-zinc-700 bg-zinc-950"
data-testid="gateway-release-log-panel"
aria-live="polite"
>
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-zinc-800 px-4 py-3">
<div>
<h4 class="text-sm font-semibold text-zinc-100">실시간 릴리스 로그</h4>
<p class="mt-1 font-mono text-[11px] text-zinc-500">
{{ selectedGatewayOperationId }}
</p>
</div>
<div class="flex items-center gap-2 text-xs">
<span
class="h-2 w-2 rounded-full"
:class="
gatewayReleaseLogConnection === 'reconnecting'
? 'animate-pulse bg-amber-400'
: ['QUEUED', 'RUNNING'].includes(
gatewayReleaseLogStatus || selectedGatewayOperation?.status || ''
)
? 'animate-pulse bg-emerald-400'
: 'bg-zinc-500'
"
></span>
<span data-testid="gateway-release-log-status">
{{ gatewayReleaseLogStatus || selectedGatewayOperation?.status || '연결 중' }}
<template v-if="gatewayReleaseLogConnection === 'reconnecting'"> · 재연결 </template>
</span>
</div>
</div>
<div
ref="gatewayReleaseLogViewport"
class="h-72 overflow-y-auto px-4 py-3 font-mono text-xs leading-5"
data-testid="gateway-release-log"
>
<div v-if="!gatewayReleaseLogs.length" class="text-zinc-500">
controller 로그를 기다리고 있습니다
</div>
<div
v-for="entry in gatewayReleaseLogs"
:key="entry.cursor"
:class="entry.level === 'ERROR' ? 'text-red-300' : entry.level === 'OUTPUT' ? 'text-zinc-300' : 'text-cyan-300'"
>
<span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span>
<span class="ml-2 text-violet-300">[{{ entry.phase }}]</span>
<span class="ml-2 whitespace-pre-wrap break-all">{{ entry.message }}</span>
</div>
</div>
</section>
<div class="overflow-x-auto">
<table class="w-full min-w-[760px] text-left text-xs" data-testid="gateway-release-table">
<thead class="border-b border-zinc-700 text-zinc-500">
@@ -898,6 +1048,7 @@ onBeforeUnmount(() => {
<th class="p-2">소스</th>
<th class="p-2">해석 커밋</th>
<th class="p-2">오류</th>
<th class="p-2">로그</th>
</tr>
</thead>
<tbody>
@@ -912,6 +1063,16 @@ onBeforeUnmount(() => {
<td class="p-2 font-mono">{{ operation.sourceRef }}</td>
<td class="p-2 font-mono">{{ shortSha(operation.resolvedCommitSha) }}</td>
<td class="max-w-xs p-2 text-red-300">{{ operation.error }}</td>
<td class="p-2">
<button
type="button"
class="rounded border border-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-800"
:class="operation.id === selectedGatewayOperationId ? 'border-violet-500 text-violet-200' : ''"
@click="selectGatewayReleaseOperation(operation.id)"
>
보기
</button>
</td>
</tr>
</tbody>
</table>
+85 -11
View File
@@ -1,9 +1,11 @@
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { stripVTControlCharacters } from 'node:util';
import {
assertReleaseComponents,
type BuildCommand,
type BuildProgressEvent,
type BuildRunner,
type GatewayReleaseOperationRecord,
type GatewayReleaseRepository,
@@ -20,6 +22,7 @@ import type { ReleaseControllerConfig } from './config.js';
const LEASE_DURATION_MS = 10 * 60_000;
const HEARTBEAT_INTERVAL_MS = 60_000;
const PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const;
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
export const buildGatewayReleaseCommands = (
workspaceRoot: string,
@@ -108,17 +111,68 @@ export class GatewayReleaseController {
private readonly fetchImpl: typeof fetch = fetch
) {}
private sanitizeLogMessage(message: string): string {
let sanitized = stripVTControlCharacters(message);
const sensitiveValues = new Set([
this.config.gatewayDatabaseUrl,
...Object.entries(this.config.baseEnv)
.filter(([name]) => SENSITIVE_ENV_NAME.test(name))
.map(([, value]) => value),
]);
for (const secret of sensitiveValues) {
if (secret && secret.length >= 4) sanitized = sanitized.replaceAll(secret, '[REDACTED]');
}
return sanitized.replace(/(:\/\/[^:\s/@]+:)[^@\s/]+@/gu, '$1[REDACTED]@').slice(0, 4_000);
}
private async appendLog(
operationId: string,
phase: string,
message: string,
level: 'INFO' | 'OUTPUT' | 'ERROR' = 'INFO'
): Promise<void> {
try {
await this.repository.appendOperationLog(operationId, {
level,
phase,
message: this.sanitizeLogMessage(message),
});
} catch {
// The first deployment that creates the log table must remain deployable.
}
}
private readonly buildProgress = (operationId: string, phase: string) => async (event: BuildProgressEvent) => {
if (event.type === 'OUTPUT') {
if (event.message) await this.appendLog(operationId, phase, event.message, 'OUTPUT');
return;
}
const command = [event.command.command, ...event.command.args].join(' ');
if (event.type === 'COMMAND_START') {
await this.appendLog(operationId, phase, `$ ${command}`);
return;
}
await this.appendLog(
operationId,
phase,
`${command} 종료 (exit ${event.exitCode ?? 'unknown'})`,
event.exitCode === 0 ? 'INFO' : 'ERROR'
);
};
async runOnce(): Promise<GatewayReleaseOperationRecord | null> {
const operation = await this.repository.claimNextOperation(this.now(), {
ownerId: this.ownerId,
durationMs: LEASE_DURATION_MS,
});
if (!operation) return null;
await this.appendLog(operation.id, 'claim', `릴리스 작업을 시작합니다. 시도 ${operation.attempts}회차.`);
const heartbeat = setInterval(() => {
void this.repository.renewOperationLease(operation.id, this.ownerId, this.now(), LEASE_DURATION_MS);
}, HEARTBEAT_INTERVAL_MS);
let resolvedCommitSha: string | undefined;
try {
await this.appendLog(operation.id, 'resolve', '현재 Gateway 릴리스 상태를 확인합니다.');
const state = await this.repository.getState();
const deploymentState: GatewayReleaseStateRecord = {
...state,
@@ -128,11 +182,14 @@ export class GatewayReleaseController {
const sourceMode = operation.sourceMode ?? 'COMMIT';
const sourceRef = operation.sourceRef ?? state.previousCommitSha;
if (!sourceRef) throw new Error('Release source is missing.');
await this.appendLog(operation.id, 'resolve', `${sourceMode} ${sourceRef} 커밋을 해석합니다.`);
resolvedCommitSha = await this.workspaceManager.resolveCommit(sourceMode, sourceRef);
if (!(await this.repository.pinOperationResolvedCommit(operation.id, this.ownerId, resolvedCommitSha))) {
throw new Error('Gateway release lease was lost while pinning the commit.');
}
await this.appendLog(operation.id, 'resolve', `대상 커밋을 ${resolvedCommitSha}로 고정했습니다.`);
await this.deploy(operation, deploymentState, resolvedCommitSha);
await this.appendLog(operation.id, 'complete', 'Gateway 릴리스가 완료되었습니다.');
return await this.repository.completeOperation(
operation.id,
'SUCCEEDED',
@@ -141,6 +198,7 @@ export class GatewayReleaseController {
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
await this.appendLog(operation.id, 'failed', detail, 'ERROR');
await this.repository.recordStateError(detail);
return await this.repository.completeOperation(
operation.id,
@@ -158,31 +216,43 @@ export class GatewayReleaseController {
state: GatewayReleaseStateRecord,
commitSha: string
): Promise<void> {
await this.appendLog(operation.id, 'workspace', `커밋 ${commitSha}의 worktree를 준비합니다.`);
const workspace = await this.workspaceManager.prepare(commitSha);
await this.appendLog(operation.id, 'workspace', `worktree 준비 완료: ${workspace.root}`);
const manifest = await readReleaseManifest(workspace.root);
assertReleaseComponents(manifest, ['gateway-api', 'gateway-frontend']);
await this.appendLog(operation.id, 'build', 'Gateway 구성 요소를 빌드합니다.');
const build = await this.buildRunner.run(
buildGatewayReleaseCommands(workspace.root, workspace.needsInstall, this.config)
buildGatewayReleaseCommands(workspace.root, workspace.needsInstall, this.config),
this.buildProgress(operation.id, 'build')
);
if (!build.ok) throw new Error(`Gateway release build failed: ${build.output.slice(-4000)}`);
const migration = await this.buildRunner.run([buildGatewayMigrationCommand(workspace.root, this.config)]);
await this.appendLog(operation.id, 'migration', 'Gateway database migration을 적용합니다.');
const migration = await this.buildRunner.run(
[buildGatewayMigrationCommand(workspace.root, this.config)],
this.buildProgress(operation.id, 'migration')
);
if (!migration.ok) throw new Error(`Gateway migration failed: ${migration.output.slice(-4000)}`);
await this.appendLog(operation.id, 'migration', 'Gateway database migration이 완료되었습니다.');
const previousDefinitions = state.activeWorkspace
? buildGatewayProcessDefinitions(state.activeWorkspace, this.config)
: [];
await this.stopManagedProcesses();
await this.appendLog(operation.id, 'switch', '기존 Gateway process를 정지합니다.');
await this.stopManagedProcesses(operation.id);
try {
await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config));
await this.waitForReadiness();
await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config), operation.id);
await this.waitForReadiness(operation.id);
} catch (error) {
await this.stopManagedProcesses();
await this.appendLog(operation.id, 'rollback', '새 Gateway 시작에 실패하여 이전 process를 복구합니다.', 'ERROR');
await this.stopManagedProcesses(operation.id);
if (previousDefinitions.length) {
await this.startDefinitions(previousDefinitions);
await this.waitForReadiness();
await this.startDefinitions(previousDefinitions, operation.id);
await this.waitForReadiness(operation.id);
}
throw error;
}
await this.appendLog(operation.id, 'publish', '검증된 Gateway 릴리스를 active 상태로 게시합니다.');
await this.repository.publishRelease(operation.id, this.ownerId, {
commitSha,
workspace: workspace.root,
@@ -191,10 +261,11 @@ export class GatewayReleaseController {
});
}
private async startDefinitions(definitions: ProcessDefinition[]): Promise<void> {
private async startDefinitions(definitions: ProcessDefinition[], operationId: string): Promise<void> {
const started: string[] = [];
try {
for (const definition of definitions) {
await this.appendLog(operationId, 'switch', `${definition.name} process를 시작합니다.`);
await this.processManager.start(definition);
started.push(definition.name);
}
@@ -210,11 +281,12 @@ export class GatewayReleaseController {
}
}
private async stopManagedProcesses(): Promise<void> {
private async stopManagedProcesses(operationId: string): Promise<void> {
const existing = new Set((await this.processManager.list()).map((process) => process.name));
const failures: string[] = [];
for (const name of [...PROCESS_NAMES].reverse()) {
if (!existing.has(name)) continue;
await this.appendLog(operationId, 'switch', `${name} process를 정리합니다.`);
try {
await this.processManager.stop(name);
} catch {
@@ -229,7 +301,8 @@ export class GatewayReleaseController {
if (failures.length) throw new Error(`Failed to stop gateway processes: ${failures.join('; ')}`);
}
private async waitForReadiness(): Promise<void> {
private async waitForReadiness(operationId: string): Promise<void> {
await this.appendLog(operationId, 'readiness', 'Gateway API, frontend와 PM2 process readiness를 확인합니다.');
const deadline = Date.now() + this.config.readinessTimeoutMs;
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
const frontendUrl = `http://127.0.0.1:${this.config.gatewayFrontendPort}${this.config.gatewayBasePath}/`;
@@ -250,6 +323,7 @@ export class GatewayReleaseController {
safe.length === PROCESS_NAMES.length &&
new Set(safe.map((process) => process.name)).size === PROCESS_NAMES.length
) {
await this.appendLog(operationId, 'readiness', 'Gateway readiness 확인을 통과했습니다.');
return;
}
} catch {
@@ -75,6 +75,7 @@ const config: ReleaseControllerConfig = {
readinessTimeoutMs: 10,
baseEnv: {
REDIS_URL: 'redis://integration.invalid:6379/0',
GATEWAY_BOOTSTRAP_TOKEN: 'bootstrap-secret-value',
},
};
@@ -87,10 +88,21 @@ const createRepository = () => {
const completions: string[] = [];
const published: Array<{ commitSha: string; workspace: string; previousCommitSha?: string }> = [];
const errors: string[] = [];
const logs: Array<{ level: string; phase: string; message: string }> = [];
const repository: GatewayReleaseRepository = {
getState: async () => state,
listOperations: async () => [],
getOperation: async () => operation,
listOperationLogs: async () => [],
appendOperationLog: async (id, input) => {
logs.push(input);
return {
cursor: String(logs.length),
operationId: id,
createdAt: '2026-08-01T00:00:00.000Z',
...input,
};
},
createOperation: async () => operation,
claimNextOperation: async () => {
const claimed = next;
@@ -113,7 +125,7 @@ const createRepository = () => {
cancelOperation: async () => false,
retryOperation: async () => null,
};
return { repository, completions, published, errors };
return { repository, completions, published, errors, logs };
};
const gatewayNames = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'];
@@ -203,6 +215,9 @@ describe('GatewayReleaseController', () => {
{ commitSha: SHA, workspace, previousCommitSha: OLD_SHA, previousWorkspace: '/srv/sammo/old' },
]);
expect(harness.completions).toEqual(['SUCCEEDED']);
expect(harness.logs.map((entry) => entry.phase)).toEqual(
expect.arrayContaining(['claim', 'resolve', 'workspace', 'build', 'migration', 'switch', 'readiness', 'publish'])
);
});
it('restores the previous gateway processes when the new process set cannot start', async () => {
@@ -245,6 +260,47 @@ describe('GatewayReleaseController', () => {
expect(harness.completions).toEqual(['FAILED']);
expect(harness.errors.at(-1)).toContain('new gateway failed');
});
it('redacts configured credentials and URI passwords from persisted build output', async () => {
const workspace = await createReleaseWorkspace();
const harness = createRepository();
const processManager: ProcessManager = {
list: async () => gatewayNames.map((name) => ({ name, status: 'online', restartCount: 0 })),
start: async () => {},
stop: async () => {},
delete: async () => {},
};
const buildRunner: BuildRunner = {
run: async (_commands, onProgress) => {
await onProgress?.({
type: 'OUTPUT',
stream: 'stdout',
message:
'bootstrap-secret-value postgresql://operator:visible-password@db.invalid/sammo',
});
return { ok: true, exitCode: 0, output: '' };
},
};
const controller = new GatewayReleaseController(
harness.repository,
{
resolveCommit: async () => SHA,
prepare: async () => ({ root: workspace, created: true, needsInstall: false }),
} as unknown as GitWorkspaceManager,
buildRunner,
processManager,
config,
() => new Date('2026-08-01T00:00:00.000Z'),
async () => new Response('', { status: 200 })
);
await controller.runOnce();
const persisted = harness.logs.map((entry) => entry.message).join('\n');
expect(persisted).not.toContain('bootstrap-secret-value');
expect(persisted).not.toContain('visible-password');
expect(persisted).toContain('[REDACTED]');
});
});
describe('resolveReleaseControllerConfig', () => {
+3 -1
View File
@@ -36,7 +36,9 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
권한과 버전 배포 권한이 모두 필요합니다.
- Gateway 릴리스는 profile 작업과 다른 전역 `admin.releases.manage` 권한을
사용하며 외부 release-controller가 실행합니다.
사용하며 외부 release-controller가 실행합니다. 선택한 릴리스 작업의 단계와
명령 출력을 관리자 화면이 long polling으로 이어 받아 표시하며, 완료된 이력의
로그도 다시 열 수 있습니다.
- 브라우저의 메뉴 노출은 편의 기능입니다. 권한 판단의 기준은 서버가 인증
session에서 해석한 capability입니다.
+12
View File
@@ -125,6 +125,18 @@ Gateway process definition에는 `GATEWAY_DATABASE_URL`과 `REDIS_URL`이 모두
Gateway 전체에는 활성 릴리스 작업을 동시에 하나만 둘 수 있습니다. 화면의
릴리스 이력에서 요청 source, 고정 commit, 상태와 오류를 확인할 수 있습니다.
작업을 선택하면 관리자 화면이 `admin.releases.logs`를 최대 20초씩 long polling하여
commit 해석, worktree 준비, build 명령 출력, migration, process 전환,
readiness와 rollback 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도
같은 이력에서 다시 열 수 있으며 화면은 최근 1,000줄을 유지합니다.
로그 원본은 Gateway DB의 `GatewayReleaseLog`에 작업별로 저장되고 작업 삭제 시
함께 제거됩니다. Controller는 ANSI 제어 문자를 제거하고 secret·token·password
계열 환경 변수 값과 URL password를 저장 전에 가립니다. 최초로 이 migration을
적용하는 배포에서는 log table이 생기기 전의 build 구간을 기록할 수 없지만,
migration 이후 단계와 다음 릴리스부터는 전체 진행 로그를 기록합니다. 로그가
관리자 전용이라고 해도 credential이나 실제 환경 파일 내용을 명령 출력에
의도적으로 남기지 마세요.
### Gateway rollback
@@ -0,0 +1,16 @@
CREATE TABLE "gateway_release_log" (
"id" BIGSERIAL NOT NULL,
"operation_id" TEXT NOT NULL,
"level" TEXT NOT NULL,
"phase" TEXT NOT NULL,
"message" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gateway_release_log_pkey" PRIMARY KEY ("id"),
CONSTRAINT "gateway_release_log_operation_id_fkey"
FOREIGN KEY ("operation_id") REFERENCES "gateway_release_operation"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "gateway_release_log_operation_id_id_idx"
ON "gateway_release_log" ("operation_id", "id");
+14
View File
@@ -321,12 +321,26 @@ model GatewayReleaseOperation {
attempts Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
logs GatewayReleaseLog[]
@@index([status, leaseUntil, createdAt])
@@index([createdAt])
@@map("gateway_release_operation")
}
model GatewayReleaseLog {
id BigInt @id @default(autoincrement())
operationId String @map("operation_id")
level String
phase String
message String @db.Text
createdAt DateTime @default(now()) @map("created_at")
operation GatewayReleaseOperation @relation(fields: [operationId], references: [id], onDelete: Cascade)
@@index([operationId, id])
@@map("gateway_release_log")
}
model SystemSetting {
id Int @id @default(1) @map("no")
registrationEnabled Boolean @default(false) @map("registration_enabled")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 1,
"gatewaySchemaHead": "20260808001000_add_special_account_access_grants",
"gatewaySchemaHead": "20260809000000_add_gateway_release_logs",
"gameSchemaHead": "20260803000000_add_logical_game_clock",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}