feat: add durable release controller and admin deploy modes
This commit is contained in:
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user