feat: add durable release controller and admin deploy modes

This commit is contained in:
2026-08-01 03:27:38 +00:00
parent 395b60cdbe
commit 7b466de0a6
37 changed files with 2778 additions and 35 deletions
+56
View File
@@ -0,0 +1,56 @@
import path from 'node:path';
const parsePositiveInt = (value: string | undefined, fallback: number, name: string): number => {
if (!value) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer.`);
return parsed;
};
const applySchema = (databaseUrl: string, schema: string): string => {
const parsed = new URL(databaseUrl);
parsed.searchParams.set('schema', schema);
return parsed.toString();
};
export interface ReleaseControllerConfig {
workspaceRoot: string;
worktreeRoot: string;
gatewayDatabaseUrl: string;
gatewayDbSchema: string;
gatewayApiPort: number;
gatewayFrontendPort: number;
gatewayBasePath: string;
pollIntervalMs: number;
readinessTimeoutMs: number;
baseEnv: Record<string, string>;
}
export const resolveReleaseControllerConfig = (env: NodeJS.ProcessEnv = process.env): ReleaseControllerConfig => {
const rawGatewayDatabaseUrl = env.GATEWAY_DATABASE_URL ?? env.DATABASE_URL ?? '';
if (!rawGatewayDatabaseUrl) {
throw new Error('GATEWAY_DATABASE_URL or DATABASE_URL is required.');
}
const workspaceRoot = path.resolve(env.RELEASE_CONTROLLER_WORKSPACE_ROOT ?? process.cwd());
const gatewayDbSchema = env.GATEWAY_DB_SCHEMA?.trim() || 'public';
return {
workspaceRoot,
worktreeRoot: path.resolve(
env.RELEASE_CONTROLLER_WORKTREE_ROOT ?? path.join(workspaceRoot, '.release-worktrees')
),
gatewayDatabaseUrl: applySchema(rawGatewayDatabaseUrl, gatewayDbSchema),
gatewayDbSchema,
gatewayApiPort: parsePositiveInt(env.GATEWAY_API_PORT, 15001, 'GATEWAY_API_PORT'),
gatewayFrontendPort: parsePositiveInt(env.GATEWAY_FRONTEND_PORT, 15000, 'GATEWAY_FRONTEND_PORT'),
gatewayBasePath: env.GATEWAY_BASE_PATH?.trim() || '/gateway',
pollIntervalMs: parsePositiveInt(env.RELEASE_CONTROLLER_POLL_MS, 5000, 'RELEASE_CONTROLLER_POLL_MS'),
readinessTimeoutMs: parsePositiveInt(
env.RELEASE_CONTROLLER_READINESS_TIMEOUT_MS,
60000,
'RELEASE_CONTROLLER_READINESS_TIMEOUT_MS'
),
baseEnv: Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
),
};
};
+83
View File
@@ -0,0 +1,83 @@
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import {
createGatewayReleaseRepository,
GitWorkspaceManager,
Pm2ProcessManager,
PnpmBuildRunner,
} from '@sammo-ts/gateway-api';
import { resolveReleaseControllerConfig } from './config.js';
import { GatewayReleaseController } from './releaseController.js';
import { upgradeReleaseController } from './selfUpgrade.js';
export * from './config.js';
export * from './releaseController.js';
export * from './selfUpgrade.js';
const main = async (): Promise<void> => {
const config = resolveReleaseControllerConfig();
const postgres = createGatewayPostgresConnector({ url: config.gatewayDatabaseUrl });
await postgres.connect();
const repository = createGatewayReleaseRepository(postgres.prisma as GatewayPrismaClient);
const workspaceManager = new GitWorkspaceManager({
repoRoot: config.workspaceRoot,
worktreeRoot: config.worktreeRoot,
baseEnv: config.baseEnv,
});
const buildRunner = new PnpmBuildRunner();
const processManager = new Pm2ProcessManager();
const controller = new GatewayReleaseController(repository, workspaceManager, buildRunner, processManager, config);
const command = process.argv[2] ?? 'daemon';
if (command === 'status') {
console.log(
JSON.stringify(
{ state: await repository.getState(), operations: await repository.listOperations(20) },
null,
2
)
);
await postgres.disconnect();
return;
}
if (command === 'run-once') {
console.log(JSON.stringify(await controller.runOnce(), null, 2));
await postgres.disconnect();
return;
}
if (command === 'self-upgrade') {
const sourceMode = process.argv[3];
const sourceRef = process.argv[4];
if ((sourceMode !== 'BRANCH' && sourceMode !== 'COMMIT') || !sourceRef) {
throw new Error('usage: release-controller self-upgrade <BRANCH|COMMIT> <ref>');
}
const result = await upgradeReleaseController({
sourceMode,
sourceRef,
workspaceManager,
buildRunner,
processManager,
config,
});
console.log(JSON.stringify(result, null, 2));
await postgres.disconnect();
return;
}
if (command !== 'daemon') throw new Error(`Unknown release-controller command: ${command}`);
let stopping = false;
const stop = async (): Promise<void> => {
if (stopping) return;
stopping = true;
await postgres.disconnect();
};
process.once('SIGINT', () => void stop());
process.once('SIGTERM', () => void stop());
while (!stopping) {
await controller.runOnce();
await new Promise<void>((resolve) => setTimeout(resolve, config.pollIntervalMs));
}
};
main().catch((error) => {
console.error('[release-controller] failed', error);
process.exitCode = 1;
});
@@ -0,0 +1,247 @@
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import {
assertReleaseComponents,
type BuildCommand,
type BuildRunner,
type GatewayReleaseOperationRecord,
type GatewayReleaseRepository,
type GatewayReleaseStateRecord,
type GitWorkspaceManager,
type ProcessDefinition,
type ProcessManager,
readReleaseManifest,
} from '@sammo-ts/gateway-api';
import type { ReleaseControllerConfig } from './config.js';
const LEASE_DURATION_MS = 10 * 60_000;
const HEARTBEAT_INTERVAL_MS = 60_000;
const PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const;
export const buildGatewayReleaseCommands = (
workspaceRoot: string,
needsInstall: boolean,
config: ReleaseControllerConfig
): BuildCommand[] => {
const env = {
...config.baseEnv,
VITE_APP_BASE_PATH: config.gatewayBasePath,
VITE_GATEWAY_API_URL: `${config.gatewayBasePath}/api/trpc`,
VITE_GAME_API_URL_TEMPLATE: '/{profile}/api/trpc',
VITE_GAME_WEB_URL_TEMPLATE: '/{profile}/',
};
return [
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
{ command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:generate'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/logic', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/game-engine', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-api', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-frontend', 'build'], cwd: workspaceRoot, env },
];
};
export const buildGatewayMigrationCommand = (workspaceRoot: string, config: ReleaseControllerConfig): BuildCommand => ({
command: 'pnpm',
args: ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'],
cwd: workspaceRoot,
env: {
...config.baseEnv,
GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl,
},
});
export const buildGatewayProcessDefinitions = (
workspaceRoot: string,
config: ReleaseControllerConfig
): ProcessDefinition[] => {
const apiCwd = path.join(workspaceRoot, 'app', 'gateway-api');
const frontendCwd = path.join(workspaceRoot, 'app', 'gateway-frontend');
const apiScript = path.join(apiCwd, 'dist', 'index.js');
const frontendScript = path.join(workspaceRoot, 'node_modules', 'vite', 'bin', 'vite.js');
const env = {
...config.baseEnv,
GATEWAY_API_HOST: '0.0.0.0',
GATEWAY_API_PORT: String(config.gatewayApiPort),
GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl,
};
return [
{ name: 'sammo:gateway-api', script: apiScript, cwd: apiCwd, env: { ...env, GATEWAY_ROLE: 'api' } },
{
name: 'sammo:gateway-frontend',
script: frontendScript,
cwd: frontendCwd,
args: ['preview', '--host', '0.0.0.0', '--port', String(config.gatewayFrontendPort)],
env,
},
{
name: 'sammo:gateway-orchestrator',
script: apiScript,
cwd: apiCwd,
env: { ...env, GATEWAY_ROLE: 'orchestrator' },
},
];
};
const isMissingProcessError = (error: unknown): boolean =>
error instanceof Error && /process or namespace not found/i.test(error.message);
export class GatewayReleaseController {
private readonly ownerId = randomUUID();
constructor(
private readonly repository: GatewayReleaseRepository,
private readonly workspaceManager: GitWorkspaceManager,
private readonly buildRunner: BuildRunner,
private readonly processManager: ProcessManager,
private readonly config: ReleaseControllerConfig,
private readonly now: () => Date = () => new Date(),
private readonly fetchImpl: typeof fetch = fetch
) {}
async runOnce(): Promise<GatewayReleaseOperationRecord | null> {
const operation = await this.repository.claimNextOperation(this.now(), {
ownerId: this.ownerId,
durationMs: LEASE_DURATION_MS,
});
if (!operation) return null;
const heartbeat = setInterval(() => {
void this.repository.renewOperationLease(operation.id, this.ownerId, this.now(), LEASE_DURATION_MS);
}, HEARTBEAT_INTERVAL_MS);
let resolvedCommitSha: string | undefined;
try {
const state = await this.repository.getState();
const deploymentState: GatewayReleaseStateRecord = {
...state,
activeCommitSha: state.activeCommitSha ?? (await this.workspaceManager.resolveCommit('COMMIT', 'HEAD')),
activeWorkspace: state.activeWorkspace ?? this.config.workspaceRoot,
};
const sourceMode = operation.sourceMode ?? 'COMMIT';
const sourceRef = operation.sourceRef ?? state.previousCommitSha;
if (!sourceRef) throw new Error('Release source is missing.');
resolvedCommitSha = await this.workspaceManager.resolveCommit(sourceMode, sourceRef);
if (!(await this.repository.pinOperationResolvedCommit(operation.id, this.ownerId, resolvedCommitSha))) {
throw new Error('Gateway release lease was lost while pinning the commit.');
}
await this.deploy(operation, deploymentState, resolvedCommitSha);
return await this.repository.completeOperation(
operation.id,
'SUCCEEDED',
{ resolvedCommitSha, error: null },
this.ownerId
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
await this.repository.recordStateError(detail);
return await this.repository.completeOperation(
operation.id,
'FAILED',
{ resolvedCommitSha, error: detail },
this.ownerId
);
} finally {
clearInterval(heartbeat);
}
}
private async deploy(
operation: GatewayReleaseOperationRecord,
state: GatewayReleaseStateRecord,
commitSha: string
): Promise<void> {
const workspace = await this.workspaceManager.prepare(commitSha);
const manifest = await readReleaseManifest(workspace.root);
assertReleaseComponents(manifest, ['gateway-api', 'gateway-frontend']);
const build = await this.buildRunner.run(
buildGatewayReleaseCommands(workspace.root, workspace.needsInstall, this.config)
);
if (!build.ok) throw new Error(`Gateway release build failed: ${build.output.slice(-4000)}`);
const migration = await this.buildRunner.run([buildGatewayMigrationCommand(workspace.root, this.config)]);
if (!migration.ok) throw new Error(`Gateway migration failed: ${migration.output.slice(-4000)}`);
const previousDefinitions = state.activeWorkspace
? buildGatewayProcessDefinitions(state.activeWorkspace, this.config)
: [];
await this.stopManagedProcesses();
try {
await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config));
await this.waitForReadiness();
} catch (error) {
await this.stopManagedProcesses();
if (previousDefinitions.length) {
await this.startDefinitions(previousDefinitions);
await this.waitForReadiness();
}
throw error;
}
await this.repository.publishRelease(operation.id, this.ownerId, {
commitSha,
workspace: workspace.root,
previousCommitSha: state.activeCommitSha,
previousWorkspace: state.activeWorkspace,
});
}
private async startDefinitions(definitions: ProcessDefinition[]): Promise<void> {
const started: string[] = [];
try {
for (const definition of definitions) {
await this.processManager.start(definition);
started.push(definition.name);
}
} catch (error) {
for (const name of started.reverse()) {
try {
await this.processManager.delete(name);
} catch {
// Preserve the start failure.
}
}
throw error;
}
}
private async stopManagedProcesses(): Promise<void> {
const existing = new Set((await this.processManager.list()).map((process) => process.name));
const failures: string[] = [];
for (const name of [...PROCESS_NAMES].reverse()) {
if (!existing.has(name)) continue;
try {
await this.processManager.stop(name);
} catch {
// Delete below is authoritative.
}
try {
await this.processManager.delete(name);
} catch (error) {
if (!isMissingProcessError(error)) failures.push(`${name}: ${String(error)}`);
}
}
if (failures.length) throw new Error(`Failed to stop gateway processes: ${failures.join('; ')}`);
}
private async waitForReadiness(): Promise<void> {
const deadline = Date.now() + this.config.readinessTimeoutMs;
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
const frontendUrl = `http://127.0.0.1:${this.config.gatewayFrontendPort}${this.config.gatewayBasePath}/`;
while (Date.now() < deadline) {
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)
);
if (api.ok && frontend.ok && PROCESS_NAMES.every((name) => online.has(name))) return;
} catch {
// Retry until the bounded deadline.
}
await new Promise<void>((resolve) => setTimeout(resolve, 500));
}
throw new Error('Gateway release did not become ready before the timeout.');
}
}
+108
View File
@@ -0,0 +1,108 @@
import path from 'node:path';
import {
assertReleaseComponents,
type BuildCommand,
type BuildRunner,
type GitWorkspaceManager,
type ProcessDefinition,
type ProcessManager,
readReleaseManifest,
} from '@sammo-ts/gateway-api';
import type { ReleaseControllerConfig } from './config.js';
import { buildGatewayMigrationCommand } from './releaseController.js';
const CONTROLLER_PROCESS_NAME = 'sammo:release-controller';
export const buildReleaseControllerCommands = (
workspaceRoot: string,
needsInstall: boolean,
config: ReleaseControllerConfig
): BuildCommand[] => {
const env = config.baseEnv;
return [
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
{ command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:generate'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/logic', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/game-engine', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-api', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/release-controller', 'build'], cwd: workspaceRoot, env },
];
};
export const buildReleaseControllerDefinition = (
workspaceRoot: string,
config: ReleaseControllerConfig
): ProcessDefinition => ({
name: CONTROLLER_PROCESS_NAME,
script: path.join(workspaceRoot, 'app', 'release-controller', 'dist', 'index.js'),
cwd: path.join(workspaceRoot, 'app', 'release-controller'),
args: ['daemon'],
env: {
...config.baseEnv,
GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl,
GATEWAY_DB_SCHEMA: config.gatewayDbSchema,
RELEASE_CONTROLLER_WORKSPACE_ROOT: workspaceRoot,
RELEASE_CONTROLLER_WORKTREE_ROOT: config.worktreeRoot,
},
});
const workspaceFromControllerCwd = (cwd: string | undefined, fallback: string): string =>
cwd ? path.resolve(cwd, '..', '..') : fallback;
export const upgradeReleaseController = async (options: {
sourceMode: 'BRANCH' | 'COMMIT';
sourceRef: string;
workspaceManager: GitWorkspaceManager;
buildRunner: BuildRunner;
processManager: ProcessManager;
config: ReleaseControllerConfig;
readinessTimeoutMs?: number;
}): Promise<{ commitSha: string; workspace: string }> => {
const commitSha = await options.workspaceManager.resolveCommit(options.sourceMode, options.sourceRef);
const workspace = await options.workspaceManager.prepare(commitSha);
const manifest = await readReleaseManifest(workspace.root);
assertReleaseComponents(manifest, ['release-controller']);
const build = await options.buildRunner.run(
buildReleaseControllerCommands(workspace.root, workspace.needsInstall, options.config)
);
if (!build.ok) throw new Error(`Release controller build failed: ${build.output.slice(-4000)}`);
const migration = await options.buildRunner.run([buildGatewayMigrationCommand(workspace.root, options.config)]);
if (!migration.ok) throw new Error(`Gateway migration failed: ${migration.output.slice(-4000)}`);
const existing = (await options.processManager.list()).find((process) => process.name === CONTROLLER_PROCESS_NAME);
const previousDefinition = buildReleaseControllerDefinition(
workspaceFromControllerCwd(existing?.cwd, options.config.workspaceRoot),
options.config
);
if (existing) {
try {
await options.processManager.stop(CONTROLLER_PROCESS_NAME);
} finally {
await options.processManager.delete(CONTROLLER_PROCESS_NAME);
}
}
try {
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'
);
if (active) 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.');
} catch (error) {
try {
await options.processManager.delete(CONTROLLER_PROCESS_NAME);
} catch {
// The failed new process may already be absent.
}
if (existing) await options.processManager.start(previousDefinition);
throw error;
}
};