fix: 첫 빌드 전 닫힌 서버도 패치 정책으로 오픈 요청을 받는다

This commit is contained in:
2026-10-23 00:42:11 +09:00
parent d4a87c981f
commit b377250fb9
5 changed files with 51 additions and 10 deletions
@@ -0,0 +1,26 @@
import { TRPCError } from '@trpc/server';
import type { GatewayProfileRecord } from '../orchestrator/profileRepository.js';
import { readProfileReleaseSource } from '../orchestrator/profileReleaseSource.js';
import { resolveGitBranchCommitSha, resolveGitCommitSha } from '../scenario/scenarioCatalog.js';
// 이미 배포된 서버는 그 catalog를 보존하고, 첫 오픈 전 서버만 운영자가 정한 source를 사용한다.
// 일반 사용자는 이 조회에도 branch/commit을 전달할 수 없다.
export const resolveOpeningCatalogCommit = async (
profile: Pick<GatewayProfileRecord, 'meta' | 'buildCommitSha'>
): Promise<string> => {
if (profile.buildCommitSha?.trim()) return profile.buildCommitSha.trim();
const source = readProfileReleaseSource(profile);
if (!source) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '서버의 배포 버전 또는 패치 정책이 필요합니다.' });
}
try {
return source.mode === 'BRANCH'
? await resolveGitBranchCommitSha(source.ref)
: await resolveGitCommitSha(source.ref);
} catch {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '서버 패치 정책의 시나리오 버전을 확인하지 못했습니다.',
});
}
};
+6 -2
View File
@@ -16,6 +16,7 @@ import {
} from '../scenario/scenarioCatalog.js'; } from '../scenario/scenarioCatalog.js';
import { canRequestOpening, openingSeasonKey, readOpenRequestPolicy, type OpenRequestPolicy } from './policy.js'; import { canRequestOpening, openingSeasonKey, readOpenRequestPolicy, type OpenRequestPolicy } from './policy.js';
import { z } from 'zod'; import { z } from 'zod';
import { resolveOpeningCatalogCommit } from './catalogSource.js';
export const zOpeningInstall = zOperationInstallOptions.required({ preopenAt: true, openAt: true }).strict(); export const zOpeningInstall = zOperationInstallOptions.required({ preopenAt: true, openAt: true }).strict();
export type OpeningInstall = z.infer<typeof zOpeningInstall>; export type OpeningInstall = z.infer<typeof zOpeningInstall>;
@@ -200,8 +201,11 @@ export const createOpenRequest = async (
} }
if (old.filter((request) => activeStatus(request, profile, now) === 'PENDING').length >= 100) if (old.filter((request) => activeStatus(request, profile, now) === 'PENDING').length >= 100)
return fail('이 서버의 대기 요청이 너무 많습니다.', 'CONFLICT'); return fail('이 서버의 대기 요청이 너무 많습니다.', 'CONFLICT');
if (!profile.buildCommitSha) return fail('서버의 시나리오 버전이 준비되지 않았습니다.'); const catalogCommit = await resolveOpeningCatalogCommit({
const scenario = (await listScenarioPreviews({ gitRef: profile.buildCommitSha })).find( meta: profile.meta as GatewayPrisma.JsonObject,
buildCommitSha: profile.buildCommitSha ?? undefined,
});
const scenario = (await listScenarioPreviews({ gitRef: catalogCommit })).find(
(entry) => entry.id === install.scenarioId (entry) => entry.id === install.scenarioId
); );
if (!scenario) return fail('현재 서버에 없는 시나리오입니다.'); if (!scenario) return fail('현재 서버에 없는 시나리오입니다.');
+4 -8
View File
@@ -12,6 +12,7 @@ import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js'; import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js'; import { adminRouter } from './adminRouter.js';
import { openRequestsRouter } from './openRequests/router.js'; import { openRequestsRouter } from './openRequests/router.js';
import { resolveOpeningCatalogCommit } from './openRequests/catalogSource.js';
import { accountRouter } from './account/router.js'; import { accountRouter } from './account/router.js';
import { import {
hasActiveSpecialAccountGrant, hasActiveSpecialAccountGrant,
@@ -217,16 +218,11 @@ export const appRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
} }
const profile = await ctx.profiles.getProfile(input.profileName); const profile = await ctx.profiles.getProfile(input.profileName);
const activeBuildCommit = profile?.buildCommitSha?.trim(); if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
if (!profile || !activeBuildCommit) { const catalogCommit = await resolveOpeningCatalogCommit(profile);
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'The profile has no active build commit.',
});
}
try { try {
return await listScenarioPreviews({ gitRef: activeBuildCommit }); return await listScenarioPreviews({ gitRef: catalogCommit });
} catch { } catch {
throw new TRPCError({ throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
@@ -209,6 +209,17 @@ suite('durable opening requests (isolated PostgreSQL and HTTP)', () => {
expect((await listOpenRequests(context(), await user(2), profileName)).requests).toHaveLength(1); expect((await listOpenRequests(context(), await user(2), profileName)).requests).toHaveLength(1);
expect(JSON.stringify(await listOpenRequests(context(), await user(2), profileName))).not.toContain('password'); expect(JSON.stringify(await listOpenRequests(context(), await user(2), profileName))).not.toContain('password');
}); });
it('supports the first opening of a closed server using its configured source without an active build', async () => {
await prisma.gatewayProfile.update({
where: { profileName },
data: { buildCommitSha: null, meta: { releaseSource: { mode: 'BRANCH', ref: 'main' } } },
});
expect(await (await caller(1)).lobby.scenarios({ profileName })).toHaveLength(1);
expect(catalog.resolveGitBranchCommitSha).toHaveBeenCalledWith('main');
const request = await create();
await (await caller(3)).admin.openRequests.decide({ id: request.id, action: 'ACCEPT' });
expect((await prisma.gatewayOperation.findFirstOrThrow({ where: { profileName } })).sourceMode).toBe('BRANCH');
});
it('uses the configured lifetime for distant proposals', async () => { it('uses the configured lifetime for distant proposals', async () => {
await policy(null, { lifetimeDays: 2 }); await policy(null, { lifetimeDays: 2 });
const input = install(); const input = install();
+4
View File
@@ -18,6 +18,10 @@
않는다. 정책 저장은 `admin.profiles.settings` 권한이 필요하다. 기존 superuser 않는다. 정책 저장은 `admin.profiles.settings` 권한이 필요하다. 기존 superuser
판정과 profile scope 검사를 그대로 사용하며 운영 mutation은 감사 원장에 남긴다. 판정과 profile scope 검사를 그대로 사용하며 운영 mutation은 감사 원장에 남긴다.
배포 이력이 있는 서버의 시나리오는 active build에서 읽는다. 아직 첫 build가 없는
닫힌 서버는 운영자가 설정한 releaseSource에서 시나리오를 읽어 첫 오픈도 요청할 수
있다. active build와 patch 정책이 모두 없으면 운영 설정 준비를 요구한다.
## 서버 정책 ## 서버 정책
정책은 `GatewayProfile.meta.openRequestPolicy`에 저장한다. 요청 화면의 권한자용 정책은 `GatewayProfile.meta.openRequestPolicy`에 저장한다. 요청 화면의 권한자용