diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 1a8b665d..c95c88a3 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1432,13 +1432,23 @@ export const adminRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Gateway release source is invalid.' }); } try { - return await ctx.releases.createOperation({ + const operation = await ctx.releases.createOperation({ type: 'DEPLOY', sourceMode: input.sourceMode, sourceRef, reason: input.reason, requestedBy: adminAuth.user.id, }); + try { + await ctx.releases.appendOperationLog(operation.id, { + level: 'INFO', + phase: 'queue', + message: 'Gateway 배포 작업을 controller queue에 등록했습니다.', + }); + } catch { + // The API that first creates GatewayReleaseLog must still be able to queue its own release. + } + return operation; } catch (error) { if (!isUniqueConstraintError(error)) { throw error; @@ -1455,7 +1465,7 @@ export const adminRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: 'No previous gateway release is available.' }); } try { - return await ctx.releases.createOperation({ + const operation = await ctx.releases.createOperation({ type: 'ROLLBACK', sourceMode: 'COMMIT', sourceRef: state.previousCommitSha, @@ -1466,6 +1476,16 @@ export const adminRouter = router({ reason: input?.reason, requestedBy: adminAuth.user.id, }); + try { + await ctx.releases.appendOperationLog(operation.id, { + level: 'INFO', + phase: 'queue', + message: 'Gateway rollback 작업을 controller queue에 등록했습니다.', + }); + } catch { + // Preserve the bootstrap release when the log table does not exist yet. + } + return operation; } catch (error) { if (!isUniqueConstraintError(error)) { throw error; diff --git a/app/gateway-api/src/orchestrator/releaseManifest.ts b/app/gateway-api/src/orchestrator/releaseManifest.ts index 5564ffb7..a2c77670 100644 --- a/app/gateway-api/src/orchestrator/releaseManifest.ts +++ b/app/gateway-api/src/orchestrator/releaseManifest.ts @@ -3,7 +3,10 @@ import path from 'node:path'; import { isRecord } from '@sammo-ts/common'; -export const RELEASE_CONTROLLER_PROTOCOL = 1; +// Protocol 2 requires a controller that persists GatewayReleaseLog progress. +// Older controllers must reject these releases instead of silently deploying a +// log-aware API/frontend while continuing to run without the logging contract. +export const RELEASE_CONTROLLER_PROTOCOL = 2; export interface ReleaseManifest { formatVersion: 1; diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 092db6eb..192ad490 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -48,6 +48,7 @@ const buildCaller = async ( const session = await sessions.createSession({ ...admin, roles: adminRoles }); const createdInputs: GatewayOperationCreateInput[] = []; const createdReleaseInputs: GatewayReleaseOperationCreateInput[] = []; + const appendedReleaseLogs: Array<{ operationId: string; phase: string; message: string }> = []; const releaseLogs = [ { cursor: '1', @@ -150,12 +151,15 @@ const buildCaller = async ( } return 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, - }), + appendOperationLog: async (operationId, input) => { + appendedReleaseLogs.push({ operationId, phase: input.phase, message: input.message }); + return { + cursor: '2', + operationId, + createdAt: '2026-08-01T00:00:02.000Z', + ...input, + }; + }, createOperation: async (input) => { createdReleaseInputs.push(input); return { @@ -290,6 +294,7 @@ const buildCaller = async ( caller, createdInputs, createdReleaseInputs, + appendedReleaseLogs, createdRuntimeActions, users, admin, @@ -753,6 +758,11 @@ describe('gateway release API', () => { requestedBy: harness.admin.id, }); expect(harness.createdReleaseInputs[0]?.sourceRef).toMatch(/^[0-9a-f]{40}$/u); + expect(harness.appendedReleaseLogs).toContainEqual({ + operationId: '44444444-4444-4444-8444-444444444444', + phase: 'queue', + message: 'Gateway 배포 작업을 controller queue에 등록했습니다.', + }); }); it('queues rollback to the previously published gateway commit', async () => { @@ -767,6 +777,11 @@ describe('gateway release API', () => { sourceMode: 'COMMIT', sourceRef: '2222222222222222222222222222222222222222', }); + expect(harness.appendedReleaseLogs).toContainEqual({ + operationId: '44444444-4444-4444-8444-444444444444', + phase: 'queue', + message: 'Gateway rollback 작업을 controller queue에 등록했습니다.', + }); }); it('requires the global release permission even for profile-scoped administrators', async () => { diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index ce10587f..f1b6afaf 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { readReleaseManifest } from '../src/orchestrator/releaseManifest.js'; +import { readReleaseManifest, RELEASE_CONTROLLER_PROTOCOL } from '../src/orchestrator/releaseManifest.js'; const temporaryDirectories: string[] = []; @@ -37,6 +37,7 @@ describe('readReleaseManifest', () => { const workspaceRoot = path.resolve(import.meta.dirname, '../../..'); await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ + controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260809000000_add_gateway_release_logs', gameSchemaHead: '20260803000000_add_logical_game_clock', }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index b646102f..4c84be74 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -34,6 +34,7 @@ type FixtureState = { runtimeRunning: boolean; requestBodies: Array<{ operation: string; body: unknown }>; gatewayLogPollCount?: number; + gatewayLogsEmpty?: boolean; capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>; profileListDelayMs?: number; profileNavigationDelayMs?: number; @@ -178,6 +179,9 @@ const installFixture = async (page: Page, state: FixtureState) => { const releaseOperation = state.gatewayOperations[0]; if (!releaseOperation) throw new Error('Release operation fixture is missing'); state.gatewayLogPollCount = (state.gatewayLogPollCount ?? 0) + 1; + if (state.gatewayLogsEmpty) { + return response({ operation: releaseOperation, entries: [] }); + } const completed = state.gatewayLogPollCount > 1; return response({ operation: { ...releaseOperation, status: completed ? 'SUCCEEDED' : 'RUNNING' }, @@ -640,6 +644,35 @@ test('controls gateway deployment and rollback through the external controller q expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayRollback')).toBe(true); }); +test('explains terminal releases created before controller progress logging', async ({ page }) => { + const state: FixtureState = { + operations: [], + gatewayOperations: [ + { + id: '99999999-9999-4999-8999-999999999999', + type: 'DEPLOY', + status: 'SUCCEEDED', + sourceMode: 'COMMIT', + sourceRef: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + payload: {}, + requestedBy: 'admin', + createdAt: '2026-08-01T02:00:00.000Z', + updatedAt: '2026-08-01T02:02:00.000Z', + }, + ], + gatewayLogsEmpty: true, + runtimeRunning: true, + requestBodies: [], + }; + await installFixture(page, state); + + await page.goto('admin/releases'); + await expect(page.getByTestId('gateway-release-log')).toContainText( + '로그 지원 controller 적용 전 작업일 수 있습니다.' + ); + await expect(page.getByTestId('gateway-release-log')).not.toContainText('controller 로그를 기다리고 있습니다'); +}); + test('renders a failed reset, retries it as a new operation, and reaches success', async ({ page }, testInfo) => { const longError = '선택한 커밋의 프로필 프로세스를 시작하지 못했습니다. 실패 원인을 확인한 뒤 동일 generation으로 재시도해 주세요.'; diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 562a11f2..8d4a2eef 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -135,6 +135,17 @@ const gatewayForm = reactive({ const selectedGatewayOperation = computed( () => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null ); +const gatewayReleaseLogEmptyMessage = computed(() => { + const operation = selectedGatewayOperation.value; + const status = gatewayReleaseLogStatus.value || operation?.status; + if (!operation || !status || ['QUEUED', 'RUNNING'].includes(status)) { + return 'controller 로그를 기다리고 있습니다…'; + } + if (operation.error) { + return `이 작업에는 controller 로그가 기록되지 않았습니다. 작업 오류: ${operation.error}`; + } + return '이 작업에는 controller 로그가 기록되지 않았습니다. 로그 지원 controller 적용 전 작업일 수 있습니다.'; +}); const hasCapability = (permission: string): boolean => capabilities.value.some((entry) => { if (entry.permission !== permission && entry.permission !== 'admin.profiles.manage') return false; @@ -993,7 +1004,7 @@ onBeforeUnmount(() => { data-testid="gateway-release-log" >