Merge branch 'fix/profile-identity-split-20260813'

This commit is contained in:
2026-08-13 16:07:14 +00:00
26 changed files with 399 additions and 83 deletions
@@ -50,6 +50,8 @@ integration('gateway runtime action consumer', () => {
create: { create: {
profileName, profileName,
profile: 'runtime', profile: 'runtime',
instanceKey: 'consumer-integration',
currentScenario: 'consumer-integration',
scenario: 'consumer-integration', scenario: 'consumer-integration',
apiPort: 15998, apiPort: 15998,
status: 'RUNNING', status: 'RUNNING',
+17 -4
View File
@@ -1280,7 +1280,10 @@ export const adminRouter = router({
sourceRef = resolved; sourceRef = resolved;
} }
const scenarios = await listScenarioPreviews({ gitRef: 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.'); throw new Error('Current scenario is not available at source.');
} }
} catch { } catch {
@@ -1579,6 +1582,8 @@ export const adminRouter = router({
.map((profile) => ({ .map((profile) => ({
profileName: profile.profileName, profileName: profile.profileName,
profile: profile.profile, profile: profile.profile,
instanceKey: profile.instanceKey,
currentScenario: profile.currentScenario,
meta: { meta: {
...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}), ...(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); const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); 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; currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
gitRef = profile.buildCommitSha?.trim(); gitRef = profile.buildCommitSha?.trim();
if (!gitRef) { if (!gitRef) {
@@ -1692,8 +1698,13 @@ export const adminRouter = router({
upsert: profileAdminProcedure upsert: profileAdminProcedure
.input( .input(
z.object({ z.object({
profile: z.string().min(1).max(32), profile: z.string().regex(/^[a-z0-9-]{1,32}$/),
scenario: z.string().min(1).max(64), 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), apiPort: z.number().int().min(1).max(65535),
status: zProfileStatus.optional(), status: zProfileStatus.optional(),
preopenAt: z.string().datetime().optional(), preopenAt: z.string().datetime().optional(),
@@ -1706,6 +1717,8 @@ export const adminRouter = router({
const status = input.status ?? 'STOPPED'; const status = input.status ?? 'STOPPED';
return ctx.profiles.upsertProfile({ return ctx.profiles.upsertProfile({
profile: input.profile, profile: input.profile,
instanceKey: input.instanceKey,
currentScenario: input.currentScenario,
scenario: input.scenario, scenario: input.scenario,
apiPort: input.apiPort, apiPort: input.apiPort,
status, status,
@@ -21,6 +21,9 @@ export type LobbyGeneralStatus = {
export type LobbyProfileStatus = { export type LobbyProfileStatus = {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string; scenario: string;
status: GatewayProfileStatus; status: GatewayProfileStatus;
apiPort: number; apiPort: number;
@@ -87,6 +90,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
return { return {
profileName: row.profileName, profileName: row.profileName,
profile: row.profile, profile: row.profile,
instanceKey: row.instanceKey,
currentScenario: row.currentScenario,
scenario: row.scenario, scenario: row.scenario,
status: row.status, status: row.status,
apiPort: row.apiPort, apiPort: row.apiPort,
@@ -396,7 +396,7 @@ export const buildProcessDefinitions = (
...baseEnv, ...baseEnv,
GAME_API_ROLE: 'server', GAME_API_ROLE: 'server',
PROFILE: profile.profile, PROFILE: profile.profile,
SCENARIO: profile.scenario, SCENARIO: profile.currentScenario ?? 'default',
GAME_PROFILE_NAME: profile.profileName, GAME_PROFILE_NAME: profile.profileName,
GAME_API_PORT: String(profile.apiPort), GAME_API_PORT: String(profile.apiPort),
GAME_TRPC_PATH: `/${profile.profile}/api/trpc`, GAME_TRPC_PATH: `/${profile.profile}/api/trpc`,
@@ -411,7 +411,7 @@ export const buildProcessDefinitions = (
GAME_ENGINE_ROLE: 'turn-daemon', GAME_ENGINE_ROLE: 'turn-daemon',
TURN_PROFILE: profile.profile, TURN_PROFILE: profile.profile,
PROFILE: profile.profile, PROFILE: profile.profile,
SCENARIO: profile.scenario, SCENARIO: profile.currentScenario ?? 'default',
TURN_PROFILE_NAME: profile.profileName, TURN_PROFILE_NAME: profile.profileName,
}; };
return { return {
@@ -1422,8 +1422,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
} = parseInstallOptions(action); } = parseInstallOptions(action);
const tickOverride = const tickOverride =
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined; installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
const scenarioId = installScenarioId ?? parseScenarioId(profile.scenario); const scenarioId = installScenarioId ?? parseScenarioId(profile.currentScenario);
if (!scenarioId) { if (scenarioId === null) {
return { status: 'FAILED', detail: 'scenarioId is missing' }; return { status: 'FAILED', detail: 'scenarioId is missing' };
} }
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile); const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
@@ -1547,7 +1547,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING'; const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
const publishedProfile = await updateClaimedProfile( const publishedProfile = await updateClaimedProfile(
{ {
scenario: String(scenarioId), currentScenario: String(scenarioId),
status: desiredStatus, status: desiredStatus,
buildStatus: 'SUCCEEDED', buildStatus: 'SUCCEEDED',
buildWorkspace: workspace.root, buildWorkspace: workspace.root,
@@ -1564,8 +1564,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
completedAt, completedAt,
error: null, error: null,
}); });
if (String(scenarioId) !== profile.scenario) { if (String(scenarioId) !== profile.currentScenario) {
await this.repository.updateScenario(profile.profileName, String(scenarioId)); await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
} }
return this.repository.updateStatus(profile.profileName, desiredStatus, { return this.repository.updateStatus(profile.profileName, desiredStatus, {
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null, preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
@@ -1577,6 +1577,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
releasePrepared = true; releasePrepared = true;
const builtProfile = publishedProfile ?? { const builtProfile = publishedProfile ?? {
...profile, ...profile,
currentScenario: String(scenarioId),
scenario: String(scenarioId), scenario: String(scenarioId),
status: desiredStatus, status: desiredStatus,
buildWorkspace: workspace.root, buildWorkspace: workspace.root,
@@ -1642,7 +1643,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
meta: Record<string, unknown>; meta: Record<string, unknown>;
}> { }> {
const databaseUrl = databaseUrlOverride ?? this.resolveProfileDatabaseUrl(profile); 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 tickSeconds: number | undefined = overrides?.tickSeconds;
let meta: Record<string, unknown> = {}; let meta: Record<string, unknown> = {};
const connector = createGamePostgresConnector({ url: databaseUrl }); const connector = createGamePostgresConnector({ url: databaseUrl });
@@ -78,6 +78,9 @@ export interface GatewayOperationLogInput {
export interface GatewayProfileRecord { export interface GatewayProfileRecord {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string; scenario: string;
apiPort: number; apiPort: number;
status: GatewayProfileStatus; status: GatewayProfileStatus;
@@ -100,7 +103,10 @@ export interface GatewayProfileRecord {
export interface GatewayProfileUpsertInput { export interface GatewayProfileUpsertInput {
profile: string; profile: string;
scenario: string; instanceKey?: string;
currentScenario?: string | null;
/** @deprecated Accepted while older bootstrap clients are still supported. */
scenario?: string;
apiPort: number; apiPort: number;
status?: GatewayProfileStatus; status?: GatewayProfileStatus;
preopenAt?: string; preopenAt?: string;
@@ -111,7 +117,7 @@ export interface GatewayProfileUpsertInput {
} }
export interface GatewayClaimedProfileUpdate { export interface GatewayClaimedProfileUpdate {
scenario?: string; currentScenario?: string | null;
status?: GatewayProfileStatus; status?: GatewayProfileStatus;
buildStatus?: GatewayBuildStatus; buildStatus?: GatewayBuildStatus;
buildCommitSha?: string | null; buildCommitSha?: string | null;
@@ -131,7 +137,7 @@ export interface GatewayProfileRepository {
listProfiles(): Promise<GatewayProfileRecord[]>; listProfiles(): Promise<GatewayProfileRecord[]>;
getProfile(profileName: string): Promise<GatewayProfileRecord | null>; getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>; upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null>; updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null>;
updateStatus( updateStatus(
profileName: string, profileName: string,
status: GatewayProfileStatus, status: GatewayProfileStatus,
@@ -219,6 +225,8 @@ export const buildRetryOperationSource = (previous: {
type GatewayProfileRow = { type GatewayProfileRow = {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
scenario: string; scenario: string;
apiPort: number; apiPort: number;
status: GatewayProfileStatus; status: GatewayProfileStatus;
@@ -265,6 +273,8 @@ type GatewayOperationRow = {
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
profileName: row.profileName, profileName: row.profileName,
profile: row.profile, profile: row.profile,
instanceKey: row.instanceKey,
currentScenario: row.currentScenario,
scenario: row.scenario, scenario: row.scenario,
apiPort: row.apiPort, apiPort: row.apiPort,
status: row.status, status: row.status,
@@ -285,7 +295,24 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
updatedAt: row.updatedAt.toISOString(), 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 => ({ const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
id: row.id, id: row.id,
@@ -331,7 +358,7 @@ const mapOperationLog = (row: {
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({
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }], orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
}); });
return rows.map(mapProfile); return rows.map(mapProfile);
}, },
@@ -342,13 +369,16 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return row ? mapProfile(row) : null; return row ? mapProfile(row) : null;
}, },
async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> { async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> {
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({ const row = await prisma.gatewayProfile.upsert({
where: { profileName }, where: { profileName },
create: { create: {
profileName, profileName,
profile: input.profile, profile: input.profile,
scenario: input.scenario, instanceKey,
currentScenario,
scenario: currentScenario ?? 'default',
apiPort: input.apiPort, apiPort: input.apiPort,
status: input.status ?? 'STOPPED', status: input.status ?? 'STOPPED',
preopenAt: input.preopenAt ? new Date(input.preopenAt) : null, preopenAt: input.preopenAt ? new Date(input.preopenAt) : null,
@@ -358,6 +388,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
meta: (input.meta ?? {}) as GatewayPrisma.JsonObject, meta: (input.meta ?? {}) as GatewayPrisma.JsonObject,
}, },
update: { update: {
currentScenario: shouldUpdateCurrentScenario ? currentScenario : undefined,
scenario: shouldUpdateCurrentScenario ? (currentScenario ?? 'default') : undefined,
apiPort: input.apiPort, apiPort: input.apiPort,
status: input.status, status: input.status,
preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined, 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); return mapProfile(row);
}, },
async updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null> { async updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null> {
const row = await prisma.gatewayProfile.update({ const row = await prisma.gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { data: {
scenario, currentScenario: scenario,
scenario: scenario ?? 'default',
}, },
}); });
return row ? mapProfile(row) : null; return row ? mapProfile(row) : null;
@@ -700,7 +733,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return tx.gatewayProfile.update({ return tx.gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { data: {
scenario: patch.scenario, currentScenario: patch.currentScenario,
scenario: patch.currentScenario === undefined ? undefined : (patch.currentScenario ?? 'default'),
status: patch.status, status: patch.status,
buildStatus: patch.buildStatus, buildStatus: patch.buildStatus,
buildCommitSha: patch.buildCommitSha, buildCommitSha: patch.buildCommitSha,
+4 -4
View File
@@ -3,8 +3,8 @@ export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya',
const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index])); const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index]));
export const compareGatewayProfiles = ( export const compareGatewayProfiles = (
left: { profile: string; scenario: string }, left: { profile: string; instanceKey: string },
right: { profile: string; scenario: string } right: { profile: string; instanceKey: string }
): number => { ): number => {
const unknownRank = GATEWAY_PROFILE_ORDER.length; const unknownRank = GATEWAY_PROFILE_ORDER.length;
const profileOrder = const profileOrder =
@@ -14,8 +14,8 @@ export const compareGatewayProfiles = (
const profileNameOrder = left.profile.localeCompare(right.profile); const profileNameOrder = left.profile.localeCompare(right.profile);
if (profileNameOrder !== 0) return profileNameOrder; if (profileNameOrder !== 0) return profileNameOrder;
return left.scenario.localeCompare(right.scenario); return left.instanceKey.localeCompare(right.instanceKey);
}; };
export const orderGatewayProfiles = <T extends { profile: string; scenario: string }>(profiles: readonly T[]): T[] => export const orderGatewayProfiles = <T extends { profile: string; instanceKey: string }>(profiles: readonly T[]): T[] =>
[...profiles].sort(compareGatewayProfiles); [...profiles].sort(compareGatewayProfiles);
+5 -1
View File
@@ -85,6 +85,8 @@ const buildCaller = async (
const profile = { const profile = {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: options.profileScenario ?? '2',
scenario: options.profileScenario ?? '2', scenario: options.profileScenario ?? '2',
apiPort: 15003, apiPort: 15003,
status: options.initialProfileStatus ?? ('STOPPED' as const), status: options.initialProfileStatus ?? ('STOPPED' as const),
@@ -98,7 +100,7 @@ const buildCaller = async (
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async () => profile, getProfile: async () => profile,
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async (_profileName, status) => { updateStatus: async (_profileName, status) => {
updatedStatuses.push(status); updatedStatuses.push(status);
return { ...profile, status }; return { ...profile, status };
@@ -362,6 +364,8 @@ describe('admin profile navigation API', () => {
{ {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: '2',
meta: {}, meta: {},
}, },
]); ]);
@@ -15,6 +15,8 @@ import { appRouter } from '../src/router.js';
const profile = { const profile = {
profileName: 'che:default', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: null,
scenario: 'default', scenario: 'default',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING' as const, status: 'RUNNING' as const,
@@ -28,7 +30,7 @@ const profiles: GatewayProfileRepository = {
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async (profileName) => (profileName === profile.profileName ? profile : null), getProfile: async (profileName) => (profileName === profile.profileName ? profile : null),
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async () => profile, updateStatus: async () => profile,
updateBuildStatus: async () => profile, updateBuildStatus: async () => profile,
updateMeta: async () => profile, updateMeta: async () => profile,
+7 -1
View File
@@ -92,6 +92,8 @@ const buildCaller = (
{ {
profileName: 'che:default', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: null,
scenario: 'default', scenario: 'default',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING' as const, status: 'RUNNING' as const,
@@ -103,6 +105,8 @@ const buildCaller = (
{ {
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default',
currentScenario: null,
scenario: 'default', scenario: 'default',
apiPort: 15015, apiPort: 15015,
status: 'RUNNING' as const, status: 'RUNNING' as const,
@@ -119,7 +123,7 @@ const buildCaller = (
upsertProfile: async () => { upsertProfile: async () => {
throw new Error('not used'); throw new Error('not used');
}, },
updateScenario: async () => null, updateCurrentScenario: async () => null,
updateStatus: async () => null, updateStatus: async () => null,
updateBuildStatus: async () => null, updateBuildStatus: async () => null,
updateMeta: async () => null, updateMeta: async () => null,
@@ -167,6 +171,8 @@ const buildCaller = (
profileRows.map((profile) => ({ profileRows.map((profile) => ({
profileName: profile.profileName, profileName: profile.profileName,
profile: profile.profile, profile: profile.profile,
instanceKey: profile.instanceKey,
currentScenario: profile.currentScenario,
scenario: profile.scenario, scenario: profile.scenario,
status: profile.status, status: profile.status,
apiPort: profile.apiPort, apiPort: profile.apiPort,
@@ -13,6 +13,8 @@ import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
const profile: GatewayProfileRecord = { const profile: GatewayProfileRecord = {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: '2',
scenario: '2', scenario: '2',
apiPort: 15003, apiPort: 15003,
status: 'STOPPED', status: 'STOPPED',
@@ -58,7 +60,7 @@ const createHarness = (
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async () => profile, getProfile: async () => profile,
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async (_profileName, status) => { updateStatus: async (_profileName, status) => {
statuses.push(status); statuses.push(status);
return { ...profile, status }; return { ...profile, status };
@@ -14,6 +14,8 @@ import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({ const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: '2',
scenario: '2', scenario: '2',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING', status: 'RUNNING',
@@ -172,6 +174,39 @@ describe('buildProcessDefinitions', () => {
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); 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', () => { it('does not forward PM2 identity or parent runtime roles to profile processes', () => {
const definitions = buildProcessDefinitions(buildProfile(), { const definitions = buildProcessDefinitions(buildProfile(), {
...processConfig, ...processConfig,
@@ -14,6 +14,8 @@ const makeProfile = (
): GatewayProfileRecord => ({ ): GatewayProfileRecord => ({
profileName, profileName,
profile: profileName.split(':')[0] ?? 'che', profile: profileName.split(':')[0] ?? 'che',
instanceKey: profileName.split(':')[1] ?? 'default',
currentScenario: null,
scenario: profileName.split(':')[1] ?? 'default', scenario: profileName.split(':')[1] ?? 'default',
apiPort: 15_003, apiPort: 15_003,
status: 'RUNNING', status: 'RUNNING',
@@ -50,6 +50,8 @@ describe('profile DEPLOY operation', () => {
const profile: GatewayProfileRecord = { const profile: GatewayProfileRecord = {
profileName: 'che:1010', profileName: 'che:1010',
profile: 'che', profile: 'che',
instanceKey: '1010',
currentScenario: '1010',
scenario: '1010', scenario: '1010',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING', status: 'RUNNING',
@@ -80,7 +82,7 @@ describe('profile DEPLOY operation', () => {
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async () => profile, getProfile: async () => profile,
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async () => profile, updateStatus: async () => profile,
updateBuildStatus: async () => profile, updateBuildStatus: async () => profile,
updateMeta: async () => profile, updateMeta: async () => profile,
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import {
buildGatewayProfileName,
resolveGatewayProfileIdentity,
type GatewayProfileUpsertInput,
} from '../src/orchestrator/profileRepository.js';
const resolve = (input: Partial<GatewayProfileUpsertInput>) =>
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,
});
});
});
+10 -10
View File
@@ -6,25 +6,25 @@ describe('orderGatewayProfiles', () => {
it('uses the public server order instead of alphabetical profile order', () => { it('uses the public server order instead of alphabetical profile order', () => {
const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({ const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({
profile, profile,
scenario: 'default', instanceKey: 'default',
})); }));
expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER); 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 = [ const profiles = [
{ profile: 'zeta', scenario: 'default' }, { profile: 'zeta', instanceKey: 'default' },
{ profile: 'che', scenario: '20' }, { profile: 'che', instanceKey: '20' },
{ profile: 'alpha', scenario: 'default' }, { profile: 'alpha', instanceKey: 'default' },
{ profile: 'che', scenario: '10' }, { profile: 'che', instanceKey: '10' },
]; ];
expect(orderGatewayProfiles(profiles)).toEqual([ expect(orderGatewayProfiles(profiles)).toEqual([
{ profile: 'che', scenario: '10' }, { profile: 'che', instanceKey: '10' },
{ profile: 'che', scenario: '20' }, { profile: 'che', instanceKey: '20' },
{ profile: 'alpha', scenario: 'default' }, { profile: 'alpha', instanceKey: 'default' },
{ profile: 'zeta', scenario: 'default' }, { profile: 'zeta', instanceKey: 'default' },
]); ]);
expect(profiles[0]?.profile).toBe('zeta'); expect(profiles[0]?.profile).toBe('zeta');
}); });
+1 -1
View File
@@ -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: '20260811000000_add_gateway_operation_logs', gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
gameSchemaHead: '20260803000000_add_logical_game_clock', gameSchemaHead: '20260803000000_add_logical_game_clock',
}); });
}); });
@@ -150,6 +150,8 @@ const installFixture = async (
{ {
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default',
currentScenario: '1010',
meta: {}, meta: {},
}, },
]); ]);
@@ -160,6 +162,8 @@ const installFixture = async (
{ {
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default',
currentScenario: '1010',
scenario: '1010', scenario: '1010',
apiPort: 15015, apiPort: 15015,
status: 'RUNNING', status: 'RUNNING',
@@ -352,7 +356,9 @@ test('directs profile deployment to the selected server version tab', async ({ p
await expect(versionTab).toBeFocused(); await expect(versionTab).toBeFocused();
const tabAndHeaderGeometry = await Promise.all([ const tabAndHeaderGeometry = await Promise.all([
tabs.evaluate((element) => element.getBoundingClientRect().top), 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]); expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true });
@@ -41,6 +41,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
{ {
profileName: 'hwe:2', profileName: 'hwe:2',
profile: 'hwe', profile: 'hwe',
instanceKey: '2',
currentScenario: '1010',
scenario: '1010', scenario: '1010',
status: 'RUNNING', status: 'RUNNING',
buildStatus: 'SUCCEEDED', buildStatus: 'SUCCEEDED',
@@ -59,6 +61,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
{ {
profileName: 'hwe:2', profileName: 'hwe:2',
profile: 'hwe', profile: 'hwe',
instanceKey: '2',
currentScenario: '1010',
meta: { korName: '환상서버' }, 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 expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click(); await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' }); 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: 'Gateway 릴리스' })).toHaveCount(0);
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0); await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
await scopedContext.close(); await scopedContext.close();
@@ -55,8 +55,10 @@ type FixtureState = {
}; };
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
profileName: 'che:2', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: '2',
scenario: '2', scenario: '2',
apiPort: 15003, apiPort: 15003,
status: runtimeRunning ? 'RUNNING' : 'STOPPED', status: runtimeRunning ? 'RUNNING' : 'STOPPED',
@@ -69,7 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown
activeOperation: null, activeOperation: null,
runtimeActions: [], runtimeActions: [],
runtime: { runtime: {
profileName: 'che:2', profileName: 'che:default',
frontendRunning: runtimeRunning, frontendRunning: runtimeRunning,
apiRunning: runtimeRunning, apiRunning: runtimeRunning,
daemonRunning: runtimeRunning, daemonRunning: runtimeRunning,
@@ -145,10 +147,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
await route.abort('failed'); await route.abort('failed');
return; return;
} }
if ( if (names.includes('admin.releases.gatewayState') && (state.gatewayStateFailuresRemaining ?? 0) > 0) {
names.includes('admin.releases.gatewayState') &&
(state.gatewayStateFailuresRemaining ?? 0) > 0
) {
state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1; state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1;
state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1; state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1;
await route.fulfill({ await route.fulfill({
@@ -171,8 +170,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.profiles.listNavigation') { if (name === 'admin.profiles.listNavigation') {
return response([ return response([
{ {
profileName: 'che:2', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: '2',
meta: { korName: '천하서버' }, meta: { korName: '천하서버' },
}, },
]); ]);
@@ -314,7 +315,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.requestReset') { if (name === 'admin.operations.requestReset') {
const operation: Operation = { const operation: Operation = {
id: '11111111-1111-4111-8111-111111111111', id: '11111111-1111-4111-8111-111111111111',
profileName: 'che:2', profileName: 'che:default',
type: 'RESET', type: 'RESET',
status: 'QUEUED', status: 'QUEUED',
sourceMode: 'COMMIT', sourceMode: 'COMMIT',
@@ -330,7 +331,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.requestDeploy') { if (name === 'admin.operations.requestDeploy') {
const operation: Operation = { const operation: Operation = {
id: '66666666-6666-4666-8666-666666666666', id: '66666666-6666-4666-8666-666666666666',
profileName: 'che:2', profileName: 'che:default',
type: 'DEPLOY', type: 'DEPLOY',
status: 'QUEUED', status: 'QUEUED',
sourceMode: 'BRANCH', sourceMode: 'BRANCH',
@@ -368,7 +369,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
type === 'START' type === 'START'
? '22222222-2222-4222-8222-222222222222' ? '22222222-2222-4222-8222-222222222222'
: '33333333-3333-4333-8333-333333333333', : '33333333-3333-4333-8333-333333333333',
profileName: 'che:2', profileName: 'che:default',
type, type,
status: 'SUCCEEDED', status: 'SUCCEEDED',
payload: {}, payload: {},
@@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.retry') { if (name === 'admin.operations.retry') {
const operation: Operation = { const operation: Operation = {
id: '44444444-4444-4444-8444-444444444444', id: '44444444-4444-4444-8444-444444444444',
profileName: 'che:2', profileName: 'che:default',
type: 'RESET', type: 'RESET',
status: 'QUEUED', status: 'QUEUED',
sourceMode: 'COMMIT', sourceMode: 'COMMIT',
@@ -420,9 +421,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await installFixture(page, state); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); 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.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-current')).toBeChecked();
await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋'); await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋');
await expect(page.getByTestId('scenario-select')).toHaveValue('2'); 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.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('RESET'); await expect(page.getByTestId('operations-table')).toContainText('RESET');
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); 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')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => { 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); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); 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.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible();
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute( 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.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY'); await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); 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')).toContainText('game-frontend build complete');
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true); 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); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); 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 expect(page.getByTestId('reset-turn-term')).toHaveValue('20');
await page.getByText('고급 시나리오 옵션').click(); await page.getByText('고급 시나리오 옵션').click();
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타'); 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: [] }; const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state); await installFixture(page, state);
await page.goto('admin/servers/che%3A2'); await page.goto('admin/servers/che%3Adefault');
await page.getByText('서버 리셋 기본 옵션').click(); await page.getByText('서버 리셋 기본 옵션').click();
await page.getByTestId('meta-reset-turn-term').selectOption('10'); await page.getByTestId('meta-reset-turn-term').selectOption('10');
await page.getByTestId('meta-reset-npc-mode').selectOption('2'); 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 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.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error');
await page.getByRole('button', { name: '메타 저장' }).click(); 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 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 }); await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 });
expect(state.profileNavigationResolved).toBe(false); expect(state.profileNavigationResolved).toBe(false);
await expect.poll(() => state.profileNavigationResolved).toBe(true); 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 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('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.');
await expect(page.getByTestId('request-reset')).toBeDisabled(); await expect(page.getByTestId('request-reset')).toBeDisabled();
await page.getByTestId('load-scenarios').click(); 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(); 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 = { const state: FixtureState = {
operations: [], operations: [],
gatewayOperations: [], gatewayOperations: [],
@@ -800,10 +803,42 @@ test('renders the server navigation before the detailed runtime profile request
}; };
await installFixture(page, state); await installFixture(page, state);
await page.goto('admin/servers/che%3A2'); await page.goto('admin/servers/che%3Adefault');
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' }); 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(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); expect(state.profileNavigationRequests).toBe(1);
}); });
@@ -813,12 +848,12 @@ test('scenario-only operator resets the current version without Git or Gateway c
gatewayOperations: [], gatewayOperations: [],
runtimeRunning: true, runtimeRunning: true,
requestBodies: [], 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); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); 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-current')).toBeChecked();
await expect(page.getByTestId('source-branch')).toHaveCount(0); await expect(page.getByTestId('source-branch')).toHaveCount(0);
await expect(page.getByTestId('source-commit')).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.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1);
expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1); expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1);
expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0); expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual( expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
mobileGeometry.viewportWidth
);
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth); expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true }); 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: [ operations: [
{ {
id: '55555555-5555-4555-8555-555555555555', id: '55555555-5555-4555-8555-555555555555',
profileName: 'che:2', profileName: 'che:default',
type: 'RESET', type: 'RESET',
status: 'FAILED', status: 'FAILED',
sourceMode: 'COMMIT', sourceMode: 'COMMIT',
@@ -1064,7 +1097,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
await installFixture(page, state); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); 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.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
const failure = page.getByTestId('operations-table').getByText(longError); const failure = page.getByTestId('operations-table').getByText(longError);
@@ -16,12 +16,28 @@ const adminNavigationClient = directTrpc.admin as unknown as {
capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } }; capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } };
profiles: { profiles: {
listNavigation: { listNavigation: {
query: () => Promise<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>; query: () => Promise<
Array<{
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
meta?: Record<string, unknown>;
}>
>;
}; };
}; };
}; };
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]); const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
const profiles = ref<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>([]); const profiles = ref<
Array<{
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
meta?: Record<string, unknown>;
}>
>([]);
const isRootAdmin = computed(() => const isRootAdmin = computed(() =>
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser') (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 profileLabel = (profile: (typeof profiles.value)[number]): string => {
const korName = profile.meta?.korName; 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(() => [ const navigation = computed(() => [
@@ -70,6 +87,7 @@ const navigation = computed(() => [
...profiles.value.map((profile) => ({ ...profiles.value.map((profile) => ({
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`, to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
label: profileLabel(profile), label: profileLabel(profile),
title: `서버 ID: ${profile.profileName}`,
icon: '└', icon: '└',
exact: false, exact: false,
visible: true, visible: true,
@@ -156,6 +174,7 @@ onMounted(async () => {
:class="{ child: item.child }" :class="{ child: item.child }"
:active-class="item.exact ? '' : 'active'" :active-class="item.exact ? '' : 'active'"
:exact-active-class="item.exact ? 'active' : ''" :exact-active-class="item.exact ? 'active' : ''"
:title="'title' in item ? item.title : undefined"
@click="menuOpen = false" @click="menuOpen = false"
> >
<span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span> <span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span>
+10 -2
View File
@@ -176,6 +176,9 @@ type AdminPublicUser = {
type AdminProfile = { type AdminProfile = {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string; scenario: string;
status: string; status: string;
apiPort: number; apiPort: number;
@@ -2058,9 +2061,14 @@ onMounted(() => {
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div> <div>
<div class="text-base font-semibold"> <div class="text-base font-semibold">
{{ profile.profileName }} ({{ profile.profile }}) {{ profile.meta.korName ?? profile.profile }}
</div>
<div class="text-xs text-zinc-500">
서버 ID: {{ profile.profileName }} · 인스턴스: {{ profile.instanceKey }}
</div>
<div class="text-xs text-zinc-500">
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
</div> </div>
<div class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
</div> </div>
<div class="text-xs text-zinc-400"> <div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / 상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
+5 -1
View File
@@ -31,6 +31,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와 - 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과 시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다. `aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
- `profileName``${profile}:${instanceKey}` 형식의 불변 기술 ID입니다.
`che:default``default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다.
좌측 메뉴는 기본 인스턴스의 suffix를 숨기고 표시명만 보여 주며, 상태 상세에서
기술 ID·인스턴스 키·nullable 현재 시나리오를 분리해 확인할 수 있습니다.
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가 - 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를 이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세 기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
@@ -45,7 +49,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화 업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
권한과 버전 배포 권한이 모두 필요합니다. 권한과 버전 배포 권한이 모두 필요합니다.
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와 - 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은 분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이 그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다. 실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
- 서버 상태의 `서버 리셋 기본 옵션``GatewayProfile.meta.resetDefaults` - 서버 상태의 `서버 리셋 기본 옵션``GatewayProfile.meta.resetDefaults`
+7
View File
@@ -131,6 +131,13 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
확인합니다. 확인합니다.
6. 모두 준비된 경우에만 현재·이전 commit과 workspace를 게시합니다. 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`, release-controller는 PM2 process 안에서 실행되므로 부모의 `args`, `pm_id`,
`pm_exec_path`, `name`, `NODE_APP_INSTANCE``axm_*` 같은 PM2 내부 값을 자식 `pm_exec_path`, `name`, `NODE_APP_INSTANCE``axm_*` 같은 PM2 내부 값을 자식
환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의 환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의
@@ -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"();
+5 -1
View File
@@ -208,6 +208,10 @@ model LegacyRootKeyValue {
model GatewayProfile { model GatewayProfile {
profileName String @id @map("profile_name") profileName String @id @map("profile_name")
profile String 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 scenario String
apiPort Int @map("api_port") apiPort Int @map("api_port")
status GatewayProfileStatus status GatewayProfileStatus
@@ -229,7 +233,7 @@ model GatewayProfile {
operations GatewayOperation[] operations GatewayOperation[]
runtimeActions GatewayRuntimeAction[] runtimeActions GatewayRuntimeAction[]
@@unique([profile, scenario]) @@unique([profile, instanceKey])
@@map("gateway_profile") @@map("gateway_profile")
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260811000000_add_gateway_operation_logs", "gatewaySchemaHead": "20260813000000_split_gateway_profile_identity",
"gameSchemaHead": "20260803000000_add_logical_game_clock", "gameSchemaHead": "20260803000000_add_logical_game_clock",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }