diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index e7ffe19..e94f66c 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -13,7 +13,7 @@ import { import { isRecord } from '@sammo-ts/common'; import type { BuildCommand, BuildRunner } from './buildRunner.js'; -import type { ProcessManager } from './processManager.js'; +import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js'; import type { GatewayClaimedProfileUpdate, GatewayOperationRecord, @@ -368,7 +368,7 @@ export const buildProcessDefinitions = ( battleSim: { name: string; script: string; cwd: string; env: Record }; tournament: { name: string; script: string; cwd: string; env: Record }; } => { - const baseEnv = { ...(config.baseEnv ?? {}) }; + const baseEnv = sanitizeManagedProcessEnv(config.baseEnv ?? {}); const frontendName = buildProcessName(profile.profileName, 'frontend'); const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); @@ -1453,11 +1453,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { buildWorkspace: workspace.root, }; const started = await this.startProfile(builtProfile, assertLease); - if (!started) { - await updateClaimedProfile({ status: 'STOPPED', lastError: 'Failed to start profile processes.' }, () => + const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease)); + if (!ready) { + if (started) { + await this.stopProfile(builtProfile, assertLease); + } + const detail = started + ? 'reset completed but profile processes failed readiness' + : 'reset completed but profile processes failed to start'; + await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () => this.repository.updateStatus(profile.profileName, 'STOPPED') ); - return { status: 'FAILED', detail: 'reset completed but profile processes failed to start' }; + return { status: 'FAILED', detail }; } await updateClaimedProfile({ lastError: null }, async () => { await this.repository.updateLastError(profile.profileName, null); @@ -1723,6 +1730,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ]; const attemptedNames: string[] = []; try { + const expectedNames = new Set(orderedDefinitions.map((definition) => definition.name)); + const existingNames = new Set( + (await this.processManager.list()) + .filter((process) => expectedNames.has(process.name)) + .map((process) => process.name) + ); + for (const name of existingNames) { + await assertLease?.(); + await this.processManager.delete(name); + await assertLease?.(); + } for (const definition of orderedDefinitions) { await assertLease?.(); attemptedNames.push(definition.name); @@ -1774,12 +1792,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { this.fetchImpl(frontendUrl), this.processManager.list(), ]); - const online = new Set( - processes - .filter((process) => process.status.toLowerCase() === 'online') - .map((process) => process.name) + const expectedProcesses = processes.filter((process) => expectedNames.includes(process.name)); + const safeProcesses = expectedProcesses.filter( + (process) => process.status.toLowerCase() === 'online' && (process.restartCount ?? 0) === 0 ); - if (api.ok && frontend.ok && expectedNames.every((name) => online.has(name))) { + if ( + api.ok && + frontend.ok && + expectedProcesses.length === expectedNames.length && + safeProcesses.length === expectedNames.length && + new Set(safeProcesses.map((process) => process.name)).size === expectedNames.length + ) { return true; } } catch { diff --git a/app/gateway-api/src/orchestrator/orchestratorFactory.ts b/app/gateway-api/src/orchestrator/orchestratorFactory.ts index 2613e98..c6a8f29 100644 --- a/app/gateway-api/src/orchestrator/orchestratorFactory.ts +++ b/app/gateway-api/src/orchestrator/orchestratorFactory.ts @@ -4,13 +4,13 @@ import type { GatewayOrchestratorConfig } from '../config.js'; import { createGatewayProfileRepository } from './profileRepository.js'; import { GatewayOrchestrator } from './gatewayOrchestrator.js'; import { Pm2ProcessManager } from './pm2ProcessManager.js'; +import { sanitizeManagedProcessEnv } from './processManager.js'; import { PnpmBuildRunner } from './buildRunner.js'; import { resolveWorkspaceRoot } from './workspaceRoot.js'; import { GitWorkspaceManager } from './workspaceManager.js'; export const buildEnvMap = (env: NodeJS.ProcessEnv): Record => { - const entries = Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string'); - return Object.fromEntries(entries); + return sanitizeManagedProcessEnv(env); }; export const createGatewayOrchestrator = ( diff --git a/app/gateway-api/src/orchestrator/pm2ProcessManager.ts b/app/gateway-api/src/orchestrator/pm2ProcessManager.ts index acd819e..f94edcd 100644 --- a/app/gateway-api/src/orchestrator/pm2ProcessManager.ts +++ b/app/gateway-api/src/orchestrator/pm2ProcessManager.ts @@ -1,7 +1,12 @@ import { createRequire } from 'node:module'; import type * as Pm2 from 'pm2'; -import type { ProcessManager, ManagedProcessInfo, ProcessDefinition } from './processManager.js'; +import { + sanitizePm2IdentityEnv, + type ProcessManager, + type ManagedProcessInfo, + type ProcessDefinition, +} from './processManager.js'; type Pm2Module = typeof Pm2; @@ -27,6 +32,20 @@ const withPm2 = async (handler: (pm2: Pm2Module) => Promise): Promise = } }; +export const buildPm2StartOptions = (definition: ProcessDefinition) => ({ + name: definition.name, + script: definition.script, + cwd: definition.cwd, + args: definition.args, + env: sanitizePm2IdentityEnv(definition.env ?? {}), + autorestart: true, + max_restarts: 5, + min_uptime: 10_000, + restart_delay: 2_000, + kill_timeout: 15_000, + time: true, +}); + export class Pm2ProcessManager implements ProcessManager { async list(): Promise { return withPm2( @@ -44,6 +63,7 @@ export class Pm2ProcessManager implements ProcessManager { pid: item.pid ?? undefined, cwd: item.pm2_env?.pm_cwd ?? undefined, script: item.pm2_env?.pm_exec_path ?? undefined, + restartCount: item.pm2_env?.restart_time ?? 0, })) ?? []; resolve(normalized); }); @@ -55,24 +75,26 @@ export class Pm2ProcessManager implements ProcessManager { await withPm2( (pm2) => new Promise((resolve, reject) => { - pm2.start( - { - name: definition.name, - script: definition.script, - cwd: definition.cwd, - args: definition.args, - env: definition.env, - autorestart: true, - time: true, - }, - (error) => { - if (error) { - reject(error); - return; - } - resolve(); + pm2.list((listError, list) => { + if (listError) { + reject(listError); + return; } - ); + if (list?.some((item) => item.name === definition.name)) { + reject(new Error(`PM2 process name already exists: ${definition.name}`)); + return; + } + pm2.start( + buildPm2StartOptions(definition), + (error) => { + if (error) { + reject(error); + return; + } + resolve(); + } + ); + }); }) ); } diff --git a/app/gateway-api/src/orchestrator/processManager.ts b/app/gateway-api/src/orchestrator/processManager.ts index 4dfc10f..ba24364 100644 --- a/app/gateway-api/src/orchestrator/processManager.ts +++ b/app/gateway-api/src/orchestrator/processManager.ts @@ -4,6 +4,7 @@ export interface ManagedProcessInfo { pid?: number; cwd?: string; script?: string; + restartCount?: number; } export interface ProcessDefinition { @@ -20,3 +21,52 @@ export interface ProcessManager { stop(name: string): Promise; delete(name: string): Promise; } + +const PM2_INTERNAL_ENV_KEYS = new Set([ + 'NODE_APP_INSTANCE', + 'autorestart', + 'autostart', + 'created_at', + 'exec_interpreter', + 'exec_mode', + 'exit_code', + 'instance_var', + 'instances', + 'merge_logs', + 'name', + 'namespace', + 'node_args', + 'node_version', + 'pm_cwd', + 'pm_err_log_path', + 'pm_exec_path', + 'pm_id', + 'pm_out_log_path', + 'pm_pid_path', + 'pm_uptime', + 'restart_time', + 'status', + 'unstable_restarts', + 'version', + 'vizion', + 'watch', +]); + +export const sanitizePm2IdentityEnv = (env: NodeJS.ProcessEnv | Record): Record => + Object.fromEntries( + Object.entries(env).filter( + (entry): entry is [string, string] => + typeof entry[1] === 'string' && + !entry[0].startsWith('axm_') && + !entry[0].startsWith('pm_') && + !PM2_INTERNAL_ENV_KEYS.has(entry[0]) + ) + ); + +export const sanitizeManagedProcessEnv = (env: NodeJS.ProcessEnv | Record): Record => { + const sanitized = sanitizePm2IdentityEnv(env); + delete sanitized.GATEWAY_ROLE; + delete sanitized.GAME_API_ROLE; + delete sanitized.GAME_ENGINE_ROLE; + return sanitized; +}; diff --git a/app/gateway-api/test/orchestratorFactory.test.ts b/app/gateway-api/test/orchestratorFactory.test.ts new file mode 100644 index 0000000..42be056 --- /dev/null +++ b/app/gateway-api/test/orchestratorFactory.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { buildEnvMap } from '../src/orchestrator/orchestratorFactory.js'; + +describe('buildEnvMap', () => { + it('removes PM2 child identity before creating Git and profile process environments', () => { + expect( + buildEnvMap({ + DATABASE_URL: 'postgresql://integration.invalid/sammo', + GATEWAY_ROLE: 'orchestrator', + pm_id: '2', + pm_exec_path: '/workspace/core2026/app/gateway-api/dist/index.js', + name: 'sammo:gateway-orchestrator', + axm_dynamic: '{}', + }) + ).toEqual({ DATABASE_URL: 'postgresql://integration.invalid/sammo' }); + }); +}); diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index ecfe3a3..816c3e6 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -174,6 +174,14 @@ describe('GatewayOrchestrator first-class operations', () => { 'sammo:che:2:battle-sim-worker', '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', + 'sammo:che:2:battle-sim-worker', + 'sammo:che:2:tournament-worker', + ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); @@ -236,6 +244,12 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.completions).toEqual(['FAILED']); expect(harness.deleted).toEqual([ + 'sammo:che:2:game-frontend', + 'sammo:che:2:game-api', + 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', + 'sammo:che:2:battle-sim-worker', + 'sammo:che:2:tournament-worker', 'sammo:che:2:turn-daemon', 'sammo:che:2:game-api', 'sammo:che:2:game-frontend', diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index c08d881..384a579 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -8,6 +8,7 @@ import { 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 => ({ @@ -116,6 +117,9 @@ describe('buildProcessDefinitions', () => { 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'), @@ -167,6 +171,50 @@ describe('buildProcessDefinitions', () => { 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('does not forward PM2 identity or parent runtime roles to profile processes', () => { + const definitions = buildProcessDefinitions(buildProfile(), { + ...processConfig, + baseEnv: { + DATABASE_URL: 'postgresql://integration.invalid/sammo', + GATEWAY_ROLE: 'orchestrator', + 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('pm_exec_path'); + expect(definition.env).not.toHaveProperty('name'); + expect(definition.env).not.toHaveProperty('NODE_APP_INSTANCE'); + expect(definition.env).not.toHaveProperty('GATEWAY_ROLE'); + } + }); +}); + +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', + 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', () => { diff --git a/app/gateway-api/test/pm2ProcessManager.test.ts b/app/gateway-api/test/pm2ProcessManager.test.ts new file mode 100644 index 0000000..7d270c5 --- /dev/null +++ b/app/gateway-api/test/pm2ProcessManager.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPm2StartOptions } from '../src/orchestrator/pm2ProcessManager.js'; + +describe('buildPm2StartOptions', () => { + it('enforces bounded restart policy and strips inherited PM2 identity at the PM2 boundary', () => { + const options = buildPm2StartOptions({ + name: 'sammo:che:2:game-api', + script: '/srv/sammo/app/game-api/dist/index.js', + cwd: '/srv/sammo/app/game-api', + env: { + DATABASE_URL: 'postgresql://integration.invalid/sammo', + GAME_API_ROLE: 'server', + pm_id: '2', + pm_exec_path: '/srv/sammo/app/gateway-api/dist/index.js', + name: 'sammo:gateway-orchestrator', + NODE_APP_INSTANCE: '2', + }, + }); + + expect(options).toMatchObject({ + name: 'sammo:che:2:game-api', + autorestart: true, + max_restarts: 5, + min_uptime: 10_000, + restart_delay: 2_000, + kill_timeout: 15_000, + env: { + DATABASE_URL: 'postgresql://integration.invalid/sammo', + GAME_API_ROLE: 'server', + }, + }); + expect(options.env).not.toHaveProperty('pm_id'); + expect(options.env).not.toHaveProperty('pm_exec_path'); + expect(options.env).not.toHaveProperty('name'); + expect(options.env).not.toHaveProperty('NODE_APP_INSTANCE'); + expect(options.env).toHaveProperty('GAME_API_ROLE', 'server'); + }); +}); diff --git a/app/release-controller/src/config.ts b/app/release-controller/src/config.ts index c94b6a0..6e6e81d 100644 --- a/app/release-controller/src/config.ts +++ b/app/release-controller/src/config.ts @@ -1,5 +1,7 @@ import path from 'node:path'; +import { sanitizeManagedProcessEnv } from '@sammo-ts/gateway-api'; + const parsePositiveInt = (value: string | undefined, fallback: number, name: string): number => { if (!value) return fallback; const parsed = Number(value); @@ -49,8 +51,6 @@ export const resolveReleaseControllerConfig = (env: NodeJS.ProcessEnv = process. 60000, 'RELEASE_CONTROLLER_READINESS_TIMEOUT_MS' ), - baseEnv: Object.fromEntries( - Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string') - ), + baseEnv: sanitizeManagedProcessEnv(env), }; }; diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index b805f6b..4ececf8 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -12,6 +12,7 @@ import { type ProcessDefinition, type ProcessManager, readReleaseManifest, + sanitizeManagedProcessEnv, } from '@sammo-ts/gateway-api'; import type { ReleaseControllerConfig } from './config.js'; @@ -26,7 +27,7 @@ export const buildGatewayReleaseCommands = ( config: ReleaseControllerConfig ): BuildCommand[] => { const env = { - ...config.baseEnv, + ...sanitizeManagedProcessEnv(config.baseEnv), VITE_APP_BASE_PATH: config.gatewayBasePath, VITE_GATEWAY_API_URL: `${config.gatewayBasePath}/api/trpc`, VITE_GAME_API_URL_TEMPLATE: '/{profile}/api/trpc', @@ -49,7 +50,7 @@ export const buildGatewayMigrationCommand = (workspaceRoot: string, config: Rele args: ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], cwd: workspaceRoot, env: { - ...config.baseEnv, + ...sanitizeManagedProcessEnv(config.baseEnv), GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl, }, }); @@ -63,7 +64,7 @@ export const buildGatewayProcessDefinitions = ( const apiScript = path.join(apiCwd, 'dist', 'index.js'); const frontendScript = path.join(frontendCwd, 'node_modules', 'vite', 'bin', 'vite.js'); const env = { - ...config.baseEnv, + ...sanitizeManagedProcessEnv(config.baseEnv), GATEWAY_API_HOST: '0.0.0.0', GATEWAY_API_PORT: String(config.gatewayApiPort), GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl, @@ -231,12 +232,19 @@ export class GatewayReleaseController { try { const [api, frontend] = await Promise.all([this.fetchImpl(apiUrl), this.fetchImpl(frontendUrl)]); const processes = await this.processManager.list(); - const online = new Set( - processes - .filter((process) => process.status.toLowerCase() === 'online') - .map((process) => process.name) + const expected = processes.filter((process) => PROCESS_NAMES.includes(process.name as (typeof PROCESS_NAMES)[number])); + const safe = expected.filter( + (process) => process.status.toLowerCase() === 'online' && (process.restartCount ?? 0) === 0 ); - if (api.ok && frontend.ok && PROCESS_NAMES.every((name) => online.has(name))) return; + if ( + api.ok && + frontend.ok && + expected.length === PROCESS_NAMES.length && + safe.length === PROCESS_NAMES.length && + new Set(safe.map((process) => process.name)).size === PROCESS_NAMES.length + ) { + return; + } } catch { // Retry until the bounded deadline. } diff --git a/app/release-controller/src/selfUpgrade.ts b/app/release-controller/src/selfUpgrade.ts index 8f8d1a9..76aae28 100644 --- a/app/release-controller/src/selfUpgrade.ts +++ b/app/release-controller/src/selfUpgrade.ts @@ -8,6 +8,7 @@ import { type ProcessDefinition, type ProcessManager, readReleaseManifest, + sanitizeManagedProcessEnv, } from '@sammo-ts/gateway-api'; import type { ReleaseControllerConfig } from './config.js'; @@ -20,7 +21,7 @@ export const buildReleaseControllerCommands = ( needsInstall: boolean, config: ReleaseControllerConfig ): BuildCommand[] => { - const env = config.baseEnv; + const env = sanitizeManagedProcessEnv(config.baseEnv); return [ ...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []), { command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env }, @@ -42,7 +43,7 @@ export const buildReleaseControllerDefinition = ( cwd: path.join(workspaceRoot, 'app', 'release-controller'), args: ['daemon'], env: { - ...config.baseEnv, + ...sanitizeManagedProcessEnv(config.baseEnv), GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl, GATEWAY_DB_SCHEMA: config.gatewayDbSchema, RELEASE_CONTROLLER_WORKSPACE_ROOT: workspaceRoot, @@ -89,10 +90,16 @@ export const upgradeReleaseController = async (options: { await options.processManager.start(buildReleaseControllerDefinition(workspace.root, options.config)); const deadline = Date.now() + (options.readinessTimeoutMs ?? options.config.readinessTimeoutMs); while (Date.now() < deadline) { - const active = (await options.processManager.list()).find( - (process) => process.name === CONTROLLER_PROCESS_NAME && process.status.toLowerCase() === 'online' + const matching = (await options.processManager.list()).filter( + (process) => process.name === CONTROLLER_PROCESS_NAME ); - if (active) return { commitSha, workspace: workspace.root }; + if ( + matching.length === 1 && + matching[0]?.status.toLowerCase() === 'online' && + (matching[0]?.restartCount ?? 0) === 0 + ) { + return { commitSha, workspace: workspace.root }; + } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error('Release controller did not become online before the timeout.'); diff --git a/app/release-controller/test/releaseController.test.ts b/app/release-controller/test/releaseController.test.ts index 119aa3e..a87f9a7 100644 --- a/app/release-controller/test/releaseController.test.ts +++ b/app/release-controller/test/releaseController.test.ts @@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { resolveReleaseControllerConfig, type ReleaseControllerConfig } from '../src/config.js'; import { buildGatewayProcessDefinitions, GatewayReleaseController } from '../src/releaseController.js'; -import { upgradeReleaseController } from '../src/selfUpgrade.js'; +import { buildReleaseControllerDefinition, upgradeReleaseController } from '../src/selfUpgrade.js'; const SHA = '1111111111111111111111111111111111111111'; const OLD_SHA = '2222222222222222222222222222222222222222'; @@ -124,6 +124,26 @@ it('runs Gateway preview from the frontend workspace dependency', () => { ); }); +it('does not forward release-controller PM2 identity to Gateway processes', () => { + const definitions = buildGatewayProcessDefinitions('/srv/sammo/release', { + ...config, + baseEnv: { + DATABASE_URL: 'postgresql://integration.invalid/sammo', + GATEWAY_ROLE: 'orchestrator', + name: 'sammo:release-controller', + pm_id: '3', + pm_exec_path: '/srv/release-controller.js', + }, + }); + + for (const definition of definitions) { + expect(definition.env).toMatchObject({ DATABASE_URL: 'postgresql://integration.invalid/sammo' }); + expect(definition.env).not.toHaveProperty('pm_id'); + expect(definition.env).not.toHaveProperty('pm_exec_path'); + expect(definition.env).not.toHaveProperty('name'); + } +}); + describe('GatewayReleaseController', () => { it('builds, migrates, switches all gateway roles, verifies readiness, and publishes atomically', async () => { const workspace = await createReleaseWorkspace(); @@ -225,9 +245,44 @@ describe('resolveReleaseControllerConfig', () => { expect(new URL(resolved.gatewayDatabaseUrl).searchParams.get('schema')).toBe('gateway_release'); }); + + it('removes PM2 metadata from the inherited controller environment', () => { + const resolved = resolveReleaseControllerConfig({ + GATEWAY_DATABASE_URL: 'postgresql://user:pass@127.0.0.1:5432/sammo', + RELEASE_CONTROLLER_WORKSPACE_ROOT: '/srv/sammo/controller', + pm_id: '3', + name: 'sammo:release-controller', + axm_monitor: '{}', + }); + + expect(resolved.baseEnv).toMatchObject({ + GATEWAY_DATABASE_URL: 'postgresql://user:pass@127.0.0.1:5432/sammo', + RELEASE_CONTROLLER_WORKSPACE_ROOT: '/srv/sammo/controller', + }); + expect(resolved.baseEnv).not.toHaveProperty('pm_id'); + expect(resolved.baseEnv).not.toHaveProperty('name'); + expect(resolved.baseEnv).not.toHaveProperty('axm_monitor'); + }); }); describe('upgradeReleaseController', () => { + it('does not forward the old controller PM2 identity to its replacement', () => { + const definition = buildReleaseControllerDefinition('/srv/sammo/release', { + ...config, + baseEnv: { + DATABASE_URL: 'postgresql://integration.invalid/sammo', + name: 'sammo:release-controller', + pm_id: '3', + pm_exec_path: '/srv/old-controller.js', + }, + }); + + expect(definition.env).toMatchObject({ DATABASE_URL: 'postgresql://integration.invalid/sammo' }); + expect(definition.env).not.toHaveProperty('pm_id'); + expect(definition.env).not.toHaveProperty('pm_exec_path'); + expect(definition.env).not.toHaveProperty('name'); + }); + 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']]); diff --git a/docs/release-operations.md b/docs/release-operations.md index 6c16e6a..42ba5c4 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -32,6 +32,12 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필 `GAME_API_ROLE=server|*-worker`, turn daemon에 `GAME_ENGINE_ROLE=turn-daemon`을 명시합니다. library import나 PM2 wrapper의 argv만으로 실행 역할을 추론하지 않습니다. +- PM2 child에서 상속된 `pm_id`, `name`, `pm_exec_path`, `NODE_APP_INSTANCE`와 + `axm_*` 메타데이터는 새 definition에 전달하지 않습니다. 동일 process 이름은 + 시작 전에 제거하며 PM2 start는 남은 동일 이름을 거부합니다. +- Runtime process는 10초 이전의 불안정 종료에 대해 최대 5회, 2초 간격으로만 + 자동 재시작합니다. Readiness는 예상 process 수가 정확하고 모든 restart count가 + 0일 때만 성공합니다. - migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이 유지됩니다. @@ -61,7 +67,9 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필 사용합니다. Source와 scenario를 먼저 불러온 뒤 turn 간격, 가오픈·정식 오픈, NPC와 자동 진행 설정을 확인하고 요청해 주세요. -이 모드는 build와 migration 후 scenario seeder를 실행합니다. 현 시즌의 장수, +이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다. +빈 profile schema도 migration을 먼저 적용하므로 최초 `world_state` 조회가 +table 부재로 실패하지 않습니다. 현 시즌의 장수, 국가, 도시, command queue와 시장·경매 등은 새 scenario 기준으로 교체됩니다. 다음 장기보존 자료는 reset 범위 밖에 있으므로 기수를 넘어 유지됩니다. @@ -76,7 +84,7 @@ scenario 설정을 확인한 뒤 실행해 주세요. ### Profile 실패와 재시도 Build는 현재 runtime을 멈추기 전에 수행합니다. Migration 또는 새 process -readiness가 실패하면 작업은 `FAILED`가 되며 orchestrator는 이전 worktree의 +readiness가 실패하면 작업은 `FAILED`가 됩니다. DB 유지 배포는 이전 worktree의 process 복구를 시도합니다. 관리자 화면의 오류와 PM2 process 상태를 확인한 뒤 원인을 해결하고 실패한 작업을 재시도해 주세요. 재시도는 처음 고정된 commit을 사용합니다. @@ -159,7 +167,8 @@ pnpm --filter @sammo-ts/release-controller self-upgrade -- COMMIT 배포 후: - 작업이 `SUCCEEDED`이고 고정 commit이 요청한 commit과 같은지 확인합니다. -- PM2의 cwd와 script가 게시된 worktree를 가리키는지 확인합니다. +- PM2 process 이름별 항목이 정확히 하나이고 restart count가 0이며, cwd와 + script가 게시된 worktree를 가리키는지 확인합니다. - `/gateway/` 또는 대상 profile prefix에 직접 접속하고 새로고침합니다. - API health, tRPC, SSE와 정적 자산 경로를 확인합니다. - DB 유지 배포에서는 현재 season/scenario와 핵심 게임 상태가 유지됐는지