102 lines
3.9 KiB
JavaScript
102 lines
3.9 KiB
JavaScript
import Docker from 'dockerode';
|
|
import { config } from '../../config.js';
|
|
import { resolveWorkspacePath } from '../../lib/sandbox.js';
|
|
import { logger } from '../../lib/logger.js';
|
|
class DockerManager {
|
|
docker;
|
|
containers = new Map();
|
|
daemonReachable = null;
|
|
constructor() {
|
|
this.docker = new Docker();
|
|
}
|
|
async ensureReachable() {
|
|
if (this.daemonReachable === true)
|
|
return;
|
|
try {
|
|
await this.docker.ping();
|
|
this.daemonReachable = true;
|
|
}
|
|
catch (error) {
|
|
this.daemonReachable = false;
|
|
throw new Error('Docker daemon is not reachable. Make sure Docker is running and the current user can access it. ' +
|
|
`Original error: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
getDocker() {
|
|
return this.docker;
|
|
}
|
|
track(containerId, image, name, ports) {
|
|
if (this.containers.size >= config.maxConcurrentContainers) {
|
|
throw new Error(`Refusing to start another container: already at the max of ${config.maxConcurrentContainers} concurrent containers.`);
|
|
}
|
|
const shortId = containerId.substring(0, 12);
|
|
const managed = {
|
|
id: shortId,
|
|
image,
|
|
name,
|
|
startedAt: new Date().toISOString(),
|
|
ports,
|
|
};
|
|
this.containers.set(shortId, managed);
|
|
return managed;
|
|
}
|
|
getTracked(id) {
|
|
return this.containers.get(id) ?? this.containers.get(id.substring(0, 12));
|
|
}
|
|
listTracked() {
|
|
return Array.from(this.containers.values());
|
|
}
|
|
untrack(id) {
|
|
this.containers.delete(id.substring(0, 12));
|
|
}
|
|
async cleanupAll() {
|
|
for (const [id, managed] of this.containers.entries()) {
|
|
try {
|
|
const container = this.docker.getContainer(id);
|
|
await container.stop({ t: 5 }).catch(() => null);
|
|
await container.remove({ force: true }).catch(() => null);
|
|
}
|
|
catch (error) {
|
|
logger.error(`failed to cleanup container ${managed.name} (${id})`, {
|
|
error: error instanceof Error ? error.message : error,
|
|
});
|
|
}
|
|
finally {
|
|
this.containers.delete(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
export const dockerManager = new DockerManager();
|
|
/**
|
|
* Validates that a volume mount specification keeps the source path inside the
|
|
* workspace root. Rejects Docker socket mounts, host-relative escapes, etc.
|
|
*/
|
|
export function validateVolumeMount(spec) {
|
|
const parts = spec.split(':');
|
|
if (parts.length < 2) {
|
|
throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`);
|
|
}
|
|
const source = parts[0] ?? '';
|
|
const target = parts[1] ?? '';
|
|
const mode = parts[2] ?? 'rw';
|
|
if (!source || !target) {
|
|
throw new Error(`Invalid volume mount "${spec}": source and target are required.`);
|
|
}
|
|
const absoluteSource = resolveWorkspacePath(config.workspaceRoot, source);
|
|
if (absoluteSource.toLowerCase().startsWith('\\\\.\\pipe\\docker_engine') || source.includes('/var/run/docker.sock')) {
|
|
throw new Error(`Refusing to mount the Docker socket: "${source}".`);
|
|
}
|
|
return { source: absoluteSource, target, mode };
|
|
}
|
|
export function isAllowListedImage(image) {
|
|
const base = (image.split(':')[0] ?? '').split('/').pop()?.toLowerCase() ?? '';
|
|
return config.dockerAllowedBaseImages.some((allowed) => base === allowed || base.startsWith(allowed));
|
|
}
|
|
export function isLocallyBuiltImage(image) {
|
|
// Heuristic: an image built by this server via docker_build_image won't have
|
|
// a registry prefix. We treat "my-app" or "web-dev-mcp" as local; anything
|
|
// with a registry host (contains '.' or '/') is not.
|
|
return !/[/.]/.test(image);
|
|
}
|
|
//# sourceMappingURL=dockerManager.js.map
|