fix(gateway): require log-aware release controller
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user