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