From d01be27828c00ce76a3dae5fb6d0fc58711bd981 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 9 Aug 2026 09:50:06 +0000 Subject: [PATCH] feat(gateway): stream release progress logs --- app/gateway-api/src/adminRouter.ts | 30 ++++ .../src/orchestrator/buildRunner.ts | 59 +++++-- .../orchestrator/gatewayReleaseRepository.ts | 60 +++++++ app/gateway-api/test/adminOperations.test.ts | 49 ++++- app/gateway-api/test/buildRunner.test.ts | 26 +++ ...tewayReleaseRepository.integration.test.ts | 11 ++ .../e2e/server-operations.spec.ts | 46 +++++ .../src/views/ServerOperationsView.vue | 167 +++++++++++++++++- .../src/releaseController.ts | 96 ++++++++-- .../test/releaseController.test.ts | 58 +++++- docs/admin-console.md | 4 +- docs/release-operations.md | 12 ++ .../migration.sql | 16 ++ packages/infra/prisma/gateway.prisma | 14 ++ release-manifest.json | 2 +- 15 files changed, 619 insertions(+), 31 deletions(-) create mode 100644 packages/infra/prisma/gateway-migrations/20260809000000_add_gateway_release_logs/migration.sql diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 488951b3..4de11f67 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -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((resolve) => setTimeout(resolve, 250)); + } + }), requestGatewayDeploy: releaseAdminProcedure .input( z.object({ diff --git a/app/gateway-api/src/orchestrator/buildRunner.ts b/app/gateway-api/src/orchestrator/buildRunner.ts index a8e62abc..56c3cebf 100644 --- a/app/gateway-api/src/orchestrator/buildRunner.ts +++ b/app/gateway-api/src/orchestrator/buildRunner.ts @@ -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; + export interface BuildRunner { - run(commands: BuildCommand[]): Promise; + run(commands: BuildCommand[], onProgress?: BuildProgressObserver): Promise; } 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 => +const runCommand = (command: BuildCommand, onProgress?: BuildProgressObserver): Promise => 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 { + async run(commands: BuildCommand[], onProgress?: BuildProgressObserver): Promise { 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 { diff --git a/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts b/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts index b02b9fb8..f1cd3e32 100644 --- a/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts +++ b/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts @@ -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; listOperations(limit?: number): Promise; getOperation(id: string): Promise; + listOperationLogs(id: string, afterCursor?: string, limit?: number): Promise; + appendOperationLog(id: string, input: GatewayReleaseLogInput): Promise; createOperation(input: GatewayReleaseOperationCreateInput): Promise; 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: { diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 5de0abeb..f7e03d9c 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -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>>(); const createdRuntimeActions: Array> = []; 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'); diff --git a/app/gateway-api/test/buildRunner.test.ts b/app/gateway-api/test/buildRunner.test.ts index 2cef6f43..134561c6 100644 --- a/app/gateway-api/test/buildRunner.test.ts +++ b/app/gateway-api/test/buildRunner.test.ts @@ -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' }, + ]); + }); }); diff --git a/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts b/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts index 392423a1..bf71e4af 100644 --- a/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts +++ b/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts @@ -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), diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index aa456413..a65f5139 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -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(); diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 2d3eb7de..07bbb89a 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -1,5 +1,5 @@