feat: add DockerSandbox class for container lifecycle
This commit is contained in:
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { DockerSandbox, type DockerSandboxConfig } from './docker.js';
|
||||||
|
import * as childProcess from 'child_process';
|
||||||
|
|
||||||
|
// Mock child_process.execFile
|
||||||
|
vi.mock('child_process', () => ({
|
||||||
|
execFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedExecFile = vi.mocked(childProcess.execFile);
|
||||||
|
|
||||||
|
function mockExecFileSuccess(stdout = '', stderr = '') {
|
||||||
|
mockedExecFile.mockImplementation(
|
||||||
|
(_cmd: unknown, _args: unknown, _opts: unknown, callback: unknown) => {
|
||||||
|
(callback as (err: null, stdout: string, stderr: string) => void)(null, stdout, stderr);
|
||||||
|
return {} as ReturnType<typeof childProcess.execFile>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockExecFileError(message: string) {
|
||||||
|
mockedExecFile.mockImplementation(
|
||||||
|
(_cmd: unknown, _args: unknown, _opts: unknown, callback: unknown) => {
|
||||||
|
(callback as (err: Error) => void)(new Error(message));
|
||||||
|
return {} as ReturnType<typeof childProcess.execFile>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DockerSandbox', () => {
|
||||||
|
const defaultConfig: DockerSandboxConfig = {
|
||||||
|
sessionId: 'test-session',
|
||||||
|
image: 'node:22-slim',
|
||||||
|
workspaceDir: '/workspace',
|
||||||
|
network: 'none',
|
||||||
|
memoryLimit: '512m',
|
||||||
|
cpuLimit: '1.0',
|
||||||
|
timeoutSeconds: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create()', () => {
|
||||||
|
it('creates a docker container with correct args', async () => {
|
||||||
|
mockExecFileSuccess('container-abc123');
|
||||||
|
const sandbox = new DockerSandbox(defaultConfig);
|
||||||
|
await sandbox.create();
|
||||||
|
|
||||||
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||||
|
'docker',
|
||||||
|
expect.arrayContaining([
|
||||||
|
'create',
|
||||||
|
'--name', expect.stringContaining('flynn-test-session'),
|
||||||
|
'--memory', '512m',
|
||||||
|
'--cpus', '1.0',
|
||||||
|
'--network', 'none',
|
||||||
|
'-v', expect.stringContaining(':/workspace'),
|
||||||
|
'node:22-slim',
|
||||||
|
'sleep', 'infinity',
|
||||||
|
]),
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(sandbox.containerId).toBe('container-abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the container after creating', async () => {
|
||||||
|
mockExecFileSuccess('container-abc123');
|
||||||
|
const sandbox = new DockerSandbox(defaultConfig);
|
||||||
|
await sandbox.create();
|
||||||
|
|
||||||
|
// Second call should be docker start
|
||||||
|
expect(mockedExecFile).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockedExecFile).toHaveBeenNthCalledWith(
|
||||||
|
2, 'docker', ['start', 'container-abc123'],
|
||||||
|
expect.any(Object), expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if docker create fails', async () => {
|
||||||
|
mockExecFileError('docker not found');
|
||||||
|
const sandbox = new DockerSandbox(defaultConfig);
|
||||||
|
await expect(sandbox.create()).rejects.toThrow('docker not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exec()', () => {
|
||||||
|
it('runs command inside container', async () => {
|
||||||
|
const sandbox = new DockerSandbox(defaultConfig);
|
||||||
|
// Manually set container ID to skip create
|
||||||
|
(sandbox as unknown as { _containerId: string })._containerId = 'container-abc';
|
||||||
|
|
||||||
|
mockExecFileSuccess('hello world\n');
|
||||||
|
const result = await sandbox.exec('echo hello world');
|
||||||
|
|
||||||
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||||
|
'docker',
|
||||||
|
['exec', 'container-abc', 'bash', '-c', 'echo hello world'],
|
||||||
|
expect.objectContaining({ timeout: expect.any(Number) }),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ stdout: 'hello world\n', stderr: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes cwd as workdir option', async () => {
|
||||||
|
const sandbox = new DockerSandbox(defaultConfig);
|
||||||
|
(sandbox as unknown as { _containerId: string })._containerId = 'container-abc';
|
||||||
|
|
||||||
|
mockExecFileSuccess('');
|
||||||
|
await sandbox.exec('ls', { cwd: '/workspace/project' });
|
||||||
|
|
||||||
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||||
|
'docker',
|
||||||
|
['exec', '-w', '/workspace/project', 'container-abc', 'bash', '-c', 'ls'],
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if no container created', async () => {
|
||||||
|
const sandbox = new DockerSandbox(defaultConfig);
|
||||||
|
await expect(sandbox.exec('echo hi')).rejects.toThrow('not created');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy()', () => {
|
||||||
|
it('force-removes the container', async () => {
|
||||||
|
const sandbox = new DockerSandbox(defaultConfig);
|
||||||
|
(sandbox as unknown as { _containerId: string })._containerId = 'container-abc';
|
||||||
|
|
||||||
|
mockExecFileSuccess();
|
||||||
|
await sandbox.destroy();
|
||||||
|
|
||||||
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||||
|
'docker', ['rm', '-f', 'container-abc'],
|
||||||
|
expect.any(Object), expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if no container', async () => {
|
||||||
|
const sandbox = new DockerSandbox(defaultConfig);
|
||||||
|
await sandbox.destroy(); // should not throw
|
||||||
|
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAvailable()', () => {
|
||||||
|
it('returns true when docker is installed', async () => {
|
||||||
|
mockExecFileSuccess('Docker version 27.0.0');
|
||||||
|
const result = await DockerSandbox.isAvailable();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when docker is not installed', async () => {
|
||||||
|
mockExecFileError('command not found');
|
||||||
|
const result = await DockerSandbox.isAvailable();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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