test(sandbox): container options, tar, file listing, exec runner and reaper

This commit is contained in:
Mauricio Siu 2026-09-10 01:16:56 -06:00
parent e3954f7915
commit 3145478595
5 changed files with 634 additions and 0 deletions

View File

@ -0,0 +1,101 @@
import {
buildSandboxContainerOptions,
getSandboxNetworkName,
parseSandboxEnvVars,
parseSandboxUser,
SANDBOX_INTERNET_NETWORK,
SANDBOX_ISOLATED_NETWORK,
} from "@dokploy/server/utils/sandbox/container";
import { describe, expect, it } from "vitest";
const spec = {
sandboxId: "sb_1",
projectId: "proj_1",
environmentId: "env_1",
image: "python:3.12-slim",
cpu: 1.5,
memoryMb: 512,
pidsLimit: 256,
networkMode: "isolated" as const,
envVars: "FOO=bar\n# comment\n\nBAZ=qux=1\ninvalid",
workdir: "/home/user",
user: "1000:1000",
};
describe("buildSandboxContainerOptions", () => {
it("applies cpu, memory and pids limits", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.HostConfig?.NanoCpus).toBe(1_500_000_000);
expect(options.HostConfig?.Memory).toBe(512 * 1024 * 1024);
expect(options.HostConfig?.MemorySwap).toBe(512 * 1024 * 1024);
expect(options.HostConfig?.PidsLimit).toBe(256);
});
it("hardens the container", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.HostConfig?.CapDrop).toEqual(["ALL"]);
expect(options.HostConfig?.SecurityOpt).toEqual(["no-new-privileges"]);
expect(options.HostConfig?.RestartPolicy).toEqual({ Name: "no" });
expect(options.HostConfig?.LogConfig?.Config?.["max-size"]).toBe("10m");
expect(options.User).toBe("1000:1000");
});
it("keeps the container alive with a portable command", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.Entrypoint).toEqual([""]);
expect(options.Cmd).toEqual(["tail", "-f", "/dev/null"]);
expect(options.WorkingDir).toBe("/home/user");
});
it("sets the labels used by the reaper and reconcile", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.name).toBe("dokploy-sandbox-sb_1");
expect(options.Labels).toEqual({
"dokploy.sandbox": "true",
"dokploy.sandboxId": "sb_1",
"dokploy.projectId": "proj_1",
"dokploy.environmentId": "env_1",
});
});
it("selects the isolated or internet network and never dokploy-network", () => {
expect(buildSandboxContainerOptions(spec).HostConfig?.NetworkMode).toBe(
SANDBOX_ISOLATED_NETWORK,
);
expect(
buildSandboxContainerOptions({ ...spec, networkMode: "internet" })
.HostConfig?.NetworkMode,
).toBe(SANDBOX_INTERNET_NETWORK);
expect(getSandboxNetworkName("isolated")).not.toBe("dokploy-network");
expect(getSandboxNetworkName("internet")).not.toBe("dokploy-network");
});
it("passes env vars and HOME, skipping comments and malformed lines", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.Env).toEqual(["HOME=/home/user", "FOO=bar", "BAZ=qux=1"]);
});
it("omits User when the image default should be used", () => {
const options = buildSandboxContainerOptions({ ...spec, user: null });
expect(options).not.toHaveProperty("User");
});
});
describe("parseSandboxEnvVars", () => {
it("returns an empty list for empty input", () => {
expect(parseSandboxEnvVars(null)).toEqual([]);
expect(parseSandboxEnvVars("")).toEqual([]);
});
});
describe("parseSandboxUser", () => {
it("parses uid:gid", () => {
expect(parseSandboxUser("1000:1000")).toEqual({ uid: 1000, gid: 1000 });
expect(parseSandboxUser("1001")).toEqual({ uid: 1001, gid: 1001 });
});
it("rejects named users", () => {
expect(parseSandboxUser("node")).toBeNull();
expect(parseSandboxUser(null)).toBeNull();
});
});

View File

