merge: enforce Gateway release log controller protocol
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.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Gateway release source is invalid.' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await ctx.releases.createOperation({
|
const operation = await ctx.releases.createOperation({
|
||||||
type: 'DEPLOY',
|
type: 'DEPLOY',
|
||||||
sourceMode: input.sourceMode,
|
sourceMode: input.sourceMode,
|
||||||
sourceRef,
|
sourceRef,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
requestedBy: adminAuth.user.id,
|
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) {
|
} catch (error) {
|
||||||
if (!isUniqueConstraintError(error)) {
|
if (!isUniqueConstraintError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -1455,7 +1465,7 @@ export const adminRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No previous gateway release is available.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No previous gateway release is available.' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await ctx.releases.createOperation({
|
const operation = await ctx.releases.createOperation({
|
||||||
type: 'ROLLBACK',
|
type: 'ROLLBACK',
|
||||||
sourceMode: 'COMMIT',
|
sourceMode: 'COMMIT',
|
||||||
sourceRef: state.previousCommitSha,
|
sourceRef: state.previousCommitSha,
|
||||||
@@ -1466,6 +1476,16 @@ export const adminRouter = router({
|
|||||||
reason: input?.reason,
|
reason: input?.reason,
|
||||||
requestedBy: adminAuth.user.id,
|
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) {
|
} catch (error) {
|
||||||
if (!isUniqueConstraintError(error)) {
|
if (!isUniqueConstraintError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import { isRecord } from '@sammo-ts/common';
|
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 {
|
export interface ReleaseManifest {
|
||||||
formatVersion: 1;
|
formatVersion: 1;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const buildCaller = async (
|
|||||||
const session = await sessions.createSession({ ...admin, roles: adminRoles });
|
const session = await sessions.createSession({ ...admin, roles: adminRoles });
|
||||||
const createdInputs: GatewayOperationCreateInput[] = [];
|
const createdInputs: GatewayOperationCreateInput[] = [];
|
||||||
const createdReleaseInputs: GatewayReleaseOperationCreateInput[] = [];
|
const createdReleaseInputs: GatewayReleaseOperationCreateInput[] = [];
|
||||||
|
const appendedReleaseLogs: Array<{ operationId: string; phase: string; message: string }> = [];
|
||||||
const releaseLogs = [
|
const releaseLogs = [
|
||||||
{
|
{
|
||||||
cursor: '1',
|
cursor: '1',
|
||||||
@@ -150,12 +151,15 @@ const buildCaller = async (
|
|||||||
}
|
}
|
||||||
return releaseLogs.filter((entry) => !afterCursor || BigInt(entry.cursor) > BigInt(afterCursor));
|
return releaseLogs.filter((entry) => !afterCursor || BigInt(entry.cursor) > BigInt(afterCursor));
|
||||||
},
|
},
|
||||||
appendOperationLog: async (_id, input) => ({
|
appendOperationLog: async (operationId, input) => {
|
||||||
cursor: '2',
|
appendedReleaseLogs.push({ operationId, phase: input.phase, message: input.message });
|
||||||
operationId: '44444444-4444-4444-8444-444444444444',
|
return {
|
||||||
createdAt: '2026-08-01T00:00:02.000Z',
|
cursor: '2',
|
||||||
...input,
|
operationId,
|
||||||
}),
|
createdAt: '2026-08-01T00:00:02.000Z',
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
},
|
||||||
createOperation: async (input) => {
|
createOperation: async (input) => {
|
||||||
createdReleaseInputs.push(input);
|
createdReleaseInputs.push(input);
|
||||||
return {
|
return {
|
||||||
@@ -290,6 +294,7 @@ const buildCaller = async (
|
|||||||
caller,
|
caller,
|
||||||
createdInputs,
|
createdInputs,
|
||||||
createdReleaseInputs,
|
createdReleaseInputs,
|
||||||
|
appendedReleaseLogs,
|
||||||
createdRuntimeActions,
|
createdRuntimeActions,
|
||||||
users,
|
users,
|
||||||
admin,
|
admin,
|
||||||
@@ -753,6 +758,11 @@ describe('gateway release API', () => {
|
|||||||
requestedBy: harness.admin.id,
|
requestedBy: harness.admin.id,
|
||||||
});
|
});
|
||||||
expect(harness.createdReleaseInputs[0]?.sourceRef).toMatch(/^[0-9a-f]{40}$/u);
|
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 () => {
|
it('queues rollback to the previously published gateway commit', async () => {
|
||||||
@@ -767,6 +777,11 @@ describe('gateway release API', () => {
|
|||||||
sourceMode: 'COMMIT',
|
sourceMode: 'COMMIT',
|
||||||
sourceRef: '2222222222222222222222222222222222222222',
|
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 () => {
|
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 { 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[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ describe('readReleaseManifest', () => {
|
|||||||
const workspaceRoot = path.resolve(import.meta.dirname, '../../..');
|
const workspaceRoot = path.resolve(import.meta.dirname, '../../..');
|
||||||
|
|
||||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20260809000000_add_gateway_release_logs',
|
gatewaySchemaHead: '20260809000000_add_gateway_release_logs',
|
||||||
gameSchemaHead: '20260803000000_add_logical_game_clock',
|
gameSchemaHead: '20260803000000_add_logical_game_clock',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type FixtureState = {
|
|||||||
runtimeRunning: boolean;
|
runtimeRunning: boolean;
|
||||||
requestBodies: Array<{ operation: string; body: unknown }>;
|
requestBodies: Array<{ operation: string; body: unknown }>;
|
||||||
gatewayLogPollCount?: number;
|
gatewayLogPollCount?: number;
|
||||||
|
gatewayLogsEmpty?: boolean;
|
||||||
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
|
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
|
||||||
profileListDelayMs?: number;
|
profileListDelayMs?: number;
|
||||||
profileNavigationDelayMs?: number;
|
profileNavigationDelayMs?: number;
|
||||||
@@ -178,6 +179,9 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
const releaseOperation = state.gatewayOperations[0];
|
const releaseOperation = state.gatewayOperations[0];
|
||||||
if (!releaseOperation) throw new Error('Release operation fixture is missing');
|
if (!releaseOperation) throw new Error('Release operation fixture is missing');
|
||||||
state.gatewayLogPollCount = (state.gatewayLogPollCount ?? 0) + 1;
|
state.gatewayLogPollCount = (state.gatewayLogPollCount ?? 0) + 1;
|
||||||
|
if (state.gatewayLogsEmpty) {
|
||||||
|
return response({ operation: releaseOperation, entries: [] });
|
||||||
|
}
|
||||||
const completed = state.gatewayLogPollCount > 1;
|
const completed = state.gatewayLogPollCount > 1;
|
||||||
return response({
|
return response({
|
||||||
operation: { ...releaseOperation, status: completed ? 'SUCCEEDED' : 'RUNNING' },
|
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);
|
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) => {
|
test('renders a failed reset, retries it as a new operation, and reaches success', async ({ page }, testInfo) => {
|
||||||
const longError =
|
const longError =
|
||||||
'선택한 커밋의 프로필 프로세스를 시작하지 못했습니다. 실패 원인을 확인한 뒤 동일 generation으로 재시도해 주세요.';
|
'선택한 커밋의 프로필 프로세스를 시작하지 못했습니다. 실패 원인을 확인한 뒤 동일 generation으로 재시도해 주세요.';
|
||||||
|
|||||||
@@ -135,6 +135,17 @@ const gatewayForm = reactive({
|
|||||||
const selectedGatewayOperation = computed(
|
const selectedGatewayOperation = computed(
|
||||||
() => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null
|
() => 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 =>
|
const hasCapability = (permission: string): boolean =>
|
||||||
capabilities.value.some((entry) => {
|
capabilities.value.some((entry) => {
|
||||||
if (entry.permission !== permission && entry.permission !== 'admin.profiles.manage') return false;
|
if (entry.permission !== permission && entry.permission !== 'admin.profiles.manage') return false;
|
||||||
@@ -993,7 +1004,7 @@ onBeforeUnmount(() => {
|
|||||||
data-testid="gateway-release-log"
|
data-testid="gateway-release-log"
|
||||||
>
|
>
|
||||||
<div v-if="!gatewayReleaseLogs.length" class="text-zinc-500">
|
<div v-if="!gatewayReleaseLogs.length" class="text-zinc-500">
|
||||||
controller 로그를 기다리고 있습니다…
|
{{ gatewayReleaseLogEmptyMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="entry in gatewayReleaseLogs"
|
v-for="entry in gatewayReleaseLogs"
|
||||||
|
|||||||
@@ -82,3 +82,8 @@ pnpm --filter @sammo-ts/release-controller self-upgrade COMMIT <full-sha>
|
|||||||
|
|
||||||
Database migration은 일반적으로 되돌리지 않습니다. 이전 애플리케이션으로
|
Database migration은 일반적으로 되돌리지 않습니다. 이전 애플리케이션으로
|
||||||
rollback하려면 새 schema와의 하위 호환성을 릴리스 전에 확인해 주세요.
|
rollback하려면 새 schema와의 하위 호환성을 릴리스 전에 확인해 주세요.
|
||||||
|
|
||||||
|
`release-manifest.json`의 `controllerProtocol`이 올라간 릴리스는 controller를
|
||||||
|
먼저 self-upgrade해야 합니다. Protocol 2는 `GatewayReleaseLog` 진행 로그 저장을
|
||||||
|
요구합니다. 구형 controller로 새 Gateway만 배포하면 관리자 화면과 controller의
|
||||||
|
기능이 어긋날 수 있으므로, manifest protocol 검사를 우회하지 마세요.
|
||||||
|
|||||||
@@ -198,6 +198,13 @@ pnpm --filter @sammo-ts/release-controller self-upgrade COMMIT <full-sha>
|
|||||||
새 controller가 제한 시간 안에 `online`이 되지 않으면 이전 definition을
|
새 controller가 제한 시간 안에 `online`이 되지 않으면 이전 definition을
|
||||||
복구합니다. Self-upgrade 중에도 migration downgrade는 수행하지 않습니다.
|
복구합니다. 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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 1,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260809000000_add_gateway_release_logs",
|
"gatewaySchemaHead": "20260809000000_add_gateway_release_logs",
|
||||||
"gameSchemaHead": "20260803000000_add_logical_game_clock",
|
"gameSchemaHead": "20260803000000_add_logical_game_clock",
|
||||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||||
|
|||||||
Reference in New Issue
Block a user