fix(runtime): make production commands executable
This commit is contained in:
@@ -7,16 +7,17 @@
|
||||
"scripts": {
|
||||
"test": "vitest run --root ../.. apps/api/src",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"canary": "node --experimental-strip-types src/canary-cli.ts",
|
||||
"start:production": "node --experimental-strip-types src/production-cli.ts",
|
||||
"migration": "node --experimental-strip-types src/application/legacy-import/migration-command.ts"
|
||||
"canary": "tsx src/canary-cli.ts",
|
||||
"start:production": "tsx src/production-cli.ts",
|
||||
"migration": "tsx src/application/legacy-import/migration-command.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multi-simadmin/contracts": "workspace:*",
|
||||
"@multi-simadmin/operation-registry": "workspace:*",
|
||||
"better-sqlite3": "12.11.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"fastify": "5.10.0"
|
||||
"fastify": "5.10.0",
|
||||
"tsx": "4.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface LegacyImportResult {
|
||||
|
||||
export type LegacyImportTransactionHook = (result: LegacyImportResult) => void;
|
||||
|
||||
interface NormalizedInstance extends Omit<LegacyImportInstancePreview, 'status'> {}
|
||||
type NormalizedInstance = Omit<LegacyImportInstancePreview, 'status'>;
|
||||
|
||||
interface SourceRead {
|
||||
readonly bytes: Buffer;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createCanaryGateway } from './canary-gateway.ts';
|
||||
import { readCanaryRuntimeOptions } from './canary-runtime.ts';
|
||||
import { createCanaryGateway } from './canary-gateway.js';
|
||||
import { readCanaryRuntimeOptions } from './canary-runtime.js';
|
||||
|
||||
const gateway = createCanaryGateway(readCanaryRuntimeOptions(process.env));
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, '../../..');
|
||||
const gatewayToken = 'synthetic-runtime-test-token-at-least-32-bytes';
|
||||
const cleanup: Array<() => Promise<void>> = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface RunningCommand {
|
||||
readonly child: ChildProcessWithoutNullStreams;
|
||||
output(): string;
|
||||
}
|
||||
|
||||
function startPackageCommand(
|
||||
script: 'canary' | 'start:production',
|
||||
environment: NodeJS.ProcessEnv,
|
||||
) {
|
||||
const child = spawn('corepack', ['pnpm', '--filter', '@multi-simadmin/api', 'run', script], {
|
||||
cwd: repositoryRoot,
|
||||
env: { ...process.env, ...environment, FORCE_COLOR: '0' },
|
||||
detached: true,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
let output = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
||||
child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
||||
const command: RunningCommand = { child, output: () => output };
|
||||
cleanup.push(() => stopCommand(command));
|
||||
return command;
|
||||
}
|
||||
|
||||
async function stopCommand(command: RunningCommand): Promise<void> {
|
||||
if (command.child.exitCode !== null || command.child.signalCode !== null) return;
|
||||
const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolveExit) =>
|
||||
command.child.once('exit', (code, signal) => resolveExit({ code, signal })),
|
||||
);
|
||||
if (command.child.pid === undefined) throw new Error('Runtime command has no pid');
|
||||
process.kill(-command.child.pid, 'SIGTERM');
|
||||
const result = await Promise.race([
|
||||
exit,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`Runtime did not stop after SIGTERM:\n${command.output()}`)),
|
||||
5_000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
// pnpm's wrapper reports 143 when its child handles the group SIGTERM cleanly.
|
||||
expect(result.code === 0 || result.code === 143 || result.signal === 'SIGTERM').toBe(true);
|
||||
}
|
||||
|
||||
async function waitForOutput(command: RunningCommand, text: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (!command.output().includes(text)) {
|
||||
if (command.child.exitCode !== null || command.child.signalCode !== null) {
|
||||
throw new Error(`Runtime exited before becoming ready:\n${command.output()}`);
|
||||
}
|
||||
if (Date.now() >= deadline)
|
||||
throw new Error(`Runtime readiness timed out:\n${command.output()}`);
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
||||
}
|
||||
}
|
||||
|
||||
async function listenOn(port: number): Promise<ReturnType<typeof createServer>> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolveListen, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, '127.0.0.1', resolveListen);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
async function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
|
||||
await new Promise<void>((resolveClose, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolveClose())),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectPortFree(port: number): Promise<void> {
|
||||
const server = await listenOn(port);
|
||||
await closeServer(server);
|
||||
}
|
||||
|
||||
async function listenerPids(port: number): Promise<readonly string[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
|
||||
return stdout.trim().split(/\s+/u).filter(Boolean).sort();
|
||||
} catch (error) {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
if (code === 1 || code === '1') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanup.splice(0).map((fn) => fn()));
|
||||
});
|
||||
|
||||
describe.sequential('executable package runtimes', () => {
|
||||
it('starts the exact production command on 8790 and shuts down on SIGTERM', async () => {
|
||||
await expectPortFree(8790);
|
||||
const legacyListeners = await listenerPids(8788);
|
||||
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-production-cli-'));
|
||||
cleanup.push(() => rm(directory, { recursive: true, force: true }));
|
||||
const dataRoot = join(directory, 'data');
|
||||
await mkdir(dataRoot);
|
||||
|
||||
const command = startPackageCommand('start:production', {
|
||||
MULTI_SIMADMIN_DATA_ROOT: dataRoot,
|
||||
MULTI_SIMADMIN_DATABASE_PATH: join(dataRoot, 'synthetic.sqlite'),
|
||||
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
|
||||
API_HOST: '127.0.0.1',
|
||||
API_PORT: '8790',
|
||||
});
|
||||
await waitForOutput(command, 'API listening at http://127.0.0.1:8790');
|
||||
|
||||
const response = await fetch('http://127.0.0.1:8790/healthz', {
|
||||
headers: { [GATEWAY_AUTH_HEADER]: gatewayToken },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ status: 'ok' });
|
||||
expect(await listenerPids(8788)).toEqual(legacyListeners);
|
||||
expect(command.output()).not.toContain(':8788');
|
||||
expect(command.output()).not.toContain(gatewayToken);
|
||||
|
||||
await stopCommand(command);
|
||||
await expectPortFree(8790);
|
||||
expect(command.output()).not.toContain(gatewayToken);
|
||||
}, 20_000);
|
||||
|
||||
it('starts the exact canary command on 8789 with synthetic dist and shuts down on SIGTERM', async () => {
|
||||
const port = 8789;
|
||||
await expectPortFree(port);
|
||||
const legacyListeners = await listenerPids(8788);
|
||||
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-canary-cli-'));
|
||||
cleanup.push(() => rm(directory, { recursive: true, force: true }));
|
||||
await writeFile(join(directory, 'index.html'), '<main>synthetic canary</main>');
|
||||
|
||||
const command = startPackageCommand('canary', {
|
||||
CANARY_DIST_DIR: directory,
|
||||
CANARY_PORT: String(port),
|
||||
CANARY_UPSTREAM_PORT: '8790',
|
||||
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
|
||||
});
|
||||
await waitForOutput(command, `Canary gateway listening at http://127.0.0.1:${port}`);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('<main>synthetic canary</main>');
|
||||
expect(await listenerPids(8788)).toEqual(legacyListeners);
|
||||
expect(command.output()).not.toContain(':8788');
|
||||
expect(command.output()).not.toContain(gatewayToken);
|
||||
|
||||
await stopCommand(command);
|
||||
await expectPortFree(port);
|
||||
expect(command.output()).not.toContain(gatewayToken);
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildProductionControlPlane } from './production-control-plane.ts';
|
||||
import { createRuntimeConfig } from './runtime-config.ts';
|
||||
import { installBoundedShutdown } from './shutdown.ts';
|
||||
import { buildProductionControlPlane } from './production-control-plane.js';
|
||||
import { createRuntimeConfig } from './runtime-config.js';
|
||||
import { installBoundedShutdown } from './shutdown.js';
|
||||
|
||||
try {
|
||||
const config = createRuntimeConfig(process.env);
|
||||
@@ -8,7 +8,7 @@ try {
|
||||
databasePath: config.databasePath,
|
||||
gatewayToken: config.gatewayToken,
|
||||
});
|
||||
await app.listen(config.api);
|
||||
await app.listen({ host: config.api.host, port: config.api.port });
|
||||
installBoundedShutdown(process, app, {
|
||||
terminate: () => process.exit(1),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/canary-cli.ts", "src/production-cli.ts"]
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
Generated
+28
-12
@@ -32,7 +32,7 @@ importers:
|
||||
version: 8.53.0(eslint@9.39.2)(typescript@5.9.3)
|
||||
vitest:
|
||||
specifier: 4.1.0
|
||||
version: 4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3))
|
||||
version: 4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4))
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
@@ -51,6 +51,9 @@ importers:
|
||||
fastify:
|
||||
specifier: 5.10.0
|
||||
version: 5.10.0
|
||||
tsx:
|
||||
specifier: 4.22.4
|
||||
version: 4.22.4
|
||||
devDependencies:
|
||||
'@types/better-sqlite3':
|
||||
specifier: 7.6.13
|
||||
@@ -85,13 +88,13 @@ importers:
|
||||
version: 19.2.3(@types/react@19.2.7)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: 5.1.1
|
||||
version: 5.1.1(vite@7.3.5(@types/node@24.13.3))
|
||||
version: 5.1.1(vite@7.3.5(@types/node@24.13.3)(tsx@4.22.4))
|
||||
jsdom:
|
||||
specifier: 27.2.0
|
||||
version: 27.2.0
|
||||
vite:
|
||||
specifier: 7.3.5
|
||||
version: 7.3.5(@types/node@24.13.3)
|
||||
version: 7.3.5(@types/node@24.13.3)(tsx@4.22.4)
|
||||
|
||||
packages/contracts: {}
|
||||
|
||||
@@ -1974,6 +1977,11 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
tsx@4.22.4:
|
||||
resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
@@ -2907,7 +2915,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@vitejs/plugin-react@5.1.1(vite@7.3.5(@types/node@24.13.3))':
|
||||
'@vitejs/plugin-react@5.1.1(vite@7.3.5(@types/node@24.13.3)(tsx@4.22.4))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
|
||||
@@ -2915,7 +2923,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.47
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 7.3.5(@types/node@24.13.3)
|
||||
vite: 7.3.5(@types/node@24.13.3)(tsx@4.22.4)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2928,13 +2936,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.0(vite@7.3.6(@types/node@24.13.3))':
|
||||
'@vitest/mocker@4.1.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.6(@types/node@24.13.3)
|
||||
vite: 7.3.6(@types/node@24.13.3)(tsx@4.22.4)
|
||||
|
||||
'@vitest/pretty-format@4.1.0':
|
||||
dependencies:
|
||||
@@ -3924,6 +3932,12 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
tsx@4.22.4:
|
||||
dependencies:
|
||||
esbuild: 0.28.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -3962,7 +3976,7 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite@7.3.5(@types/node@24.13.3):
|
||||
vite@7.3.5(@types/node@24.13.3)(tsx@4.22.4):
|
||||
dependencies:
|
||||
esbuild: 0.27.7
|
||||
fdir: 6.5.0(picomatch@4.0.5)
|
||||
@@ -3973,8 +3987,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.3
|
||||
fsevents: 2.3.3
|
||||
tsx: 4.22.4
|
||||
|
||||
vite@7.3.6(@types/node@24.13.3):
|
||||
vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4):
|
||||
dependencies:
|
||||
esbuild: 0.28.1
|
||||
fdir: 6.5.0(picomatch@4.0.5)
|
||||
@@ -3985,11 +4000,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.3
|
||||
fsevents: 2.3.3
|
||||
tsx: 4.22.4
|
||||
|
||||
vitest@4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3)):
|
||||
vitest@4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.0
|
||||
'@vitest/mocker': 4.1.0(vite@7.3.6(@types/node@24.13.3))
|
||||
'@vitest/mocker': 4.1.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4))
|
||||
'@vitest/pretty-format': 4.1.0
|
||||
'@vitest/runner': 4.1.0
|
||||
'@vitest/snapshot': 4.1.0
|
||||
@@ -4006,7 +4022,7 @@ snapshots:
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 7.3.6(@types/node@24.13.3)
|
||||
vite: 7.3.6(@types/node@24.13.3)(tsx@4.22.4)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.3
|
||||
|
||||
Reference in New Issue
Block a user