Merge branch 'main' into feature/user-icon-library-20260801
This commit is contained in:
@@ -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