@ -0,0 +1,213 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import {
buildSandboxKillCommand,
runSandboxExec,
SANDBOX_EXEC_MARKER_ENV,
SANDBOX_EXEC_TIMEOUT_EXIT_CODE,
toSandboxEnvArray,
} from "@dokploy/server/utils/sandbox/exec";
import { parse, quote } from "shell-quote";
import { describe, expect, it, vi } from "vitest";
// Docker multiplexes stdout/stderr on a non-tty exec as 8-byte frames:
// [stream type, 0, 0, 0, size (BE uint32)] followed by the payload.
const frame = (type: 1 | 2, text: string) => {
const payload = Buffer.from(text);
const header = Buffer.alloc(8);
header[0] = type;
header.writeUInt32BE(payload.length, 4);
return Buffer.concat([header, payload]);
};
// Mirrors docker-modem's demuxStream so the runner is exercised end to end.
const demuxStream = (
stream: NodeJS.ReadableStream,
stdout: NodeJS.WritableStream,
stderr: NodeJS.WritableStream,
) => {
let buffer = Buffer.alloc(0);
stream.on("data", (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 8) {
const size = buffer.readUInt32BE(4);
if (buffer.length < 8 + size) break;
const payload = buffer.subarray(8, 8 + size);
(buffer[0] === 2 ? stderr : stdout).write(payload);
buffer = buffer.subarray(8 + size);
}
});
};
const createDocker = (opts: {
frames: Buffer[];
exitCode: number | null;
endAfterMs?: number;
dieOnKill?: boolean;
}) => {
const stream = new PassThrough();
const execCreate = vi.fn();
const execStart = vi.fn();
const kill = vi.fn(async () => {
stream.end();
});
const killExecStart = vi.fn(async () => {
if (opts.dieOnKill) stream.end();
});
const inspect = vi.fn(async () => ({ ExitCode: opts.exitCode }));
const exec = vi.fn(async (options: { Cmd: string[] }) => {
execCreate(options);
if (options.Cmd[2]?.startsWith("for p in /proc/")) {
return { start: killExecStart, inspect };
}
return {
start: vi.fn(async (startOptions: unknown) => {
execStart(startOptions);
setTimeout(() => {
for (const chunk of opts.frames) stream.write(chunk);
if (opts.endAfterMs === undefined) stream.end();
else if (opts.endAfterMs > 0)
setTimeout(() => stream.end(), opts.endAfterMs);
}, 5);
return stream;
}),
inspect,
};
});
const docker = {
getContainer: vi.fn(() => ({ exec, kill })),
modem: { demuxStream },
};
return { docker, execCreate, execStart, kill, killExecStart, inspect };
};
describe("runSandboxExec", () => {
it("demuxes stdout/stderr and reports the exit code", async () => {
const { docker, execCreate, execStart } = createDocker({
frames: [frame(1, "hello "), frame(1, "world\n"), frame(2, "oops\n")],
exitCode: 3,
});
const stdoutChunks: string[] = [];
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "echo hello world; echo oops >&2; exit 3",
cwd: "/home/user",
env: { FOO: "bar" },
timeoutMs: 5000,
onStdout: (chunk) => stdoutChunks.push(chunk),
});
expect(result).toMatchObject({
stdout: "hello world\n",
stderr: "oops\n",
exitCode: 3,
timedOut: false,
truncated: false,
containerKilled: false,
});
expect(stdoutChunks.join("")).toBe("hello world\n");
const options = execCreate.mock.calls[0]?.[0];
expect(options.Cmd).toEqual([
"sh",
"-c",
"echo hello world; echo oops >&2; exit 3",
]);
expect(options.WorkingDir).toBe("/home/user");
expect(options.Tty).toBe(false);
expect(options.Env).toContain("FOO=bar");
expect(
options.Env.some((e: string) =>
e.startsWith(`${SANDBOX_EXEC_MARKER_ENV}=`),
),
).toBe(true);
expect(execStart).toHaveBeenCalledWith({ hijack: true, stdin: false });
});
it("treats a null exit code as failure", async () => {
const { docker } = createDocker({ frames: [], exitCode: null });
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "true",
timeoutMs: 5000,
});
expect(result.exitCode).toBe(1);
});
it("truncates output beyond maxOutputBytes but keeps streaming", async () => {
const { docker } = createDocker({
frames: [frame(1, "a".repeat(100)), frame(1, "b".repeat(100))],
exitCode: 0,
});
const streamed: string[] = [];
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "yes",
timeoutMs: 5000,
maxOutputBytes: 150,
onStdout: (chunk) => streamed.push(chunk),
});
expect(result.stdout).toHaveLength(150);
expect(result.truncated).toBe(true);
expect(streamed.join("")).toHaveLength(200);
});
it("kills the process on timeout and returns exit code 124", async () => {
const { docker, killExecStart, kill } = createDocker({
frames: [frame(1, "partial")],
exitCode: 0,
endAfterMs: 0,
dieOnKill: true,
});
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "sleep 100",
timeoutMs: 50,
});
expect(result.timedOut).toBe(true);
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
expect(result.stdout).toBe("partial");
expect(killExecStart).toHaveBeenCalledWith({ Detach: true });
expect(kill).not.toHaveBeenCalled();
expect(result.containerKilled).toBe(false);
});
it("kills the container when the process survives the marker kill", async () => {
const { docker, kill } = createDocker({
frames: [],
exitCode: 0,
endAfterMs: 0,
dieOnKill: false,
});
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "sleep 100",
timeoutMs: 50,
});
expect(result.timedOut).toBe(true);
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
expect(kill).toHaveBeenCalledWith({ signal: "SIGKILL" });
expect(result.containerKilled).toBe(true);
}, 10_000);
});
describe("buildSandboxKillCommand", () => {
it("matches the marker as a fixed string and never fails the shell", () => {
const cmd = buildSandboxKillCommand("abc-123");
expect(cmd).toContain(`grep -qxF ${quote(["DOKPLOY_EXEC_ID=abc-123"])}`);
expect(parse(cmd)).toContain("DOKPLOY_EXEC_ID=abc-123");
expect(cmd).toContain("kill -9");
expect(cmd.endsWith("; true")).toBe(true);
});
});
describe("toSandboxEnvArray", () => {
it("converts records and passes arrays through", () => {
expect(toSandboxEnvArray({ A: "1", B: "x=y" })).toEqual(["A=1", "B=x=y"]);
expect(toSandboxEnvArray(["C=3"])).toEqual(["C=3"]);
expect(toSandboxEnvArray(undefined)).toEqual([]);
});
});
describe("EventEmitter sanity", () => {
it("keeps the fake stream API compatible with what the runner uses", () => {
expect(new PassThrough()).toBeInstanceOf(EventEmitter);
});
});

