From 31454785953d7f0727bb713166cf4714fd020b80 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 10 Sep 2026 01:16:56 -0600 Subject: [PATCH] test(sandbox): container options, tar, file listing, exec runner and reaper --- .../__test__/sandbox/container.test.ts | 101 +++++++++ apps/dokploy/__test__/sandbox/exec.test.ts | 213 ++++++++++++++++++ apps/dokploy/__test__/sandbox/files.test.ts | 80 +++++++ apps/dokploy/__test__/sandbox/reaper.test.ts | 109 +++++++++ apps/dokploy/__test__/sandbox/tar.test.ts | 131 +++++++++++ 5 files changed, 634 insertions(+) create mode 100644 apps/dokploy/__test__/sandbox/container.test.ts create mode 100644 apps/dokploy/__test__/sandbox/exec.test.ts create mode 100644 apps/dokploy/__test__/sandbox/files.test.ts create mode 100644 apps/dokploy/__test__/sandbox/reaper.test.ts create mode 100644 apps/dokploy/__test__/sandbox/tar.test.ts diff --git a/apps/dokploy/__test__/sandbox/container.test.ts b/apps/dokploy/__test__/sandbox/container.test.ts new file mode 100644 index 000000000..5f107a9ae --- /dev/null +++ b/apps/dokploy/__test__/sandbox/container.test.ts @@ -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(); + }); +}); diff --git a/apps/dokploy/__test__/sandbox/exec.test.ts b/apps/dokploy/__test__/sandbox/exec.test.ts new file mode 100644 index 000000000..94e660a66 --- /dev/null +++ b/apps/dokploy/__test__/sandbox/exec.test.ts @@ -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); + }); +}); diff --git a/apps/dokploy/__test__/sandbox/files.test.ts b/apps/dokploy/__test__/sandbox/files.test.ts new file mode 100644 index 000000000..b22f3239f --- /dev/null +++ b/apps/dokploy/__test__/sandbox/files.test.ts @@ -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 }, + ]); + }); +}); diff --git a/apps/dokploy/__test__/sandbox/reaper.test.ts b/apps/dokploy/__test__/sandbox/reaper.test.ts new file mode 100644 index 000000000..27cc73185 --- /dev/null +++ b/apps/dokploy/__test__/sandbox/reaper.test.ts @@ -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: [] }); + }); +}); diff --git a/apps/dokploy/__test__/sandbox/tar.test.ts b/apps/dokploy/__test__/sandbox/tar.test.ts new file mode 100644 index 000000000..70a2c1051 --- /dev/null +++ b/apps/dokploy/__test__/sandbox/tar.test.ts @@ -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 = { + "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); + }); +});