import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import type { BuildRunner, GatewayReleaseOperationRecord, GatewayReleaseRepository, GatewayReleaseStateRecord, GitWorkspaceManager, ProcessDefinition, ProcessManager, } from '@sammo-ts/gateway-api'; import { afterEach, describe, expect, it } from 'vitest'; import { resolveReleaseControllerConfig, type ReleaseControllerConfig } from '../src/config.js'; import { GatewayReleaseController } from '../src/releaseController.js'; import { upgradeReleaseController } from '../src/selfUpgrade.js'; const SHA = '1111111111111111111111111111111111111111'; const OLD_SHA = '2222222222222222222222222222222222222222'; const temporaryDirectories: string[] = []; const createReleaseWorkspace = async (): Promise => { const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-release-controller-')); temporaryDirectories.push(workspace); const gatewayHead = '20260801000000_gateway'; const gameHead = '20260801000000_game'; 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', 'release-controller'], }) ); return workspace; }; const operation: GatewayReleaseOperationRecord = { id: '11111111-1111-4111-8111-111111111111', type: 'DEPLOY', status: 'RUNNING', sourceMode: 'COMMIT', sourceRef: SHA, payload: {}, requestedBy: 'admin', attempts: 1, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', }; const state: GatewayReleaseStateRecord = { id: 'gateway', activeCommitSha: OLD_SHA, activeWorkspace: '/srv/sammo/old', updatedAt: '2026-08-01T00:00:00.000Z', }; const config: ReleaseControllerConfig = { workspaceRoot: '/srv/sammo/controller', worktreeRoot: '/srv/sammo/releases', gatewayDatabaseUrl: 'postgresql://integration.invalid/sammo?schema=gateway', gatewayDbSchema: 'gateway', gatewayApiPort: 15001, gatewayFrontendPort: 15000, gatewayBasePath: '/gateway', pollIntervalMs: 5, readinessTimeoutMs: 10, baseEnv: {}, }; afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true }))); }); const createRepository = () => { let next: GatewayReleaseOperationRecord | null = operation; const completions: string[] = []; const published: Array<{ commitSha: string; workspace: string; previousCommitSha?: string }> = []; const errors: string[] = []; const repository: GatewayReleaseRepository = { getState: async () => state, listOperations: async () => [], getOperation: async () => operation, createOperation: async () => operation, claimNextOperation: async () => { const claimed = next; next = null; return claimed; }, renewOperationLease: async () => true, pinOperationResolvedCommit: async () => true, completeOperation: async (_id, statusValue) => { completions.push(statusValue); return { ...operation, status: statusValue }; }, publishRelease: async (_id, _owner, release) => { published.push(release); return { ...state, activeCommitSha: release.commitSha, activeWorkspace: release.workspace }; }, recordStateError: async (detail) => { errors.push(detail); }, cancelOperation: async () => false, retryOperation: async () => null, }; return { repository, completions, published, errors }; }; const gatewayNames = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator']; describe('GatewayReleaseController', () => { it('builds, migrates, switches all gateway roles, verifies readiness, and publishes atomically', async () => { const workspace = await createReleaseWorkspace(); const harness = createRepository(); const commandGroups: string[][] = []; const running = new Map(gatewayNames.map((name) => [name, '/srv/sammo/old'])); const processManager: ProcessManager = { list: async () => [...running].map(([name, cwd]) => ({ name, cwd, status: 'online', script: path.join(cwd, 'dist.js') })), start: async (definition) => { running.set(definition.name, definition.cwd); }, stop: async () => {}, delete: async (name) => { running.delete(name); }, }; const buildRunner: BuildRunner = { run: async (commands) => { commandGroups.push(commands.map((command) => command.args.join(' '))); return { ok: true, exitCode: 0, output: '' }; }, }; const workspaceManager = { resolveCommit: async () => SHA, prepare: async () => ({ root: workspace, created: true, needsInstall: true }), } as unknown as GitWorkspaceManager; const controller = new GatewayReleaseController( harness.repository, workspaceManager, buildRunner, processManager, config, () => new Date('2026-08-01T00:00:00.000Z'), async () => new Response('', { status: 200 }) ); await controller.runOnce(); expect(commandGroups).toHaveLength(2); expect(commandGroups[0]?.[0]).toBe('install --frozen-lockfile'); expect(commandGroups[1]).toEqual(['--filter @sammo-ts/infra prisma:migrate:deploy:gateway']); expect([...running.keys()].sort()).toEqual([...gatewayNames].sort()); expect(harness.published).toEqual([ { commitSha: SHA, workspace, previousCommitSha: OLD_SHA, previousWorkspace: '/srv/sammo/old' }, ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); it('restores the previous gateway processes when the new process set cannot start', async () => { const workspace = await createReleaseWorkspace(); const harness = createRepository(); const started: ProcessDefinition[] = []; const running = new Map(gatewayNames.map((name) => [name, '/srv/sammo/old'])); const processManager: ProcessManager = { list: async () => [...running].map(([name, cwd]) => ({ name, cwd, status: 'online' })), start: async (definition) => { if (definition.cwd.startsWith(workspace) && definition.name === 'sammo:gateway-api') { throw new Error('new gateway failed'); } started.push(definition); running.set(definition.name, definition.cwd); }, stop: async () => {}, delete: async (name) => { running.delete(name); }, }; const workspaceManager = { resolveCommit: async () => SHA, prepare: async () => ({ root: workspace, created: true, needsInstall: false }), } as unknown as GitWorkspaceManager; const controller = new GatewayReleaseController( harness.repository, workspaceManager, { run: async () => ({ ok: true, exitCode: 0, output: '' }) }, processManager, config, () => new Date('2026-08-01T00:00:00.000Z'), async () => new Response('', { status: 200 }) ); await controller.runOnce(); expect(started.filter((definition) => definition.cwd.startsWith('/srv/sammo/old'))).toHaveLength(3); expect(harness.published).toEqual([]); expect(harness.completions).toEqual(['FAILED']); expect(harness.errors.at(-1)).toContain('new gateway failed'); }); }); describe('resolveReleaseControllerConfig', () => { it('applies the configured gateway schema to the controller database URL', () => { const resolved = resolveReleaseControllerConfig({ GATEWAY_DATABASE_URL: 'postgresql://user:pass@127.0.0.1:5432/sammo?schema=wrong', GATEWAY_DB_SCHEMA: 'gateway_release', RELEASE_CONTROLLER_WORKSPACE_ROOT: '/srv/sammo/controller', }); expect(new URL(resolved.gatewayDatabaseUrl).searchParams.get('schema')).toBe('gateway_release'); }); }); describe('upgradeReleaseController', () => { it('switches the controller daemon from a separately invoked CLI process', async () => { const workspace = await createReleaseWorkspace(); const running = new Map([['sammo:release-controller', '/srv/sammo/old/app/release-controller']]); const starts: ProcessDefinition[] = []; const processManager: ProcessManager = { list: async () => [...running].map(([name, cwd]) => ({ name, cwd, status: 'online' })), start: async (definition) => { starts.push(definition); running.set(definition.name, definition.cwd); }, stop: async () => {}, delete: async (name) => { running.delete(name); }, }; const workspaceManager = { resolveCommit: async () => SHA, prepare: async () => ({ root: workspace, created: true, needsInstall: true }), } as unknown as GitWorkspaceManager; const commandGroups: string[][] = []; await expect( upgradeReleaseController({ sourceMode: 'COMMIT', sourceRef: SHA, workspaceManager, buildRunner: { run: async (commands) => { commandGroups.push(commands.map((command) => command.args.join(' '))); return { ok: true, exitCode: 0, output: '' }; }, }, processManager, config, readinessTimeoutMs: 10, }) ).resolves.toEqual({ commitSha: SHA, workspace }); expect(commandGroups).toHaveLength(2); expect(commandGroups[0]?.at(-1)).toBe('--filter @sammo-ts/release-controller build'); expect(starts.at(-1)).toMatchObject({ name: 'sammo:release-controller', cwd: path.join(workspace, 'app', 'release-controller'), args: ['daemon'], }); }); it('restores the old controller definition when the new daemon cannot start', async () => { const workspace = await createReleaseWorkspace(); const starts: ProcessDefinition[] = []; const running = new Map([['sammo:release-controller', '/srv/sammo/old/app/release-controller']]); const processManager: ProcessManager = { list: async () => [...running].map(([name, cwd]) => ({ name, cwd, status: 'online' })), start: async (definition) => { if (definition.cwd.startsWith(workspace)) throw new Error('new controller failed'); starts.push(definition); running.set(definition.name, definition.cwd); }, stop: async () => {}, delete: async (name) => { running.delete(name); }, }; const workspaceManager = { resolveCommit: async () => SHA, prepare: async () => ({ root: workspace, created: true, needsInstall: false }), } as unknown as GitWorkspaceManager; await expect( upgradeReleaseController({ sourceMode: 'COMMIT', sourceRef: SHA, workspaceManager, buildRunner: { run: async () => ({ ok: true, exitCode: 0, output: '' }) }, processManager, config, readinessTimeoutMs: 10, }) ).rejects.toThrow('new controller failed'); expect(starts.at(-1)?.cwd).toBe('/srv/sammo/old/app/release-controller'); }); });