View File

@ -0,0 +1,80 @@
import {
buildSandboxFindCommand,
buildSandboxLsCommand,
parseSandboxFindOutput,
parseSandboxLsOutput,
} from "@dokploy/server/utils/sandbox/files";
import { parse } from "shell-quote";
import { describe, expect, it } from "vitest";
describe("list files commands", () => {
it("quotes the path against shell injection", () => {
expect(buildSandboxFindCommand("/tmp/a b; rm -rf /")).toContain(
"'/tmp/a b; rm -rf /'",
);
expect(parse(buildSandboxLsCommand("/tmp/$(id)"))).toEqual([
"ls",
"-1Ap",
"/tmp/$(id)",
]);
});
});
describe("parseSandboxFindOutput", () => {
it("parses type, size, mtime, mode and name, directories first", () => {
const out = [
"f\t12\t1700000000.5000\t644\tmain.py",
"d\t4096\t1700000100.0000\t755\tsrc",
"l\t7\t1700000200.0000\t777\tlink",
"f\t0\t1700000300.0000\t600\tname with\tspaces",
].join("\n");
const entries = parseSandboxFindOutput(out);
expect(entries.map((e) => e.name)).toEqual([
"src",
"link",
"main.py",
"name with\tspaces",
]);
expect(entries[0]).toEqual({
name: "src",
type: "directory",
size: 4096,
mode: "755",
modifiedAt: new Date(1700000100 * 1000).toISOString(),
});
expect(entries[2]).toMatchObject({
type: "file",
size: 12,
modifiedAt: new Date(1700000000500).toISOString(),
});
expect(entries[1]?.type).toBe("symlink");
});
it("returns an empty list for an empty directory", () => {
expect(parseSandboxFindOutput("")).toEqual([]);
expect(parseSandboxFindOutput("\n")).toEqual([]);
});
});
describe("parseSandboxLsOutput", () => {
it("marks trailing-slash entries as directories", () => {
const entries = parseSandboxLsOutput("b.txt\na/\n.hidden\n");
expect(entries).toEqual([
{
name: "a",
type: "directory",
size: null,
mode: null,
modifiedAt: null,
},
{
name: ".hidden",
type: "file",
size: null,
mode: null,
modifiedAt: null,
},
{ name: "b.txt", type: "file", size: null, mode: null, modifiedAt: null },
]);
});
});

