merge: 일반 리셋 브랜치 자동 갱신 통합
This commit is contained in:
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> => (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<void>,
|
||||
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<void>,
|
||||
operationId?: string
|
||||
operationId?: string,
|
||||
releaseSource?: ProfileReleaseSource
|
||||
): Promise<GatewayAdminActionResult> {
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
현재 배포 버전
|
||||
서버 지정 버전
|
||||
</label>
|
||||
<label
|
||||
v-if="mode === 'version' || hasCapability('admin.profiles.deploy')"
|
||||
|
||||
+11
-11
@@ -15,7 +15,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 |
|
||||
| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
|
||||
| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 |
|
||||
| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 현재 배포 버전 또는 새 버전으로 현 시즌 DB와 시나리오 교체 |
|
||||
| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 서버 지정 branch 최신 또는 고정 commit으로 현 시즌 DB와 시나리오 교체 |
|
||||
| 게임 취소 | `/gateway/admin/servers/:profileName/cancel` | 잘못 연 게임을 닫고 기록·유산 포인트를 취소 정책에 따라 원자적으로 정산 |
|
||||
| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback |
|
||||
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
|
||||
@@ -63,7 +63,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
- 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git
|
||||
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
|
||||
권한과 버전 배포 권한이 모두 필요합니다.
|
||||
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
|
||||
- 서버 지정 버전의 시나리오 catalog는 capability·operation polling batch와
|
||||
분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
|
||||
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
|
||||
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
|
||||
@@ -95,15 +95,15 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
|
||||
## Profile 권한 분류
|
||||
|
||||
| capability | 허용 작업 |
|
||||
| -------------------------------- | --------------------------------------------------------- |
|
||||
| `admin.profiles.runtime:<name>` | 시작·정지·일시정지·재개, 시간 조정과 현재 기수 게임 옵션 |
|
||||
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
|
||||
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
|
||||
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
|
||||
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
|
||||
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
|
||||
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
|
||||
| capability | 허용 작업 |
|
||||
| -------------------------------- | ---------------------------------------------------------- |
|
||||
| `admin.profiles.runtime:<name>` | 시작·정지·일시정지·재개, 시간 조정과 현재 기수 게임 옵션 |
|
||||
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
|
||||
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
|
||||
| `admin.scenarios.reset:<name>` | 서버 지정 branch 최신 또는 고정 commit으로 시나리오 초기화 |
|
||||
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
|
||||
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
|
||||
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
|
||||
|
||||
기존 상태 화면의 `즉시 리셋`·`리셋 예약` 버튼은 실제 DB 초기화 operation과
|
||||
다른 metadata action이어서 제거했습니다. 초기화와 예약은 시나리오 초기화 탭의
|
||||
|
||||
@@ -100,12 +100,20 @@ Profile process 전환 중에는 frontend/API port가 잠시 닫힐 수 있습
|
||||
### 시나리오 초기화
|
||||
|
||||
시나리오 초기화는 새 시즌이나 새 scenario로 현 시즌 데이터를 교체할 때
|
||||
사용합니다. 기본 `현재 배포 버전`은 profile의 게시된 full commit을 서버에서
|
||||
결정하므로 Git 입력과 `admin.profiles.deploy` 권한이 필요하지 않습니다. 새 branch
|
||||
사용합니다. 기본 `서버 지정 버전`은 마지막으로 성공한 상위 권한자의 배포 소스를
|
||||
따릅니다. Branch가 지정되어 있으면 worker가 작업을 claim할 때 원격 최신 commit을
|
||||
해석하고, commit이 지정되어 있으면 그 full SHA를 계속 사용합니다. 따라서 Git 입력과
|
||||
`admin.profiles.deploy` 권한 없이도 일반 초기화 권한자가 branch 업데이트를 포함해
|
||||
초기화할 수 있습니다. 새 branch
|
||||
또는 commit을 함께 배포하는 모드는 `admin.scenarios.reset`과
|
||||
`admin.profiles.deploy`를 모두 요구합니다. Source와 scenario를 확인한 뒤 turn 간격, 가오픈·정식 오픈,
|
||||
NPC와 자동 진행 설정을 확인하고 요청해 주세요.
|
||||
|
||||
같은 commit이 이미 active profile workspace에 설치되어 있으면 build command는 생략하고
|
||||
migration과 scenario seed부터 진행합니다. 상위 권한자가 버전 업데이트에서 commit을
|
||||
선택한 경우에만 이후 일반 초기화가 그 commit에 고정됩니다. 다시 branch를 선택해
|
||||
성공적으로 배포하면 branch 추적으로 돌아갑니다.
|
||||
|
||||
턴 간격과 고급 옵션은 서버 상태 화면에서 저장한
|
||||
`GatewayProfile.meta.resetDefaults`를 최초값으로 사용합니다. 서버별 기본값을
|
||||
바꾸려면 `admin.profiles.settings:<name>` 권한으로 메타를 저장하고 시나리오
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
-- Preserve the source policy of the release that produced the active profile build.
|
||||
-- BRANCH keeps following its remote head; COMMIT remains explicitly pinned.
|
||||
WITH latest_success AS (
|
||||
SELECT DISTINCT ON ("profile_name")
|
||||
"profile_name",
|
||||
"source_mode",
|
||||
"source_ref",
|
||||
"resolved_commit_sha"
|
||||
FROM "gateway_operation"
|
||||
WHERE "status" = 'SUCCEEDED'
|
||||
AND (
|
||||
"type" = 'DEPLOY'
|
||||
OR ("type" = 'RESET' AND "payload" ->> 'requestedSource' IN ('BRANCH', 'COMMIT'))
|
||||
)
|
||||
AND "source_mode" IS NOT NULL
|
||||
AND "source_ref" IS NOT NULL
|
||||
AND "resolved_commit_sha" IS NOT NULL
|
||||
ORDER BY "profile_name", "completed_at" DESC NULLS LAST, "created_at" DESC
|
||||
)
|
||||
UPDATE "gateway_profile" AS profile
|
||||
SET "meta" = jsonb_set(
|
||||
COALESCE(profile."meta", '{}'::jsonb),
|
||||
'{releaseSource}',
|
||||
jsonb_build_object(
|
||||
'mode', latest."source_mode"::text,
|
||||
'ref', CASE
|
||||
WHEN latest."source_mode" = 'BRANCH' THEN latest."source_ref"
|
||||
ELSE latest."resolved_commit_sha"
|
||||
END
|
||||
),
|
||||
true
|
||||
)
|
||||
FROM latest_success AS latest
|
||||
WHERE profile."profile_name" = latest."profile_name"
|
||||
AND profile."build_commit_sha" = latest."resolved_commit_sha"
|
||||
AND NOT (COALESCE(profile."meta", '{}'::jsonb) ? 'releaseSource');
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"controllerProtocol": 2,
|
||||
"gatewaySchemaHead": "20260818001000_add_game_cancellation_operation",
|
||||
"gatewaySchemaHead": "20260819000000_backfill_profile_release_source",
|
||||
"gameSchemaHead": "20260818010000_add_legacy_battle_result_logs",
|
||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user