feat(FN-4642): complete Step 2 — add container backend prototype
Fusion-Task-Id: FN-4642 Fusion-Task-Lineage: a42a36f8-2d2a-43fe-b7c1-d7cf1ad321c3
This commit is contained in:
committed by
gsxdsm
parent
13e74488a4
commit
d8768a280f
112
packages/engine/src/sandbox/__tests__/container.test.ts
Normal file
112
packages/engine/src/sandbox/__tests__/container.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import * as containerArgv from "../container-argv.js";
|
||||
import { ContainerSandboxBackend } from "../container.js";
|
||||
|
||||
const { mockExec, mockExecFile } = vi.hoisted(() => ({
|
||||
mockExec: vi.fn(),
|
||||
mockExecFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
exec: mockExec,
|
||||
execFile: mockExecFile,
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ContainerSandboxBackend", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExec.mockImplementation((_command: string, _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||
callback(null, "podman version", "");
|
||||
return {} as never;
|
||||
});
|
||||
});
|
||||
|
||||
it("returns success result when command succeeds", async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||
callback(null, { stdout: "ok", stderr: "" } as unknown as string, "");
|
||||
return {} as never;
|
||||
},
|
||||
);
|
||||
const backend = new ContainerSandboxBackend({ runtime: "podman" });
|
||||
await backend.prepare({ allowNetwork: true });
|
||||
|
||||
const result = await backend.run("echo ok", { cwd: "/tmp/work", timeoutMs: 1000, maxBuffer: 1024 });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
bufferExceeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps non-zero exit without throwing", async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: (error: Error) => void) => {
|
||||
callback({ code: 2, stdout: "", stderr: "bad" } as unknown as Error);
|
||||
return {} as never;
|
||||
},
|
||||
);
|
||||
const backend = new ContainerSandboxBackend({ runtime: "podman" });
|
||||
|
||||
const result = await backend.run("false", { cwd: "/tmp/work", timeoutMs: 1000, maxBuffer: 1024 });
|
||||
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stderr).toBe("bad");
|
||||
});
|
||||
|
||||
it("maps timeout errors", async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: (error: Error) => void) => {
|
||||
callback({ killed: true, signal: "SIGTERM", message: "Command timed out" } as unknown as Error);
|
||||
return {} as never;
|
||||
},
|
||||
);
|
||||
const backend = new ContainerSandboxBackend({ runtime: "podman" });
|
||||
|
||||
const result = await backend.run("sleep 10", { cwd: "/tmp/work", timeoutMs: 1000, maxBuffer: 1024 });
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.signal).toBe("SIGTERM");
|
||||
});
|
||||
|
||||
it("maps maxBuffer errors", async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: (error: Error) => void) => {
|
||||
callback({ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", message: "maxBuffer" } as unknown as Error);
|
||||
return {} as never;
|
||||
},
|
||||
);
|
||||
const backend = new ContainerSandboxBackend({ runtime: "podman" });
|
||||
|
||||
const result = await backend.run("cat big", { cwd: "/tmp/work", timeoutMs: 1000, maxBuffer: 10 });
|
||||
|
||||
expect(result.bufferExceeded).toBe(true);
|
||||
});
|
||||
|
||||
it("returns structured spawnError when runtime probe fails", async () => {
|
||||
const runtimeError = Object.assign(new Error("not found"), { code: "ENOENT" });
|
||||
mockExec.mockImplementation((_command: string, _options: unknown, callback: (error: Error) => void) => {
|
||||
callback(runtimeError);
|
||||
return {} as never;
|
||||
});
|
||||
const argvSpy = vi.spyOn(containerArgv, "buildContainerArgv");
|
||||
|
||||
const backend = new ContainerSandboxBackend({ runtime: "podman" });
|
||||
const result = await backend.run("echo ok", { cwd: "/tmp/work", timeoutMs: 1000, maxBuffer: 1024 });
|
||||
|
||||
expect(result.exitCode).toBeNull();
|
||||
expect(result.spawnError).toBe(runtimeError);
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
expect(argvSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports configured runtime through capabilities", () => {
|
||||
const backend = new ContainerSandboxBackend({ runtime: "docker" });
|
||||
expect(backend.capabilities().id).toBe("docker");
|
||||
});
|
||||
});
|
||||
249
packages/engine/src/sandbox/container.ts
Normal file
249
packages/engine/src/sandbox/container.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { exec, execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { buildContainerArgv } from "./container-argv.js";
|
||||
import type {
|
||||
SandboxBackend,
|
||||
SandboxCapabilities,
|
||||
SandboxPolicy,
|
||||
SandboxRunOptions,
|
||||
SandboxRunResult,
|
||||
SandboxRunStreamingOptions,
|
||||
SandboxStreamingResult,
|
||||
} from "./types.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Experimental container-backed sandbox execution using Podman or Docker.
|
||||
*/
|
||||
export class ContainerSandboxBackend implements SandboxBackend {
|
||||
private readonly runtime: "podman" | "docker";
|
||||
|
||||
private policy: SandboxPolicy = { allowNetwork: true };
|
||||
|
||||
private probeCompleted = false;
|
||||
|
||||
private runtimeAvailable = true;
|
||||
|
||||
private runtimeProbeError: Error | undefined;
|
||||
|
||||
constructor(options?: { runtime?: "podman" | "docker" }) {
|
||||
this.runtime = options?.runtime ?? "podman";
|
||||
}
|
||||
|
||||
capabilities(): SandboxCapabilities {
|
||||
return {
|
||||
id: this.runtime,
|
||||
supportsNetworkPolicy: true,
|
||||
supportsFilesystemPolicy: false,
|
||||
supportsStreaming: true,
|
||||
platform: ["linux", "darwin"],
|
||||
};
|
||||
}
|
||||
|
||||
async prepare(policy: SandboxPolicy): Promise<void> {
|
||||
this.policy = policy;
|
||||
if (this.probeCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`${this.runtime} --version`, { timeout: 5_000, maxBuffer: 1024 * 1024 });
|
||||
this.runtimeAvailable = true;
|
||||
this.runtimeProbeError = undefined;
|
||||
} catch (error) {
|
||||
this.runtimeAvailable = false;
|
||||
this.runtimeProbeError = error as Error;
|
||||
} finally {
|
||||
this.probeCompleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
async run(command: string, options: SandboxRunOptions): Promise<SandboxRunResult> {
|
||||
if (!this.probeCompleted) {
|
||||
await this.prepare(this.policy);
|
||||
}
|
||||
|
||||
if (!this.runtimeAvailable) {
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
bufferExceeded: false,
|
||||
spawnError: this.runtimeProbeError,
|
||||
};
|
||||
}
|
||||
|
||||
const argv = buildContainerArgv(this.runtime, command, options, this.policy);
|
||||
|
||||
try {
|
||||
const execResult = await execFileAsync(argv[0]!, argv.slice(1), {
|
||||
cwd: options.cwd,
|
||||
timeout: options.timeoutMs,
|
||||
maxBuffer: options.maxBuffer,
|
||||
encoding: options.encoding ?? "utf-8",
|
||||
signal: options.signal,
|
||||
});
|
||||
const stdout = typeof execResult === "object" && execResult && "stdout" in execResult ? execResult.stdout : execResult;
|
||||
const stderr = typeof execResult === "object" && execResult && "stderr" in execResult ? execResult.stderr : "";
|
||||
return {
|
||||
stdout: stdout?.toString?.() ?? "",
|
||||
stderr: stderr?.toString?.() ?? "",
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
bufferExceeded: false,
|
||||
};
|
||||
} catch (error) {
|
||||
const errObj = error as Record<string, unknown>;
|
||||
const code = errObj.code;
|
||||
const status = typeof errObj.status === "number" ? errObj.status : null;
|
||||
const exitCode = typeof code === "number" ? code : status;
|
||||
const message = String(errObj.message ?? "");
|
||||
|
||||
return {
|
||||
stdout: typeof (errObj.stdout as { toString?: unknown })?.toString === "function" ? String(errObj.stdout) : "",
|
||||
stderr: typeof (errObj.stderr as { toString?: unknown })?.toString === "function" ? String(errObj.stderr) : "",
|
||||
exitCode,
|
||||
signal: (errObj.signal as NodeJS.Signals | null | undefined) ?? null,
|
||||
bufferExceeded:
|
||||
code === "ENOBUFS"
|
||||
|| code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
||||
|| message.includes("maxBuffer"),
|
||||
timedOut:
|
||||
code === "ETIMEDOUT"
|
||||
|| (errObj.killed === true && (errObj.signal === "SIGTERM" || message.includes("timed out"))),
|
||||
spawnError: code === "ENOENT" || code === "EACCES" ? (error as Error) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async runStreaming(command: string, options: SandboxRunStreamingOptions): Promise<SandboxStreamingResult> {
|
||||
if (options.signal?.aborted) {
|
||||
return {
|
||||
outcome: "aborted",
|
||||
phase: "pre-start",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.probeCompleted) {
|
||||
await this.prepare(this.policy);
|
||||
}
|
||||
|
||||
if (!this.runtimeAvailable) {
|
||||
return {
|
||||
outcome: "spawn-error",
|
||||
error: this.runtimeProbeError ?? new Error(`${this.runtime} unavailable`),
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
|
||||
const runOptions: SandboxRunOptions = {
|
||||
cwd: options.cwd,
|
||||
timeoutMs: options.timeout,
|
||||
maxBuffer: options.maxBuffer,
|
||||
env: options.env,
|
||||
signal: options.signal,
|
||||
};
|
||||
const argv = buildContainerArgv(this.runtime, command, runOptions, this.policy);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn(argv[0]!, argv.slice(1), {
|
||||
cwd: options.cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: options.env,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutOverflow = false;
|
||||
let stderrOverflow = false;
|
||||
let timedOut = false;
|
||||
let aborted = false;
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (settled) return;
|
||||
child.kill("SIGKILL");
|
||||
}, 5_000).unref();
|
||||
}, options.timeout);
|
||||
timer.unref();
|
||||
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (settled) return;
|
||||
child.kill("SIGKILL");
|
||||
}, 5_000).unref();
|
||||
};
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
if (stdoutOverflow) return;
|
||||
if (stdout.length + chunk.length > options.maxBuffer) {
|
||||
stdoutOverflow = true;
|
||||
stdout += chunk.toString("utf-8", 0, options.maxBuffer - stdout.length);
|
||||
return;
|
||||
}
|
||||
stdout += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
if (stderrOverflow) return;
|
||||
if (stderr.length + chunk.length > options.maxBuffer) {
|
||||
stderrOverflow = true;
|
||||
stderr += chunk.toString("utf-8", 0, options.maxBuffer - stderr.length);
|
||||
return;
|
||||
}
|
||||
stderr += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
const finish = (err: NodeJS.ErrnoException | null, code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (aborted) {
|
||||
resolve({ outcome: "aborted", phase: "mid-flight", stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
resolve({ outcome: "timeout", timeoutMs: options.timeout, stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
resolve({ outcome: "spawn-error", error: err, stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve({ outcome: "success", stdout, stderr, bufferOverflow: stdoutOverflow || stderrOverflow });
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ outcome: "non-zero-exit", stdout, stderr, exitCode: code, signal });
|
||||
};
|
||||
|
||||
child.on("error", (err) => finish(err, null, null));
|
||||
child.on("close", (code, signal) => finish(null, code, signal));
|
||||
});
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user