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.' });
|
||||
}
|
||||
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',
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 검사를 우회하지 마세요.
|
||||
|
||||
@@ -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,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"]
|
||||
|
||||
Reference in New Issue
Block a user