112 lines
5.2 KiB
JavaScript
112 lines
5.2 KiB
JavaScript
import { z } from 'zod';
|
|
import { config } from '../../config.js';
|
|
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
|
import { dockerManager, isAllowListedImage, isLocallyBuiltImage, validateVolumeMount } from './dockerManager.js';
|
|
export function registerDockerRunContainerTool(server) {
|
|
server.registerTool('docker_run_container', {
|
|
title: 'Docker Run Container',
|
|
description: 'Run a Docker container from an image with port mappings, env vars, and workspace-confined volume mounts. ' +
|
|
'Locally built images are allowed; non-local images must be on the allow-list unless confirm: true is passed.',
|
|
inputSchema: {
|
|
image: z.string().min(1).describe('Docker image to run, e.g. "my-app:latest" or "nginx".'),
|
|
name: z.string().optional().describe('Optional container name.'),
|
|
ports: z
|
|
.record(z.number().int().positive())
|
|
.optional()
|
|
.describe('Port mapping { hostPort: containerPort }.'),
|
|
env: z.record(z.string()).optional().describe('Environment variables.'),
|
|
volumes: z
|
|
.array(z.string())
|
|
.optional()
|
|
.describe('Volume mounts in "source:target[:mode]" format. Source paths are confined to WORKSPACE_ROOT.'),
|
|
command: z.array(z.string()).optional().describe('Override the container command.'),
|
|
memory: z.string().optional().describe('Memory limit, e.g. "512m". Defaults to 512m.'),
|
|
cpus: z.string().optional().describe('CPU limit, e.g. "1.0". Defaults to 1.0.'),
|
|
confirm: z
|
|
.boolean()
|
|
.optional()
|
|
.describe('Required as true to run an image not on the allow-list.'),
|
|
},
|
|
}, async ({ image, name, ports, env, volumes, command, memory, cpus, confirm }) => {
|
|
try {
|
|
await dockerManager.ensureReachable();
|
|
if (!isLocallyBuiltImage(image) && !isAllowListedImage(image) && confirm !== true) {
|
|
return errorResult(`Image "${image}" is not locally built and not on the allow-list (${config.dockerAllowedBaseImages.join(', ')}). ` +
|
|
'Pass confirm: true to run it anyway.');
|
|
}
|
|
const portBindings = {};
|
|
const exposedPorts = {};
|
|
const portMap = {};
|
|
if (ports) {
|
|
for (const [hostPort, containerPort] of Object.entries(ports)) {
|
|
const key = `${containerPort}/tcp`;
|
|
portBindings[key] = [{ HostPort: hostPort }];
|
|
exposedPorts[key] = {};
|
|
portMap[Number(hostPort)] = containerPort;
|
|
}
|
|
}
|
|
const binds = [];
|
|
if (volumes) {
|
|
for (const spec of volumes) {
|
|
const mount = validateVolumeMount(spec);
|
|
binds.push(`${mount.source}:${mount.target}:${mount.mode}`);
|
|
}
|
|
}
|
|
const createOptions = {
|
|
Image: image,
|
|
name,
|
|
Cmd: command,
|
|
Env: env ? Object.entries(env).map(([k, v]) => `${k}=${v}`) : undefined,
|
|
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
|
HostConfig: {
|
|
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
|
Binds: binds.length > 0 ? binds : undefined,
|
|
Memory: parseMemory(memory ?? config.defaultContainerMemory),
|
|
NanoCpus: parseCpus(cpus ?? config.defaultContainerCpus),
|
|
// Security guardrails:
|
|
Privileged: false,
|
|
NetworkMode: 'bridge',
|
|
PidMode: undefined,
|
|
IpcMode: undefined,
|
|
},
|
|
};
|
|
const docker = dockerManager.getDocker();
|
|
const container = await docker.createContainer(createOptions);
|
|
await container.start();
|
|
const info = await container.inspect();
|
|
const managed = dockerManager.track(container.id, image, info.Name?.replace(/^\//, '') ?? container.id, portMap);
|
|
return jsonResult({ ...managed, shortId: container.id.substring(0, 12) });
|
|
}
|
|
catch (error) {
|
|
return errorResult(toErrorMessage(error));
|
|
}
|
|
});
|
|
}
|
|
function parseMemory(value) {
|
|
const match = value.match(/^([0-9.]+)([kmgt]?b?)$/i);
|
|
if (!match || !match[1])
|
|
return 512 * 1024 * 1024;
|
|
const num = Number.parseFloat(match[1]);
|
|
const unit = match[2]?.toLowerCase() ?? '';
|
|
switch (unit) {
|
|
case 'k':
|
|
case 'kb':
|
|
return num * 1024;
|
|
case 'm':
|
|
case 'mb':
|
|
return num * 1024 * 1024;
|
|
case 'g':
|
|
case 'gb':
|
|
return num * 1024 * 1024 * 1024;
|
|
case 't':
|
|
case 'tb':
|
|
return num * 1024 * 1024 * 1024 * 1024;
|
|
default:
|
|
return num;
|
|
}
|
|
}
|
|
function parseCpus(value) {
|
|
const num = Number.parseFloat(value);
|
|
return Number.isNaN(num) ? 1_000_000_000 : Math.round(num * 1_000_000_000);
|
|
}
|
|
//# sourceMappingURL=runContainer.js.map
|