feat: add DockerSandbox class for container lifecycle
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
export interface DockerSandboxConfig {
|
||||
sessionId: string;
|
||||
image: string;
|
||||
workspaceDir: string;
|
||||
network: 'none' | 'bridge' | 'host';
|
||||
memoryLimit: string;
|
||||
cpuLimit: string;
|
||||
timeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface ExecOptions {
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a single Docker container for sandboxed tool execution.
|
||||
* Uses the Docker CLI directly (no SDK dependency).
|
||||
*/
|
||||
export class DockerSandbox {
|
||||
private config: DockerSandboxConfig;
|
||||
private _containerId: string | null = null;
|
||||
private _hostWorkdir: string;
|
||||
|
||||
constructor(config: DockerSandboxConfig) {
|
||||
this.config = config;
|
||||
const sanitizedId = config.sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
this._hostWorkdir = `/tmp/flynn-sandbox-${sanitizedId}`;
|
||||
}
|
||||
|
||||
get containerId(): string | null {
|
||||
return this._containerId;
|
||||
}
|
||||
|
||||
get containerName(): string {
|
||||
const sanitizedId = this.config.sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
return `flynn-${sanitizedId}`;
|
||||
}
|
||||
|
||||
/** Create and start the sandbox container. */
|
||||
async create(): Promise<void> {
|
||||
const args = [
|
||||
'create',
|
||||
'--name', this.containerName,
|
||||
'--memory', this.config.memoryLimit,
|
||||
'--cpus', this.config.cpuLimit,
|
||||
'--network', this.config.network,
|
||||
'-v', `${this._hostWorkdir}:${this.config.workspaceDir}`,
|
||||
this.config.image,
|
||||
'sleep', 'infinity',
|
||||
];
|
||||
|
||||
const createResult = await this.dockerCmd(args);
|
||||
this._containerId = createResult.stdout.trim();
|
||||
|
||||
await this.dockerCmd(['start', this._containerId]);
|
||||
}
|
||||
|
||||
/** Execute a command inside the container. */
|
||||
async exec(command: string, opts?: ExecOptions): Promise<ExecResult> {
|
||||
if (!this._containerId) {
|
||||
throw new Error('Sandbox container not created. Call create() first.');
|
||||
}
|
||||
|
||||
const args = ['exec'];
|
||||
if (opts?.cwd) {
|
||||
args.push('-w', opts.cwd);
|
||||
}
|
||||
args.push(this._containerId, 'bash', '-c', command);
|
||||
|
||||
const timeout = opts?.timeout ?? this.config.timeoutSeconds * 1000;
|
||||
return this.dockerCmd(args, timeout);
|
||||
}
|
||||
|
||||
/** Force-remove the container. */
|
||||
async destroy(): Promise<void> {
|
||||
if (!this._containerId) return;
|
||||
|
||||
try {
|
||||
await this.dockerCmd(['rm', '-f', this._containerId]);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
this._containerId = null;
|
||||
}
|
||||
|
||||
/** Check if Docker is available on this host. */
|
||||
static async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await new Promise<string>((resolve, reject) => {
|
||||
execFile('docker', ['version', '--format', '{{.Server.Version}}'], {
|
||||
timeout: 5000,
|
||||
}, (error, stdout) => {
|
||||
if (error) reject(error);
|
||||
else resolve(stdout);
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a docker CLI command. */
|
||||
private dockerCmd(args: string[], timeout = 30_000): Promise<ExecResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('docker', args, { timeout, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user