역할별 PostgreSQL pool 상한과 동일 process 공유 pool을 적용하고 health 지표를 노출한다.\n\n게임 기능 advisory lock을 schema namespace로 통일하고 실제 PostgreSQL 통합 하네스를 보강한다.
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
buildProfileMigrationCommand,
|
|
buildProcessDefinitions,
|
|
buildWorkspaceCommands,
|
|
planProfileReconcile,
|
|
} from '../src/orchestrator/gatewayOrchestrator.js';
|
|
import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js';
|
|
import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js';
|
|
|
|
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
|
|
profileName: 'che:2',
|
|
profile: 'che',
|
|
instanceKey: '2',
|
|
currentScenario: '2',
|
|
scenario: '2',
|
|
apiPort: 15003,
|
|
status: 'RUNNING',
|
|
buildStatus: 'SUCCEEDED',
|
|
buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
|
|
buildWorkspace,
|
|
meta: {},
|
|
createdAt: '2026-07-25T00:00:00.000Z',
|
|
updatedAt: '2026-07-25T00:00:00.000Z',
|
|
});
|
|
|
|
describe('planProfileReconcile', () => {
|
|
it('starts missing processes for running profiles', () => {
|
|
expect(
|
|
planProfileReconcile('RUNNING', {
|
|
frontendRunning: true,
|
|
apiRunning: true,
|
|
daemonRunning: false,
|
|
auctionRunning: true,
|
|
battleSimRunning: true,
|
|
tournamentRunning: true,
|
|
})
|
|
).toEqual({ shouldStart: true, shouldStop: false });
|
|
});
|
|
|
|
it('starts processes for preopen profiles', () => {
|
|
expect(
|
|
planProfileReconcile('PREOPEN', {
|
|
frontendRunning: false,
|
|
apiRunning: false,
|
|
daemonRunning: false,
|
|
auctionRunning: false,
|
|
battleSimRunning: false,
|
|
tournamentRunning: false,
|
|
})
|
|
).toEqual({ shouldStart: true, shouldStop: false });
|
|
});
|
|
|
|
it('does nothing when running profile is healthy', () => {
|
|
expect(
|
|
planProfileReconcile('RUNNING', {
|
|
frontendRunning: true,
|
|
apiRunning: true,
|
|
daemonRunning: true,
|
|
auctionRunning: true,
|
|
battleSimRunning: true,
|
|
tournamentRunning: true,
|
|
})
|
|
).toEqual({ shouldStart: false, shouldStop: false });
|
|
});
|
|
|
|
it('restarts a running profile when only the auction worker is missing', () => {
|
|
expect(
|
|
planProfileReconcile('RUNNING', {
|
|
frontendRunning: true,
|
|
apiRunning: true,
|
|
daemonRunning: true,
|
|
auctionRunning: false,
|
|
battleSimRunning: true,
|
|
tournamentRunning: true,
|
|
})
|
|
).toEqual({ shouldStart: true, shouldStop: false });
|
|
});
|
|
|
|
it('stops processes for non-running profiles', () => {
|
|
expect(
|
|
planProfileReconcile('STOPPED', {
|
|
frontendRunning: false,
|
|
apiRunning: false,
|
|
daemonRunning: true,
|
|
auctionRunning: false,
|
|
battleSimRunning: false,
|
|
tournamentRunning: false,
|
|
})
|
|
).toEqual({ shouldStart: false, shouldStop: true });
|
|
});
|
|
|
|
it('keeps reserved profiles off', () => {
|
|
expect(
|
|
planProfileReconcile('RESERVED', {
|
|
frontendRunning: false,
|
|
apiRunning: false,
|
|
daemonRunning: false,
|
|
auctionRunning: false,
|
|
battleSimRunning: false,
|
|
tournamentRunning: false,
|
|
})
|
|
).toEqual({ shouldStart: false, shouldStop: false });
|
|
});
|
|
});
|
|
|
|
describe('buildProcessDefinitions', () => {
|
|
const processConfig = {
|
|
workspaceRoot: '/srv/sammo/main',
|
|
redisKeyPrefix: 'sammo:gateway',
|
|
gameTokenSecret: 'test-secret',
|
|
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
|
|
};
|
|
|
|
it('runs a built profile from its commit worktree', () => {
|
|
const buildWorkspace = '/srv/sammo/worktrees/0123456789abcdef';
|
|
const definitions = buildProcessDefinitions(buildProfile(buildWorkspace), processConfig);
|
|
|
|
expect(Object.values(definitions)).toHaveLength(6);
|
|
expect(new Set(Object.values(definitions).map((definition) => definition.name)).size).toBe(6);
|
|
|
|
expect(definitions.frontend).toMatchObject({
|
|
cwd: path.join(buildWorkspace, 'app', 'game-frontend'),
|
|
script: path.join(buildWorkspace, 'app', 'game-frontend', '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({
|
|
GAME_PROFILE_NAME: 'che:2',
|
|
POSTGRES_POOL_MAX: '4',
|
|
GAME_TRPC_PATH: '/che/api/trpc',
|
|
GAME_API_EVENTS_PATH: '/che/api/events',
|
|
GATEWAY_INTERNAL_API_URL: 'http://127.0.0.1:13000',
|
|
GAME_UPLOAD_PATH: '/che/api/uploads',
|
|
});
|
|
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
|
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
|
expect(definitions.daemon.env.POSTGRES_POOL_MAX).toBe('2');
|
|
expect(definitions.auction).toMatchObject({
|
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
|
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
|
env: { GAME_API_ROLE: 'auction-worker', POSTGRES_POOL_MAX: '1' },
|
|
});
|
|
expect(definitions.battleSim).toMatchObject({
|
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
|
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
|
env: { GAME_API_ROLE: 'battle-sim-worker', POSTGRES_POOL_MAX: '1' },
|
|
});
|
|
expect(definitions.tournament).toMatchObject({
|
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
|
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
|
env: { GAME_API_ROLE: 'tournament-worker', POSTGRES_POOL_MAX: '1' },
|
|
});
|
|
});
|
|
|
|
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'));
|
|
expect(definitions.battleSim.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
|
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
|
});
|
|
|
|
it('keeps the instance identity stable while passing the mutable current scenario', () => {
|
|
const definitions = buildProcessDefinitions(
|
|
{
|
|
...buildProfile(),
|
|
profileName: 'che:default',
|
|
instanceKey: 'default',
|
|
currentScenario: '1010',
|
|
scenario: '1010',
|
|
},
|
|
processConfig
|
|
);
|
|
|
|
expect(definitions.api.name).toBe('sammo:che:default:game-api');
|
|
expect(definitions.api.env).toMatchObject({
|
|
GAME_PROFILE_NAME: 'che:default',
|
|
SCENARIO: '1010',
|
|
});
|
|
expect(definitions.daemon.env).toMatchObject({
|
|
TURN_PROFILE_NAME: 'che:default',
|
|
SCENARIO: '1010',
|
|
});
|
|
});
|
|
|
|
it('uses the legacy default scenario marker only for an uninitialized instance runtime', () => {
|
|
const definitions = buildProcessDefinitions(
|
|
{ ...buildProfile(), currentScenario: null, scenario: 'default' },
|
|
processConfig
|
|
);
|
|
|
|
expect(definitions.api.env.SCENARIO).toBe('default');
|
|
expect(definitions.daemon.env.SCENARIO).toBe('default');
|
|
});
|
|
|
|
it('does not forward PM2 identity or parent runtime roles to profile processes', () => {
|
|
const definitions = buildProcessDefinitions(buildProfile(), {
|
|
...processConfig,
|
|
baseEnv: {
|
|
DATABASE_URL: 'postgresql://integration.invalid/sammo',
|
|
VITE_APP_BASE_PATH: '/gateway',
|
|
GATEWAY_ROLE: 'orchestrator',
|
|
args: 'daemon',
|
|
NODE_APP_INSTANCE: '2',
|
|
name: 'sammo:gateway-orchestrator',
|
|
pm_id: '2',
|
|
pm_exec_path: '/srv/controller.js',
|
|
},
|
|
});
|
|
|
|
for (const definition of Object.values(definitions)) {
|
|
expect(definition.env).toMatchObject({ DATABASE_URL: 'postgresql://integration.invalid/sammo' });
|
|
expect(definition.env).not.toHaveProperty('pm_id');
|
|
expect(definition.env).not.toHaveProperty('args');
|
|
expect(definition.env).not.toHaveProperty('pm_exec_path');
|
|
expect(definition.env).not.toHaveProperty('name');
|
|
expect(definition.env).not.toHaveProperty('NODE_APP_INSTANCE');
|
|
expect(definition.env).not.toHaveProperty('GATEWAY_ROLE');
|
|
}
|
|
expect(definitions.frontend.env.VITE_APP_BASE_PATH).toBe('/che');
|
|
});
|
|
|
|
it('applies a dedicated Node heap option only to the turn daemon', () => {
|
|
const definitions = buildProcessDefinitions(buildProfile(), {
|
|
...processConfig,
|
|
baseEnv: {
|
|
NODE_OPTIONS: '--max-old-space-size=1536',
|
|
TURN_DAEMON_NODE_OPTIONS: '--max-old-space-size=3072',
|
|
},
|
|
});
|
|
|
|
expect(definitions.daemon.env.NODE_OPTIONS).toBe('--max-old-space-size=3072');
|
|
expect(definitions.frontend.env.NODE_OPTIONS).toBe('--max-old-space-size=1536');
|
|
expect(definitions.api.env.NODE_OPTIONS).toBe('--max-old-space-size=1536');
|
|
expect(definitions.auction.env.NODE_OPTIONS).toBe('--max-old-space-size=1536');
|
|
expect(definitions.battleSim.env.NODE_OPTIONS).toBe('--max-old-space-size=1536');
|
|
expect(definitions.tournament.env.NODE_OPTIONS).toBe('--max-old-space-size=1536');
|
|
});
|
|
|
|
it('accepts role-specific pool budget overrides without changing sibling roles', () => {
|
|
const definitions = buildProcessDefinitions(buildProfile(), {
|
|
...processConfig,
|
|
baseEnv: {
|
|
GAME_API_POSTGRES_POOL_MAX: '6',
|
|
AUCTION_WORKER_POSTGRES_POOL_MAX: '2',
|
|
},
|
|
});
|
|
|
|
expect(definitions.api.env.POSTGRES_POOL_MAX).toBe('6');
|
|
expect(definitions.daemon.env.POSTGRES_POOL_MAX).toBe('2');
|
|
expect(definitions.auction.env.POSTGRES_POOL_MAX).toBe('2');
|
|
expect(definitions.tournament.env.POSTGRES_POOL_MAX).toBe('1');
|
|
});
|
|
});
|
|
|
|
describe('sanitizeManagedProcessEnv', () => {
|
|
it('keeps application configuration while removing PM2 metadata and role selectors', () => {
|
|
expect(
|
|
sanitizeManagedProcessEnv({
|
|
DATABASE_URL: 'postgresql://integration.invalid/sammo',
|
|
PATH: '/usr/local/bin:/usr/bin',
|
|
GATEWAY_ROLE: 'orchestrator',
|
|
GAME_API_ROLE: 'server',
|
|
args: 'daemon',
|
|
NODE_APP_INSTANCE: '2',
|
|
name: 'sammo:gateway-orchestrator',
|
|
pm_id: '2',
|
|
pm_cwd: '/srv/controller',
|
|
axm_monitor: '{}',
|
|
})
|
|
).toEqual({
|
|
DATABASE_URL: 'postgresql://integration.invalid/sammo',
|
|
PATH: '/usr/local/bin:/usr/bin',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('buildWorkspaceCommands', () => {
|
|
it('installs and builds runtime dependencies before the profile processes', () => {
|
|
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
|
|
const commands = buildWorkspaceCommands(workspaceRoot, true, undefined, '/srv/sammo/controller');
|
|
|
|
expect(commands.map(({ args }) => args)).toEqual([
|
|
['install', '--frozen-lockfile'],
|
|
[
|
|
'exec',
|
|
'turbo',
|
|
'run',
|
|
'build',
|
|
'--filter=@sammo-ts/game-api',
|
|
'--filter=@sammo-ts/gateway-api',
|
|
'--cache-dir=/srv/sammo/controller/.turbo/release-cache',
|
|
'--concurrency=1',
|
|
'--ui=stream',
|
|
'--output-logs=new-only',
|
|
],
|
|
]);
|
|
expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true);
|
|
});
|
|
|
|
it('deploys the game schema migration after building the selected workspace', () => {
|
|
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
|
|
const databaseUrl = 'postgresql://integration.invalid/sammo?schema=che';
|
|
const command = buildProfileMigrationCommand(workspaceRoot, databaseUrl, { NODE_ENV: 'production' });
|
|
|
|
expect(command).toEqual({
|
|
command: 'pnpm',
|
|
args: ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
|
cwd: workspaceRoot,
|
|
env: {
|
|
NODE_ENV: 'production',
|
|
DATABASE_URL: databaseUrl,
|
|
},
|
|
});
|
|
});
|
|
});
|