fix(gateway): require log-aware release controller

This commit is contained in:
2026-08-10 23:58:02 +00:00
parent 9ad1de28dd
commit c9e1200745
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',
});