297 lines
8.7 KiB
TypeScript
Raw Permalink Normal View History

import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import * as fs from 'node:fs';
import * as net from 'node:net';
import * as path from 'node:path';
import { connect as natsConnect, type NatsConnection } from 'nats';
import {
DEFAULT_SECURITY_POLICY,
DEFAULT_SUPERVISION_POLICY,
SecurityRealm,
} from '@open-matrix/core';
import { createPolicyEngine } from '@open-matrix/core/core/security/PolicyPresets';
import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js';
import { InMemoryBroker } from '@open-matrix/core/transport/InMemoryBroker.js';
import { InMemoryTransport } from '@open-matrix/core/transport/InMemoryTransport.js';
import { NatsTransport } from '@open-matrix/core/transport/NatsTransport.js';
import { authenticatorFromCredentialsRef } from '@open-matrix/nats-auth';
import { SqliteRecordStoreProvider } from '@open-matrix/system-sqlite';
import type { IResolvedRunnerTransportPlan } from './runner-transport.js';
export interface IRunnerRuntimeHandle {
readonly runtime: MatrixRuntime;
readonly transportMode: 'in-memory' | 'external' | 'embedded';
readonly root?: string;
readonly runnerTransport?: IResolvedRunnerTransportPlan;
readonly closed?: Promise<IRunnerRuntimeClosed>;
shutdown(): Promise<void>;
}
export interface IRunnerRuntimeClosed {
readonly error?: string;
}
export function formatRunnerRuntimeClosed(closed: IRunnerRuntimeClosed): string {
const detail = closed.error?.trim();
return detail ? `Runner transport closed: ${detail}` : 'Runner transport closed';
}
export function createRunnerSecurityRealm(): SecurityRealm {
return new SecurityRealm(
DEFAULT_SUPERVISION_POLICY,
DEFAULT_SECURITY_POLICY,
undefined,
undefined,
createPolicyEngine({ policyEngine: 'declarative', preset: 'standard' }),
);
}
export async function createRunnerRuntimeHandle(
root: string | undefined,
runnerTransport?: IResolvedRunnerTransportPlan,
): Promise<IRunnerRuntimeHandle> {
if (!runnerTransport) {
const transport = new InMemoryTransport(new InMemoryBroker(), {
name: 'mx-service-runner',
...(root ? { root } : {}),
});
const runtime = new MatrixRuntime({
transport,
logging: false,
allowDynamicCompilation: false,
securityRealm: createRunnerSecurityRealm(),
recordStoreProvider: new SqliteRecordStoreProvider(),
});
return {
runtime,
transportMode: 'in-memory',
...(root ? { root } : {}),
async shutdown(): Promise<void> {
await runtime.shutdown();
await transport.disconnect();
},
};
}
if (runnerTransport.mode === 'external') {
const authenticator = await authenticatorFromCredentialsRef(runnerTransport.credentialsRef);
const connection = await natsConnect({
servers: [runnerTransport.url],
name: `mx-runner-${process.pid}`,
timeout: 5_000,
...(authenticator ? { authenticator } : {}),
});
const closed = watchNatsConnectionClosed(connection);
const transport = new NatsTransport(connection, { root: runnerTransport.root });
const runtime = new MatrixRuntime({
transport,
logging: false,
allowDynamicCompilation: false,
securityRealm: createRunnerSecurityRealm(),
recordStoreProvider: new SqliteRecordStoreProvider(),
});
return {
runtime,
transportMode: 'external',
root: runnerTransport.root,
runnerTransport,
closed,
async shutdown(): Promise<void> {
await runtime.shutdown();
await transport.disconnect();
if (!connection.isClosed()) {
await connection.close();
}
await closed;
},
};
}
const child = await startEmbeddedNatsServer(runnerTransport);
const connection = await natsConnect({
servers: [runnerTransport.url],
name: `mx-runner-embedded-${process.pid}`,
timeout: 5_000,
});
const closed = watchNatsConnectionClosed(connection);
const transport = new NatsTransport(connection, { root: runnerTransport.root });
writePidFile(runnerTransport.pidFile, child.pid);
const runtime = new MatrixRuntime({
transport,
logging: false,
allowDynamicCompilation: false,
securityRealm: createRunnerSecurityRealm(),
});
return {
runtime,
transportMode: 'embedded',
root: runnerTransport.root,
runnerTransport,
closed,
async shutdown(): Promise<void> {
await runtime.shutdown();
await transport.disconnect();
if (!connection.isClosed()) {
await connection.close();
}
await closed;
await stopChildProcess(child, runnerTransport.pidFile);
},
};
}
function watchNatsConnectionClosed(connection: NatsConnection): Promise<IRunnerRuntimeClosed> {
return connection.closed().then(
(reason): IRunnerRuntimeClosed => {
if (reason instanceof Error) {
return { error: reason.message };
}
return {};
},
(err: unknown): IRunnerRuntimeClosed => ({
error: err instanceof Error ? err.message : String(err),
}),
);
}
async function startEmbeddedNatsServer(
runnerTransport: IResolvedRunnerTransportPlan,
): Promise<ChildProcessWithoutNullStreams> {
if (!runnerTransport.binaryPath) {
throw new Error('Embedded runner transport requires binaryPath');
}
if (!runnerTransport.port) {
throw new Error('Embedded runner transport requires port');
}
if (!runnerTransport.wsPort) {
throw new Error('Embedded runner transport requires wsPort');
}
if (!runnerTransport.dataDir) {
throw new Error('Embedded runner transport requires dataDir');
}
fs.mkdirSync(runnerTransport.dataDir, { recursive: true });
const configPath = path.join(runnerTransport.dataDir, 'nats-server.conf');
const escapedStoreDir = runnerTransport.dataDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
fs.writeFileSync(configPath, [
'host: "127.0.0.1"',
`port: ${runnerTransport.port}`,
'jetstream {',
` store_dir: "${escapedStoreDir}"`,
'}',
'websocket {',
' host: "127.0.0.1"',
` port: ${runnerTransport.wsPort}`,
' no_tls: true',
'}',
'',
].join('\n'));
const child = spawn(runnerTransport.binaryPath, ['-c', configPath], {
stdio: 'pipe',
env: process.env,
windowsHide: true,
});
let stderr = '';
let spawnError: Error | null = null;
child.once('error', (error: Error) => {
spawnError = error;
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
await waitForPorts(
[runnerTransport.port, runnerTransport.wsPort],
10_000,
child,
() => {
if (spawnError) {
return `${spawnError.message}${stderr ? ` | ${stderr.trim()}` : ''}`;
}
return stderr.trim();
},
() => spawnError,
);
return child;
}
async function waitForPorts(
ports: number[],
timeoutMs: number,
child: ChildProcessWithoutNullStreams,
getLogs: () => string,
getSpawnError?: () => Error | null,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const spawnError = getSpawnError?.();
if (spawnError) {
throw new Error(`Failed to spawn nats-server: ${spawnError.message}`);
}
if (child.exitCode != null) {
throw new Error(`nats-server exited early (${child.exitCode}): ${getLogs()}`);
}
const allListening = await Promise.all(ports.map((port) => isPortListening(port)));
if (allListening.every(Boolean)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Timed out waiting for nats-server on ports ${ports.join(', ')}: ${getLogs()}`);
}
async function isPortListening(port: number): Promise<boolean> {
return await new Promise((resolve) => {
const socket = net.connect({ host: '127.0.0.1', port });
socket.once('connect', () => {
socket.destroy();
resolve(true);
});
socket.once('error', () => {
socket.destroy();
resolve(false);
});
socket.setTimeout(500, () => {
socket.destroy();
resolve(false);
});
});
}
async function stopChildProcess(
child: ChildProcessWithoutNullStreams,
pidFile?: string,
): Promise<void> {
if (child.exitCode == null) {
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (child.exitCode == null) {
child.kill('SIGKILL');
}
}, 2_000);
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
child.kill('SIGTERM');
});
}
if (pidFile) {
fs.rmSync(pidFile, { force: true });
}
}
function writePidFile(pidFile: string | undefined, pid: number | undefined): void {
if (!pidFile || !pid) {
return;
}
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
fs.writeFileSync(pidFile, `${pid}\n`, 'utf8');
}