feat: add admin scenario operations

This commit is contained in:
2026-07-25 11:38:58 +00:00
parent a83f7a44e3
commit 02d22de72c
19 changed files with 2020 additions and 22 deletions
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest';
import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
import type { GatewayOperationCreateInput, GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
import { createGatewayApiContext } from '../src/context.js';
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
import { appRouter } from '../src/router.js';
const buildCaller = async (createOperation: GatewayProfileRepository['createOperation']) => {
const users = createInMemoryUserRepository();
const admin = await users.createUser({
username: 'admin',
password: 'secretpass',
displayName: 'Admin',
});
await users.updateRoles(admin.id, ['superuser']);
const sessions = new InMemoryGatewaySessionService({
sessionTtlSeconds: 600,
gameSessionTtlSeconds: 600,
});
const session = await sessions.createSession({ ...admin, roles: ['superuser'] });
const createdInputs: GatewayOperationCreateInput[] = [];
const profile = {
profileName: 'che:2',
profile: 'che',
scenario: '2',
apiPort: 15003,
status: 'STOPPED' as const,
buildStatus: 'SUCCEEDED' as const,
meta: {},
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
};
const profiles: 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 () => null,
createOperation: async (input) => {
createdInputs.push(input);
return createOperation(input);
},
claimNextOperation: async () => null,
completeOperation: async () => {
throw new Error('not used');
},
requeueOperation: async () => {
throw new Error('not used');
},
cancelOperation: async () => false,
retryOperation: async () => null,
};
const caller = appRouter.createCaller(
createGatewayApiContext({
users,
sessions,
flushPublisher: { publishUserFlush: async () => {} },
gameTokenSecret: 'test-secret',
gameSessionTtlSeconds: 600,
kakaoClient: {} as never,
oauthSessions: {} as never,
publicBaseUrl: 'http://localhost',
adminLocalAccountEnabled: false,
profiles,
orchestrator: {
start: () => {},
stop: async () => {},
reconcileNow: async () => {},
runScheduleNow: async () => {},
runBuildQueueNow: async () => {},
runOperationsNow: async () => {},
cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }),
listRuntimeStates: async () => [],
},
profileStatus: new InMemoryProfileStatusService(),
requestHeaders: { 'x-session-token': session.sessionToken },
prisma: {
appUser: {
findFirst: async () => ({ id: admin.id }),
},
} as unknown as GatewayPrismaClient,
})
);
return { caller, createdInputs };
};
describe('admin operation API', () => {
it('queues a start operation with the authenticated requester', async () => {
const operation = {
id: '11111111-1111-4111-8111-111111111111',
profileName: 'che:2',
type: 'START' as const,
status: 'QUEUED' as const,
payload: {},
requestedBy: 'admin-id',
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
};
const harness = await buildCaller(async () => operation);
const result = await harness.caller.admin.operations.requestRuntime({
profileName: 'che:2',
action: 'START',
reason: 'maintenance complete',
});
expect(result.type).toBe('START');
expect(harness.createdInputs[0]).toMatchObject({
profileName: 'che:2',
type: 'START',
reason: 'maintenance complete',
});
});
it('reports an active-operation uniqueness conflict', async () => {
const harness = await buildCaller(async () => {
throw { code: 'P2002' };
});
await expect(
harness.caller.admin.operations.requestRuntime({
profileName: 'che:2',
action: 'STOP',
})
).rejects.toMatchObject({ code: 'CONFLICT' });
});
});
+15
View File
@@ -59,6 +59,20 @@ const buildCaller = () => {
updateLastError: async () => {},
updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {},
listOperations: async () => [],
getOperation: async () => null,
createOperation: async () => {
throw new Error('not implemented');
},
claimNextOperation: async () => null,
completeOperation: async () => {
throw new Error('not implemented');
},
requeueOperation: async () => {
throw new Error('not implemented');
},
cancelOperation: async () => false,
retryOperation: async () => null,
};
const orchestrator = {
start: () => {},
@@ -66,6 +80,7 @@ const buildCaller = () => {
reconcileNow: async () => {},
runScheduleNow: async () => {},
runBuildQueueNow: async () => {},
runOperationsNow: async () => {},
cleanupStaleWorkspaces: async () => ({
removed: [],
skipped: [],
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js';
import type {
GatewayOperationRecord,
GatewayOperationStatus,
GatewayProfileRecord,
GatewayProfileRepository,
} from '../src/orchestrator/profileRepository.js';
import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
const profile: GatewayProfileRecord = {
profileName: 'che:2',
profile: 'che',
scenario: '2',
apiPort: 15003,
status: 'STOPPED',
buildStatus: 'SUCCEEDED',
buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
meta: {},
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
};
const buildOperation = (type: 'START' | 'STOP'): GatewayOperationRecord => ({
id: '11111111-1111-4111-8111-111111111111',
profileName: profile.profileName,
type,
status: 'RUNNING',
payload: {},
requestedBy: 'admin',
createdAt: '2026-07-25T01:00:00.000Z',
startedAt: '2026-07-25T01:00:00.000Z',
updatedAt: '2026-07-25T01:00:00.000Z',
});
const createHarness = (operation: GatewayOperationRecord, failStart = false, failStop = false) => {
let nextOperation: GatewayOperationRecord | null = operation;
const statuses: string[] = [];
const completions: GatewayOperationStatus[] = [];
const started: ProcessDefinition[] = [];
const stopped: string[] = [];
const deleted: string[] = [];
const repository: GatewayProfileRepository = {
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateStatus: async (_profileName, status) => {
statuses.push(status);
return { ...profile, status };
},
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 result = nextOperation;
nextOperation = null;
return result;
},
completeOperation: async (_id, status) => {
completions.push(status);
return { ...operation, status };
},
requeueOperation: async () => ({ ...operation, status: 'QUEUED' }),
cancelOperation: async () => false,
retryOperation: async () => null,
};
const processManager: ProcessManager = {
list: async () => [],
start: async (definition) => {
if (failStart) {
throw new Error('pm2 unavailable');
}
started.push(definition);
},
stop: async (name) => {
stopped.push(name);
if (failStop) {
throw new Error('pm2 stop failed');
}
},
delete: async (name) => {
deleted.push(name);
if (failStop) {
throw new Error('pm2 delete failed');
}
},
};
const orchestrator = new GatewayOrchestrator({
repository,
processManager,
buildRunner: {
run: async () => ({ ok: true, exitCode: 0, output: '' }),
},
workspaceManager: new GitWorkspaceManager({
repoRoot: '/tmp/not-used',
worktreeRoot: '/tmp/not-used-worktrees',
}),
processConfig: {
workspaceRoot: '/srv/sammo',
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
},
reconcileIntervalMs: 60_000,
scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000,
});
return { orchestrator, statuses, completions, started, stopped, deleted };
};
describe('GatewayOrchestrator first-class operations', () => {
it('starts both profile processes and records success', async () => {
const harness = createHarness(buildOperation('START'));
await harness.orchestrator.runOperationsNow();
expect(harness.statuses).toEqual(['RUNNING']);
expect(harness.started.map((definition) => definition.name)).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
]);
expect(harness.completions).toEqual(['SUCCEEDED']);
});
it('stops both profile processes and records success', async () => {
const harness = createHarness(buildOperation('STOP'));
await harness.orchestrator.runOperationsNow();
expect(harness.statuses).toEqual(['STOPPED']);
expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
expect(harness.completions).toEqual(['SUCCEEDED']);
});
it('records a failed start instead of reporting a false success', async () => {
const harness = createHarness(buildOperation('START'), true);
await harness.orchestrator.runOperationsNow();
expect(harness.completions).toEqual(['FAILED']);
});
it('attempts to stop both roles before reporting a partial PM2 failure', async () => {
const harness = createHarness(buildOperation('STOP'), false, true);
await harness.orchestrator.runOperationsNow();
expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
expect(harness.completions).toEqual(['FAILED']);
});
});
@@ -0,0 +1,88 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
const temporaryRoots: string[] = [];
const git = (cwd: string, ...args: string[]): string =>
execFileSync('git', args, {
cwd,
encoding: 'utf8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'Sammo Test',
GIT_AUTHOR_EMAIL: 'sammo-test@example.invalid',
GIT_COMMITTER_NAME: 'Sammo Test',
GIT_COMMITTER_EMAIL: 'sammo-test@example.invalid',
},
}).trim();
const createRepositoryFixture = (): {
source: string;
checkout: string;
worktrees: string;
firstCommit: string;
} => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-workspace-manager-'));
temporaryRoots.push(root);
const remote = path.join(root, 'remote.git');
const source = path.join(root, 'source');
const checkout = path.join(root, 'checkout');
const worktrees = path.join(root, 'worktrees');
fs.mkdirSync(source);
git(root, 'init', '--bare', remote);
git(source, 'init', '-b', 'main');
fs.writeFileSync(path.join(source, 'version.txt'), 'first\n');
git(source, 'add', 'version.txt');
git(source, 'commit', '-m', 'first');
const firstCommit = git(source, 'rev-parse', 'HEAD');
git(source, 'remote', 'add', 'origin', remote);
git(source, 'push', '-u', 'origin', 'main');
git(root, 'clone', '--branch', 'main', remote, checkout);
return { source, checkout, worktrees, firstCommit };
};
afterEach(() => {
for (const root of temporaryRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe('GitWorkspaceManager source resolution', () => {
it('keeps COMMIT pinned while BRANCH follows the latest remote head', async () => {
const fixture = createRepositoryFixture();
const manager = new GitWorkspaceManager({
repoRoot: fixture.checkout,
worktreeRoot: fixture.worktrees,
});
expect(await manager.resolveCommit('COMMIT', fixture.firstCommit)).toBe(fixture.firstCommit);
expect(await manager.resolveCommit('BRANCH', 'main')).toBe(fixture.firstCommit);
fs.writeFileSync(path.join(fixture.source, 'version.txt'), 'second\n');
git(fixture.source, 'add', 'version.txt');
git(fixture.source, 'commit', '-m', 'second');
const secondCommit = git(fixture.source, 'rev-parse', 'HEAD');
git(fixture.source, 'push', 'origin', 'main');
expect(await manager.resolveCommit('COMMIT', fixture.firstCommit)).toBe(fixture.firstCommit);
expect(await manager.resolveCommit('BRANCH', 'main')).toBe(secondCommit);
});
it('rejects option-like and range refs', async () => {
const fixture = createRepositoryFixture();
const manager = new GitWorkspaceManager({
repoRoot: fixture.checkout,
worktreeRoot: fixture.worktrees,
});
await expect(manager.resolveCommit('BRANCH', '--upload-pack=bad')).rejects.toThrow('Invalid git ref');
await expect(manager.resolveCommit('COMMIT', 'HEAD..main')).rejects.toThrow('Invalid git ref');
});
});