From 4977fb615d093b206cd0747501f9121b94eeff04 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 19 Aug 2026 13:42:47 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=BC=EB=B0=98=20=EB=A6=AC=EC=85=8B?= =?UTF-8?q?=EC=9D=B4=20=EC=84=9C=EB=B2=84=20=EC=A7=80=EC=A0=95=20=EB=B8=8C?= =?UTF-8?q?=EB=9E=9C=EC=B9=98=EB=A5=BC=20=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상위 배포 권한자가 선택한 branch 또는 commit 정책을 profile metadata에 보존하고 일반 시나리오 초기화가 이를 따르도록 변경한다. 같은 active commit의 설치 완료 workspace는 빌드를 생략하고 기존 migration, seed, readiness 흐름을 유지한다. --- app/gateway-api/src/adminRouter.ts | 20 ++++-- .../src/orchestrator/gatewayOrchestrator.ts | 54 +++++++++++++-- .../src/orchestrator/profileReleaseSource.ts | 46 +++++++++++++ .../src/orchestrator/profileRepository.ts | 2 + app/gateway-api/test/adminOperations.test.ts | 17 +++-- .../test/profileDeployOperation.test.ts | 1 + .../test/profileReleaseSource.test.ts | 68 +++++++++++++++++++ .../test/profileRepository.test.ts | 2 + .../e2e/server-operations.spec.ts | 4 +- .../src/views/ServerOperationsView.vue | 8 +-- docs/admin-console.md | 22 +++--- docs/release-operations.md | 12 +++- .../migration.sql | 36 ++++++++++ release-manifest.json | 2 +- 14 files changed, 259 insertions(+), 35 deletions(-) create mode 100644 app/gateway-api/src/orchestrator/profileReleaseSource.ts create mode 100644 app/gateway-api/test/profileReleaseSource.test.ts create mode 100644 packages/infra/prisma/gateway-migrations/20260819000000_backfill_profile_release_source/migration.sql diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index c1f1fe0c..4489e507 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -21,6 +21,7 @@ import { 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 { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; @@ -1210,9 +1211,10 @@ export const adminRouter = router({ }); } - const sourceMode: 'BRANCH' | 'COMMIT' = input.sourceMode === 'CURRENT' ? 'COMMIT' : input.sourceMode; - let sourceRef = - input.sourceMode === 'CURRENT' ? profile.buildCommitSha?.trim() : input.sourceRef?.trim(); + const configuredSource = input.sourceMode === 'CURRENT' ? readProfileReleaseSource(profile) : null; + const sourceMode: 'BRANCH' | 'COMMIT' = + input.sourceMode === 'CURRENT' ? (configuredSource?.mode ?? 'COMMIT') : input.sourceMode; + let sourceRef = configuredSource?.ref ?? input.sourceRef?.trim(); if (!sourceRef) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -1253,6 +1255,7 @@ export const adminRouter = router({ payload: { install: input.install, requestedSource: input.sourceMode, + releaseSource: { mode: sourceMode, ref: sourceRef }, } as GatewayPrisma.JsonObject, reason: input.reason, requestedBy: adminAuth.user.id, @@ -1372,6 +1375,7 @@ export const adminRouter = router({ type: 'DEPLOY', sourceMode: input.sourceMode, sourceRef, + payload: { releaseSource: { mode: input.sourceMode, ref: sourceRef } }, reason: input.reason, requestedBy: adminAuth.user.id, }); @@ -1740,6 +1744,8 @@ export const adminRouter = router({ .query(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); const sourceMode = input?.sourceMode ?? 'CURRENT'; + let resolvedSourceMode: 'BRANCH' | 'COMMIT' | undefined = + sourceMode === 'CURRENT' ? undefined : sourceMode; let gitRef = input?.gitRef?.trim(); let currentScenarioId: number | null = null; if (sourceMode === 'CURRENT') { @@ -1754,8 +1760,10 @@ export const adminRouter = router({ const parsedScenarioId = profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario); currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null; - gitRef = profile.buildCommitSha?.trim(); - if (!gitRef) { + const configuredSource = readProfileReleaseSource(profile); + gitRef = configuredSource?.ref; + resolvedSourceMode = configuredSource?.mode; + if (!configuredSource || !gitRef) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'The profile has no active build commit.', @@ -1771,7 +1779,7 @@ export const adminRouter = router({ ? await listScenarioPreviews() : await listScenarioPreviews({ gitRef: - sourceMode === 'BRANCH' + resolvedSourceMode === 'BRANCH' ? await resolveGitBranchCommitSha(gitRef) : await resolveGitCommitSha(gitRef), }); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 6150fda5..519694d3 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -38,6 +38,11 @@ import type { GatewayProfileRepository, GatewayProfileStatus, } from './profileRepository.js'; +import { + canReuseActiveProfileWorkspace, + writeProfileReleaseSource, + type ProfileReleaseSource, +} from './profileReleaseSource.js'; import type { GitWorkspaceManager } from './workspaceManager.js'; import type { AdminSeedUser } from './seedProfileDatabase.js'; import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js'; @@ -197,6 +202,16 @@ class OperationLeaseLostError extends Error {} const normalizeMeta = (value: unknown): Record => (isRecord(value) ? value : {}); +const readOperationReleaseSource = (operation: GatewayOperationRecord): ProfileReleaseSource => { + const stored = normalizeMeta(normalizeMeta(operation.payload).releaseSource); + const mode = stored.mode; + const ref = typeof stored.ref === 'string' ? stored.ref.trim() : ''; + if ((mode === 'BRANCH' || mode === 'COMMIT') && ref) { + return { mode, ref }; + } + return { mode: operation.sourceMode!, ref: operation.sourceRef! }; +}; + export const buildTournamentRuntimeKeys = (profileName: string): string[] => [ `sammo:${profileName}:tournament:state`, `sammo:${profileName}:tournament:participants`, @@ -1167,7 +1182,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return; } if (operation.type === 'DEPLOY') { - const result = await this.handleProfileDeploy(profile, commitSha, assertLease, operation.id); + const result = await this.handleProfileDeploy( + profile, + commitSha, + assertLease, + operation.id, + readOperationReleaseSource(operation) + ); if (!result.ok) { throw new Error(result.detail); } @@ -1192,7 +1213,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { installOperationId, install, }; - const result = await this.handleResetAction(profile, resetAction, commitSha, assertLease, operation.id); + const result = await this.handleResetAction( + profile, + resetAction, + commitSha, + assertLease, + operation.id, + readOperationReleaseSource(operation) + ); if (result.status === 'REQUESTED') { const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString(); await this.appendOperationLog( @@ -1360,7 +1388,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { profile: GatewayProfileRecord, commitSha: string, assertLease: () => Promise, - operationId: string + operationId: string, + releaseSource: ProfileReleaseSource ): Promise<{ ok: true } | { ok: false; detail: string }> { if (this.buildInFlight) { return { ok: false, detail: 'build already in progress' }; @@ -1498,6 +1527,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { buildCompletedAt: completedAt, buildError: null, lastError: null, + meta: writeProfileReleaseSource(profile.meta, releaseSource), }); await this.appendOperationLog( operationId, @@ -1617,7 +1647,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { action: GatewayAdminActionRecord, commitShaOverride?: string, assertLease?: () => Promise, - operationId?: string + operationId?: string, + releaseSource?: ProfileReleaseSource ): Promise { const appendLog = async ( phase: string, @@ -1819,6 +1850,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null, openAt: openAt ? openAt.toISOString() : null, scheduledStartAt: action.scheduledAt ?? null, + ...(releaseSource ? { meta: writeProfileReleaseSource(profile.meta, releaseSource) } : {}), }, async () => { await this.repository.updateWorkspaceUsage(profile.profileName, workspace.root, completedAt); @@ -1951,6 +1983,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { if (operationId) { await this.appendOperationLog(operationId, 'workspace', `worktree 준비 완료: ${workspace.root}`); } + const activeWorkspaceReusable = canReuseActiveProfileWorkspace(profile, commitSha, workspace); + if (activeWorkspaceReusable) { + if (operationId) { + await this.appendOperationLog( + operationId, + 'build', + '이미 최신 커밋의 빌드 산출물이 준비되어 있어 빌드를 생략합니다.' + ); + } + return { + result: { ok: true, exitCode: 0, output: '' }, + workspace, + }; + } const commands = [ ...buildWorkspaceCommands( workspace.root, diff --git a/app/gateway-api/src/orchestrator/profileReleaseSource.ts b/app/gateway-api/src/orchestrator/profileReleaseSource.ts new file mode 100644 index 00000000..4f956cd7 --- /dev/null +++ b/app/gateway-api/src/orchestrator/profileReleaseSource.ts @@ -0,0 +1,46 @@ +import path from 'node:path'; + +import type { GatewayPrisma } from '@sammo-ts/infra'; + +import type { GatewayProfileRecord, GatewaySourceMode } from './profileRepository.js'; +import type { WorkspaceInfo } from './workspaceManager.js'; + +export interface ProfileReleaseSource { + mode: GatewaySourceMode; + ref: string; +} + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === 'object' && !Array.isArray(value)); + +export const readProfileReleaseSource = (profile: GatewayProfileRecord): ProfileReleaseSource | null => { + const stored = isRecord(profile.meta) && isRecord(profile.meta.releaseSource) ? profile.meta.releaseSource : null; + const mode = stored?.mode; + const ref = typeof stored?.ref === 'string' ? stored.ref.trim() : ''; + if ((mode === 'BRANCH' || mode === 'COMMIT') && ref) { + return { mode, ref }; + } + const activeCommit = profile.buildCommitSha?.trim(); + return activeCommit ? { mode: 'COMMIT', ref: activeCommit } : null; +}; + +export const writeProfileReleaseSource = ( + meta: GatewayPrisma.JsonObject, + source: ProfileReleaseSource +): GatewayPrisma.JsonObject => ({ + ...meta, + releaseSource: { + mode: source.mode, + ref: source.ref, + }, +}); + +export const canReuseActiveProfileWorkspace = ( + profile: GatewayProfileRecord | undefined, + commitSha: string, + workspace: WorkspaceInfo +): boolean => + profile?.buildCommitSha === commitSha && + typeof profile.buildWorkspace === 'string' && + path.resolve(profile.buildWorkspace) === path.resolve(workspace.root) && + !workspace.needsInstall; diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 1fa900a6..2fab771e 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -122,6 +122,7 @@ export interface GatewayClaimedProfileUpdate { buildCompletedAt?: string | null; buildError?: string | null; lastError?: string | null; + meta?: GatewayPrisma.JsonObject; } export interface GatewayProfileRepository { @@ -748,6 +749,7 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat buildCompletedAt: toDate(patch.buildCompletedAt), buildError: patch.buildError, lastError: patch.lastError, + meta: patch.meta, }, }); }); diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 7cef041a..e5a484b2 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -613,7 +613,7 @@ describe('admin operation API', () => { ).rejects.toMatchObject({ code: 'CONFLICT' }); }); - it('queues a DB-preserving profile deployment without reset payload', async () => { + it('queues a DB-preserving profile deployment with a durable release policy and no reset payload', async () => { const harness = await buildCaller( async (input) => ({ id: '33333333-3333-4333-8333-333333333333', @@ -642,11 +642,17 @@ describe('admin operation API', () => { type: 'DEPLOY', sourceMode: 'COMMIT', reason: 'preserve live season', + payload: { + releaseSource: { + mode: 'COMMIT', + ref: expect.stringMatching(/^[0-9a-f]{40}$/u), + }, + }, }); - expect(harness.createdInputs[0]).not.toHaveProperty('payload'); + expect(harness.createdInputs[0]?.payload).not.toHaveProperty('install'); }); - it('lets a scenario-only operator reset from the active commit without selecting Git', async () => { + it('lets a scenario-only operator reset from the configured branch latest without selecting Git', async () => { const harness = await buildCaller( async (input) => ({ id: '55555555-5555-4555-8555-555555555555', @@ -664,6 +670,7 @@ describe('admin operation API', () => { adminRoles: ['admin.scenarios.reset:che:2'], firstUserIsAdmin: false, profileScenario: '1010', + profileMeta: { releaseSource: { mode: 'BRANCH', ref: 'main' } }, } ); @@ -687,8 +694,8 @@ describe('admin operation API', () => { expect(harness.createdInputs[0]).toMatchObject({ type: 'RESET', - sourceMode: 'COMMIT', - sourceRef: expect.stringMatching(/^[0-9a-f]{40}$/u), + sourceMode: 'BRANCH', + sourceRef: 'main', reason: 'new season only', }); }); diff --git a/app/gateway-api/test/profileDeployOperation.test.ts b/app/gateway-api/test/profileDeployOperation.test.ts index 5b74ba6d..d61ceae7 100644 --- a/app/gateway-api/test/profileDeployOperation.test.ts +++ b/app/gateway-api/test/profileDeployOperation.test.ts @@ -193,6 +193,7 @@ describe('profile DEPLOY operation', () => { buildStatus: 'SUCCEEDED', buildCommitSha: SHA, buildWorkspace: workspace, + meta: { releaseSource: { mode: 'COMMIT', ref: SHA } }, }); expect(completions).toEqual(['SUCCEEDED']); expect(logs).toEqual( diff --git a/app/gateway-api/test/profileReleaseSource.test.ts b/app/gateway-api/test/profileReleaseSource.test.ts new file mode 100644 index 00000000..4d760796 --- /dev/null +++ b/app/gateway-api/test/profileReleaseSource.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { + canReuseActiveProfileWorkspace, + readProfileReleaseSource, + writeProfileReleaseSource, +} from '../src/orchestrator/profileReleaseSource.js'; +import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js'; + +const profile = (overrides: Partial = {}): GatewayProfileRecord => ({ + profileName: 'che:2', + profile: 'che', + instanceKey: '2', + currentScenario: '1010', + scenario: '1010', + apiPort: 15003, + status: 'RUNNING', + buildStatus: 'SUCCEEDED', + buildCommitSha: 'a'.repeat(40), + buildWorkspace: `/srv/sammo/worktrees/${'a'.repeat(40)}`, + meta: {}, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', + ...overrides, +}); + +describe('profile release source policy', () => { + it('follows the stored branch instead of pinning the active commit', () => { + const current = profile({ meta: { releaseSource: { mode: 'BRANCH', ref: 'main' } } }); + expect(readProfileReleaseSource(current)).toEqual({ mode: 'BRANCH', ref: 'main' }); + }); + + it('falls back to the active commit for profiles without a stored policy', () => { + expect(readProfileReleaseSource(profile())).toEqual({ mode: 'COMMIT', ref: 'a'.repeat(40) }); + }); + + it('preserves unrelated metadata while changing the privileged release policy', () => { + expect(writeProfileReleaseSource({ nextSeasonIdx: 3 }, { mode: 'COMMIT', ref: 'b'.repeat(40) })).toEqual({ + nextSeasonIdx: 3, + releaseSource: { mode: 'COMMIT', ref: 'b'.repeat(40) }, + }); + }); + + it('reuses installed artifacts only for the same active commit and workspace', () => { + const current = profile(); + expect( + canReuseActiveProfileWorkspace(current, 'a'.repeat(40), { + root: current.buildWorkspace!, + created: false, + needsInstall: false, + }) + ).toBe(true); + expect( + canReuseActiveProfileWorkspace(current, 'b'.repeat(40), { + root: current.buildWorkspace!, + created: false, + needsInstall: false, + }) + ).toBe(false); + expect( + canReuseActiveProfileWorkspace(current, 'a'.repeat(40), { + root: current.buildWorkspace!, + created: false, + needsInstall: true, + }) + ).toBe(false); + }); +}); diff --git a/app/gateway-api/test/profileRepository.test.ts b/app/gateway-api/test/profileRepository.test.ts index 39155e28..8d996e47 100644 --- a/app/gateway-api/test/profileRepository.test.ts +++ b/app/gateway-api/test/profileRepository.test.ts @@ -18,12 +18,14 @@ describe('buildRetryOperationPayload', () => { { install: { scenarioId: 903 }, installOperationId: 'original-install-generation', + releaseSource: { mode: 'BRANCH', ref: 'main' }, }, 'newer-failed-operation' ) ).toEqual({ install: { scenarioId: 903 }, installOperationId: 'original-install-generation', + releaseSource: { mode: 'BRANCH', ref: 'main' }, }); }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index efd9920d..c8ed572f 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -507,7 +507,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat await expect(page.getByTestId('server-operations-page')).toBeVisible(); 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('source-help')).toContainText('브랜치를 추적하면 작업 시작 시 최신 커밋'); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); await expect(page.getByTestId('request-reset')).toBeEnabled(); await expect(page.getByTestId('scenario-select').locator('option:checked')).toContainText('현재 시나리오'); @@ -1176,7 +1176,7 @@ test('renders the stable server identity without exposing the default suffix as expect(state.profileNavigationRequests).toBe(1); }); -test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => { +test('scenario-only operator resets the server-selected version without Git or Gateway controls', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [], diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index b28c780f..618ad3ec 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -211,7 +211,7 @@ const pageDescription = computed(() => { return '진행 중 게임을 닫고 정식 기수에서 제외하며 장수 기록과 유산 포인트 보전 범위를 선택합니다.'; } if (props.mode === 'scenario') { - return '현재 배포 버전으로 시나리오만 초기화하거나, 배포 권한이 있을 때 새 버전과 함께 초기화합니다.'; + return '서버에 지정된 브랜치의 최신 버전 또는 고정 커밋으로 시나리오를 초기화합니다.'; } return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.'; }); @@ -227,7 +227,7 @@ const activeOperation = computed( const sourceHelp = computed(() => form.sourceMode === 'CURRENT' - ? '현재 서버에 배포된 커밋의 시나리오 리소스를 사용합니다.' + ? '서버가 브랜치를 추적하면 작업 시작 시 최신 커밋을 사용하고, 커밋 고정 상태면 그 버전을 유지합니다.' : form.sourceMode === 'BRANCH' ? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.' : '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.' @@ -597,7 +597,7 @@ const requestReset = async () => { } const scenarioId = form.scenarioId; const sourceLabel = - form.sourceMode === 'CURRENT' ? '현재 배포 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋'; + form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋'; if ( !window.confirm( `${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}` @@ -925,7 +925,7 @@ onBeforeUnmount(() => { value="CURRENT" data-testid="source-current" /> - 현재 배포 버전 + 서버 지정 버전