Merge branch 'main' into feature/user-icon-library-20260801
This commit is contained in:
@@ -39,6 +39,7 @@ const ROLE_SUPERUSER = 'superuser';
|
||||
const ROLE_ADMIN_USERS = 'admin.users.manage';
|
||||
const ROLE_ADMIN_USERS_CREATE = 'admin.users.create';
|
||||
const ROLE_ADMIN_PROFILES = 'admin.profiles.manage';
|
||||
const ROLE_ADMIN_RELEASES = 'admin.releases.manage';
|
||||
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
|
||||
const ROLE_RESET_SCHEDULE = 'admin.reset.schedule';
|
||||
const ROLE_RESUME_WHEN_STOPPED = 'admin.resume.when-stopped';
|
||||
@@ -242,6 +243,12 @@ const profileAdminProcedure = adminProcedure.use(({ ctx, next }) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
const releaseAdminProcedure = adminProcedure.use(({ ctx, next }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
assertPermission(adminAuth, ROLE_ADMIN_RELEASES);
|
||||
return next();
|
||||
});
|
||||
|
||||
const zUserLookupInput = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
@@ -766,6 +773,60 @@ export const adminRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
requestDeploy: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
profileName: z.string().min(1),
|
||||
sourceMode: zSourceMode,
|
||||
sourceRef: z.string().min(1).max(128),
|
||||
reason: z.string().max(200).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
|
||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||
if (!profile) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||
}
|
||||
let sourceRef = input.sourceRef.trim();
|
||||
try {
|
||||
const resolved =
|
||||
input.sourceMode === 'BRANCH'
|
||||
? await resolveGitBranchCommitSha(sourceRef)
|
||||
: await resolveGitCommitSha(sourceRef);
|
||||
if (input.sourceMode === 'COMMIT') {
|
||||
sourceRef = resolved;
|
||||
}
|
||||
const scenarios = await listScenarioPreviews({ gitRef: resolved });
|
||||
if (!scenarios.some((scenario) => String(scenario.id) === profile.scenario)) {
|
||||
throw new Error('Current scenario is not available at source.');
|
||||
}
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Source is invalid or does not contain the current scenario.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await ctx.profiles.createOperation({
|
||||
profileName: input.profileName,
|
||||
type: 'DEPLOY',
|
||||
sourceMode: input.sourceMode,
|
||||
sourceRef,
|
||||
reason: input.reason,
|
||||
requestedBy: adminAuth.user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'This profile already has a queued or running operation.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
requestRuntime: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -845,6 +906,103 @@ export const adminRouter = router({
|
||||
}
|
||||
}),
|
||||
}),
|
||||
releases: router({
|
||||
gatewayState: releaseAdminProcedure.query(({ ctx }) => ctx.releases.getState()),
|
||||
list: releaseAdminProcedure
|
||||
.input(z.object({ limit: z.number().int().min(1).max(200).optional() }).optional())
|
||||
.query(({ ctx, input }) => ctx.releases.listOperations(input?.limit)),
|
||||
requestGatewayDeploy: releaseAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sourceMode: zSourceMode,
|
||||
sourceRef: z.string().min(1).max(128),
|
||||
reason: z.string().max(200).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
let sourceRef = input.sourceRef.trim();
|
||||
try {
|
||||
const resolved =
|
||||
input.sourceMode === 'BRANCH'
|
||||
? await resolveGitBranchCommitSha(sourceRef)
|
||||
: await resolveGitCommitSha(sourceRef);
|
||||
if (input.sourceMode === 'COMMIT') {
|
||||
sourceRef = resolved;
|
||||
}
|
||||
} catch {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Gateway release source is invalid.' });
|
||||
}
|
||||
try {
|
||||
return await ctx.releases.createOperation({
|
||||
type: 'DEPLOY',
|
||||
sourceMode: input.sourceMode,
|
||||
sourceRef,
|
||||
reason: input.reason,
|
||||
requestedBy: adminAuth.user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({ code: 'CONFLICT', message: 'A gateway release is already active.' });
|
||||
}
|
||||
}),
|
||||
requestGatewayRollback: releaseAdminProcedure
|
||||
.input(z.object({ reason: z.string().max(200).optional() }).optional())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const state = await ctx.releases.getState();
|
||||
if (!state.previousCommitSha || !state.previousWorkspace) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No previous gateway release is available.' });
|
||||
}
|
||||
try {
|
||||
return await ctx.releases.createOperation({
|
||||
type: 'ROLLBACK',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: state.previousCommitSha,
|
||||
payload: {
|
||||
expectedWorkspace: state.previousWorkspace,
|
||||
replacedCommitSha: state.activeCommitSha ?? null,
|
||||
},
|
||||
reason: input?.reason,
|
||||
requestedBy: adminAuth.user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({ code: 'CONFLICT', message: 'A gateway release is already active.' });
|
||||
}
|
||||
}),
|
||||
cancel: releaseAdminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||
if (!(await ctx.releases.cancelOperation(input.id))) {
|
||||
throw new TRPCError({ code: 'CONFLICT', message: 'Only queued releases can be cancelled.' });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
retry: releaseAdminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
try {
|
||||
const operation = await ctx.releases.retryOperation(input.id, adminAuth.user.id);
|
||||
if (!operation) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Only failed or cancelled releases can be retried.',
|
||||
});
|
||||
}
|
||||
return operation;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({ code: 'CONFLICT', message: 'A gateway release is already active.' });
|
||||
}
|
||||
}),
|
||||
}),
|
||||
profiles: router({
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
const profiles = await ctx.profiles.listProfiles();
|
||||
@@ -883,6 +1041,7 @@ export const adminRouter = router({
|
||||
activeOperation: activeOperationByProfile.get(profile.profileName) ?? null,
|
||||
runtime: runtimeMap.get(profile.profileName) ?? {
|
||||
profileName: profile.profileName,
|
||||
frontendRunning: false,
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
auctionRunning: false,
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { UserRepository } from './auth/userRepository.js';
|
||||
import type { KakaoOAuthClient } from './auth/kakaoClient.js';
|
||||
import type { OAuthSessionStore } from './auth/oauthSessionStore.js';
|
||||
import type { GatewayProfileRepository } from './orchestrator/profileRepository.js';
|
||||
import {
|
||||
createGatewayReleaseRepository,
|
||||
type GatewayReleaseRepository,
|
||||
} from './orchestrator/gatewayReleaseRepository.js';
|
||||
import type { GatewayOrchestratorHandle } from './orchestrator/gatewayOrchestrator.js';
|
||||
import type { GatewayProfileStatusService } from './lobby/profileStatusService.js';
|
||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
@@ -26,6 +30,7 @@ export interface GatewayApiContext {
|
||||
localAccountGraceDays: number;
|
||||
passwordEnvelope: PasswordEnvelopeService;
|
||||
profiles: GatewayProfileRepository;
|
||||
releases: GatewayReleaseRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
requestHeaders: Record<string, string | string[] | undefined>;
|
||||
@@ -49,6 +54,7 @@ export const createGatewayApiContext = (options: {
|
||||
localAccountGraceDays: number;
|
||||
passwordEnvelope: PasswordEnvelopeService;
|
||||
profiles: GatewayProfileRepository;
|
||||
releases?: GatewayReleaseRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
requestHeaders?: Record<string, string | string[] | undefined>;
|
||||
@@ -69,6 +75,7 @@ export const createGatewayApiContext = (options: {
|
||||
localAccountGraceDays: options.localAccountGraceDays,
|
||||
passwordEnvelope: options.passwordEnvelope,
|
||||
profiles: options.profiles,
|
||||
releases: options.releases ?? createGatewayReleaseRepository(options.prisma),
|
||||
orchestrator: options.orchestrator,
|
||||
profileStatus: options.profileStatus,
|
||||
requestHeaders: options.requestHeaders ?? {},
|
||||
|
||||
@@ -14,7 +14,13 @@ export { GatewayPrisma };
|
||||
export type JsonObject = GatewayPrisma.JsonObject;
|
||||
export type JsonArray = GatewayPrisma.JsonArray;
|
||||
export * from './orchestrator/profileRepository.js';
|
||||
export * from './orchestrator/gatewayReleaseRepository.js';
|
||||
export * from './orchestrator/gatewayOrchestrator.js';
|
||||
export * from './orchestrator/workspaceManager.js';
|
||||
export * from './orchestrator/buildRunner.js';
|
||||
export * from './orchestrator/processManager.js';
|
||||
export * from './orchestrator/pm2ProcessManager.js';
|
||||
export * from './orchestrator/releaseManifest.js';
|
||||
export * from './auth/userRepository.js';
|
||||
export * from './auth/passwordHasher.js';
|
||||
export * from './auth/inMemoryUserRepository.js';
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
} from './profileRepository.js';
|
||||
import type { GitWorkspaceManager } from './workspaceManager.js';
|
||||
import type { AdminSeedUser } from './seedProfileDatabase.js';
|
||||
import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js';
|
||||
|
||||
export interface GatewayProcessConfig {
|
||||
workspaceRoot: string;
|
||||
@@ -37,10 +38,13 @@ export interface GatewayOrchestratorOptions {
|
||||
scheduleIntervalMs: number;
|
||||
buildIntervalMs: number;
|
||||
adminActionIntervalMs: number;
|
||||
profileReadinessTimeoutMs?: number;
|
||||
now?: () => Date;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface ProfileRuntimeState {
|
||||
frontendRunning: boolean;
|
||||
apiRunning: boolean;
|
||||
daemonRunning: boolean;
|
||||
auctionRunning: boolean;
|
||||
@@ -73,6 +77,7 @@ export const planProfileReconcile = (
|
||||
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
||||
return {
|
||||
shouldStart: !(
|
||||
runtime.frontendRunning &&
|
||||
runtime.apiRunning &&
|
||||
runtime.daemonRunning &&
|
||||
runtime.auctionRunning &&
|
||||
@@ -85,6 +90,7 @@ export const planProfileReconcile = (
|
||||
return {
|
||||
shouldStart: false,
|
||||
shouldStop:
|
||||
runtime.frontendRunning ||
|
||||
runtime.apiRunning ||
|
||||
runtime.daemonRunning ||
|
||||
runtime.auctionRunning ||
|
||||
@@ -301,18 +307,20 @@ const parseInstallOptions = (
|
||||
|
||||
const buildProcessName = (
|
||||
profileName: string,
|
||||
role: 'api' | 'daemon' | 'auction' | 'battle-sim' | 'tournament'
|
||||
role: 'frontend' | 'api' | 'daemon' | 'auction' | 'battle-sim' | 'tournament'
|
||||
): string =>
|
||||
`sammo:${profileName}:${
|
||||
role === 'api'
|
||||
? 'game-api'
|
||||
: role === 'daemon'
|
||||
? 'turn-daemon'
|
||||
: role === 'auction'
|
||||
? 'auction-worker'
|
||||
: role === 'battle-sim'
|
||||
? 'battle-sim-worker'
|
||||
: 'tournament-worker'
|
||||
role === 'frontend'
|
||||
? 'game-frontend'
|
||||
: role === 'api'
|
||||
? 'game-api'
|
||||
: role === 'daemon'
|
||||
? 'turn-daemon'
|
||||
: role === 'auction'
|
||||
? 'auction-worker'
|
||||
: role === 'battle-sim'
|
||||
? 'battle-sim-worker'
|
||||
: 'tournament-worker'
|
||||
}`;
|
||||
|
||||
const isMissingProcessError = (error: unknown): boolean =>
|
||||
@@ -335,6 +343,7 @@ export const buildProcessDefinitions = (
|
||||
profile: GatewayProfileRecord,
|
||||
config: GatewayProcessConfig
|
||||
): {
|
||||
frontend: { name: string; script: string; cwd: string; args: string[]; env: Record<string, string> };
|
||||
api: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
auction: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
@@ -342,12 +351,16 @@ export const buildProcessDefinitions = (
|
||||
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
} => {
|
||||
const baseEnv = { ...(config.baseEnv ?? {}) };
|
||||
const frontendName = buildProcessName(profile.profileName, 'frontend');
|
||||
const apiName = buildProcessName(profile.profileName, 'api');
|
||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||
const auctionName = buildProcessName(profile.profileName, 'auction');
|
||||
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
|
||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
||||
const frontendCwd = path.join(runtimeWorkspace, 'app', 'game-frontend');
|
||||
const frontendOutDir = buildProfileFrontendOutDir(runtimeWorkspace, profile.profileName);
|
||||
const frontendScript = path.join(runtimeWorkspace, 'node_modules', 'vite', 'bin', 'vite.js');
|
||||
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
||||
const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine');
|
||||
const apiScript = path.join(apiCwd, 'dist', 'index.js');
|
||||
@@ -373,6 +386,13 @@ export const buildProcessDefinitions = (
|
||||
TURN_PROFILE_NAME: profile.profileName,
|
||||
};
|
||||
return {
|
||||
frontend: {
|
||||
name: frontendName,
|
||||
script: frontendScript,
|
||||
cwd: frontendCwd,
|
||||
args: ['preview', '--host', '0.0.0.0', '--port', String(profile.apiPort - 1), '--outDir', frontendOutDir],
|
||||
env: baseEnv,
|
||||
},
|
||||
api: {
|
||||
name: apiName,
|
||||
script: apiScript,
|
||||
@@ -415,6 +435,39 @@ export const buildProcessDefinitions = (
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeArtifactName = (value: string): string => value.replace(/[^0-9A-Za-z._-]+/g, '_');
|
||||
|
||||
export const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string): string =>
|
||||
path.join(workspaceRoot, '.release-dist', sanitizeArtifactName(profileName), 'game-frontend');
|
||||
|
||||
export const buildProfileFrontendCommands = (
|
||||
workspaceRoot: string,
|
||||
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
|
||||
env?: Record<string, string>
|
||||
): BuildCommand[] => {
|
||||
const buildEnv = {
|
||||
...(env ?? {}),
|
||||
VITE_APP_BASE_PATH: `/${profile.profile}`,
|
||||
VITE_GAME_API_URL: `/${profile.profile}/api/trpc`,
|
||||
VITE_GAME_SSE_URL: `/${profile.profile}/api/events`,
|
||||
};
|
||||
const outDir = buildProfileFrontendOutDir(workspaceRoot, profile.profileName);
|
||||
return [
|
||||
{
|
||||
command: 'pnpm',
|
||||
args: ['--filter', '@sammo-ts/game-frontend', 'exec', 'vue-tsc', '--noEmit'],
|
||||
cwd: workspaceRoot,
|
||||
env: buildEnv,
|
||||
},
|
||||
{
|
||||
command: 'pnpm',
|
||||
args: ['--filter', '@sammo-ts/game-frontend', 'exec', 'vite', 'build', '--outDir', outDir],
|
||||
cwd: workspaceRoot,
|
||||
env: buildEnv,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const buildWorkspaceCommands = (
|
||||
workspaceRoot: string,
|
||||
needsInstall: boolean,
|
||||
@@ -424,7 +477,7 @@ export const buildWorkspaceCommands = (
|
||||
if (needsInstall) {
|
||||
commands.push({
|
||||
command: 'pnpm',
|
||||
args: ['install'],
|
||||
args: ['install', '--frozen-lockfile'],
|
||||
cwd: workspaceRoot,
|
||||
env,
|
||||
});
|
||||
@@ -462,6 +515,7 @@ export const buildProfileMigrationCommand = (
|
||||
|
||||
const mapRuntimeStates = (profileNames: string[], processNames: Map<string, boolean>): ProfileRuntimeSnapshot[] =>
|
||||
profileNames.map((profileName) => {
|
||||
const frontendName = buildProcessName(profileName, 'frontend');
|
||||
const apiName = buildProcessName(profileName, 'api');
|
||||
const daemonName = buildProcessName(profileName, 'daemon');
|
||||
const auctionName = buildProcessName(profileName, 'auction');
|
||||
@@ -469,6 +523,7 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
|
||||
const tournamentName = buildProcessName(profileName, 'tournament');
|
||||
return {
|
||||
profileName,
|
||||
frontendRunning: processNames.get(frontendName) ?? false,
|
||||
apiRunning: processNames.get(apiName) ?? false,
|
||||
daemonRunning: processNames.get(daemonName) ?? false,
|
||||
auctionRunning: processNames.get(auctionName) ?? false,
|
||||
@@ -487,7 +542,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
private readonly scheduleIntervalMs: number;
|
||||
private readonly buildIntervalMs: number;
|
||||
private readonly adminActionIntervalMs: number;
|
||||
private readonly profileReadinessTimeoutMs: number;
|
||||
private readonly now: () => Date;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private reconcileTimer?: NodeJS.Timeout;
|
||||
private scheduleTimer?: NodeJS.Timeout;
|
||||
private buildTimer?: NodeJS.Timeout;
|
||||
@@ -513,7 +570,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.scheduleIntervalMs = options.scheduleIntervalMs;
|
||||
this.buildIntervalMs = options.buildIntervalMs;
|
||||
this.adminActionIntervalMs = options.adminActionIntervalMs;
|
||||
this.profileReadinessTimeoutMs = options.profileReadinessTimeoutMs ?? 30_000;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -668,7 +727,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
startedAt,
|
||||
error: null,
|
||||
});
|
||||
const { result, workspace } = await this.runBuildCommands(queued.buildCommitSha);
|
||||
const { result, workspace } = await this.runBuildCommands(queued.buildCommitSha, queued);
|
||||
const completedAt = this.now().toISOString();
|
||||
if (result.ok) {
|
||||
await this.repository.updateWorkspaceUsage(
|
||||
@@ -863,6 +922,19 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}
|
||||
}
|
||||
await assertLease();
|
||||
if (operation.type === 'DEPLOY') {
|
||||
const result = await this.handleProfileDeploy(profile, commitSha, assertLease, operation.id);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.detail);
|
||||
}
|
||||
await this.repository.completeOperation(
|
||||
operation.id,
|
||||
'SUCCEEDED',
|
||||
{ resolvedCommitSha: commitSha, error: null },
|
||||
this.operationLeaseOwner
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = normalizeMeta(operation.payload);
|
||||
const install = isRecord(payload.install) ? payload.install : {};
|
||||
const installOperationId =
|
||||
@@ -923,6 +995,149 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProfileDeploy(
|
||||
profile: GatewayProfileRecord,
|
||||
commitSha: string,
|
||||
assertLease: () => Promise<void>,
|
||||
operationId: string
|
||||
): Promise<{ ok: true } | { ok: false; detail: string }> {
|
||||
if (this.buildInFlight) {
|
||||
return { ok: false, detail: 'build already in progress' };
|
||||
}
|
||||
this.buildInFlight = true;
|
||||
const shouldRun = ['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED'].includes(profile.status);
|
||||
const updateClaimedProfile = async (patch: GatewayClaimedProfileUpdate): Promise<GatewayProfileRecord> => {
|
||||
if (!this.repository.updateProfileForOperation) {
|
||||
throw new Error('Profile deploy requires lease-fenced profile updates.');
|
||||
}
|
||||
const updated = await this.repository.updateProfileForOperation(
|
||||
operationId,
|
||||
this.operationLeaseOwner,
|
||||
profile.profileName,
|
||||
patch
|
||||
);
|
||||
if (!updated) {
|
||||
throw new OperationLeaseLostError(`Operation lease lost while deploying profile: ${operationId}`);
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
let oldRuntimeStopped = false;
|
||||
try {
|
||||
const startedAt = this.now().toISOString();
|
||||
await updateClaimedProfile({
|
||||
buildStatus: 'RUNNING',
|
||||
buildRequestedAt: startedAt,
|
||||
buildStartedAt: startedAt,
|
||||
buildError: null,
|
||||
});
|
||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||
const manifest = await readReleaseManifest(workspace.root);
|
||||
assertReleaseComponents(manifest, ['game-api', 'game-engine', 'game-frontend']);
|
||||
const commands = [
|
||||
...buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv),
|
||||
...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv),
|
||||
];
|
||||
const result = await this.buildRunner.run(commands);
|
||||
await assertLease();
|
||||
if (!result.ok) {
|
||||
const detail = result.output.slice(-4000) || 'selected workspace build failed';
|
||||
await updateClaimedProfile({
|
||||
buildStatus: 'FAILED',
|
||||
buildCompletedAt: this.now().toISOString(),
|
||||
buildError: detail,
|
||||
});
|
||||
return { ok: false, detail };
|
||||
}
|
||||
|
||||
await this.stopProfile(profile, assertLease);
|
||||
oldRuntimeStopped = true;
|
||||
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
|
||||
const migration = await this.runProfileMigration(workspace.root, profileDatabaseUrl);
|
||||
await assertLease();
|
||||
if (!migration.ok) {
|
||||
const detail = migration.output.slice(-4000) || 'profile database migration failed';
|
||||
if (shouldRun) {
|
||||
await this.startProfile(profile, assertLease);
|
||||
oldRuntimeStopped = false;
|
||||
}
|
||||
await updateClaimedProfile({
|
||||
buildStatus: 'FAILED',
|
||||
buildCompletedAt: this.now().toISOString(),
|
||||
buildError: detail,
|
||||
});
|
||||
return { ok: false, detail };
|
||||
}
|
||||
|
||||
const completedAt = this.now().toISOString();
|
||||
const candidate: GatewayProfileRecord = {
|
||||
...profile,
|
||||
buildStatus: 'SUCCEEDED',
|
||||
buildCommitSha: commitSha,
|
||||
buildWorkspace: workspace.root,
|
||||
buildLastUsedAt: completedAt,
|
||||
buildCompletedAt: completedAt,
|
||||
buildError: undefined,
|
||||
};
|
||||
if (shouldRun) {
|
||||
const started = await this.startProfile(candidate, assertLease);
|
||||
const ready = started && (await this.waitForProfileReadiness(candidate, assertLease));
|
||||
if (!ready) {
|
||||
if (started) {
|
||||
await this.stopProfile(candidate, assertLease);
|
||||
}
|
||||
const rollbackStarted =
|
||||
(await this.startProfile(profile, assertLease)) &&
|
||||
(await this.waitForProfileReadiness(profile, assertLease));
|
||||
oldRuntimeStopped = !rollbackStarted;
|
||||
const detail = rollbackStarted
|
||||
? 'new profile release failed readiness; previous runtime restored'
|
||||
: 'new profile release failed and previous runtime could not be restored';
|
||||
await updateClaimedProfile({
|
||||
buildStatus: 'FAILED',
|
||||
buildCompletedAt: completedAt,
|
||||
buildError: detail,
|
||||
lastError: detail,
|
||||
status: rollbackStarted ? profile.status : 'STOPPED',
|
||||
});
|
||||
return { ok: false, detail };
|
||||
}
|
||||
}
|
||||
await assertLease();
|
||||
await updateClaimedProfile({
|
||||
buildStatus: 'SUCCEEDED',
|
||||
buildCommitSha: commitSha,
|
||||
buildWorkspace: workspace.root,
|
||||
buildLastUsedAt: completedAt,
|
||||
buildCompletedAt: completedAt,
|
||||
buildError: null,
|
||||
lastError: null,
|
||||
});
|
||||
oldRuntimeStopped = false;
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (error instanceof OperationLeaseLostError) {
|
||||
throw error;
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
if (oldRuntimeStopped && shouldRun) {
|
||||
try {
|
||||
await this.startProfile(profile, assertLease);
|
||||
} catch {
|
||||
// The original error remains authoritative; reconciliation records the stopped runtime.
|
||||
}
|
||||
}
|
||||
await updateClaimedProfile({
|
||||
buildStatus: 'FAILED',
|
||||
buildCompletedAt: this.now().toISOString(),
|
||||
buildError: detail,
|
||||
lastError: detail,
|
||||
});
|
||||
return { ok: false, detail };
|
||||
} finally {
|
||||
this.buildInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runAdminActionsNow(): Promise<void> {
|
||||
if (this.stopping || this.adminActionInFlight) {
|
||||
return;
|
||||
@@ -1095,7 +1310,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
commitSha,
|
||||
})
|
||||
);
|
||||
const { result, workspace } = await this.runBuildCommands(commitSha);
|
||||
const { result, workspace } = await this.runBuildCommands(commitSha, profile);
|
||||
await assertLease?.();
|
||||
if (!result.ok) {
|
||||
const completedAt = this.now().toISOString();
|
||||
@@ -1289,12 +1504,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
return { databaseUrl, scenarioId, tickSeconds, meta };
|
||||
}
|
||||
|
||||
private async runBuildCommands(commitSha: string): Promise<{
|
||||
private async runBuildCommands(
|
||||
commitSha: string,
|
||||
profile?: GatewayProfileRecord
|
||||
): Promise<{
|
||||
result: Awaited<ReturnType<BuildRunner['run']>>;
|
||||
workspace: Awaited<ReturnType<GitWorkspaceManager['prepare']>>;
|
||||
}> {
|
||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||
const commands = buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv);
|
||||
const commands = [
|
||||
...buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv),
|
||||
...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []),
|
||||
];
|
||||
return { result: await this.buildRunner.run(commands), workspace };
|
||||
}
|
||||
|
||||
@@ -1403,6 +1624,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
for (const [workspace, entry] of workspaceMap.entries()) {
|
||||
const profileProcessNames = new Set(
|
||||
entry.profileNames.flatMap((profileName) => [
|
||||
buildProcessName(profileName, 'frontend'),
|
||||
buildProcessName(profileName, 'api'),
|
||||
buildProcessName(profileName, 'daemon'),
|
||||
buildProcessName(profileName, 'auction'),
|
||||
@@ -1451,6 +1673,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
private async startProfile(profile: GatewayProfileRecord, assertLease?: () => Promise<void>): Promise<boolean> {
|
||||
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
||||
const orderedDefinitions = [
|
||||
definitions.frontend,
|
||||
definitions.api,
|
||||
definitions.daemon,
|
||||
definitions.auction,
|
||||
@@ -1493,7 +1716,41 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForProfileReadiness(
|
||||
profile: GatewayProfileRecord,
|
||||
assertLease?: () => Promise<void>
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + this.profileReadinessTimeoutMs;
|
||||
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
||||
const expectedNames = Object.values(definitions).map((definition) => definition.name);
|
||||
const apiUrl = `http://127.0.0.1:${profile.apiPort}/healthz`;
|
||||
const frontendUrl = `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`;
|
||||
while (Date.now() < deadline) {
|
||||
await assertLease?.();
|
||||
try {
|
||||
const [api, frontend, processes] = await Promise.all([
|
||||
this.fetchImpl(apiUrl),
|
||||
this.fetchImpl(frontendUrl),
|
||||
this.processManager.list(),
|
||||
]);
|
||||
const online = new Set(
|
||||
processes
|
||||
.filter((process) => process.status.toLowerCase() === 'online')
|
||||
.map((process) => process.name)
|
||||
);
|
||||
if (api.ok && frontend.ok && expectedNames.every((name) => online.has(name))) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Retry until the bounded deadline.
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async stopProfile(profile: GatewayProfileRecord, assertLease?: () => Promise<void>): Promise<void> {
|
||||
const frontendName = buildProcessName(profile.profileName, 'frontend');
|
||||
const apiName = buildProcessName(profile.profileName, 'api');
|
||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||
const auctionName = buildProcessName(profile.profileName, 'auction');
|
||||
@@ -1503,7 +1760,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
||||
await assertLease?.();
|
||||
const failures: string[] = [];
|
||||
for (const name of [apiName, daemonName, auctionName, battleSimName, tournamentName]) {
|
||||
for (const name of [frontendName, apiName, daemonName, auctionName, battleSimName, tournamentName]) {
|
||||
if (!existingNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import type { GatewayOperationStatus, GatewaySourceMode } from './profileRepository.js';
|
||||
|
||||
export const GATEWAY_RELEASE_OPERATION_TYPES = ['DEPLOY', 'ROLLBACK'] as const;
|
||||
export type GatewayReleaseOperationType = (typeof GATEWAY_RELEASE_OPERATION_TYPES)[number];
|
||||
|
||||
export interface GatewayReleaseStateRecord {
|
||||
id: string;
|
||||
activeCommitSha?: string;
|
||||
activeWorkspace?: string;
|
||||
previousCommitSha?: string;
|
||||
previousWorkspace?: string;
|
||||
lastSuccessfulAt?: string;
|
||||
lastError?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GatewayReleaseOperationRecord {
|
||||
id: string;
|
||||
type: GatewayReleaseOperationType;
|
||||
status: GatewayOperationStatus;
|
||||
sourceMode?: GatewaySourceMode;
|
||||
sourceRef?: string;
|
||||
resolvedCommitSha?: string;
|
||||
payload: GatewayPrisma.JsonObject;
|
||||
reason?: string;
|
||||
requestedBy: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
error?: string;
|
||||
leaseOwner?: string;
|
||||
leaseUntil?: string;
|
||||
heartbeatAt?: string;
|
||||
attempts: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GatewayReleaseOperationCreateInput {
|
||||
type: GatewayReleaseOperationType;
|
||||
sourceMode?: GatewaySourceMode;
|
||||
sourceRef?: string;
|
||||
payload?: GatewayPrisma.JsonObject;
|
||||
reason?: string;
|
||||
requestedBy: string;
|
||||
}
|
||||
|
||||
export interface GatewayReleaseRepository {
|
||||
getState(): Promise<GatewayReleaseStateRecord>;
|
||||
listOperations(limit?: number): Promise<GatewayReleaseOperationRecord[]>;
|
||||
getOperation(id: string): Promise<GatewayReleaseOperationRecord | null>;
|
||||
createOperation(input: GatewayReleaseOperationCreateInput): Promise<GatewayReleaseOperationRecord>;
|
||||
claimNextOperation(
|
||||
now: Date,
|
||||
lease: { ownerId: string; durationMs: number }
|
||||
): Promise<GatewayReleaseOperationRecord | null>;
|
||||
renewOperationLease(id: string, ownerId: string, now: Date, durationMs: number): Promise<boolean>;
|
||||
pinOperationResolvedCommit(id: string, ownerId: string, resolvedCommitSha: string): Promise<boolean>;
|
||||
completeOperation(
|
||||
id: string,
|
||||
status: Extract<GatewayOperationStatus, 'SUCCEEDED' | 'FAILED'>,
|
||||
fields: { resolvedCommitSha?: string | null; error?: string | null },
|
||||
leaseOwner: string
|
||||
): Promise<GatewayReleaseOperationRecord>;
|
||||
publishRelease(
|
||||
operationId: string,
|
||||
leaseOwner: string,
|
||||
release: { commitSha: string; workspace: string; previousCommitSha?: string; previousWorkspace?: string }
|
||||
): Promise<GatewayReleaseStateRecord>;
|
||||
recordStateError(detail: string): Promise<void>;
|
||||
cancelOperation(id: string): Promise<boolean>;
|
||||
retryOperation(id: string, requestedBy: string): Promise<GatewayReleaseOperationRecord | null>;
|
||||
}
|
||||
|
||||
const toIso = (value: Date | null): string | undefined => (value ? value.toISOString() : undefined);
|
||||
|
||||
const mapState = (row: {
|
||||
id: string;
|
||||
activeCommitSha: string | null;
|
||||
activeWorkspace: string | null;
|
||||
previousCommitSha: string | null;
|
||||
previousWorkspace: string | null;
|
||||
lastSuccessfulAt: Date | null;
|
||||
lastError: string | null;
|
||||
updatedAt: Date;
|
||||
}): GatewayReleaseStateRecord => ({
|
||||
id: row.id,
|
||||
activeCommitSha: row.activeCommitSha ?? undefined,
|
||||
activeWorkspace: row.activeWorkspace ?? undefined,
|
||||
previousCommitSha: row.previousCommitSha ?? undefined,
|
||||
previousWorkspace: row.previousWorkspace ?? undefined,
|
||||
lastSuccessfulAt: toIso(row.lastSuccessfulAt),
|
||||
lastError: row.lastError ?? undefined,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
const mapOperation = (row: {
|
||||
id: string;
|
||||
type: GatewayReleaseOperationType;
|
||||
status: GatewayOperationStatus;
|
||||
sourceMode: GatewaySourceMode | null;
|
||||
sourceRef: string | null;
|
||||
resolvedCommitSha: string | null;
|
||||
payload: GatewayPrisma.JsonValue;
|
||||
reason: string | null;
|
||||
requestedBy: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
error: string | null;
|
||||
leaseOwner: string | null;
|
||||
leaseUntil: Date | null;
|
||||
heartbeatAt: Date | null;
|
||||
attempts: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): GatewayReleaseOperationRecord => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
sourceMode: row.sourceMode ?? undefined,
|
||||
sourceRef: row.sourceRef ?? undefined,
|
||||
resolvedCommitSha: row.resolvedCommitSha ?? undefined,
|
||||
payload: (row.payload ?? {}) as GatewayPrisma.JsonObject,
|
||||
reason: row.reason ?? undefined,
|
||||
requestedBy: row.requestedBy,
|
||||
startedAt: toIso(row.startedAt),
|
||||
completedAt: toIso(row.completedAt),
|
||||
error: row.error ?? undefined,
|
||||
leaseOwner: row.leaseOwner ?? undefined,
|
||||
leaseUntil: toIso(row.leaseUntil),
|
||||
heartbeatAt: toIso(row.heartbeatAt),
|
||||
attempts: row.attempts,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): GatewayReleaseRepository => ({
|
||||
async getState() {
|
||||
const row = await prisma.gatewayReleaseState.upsert({
|
||||
where: { id: 'gateway' },
|
||||
create: { id: 'gateway' },
|
||||
update: {},
|
||||
});
|
||||
return mapState(row);
|
||||
},
|
||||
async listOperations(limit = 50) {
|
||||
const rows = await prisma.gatewayReleaseOperation.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: Math.min(Math.max(limit, 1), 200),
|
||||
});
|
||||
return rows.map(mapOperation);
|
||||
},
|
||||
async getOperation(id) {
|
||||
const row = await prisma.gatewayReleaseOperation.findUnique({ where: { id } });
|
||||
return row ? mapOperation(row) : null;
|
||||
},
|
||||
async createOperation(input) {
|
||||
const row = await prisma.gatewayReleaseOperation.create({
|
||||
data: {
|
||||
type: input.type,
|
||||
sourceMode: input.sourceMode,
|
||||
sourceRef: input.sourceRef,
|
||||
payload: input.payload ?? {},
|
||||
reason: input.reason,
|
||||
requestedBy: input.requestedBy,
|
||||
},
|
||||
});
|
||||
return mapOperation(row);
|
||||
},
|
||||
async claimNextOperation(now, lease) {
|
||||
const row = await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw<Array<{ lock_result: string }>>`
|
||||
SELECT pg_advisory_xact_lock(hashtextextended('gateway_release_operation_claim', 0))::text AS lock_result
|
||||
`;
|
||||
const staleBefore = new Date(now.getTime() - lease.durationMs);
|
||||
const running = await tx.gatewayReleaseOperation.findFirst({
|
||||
where: { status: 'RUNNING' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const runningIsStale = Boolean(
|
||||
running &&
|
||||
((running.leaseUntil && running.leaseUntil < now) ||
|
||||
(!running.leaseUntil && running.startedAt && running.startedAt <= staleBefore))
|
||||
);
|
||||
if (running && !runningIsStale) {
|
||||
return null;
|
||||
}
|
||||
const candidate =
|
||||
running ??
|
||||
(await tx.gatewayReleaseOperation.findFirst({
|
||||
where: { status: 'QUEUED' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}));
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
const claimed = await tx.gatewayReleaseOperation.updateMany({
|
||||
where:
|
||||
candidate.status === 'QUEUED'
|
||||
? { id: candidate.id, status: 'QUEUED' }
|
||||
: { id: candidate.id, status: 'RUNNING', leaseUntil: candidate.leaseUntil },
|
||||
data: {
|
||||
status: 'RUNNING',
|
||||
startedAt: candidate.startedAt ?? now,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
leaseOwner: lease.ownerId,
|
||||
leaseUntil: new Date(now.getTime() + lease.durationMs),
|
||||
heartbeatAt: now,
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
return claimed.count === 1 ? tx.gatewayReleaseOperation.findUnique({ where: { id: candidate.id } }) : null;
|
||||
});
|
||||
return row ? mapOperation(row) : null;
|
||||
},
|
||||
async renewOperationLease(id, ownerId, now, durationMs) {
|
||||
const updated = await prisma.gatewayReleaseOperation.updateMany({
|
||||
where: { id, status: 'RUNNING', leaseOwner: ownerId },
|
||||
data: {
|
||||
leaseUntil: new Date(now.getTime() + durationMs),
|
||||
heartbeatAt: now,
|
||||
},
|
||||
});
|
||||
return updated.count === 1;
|
||||
},
|
||||
async pinOperationResolvedCommit(id, ownerId, resolvedCommitSha) {
|
||||
const updated = await prisma.gatewayReleaseOperation.updateMany({
|
||||
where: {
|
||||
id,
|
||||
status: 'RUNNING',
|
||||
leaseOwner: ownerId,
|
||||
OR: [{ resolvedCommitSha: null }, { resolvedCommitSha }],
|
||||
},
|
||||
data: { resolvedCommitSha },
|
||||
});
|
||||
return updated.count === 1;
|
||||
},
|
||||
async completeOperation(id, status, fields, leaseOwner) {
|
||||
const updated = await prisma.gatewayReleaseOperation.updateMany({
|
||||
where: { id, status: 'RUNNING', leaseOwner },
|
||||
data: {
|
||||
status,
|
||||
completedAt: new Date(),
|
||||
resolvedCommitSha: fields.resolvedCommitSha,
|
||||
error: fields.error,
|
||||
leaseOwner: null,
|
||||
leaseUntil: null,
|
||||
heartbeatAt: null,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Gateway release lease lost before completion: ${id}`);
|
||||
}
|
||||
return mapOperation(await prisma.gatewayReleaseOperation.findUniqueOrThrow({ where: { id } }));
|
||||
},
|
||||
async publishRelease(operationId, leaseOwner, release) {
|
||||
const row = await prisma.$transaction(async (tx) => {
|
||||
const owned = await tx.gatewayReleaseOperation.findFirst({
|
||||
where: { id: operationId, status: 'RUNNING', leaseOwner },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) {
|
||||
throw new Error(`Gateway release lease lost before publish: ${operationId}`);
|
||||
}
|
||||
return tx.gatewayReleaseState.upsert({
|
||||
where: { id: 'gateway' },
|
||||
create: {
|
||||
id: 'gateway',
|
||||
activeCommitSha: release.commitSha,
|
||||
activeWorkspace: release.workspace,
|
||||
previousCommitSha: release.previousCommitSha,
|
||||
previousWorkspace: release.previousWorkspace,
|
||||
lastSuccessfulAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
update: {
|
||||
activeCommitSha: release.commitSha,
|
||||
activeWorkspace: release.workspace,
|
||||
previousCommitSha: release.previousCommitSha,
|
||||
previousWorkspace: release.previousWorkspace,
|
||||
lastSuccessfulAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
return mapState(row);
|
||||
},
|
||||
async recordStateError(detail) {
|
||||
await prisma.gatewayReleaseState.upsert({
|
||||
where: { id: 'gateway' },
|
||||
create: { id: 'gateway', lastError: detail },
|
||||
update: { lastError: detail },
|
||||
});
|
||||
},
|
||||
async cancelOperation(id) {
|
||||
const updated = await prisma.gatewayReleaseOperation.updateMany({
|
||||
where: { id, status: 'QUEUED' },
|
||||
data: { status: 'CANCELLED', completedAt: new Date() },
|
||||
});
|
||||
return updated.count === 1;
|
||||
},
|
||||
async retryOperation(id, requestedBy) {
|
||||
const row = await prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.gatewayReleaseOperation.findUnique({ where: { id } });
|
||||
if (!previous || (previous.status !== 'FAILED' && previous.status !== 'CANCELLED')) {
|
||||
return null;
|
||||
}
|
||||
return tx.gatewayReleaseOperation.create({
|
||||
data: {
|
||||
type: previous.type,
|
||||
sourceMode: previous.resolvedCommitSha ? 'COMMIT' : previous.sourceMode,
|
||||
sourceRef: previous.resolvedCommitSha ?? previous.sourceRef,
|
||||
payload: previous.payload as GatewayPrisma.JsonObject,
|
||||
reason: previous.reason,
|
||||
requestedBy,
|
||||
},
|
||||
});
|
||||
});
|
||||
return row ? mapOperation(row) : null;
|
||||
},
|
||||
});
|
||||
@@ -14,7 +14,7 @@ export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number];
|
||||
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
|
||||
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
|
||||
|
||||
export const GATEWAY_OPERATION_TYPES = ['RESET', 'START', 'STOP'] as const;
|
||||
export const GATEWAY_OPERATION_TYPES = ['RESET', 'DEPLOY', 'START', 'STOP'] as const;
|
||||
export type GatewayOperationType = (typeof GATEWAY_OPERATION_TYPES)[number];
|
||||
|
||||
export const GATEWAY_OPERATION_STATUSES = ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'] as const;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isRecord } from '@sammo-ts/common';
|
||||
|
||||
export const RELEASE_CONTROLLER_PROTOCOL = 1;
|
||||
|
||||
export interface ReleaseManifest {
|
||||
formatVersion: 1;
|
||||
controllerProtocol: number;
|
||||
gatewaySchemaHead: string;
|
||||
gameSchemaHead: string;
|
||||
components: string[];
|
||||
}
|
||||
|
||||
const assertMigrationHead = async (workspaceRoot: string, directory: string, expected: string): Promise<void> => {
|
||||
const migrationRoot = path.join(workspaceRoot, 'packages', 'infra', 'prisma', directory);
|
||||
const entries = await fs.readdir(migrationRoot, { withFileTypes: true });
|
||||
const latest = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.at(-1);
|
||||
if (!latest || latest !== expected) {
|
||||
throw new Error(
|
||||
`Release manifest ${directory} head ${expected} does not match workspace head ${latest ?? 'none'}.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const readReleaseManifest = async (workspaceRoot: string): Promise<ReleaseManifest> => {
|
||||
const manifestPath = path.join(workspaceRoot, 'release-manifest.json');
|
||||
const parsed = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as unknown;
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
parsed.formatVersion !== 1 ||
|
||||
typeof parsed.controllerProtocol !== 'number' ||
|
||||
!Number.isInteger(parsed.controllerProtocol) ||
|
||||
typeof parsed.gatewaySchemaHead !== 'string' ||
|
||||
typeof parsed.gameSchemaHead !== 'string' ||
|
||||
!Array.isArray(parsed.components) ||
|
||||
!parsed.components.every((component) => typeof component === 'string')
|
||||
) {
|
||||
throw new Error(`Invalid release manifest: ${manifestPath}`);
|
||||
}
|
||||
if (parsed.controllerProtocol > RELEASE_CONTROLLER_PROTOCOL) {
|
||||
throw new Error(
|
||||
`Release requires controller protocol ${parsed.controllerProtocol}; this controller supports ${RELEASE_CONTROLLER_PROTOCOL}.`
|
||||
);
|
||||
}
|
||||
const manifest = parsed as unknown as ReleaseManifest;
|
||||
await Promise.all([
|
||||
assertMigrationHead(workspaceRoot, 'gateway-migrations', manifest.gatewaySchemaHead),
|
||||
assertMigrationHead(workspaceRoot, 'migrations', manifest.gameSchemaHead),
|
||||
]);
|
||||
return manifest;
|
||||
};
|
||||
|
||||
export const assertReleaseComponents = (manifest: ReleaseManifest, required: string[]): void => {
|
||||
const available = new Set(manifest.components);
|
||||
const missing = required.filter((component) => !available.has(component));
|
||||
if (missing.length) {
|
||||
throw new Error(`Release manifest is missing components: ${missing.join(', ')}`);
|
||||
}
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import { createPasswordHasher } from './auth/passwordHasher.js';
|
||||
import { createPasswordEnvelopeService } from './auth/passwordEnvelope.js';
|
||||
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
|
||||
import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRepository.js';
|
||||
import { appRouter } from './router.js';
|
||||
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
||||
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
||||
@@ -64,6 +65,7 @@ export const createGatewayApiServer = async () => {
|
||||
config,
|
||||
process.env
|
||||
);
|
||||
const releases = createGatewayReleaseRepository(postgres.prisma as GatewayPrismaClient);
|
||||
const profileStatus = new RepositoryProfileStatusService(profiles, orchestrator);
|
||||
|
||||
const app = fastify({
|
||||
@@ -106,6 +108,7 @@ export const createGatewayApiServer = async () => {
|
||||
localAccountGraceDays: config.localAccountGraceDays,
|
||||
passwordEnvelope,
|
||||
profiles,
|
||||
releases,
|
||||
orchestrator,
|
||||
profileStatus,
|
||||
requestHeaders: req.headers,
|
||||
|
||||
@@ -9,6 +9,11 @@ import type {
|
||||
GatewayProfileRecord,
|
||||
GatewayProfileRepository,
|
||||
} from '../src/orchestrator/profileRepository.js';
|
||||
import type {
|
||||
GatewayReleaseOperationCreateInput,
|
||||
GatewayReleaseOperationRecord,
|
||||
GatewayReleaseRepository,
|
||||
} from '../src/orchestrator/gatewayReleaseRepository.js';
|
||||
import { createGatewayApiContext } from '../src/context.js';
|
||||
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
@@ -22,6 +27,7 @@ const buildCaller = async (
|
||||
runtimeActionCreateError?: unknown;
|
||||
initialNotice?: string;
|
||||
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||
profileScenario?: string;
|
||||
} = {}
|
||||
) => {
|
||||
const users = createInMemoryUserRepository();
|
||||
@@ -38,6 +44,7 @@ const buildCaller = async (
|
||||
});
|
||||
const session = await sessions.createSession({ ...admin, roles: adminRoles });
|
||||
const createdInputs: GatewayOperationCreateInput[] = [];
|
||||
const createdReleaseInputs: GatewayReleaseOperationCreateInput[] = [];
|
||||
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
|
||||
const createdRuntimeActions: Array<Record<string, unknown>> = [];
|
||||
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
|
||||
@@ -48,7 +55,7 @@ const buildCaller = async (
|
||||
const profile = {
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
scenario: '2',
|
||||
scenario: options.profileScenario ?? '2',
|
||||
apiPort: 15003,
|
||||
status: options.initialProfileStatus ?? ('STOPPED' as const),
|
||||
buildStatus: 'SUCCEEDED' as const,
|
||||
@@ -93,6 +100,46 @@ const buildCaller = async (
|
||||
cancelOperation: async () => false,
|
||||
retryOperation: async () => null,
|
||||
};
|
||||
const releases: GatewayReleaseRepository = {
|
||||
getState: async () => ({
|
||||
id: 'gateway',
|
||||
activeCommitSha: '1111111111111111111111111111111111111111',
|
||||
activeWorkspace: '/srv/sammo/current',
|
||||
previousCommitSha: '2222222222222222222222222222222222222222',
|
||||
previousWorkspace: '/srv/sammo/previous',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
}),
|
||||
listOperations: async () => [],
|
||||
getOperation: async () => null,
|
||||
createOperation: async (input) => {
|
||||
createdReleaseInputs.push(input);
|
||||
return {
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
type: input.type,
|
||||
status: 'QUEUED',
|
||||
sourceMode: input.sourceMode,
|
||||
sourceRef: input.sourceRef,
|
||||
payload: input.payload ?? {},
|
||||
reason: input.reason,
|
||||
requestedBy: input.requestedBy,
|
||||
attempts: 0,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
} satisfies GatewayReleaseOperationRecord;
|
||||
},
|
||||
claimNextOperation: async () => null,
|
||||
renewOperationLease: async () => false,
|
||||
pinOperationResolvedCommit: async () => false,
|
||||
completeOperation: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
publishRelease: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
recordStateError: async () => {},
|
||||
cancelOperation: async () => false,
|
||||
retryOperation: async () => null,
|
||||
};
|
||||
const caller = appRouter.createCaller(
|
||||
createGatewayApiContext({
|
||||
users,
|
||||
@@ -116,6 +163,7 @@ const buildCaller = async (
|
||||
localAccountGraceDays: 7,
|
||||
passwordEnvelope: createPasswordEnvelopeService(),
|
||||
profiles,
|
||||
releases,
|
||||
orchestrator: {
|
||||
start: () => {},
|
||||
stop: async () => {},
|
||||
@@ -170,6 +218,7 @@ const buildCaller = async (
|
||||
return {
|
||||
caller,
|
||||
createdInputs,
|
||||
createdReleaseInputs,
|
||||
createdRuntimeActions,
|
||||
users,
|
||||
admin,
|
||||
@@ -270,6 +319,86 @@ describe('admin operation API', () => {
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
});
|
||||
|
||||
it('queues a DB-preserving profile deployment without reset payload', async () => {
|
||||
const harness = await buildCaller(
|
||||
async (input) => ({
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
profileName: input.profileName,
|
||||
type: 'DEPLOY',
|
||||
status: 'QUEUED',
|
||||
sourceMode: input.sourceMode,
|
||||
sourceRef: input.sourceRef,
|
||||
payload: {},
|
||||
requestedBy: input.requestedBy,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
}),
|
||||
{ profileScenario: '1010' }
|
||||
);
|
||||
|
||||
await harness.caller.admin.operations.requestDeploy({
|
||||
profileName: 'che:2',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: 'HEAD',
|
||||
reason: 'preserve live season',
|
||||
});
|
||||
|
||||
expect(harness.createdInputs[0]).toMatchObject({
|
||||
profileName: 'che:2',
|
||||
type: 'DEPLOY',
|
||||
sourceMode: 'COMMIT',
|
||||
reason: 'preserve live season',
|
||||
});
|
||||
expect(harness.createdInputs[0]).not.toHaveProperty('payload');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateway release API', () => {
|
||||
it('queues a gateway deployment for the external release controller', async () => {
|
||||
const harness = await buildCaller(async () => {
|
||||
throw new Error('not used');
|
||||
});
|
||||
|
||||
await harness.caller.admin.releases.requestGatewayDeploy({
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: 'HEAD',
|
||||
reason: 'gateway rollout',
|
||||
});
|
||||
|
||||
expect(harness.createdReleaseInputs[0]).toMatchObject({
|
||||
type: 'DEPLOY',
|
||||
sourceMode: 'COMMIT',
|
||||
reason: 'gateway rollout',
|
||||
requestedBy: harness.admin.id,
|
||||
});
|
||||
expect(harness.createdReleaseInputs[0]?.sourceRef).toMatch(/^[0-9a-f]{40}$/u);
|
||||
});
|
||||
|
||||
it('queues rollback to the previously published gateway commit', async () => {
|
||||
const harness = await buildCaller(async () => {
|
||||
throw new Error('not used');
|
||||
});
|
||||
|
||||
await harness.caller.admin.releases.requestGatewayRollback({ reason: 'readiness regression' });
|
||||
|
||||
expect(harness.createdReleaseInputs[0]).toMatchObject({
|
||||
type: 'ROLLBACK',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: '2222222222222222222222222222222222222222',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires the global release permission even for profile-scoped administrators', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
{ adminRoles: ['admin.profiles.manage:che:2'], firstUserIsAdmin: false }
|
||||
);
|
||||
|
||||
await expect(harness.caller.admin.releases.gatewayState()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy profile install API', () => {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGatewayReleaseRepository } from '../src/orchestrator/gatewayReleaseRepository.js';
|
||||
|
||||
const databaseUrl = process.env.GATEWAY_RELEASE_DATABASE_URL;
|
||||
const describeDatabase = describe.runIf(Boolean(databaseUrl));
|
||||
|
||||
describeDatabase('gateway release operation persistence', () => {
|
||||
const connector = createGatewayPostgresConnector({ url: databaseUrl ?? '' });
|
||||
const repository = createGatewayReleaseRepository(connector.prisma);
|
||||
|
||||
beforeAll(async () => {
|
||||
await connector.connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await connector.prisma.gatewayReleaseOperation.deleteMany();
|
||||
await connector.prisma.gatewayReleaseState.deleteMany();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await connector.disconnect();
|
||||
});
|
||||
|
||||
it('serializes releases, fences publication by lease owner, and records rollback state', async () => {
|
||||
const operation = await repository.createOperation({
|
||||
type: 'DEPLOY',
|
||||
sourceMode: 'BRANCH',
|
||||
sourceRef: 'main',
|
||||
requestedBy: 'admin-a',
|
||||
});
|
||||
await expect(
|
||||
repository.createOperation({
|
||||
type: 'ROLLBACK',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: '2222222222222222222222222222222222222222',
|
||||
requestedBy: 'admin-b',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'P2002' });
|
||||
|
||||
const now = new Date('2030-01-01T00:00:00.000Z');
|
||||
await expect(
|
||||
repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 })
|
||||
).resolves.toMatchObject({
|
||||
id: operation.id,
|
||||
attempts: 1,
|
||||
leaseOwner: 'controller-a',
|
||||
});
|
||||
await expect(repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'a'.repeat(40))).resolves.toBe(
|
||||
true
|
||||
);
|
||||
await expect(
|
||||
repository.publishRelease(operation.id, 'stale-controller', {
|
||||
commitSha: 'a'.repeat(40),
|
||||
workspace: '/srv/sammo/new',
|
||||
})
|
||||
).rejects.toThrow('lease lost before publish');
|
||||
|
||||
await expect(
|
||||
repository.publishRelease(operation.id, 'controller-a', {
|
||||
commitSha: 'a'.repeat(40),
|
||||
workspace: '/srv/sammo/new',
|
||||
previousCommitSha: 'b'.repeat(40),
|
||||
previousWorkspace: '/srv/sammo/old',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
activeCommitSha: 'a'.repeat(40),
|
||||
activeWorkspace: '/srv/sammo/new',
|
||||
previousCommitSha: 'b'.repeat(40),
|
||||
previousWorkspace: '/srv/sammo/old',
|
||||
});
|
||||
await expect(
|
||||
repository.completeOperation(
|
||||
operation.id,
|
||||
'SUCCEEDED',
|
||||
{ resolvedCommitSha: 'a'.repeat(40), error: null },
|
||||
'controller-a'
|
||||
)
|
||||
).resolves.toMatchObject({ status: 'SUCCEEDED' });
|
||||
});
|
||||
|
||||
it('reclaims an expired release while preserving its pinned commit', async () => {
|
||||
const operation = await repository.createOperation({
|
||||
type: 'DEPLOY',
|
||||
sourceMode: 'BRANCH',
|
||||
sourceRef: 'main',
|
||||
requestedBy: 'admin',
|
||||
});
|
||||
const now = new Date('2030-01-01T00:00:00.000Z');
|
||||
await repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 });
|
||||
await repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'c'.repeat(40));
|
||||
|
||||
await expect(
|
||||
repository.claimNextOperation(new Date(now.getTime() + 1_001), {
|
||||
ownerId: 'controller-b',
|
||||
durationMs: 1_000,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
id: operation.id,
|
||||
attempts: 2,
|
||||
leaseOwner: 'controller-b',
|
||||
resolvedCommitSha: 'c'.repeat(40),
|
||||
});
|
||||
await expect(repository.renewOperationLease(operation.id, 'controller-a', now, 1_000)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,7 @@ const createHarness = (
|
||||
list: async () =>
|
||||
processesPresent
|
||||
? [
|
||||
{ name: 'sammo:che:2:game-frontend', status: 'online' },
|
||||
{ name: 'sammo:che:2:game-api', status: 'online' },
|
||||
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
|
||||
{ name: 'sammo:che:2:auction-worker', status: 'online' },
|
||||
@@ -166,6 +167,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
|
||||
expect(harness.statuses).toEqual(['RUNNING']);
|
||||
expect(harness.started.map((definition) => definition.name)).toEqual([
|
||||
'sammo:che:2:game-frontend',
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:auction-worker',
|
||||
@@ -182,6 +184,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
|
||||
expect(harness.statuses).toEqual(['STOPPED']);
|
||||
expect(harness.stopped).toEqual([
|
||||
'sammo:che:2:game-frontend',
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:auction-worker',
|
||||
@@ -189,6 +192,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.deleted).toEqual([
|
||||
'sammo:che:2:game-frontend',
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:auction-worker',
|
||||
@@ -215,6 +219,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
await harness.orchestrator.runOperationsNow();
|
||||
|
||||
expect(harness.deleted).toEqual([
|
||||
'sammo:che:2:game-frontend',
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:auction-worker',
|
||||
@@ -231,9 +236,9 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
|
||||
expect(harness.completions).toEqual(['FAILED']);
|
||||
expect(harness.deleted).toEqual([
|
||||
'sammo:che:2:auction-worker',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:game-frontend',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -243,6 +248,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
await harness.orchestrator.runOperationsNow();
|
||||
|
||||
expect(harness.stopped).toEqual([
|
||||
'sammo:che:2:game-frontend',
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:auction-worker',
|
||||
@@ -250,6 +256,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.deleted).toEqual([
|
||||
'sammo:che:2:game-frontend',
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:auction-worker',
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('planProfileReconcile', () => {
|
||||
it('starts missing processes for running profiles', () => {
|
||||
expect(
|
||||
planProfileReconcile('RUNNING', {
|
||||
frontendRunning: true,
|
||||
apiRunning: true,
|
||||
daemonRunning: false,
|
||||
auctionRunning: true,
|
||||
@@ -40,6 +41,7 @@ describe('planProfileReconcile', () => {
|
||||
it('starts processes for preopen profiles', () => {
|
||||
expect(
|
||||
planProfileReconcile('PREOPEN', {
|
||||
frontendRunning: false,
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
auctionRunning: false,
|
||||
@@ -52,6 +54,7 @@ describe('planProfileReconcile', () => {
|
||||
it('does nothing when running profile is healthy', () => {
|
||||
expect(
|
||||
planProfileReconcile('RUNNING', {
|
||||
frontendRunning: true,
|
||||
apiRunning: true,
|
||||
daemonRunning: true,
|
||||
auctionRunning: true,
|
||||
@@ -64,6 +67,7 @@ describe('planProfileReconcile', () => {
|
||||
it('restarts a running profile when only the auction worker is missing', () => {
|
||||
expect(
|
||||
planProfileReconcile('RUNNING', {
|
||||
frontendRunning: true,
|
||||
apiRunning: true,
|
||||
daemonRunning: true,
|
||||
auctionRunning: false,
|
||||
@@ -76,6 +80,7 @@ describe('planProfileReconcile', () => {
|
||||
it('stops processes for non-running profiles', () => {
|
||||
expect(
|
||||
planProfileReconcile('STOPPED', {
|
||||
frontendRunning: false,
|
||||
apiRunning: false,
|
||||
daemonRunning: true,
|
||||
auctionRunning: false,
|
||||
@@ -88,6 +93,7 @@ describe('planProfileReconcile', () => {
|
||||
it('keeps reserved profiles off', () => {
|
||||
expect(
|
||||
planProfileReconcile('RESERVED', {
|
||||
frontendRunning: false,
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
auctionRunning: false,
|
||||
@@ -110,6 +116,19 @@ describe('buildProcessDefinitions', () => {
|
||||
const buildWorkspace = '/srv/sammo/worktrees/0123456789abcdef';
|
||||
const definitions = buildProcessDefinitions(buildProfile(buildWorkspace), processConfig);
|
||||
|
||||
expect(definitions.frontend).toMatchObject({
|
||||
cwd: path.join(buildWorkspace, 'app', 'game-frontend'),
|
||||
script: path.join(buildWorkspace, 'node_modules', 'vite', 'bin', 'vite.js'),
|
||||
args: [
|
||||
'preview',
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
'15002',
|
||||
'--outDir',
|
||||
path.join(buildWorkspace, '.release-dist', 'che_2', 'game-frontend'),
|
||||
],
|
||||
});
|
||||
expect(definitions.api.cwd).toBe(path.join(buildWorkspace, 'app', 'game-api'));
|
||||
expect(definitions.api.script).toBe(path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'));
|
||||
expect(definitions.api.env).toMatchObject({
|
||||
@@ -141,6 +160,7 @@ describe('buildProcessDefinitions', () => {
|
||||
it('keeps main as the runtime for profiles without a commit worktree', () => {
|
||||
const definitions = buildProcessDefinitions(buildProfile(), processConfig);
|
||||
|
||||
expect(definitions.frontend.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-frontend'));
|
||||
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
|
||||
expect(definitions.auction.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||
@@ -155,7 +175,7 @@ describe('buildWorkspaceCommands', () => {
|
||||
const commands = buildWorkspaceCommands(workspaceRoot, true);
|
||||
|
||||
expect(commands.map(({ args }) => args)).toEqual([
|
||||
['install'],
|
||||
['install', '--frozen-lockfile'],
|
||||
['--filter', '@sammo-ts/common', 'build'],
|
||||
['--filter', '@sammo-ts/infra', 'prisma:generate'],
|
||||
['--filter', '@sammo-ts/infra', 'build'],
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
|
||||
import type { BuildCommand } from '../src/orchestrator/buildRunner.js';
|
||||
import type { ProcessManager } from '../src/orchestrator/processManager.js';
|
||||
import type {
|
||||
GatewayClaimedProfileUpdate,
|
||||
GatewayOperationRecord,
|
||||
GatewayProfileRecord,
|
||||
GatewayProfileRepository,
|
||||
} from '../src/orchestrator/profileRepository.js';
|
||||
import type { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
|
||||
|
||||
const SHA = '1111111111111111111111111111111111111111';
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
const createReleaseWorkspace = async (): Promise<string> => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-profile-deploy-'));
|
||||
temporaryDirectories.push(workspace);
|
||||
await fs.mkdir(path.join(workspace, 'packages/infra/prisma/gateway-migrations/20260801000000_gateway'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.mkdir(path.join(workspace, 'packages/infra/prisma/migrations/20260801000000_game'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'release-manifest.json'),
|
||||
JSON.stringify({
|
||||
formatVersion: 1,
|
||||
controllerProtocol: 1,
|
||||
gatewaySchemaHead: '20260801000000_gateway',
|
||||
gameSchemaHead: '20260801000000_game',
|
||||
components: ['game-api', 'game-engine', 'game-frontend'],
|
||||
})
|
||||
);
|
||||
return workspace;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
|
||||
});
|
||||
|
||||
describe('profile DEPLOY operation', () => {
|
||||
it('migrates and switches the selected release without executing the reset seed path', async () => {
|
||||
const workspace = await createReleaseWorkspace();
|
||||
const profile: GatewayProfileRecord = {
|
||||
profileName: 'che:1010',
|
||||
profile: 'che',
|
||||
scenario: '1010',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING',
|
||||
buildStatus: 'SUCCEEDED',
|
||||
buildCommitSha: '2222222222222222222222222222222222222222',
|
||||
buildWorkspace: '/srv/sammo/old',
|
||||
meta: {},
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
};
|
||||
const operation: GatewayOperationRecord = {
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
profileName: profile.profileName,
|
||||
type: 'DEPLOY',
|
||||
status: 'RUNNING',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: SHA,
|
||||
payload: {},
|
||||
requestedBy: 'admin',
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
};
|
||||
let nextOperation: GatewayOperationRecord | null = operation;
|
||||
const patches: GatewayClaimedProfileUpdate[] = [];
|
||||
const completions: string[] = [];
|
||||
const repository: GatewayProfileRepository = {
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async () => profile,
|
||||
upsertProfile: async () => profile,
|
||||
updateScenario: async () => profile,
|
||||
updateStatus: async () => profile,
|
||||
updateBuildStatus: async () => profile,
|
||||
updateMeta: async () => profile,
|
||||
listReservedToStart: async () => [],
|
||||
findQueuedBuild: async () => null,
|
||||
updateLastError: async () => {},
|
||||
updateWorkspaceUsage: async () => {},
|
||||
clearWorkspaceUsage: async () => {},
|
||||
listOperations: async () => [],
|
||||
getOperation: async () => operation,
|
||||
createOperation: async () => operation,
|
||||
claimNextOperation: async () => {
|
||||
const value = nextOperation;
|
||||
nextOperation = null;
|
||||
return value;
|
||||
},
|
||||
renewOperationLease: async () => true,
|
||||
pinOperationResolvedCommit: async () => true,
|
||||
updateProfileForOperation: async (_id, _owner, _profileName, patch) => {
|
||||
patches.push(patch);
|
||||
return { ...profile, ...patch } as GatewayProfileRecord;
|
||||
},
|
||||
completeOperation: async (_id, status) => {
|
||||
completions.push(status);
|
||||
return { ...operation, status };
|
||||
},
|
||||
requeueOperation: async () => operation,
|
||||
cancelOperation: async () => false,
|
||||
retryOperation: async () => null,
|
||||
};
|
||||
const processNames = [
|
||||
'sammo:che:1010:game-frontend',
|
||||
'sammo:che:1010:game-api',
|
||||
'sammo:che:1010:turn-daemon',
|
||||
'sammo:che:1010:auction-worker',
|
||||
'sammo:che:1010:battle-sim-worker',
|
||||
'sammo:che:1010:tournament-worker',
|
||||
];
|
||||
const running = new Set(processNames);
|
||||
const processManager: ProcessManager = {
|
||||
list: async () => [...running].map((name) => ({ name, status: 'online' })),
|
||||
start: async (definition) => {
|
||||
running.add(definition.name);
|
||||
},
|
||||
stop: async () => {},
|
||||
delete: async (name) => {
|
||||
running.delete(name);
|
||||
},
|
||||
};
|
||||
const commandGroups: BuildCommand[][] = [];
|
||||
const workspaceManager = {
|
||||
resolveCommit: async () => SHA,
|
||||
prepare: async () => ({ root: workspace, created: true, needsInstall: true }),
|
||||
} as unknown as GitWorkspaceManager;
|
||||
const orchestrator = new GatewayOrchestrator({
|
||||
repository,
|
||||
processManager,
|
||||
buildRunner: {
|
||||
run: async (commands) => {
|
||||
commandGroups.push(commands);
|
||||
return { ok: true, exitCode: 0, output: '' };
|
||||
},
|
||||
},
|
||||
workspaceManager,
|
||||
processConfig: {
|
||||
workspaceRoot: '/srv/sammo/controller',
|
||||
redisKeyPrefix: 'sammo:test',
|
||||
gameTokenSecret: 'test-secret',
|
||||
gatewayInternalApiUrl: 'http://127.0.0.1:15001',
|
||||
baseEnv: { DATABASE_URL: 'postgresql://integration.invalid/sammo' },
|
||||
},
|
||||
reconcileIntervalMs: 60_000,
|
||||
scheduleIntervalMs: 60_000,
|
||||
buildIntervalMs: 60_000,
|
||||
adminActionIntervalMs: 60_000,
|
||||
profileReadinessTimeoutMs: 10,
|
||||
fetchImpl: async () => new Response('', { status: 200 }),
|
||||
});
|
||||
|
||||
await orchestrator.runOperationsNow();
|
||||
|
||||
expect(commandGroups).toHaveLength(2);
|
||||
expect(commandGroups[0]?.[0]?.args).toEqual(['install', '--frozen-lockfile']);
|
||||
expect(commandGroups[1]?.map((command) => command.args)).toEqual([
|
||||
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
||||
]);
|
||||
expect(commandGroups.flat().some((command) => command.env?.GATEWAY_ROLE === 'profile-seed')).toBe(false);
|
||||
expect(patches.at(-1)).toMatchObject({
|
||||
buildStatus: 'SUCCEEDED',
|
||||
buildCommitSha: SHA,
|
||||
buildWorkspace: workspace,
|
||||
});
|
||||
expect(completions).toEqual(['SUCCEEDED']);
|
||||
expect([...running].sort()).toEqual([...processNames].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { readReleaseManifest } from '../src/orchestrator/releaseManifest.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
const createWorkspace = async (gatewayHead: string, gameHead: string): Promise<string> => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-release-manifest-'));
|
||||
temporaryDirectories.push(workspace);
|
||||
await fs.mkdir(path.join(workspace, 'packages/infra/prisma/gateway-migrations', gatewayHead), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.mkdir(path.join(workspace, 'packages/infra/prisma/migrations', gameHead), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'release-manifest.json'),
|
||||
JSON.stringify({
|
||||
formatVersion: 1,
|
||||
controllerProtocol: 1,
|
||||
gatewaySchemaHead: gatewayHead,
|
||||
gameSchemaHead: gameHead,
|
||||
components: ['gateway-api', 'gateway-frontend', 'game-api', 'game-engine', 'game-frontend'],
|
||||
})
|
||||
);
|
||||
return workspace;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
|
||||
});
|
||||
|
||||
describe('readReleaseManifest', () => {
|
||||
it('accepts a manifest whose schema heads match the selected workspace', async () => {
|
||||
const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game');
|
||||
|
||||
await expect(readReleaseManifest(workspace)).resolves.toMatchObject({
|
||||
gatewaySchemaHead: '20260801000000_gateway',
|
||||
gameSchemaHead: '20260801000000_game',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a stale schema head before any deployment command runs', async () => {
|
||||
const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game');
|
||||
await fs.mkdir(path.join(workspace, 'packages/infra/prisma/migrations/20260802000000_newer'));
|
||||
|
||||
await expect(readReleaseManifest(workspace)).rejects.toThrow('does not match workspace head');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user