merge: 최신 main을 transport 권한과 커맨드 내구 행렬에 통합한다
This commit is contained in:
@@ -27,7 +27,11 @@ import type { GatewayApiContext } from './context.js';
|
||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
||||
import { readProfileReleaseSource } from './orchestrator/profileReleaseSource.js';
|
||||
import { orderGatewayProfiles, resolveGatewayProfileKoreanName } from './profileOrder.js';
|
||||
import {
|
||||
orderGatewayProfiles,
|
||||
resolveGatewayProfileDisplayName,
|
||||
resolveGatewayProfileKoreanName,
|
||||
} from './profileOrder.js';
|
||||
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
|
||||
|
||||
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
||||
@@ -650,7 +654,25 @@ export const adminRouter = router({
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(({ ctx, input }) => (ctx as GatewayApiContext).adminAudit.list(input)),
|
||||
.query(async ({ ctx, input }) => {
|
||||
const gatewayCtx = ctx as GatewayApiContext;
|
||||
const [events, profiles] = await Promise.all([
|
||||
gatewayCtx.adminAudit.list(input),
|
||||
gatewayCtx.profiles.listProfiles(),
|
||||
]);
|
||||
const displayNames = new Map(
|
||||
profiles.map((profile) => [
|
||||
profile.profileName,
|
||||
resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName),
|
||||
])
|
||||
);
|
||||
return events.map((event) => ({
|
||||
...event,
|
||||
...(event.profileName && displayNames.has(event.profileName)
|
||||
? { profileDisplayName: displayNames.get(event.profileName) }
|
||||
: {}),
|
||||
}));
|
||||
}),
|
||||
}),
|
||||
system: router({
|
||||
getNotice: adminProcedure.query(async ({ ctx }) => {
|
||||
@@ -760,6 +782,13 @@ export const adminRouter = router({
|
||||
specialAccessGrants,
|
||||
profiles: profiles.map((profile) => ({
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
instanceKey: profile.instanceKey,
|
||||
displayName: resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
profile.meta.korName
|
||||
),
|
||||
...resolveLocalAccountProfilePolicy({
|
||||
profile: profile.profile,
|
||||
profileName: profile.profileName,
|
||||
@@ -1727,6 +1756,11 @@ export const adminRouter = router({
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
instanceKey: profile.instanceKey,
|
||||
displayName: resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
profile.meta.korName
|
||||
),
|
||||
currentScenario: profile.currentScenario,
|
||||
meta: {
|
||||
korName: resolveGatewayProfileKoreanName(profile.profile, profile.meta.korName),
|
||||
@@ -1771,6 +1805,11 @@ export const adminRouter = router({
|
||||
const runtimeSettingsMap = new Map(runtimeSettings.map((settings) => [settings.profileName, settings]));
|
||||
return profiles.map((profile) => ({
|
||||
...profile,
|
||||
displayName: resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
profile.meta.korName
|
||||
),
|
||||
runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [],
|
||||
runtimeSettings: runtimeSettingsMap.get(profile.profileName) ?? null,
|
||||
activeOperation: activeOperationByProfile.get(profile.profileName) ?? null,
|
||||
@@ -1805,7 +1844,7 @@ export const adminRouter = router({
|
||||
if (sourceMode === 'CURRENT') {
|
||||
if (!input?.profileName) {
|
||||
if (!adminAuth.isSuperuser) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' });
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '대상 서버를 선택해야 합니다.' });
|
||||
}
|
||||
} else {
|
||||
assertPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, input.profileName);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { isRecord } from '@sammo-ts/common';
|
||||
|
||||
import { resolveGatewayPostgresConfigFromEnv } from '../gatewayPostgresConfig.js';
|
||||
import { resolveGatewayProfileDisplayName, resolveGatewayProfileKoreanName } from '../profileOrder.js';
|
||||
|
||||
import {
|
||||
buildTurboReleaseCommand,
|
||||
@@ -281,6 +282,13 @@ const buildServerId = (profileName: string, now: Date, installOperationId?: stri
|
||||
return `${profileName}_${year}${month}${day}_${suffix}`;
|
||||
};
|
||||
|
||||
export const resolveProfileArchiveServerName = (
|
||||
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'meta'>
|
||||
): string => {
|
||||
const meta = normalizeMeta(profile.meta);
|
||||
return resolveGatewayProfileKoreanName(profile.profile, meta.korName);
|
||||
};
|
||||
|
||||
const readMetaNumber = (meta: Record<string, unknown>, key: string): number | null => {
|
||||
const raw = meta[key];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
@@ -1618,7 +1626,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.processConfig.workspaceRoot
|
||||
)),
|
||||
];
|
||||
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`);
|
||||
await this.appendOperationLog(
|
||||
operationId,
|
||||
'build',
|
||||
`${resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName)} 구성 요소를 빌드합니다.`
|
||||
);
|
||||
const result = await this.releaseBuildRunner.run(commands, this.buildProgress(operationId, 'build'), {
|
||||
signal: this.activeOperationAbortSignal,
|
||||
});
|
||||
@@ -2010,6 +2022,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
season,
|
||||
firstGameIdx,
|
||||
serverId,
|
||||
// Snapshot the Gateway display name into this season. Hall and
|
||||
// dynasty archives must not fall back to the runtime instance key.
|
||||
serverName: resolveProfileArchiveServerName(profile),
|
||||
installCommitSha: commitSha,
|
||||
},
|
||||
adminUser,
|
||||
@@ -2222,7 +2237,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
await this.appendOperationLog(
|
||||
operationId,
|
||||
'build',
|
||||
`${profile?.profileName ?? 'profile'} 구성 요소를 빌드합니다.`
|
||||
`${
|
||||
profile
|
||||
? resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName)
|
||||
: '대상 서버'
|
||||
} 구성 요소를 빌드합니다.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -2424,7 +2443,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
|
||||
private async stageStaticProfileFrontend(profile: GatewayProfileRecord): Promise<StagedFrontendArtifact> {
|
||||
if (!profile.buildCommitSha) {
|
||||
throw new Error(`Profile ${profile.profileName} is missing the build commit SHA.`);
|
||||
throw new Error(
|
||||
`${resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
profile.meta.korName
|
||||
)} 서버의 build commit SHA가 없습니다.`
|
||||
);
|
||||
}
|
||||
const runtimeWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot;
|
||||
const sharedSourceRoot = buildSharedProfileFrontendOutDir(runtimeWorkspace);
|
||||
|
||||
@@ -20,6 +20,19 @@ export const resolveGatewayProfileKoreanName = (profile: string, configuredName?
|
||||
return gatewayProfileKoreanNames.get(profile) ?? profile;
|
||||
};
|
||||
|
||||
/**
|
||||
* User-facing profile label. The immutable profileName (`che:default`) remains
|
||||
* an internal routing/storage key and must not leak into ordinary UI copy.
|
||||
*/
|
||||
export const resolveGatewayProfileDisplayName = (
|
||||
profile: string,
|
||||
instanceKey: string,
|
||||
configuredName?: unknown
|
||||
): string => {
|
||||
const koreanName = resolveGatewayProfileKoreanName(profile, configuredName);
|
||||
return instanceKey === 'default' ? koreanName : `${koreanName} [${instanceKey}]`;
|
||||
};
|
||||
|
||||
const compareGatewayProfiles = (
|
||||
left: { profile: string; instanceKey: string },
|
||||
right: { profile: string; instanceKey: string }
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
|
||||
|
||||
export interface WebPushCoordinatorConfig {
|
||||
enabled: boolean;
|
||||
vapidSubject?: string;
|
||||
@@ -104,7 +106,14 @@ export class WebPushCoordinator {
|
||||
const [profiles, preferences, subscriptionCount, currentSubscription] = await Promise.all([
|
||||
this.prisma.gatewayProfile.findMany({
|
||||
orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
|
||||
select: { profileName: true, profile: true, currentScenario: true, status: true },
|
||||
select: {
|
||||
profileName: true,
|
||||
profile: true,
|
||||
instanceKey: true,
|
||||
currentScenario: true,
|
||||
status: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.webPushPreference.findMany({
|
||||
where: { userId },
|
||||
@@ -128,7 +137,15 @@ export class WebPushCoordinator {
|
||||
capability: this.getCapability(),
|
||||
eventTypes: WEB_PUSH_EVENT_TYPES,
|
||||
profiles: profiles.map((profile) => ({
|
||||
...profile,
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
instanceKey: profile.instanceKey,
|
||||
displayName: resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
(profile.meta as Record<string, unknown> | null)?.korName
|
||||
),
|
||||
currentScenario: profile.currentScenario,
|
||||
status: String(profile.status),
|
||||
})),
|
||||
preferences: preferences.filter((preference) => isWebPushEventType(preference.eventType)),
|
||||
@@ -218,7 +235,7 @@ export class WebPushCoordinator {
|
||||
if (!this.configured) return false;
|
||||
const profile = await tx.gatewayProfile.findUnique({
|
||||
where: { profileName: event.profileName },
|
||||
select: { profile: true, profileName: true },
|
||||
select: { profile: true, profileName: true, instanceKey: true, meta: true },
|
||||
});
|
||||
if (!profile) return false;
|
||||
const receipt = await tx.webPushEventReceipt.createMany({
|
||||
@@ -258,7 +275,16 @@ export class WebPushCoordinator {
|
||||
ids.push(subscription.id);
|
||||
subscriptionIdsByUser.set(subscription.userId, ids);
|
||||
}
|
||||
const copy = copyFor(event.eventType, profile.profile, event.year, event.month);
|
||||
const copy = copyFor(
|
||||
event.eventType,
|
||||
resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
(profile.meta as Record<string, unknown> | null)?.korName
|
||||
),
|
||||
event.year,
|
||||
event.month
|
||||
);
|
||||
for (const userId of selectedUserIds) {
|
||||
const subscriptionIds = subscriptionIdsByUser.get(userId) ?? [];
|
||||
if (subscriptionIds.length === 0) continue;
|
||||
@@ -395,10 +421,7 @@ export class WebPushCoordinator {
|
||||
});
|
||||
|
||||
for (const delivery of claimed) {
|
||||
if (
|
||||
delivery.subscription.expirationTime &&
|
||||
delivery.subscription.expirationTime.getTime() <= Date.now()
|
||||
) {
|
||||
if (delivery.subscription.expirationTime && delivery.subscription.expirationTime.getTime() <= Date.now()) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.webPushDelivery.updateMany({
|
||||
where: { id: delivery.id, lockOwner: this.owner },
|
||||
@@ -445,11 +468,15 @@ export class WebPushCoordinator {
|
||||
typeof error === 'object' && error !== null && 'statusCode' in error
|
||||
? Number((error as { statusCode?: unknown }).statusCode)
|
||||
: 0;
|
||||
const terminal = statusCode === 404 || statusCode === 410 || (statusCode >= 400 && statusCode < 500 && statusCode !== 429);
|
||||
const terminal =
|
||||
statusCode === 404 ||
|
||||
statusCode === 410 ||
|
||||
(statusCode >= 400 && statusCode < 500 && statusCode !== 429);
|
||||
const attempts = delivery.attempts;
|
||||
const exhausted = attempts >= 8;
|
||||
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
||||
const safeError = statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
|
||||
const safeError =
|
||||
statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.webPushDelivery.updateMany({
|
||||
where: { id: delivery.id, lockOwner: this.owner },
|
||||
|
||||
@@ -380,6 +380,7 @@ describe('admin profile navigation API', () => {
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
displayName: '체 [2]',
|
||||
currentScenario: '2',
|
||||
meta: { korName: '체' },
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildSharedProfileFrontendCommands,
|
||||
buildWorkspaceCommands,
|
||||
planProfileReconcile,
|
||||
resolveProfileArchiveServerName,
|
||||
resolveResetLifecycleStatus,
|
||||
} from '../src/orchestrator/gatewayOrchestrator.js';
|
||||
import { GATEWAY_PROFILE_ORDER } from '../src/profileOrder.js';
|
||||
@@ -134,6 +135,30 @@ describe('resolveResetLifecycleStatus', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProfileArchiveServerName', () => {
|
||||
it('uses the configured Gateway name and never the runtime instance key', () => {
|
||||
expect(
|
||||
resolveProfileArchiveServerName({
|
||||
...buildProfile(),
|
||||
profile: 'hwe',
|
||||
profileName: 'hwe:default',
|
||||
meta: { korName: ' 훼 ' },
|
||||
})
|
||||
).toBe('훼');
|
||||
});
|
||||
|
||||
it('uses the canonical profile label when Gateway has no override', () => {
|
||||
expect(
|
||||
resolveProfileArchiveServerName({
|
||||
...buildProfile(),
|
||||
profile: 'hwe',
|
||||
profileName: 'hwe:default',
|
||||
meta: {},
|
||||
})
|
||||
).toBe('훼');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProcessDefinitions', () => {
|
||||
const processConfig = {
|
||||
workspaceRoot: '/srv/sammo/main',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
GATEWAY_PROFILE_KOREAN_NAMES,
|
||||
GATEWAY_PROFILE_ORDER,
|
||||
orderGatewayProfiles,
|
||||
resolveGatewayProfileDisplayName,
|
||||
resolveGatewayProfileKoreanName,
|
||||
} from '../src/profileOrder.js';
|
||||
|
||||
@@ -56,3 +57,12 @@ describe('resolveGatewayProfileKoreanName', () => {
|
||||
expect(resolveGatewayProfileKoreanName('custom')).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGatewayProfileDisplayName', () => {
|
||||
it('hides the default instance key and distinguishes non-default instances', () => {
|
||||
expect(resolveGatewayProfileDisplayName('che', 'default')).toBe('체');
|
||||
expect(resolveGatewayProfileDisplayName('hwe', '2')).toBe('훼 [2]');
|
||||
expect(resolveGatewayProfileDisplayName('che', 'default', ' 천하서버 ')).toBe('천하서버');
|
||||
expect(resolveGatewayProfileDisplayName('custom', 'blue')).toBe('custom [blue]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,16 @@ import { describe, expect, it } from 'vitest';
|
||||
import { listScenarioPreviews, resolveGitCommitSha } from '../src/scenario/scenarioCatalog.js';
|
||||
|
||||
describe('scenarioCatalog git ref support', () => {
|
||||
it('includes the CHE zero-season dawn scenario in the local catalog', async () => {
|
||||
const previews = await listScenarioPreviews();
|
||||
|
||||
expect(previews.find((scenario) => scenario.id === 916)).toMatchObject({
|
||||
id: 916,
|
||||
title: '【공백지】 여명',
|
||||
year: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves HEAD to a commit hash', async () => {
|
||||
const commitSha = await resolveGitCommitSha('HEAD');
|
||||
expect(commitSha).toMatch(/^[0-9a-f]{40}$/i);
|
||||
@@ -18,6 +28,11 @@ describe('scenarioCatalog git ref support', () => {
|
||||
expect(previews.every((scenario) => scenario.fiction === null || Number.isInteger(scenario.fiction))).toBe(
|
||||
true
|
||||
);
|
||||
expect(previews.find((scenario) => scenario.id === 916)).toMatchObject({
|
||||
id: 916,
|
||||
title: '【공백지】 여명',
|
||||
year: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects without crashing when git cannot be spawned', async () => {
|
||||
|
||||
Reference in New Issue
Block a user