View File

@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/db", () => ({
db: { query: { sandboxes: { findMany } } },
}));
vi.mock("@dokploy/server/services/sandbox", () => ({
killSandbox: vi.fn(),
}));
import {
findExpiredSandboxes,
planSandboxReconcile,
reapExpiredSandboxes,
} from "@dokploy/server/utils/sandbox/reaper";
const now = new Date("2026-09-10T12:00:00Z");
const past = new Date(now.getTime() - 1000);
const future = new Date(now.getTime() + 60_000);
beforeEach(() => {
findMany.mockReset();
});
describe("findExpiredSandboxes", () => {
it("returns only running sandboxes whose expiresAt has passed", () => {
const list = [
{ sandboxId: "expired", status: "running", expiresAt: past },
{ sandboxId: "alive", status: "running", expiresAt: future },
{ sandboxId: "no-expiry", status: "running", expiresAt: null },
{ sandboxId: "killed", status: "killed", expiresAt: past },
{ sandboxId: "error", status: "error", expiresAt: past },
];
expect(findExpiredSandboxes(list, now).map((s) => s.sandboxId)).toEqual([
"expired",
]);
});
});
describe("reapExpiredSandboxes", () => {
it("kills expired sandboxes and leaves the rest untouched", async () => {
findMany.mockResolvedValue([
{ sandboxId: "expired", status: "running", expiresAt: past },
{ sandboxId: "alive", status: "running", expiresAt: future },
]);
const kill = vi.fn(async () => undefined);
const killed = await reapExpiredSandboxes(now, kill);
expect(killed).toEqual(["expired"]);
expect(kill).toHaveBeenCalledTimes(1);
expect(kill).toHaveBeenCalledWith("expired");
});
it("continues when one kill fails", async () => {
findMany.mockResolvedValue([
{ sandboxId: "a", status: "running", expiresAt: past },
{ sandboxId: "b", status: "running", expiresAt: past },
]);
const kill = vi.fn(async (id: string) => {
if (id === "a") throw new Error("docker down");
});
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const killed = await reapExpiredSandboxes(now, kill);
spy.mockRestore();
expect(killed).toEqual(["b"]);
expect(kill).toHaveBeenCalledTimes(2);
});
});
describe("planSandboxReconcile", () => {
const container = (id: string, sandboxId: string, state = "running") => ({
Id: id,
State: state,
Labels: { "dokploy.sandbox": "true", "dokploy.sandboxId": sandboxId },
});
it("removes containers without a running row and errors rows without a container", () => {
const plan = planSandboxReconcile(
[
{ sandboxId: "ok", containerId: "c-ok" },
{ sandboxId: "gone", containerId: "c-gone" },
{ sandboxId: "never-started", containerId: null },
],
[
container("c-ok", "ok"),
container("c-orphan", "orphan"),
container("c-exited", "ok-old", "exited"),
],
);
expect(plan.removeContainers).toEqual(["c-orphan", "c-exited"]);
expect(plan.markError).toEqual(["gone", "never-started"]);
});
it("removes a stopped container even when its row is running, and errors the row", () => {
const plan = planSandboxReconcile(
[{ sandboxId: "sb", containerId: "c1" }],
[container("c1", "sb", "exited")],
);
expect(plan.removeContainers).toEqual(["c1"]);
expect(plan.markError).toEqual(["sb"]);
});
it("does nothing when everything matches", () => {
const plan = planSandboxReconcile(
[{ sandboxId: "sb", containerId: "c1" }],
[container("c1", "sb")],
);
expect(plan).toEqual({ removeContainers: [], markError: [] });
});
});

