merge: 예약 초기화 중간 버전 업데이트를 반영한다
This commit is contained in:
@@ -25,7 +25,11 @@ import {
|
|||||||
} from './adminCapabilities.js';
|
} from './adminCapabilities.js';
|
||||||
import type { GatewayApiContext } from './context.js';
|
import type { GatewayApiContext } from './context.js';
|
||||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||||
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
import {
|
||||||
|
GATEWAY_BUILD_STATUSES,
|
||||||
|
GATEWAY_PROFILE_STATUSES,
|
||||||
|
GatewayProfileOperationConflictError,
|
||||||
|
} from './orchestrator/profileRepository.js';
|
||||||
import { readProfileReleaseSource } from './orchestrator/profileReleaseSource.js';
|
import { readProfileReleaseSource } from './orchestrator/profileReleaseSource.js';
|
||||||
import {
|
import {
|
||||||
orderGatewayProfiles,
|
orderGatewayProfiles,
|
||||||
@@ -469,6 +473,9 @@ const zRuntimeSettings = z
|
|||||||
const isUniqueConstraintError = (error: unknown): boolean =>
|
const isUniqueConstraintError = (error: unknown): boolean =>
|
||||||
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002');
|
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002');
|
||||||
|
|
||||||
|
const isProfileOperationConflictError = (error: unknown): boolean =>
|
||||||
|
error instanceof GatewayProfileOperationConflictError || isUniqueConstraintError(error);
|
||||||
|
|
||||||
const zInstallOptions = z.object({
|
const zInstallOptions = z.object({
|
||||||
scenarioId: z.number().int().min(0),
|
scenarioId: z.number().int().min(0),
|
||||||
turnTermMinutes: z
|
turnTermMinutes: z
|
||||||
@@ -1343,7 +1350,7 @@ export const adminRouter = router({
|
|||||||
});
|
});
|
||||||
return operation;
|
return operation;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isUniqueConstraintError(error)) {
|
if (!isProfileOperationConflictError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1399,7 +1406,7 @@ export const adminRouter = router({
|
|||||||
requestedBy: adminAuth.user.id,
|
requestedBy: adminAuth.user.id,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isUniqueConstraintError(error)) {
|
if (!isProfileOperationConflictError(error)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'The active Gateway release commit cannot be resolved for game cancellation.',
|
message: 'The active Gateway release commit cannot be resolved for game cancellation.',
|
||||||
@@ -1460,7 +1467,7 @@ export const adminRouter = router({
|
|||||||
requestedBy: adminAuth.user.id,
|
requestedBy: adminAuth.user.id,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isUniqueConstraintError(error)) {
|
if (!isProfileOperationConflictError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1505,7 +1512,7 @@ export const adminRouter = router({
|
|||||||
});
|
});
|
||||||
return operation;
|
return operation;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isUniqueConstraintError(error)) {
|
if (!isProfileOperationConflictError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1575,7 +1582,7 @@ export const adminRouter = router({
|
|||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (!isUniqueConstraintError(error)) {
|
if (!isProfileOperationConflictError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -2114,7 +2121,7 @@ export const adminRouter = router({
|
|||||||
});
|
});
|
||||||
return { ok: true, operationId: operation.id, action: actionRecord };
|
return { ok: true, operationId: operation.id, action: actionRecord };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isUniqueConstraintError(error)) {
|
if (!isProfileOperationConflictError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -2197,7 +2204,7 @@ export const adminRouter = router({
|
|||||||
message: 'Profile install operation did not complete in time.',
|
message: 'Profile install operation did not complete in time.',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isUniqueConstraintError(error)) {
|
if (!isProfileOperationConflictError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
|
|||||||
|
|
||||||
export const CONTROL_PLANE_OPERATION_CLAIM_LOCK = 'gateway_control_plane_operation_claim';
|
export const CONTROL_PLANE_OPERATION_CLAIM_LOCK = 'gateway_control_plane_operation_claim';
|
||||||
|
|
||||||
|
export class GatewayProfileOperationConflictError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('This profile already has an incompatible queued or running operation.');
|
||||||
|
this.name = 'GatewayProfileOperationConflictError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus };
|
export { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus };
|
||||||
|
|
||||||
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
|
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
|
||||||
@@ -352,6 +359,24 @@ const mapOperationLog = (row: {
|
|||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canQueueAlongsideActiveOperations = (
|
||||||
|
input: Pick<GatewayOperationCreateInput, 'type' | 'scheduledAt'>,
|
||||||
|
activeOperations: GatewayOperationRow[],
|
||||||
|
now: Date
|
||||||
|
): boolean => {
|
||||||
|
if (activeOperations.length === 0) return true;
|
||||||
|
if (input.type !== 'DEPLOY' || input.scheduledAt || activeOperations.length !== 1) return false;
|
||||||
|
|
||||||
|
const [reservedReset] = activeOperations;
|
||||||
|
return Boolean(
|
||||||
|
reservedReset &&
|
||||||
|
reservedReset.type === 'RESET' &&
|
||||||
|
reservedReset.status === 'QUEUED' &&
|
||||||
|
reservedReset.scheduledAt &&
|
||||||
|
reservedReset.scheduledAt.getTime() > now.getTime()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
|
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
|
||||||
async listProfiles(): Promise<GatewayProfileRecord[]> {
|
async listProfiles(): Promise<GatewayProfileRecord[]> {
|
||||||
const rows = await prisma.gatewayProfile.findMany({
|
const rows = await prisma.gatewayProfile.findMany({
|
||||||
@@ -620,6 +645,21 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
},
|
},
|
||||||
async createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord> {
|
async createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord> {
|
||||||
const row = await prisma.$transaction(async (tx) => {
|
const row = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRaw<Array<{ lock_result: string }>>`
|
||||||
|
SELECT pg_advisory_xact_lock(
|
||||||
|
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
|
||||||
|
)::text AS lock_result
|
||||||
|
`;
|
||||||
|
const activeOperations = await tx.gatewayOperation.findMany({
|
||||||
|
where: {
|
||||||
|
profileName: input.profileName,
|
||||||
|
status: { in: ['QUEUED', 'RUNNING'] },
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
if (!canQueueAlongsideActiveOperations(input, activeOperations, new Date())) {
|
||||||
|
throw new GatewayProfileOperationConflictError();
|
||||||
|
}
|
||||||
const operation = await tx.gatewayOperation.create({
|
const operation = await tx.gatewayOperation.create({
|
||||||
data: {
|
data: {
|
||||||
profileName: input.profileName,
|
profileName: input.profileName,
|
||||||
@@ -675,6 +715,37 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
if (running && !runningIsStale) {
|
if (running && !runningIsStale) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const expiredInterimDeploys = await tx.$queryRaw<Array<{ id: string }>>`
|
||||||
|
UPDATE "gateway_operation" AS deploy
|
||||||
|
SET "status" = 'CANCELLED',
|
||||||
|
"completed_at" = ${now},
|
||||||
|
"lease_owner" = NULL,
|
||||||
|
"lease_until" = NULL,
|
||||||
|
"heartbeat_at" = NULL,
|
||||||
|
"updated_at" = ${now}
|
||||||
|
WHERE deploy."status" = 'QUEUED'
|
||||||
|
AND deploy."type" = 'DEPLOY'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "gateway_operation" AS reset
|
||||||
|
WHERE reset."profile_name" = deploy."profile_name"
|
||||||
|
AND reset."status" = 'QUEUED'
|
||||||
|
AND reset."type" = 'RESET'
|
||||||
|
AND reset."scheduled_at" IS NOT NULL
|
||||||
|
AND reset."scheduled_at" <= ${now}
|
||||||
|
)
|
||||||
|
RETURNING deploy."id"
|
||||||
|
`;
|
||||||
|
if (expiredInterimDeploys.length) {
|
||||||
|
await tx.gatewayOperationLog.createMany({
|
||||||
|
data: expiredInterimDeploys.map(({ id }) => ({
|
||||||
|
operationId: id,
|
||||||
|
level: 'INFO',
|
||||||
|
phase: 'cancel',
|
||||||
|
message: '예약 시나리오 초기화 시각이 되어 실행 전 중간 버전 업데이트를 자동 취소했습니다.',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
const candidate =
|
const candidate =
|
||||||
running ??
|
running ??
|
||||||
(await tx.gatewayOperation.findFirst({
|
(await tx.gatewayOperation.findFirst({
|
||||||
@@ -887,10 +958,31 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
},
|
},
|
||||||
async retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null> {
|
async retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null> {
|
||||||
const row = await prisma.$transaction(async (tx) => {
|
const row = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRaw<Array<{ lock_result: string }>>`
|
||||||
|
SELECT pg_advisory_xact_lock(
|
||||||
|
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
|
||||||
|
)::text AS lock_result
|
||||||
|
`;
|
||||||
const previous = await tx.gatewayOperation.findUnique({ where: { id } });
|
const previous = await tx.gatewayOperation.findUnique({ where: { id } });
|
||||||
if (!previous || (previous.status !== 'FAILED' && previous.status !== 'CANCELLED')) {
|
if (!previous || (previous.status !== 'FAILED' && previous.status !== 'CANCELLED')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const activeOperations = await tx.gatewayOperation.findMany({
|
||||||
|
where: {
|
||||||
|
profileName: previous.profileName,
|
||||||
|
status: { in: ['QUEUED', 'RUNNING'] },
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!canQueueAlongsideActiveOperations(
|
||||||
|
{ type: previous.type, scheduledAt: undefined },
|
||||||
|
activeOperations,
|
||||||
|
new Date()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new GatewayProfileOperationConflictError();
|
||||||
|
}
|
||||||
const previousPayload = previous.payload as GatewayPrisma.JsonObject;
|
const previousPayload = previous.payload as GatewayPrisma.JsonObject;
|
||||||
const retrySource = buildRetryOperationSource(previous);
|
const retrySource = buildRetryOperationSource(previous);
|
||||||
const operation = await tx.gatewayOperation.create({
|
const operation = await tx.gatewayOperation.create({
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
|||||||
await connector.disconnect();
|
await connector.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows only one queued or running operation for a profile', async () => {
|
it('allows only one incompatible queued or running operation for a profile', async () => {
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
repository.createOperation({
|
repository.createOperation({
|
||||||
profileName,
|
profileName,
|
||||||
@@ -73,6 +73,118 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
|||||||
await expect(repository.listOperations({ profileName })).resolves.toHaveLength(1);
|
await expect(repository.listOperations({ profileName })).resolves.toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a future scheduled RESET while one interim DEPLOY runs and completes first', async () => {
|
||||||
|
const scheduledAt = new Date('2099-01-01T00:00:00.000Z');
|
||||||
|
const scheduledReset = await repository.createOperation({
|
||||||
|
profileName,
|
||||||
|
type: 'RESET',
|
||||||
|
sourceMode: 'BRANCH',
|
||||||
|
sourceRef: 'main',
|
||||||
|
scheduledAt: scheduledAt.toISOString(),
|
||||||
|
requestedBy: 'reset-admin',
|
||||||
|
});
|
||||||
|
const interimDeploy = await repository.createOperation({
|
||||||
|
profileName,
|
||||||
|
type: 'DEPLOY',
|
||||||
|
sourceMode: 'BRANCH',
|
||||||
|
sourceRef: 'main',
|
||||||
|
requestedBy: 'deploy-admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.createOperation({
|
||||||
|
profileName,
|
||||||
|
type: 'STOP',
|
||||||
|
requestedBy: 'runtime-admin',
|
||||||
|
})
|
||||||
|
).rejects.toThrow('incompatible queued or running operation');
|
||||||
|
await expect(repository.listOperations({ profileName })).resolves.toHaveLength(2);
|
||||||
|
|
||||||
|
const beforeReset = new Date('2098-12-31T23:00:00.000Z');
|
||||||
|
await expect(
|
||||||
|
repository.claimNextOperation(beforeReset, { ownerId: 'worker-a', durationMs: 1_000 })
|
||||||
|
).resolves.toMatchObject({ id: interimDeploy.id, type: 'DEPLOY', status: 'RUNNING' });
|
||||||
|
await repository.completeOperation(interimDeploy.id, 'SUCCEEDED', { error: null }, 'worker-a');
|
||||||
|
await expect(repository.getOperation(scheduledReset.id)).resolves.toMatchObject({
|
||||||
|
status: 'QUEUED',
|
||||||
|
scheduledAt: scheduledAt.toISOString(),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
repository.claimNextOperation(beforeReset, { ownerId: 'worker-b', durationMs: 1_000 })
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repository.claimNextOperation(scheduledAt, { ownerId: 'worker-b', durationMs: 1_000 })
|
||||||
|
).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts only one of two concurrent interim DEPLOY requests beside a scheduled RESET', async () => {
|
||||||
|
await repository.createOperation({
|
||||||
|
profileName,
|
||||||
|
type: 'RESET',
|
||||||
|
sourceMode: 'BRANCH',
|
||||||
|
sourceRef: 'main',
|
||||||
|
scheduledAt: new Date('2099-01-01T00:00:00.000Z').toISOString(),
|
||||||
|
requestedBy: 'reset-admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
repository.createOperation({
|
||||||
|
profileName,
|
||||||
|
type: 'DEPLOY',
|
||||||
|
sourceMode: 'BRANCH',
|
||||||
|
sourceRef: 'main',
|
||||||
|
requestedBy: 'deploy-admin-a',
|
||||||
|
}),
|
||||||
|
repository.createOperation({
|
||||||
|
profileName,
|
||||||
|
type: 'DEPLOY',
|
||||||
|
sourceMode: 'BRANCH',
|
||||||
|
sourceRef: 'main',
|
||||||
|
requestedBy: 'deploy-admin-b',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||||
|
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
||||||
|
await expect(repository.listOperations({ profileName })).resolves.toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-cancels an interim DEPLOY that is still queued when the scheduled RESET becomes due', async () => {
|
||||||
|
const scheduledAt = new Date('2099-01-01T00:00:00.000Z');
|
||||||
|
const scheduledReset = await repository.createOperation({
|
||||||
|
profileName,
|
||||||
|
type: 'RESET',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: 'a'.repeat(40),
|
||||||
|
scheduledAt: scheduledAt.toISOString(),
|
||||||
|
requestedBy: 'reset-admin',
|
||||||
|
});
|
||||||
|
const interimDeploy = await repository.createOperation({
|
||||||
|
profileName,
|
||||||
|
type: 'DEPLOY',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: 'b'.repeat(40),
|
||||||
|
requestedBy: 'deploy-admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.claimNextOperation(scheduledAt, { ownerId: 'worker-a', durationMs: 1_000 })
|
||||||
|
).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' });
|
||||||
|
await expect(repository.getOperation(interimDeploy.id)).resolves.toMatchObject({
|
||||||
|
status: 'CANCELLED',
|
||||||
|
completedAt: scheduledAt.toISOString(),
|
||||||
|
});
|
||||||
|
await expect(repository.listOperationLogs(interimDeploy.id)).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: 'queue',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: 'cancel',
|
||||||
|
message: expect.stringContaining('중간 버전 업데이트를 자동 취소'),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('stores durable cursor logs for profile operations', async () => {
|
it('stores durable cursor logs for profile operations', async () => {
|
||||||
const operation = await repository.createOperation({
|
const operation = await repository.createOperation({
|
||||||
profileName,
|
profileName,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
|
|||||||
|
|
||||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20260823010000_add_web_push_notifications',
|
gatewaySchemaHead: '20260824090000_allow_interim_profile_deploy',
|
||||||
gameSchemaHead: '20260824080000_vote_utc_wall_timestamps',
|
gameSchemaHead: '20260824080000_vote_utc_wall_timestamps',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Operation = {
|
|||||||
sourceMode?: 'BRANCH' | 'COMMIT';
|
sourceMode?: 'BRANCH' | 'COMMIT';
|
||||||
sourceRef?: string;
|
sourceRef?: string;
|
||||||
resolvedCommitSha?: string;
|
resolvedCommitSha?: string;
|
||||||
|
scheduledAt?: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
@@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
createdAt: '2026-07-25T02:00:00.000Z',
|
createdAt: '2026-07-25T02:00:00.000Z',
|
||||||
updatedAt: '2026-07-25T02:00:00.000Z',
|
updatedAt: '2026-07-25T02:00:00.000Z',
|
||||||
};
|
};
|
||||||
state.operations = [operation];
|
state.operations = [operation, ...state.operations];
|
||||||
return response(operation);
|
return response(operation);
|
||||||
}
|
}
|
||||||
if (name === 'admin.operations.requestDeploy') {
|
if (name === 'admin.operations.requestDeploy') {
|
||||||
@@ -398,7 +399,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
createdAt: '2026-08-01T01:00:00.000Z',
|
createdAt: '2026-08-01T01:00:00.000Z',
|
||||||
updatedAt: '2026-08-01T01:00:00.000Z',
|
updatedAt: '2026-08-01T01:00:00.000Z',
|
||||||
};
|
};
|
||||||
state.operations = [operation];
|
state.operations = [operation, ...state.operations];
|
||||||
return response(operation);
|
return response(operation);
|
||||||
}
|
}
|
||||||
if (name === 'admin.operations.requestGameCancellation') {
|
if (name === 'admin.operations.requestGameCancellation') {
|
||||||
@@ -817,6 +818,67 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
|||||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps a future scenario reset reserved while submitting one interim DB-preserving deploy', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const scheduledReset: Operation = {
|
||||||
|
id: '99999999-9999-4999-8999-999999999999',
|
||||||
|
profileName: 'che:default',
|
||||||
|
type: 'RESET',
|
||||||
|
status: 'QUEUED',
|
||||||
|
sourceMode: 'BRANCH',
|
||||||
|
sourceRef: 'main',
|
||||||
|
scheduledAt: '2099-08-27T05:00:00.000Z',
|
||||||
|
payload: {},
|
||||||
|
requestedBy: 'admin',
|
||||||
|
createdAt: '2026-08-24T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-24T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const state: FixtureState = {
|
||||||
|
operations: [scheduledReset],
|
||||||
|
gatewayOperations: [],
|
||||||
|
runtimeRunning: true,
|
||||||
|
requestBodies: [],
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
const confirmations: string[] = [];
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
confirmations.push(dialog.message());
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('admin/servers/che%3Adefault/version');
|
||||||
|
const notice = page.getByTestId('interim-deploy-notice');
|
||||||
|
await expect(notice).toContainText('예약 초기화 유지');
|
||||||
|
await expect(notice).toContainText('시나리오 초기화 예약은 취소되지 않습니다.');
|
||||||
|
await expect(page.getByTestId('request-deploy')).toBeEnabled();
|
||||||
|
await page.getByTestId('request-deploy').click();
|
||||||
|
|
||||||
|
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
|
||||||
|
expect(confirmations).toHaveLength(1);
|
||||||
|
expect(confirmations[0]).toContain('예약된 시나리오 초기화');
|
||||||
|
expect(confirmations[0]).toContain('자동 취소됩니다.');
|
||||||
|
expect(state.operations).toHaveLength(2);
|
||||||
|
expect(state.operations).toContainEqual(scheduledReset);
|
||||||
|
expect(state.requestBodies.filter((entry) => entry.operation === 'admin.operations.requestDeploy')).toHaveLength(1);
|
||||||
|
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const mobileGeometry = await notice.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
viewportWidth: document.documentElement.clientWidth,
|
||||||
|
documentScrollWidth: document.documentElement.scrollWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(mobileGeometry.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||||
|
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('interim-deploy-mobile.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('submits a separately authorized destructive game cancellation on desktop and mobile', async ({
|
test('submits a separately authorized destructive game cancellation on desktop and mobile', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|||||||
@@ -268,15 +268,35 @@ const pageDescription = computed(() => {
|
|||||||
return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.';
|
return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.';
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeOperation = computed(
|
const activeOperations = computed(() =>
|
||||||
|
operations.value.filter(
|
||||||
|
(operation) =>
|
||||||
|
operation.profileName === selectedProfileName.value &&
|
||||||
|
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeOperation = computed(() => activeOperations.value[0] ?? null);
|
||||||
|
|
||||||
|
const queuedFutureScheduledReset = computed(
|
||||||
() =>
|
() =>
|
||||||
operations.value.find(
|
activeOperations.value.find(
|
||||||
(operation) =>
|
(operation) =>
|
||||||
operation.profileName === selectedProfileName.value &&
|
operation.type === 'RESET' &&
|
||||||
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
|
operation.status === 'QUEUED' &&
|
||||||
|
Boolean(operation.scheduledAt) &&
|
||||||
|
new Date(operation.scheduledAt ?? '').getTime() > Date.now()
|
||||||
) ?? null
|
) ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deployBlockingOperation = computed(() => {
|
||||||
|
if (activeOperations.value.length === 1 && queuedFutureScheduledReset.value) return null;
|
||||||
|
return (
|
||||||
|
activeOperations.value.find((operation) => operation.id !== queuedFutureScheduledReset.value?.id) ??
|
||||||
|
activeOperation.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const sourceHelp = computed(() =>
|
const sourceHelp = computed(() =>
|
||||||
form.sourceMode === 'CURRENT'
|
form.sourceMode === 'CURRENT'
|
||||||
? '서버가 브랜치를 추적하면 작업 시작 시 최신 커밋을 사용하고, 커밋 고정 상태면 그 버전을 유지합니다.'
|
? '서버가 브랜치를 추적하면 작업 시작 시 최신 커밋을 사용하고, 커밋 고정 상태면 그 버전을 유지합니다.'
|
||||||
@@ -564,15 +584,18 @@ const requestDeploy = async () => {
|
|||||||
if (
|
if (
|
||||||
!selectedProfileName.value ||
|
!selectedProfileName.value ||
|
||||||
!selectedProfileIdentityReady.value ||
|
!selectedProfileIdentityReady.value ||
|
||||||
activeOperation.value ||
|
deployBlockingOperation.value ||
|
||||||
!form.sourceRef.trim() ||
|
!form.sourceRef.trim() ||
|
||||||
form.sourceMode === 'CURRENT'
|
form.sourceMode === 'CURRENT'
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const reservedResetReminder = queuedFutureScheduledReset.value?.scheduledAt
|
||||||
|
? `\n예약된 시나리오 초기화(${formatTime(queuedFutureScheduledReset.value.scheduledAt)})는 유지됩니다. 이 배포가 그 시각까지 시작되지 못하면 자동 취소됩니다.`
|
||||||
|
: '';
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
`${selectedProfileDisplayName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?`
|
`${selectedProfileDisplayName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?${reservedResetReminder}`
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -1054,6 +1077,18 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="mode === 'version' && queuedFutureScheduledReset"
|
||||||
|
class="rounded border border-cyan-800/80 bg-cyan-950/35 px-4 py-3 text-sm text-cyan-100"
|
||||||
|
data-testid="interim-deploy-notice"
|
||||||
|
>
|
||||||
|
<strong>예약 초기화 유지</strong>
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ formatTime(queuedFutureScheduledReset.scheduledAt) }} 시나리오 초기화 예약은 취소되지
|
||||||
|
않습니다. 지금 DB 유지 배포가 그 시각까지 시작되지 못하면 자동 취소됩니다.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset class="space-y-2">
|
<fieldset class="space-y-2">
|
||||||
<legend class="text-xs text-zinc-400">소스 종류</legend>
|
<legend class="text-xs text-zinc-400">소스 종류</legend>
|
||||||
<div class="flex gap-5">
|
<div class="flex gap-5">
|
||||||
@@ -1493,7 +1528,7 @@ onBeforeUnmount(() => {
|
|||||||
:disabled="
|
:disabled="
|
||||||
submitting ||
|
submitting ||
|
||||||
!selectedProfileIdentityReady ||
|
!selectedProfileIdentityReady ||
|
||||||
Boolean(activeOperation) ||
|
Boolean(deployBlockingOperation) ||
|
||||||
!form.sourceRef.trim()
|
!form.sourceRef.trim()
|
||||||
"
|
"
|
||||||
data-testid="request-deploy"
|
data-testid="request-deploy"
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
-- Keep the opening RESET reservation while allowing exactly one immediate
|
||||||
|
-- operation (the application permits only DEPLOY) beside it. A single RUNNING
|
||||||
|
-- lane preserves the existing profile mutation serialization contract.
|
||||||
|
DROP INDEX "gateway_operation_one_active_per_profile_idx";
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "gateway_operation_one_running_per_profile_idx"
|
||||||
|
ON "gateway_operation" ("profile_name")
|
||||||
|
WHERE "status" = 'RUNNING';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "gateway_operation_one_queued_scheduled_reset_per_profile_idx"
|
||||||
|
ON "gateway_operation" ("profile_name")
|
||||||
|
WHERE "status" = 'QUEUED'
|
||||||
|
AND "type" = 'RESET'
|
||||||
|
AND "scheduled_at" IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "gateway_operation_one_queued_immediate_per_profile_idx"
|
||||||
|
ON "gateway_operation" ("profile_name")
|
||||||
|
WHERE "status" = 'QUEUED'
|
||||||
|
AND NOT ("type" = 'RESET' AND "scheduled_at" IS NOT NULL);
|
||||||
@@ -362,8 +362,9 @@ model GatewayRuntimeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model GatewayOperation {
|
model GatewayOperation {
|
||||||
/// A partial unique index in the gateway migration chain permits only one
|
/// Partial unique indexes in the gateway migration chain permit one future
|
||||||
/// QUEUED or RUNNING operation per profile.
|
/// scheduled RESET beside one immediate queued operation. RUNNING work and
|
||||||
|
/// each queue lane remain unique per profile.
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
profileName String @map("profile_name")
|
profileName String @map("profile_name")
|
||||||
profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade)
|
profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260823010000_add_web_push_notifications",
|
"gatewaySchemaHead": "20260824090000_allow_interim_profile_deploy",
|
||||||
"gameSchemaHead": "20260824080000_vote_utc_wall_timestamps",
|
"gameSchemaHead": "20260824080000_vote_utc_wall_timestamps",
|
||||||
"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