merge: 일반 리셋 브랜치 자동 갱신 통합
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
|||||||
import type { GatewayApiContext } from './context.js';
|
import type { GatewayApiContext } from './context.js';
|
||||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||||
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.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, resolveGatewayProfileKoreanName } from './profileOrder.js';
|
||||||
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.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;
|
const configuredSource = input.sourceMode === 'CURRENT' ? readProfileReleaseSource(profile) : null;
|
||||||
let sourceRef =
|
const sourceMode: 'BRANCH' | 'COMMIT' =
|
||||||
input.sourceMode === 'CURRENT' ? profile.buildCommitSha?.trim() : input.sourceRef?.trim();
|
input.sourceMode === 'CURRENT' ? (configuredSource?.mode ?? 'COMMIT') : input.sourceMode;
|
||||||
|
let sourceRef = configuredSource?.ref ?? input.sourceRef?.trim();
|
||||||
if (!sourceRef) {
|
if (!sourceRef) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -1253,6 +1255,7 @@ export const adminRouter = router({
|
|||||||
payload: {
|
payload: {
|
||||||
install: input.install,
|
install: input.install,
|
||||||
requestedSource: input.sourceMode,
|
requestedSource: input.sourceMode,
|
||||||
|
releaseSource: { mode: sourceMode, ref: sourceRef },
|
||||||
} as GatewayPrisma.JsonObject,
|
} as GatewayPrisma.JsonObject,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
requestedBy: adminAuth.user.id,
|
requestedBy: adminAuth.user.id,
|
||||||
@@ -1372,6 +1375,7 @@ export const adminRouter = router({
|
|||||||
type: 'DEPLOY',
|
type: 'DEPLOY',
|
||||||
sourceMode: input.sourceMode,
|
sourceMode: input.sourceMode,
|
||||||
sourceRef,
|
sourceRef,
|
||||||
|
payload: { releaseSource: { mode: input.sourceMode, ref: sourceRef } },
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
requestedBy: adminAuth.user.id,
|
requestedBy: adminAuth.user.id,
|
||||||
});
|
});
|
||||||
@@ -1740,6 +1744,8 @@ export const adminRouter = router({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
const sourceMode = input?.sourceMode ?? 'CURRENT';
|
const sourceMode = input?.sourceMode ?? 'CURRENT';
|
||||||
|
let resolvedSourceMode: 'BRANCH' | 'COMMIT' | undefined =
|
||||||
|
sourceMode === 'CURRENT' ? undefined : sourceMode;
|
||||||
let gitRef = input?.gitRef?.trim();
|
let gitRef = input?.gitRef?.trim();
|
||||||
let currentScenarioId: number | null = null;
|
let currentScenarioId: number | null = null;
|
||||||
if (sourceMode === 'CURRENT') {
|
if (sourceMode === 'CURRENT') {
|
||||||
@@ -1754,8 +1760,10 @@ export const adminRouter = router({
|
|||||||
const parsedScenarioId =
|
const parsedScenarioId =
|
||||||
profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario);
|
profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario);
|
||||||
currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
|
currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
|
||||||
gitRef = profile.buildCommitSha?.trim();
|
const configuredSource = readProfileReleaseSource(profile);
|
||||||
if (!gitRef) {
|
gitRef = configuredSource?.ref;
|
||||||
|
resolvedSourceMode = configuredSource?.mode;
|
||||||
|
if (!configuredSource || !gitRef) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'The profile has no active build commit.',
|
message: 'The profile has no active build commit.',
|
||||||
@@ -1771,7 +1779,7 @@ export const adminRouter = router({
|
|||||||
? await listScenarioPreviews()
|
? await listScenarioPreviews()
|
||||||
: await listScenarioPreviews({
|
: await listScenarioPreviews({
|
||||||
gitRef:
|
gitRef:
|
||||||
sourceMode === 'BRANCH'
|
resolvedSourceMode === 'BRANCH'
|
||||||
? await resolveGitBranchCommitSha(gitRef)
|
? await resolveGitBranchCommitSha(gitRef)
|
||||||
: await resolveGitCommitSha(gitRef),
|
: await resolveGitCommitSha(gitRef),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ import type {
|
|||||||
GatewayProfileRepository,
|
GatewayProfileRepository,
|
||||||
GatewayProfileStatus,
|
GatewayProfileStatus,
|
||||||
} from './profileRepository.js';
|
} from './profileRepository.js';
|
||||||
|
import {
|
||||||
|
canReuseActiveProfileWorkspace,
|
||||||
|
writeProfileReleaseSource,
|
||||||
|
type ProfileReleaseSource,
|
||||||
|
} from './profileReleaseSource.js';
|
||||||
import type { GitWorkspaceManager } from './workspaceManager.js';
|
import type { GitWorkspaceManager } from './workspaceManager.js';
|
||||||
import type { AdminSeedUser } from './seedProfileDatabase.js';
|
import type { AdminSeedUser } from './seedProfileDatabase.js';
|
||||||
import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.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 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[] => [
|
export const buildTournamentRuntimeKeys = (profileName: string): string[] => [
|
||||||
`sammo:${profileName}:tournament:state`,
|
`sammo:${profileName}:tournament:state`,
|
||||||
`sammo:${profileName}:tournament:participants`,
|
`sammo:${profileName}:tournament:participants`,
|
||||||
@@ -1167,7 +1182,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (operation.type === 'DEPLOY') {
|
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) {
|
if (!result.ok) {
|
||||||
throw new Error(result.detail);
|
throw new Error(result.detail);
|
||||||
}
|
}
|
||||||
@@ -1192,7 +1213,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
installOperationId,
|
installOperationId,
|
||||||
install,
|
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') {
|
if (result.status === 'REQUESTED') {
|
||||||
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
|
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
|
||||||
await this.appendOperationLog(
|
await this.appendOperationLog(
|
||||||
@@ -1360,7 +1388,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
profile: GatewayProfileRecord,
|
profile: GatewayProfileRecord,
|
||||||
commitSha: string,
|
commitSha: string,
|
||||||
assertLease: () => Promise<void>,
|
assertLease: () => Promise<void>,
|
||||||
operationId: string
|
operationId: string,
|
||||||
|
releaseSource: ProfileReleaseSource
|
||||||
): Promise<{ ok: true } | { ok: false; detail: string }> {
|
): Promise<{ ok: true } | { ok: false; detail: string }> {
|
||||||
if (this.buildInFlight) {
|
if (this.buildInFlight) {
|
||||||
return { ok: false, detail: 'build already in progress' };
|
return { ok: false, detail: 'build already in progress' };
|
||||||
@@ -1498,6 +1527,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
buildCompletedAt: completedAt,
|
buildCompletedAt: completedAt,
|
||||||
buildError: null,
|
buildError: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
meta: writeProfileReleaseSource(profile.meta, releaseSource),
|
||||||
});
|
});
|
||||||
await this.appendOperationLog(
|
await this.appendOperationLog(
|
||||||
operationId,
|
operationId,
|
||||||
@@ -1617,7 +1647,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
action: GatewayAdminActionRecord,
|
action: GatewayAdminActionRecord,
|
||||||
commitShaOverride?: string,
|
commitShaOverride?: string,
|
||||||
assertLease?: () => Promise<void>,
|
assertLease?: () => Promise<void>,
|
||||||
operationId?: string
|
operationId?: string,
|
||||||
|
releaseSource?: ProfileReleaseSource
|
||||||
): Promise<GatewayAdminActionResult> {
|
): Promise<GatewayAdminActionResult> {
|
||||||
const appendLog = async (
|
const appendLog = async (
|
||||||
phase: string,
|
phase: string,
|
||||||
@@ -1819,6 +1850,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
|
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
|
||||||
openAt: openAt ? openAt.toISOString() : null,
|
openAt: openAt ? openAt.toISOString() : null,
|
||||||
scheduledStartAt: action.scheduledAt ?? null,
|
scheduledStartAt: action.scheduledAt ?? null,
|
||||||
|
...(releaseSource ? { meta: writeProfileReleaseSource(profile.meta, releaseSource) } : {}),
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
await this.repository.updateWorkspaceUsage(profile.profileName, workspace.root, completedAt);
|
await this.repository.updateWorkspaceUsage(profile.profileName, workspace.root, completedAt);
|
||||||
@@ -1951,6 +1983,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
if (operationId) {
|
if (operationId) {
|
||||||
await this.appendOperationLog(operationId, 'workspace', `worktree 준비 완료: ${workspace.root}`);
|
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 = [
|
const commands = [
|
||||||
...buildWorkspaceCommands(
|
...buildWorkspaceCommands(
|
||||||
workspace.root,
|
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;
|
buildCompletedAt?: string | null;
|
||||||
buildError?: string | null;
|
buildError?: string | null;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
|
meta?: GatewayPrisma.JsonObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayProfileRepository {
|
export interface GatewayProfileRepository {
|
||||||
@@ -748,6 +749,7 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
buildCompletedAt: toDate(patch.buildCompletedAt),
|
buildCompletedAt: toDate(patch.buildCompletedAt),
|
||||||
buildError: patch.buildError,
|
buildError: patch.buildError,
|
||||||
lastError: patch.lastError,
|
lastError: patch.lastError,
|
||||||
|
meta: patch.meta,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -613,7 +613,7 @@ describe('admin operation API', () => {
|
|||||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
).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(
|
const harness = await buildCaller(
|
||||||
async (input) => ({
|
async (input) => ({
|
||||||
id: '33333333-3333-4333-8333-333333333333',
|
id: '33333333-3333-4333-8333-333333333333',
|
||||||
@@ -642,11 +642,17 @@ describe('admin operation API', () => {
|
|||||||
type: 'DEPLOY',
|
type: 'DEPLOY',
|
||||||
sourceMode: 'COMMIT',
|
sourceMode: 'COMMIT',
|
||||||
reason: 'preserve live season',
|
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(
|
const harness = await buildCaller(
|
||||||
async (input) => ({
|
async (input) => ({
|
||||||
id: '55555555-5555-4555-8555-555555555555',
|
id: '55555555-5555-4555-8555-555555555555',
|
||||||
@@ -664,6 +670,7 @@ describe('admin operation API', () => {
|
|||||||
adminRoles: ['admin.scenarios.reset:che:2'],
|
adminRoles: ['admin.scenarios.reset:che:2'],
|
||||||
firstUserIsAdmin: false,
|
firstUserIsAdmin: false,
|
||||||
profileScenario: '1010',
|
profileScenario: '1010',
|
||||||
|
profileMeta: { releaseSource: { mode: 'BRANCH', ref: 'main' } },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -687,8 +694,8 @@ describe('admin operation API', () => {
|
|||||||
|
|
||||||
expect(harness.createdInputs[0]).toMatchObject({
|
expect(harness.createdInputs[0]).toMatchObject({
|
||||||
type: 'RESET',
|
type: 'RESET',
|
||||||
sourceMode: 'COMMIT',
|
sourceMode: 'BRANCH',
|
||||||
sourceRef: expect.stringMatching(/^[0-9a-f]{40}$/u),
|
sourceRef: 'main',
|
||||||
reason: 'new season only',
|
reason: 'new season only',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ describe('profile DEPLOY operation', () => {
|
|||||||
buildStatus: 'SUCCEEDED',
|
buildStatus: 'SUCCEEDED',
|
||||||
buildCommitSha: SHA,
|
buildCommitSha: SHA,
|
||||||
buildWorkspace: workspace,
|
buildWorkspace: workspace,
|
||||||
|
meta: { releaseSource: { mode: 'COMMIT', ref: SHA } },
|
||||||
});
|
});
|
||||||
expect(completions).toEqual(['SUCCEEDED']);
|
expect(completions).toEqual(['SUCCEEDED']);
|
||||||
expect(logs).toEqual(
|
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 },
|
install: { scenarioId: 903 },
|
||||||
installOperationId: 'original-install-generation',
|
installOperationId: 'original-install-generation',
|
||||||
|
releaseSource: { mode: 'BRANCH', ref: 'main' },
|
||||||
},
|
},
|
||||||
'newer-failed-operation'
|
'newer-failed-operation'
|
||||||
)
|
)
|
||||||
).toEqual({
|
).toEqual({
|
||||||
install: { scenarioId: 903 },
|
install: { scenarioId: 903 },
|
||||||
installOperationId: 'original-install-generation',
|
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.getByTestId('server-operations-page')).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/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');
|
||||||
await expect(page.getByTestId('request-reset')).toBeEnabled();
|
await expect(page.getByTestId('request-reset')).toBeEnabled();
|
||||||
await expect(page.getByTestId('scenario-select').locator('option:checked')).toContainText('현재 시나리오');
|
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);
|
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 = {
|
const state: FixtureState = {
|
||||||
operations: [],
|
operations: [],
|
||||||
gatewayOperations: [],
|
gatewayOperations: [],
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ const pageDescription = computed(() => {
|
|||||||
return '진행 중 게임을 닫고 정식 기수에서 제외하며 장수 기록과 유산 포인트 보전 범위를 선택합니다.';
|
return '진행 중 게임을 닫고 정식 기수에서 제외하며 장수 기록과 유산 포인트 보전 범위를 선택합니다.';
|
||||||
}
|
}
|
||||||
if (props.mode === 'scenario') {
|
if (props.mode === 'scenario') {
|
||||||
return '현재 배포 버전으로 시나리오만 초기화하거나, 배포 권한이 있을 때 새 버전과 함께 초기화합니다.';
|
return '서버에 지정된 브랜치의 최신 버전 또는 고정 커밋으로 시나리오를 초기화합니다.';
|
||||||
}
|
}
|
||||||
return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.';
|
return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.';
|
||||||
});
|
});
|
||||||
@@ -227,7 +227,7 @@ const activeOperation = computed(
|
|||||||
|
|
||||||
const sourceHelp = computed(() =>
|
const sourceHelp = computed(() =>
|
||||||
form.sourceMode === 'CURRENT'
|
form.sourceMode === 'CURRENT'
|
||||||
? '현재 서버에 배포된 커밋의 시나리오 리소스를 사용합니다.'
|
? '서버가 브랜치를 추적하면 작업 시작 시 최신 커밋을 사용하고, 커밋 고정 상태면 그 버전을 유지합니다.'
|
||||||
: form.sourceMode === 'BRANCH'
|
: form.sourceMode === 'BRANCH'
|
||||||
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
|
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
|
||||||
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
|
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
|
||||||
@@ -597,7 +597,7 @@ const requestReset = async () => {
|
|||||||
}
|
}
|
||||||
const scenarioId = form.scenarioId;
|
const scenarioId = form.scenarioId;
|
||||||
const sourceLabel =
|
const sourceLabel =
|
||||||
form.sourceMode === 'CURRENT' ? '현재 배포 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
|
form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}`
|
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}`
|
||||||
@@ -925,7 +925,7 @@ onBeforeUnmount(() => {
|
|||||||
value="CURRENT"
|
value="CURRENT"
|
||||||
data-testid="source-current"
|
data-testid="source-current"
|
||||||
/>
|
/>
|
||||||
현재 배포 버전
|
서버 지정 버전
|
||||||
</label>
|
</label>
|
||||||
<label
|
<label
|
||||||
v-if="mode === 'version' || hasCapability('admin.profiles.deploy')"
|
v-if="mode === 'version' || hasCapability('admin.profiles.deploy')"
|
||||||
|
|||||||
+11
-11
@@ -15,7 +15,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
|||||||
| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 |
|
| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 |
|
||||||
| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
|
| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
|
||||||
| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 |
|
| 버전 업데이트 | `/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/admin/servers/:profileName/cancel` | 잘못 연 게임을 닫고 기록·유산 포인트를 취소 정책에 따라 원자적으로 정산 |
|
||||||
| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback |
|
| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback |
|
||||||
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
|
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
|
||||||
@@ -63,7 +63,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
|||||||
- 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git
|
- 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git
|
||||||
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
|
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
|
||||||
권한과 버전 배포 권한이 모두 필요합니다.
|
권한과 버전 배포 권한이 모두 필요합니다.
|
||||||
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
|
- 서버 지정 버전의 시나리오 catalog는 capability·operation polling batch와
|
||||||
분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
|
분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
|
||||||
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
|
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
|
||||||
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
|
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
|
||||||
@@ -95,15 +95,15 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
|||||||
|
|
||||||
## Profile 권한 분류
|
## Profile 권한 분류
|
||||||
|
|
||||||
| capability | 허용 작업 |
|
| capability | 허용 작업 |
|
||||||
| -------------------------------- | --------------------------------------------------------- |
|
| -------------------------------- | ---------------------------------------------------------- |
|
||||||
| `admin.profiles.runtime:<name>` | 시작·정지·일시정지·재개, 시간 조정과 현재 기수 게임 옵션 |
|
| `admin.profiles.runtime:<name>` | 시작·정지·일시정지·재개, 시간 조정과 현재 기수 게임 옵션 |
|
||||||
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
|
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
|
||||||
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
|
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
|
||||||
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
|
| `admin.scenarios.reset:<name>` | 서버 지정 branch 최신 또는 고정 commit으로 시나리오 초기화 |
|
||||||
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
|
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
|
||||||
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
|
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
|
||||||
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
|
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
|
||||||
|
|
||||||
기존 상태 화면의 `즉시 리셋`·`리셋 예약` 버튼은 실제 DB 초기화 operation과
|
기존 상태 화면의 `즉시 리셋`·`리셋 예약` 버튼은 실제 DB 초기화 operation과
|
||||||
다른 metadata action이어서 제거했습니다. 초기화와 예약은 시나리오 초기화 탭의
|
다른 metadata action이어서 제거했습니다. 초기화와 예약은 시나리오 초기화 탭의
|
||||||
|
|||||||
@@ -100,12 +100,20 @@ Profile process 전환 중에는 frontend/API port가 잠시 닫힐 수 있습
|
|||||||
### 시나리오 초기화
|
### 시나리오 초기화
|
||||||
|
|
||||||
시나리오 초기화는 새 시즌이나 새 scenario로 현 시즌 데이터를 교체할 때
|
시나리오 초기화는 새 시즌이나 새 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`과
|
또는 commit을 함께 배포하는 모드는 `admin.scenarios.reset`과
|
||||||
`admin.profiles.deploy`를 모두 요구합니다. Source와 scenario를 확인한 뒤 turn 간격, 가오픈·정식 오픈,
|
`admin.profiles.deploy`를 모두 요구합니다. Source와 scenario를 확인한 뒤 turn 간격, 가오픈·정식 오픈,
|
||||||
NPC와 자동 진행 설정을 확인하고 요청해 주세요.
|
NPC와 자동 진행 설정을 확인하고 요청해 주세요.
|
||||||
|
|
||||||
|
같은 commit이 이미 active profile workspace에 설치되어 있으면 build command는 생략하고
|
||||||
|
migration과 scenario seed부터 진행합니다. 상위 권한자가 버전 업데이트에서 commit을
|
||||||
|
선택한 경우에만 이후 일반 초기화가 그 commit에 고정됩니다. 다시 branch를 선택해
|
||||||
|
성공적으로 배포하면 branch 추적으로 돌아갑니다.
|
||||||
|
|
||||||
턴 간격과 고급 옵션은 서버 상태 화면에서 저장한
|
턴 간격과 고급 옵션은 서버 상태 화면에서 저장한
|
||||||
`GatewayProfile.meta.resetDefaults`를 최초값으로 사용합니다. 서버별 기본값을
|
`GatewayProfile.meta.resetDefaults`를 최초값으로 사용합니다. 서버별 기본값을
|
||||||
바꾸려면 `admin.profiles.settings:<name>` 권한으로 메타를 저장하고 시나리오
|
바꾸려면 `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,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260818001000_add_game_cancellation_operation",
|
"gatewaySchemaHead": "20260819000000_backfill_profile_release_source",
|
||||||
"gameSchemaHead": "20260818010000_add_legacy_battle_result_logs",
|
"gameSchemaHead": "20260818010000_add_legacy_battle_result_logs",
|
||||||
"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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user