2026-05-19 00:26:53 +02:00
|
|
|
import net from 'node:net';
|
|
|
|
|
import { createDb, eq, isNotNull, mcpServers } from '@bmm/db';
|
|
|
|
|
import { config } from '../config.js';
|
|
|
|
|
|
|
|
|
|
const db = createDb();
|
|
|
|
|
|
|
|
|
|
async function portFree(port: number, host = '127.0.0.1'): Promise<boolean> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const tester = net
|
|
|
|
|
.createServer()
|
|
|
|
|
.once('error', () => resolve(false))
|
|
|
|
|
.once('listening', () => tester.close(() => resolve(true)))
|
|
|
|
|
.listen(port, host);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function allocatePort(): Promise<number> {
|
|
|
|
|
const used = new Set(
|
|
|
|
|
(
|
|
|
|
|
await db
|
|
|
|
|
.select({ port: mcpServers.hostPort })
|
|
|
|
|
.from(mcpServers)
|
|
|
|
|
.where(isNotNull(mcpServers.hostPort))
|
|
|
|
|
)
|
|
|
|
|
.map((r) => r.port)
|
|
|
|
|
.filter((p): p is number => typeof p === 'number'),
|
|
|
|
|
);
|
|
|
|
|
for (let port = config.RUNNER_PORT_RANGE_START; port <= config.RUNNER_PORT_RANGE_END; port++) {
|
|
|
|
|
if (used.has(port)) continue;
|
|
|
|
|
if (await portFree(port)) return port;
|
|
|
|
|
}
|
|
|
|
|
throw new Error('no_free_port');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DeployHandle {
|
|
|
|
|
containerId: string;
|
|
|
|
|
publicUrl: string;
|
|
|
|
|
hostPort: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DeployInput {
|
|
|
|
|
serverId: string;
|
|
|
|
|
slug: string;
|
|
|
|
|
hostPort: number;
|
|
|
|
|
imageTag: string;
|
|
|
|
|
envVars: Record<string, string>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Production-only flags documented but unused in dev for Windows Docker Desktop compat:
|
|
|
|
|
// '--read-only',
|
|
|
|
|
// '--cap-drop=ALL',
|
|
|
|
|
// '--security-opt=no-new-privileges',
|
|
|
|
|
// '--cpus=0.5',
|
|
|
|
|
// '--memory=512m',
|
|
|
|
|
|
|
|
|
|
export async function deployContainer(input: DeployInput): Promise<DeployHandle> {
|
|
|
|
|
// In a future iteration this calls docker engine API directly via UNIX socket / named pipe.
|
|
|
|
|
// For Sprint 1-3 we shell out via the bound docker CLI which is portable on win/mac/linux.
|
|
|
|
|
const { spawn } = await import('node:child_process');
|
|
|
|
|
const containerName = `bmm-mcp-${input.slug}-${Date.now().toString(36)}`;
|
|
|
|
|
const args = [
|
|
|
|
|
'run',
|
|
|
|
|
'-d',
|
|
|
|
|
'--name',
|
|
|
|
|
containerName,
|
|
|
|
|
'-p',
|
|
|
|
|
`${input.hostPort}:3000`,
|
|
|
|
|
];
|
|
|
|
|
for (const [k, v] of Object.entries(input.envVars)) {
|
|
|
|
|
args.push('-e', `${k}=${v}`);
|
|
|
|
|
}
|
|
|
|
|
args.push('--restart=unless-stopped', input.imageTag);
|
|
|
|
|
|
|
|
|
|
return await new Promise<DeployHandle>((resolve, reject) => {
|
|
|
|
|
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
|
|
|
let out = '';
|
|
|
|
|
let err = '';
|
|
|
|
|
child.stdout.on('data', (d) => {
|
|
|
|
|
out += d.toString();
|
|
|
|
|
});
|
|
|
|
|
child.stderr.on('data', (d) => {
|
|
|
|
|
err += d.toString();
|
|
|
|
|
});
|
|
|
|
|
child.on('error', (e) => reject(e));
|
|
|
|
|
child.on('close', async (code) => {
|
|
|
|
|
if (code !== 0) {
|
|
|
|
|
reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const containerId = out.trim().slice(0, 64);
|
|
|
|
|
const publicUrl = `http://${config.RUNNER_HOST}:${input.hostPort}`;
|
|
|
|
|
await db
|
|
|
|
|
.update(mcpServers)
|
|
|
|
|
.set({
|
|
|
|
|
containerId,
|
|
|
|
|
hostPort: input.hostPort,
|
|
|
|
|
publicUrl,
|
|
|
|
|
status: 'live',
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(eq(mcpServers.id, input.serverId));
|
|
|
|
|
resolve({ containerId, publicUrl, hostPort: input.hostPort });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 20:58:30 +02:00
|
|
|
export async function stopContainer(
|
|
|
|
|
containerId: string,
|
|
|
|
|
): Promise<{ ok: boolean; detail: string }> {
|
|
|
|
|
if (!containerId || containerId.length < 4) {
|
|
|
|
|
return { ok: false, detail: 'invalid_container_id' };
|
|
|
|
|
}
|
2026-05-19 00:26:53 +02:00
|
|
|
const { spawn } = await import('node:child_process');
|
2026-05-20 20:58:30 +02:00
|
|
|
return await new Promise<{ ok: boolean; detail: string }>((resolve) => {
|
|
|
|
|
const child = spawn('docker', ['rm', '-f', containerId], {
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
});
|
|
|
|
|
let err = '';
|
|
|
|
|
child.stderr?.on('data', (d: Buffer) => {
|
|
|
|
|
err += d.toString();
|
|
|
|
|
});
|
|
|
|
|
child.on('error', () => resolve({ ok: false, detail: 'spawn_failed' }));
|
|
|
|
|
child.on('close', (code) =>
|
|
|
|
|
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` }),
|
|
|
|
|
);
|
2026-05-19 00:26:53 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function dockerAvailable(): Promise<boolean> {
|
|
|
|
|
const { spawn } = await import('node:child_process');
|
|
|
|
|
return await new Promise<boolean>((resolve) => {
|
|
|
|
|
const child = spawn('docker', ['version'], { stdio: 'ignore' });
|
|
|
|
|
child.on('error', () => resolve(false));
|
|
|
|
|
child.on('close', (code) => resolve(code === 0));
|
|
|
|
|
});
|
|
|
|
|
}
|