feat: add SandboxManager for per-session container lifecycle
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SandboxManager } from './manager.js';
|
||||
import { DockerSandbox } from './docker.js';
|
||||
import type { SandboxConfig } from '../config/schema.js';
|
||||
|
||||
// Mock DockerSandbox
|
||||
vi.mock('./docker.js', () => ({
|
||||
DockerSandbox: vi.fn().mockImplementation(() => ({
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
containerId: 'mock-container',
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('SandboxManager', () => {
|
||||
const defaultConfig: SandboxConfig = {
|
||||
enabled: true,
|
||||
image: 'node:22-slim',
|
||||
workspace_dir: '/workspace',
|
||||
network: 'none',
|
||||
memory_limit: '512m',
|
||||
cpu_limit: '1.0',
|
||||
timeout_seconds: 300,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getOrCreate()', () => {
|
||||
it('creates a new sandbox for unknown session', async () => {
|
||||
const manager = new SandboxManager(defaultConfig);
|
||||
const sandbox = await manager.getOrCreate('session-1');
|
||||
|
||||
expect(DockerSandbox).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
image: 'node:22-slim',
|
||||
}));
|
||||
expect(sandbox.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses existing sandbox for same session', async () => {
|
||||
const manager = new SandboxManager(defaultConfig);
|
||||
const first = await manager.getOrCreate('session-1');
|
||||
const second = await manager.getOrCreate('session-1');
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(DockerSandbox).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates separate sandboxes for different sessions', async () => {
|
||||
const manager = new SandboxManager(defaultConfig);
|
||||
await manager.getOrCreate('session-1');
|
||||
await manager.getOrCreate('session-2');
|
||||
|
||||
expect(DockerSandbox).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy()', () => {
|
||||
it('destroys sandbox and removes from cache', async () => {
|
||||
const manager = new SandboxManager(defaultConfig);
|
||||
const sandbox = await manager.getOrCreate('session-1');
|
||||
|
||||
await manager.destroy('session-1');
|
||||
expect(sandbox.destroy).toHaveBeenCalled();
|
||||
|
||||
// Should create a new one now
|
||||
await manager.getOrCreate('session-1');
|
||||
expect(DockerSandbox).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does nothing for unknown session', async () => {
|
||||
const manager = new SandboxManager(defaultConfig);
|
||||
await manager.destroy('nonexistent'); // should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroyAll()', () => {
|
||||
it('destroys all sandboxes', async () => {
|
||||
const manager = new SandboxManager(defaultConfig);
|
||||
const s1 = await manager.getOrCreate('session-1');
|
||||
const s2 = await manager.getOrCreate('session-2');
|
||||
|
||||
await manager.destroyAll();
|
||||
expect(s1.destroy).toHaveBeenCalled();
|
||||
expect(s2.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { DockerSandbox } from './docker.js';
|
||||
import type { SandboxConfig } from '../config/schema.js';
|
||||
|
||||
/**
|
||||
* Manages per-session Docker sandboxes.
|
||||
* Creates containers lazily on first access, destroys on session cleanup.
|
||||
*/
|
||||
export class SandboxManager {
|
||||
private sandboxes = new Map<string, DockerSandbox>();
|
||||
private config: SandboxConfig;
|
||||
|
||||
constructor(config: SandboxConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/** Get or create a sandbox for a session. */
|
||||
async getOrCreate(sessionId: string): Promise<DockerSandbox> {
|
||||
let sandbox = this.sandboxes.get(sessionId);
|
||||
if (sandbox) return sandbox;
|
||||
|
||||
sandbox = new DockerSandbox({
|
||||
sessionId,
|
||||
image: this.config.image,
|
||||
workspaceDir: this.config.workspace_dir,
|
||||
network: this.config.network,
|
||||
memoryLimit: this.config.memory_limit,
|
||||
cpuLimit: this.config.cpu_limit,
|
||||
timeoutSeconds: this.config.timeout_seconds,
|
||||
});
|
||||
|
||||
await sandbox.create();
|
||||
this.sandboxes.set(sessionId, sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
/** Destroy a specific session's sandbox. */
|
||||
async destroy(sessionId: string): Promise<void> {
|
||||
const sandbox = this.sandboxes.get(sessionId);
|
||||
if (!sandbox) return;
|
||||
|
||||
await sandbox.destroy();
|
||||
this.sandboxes.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Destroy all sandboxes (daemon shutdown). */
|
||||
async destroyAll(): Promise<void> {
|
||||
const entries = Array.from(this.sandboxes.entries());
|
||||
await Promise.allSettled(
|
||||
entries.map(async ([_id, sandbox]) => {
|
||||
await sandbox.destroy();
|
||||
}),
|
||||
);
|
||||
this.sandboxes.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user