merge: enforce Gateway release log controller protocol

This commit is contained in:
2026-08-10 23:58:08 +00:00
9 changed files with 107 additions and 12 deletions
+22 -2
View File
@@ -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;
@@ -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;
+21 -6
View File
@@ -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 () => {
+2 -1
View File
@@ -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',
});
@@ -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으로 재시도해 주세요.';
@@ -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"
>
<div v-if="!gatewayReleaseLogs.length" class="text-zinc-500">
controller 로그를 기다리고 있습니다
{{ gatewayReleaseLogEmptyMessage }}
</div>
<div
v-for="entry in gatewayReleaseLogs"
+5
View File
@@ -82,3 +82,8 @@ pnpm --filter @sammo-ts/release-controller self-upgrade COMMIT <full-sha>
Database migration은 일반적으로 되돌리지 않습니다. 이전 애플리케이션으로
rollback하려면 새 schema와의 하위 호환성을 릴리스 전에 확인해 주세요.
`release-manifest.json``controllerProtocol`이 올라간 릴리스는 controller를
먼저 self-upgrade해야 합니다. Protocol 2는 `GatewayReleaseLog` 진행 로그 저장을
요구합니다. 구형 controller로 새 Gateway만 배포하면 관리자 화면과 controller의
기능이 어긋날 수 있으므로, manifest protocol 검사를 우회하지 마세요.
+7
View File
@@ -198,6 +198,13 @@ pnpm --filter @sammo-ts/release-controller self-upgrade COMMIT <full-sha>
새 controller가 제한 시간 안에 `online`이 되지 않으면 이전 definition을
복구합니다. Self-upgrade 중에도 migration downgrade는 수행하지 않습니다.
`release-manifest.json``controllerProtocol`이 현재 controller가 지원하는
값보다 높으면 일반 Gateway 배포는 시작 전에 실패합니다. Protocol 2부터
release-controller가 `GatewayReleaseLog` 진행 로그를 저장하는 것이 계약입니다.
로그 기능이 포함된 Gateway API/frontend만 먼저 배포하면 화면은 polling하지만
구형 controller는 로그를 만들 수 있으므로, protocol 변경 commit은 위
`self-upgrade`로 controller를 먼저 전환한 뒤 Gateway 배포를 요청해야 합니다.
## 운영 확인 목록
배포 전:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"controllerProtocol": 1,
"controllerProtocol": 2,
"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"]