View File

@ -0,0 +1,131 @@
import { Readable } from "node:stream";
import {
buildSandboxDirectoryTar,
buildSandboxFileTar,
extractSandboxFile,
SandboxFileTooLargeError,
SandboxNotRegularFileError,
} from "@dokploy/server/utils/sandbox/tar";
import { describe, expect, it } from "vitest";
type Entry = {
name: string;
type: string;
mode: number;
uid: number;
gid: number;
size: number;
content: string;
};
const TYPES: Record<string, string> = {
"0": "file",
"\0": "file",
"5": "directory",
};
// Minimal ustar reader: 512-byte header blocks with octal numeric fields.
const readTar = (buffer: Buffer): Entry[] => {
const entries: Entry[] = [];
let offset = 0;
const field = (start: number, length: number) =>
buffer
.subarray(offset + start, offset + start + length)
.toString("utf8")
.replace(/\0.*$/s, "");
const octal = (start: number, length: number) =>
Number.parseInt(field(start, length).trim() || "0", 8);
while (offset + 512 <= buffer.length) {
const name = field(0, 100);
if (!name) break;
const size = octal(124, 12);
const typeflag = buffer[offset + 156] ?? 0;
entries.push({
name,
type: TYPES[String.fromCharCode(typeflag)] ?? "other",
mode: octal(100, 8),
uid: octal(108, 8),
gid: octal(116, 8),
size,
content: buffer
.subarray(offset + 512, offset + 512 + size)
.toString("utf8"),
});
offset += 512 + Math.ceil(size / 512) * 512;
}
return entries;
};
describe("buildSandboxFileTar", () => {
it("packs a single file with the requested ownership and mode", async () => {
const tar = await buildSandboxFileTar({
name: "main.py",
content: Buffer.from("print('hi')\n"),
uid: 1000,
gid: 1000,
mode: 0o600,
});
const entries = await readTar(tar);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
name: "main.py",
type: "file",
mode: 0o600,
uid: 1000,
gid: 1000,
size: 12,
content: "print('hi')\n",
});
});
it("defaults to root ownership and 0644", async () => {
const entries = await readTar(
await buildSandboxFileTar({ name: "a.txt", content: Buffer.alloc(0) }),
);
expect(entries[0]).toMatchObject({ mode: 0o644, uid: 0, gid: 0, size: 0 });
});
});
describe("buildSandboxDirectoryTar", () => {
it("packs a directory entry with a trailing slash", async () => {
const entries = await readTar(
await buildSandboxDirectoryTar({ name: "user", uid: 1000, gid: 1000 }),
);
expect(entries[0]).toMatchObject({
name: "user/",
type: "directory",
mode: 0o755,
uid: 1000,
gid: 1000,
});
});
});
describe("extractSandboxFile", () => {
it("extracts the first regular file", async () => {
const tar = await buildSandboxFileTar({
name: "out.txt",
content: Buffer.from("hello"),
});
const file = await extractSandboxFile(Readable.from(tar), 1024);
expect(file.name).toBe("out.txt");
expect(file.content.toString()).toBe("hello");
});
it("rejects files over the limit", async () => {
const tar = await buildSandboxFileTar({
name: "big.bin",
content: Buffer.alloc(2048, 1),
});
await expect(
extractSandboxFile(Readable.from(tar), 1024),
).rejects.toBeInstanceOf(SandboxFileTooLargeError);
});
it("rejects archives without a regular file", async () => {
const tar = await buildSandboxDirectoryTar({ name: "dir" });
await expect(
extractSandboxFile(Readable.from(tar), 1024),
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
});
});