diff --git a/app/game-engine/test/gatewayRuntimeAction.integration.test.ts b/app/game-engine/test/gatewayRuntimeAction.integration.test.ts index 8be39b24..aefa199d 100644 --- a/app/game-engine/test/gatewayRuntimeAction.integration.test.ts +++ b/app/game-engine/test/gatewayRuntimeAction.integration.test.ts @@ -50,6 +50,8 @@ integration('gateway runtime action consumer', () => { create: { profileName, profile: 'runtime', + instanceKey: 'consumer-integration', + currentScenario: 'consumer-integration', scenario: 'consumer-integration', apiPort: 15998, status: 'RUNNING', diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index a4531a90..6bed55bf 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1280,7 +1280,10 @@ export const adminRouter = router({ sourceRef = resolved; } const scenarios = await listScenarioPreviews({ gitRef: resolved }); - if (!scenarios.some((scenario) => String(scenario.id) === profile.scenario)) { + if ( + profile.currentScenario === null || + !scenarios.some((scenario) => String(scenario.id) === profile.currentScenario) + ) { throw new Error('Current scenario is not available at source.'); } } catch { @@ -1579,6 +1582,8 @@ export const adminRouter = router({ .map((profile) => ({ profileName: profile.profileName, profile: profile.profile, + instanceKey: profile.instanceKey, + currentScenario: profile.currentScenario, meta: { ...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}), }, @@ -1661,7 +1666,8 @@ export const adminRouter = router({ ); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); - const parsedScenarioId = Number(profile.scenario); + const parsedScenarioId = + profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario); currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null; gitRef = profile.buildCommitSha?.trim(); if (!gitRef) { @@ -1692,8 +1698,13 @@ export const adminRouter = router({ upsert: profileAdminProcedure .input( z.object({ - profile: z.string().min(1).max(32), - scenario: z.string().min(1).max(64), + profile: z.string().regex(/^[a-z0-9-]{1,32}$/), + instanceKey: z + .string() + .regex(/^[a-z0-9-]{1,64}$/) + .optional(), + currentScenario: z.string().min(1).max(64).nullable().optional(), + scenario: z.string().min(1).max(64).optional(), apiPort: z.number().int().min(1).max(65535), status: zProfileStatus.optional(), preopenAt: z.string().datetime().optional(), @@ -1706,6 +1717,8 @@ export const adminRouter = router({ const status = input.status ?? 'STOPPED'; return ctx.profiles.upsertProfile({ profile: input.profile, + instanceKey: input.instanceKey, + currentScenario: input.currentScenario, scenario: input.scenario, apiPort: input.apiPort, status, diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index bcf165b7..5b2e5597 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -21,6 +21,9 @@ export type LobbyGeneralStatus = { export type LobbyProfileStatus = { profileName: string; profile: string; + instanceKey: string; + currentScenario: string | null; + /** @deprecated Rollback-compatible mirror of currentScenario. */ scenario: string; status: GatewayProfileStatus; apiPort: number; @@ -87,6 +90,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi return { profileName: row.profileName, profile: row.profile, + instanceKey: row.instanceKey, + currentScenario: row.currentScenario, scenario: row.scenario, status: row.status, apiPort: row.apiPort, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 365d2a16..a0a72a6e 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -396,7 +396,7 @@ export const buildProcessDefinitions = ( ...baseEnv, GAME_API_ROLE: 'server', PROFILE: profile.profile, - SCENARIO: profile.scenario, + SCENARIO: profile.currentScenario ?? 'default', GAME_PROFILE_NAME: profile.profileName, GAME_API_PORT: String(profile.apiPort), GAME_TRPC_PATH: `/${profile.profile}/api/trpc`, @@ -411,7 +411,7 @@ export const buildProcessDefinitions = ( GAME_ENGINE_ROLE: 'turn-daemon', TURN_PROFILE: profile.profile, PROFILE: profile.profile, - SCENARIO: profile.scenario, + SCENARIO: profile.currentScenario ?? 'default', TURN_PROFILE_NAME: profile.profileName, }; return { @@ -1422,8 +1422,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } = parseInstallOptions(action); const tickOverride = installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined; - const scenarioId = installScenarioId ?? parseScenarioId(profile.scenario); - if (!scenarioId) { + const scenarioId = installScenarioId ?? parseScenarioId(profile.currentScenario); + if (scenarioId === null) { return { status: 'FAILED', detail: 'scenarioId is missing' }; } const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile); @@ -1547,7 +1547,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING'; const publishedProfile = await updateClaimedProfile( { - scenario: String(scenarioId), + currentScenario: String(scenarioId), status: desiredStatus, buildStatus: 'SUCCEEDED', buildWorkspace: workspace.root, @@ -1564,8 +1564,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { completedAt, error: null, }); - if (String(scenarioId) !== profile.scenario) { - await this.repository.updateScenario(profile.profileName, String(scenarioId)); + if (String(scenarioId) !== profile.currentScenario) { + await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId)); } return this.repository.updateStatus(profile.profileName, desiredStatus, { preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null, @@ -1577,6 +1577,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { releasePrepared = true; const builtProfile = publishedProfile ?? { ...profile, + currentScenario: String(scenarioId), scenario: String(scenarioId), status: desiredStatus, buildWorkspace: workspace.root, @@ -1642,7 +1643,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { meta: Record; }> { const databaseUrl = databaseUrlOverride ?? this.resolveProfileDatabaseUrl(profile); - let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.scenario); + let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.currentScenario); let tickSeconds: number | undefined = overrides?.tickSeconds; let meta: Record = {}; const connector = createGamePostgresConnector({ url: databaseUrl }); diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index b60b2186..9f75392d 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -78,6 +78,9 @@ export interface GatewayOperationLogInput { export interface GatewayProfileRecord { profileName: string; profile: string; + instanceKey: string; + currentScenario: string | null; + /** @deprecated Rollback-compatible mirror of currentScenario. */ scenario: string; apiPort: number; status: GatewayProfileStatus; @@ -100,7 +103,10 @@ export interface GatewayProfileRecord { export interface GatewayProfileUpsertInput { profile: string; - scenario: string; + instanceKey?: string; + currentScenario?: string | null; + /** @deprecated Accepted while older bootstrap clients are still supported. */ + scenario?: string; apiPort: number; status?: GatewayProfileStatus; preopenAt?: string; @@ -111,7 +117,7 @@ export interface GatewayProfileUpsertInput { } export interface GatewayClaimedProfileUpdate { - scenario?: string; + currentScenario?: string | null; status?: GatewayProfileStatus; buildStatus?: GatewayBuildStatus; buildCommitSha?: string | null; @@ -131,7 +137,7 @@ export interface GatewayProfileRepository { listProfiles(): Promise; getProfile(profileName: string): Promise; upsertProfile(input: GatewayProfileUpsertInput): Promise; - updateScenario(profileName: string, scenario: string): Promise; + updateCurrentScenario(profileName: string, scenario: string | null): Promise; updateStatus( profileName: string, status: GatewayProfileStatus, @@ -219,6 +225,8 @@ export const buildRetryOperationSource = (previous: { type GatewayProfileRow = { profileName: string; profile: string; + instanceKey: string; + currentScenario: string | null; scenario: string; apiPort: number; status: GatewayProfileStatus; @@ -265,6 +273,8 @@ type GatewayOperationRow = { const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({ profileName: row.profileName, profile: row.profile, + instanceKey: row.instanceKey, + currentScenario: row.currentScenario, scenario: row.scenario, apiPort: row.apiPort, status: row.status, @@ -285,7 +295,24 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({ updatedAt: row.updatedAt.toISOString(), }); -const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`; +export const buildGatewayProfileName = (profile: string, instanceKey: string): string => `${profile}:${instanceKey}`; + +export const resolveGatewayProfileIdentity = ( + input: GatewayProfileUpsertInput +): { + instanceKey: string; + currentScenario: string | null; + shouldUpdateCurrentScenario: boolean; +} => { + const instanceKey = input.instanceKey ?? input.scenario ?? 'default'; + if (input.currentScenario !== undefined) { + return { instanceKey, currentScenario: input.currentScenario, shouldUpdateCurrentScenario: true }; + } + if (input.instanceKey === undefined && input.scenario !== undefined && input.scenario !== 'default') { + return { instanceKey, currentScenario: input.scenario, shouldUpdateCurrentScenario: true }; + } + return { instanceKey, currentScenario: null, shouldUpdateCurrentScenario: false }; +}; const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({ id: row.id, @@ -331,7 +358,7 @@ const mapOperationLog = (row: { export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({ async listProfiles(): Promise { const rows = await prisma.gatewayProfile.findMany({ - orderBy: [{ profile: 'asc' }, { scenario: 'asc' }], + orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }], }); return rows.map(mapProfile); }, @@ -342,13 +369,16 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat return row ? mapProfile(row) : null; }, async upsertProfile(input: GatewayProfileUpsertInput): Promise { - const profileName = buildProfileName(input.profile, input.scenario); + const { instanceKey, currentScenario, shouldUpdateCurrentScenario } = resolveGatewayProfileIdentity(input); + const profileName = buildGatewayProfileName(input.profile, instanceKey); const row = await prisma.gatewayProfile.upsert({ where: { profileName }, create: { profileName, profile: input.profile, - scenario: input.scenario, + instanceKey, + currentScenario, + scenario: currentScenario ?? 'default', apiPort: input.apiPort, status: input.status ?? 'STOPPED', preopenAt: input.preopenAt ? new Date(input.preopenAt) : null, @@ -358,6 +388,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat meta: (input.meta ?? {}) as GatewayPrisma.JsonObject, }, update: { + currentScenario: shouldUpdateCurrentScenario ? currentScenario : undefined, + scenario: shouldUpdateCurrentScenario ? (currentScenario ?? 'default') : undefined, apiPort: input.apiPort, status: input.status, preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined, @@ -373,11 +405,12 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat }); return mapProfile(row); }, - async updateScenario(profileName: string, scenario: string): Promise { + async updateCurrentScenario(profileName: string, scenario: string | null): Promise { const row = await prisma.gatewayProfile.update({ where: { profileName }, data: { - scenario, + currentScenario: scenario, + scenario: scenario ?? 'default', }, }); return row ? mapProfile(row) : null; @@ -700,7 +733,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat return tx.gatewayProfile.update({ where: { profileName }, data: { - scenario: patch.scenario, + currentScenario: patch.currentScenario, + scenario: patch.currentScenario === undefined ? undefined : (patch.currentScenario ?? 'default'), status: patch.status, buildStatus: patch.buildStatus, buildCommitSha: patch.buildCommitSha, diff --git a/app/gateway-api/src/profileOrder.ts b/app/gateway-api/src/profileOrder.ts index 8762c48c..02f1ecef 100644 --- a/app/gateway-api/src/profileOrder.ts +++ b/app/gateway-api/src/profileOrder.ts @@ -3,8 +3,8 @@ export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', const gatewayProfileOrder = new Map(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index])); export const compareGatewayProfiles = ( - left: { profile: string; scenario: string }, - right: { profile: string; scenario: string } + left: { profile: string; instanceKey: string }, + right: { profile: string; instanceKey: string } ): number => { const unknownRank = GATEWAY_PROFILE_ORDER.length; const profileOrder = @@ -14,8 +14,8 @@ export const compareGatewayProfiles = ( const profileNameOrder = left.profile.localeCompare(right.profile); if (profileNameOrder !== 0) return profileNameOrder; - return left.scenario.localeCompare(right.scenario); + return left.instanceKey.localeCompare(right.instanceKey); }; -export const orderGatewayProfiles = (profiles: readonly T[]): T[] => +export const orderGatewayProfiles = (profiles: readonly T[]): T[] => [...profiles].sort(compareGatewayProfiles); diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 4eb21690..e8e0954c 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -85,6 +85,8 @@ const buildCaller = async ( const profile = { profileName: 'che:2', profile: 'che', + instanceKey: '2', + currentScenario: options.profileScenario ?? '2', scenario: options.profileScenario ?? '2', apiPort: 15003, status: options.initialProfileStatus ?? ('STOPPED' as const), @@ -98,7 +100,7 @@ const buildCaller = async ( listProfiles: async () => [profile], getProfile: async () => profile, upsertProfile: async () => profile, - updateScenario: async () => profile, + updateCurrentScenario: async () => profile, updateStatus: async (_profileName, status) => { updatedStatuses.push(status); return { ...profile, status }; @@ -362,6 +364,8 @@ describe('admin profile navigation API', () => { { profileName: 'che:2', profile: 'che', + instanceKey: '2', + currentScenario: '2', meta: {}, }, ]); diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index 0b23020f..da662e20 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -15,6 +15,8 @@ import { appRouter } from '../src/router.js'; const profile = { profileName: 'che:default', profile: 'che', + instanceKey: 'default', + currentScenario: null, scenario: 'default', apiPort: 15003, status: 'RUNNING' as const, @@ -28,7 +30,7 @@ const profiles: GatewayProfileRepository = { listProfiles: async () => [profile], getProfile: async (profileName) => (profileName === profile.profileName ? profile : null), upsertProfile: async () => profile, - updateScenario: async () => profile, + updateCurrentScenario: async () => profile, updateStatus: async () => profile, updateBuildStatus: async () => profile, updateMeta: async () => profile, diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 48f217d6..7abadc55 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -92,6 +92,8 @@ const buildCaller = ( { profileName: 'che:default', profile: 'che', + instanceKey: 'default', + currentScenario: null, scenario: 'default', apiPort: 15003, status: 'RUNNING' as const, @@ -103,6 +105,8 @@ const buildCaller = ( { profileName: 'hwe:default', profile: 'hwe', + instanceKey: 'default', + currentScenario: null, scenario: 'default', apiPort: 15015, status: 'RUNNING' as const, @@ -119,7 +123,7 @@ const buildCaller = ( upsertProfile: async () => { throw new Error('not used'); }, - updateScenario: async () => null, + updateCurrentScenario: async () => null, updateStatus: async () => null, updateBuildStatus: async () => null, updateMeta: async () => null, @@ -167,6 +171,8 @@ const buildCaller = ( profileRows.map((profile) => ({ profileName: profile.profileName, profile: profile.profile, + instanceKey: profile.instanceKey, + currentScenario: profile.currentScenario, scenario: profile.scenario, status: profile.status, apiPort: profile.apiPort, diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 01306ecb..6d961d16 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -13,6 +13,8 @@ import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js'; const profile: GatewayProfileRecord = { profileName: 'che:2', profile: 'che', + instanceKey: '2', + currentScenario: '2', scenario: '2', apiPort: 15003, status: 'STOPPED', @@ -58,7 +60,7 @@ const createHarness = ( listProfiles: async () => [profile], getProfile: async () => profile, upsertProfile: async () => profile, - updateScenario: async () => profile, + updateCurrentScenario: async () => profile, updateStatus: async (_profileName, status) => { statuses.push(status); return { ...profile, status }; diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 1116ad75..7685460f 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -14,6 +14,8 @@ import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({ profileName: 'che:2', profile: 'che', + instanceKey: '2', + currentScenario: '2', scenario: '2', apiPort: 15003, status: 'RUNNING', @@ -172,6 +174,39 @@ describe('buildProcessDefinitions', () => { expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); }); + it('keeps the instance identity stable while passing the mutable current scenario', () => { + const definitions = buildProcessDefinitions( + { + ...buildProfile(), + profileName: 'che:default', + instanceKey: 'default', + currentScenario: '1010', + scenario: '1010', + }, + processConfig + ); + + expect(definitions.api.name).toBe('sammo:che:default:game-api'); + expect(definitions.api.env).toMatchObject({ + GAME_PROFILE_NAME: 'che:default', + SCENARIO: '1010', + }); + expect(definitions.daemon.env).toMatchObject({ + TURN_PROFILE_NAME: 'che:default', + SCENARIO: '1010', + }); + }); + + it('uses the legacy default scenario marker only for an uninitialized instance runtime', () => { + const definitions = buildProcessDefinitions( + { ...buildProfile(), currentScenario: null, scenario: 'default' }, + processConfig + ); + + expect(definitions.api.env.SCENARIO).toBe('default'); + expect(definitions.daemon.env.SCENARIO).toBe('default'); + }); + it('does not forward PM2 identity or parent runtime roles to profile processes', () => { const definitions = buildProcessDefinitions(buildProfile(), { ...processConfig, diff --git a/app/gateway-api/test/orchestratorWorkspaceCleanup.test.ts b/app/gateway-api/test/orchestratorWorkspaceCleanup.test.ts index 82f0531c..b54a1a04 100644 --- a/app/gateway-api/test/orchestratorWorkspaceCleanup.test.ts +++ b/app/gateway-api/test/orchestratorWorkspaceCleanup.test.ts @@ -14,6 +14,8 @@ const makeProfile = ( ): GatewayProfileRecord => ({ profileName, profile: profileName.split(':')[0] ?? 'che', + instanceKey: profileName.split(':')[1] ?? 'default', + currentScenario: null, scenario: profileName.split(':')[1] ?? 'default', apiPort: 15_003, status: 'RUNNING', diff --git a/app/gateway-api/test/profileDeployOperation.test.ts b/app/gateway-api/test/profileDeployOperation.test.ts index 5155b701..5b74ba6d 100644 --- a/app/gateway-api/test/profileDeployOperation.test.ts +++ b/app/gateway-api/test/profileDeployOperation.test.ts @@ -50,6 +50,8 @@ describe('profile DEPLOY operation', () => { const profile: GatewayProfileRecord = { profileName: 'che:1010', profile: 'che', + instanceKey: '1010', + currentScenario: '1010', scenario: '1010', apiPort: 15003, status: 'RUNNING', @@ -80,7 +82,7 @@ describe('profile DEPLOY operation', () => { listProfiles: async () => [profile], getProfile: async () => profile, upsertProfile: async () => profile, - updateScenario: async () => profile, + updateCurrentScenario: async () => profile, updateStatus: async () => profile, updateBuildStatus: async () => profile, updateMeta: async () => profile, diff --git a/app/gateway-api/test/profileIdentity.test.ts b/app/gateway-api/test/profileIdentity.test.ts new file mode 100644 index 00000000..37aaa818 --- /dev/null +++ b/app/gateway-api/test/profileIdentity.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildGatewayProfileName, + resolveGatewayProfileIdentity, + type GatewayProfileUpsertInput, +} from '../src/orchestrator/profileRepository.js'; + +const resolve = (input: Partial) => + resolveGatewayProfileIdentity({ + profile: 'che', + apiPort: 15003, + ...input, + }); + +describe('Gateway profile identity', () => { + it('builds the immutable technical id from profile and instance key', () => { + expect(buildGatewayProfileName('che', 'default')).toBe('che:default'); + }); + + it('does not treat a new default instance as an initialized scenario', () => { + expect(resolve({ instanceKey: 'default' })).toEqual({ + instanceKey: 'default', + currentScenario: null, + shouldUpdateCurrentScenario: false, + }); + }); + + it('accepts the old bootstrap default marker without clearing an existing scenario on upsert', () => { + expect(resolve({ scenario: 'default' })).toEqual({ + instanceKey: 'default', + currentScenario: null, + shouldUpdateCurrentScenario: false, + }); + }); + + it('maps a legacy non-default scenario to both identity and current state', () => { + expect(resolve({ scenario: '2' })).toEqual({ + instanceKey: '2', + currentScenario: '2', + shouldUpdateCurrentScenario: true, + }); + }); + + it('keeps a default instance stable when its current scenario changes', () => { + expect(resolve({ instanceKey: 'default', currentScenario: '1010' })).toEqual({ + instanceKey: 'default', + currentScenario: '1010', + shouldUpdateCurrentScenario: true, + }); + }); +}); diff --git a/app/gateway-api/test/profileOrder.test.ts b/app/gateway-api/test/profileOrder.test.ts index c8aa04be..3e712918 100644 --- a/app/gateway-api/test/profileOrder.test.ts +++ b/app/gateway-api/test/profileOrder.test.ts @@ -6,25 +6,25 @@ describe('orderGatewayProfiles', () => { it('uses the public server order instead of alphabetical profile order', () => { const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({ profile, - scenario: 'default', + instanceKey: 'default', })); expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER); }); - it('orders scenarios within a profile and places unknown profiles afterward', () => { + it('orders instance keys within a profile and places unknown profiles afterward', () => { const profiles = [ - { profile: 'zeta', scenario: 'default' }, - { profile: 'che', scenario: '20' }, - { profile: 'alpha', scenario: 'default' }, - { profile: 'che', scenario: '10' }, + { profile: 'zeta', instanceKey: 'default' }, + { profile: 'che', instanceKey: '20' }, + { profile: 'alpha', instanceKey: 'default' }, + { profile: 'che', instanceKey: '10' }, ]; expect(orderGatewayProfiles(profiles)).toEqual([ - { profile: 'che', scenario: '10' }, - { profile: 'che', scenario: '20' }, - { profile: 'alpha', scenario: 'default' }, - { profile: 'zeta', scenario: 'default' }, + { profile: 'che', instanceKey: '10' }, + { profile: 'che', instanceKey: '20' }, + { profile: 'alpha', instanceKey: 'default' }, + { profile: 'zeta', instanceKey: 'default' }, ]); expect(profiles[0]?.profile).toBe('zeta'); }); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 7b9fcd61..5f80a1e1 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -38,7 +38,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, - gatewaySchemaHead: '20260811000000_add_gateway_operation_logs', + gatewaySchemaHead: '20260813000000_split_gateway_profile_identity', gameSchemaHead: '20260803000000_add_logical_game_clock', }); }); diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index bab4440d..ca29718b 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -150,6 +150,8 @@ const installFixture = async ( { profileName: 'hwe:default', profile: 'hwe', + instanceKey: 'default', + currentScenario: '1010', meta: {}, }, ]); @@ -160,6 +162,8 @@ const installFixture = async ( { profileName: 'hwe:default', profile: 'hwe', + instanceKey: 'default', + currentScenario: '1010', scenario: '1010', apiPort: 15015, status: 'RUNNING', @@ -352,7 +356,9 @@ test('directs profile deployment to the selected server version tab', async ({ p await expect(versionTab).toBeFocused(); const tabAndHeaderGeometry = await Promise.all([ tabs.evaluate((element) => element.getBoundingClientRect().top), - page.getByText('hwe:default (hwe)', { exact: true }).evaluate((element) => element.getBoundingClientRect().top), + page + .getByText('서버 ID: hwe:default · 인스턴스: default', { exact: true }) + .evaluate((element) => element.getBoundingClientRect().top), ]); expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]); await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true }); diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index 5ef67985..e0da9c46 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -41,6 +41,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => { { profileName: 'hwe:2', profile: 'hwe', + instanceKey: '2', + currentScenario: '1010', scenario: '1010', status: 'RUNNING', buildStatus: 'SUCCEEDED', @@ -59,6 +61,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => { { profileName: 'hwe:2', profile: 'hwe', + instanceKey: '2', + currentScenario: '1010', meta: { korName: '환상서버' }, }, ] @@ -209,7 +213,7 @@ test('scoped administrators see the same navigation while ordinary users do not' await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible(); await scopedPage.getByRole('link', { name: '관리자 페이지' }).click(); const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' }); - await expect(scopedNavigation.getByRole('link', { name: '환상서버 (hwe:2)' })).toBeVisible(); + await expect(scopedNavigation.getByRole('link', { name: '환상서버 [2]' })).toBeVisible(); await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0); await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0); await scopedContext.close(); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index d369b41e..388b9de6 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -55,8 +55,10 @@ type FixtureState = { }; const profile = (runtimeRunning: boolean, resetDefaults?: Record) => ({ - profileName: 'che:2', + profileName: 'che:default', profile: 'che', + instanceKey: 'default', + currentScenario: '2', scenario: '2', apiPort: 15003, status: runtimeRunning ? 'RUNNING' : 'STOPPED', @@ -69,7 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record { await route.abort('failed'); return; } - if ( - names.includes('admin.releases.gatewayState') && - (state.gatewayStateFailuresRemaining ?? 0) > 0 - ) { + if (names.includes('admin.releases.gatewayState') && (state.gatewayStateFailuresRemaining ?? 0) > 0) { state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1; state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1; await route.fulfill({ @@ -171,8 +170,10 @@ const installFixture = async (page: Page, state: FixtureState) => { if (name === 'admin.profiles.listNavigation') { return response([ { - profileName: 'che:2', + profileName: 'che:default', profile: 'che', + instanceKey: 'default', + currentScenario: '2', meta: { korName: '천하서버' }, }, ]); @@ -314,7 +315,7 @@ const installFixture = async (page: Page, state: FixtureState) => { if (name === 'admin.operations.requestReset') { const operation: Operation = { id: '11111111-1111-4111-8111-111111111111', - profileName: 'che:2', + profileName: 'che:default', type: 'RESET', status: 'QUEUED', sourceMode: 'COMMIT', @@ -330,7 +331,7 @@ const installFixture = async (page: Page, state: FixtureState) => { if (name === 'admin.operations.requestDeploy') { const operation: Operation = { id: '66666666-6666-4666-8666-666666666666', - profileName: 'che:2', + profileName: 'che:default', type: 'DEPLOY', status: 'QUEUED', sourceMode: 'BRANCH', @@ -368,7 +369,7 @@ const installFixture = async (page: Page, state: FixtureState) => { type === 'START' ? '22222222-2222-4222-8222-222222222222' : '33333333-3333-4333-8333-333333333333', - profileName: 'che:2', + profileName: 'che:default', type, status: 'SUCCEEDED', payload: {}, @@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => { if (name === 'admin.operations.retry') { const operation: Operation = { id: '44444444-4444-4444-8444-444444444444', - profileName: 'che:2', + profileName: 'che:default', type: 'RESET', status: 'QUEUED', sourceMode: 'COMMIT', @@ -420,9 +421,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/servers/che%3A2/scenario'); + await page.goto('admin/servers/che%3Adefault/scenario'); await expect(page.getByTestId('server-operations-page')).toBeVisible(); - await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/); + await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/scenario$/); await expect(page.getByTestId('source-current')).toBeChecked(); await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋'); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); @@ -497,7 +498,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByTestId('operations-table')).toContainText('RESET'); await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); - await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.'); + await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => { @@ -614,7 +615,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/servers/che%3A2/version'); + await page.goto('admin/servers/che%3Adefault/version'); await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible(); await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute( @@ -626,7 +627,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByTestId('operations-table')).toContainText('DEPLOY'); await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); - await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.'); + await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.'); await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true); @@ -655,7 +656,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/servers/che%3A2/scenario'); + await page.goto('admin/servers/che%3Adefault/scenario'); await expect(page.getByTestId('reset-turn-term')).toHaveValue('20'); await page.getByText('고급 시나리오 옵션').click(); await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타'); @@ -679,7 +680,7 @@ test('edits server reset defaults through profile metadata settings', async ({ p const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; await installFixture(page, state); - await page.goto('admin/servers/che%3A2'); + await page.goto('admin/servers/che%3Adefault'); await page.getByText('서버 리셋 기본 옵션').click(); await page.getByTestId('meta-reset-turn-term').selectOption('10'); await page.getByTestId('meta-reset-npc-mode').selectOption('2'); @@ -742,7 +743,7 @@ test('shows a dismissible error toast when profile metadata persistence fails', }; await installFixture(page, state); - await page.goto('admin/servers/che%3A2'); + await page.goto('admin/servers/che%3Adefault'); await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error'); await page.getByRole('button', { name: '메타 저장' }).click(); @@ -765,7 +766,7 @@ test('renders the fixed-profile version form without waiting for the server list }; await installFixture(page, state); - await page.goto('admin/servers/che%3A2/version'); + await page.goto('admin/servers/che%3Adefault/version'); await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 }); expect(state.profileNavigationResolved).toBe(false); await expect.poll(() => state.profileNavigationResolved).toBe(true); @@ -782,7 +783,7 @@ test('recovers the current-version scenario catalog after the initial request fa }; await installFixture(page, state); - await page.goto('admin/servers/che%3A2/scenario'); + await page.goto('admin/servers/che%3Adefault/scenario'); await expect(page.getByTestId('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.'); await expect(page.getByTestId('request-reset')).toBeDisabled(); await page.getByTestId('load-scenarios').click(); @@ -790,7 +791,9 @@ test('recovers the current-version scenario catalog after the initial request fa await expect(page.getByTestId('request-reset')).toBeEnabled(); }); -test('renders the server navigation before the detailed runtime profile request resolves', async ({ page }) => { +test('renders the stable server identity without exposing the default suffix as the display name', async ({ + page, +}, testInfo) => { const state: FixtureState = { operations: [], gatewayOperations: [], @@ -800,10 +803,42 @@ test('renders the server navigation before the detailed runtime profile request }; await installFixture(page, state); - await page.goto('admin/servers/che%3A2'); + await page.goto('admin/servers/che%3Adefault'); const navigation = page.getByRole('navigation', { name: '관리자 메뉴' }); - await expect(navigation.getByRole('link', { name: '천하서버 (che:2)' })).toBeVisible({ timeout: 900 }); + const profileLink = navigation.getByRole('link', { name: '천하서버' }); + await expect(profileLink).toBeVisible({ timeout: 900 }); + await expect(profileLink).toHaveAttribute('title', '서버 ID: che:default'); + await expect(navigation).not.toContainText('천하서버 (che:default)'); await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 }); + await expect(page.getByText('서버 ID: che:default · 인스턴스: default')).toBeVisible(); + await expect(page.getByText('현재 시나리오: 2')).toBeVisible(); + await profileLink.focus(); + const desktop = await profileLink.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + x: rect.x, + width: rect.width, + height: rect.height, + overflow: element.scrollWidth - element.clientWidth, + backgroundColor: style.backgroundColor, + color: style.color, + }; + }); + expect(desktop.width).toBeGreaterThan(100); + expect(desktop.height).toBeGreaterThan(30); + expect(desktop.overflow).toBeLessThanOrEqual(0); + expect(desktop.backgroundColor).toBe('rgb(45, 27, 8)'); + expect(desktop.color).toBe('rgb(253, 230, 138)'); + await page.screenshot({ path: testInfo.outputPath('profile-identity-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole('button', { name: '관리자 메뉴' }).click(); + await expect(profileLink).toBeVisible(); + expect( + await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + ).toBeLessThanOrEqual(0); + await page.screenshot({ path: testInfo.outputPath('profile-identity-mobile.png'), fullPage: true }); expect(state.profileNavigationRequests).toBe(1); }); @@ -813,12 +848,12 @@ test('scenario-only operator resets the current version without Git or Gateway c gatewayOperations: [], runtimeRunning: true, requestBodies: [], - capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:2'] }], + capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:default'] }], }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/servers/che%3A2/scenario'); + await page.goto('admin/servers/che%3Adefault/scenario'); await expect(page.getByTestId('source-current')).toBeChecked(); await expect(page.getByTestId('source-branch')).toHaveCount(0); await expect(page.getByTestId('source-commit')).toHaveCount(0); @@ -996,9 +1031,7 @@ test('moves long Gateway release errors out of the table column into an expandab expect(mobileGeometry.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1); expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1); expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0); - expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual( - mobileGeometry.viewportWidth - ); + expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth); expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth); await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true }); @@ -1043,7 +1076,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success operations: [ { id: '55555555-5555-4555-8555-555555555555', - profileName: 'che:2', + profileName: 'che:default', type: 'RESET', status: 'FAILED', sourceMode: 'COMMIT', @@ -1064,7 +1097,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/servers/che%3A2/scenario'); + await page.goto('admin/servers/che%3Adefault/scenario'); await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible(); await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); const failure = page.getByTestId('operations-table').getByText(longError); diff --git a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue index 4cd44cc2..8d9defb4 100644 --- a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue +++ b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue @@ -16,12 +16,28 @@ const adminNavigationClient = directTrpc.admin as unknown as { capabilities: { list: { query: () => Promise> } }; profiles: { listNavigation: { - query: () => Promise }>>; + query: () => Promise< + Array<{ + profileName: string; + profile: string; + instanceKey: string; + currentScenario: string | null; + meta?: Record; + }> + >; }; }; }; const capabilities = ref>([]); -const profiles = ref }>>([]); +const profiles = ref< + Array<{ + profileName: string; + profile: string; + instanceKey: string; + currentScenario: string | null; + meta?: Record; + }> +>([]); const isRootAdmin = computed(() => (auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser') @@ -38,7 +54,8 @@ const hasAnyProfileCapability = computed(() => const profileLabel = (profile: (typeof profiles.value)[number]): string => { const korName = profile.meta?.korName; - return typeof korName === 'string' && korName.trim() ? `${korName} (${profile.profileName})` : profile.profileName; + const displayName = typeof korName === 'string' && korName.trim() ? korName.trim() : profile.profile; + return profile.instanceKey === 'default' ? displayName : `${displayName} [${profile.instanceKey}]`; }; const navigation = computed(() => [ @@ -70,6 +87,7 @@ const navigation = computed(() => [ ...profiles.value.map((profile) => ({ to: `/admin/servers/${encodeURIComponent(profile.profileName)}`, label: profileLabel(profile), + title: `서버 ID: ${profile.profileName}`, icon: '└', exact: false, visible: true, @@ -156,6 +174,7 @@ onMounted(async () => { :class="{ child: item.child }" :active-class="item.exact ? '' : 'active'" :exact-active-class="item.exact ? 'active' : ''" + :title="'title' in item ? item.title : undefined" @click="menuOpen = false" > diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 2f1da60c..634516af 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -176,6 +176,9 @@ type AdminPublicUser = { type AdminProfile = { profileName: string; profile: string; + instanceKey: string; + currentScenario: string | null; + /** @deprecated Rollback-compatible mirror of currentScenario. */ scenario: string; status: string; apiPort: number; @@ -2058,9 +2061,14 @@ onMounted(() => {
- {{ profile.profileName }} ({{ profile.profile }}) + {{ profile.meta.korName ?? profile.profile }} +
+
+ 서버 ID: {{ profile.profileName }} · 인스턴스: {{ profile.instanceKey }} +
+
+ 현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
-
시나리오: {{ profile.scenario }}
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / diff --git a/docs/admin-console.md b/docs/admin-console.md index 1514fd56..ca3fab33 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -31,6 +31,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 - 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와 시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과 `aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다. +- `profileName`은 `${profile}:${instanceKey}` 형식의 불변 기술 ID입니다. + `che:default`의 `default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다. + 좌측 메뉴는 기본 인스턴스의 suffix를 숨기고 표시명만 보여 주며, 상태 상세에서 + 기술 ID·인스턴스 키·nullable 현재 시나리오를 분리해 확인할 수 있습니다. - 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가 이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를 기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세 @@ -45,7 +49,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화 권한과 버전 배포 권한이 모두 필요합니다. - 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와 - 분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은 + 분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은 그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이 실패하면 현재 버전 모드에서 다시 확인할 수 있습니다. - 서버 상태의 `서버 리셋 기본 옵션`은 `GatewayProfile.meta.resetDefaults`에 diff --git a/docs/release-operations.md b/docs/release-operations.md index b3f16796..890f06a3 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -131,6 +131,13 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면 확인합니다. 6. 모두 준비된 경우에만 현재·이전 commit과 workspace를 게시합니다. +Gateway migration은 앱 rollback 때 자동으로 역방향 적용되지 않습니다. 따라서 +profile identity 분리의 첫 단계는 기존 `profile_name`과 legacy `scenario`를 유지한 +채 `instance_key`와 nullable `current_scenario`를 추가합니다. DB trigger가 구버전의 +`scenario` write와 신버전의 `current_scenario` write를 양방향 동기화하므로 migration +적용 뒤 readiness가 실패해 직전 Gateway worktree로 돌아가도 기존 DB와 시즌을 +그대로 사용할 수 있습니다. + release-controller는 PM2 process 안에서 실행되므로 부모의 `args`, `pm_id`, `pm_exec_path`, `name`, `NODE_APP_INSTANCE`와 `axm_*` 같은 PM2 내부 값을 자식 환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의 diff --git a/packages/infra/prisma/gateway-migrations/20260813000000_split_gateway_profile_identity/migration.sql b/packages/infra/prisma/gateway-migrations/20260813000000_split_gateway_profile_identity/migration.sql new file mode 100644 index 00000000..d5cfff34 --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260813000000_split_gateway_profile_identity/migration.sql @@ -0,0 +1,71 @@ +-- Keep profile_name stable because it is referenced by operations, runtime +-- actions, permission scopes, process names, Redis namespaces, and routes. +-- instance_key identifies the immutable slot while current_scenario records the +-- mutable game selection. The legacy scenario column remains during the +-- expansion phase so the previous Gateway release can still be restored. +ALTER TABLE "gateway_profile" +ADD COLUMN "instance_key" TEXT, +ADD COLUMN "current_scenario" TEXT; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "gateway_profile" + WHERE left("profile_name", length("profile") + 1) <> "profile" || ':' + ) THEN + RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon'; + END IF; +END +$$; + +UPDATE "gateway_profile" +SET + "instance_key" = substring("profile_name" FROM length("profile") + 2), + "current_scenario" = NULLIF("scenario", 'default'); + +ALTER TABLE "gateway_profile" +ALTER COLUMN "instance_key" SET NOT NULL; + +DROP INDEX "gateway_profile_profile_scenario_key"; + +ALTER TABLE "gateway_profile" +ADD CONSTRAINT "gateway_profile_profile_instance_key_key" UNIQUE ("profile", "instance_key"), +ADD CONSTRAINT "gateway_profile_identity_check" +CHECK ( + length("instance_key") > 0 + AND "profile_name" = "profile" || ':' || "instance_key" +); + +CREATE FUNCTION "sync_gateway_profile_scenario_compat"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW."instance_key" IS NULL THEN + IF left(NEW."profile_name", length(NEW."profile") + 1) <> NEW."profile" || ':' THEN + RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon'; + END IF; + NEW."instance_key" := substring(NEW."profile_name" FROM length(NEW."profile") + 2); + END IF; + + IF TG_OP = 'INSERT' THEN + IF NEW."current_scenario" IS NULL THEN + NEW."current_scenario" := NULLIF(NEW."scenario", 'default'); + ELSE + NEW."scenario" := NEW."current_scenario"; + END IF; + ELSIF NEW."current_scenario" IS DISTINCT FROM OLD."current_scenario" THEN + NEW."scenario" := COALESCE(NEW."current_scenario", 'default'); + ELSIF NEW."scenario" IS DISTINCT FROM OLD."scenario" THEN + NEW."current_scenario" := NULLIF(NEW."scenario", 'default'); + END IF; + + RETURN NEW; +END +$$; + +CREATE TRIGGER "gateway_profile_scenario_compat" +BEFORE INSERT OR UPDATE ON "gateway_profile" +FOR EACH ROW +EXECUTE FUNCTION "sync_gateway_profile_scenario_compat"(); diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index 26250626..351cee68 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -208,6 +208,10 @@ model LegacyRootKeyValue { model GatewayProfile { profileName String @id @map("profile_name") profile String + instanceKey String @map("instance_key") + currentScenario String? @map("current_scenario") + /// Legacy compatibility mirror. The database trigger keeps this synchronized + /// with currentScenario while the previous Gateway release remains rollbackable. scenario String apiPort Int @map("api_port") status GatewayProfileStatus @@ -229,7 +233,7 @@ model GatewayProfile { operations GatewayOperation[] runtimeActions GatewayRuntimeAction[] - @@unique([profile, scenario]) + @@unique([profile, instanceKey]) @@map("gateway_profile") } diff --git a/release-manifest.json b/release-manifest.json index 6c264f64..52c5b9b3 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "controllerProtocol": 2, - "gatewaySchemaHead": "20260811000000_add_gateway_operation_logs", + "gatewaySchemaHead": "20260813000000_split_gateway_profile_identity", "gameSchemaHead": "20260803000000_add_logical_game_clock", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }