fix(runtime): bound PM2 process restarts and identity
This commit is contained in:
@@ -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<string, string> };
|
||||
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
} => {
|
||||
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 {
|
||||
|
||||
@@ -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<string, string> => {
|
||||
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 = (
|
||||
|
||||
@@ -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 <T>(handler: (pm2: Pm2Module) => Promise<T>): Promise<T> =
|
||||
}
|
||||
};
|
||||
|
||||
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<ManagedProcessInfo[]> {
|
||||
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<void>((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();
|
||||
}
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
delete(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
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<string, string>): Record<string, string> =>
|
||||
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<string, string>): Record<string, string> => {
|
||||
const sanitized = sanitizePm2IdentityEnv(env);
|
||||
delete sanitized.GATEWAY_ROLE;
|
||||
delete sanitized.GAME_API_ROLE;
|
||||
delete sanitized.GAME_ENGINE_ROLE;
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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<void>((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error('Release controller did not become online before the timeout.');
|
||||
|
||||
@@ -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']]);
|
||||
|
||||
@@ -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 <full-sha>
|
||||
배포 후:
|
||||
|
||||
- 작업이 `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와 핵심 게임 상태가 유지됐는지
|
||||
|
||||
Reference in New Issue
Block a user