fix: 일반 리셋이 서버 지정 브랜치를 추적

상위 배포 권한자가 선택한 branch 또는 commit 정책을 profile metadata에 보존하고 일반 시나리오 초기화가 이를 따르도록 변경한다. 같은 active commit의 설치 완료 workspace는 빌드를 생략하고 기존 migration, seed, readiness 흐름을 유지한다.
This commit is contained in:
2026-08-19 13:42:52 +00:00
parent a9029c4675
commit 4977fb615d
14 changed files with 259 additions and 35 deletions
+14 -6
View File
@@ -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,
},
});
});
+12 -5
View File
@@ -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' },
});
});
});