Compare commits

...

8 Commits

39 changed files with 13571 additions and 1 deletions
@@ -0,0 +1,102 @@
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 under an init", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.Entrypoint).toEqual([""]);
expect(options.Cmd).toEqual(["tail", "-f", "/dev/null"]);
expect(options.HostConfig?.Init).toBe(true);
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();
});
});
+236
View File
@@ -0,0 +1,236 @@
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 start = vi.fn(async () => undefined);
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("if grep -qzF x /dev/null")) {
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, start })),
modem: { demuxStream },
};
return { docker, execCreate, execStart, kill, start, 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);
expect(result.containerRestarted).toBe(false);
});
it("kills and restarts the container when the process survives the marker kill", async () => {
const { docker, kill, start } = 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(start).toHaveBeenCalledTimes(1);
expect(result.containerKilled).toBe(true);
expect(result.containerRestarted).toBe(true);
}, 10_000);
it("reports a failed restart so the sandbox can be marked as error", async () => {
const { docker, start } = createDocker({
frames: [],
exitCode: 0,
endAfterMs: 0,
dieOnKill: false,
});
start.mockRejectedValueOnce(new Error("no such container"));
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "sleep 100",
timeoutMs: 50,
});
expect(result.containerKilled).toBe(true);
expect(result.containerRestarted).toBe(false);
}, 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 -qzxF ${quote(["DOKPLOY_EXEC_ID=abc-123"])}`);
expect(cmd).toContain(
`tr '\\0' '\\n' < "$f" 2>/dev/null | 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);
});
});
@@ -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 },
]);
});
});
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: vi.fn(),
}));
import {
ensureSandboxNetwork,
SANDBOX_NETWORK_OPTIONS,
} from "@dokploy/server/services/sandbox";
const notFound = () =>
Object.assign(new Error("no such network"), { statusCode: 404 });
const createDocker = (inspect: () => Promise<unknown>) => {
const remove = vi.fn(async () => undefined);
const createNetwork = vi.fn(async () => undefined);
const getNetwork = vi.fn(() => ({ inspect, remove }));
return {
docker: { getNetwork, createNetwork },
remove,
createNetwork,
getNetwork,
};
};
beforeEach(() => {
vi.spyOn(console, "warn").mockImplementation(() => {});
});
describe("ensureSandboxNetwork", () => {
it("creates an internal bridge without inter-container traffic for isolated mode", async () => {
const { docker, createNetwork } = createDocker(async () => {
throw notFound();
});
const name = await ensureSandboxNetwork(docker as never, "isolated");
expect(name).toBe("dokploy-sandboxes-isolated");
expect(createNetwork).toHaveBeenCalledWith(
expect.objectContaining({
Name: "dokploy-sandboxes-isolated",
Driver: "bridge",
Internal: true,
Options: SANDBOX_NETWORK_OPTIONS,
}),
);
expect(
SANDBOX_NETWORK_OPTIONS["com.docker.network.bridge.enable_icc"],
).toBe("false");
});
it("creates a non-internal bridge for internet mode", async () => {
const { docker, createNetwork } = createDocker(async () => {
throw notFound();
});
await ensureSandboxNetwork(docker as never, "internet");
expect(createNetwork).toHaveBeenCalledWith(
expect.objectContaining({
Name: "dokploy-sandboxes",
Internal: false,
Options: SANDBOX_NETWORK_OPTIONS,
}),
);
});
it("reuses a network that already blocks inter-container traffic", async () => {
const { docker, createNetwork, remove } = createDocker(async () => ({
Options: SANDBOX_NETWORK_OPTIONS,
Containers: {},
}));
await ensureSandboxNetwork(docker as never, "isolated");
expect(createNetwork).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
it("recreates an unused legacy network that allows inter-container traffic", async () => {
const { docker, createNetwork, remove } = createDocker(async () => ({
Options: {},
Containers: {},
}));
await ensureSandboxNetwork(docker as never, "isolated");
expect(remove).toHaveBeenCalledTimes(1);
expect(createNetwork).toHaveBeenCalledTimes(1);
});
it("keeps a legacy network that still has containers attached and warns", async () => {
const { docker, createNetwork, remove } = createDocker(async () => ({
Options: {},
Containers: { abc: { Name: "dokploy-sandbox-x" } },
}));
await ensureSandboxNetwork(docker as never, "isolated");
expect(remove).not.toHaveBeenCalled();
expect(createNetwork).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalled();
});
it("tolerates a concurrent create of the same network", async () => {
const { docker, createNetwork } = createDocker(async () => {
throw notFound();
});
createNetwork.mockRejectedValueOnce(
Object.assign(new Error("exists"), { statusCode: 409 }),
);
await expect(
ensureSandboxNetwork(docker as never, "isolated"),
).resolves.toBe("dokploy-sandboxes-isolated");
});
});
@@ -0,0 +1,117 @@
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", status: "running" },
{ sandboxId: "gone", containerId: "c-gone", status: "running" },
{ sandboxId: "never-started", containerId: null, status: "running" },
],
[
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", status: "running" }],
[container("c1", "sb", "exited")],
);
expect(plan.removeContainers).toEqual(["c1"]);
expect(plan.markError).toEqual(["sb"]);
});
it("leaves containers of sandboxes that are still being created alone", () => {
const plan = planSandboxReconcile(
[{ sandboxId: "new", containerId: null, status: "creating" }],
[container("c-new", "new")],
);
expect(plan).toEqual({ removeContainers: [], markError: [] });
});
it("does nothing when everything matches", () => {
const plan = planSandboxReconcile(
[{ sandboxId: "sb", containerId: "c1", status: "running" }],
[container("c1", "sb")],
);
expect(plan).toEqual({ removeContainers: [], markError: [] });
});
});
+145
View File
@@ -0,0 +1,145 @@
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 a directory archive even when it contains files", async () => {
const dir = await buildSandboxDirectoryTar({ name: "proj" });
const file = await buildSandboxFileTar({
name: "proj/main.py",
content: Buffer.from("print(1)"),
});
// tar-stream pads each archive with two zero blocks; strip the first
// archive's end-of-archive marker so the entries are read as one tar.
const combined = Buffer.concat([dir.subarray(0, dir.length - 1024), file]);
await expect(
extractSandboxFile(Readable.from(combined), 1024),
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
});
it("rejects archives without a regular file", async () => {
const tar = await buildSandboxDirectoryTar({ name: "dir" });
await expect(
extractSandboxFile(Readable.from(tar), 1024),
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
});
});
@@ -0,0 +1,383 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Box, PlusIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
const schema = z
.object({
name: z.string().max(64).optional(),
source: z.enum(["template", "image"]),
template: z.enum(["base", "python", "node"]),
image: z.string().max(255).optional(),
serverId: z.string().nullable(),
cpu: z.number().min(0.1).max(64),
memoryMb: z.number().int().min(64).max(262_144),
timeoutMinutes: z.number().min(1).max(1440),
networkMode: z.enum(["isolated", "internet"]),
envVars: z.string().optional(),
})
.refine((value) => value.source === "template" || !!value.image?.trim(), {
message: "Image is required",
path: ["image"],
});
type FormValues = z.infer<typeof schema>;
interface Props {
environmentId: string;
projectId: string;
}
export const AddSandbox = ({ environmentId, projectId }: Props) => {
const router = useRouter();
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: templates } = api.sandbox.templates.useQuery();
const { mutateAsync, isPending, error } = api.sandbox.create.useMutation();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const form = useForm<FormValues>({
defaultValues: {
name: "",
source: "template",
template: "python",
image: "",
serverId: null,
cpu: 1,
memoryMb: 512,
timeoutMinutes: 5,
networkMode: "isolated",
envVars: "",
},
resolver: zodResolver(schema),
});
const source = form.watch("source");
const onSubmit = async (values: FormValues) => {
try {
const sandbox = await mutateAsync({
environmentId,
name: values.name?.trim() || undefined,
template: values.source === "template" ? values.template : undefined,
image: values.source === "image" ? values.image?.trim() : undefined,
serverId: values.serverId,
cpu: values.cpu,
memoryMb: values.memoryMb,
timeoutMs: Math.round(values.timeoutMinutes * 60_000),
networkMode: values.networkMode,
envVars: values.envVars?.trim() || undefined,
});
toast.success("Sandbox created");
await utils.sandbox.list.invalidate({ environmentId });
setOpen(false);
form.reset();
router.push(
`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`,
);
} catch {
toast.error("Error creating the sandbox");
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<PlusIcon className="size-4" />
Create Sandbox
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl md:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Box className="size-5 text-muted-foreground" />
New Sandbox
</DialogTitle>
<DialogDescription>
An ephemeral, resource-limited container you can drive through the
API. It is removed automatically when its timeout expires.
</DialogDescription>
</DialogHeader>
{error && <AlertBlock type="error">{error.message}</AlertBlock>}
<Form {...form}>
<form
id="add-sandbox-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="my-sandbox" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="source"
render={({ field }) => (
<FormItem>
<FormLabel>Image</FormLabel>
<Tabs value={field.value} onValueChange={field.onChange}>
<TabsList>
<TabsTrigger value="template">Template</TabsTrigger>
<TabsTrigger value="image">Custom image</TabsTrigger>
</TabsList>
</Tabs>
</FormItem>
)}
/>
{source === "template" ? (
<FormField
control={form.control}
name="template"
render={({ field }) => (
<FormItem>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a template" />
</SelectTrigger>
<SelectContent>
{(templates ?? []).map((template) => (
<SelectItem key={template.name} value={template.name}>
<span className="capitalize">{template.name}</span>
<span className="text-muted-foreground">
{" "}
· {template.image}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="alpine:3.20" {...field} />
</FormControl>
<FormDescription>
The image must contain a POSIX shell (sh) and tail.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{(servers?.length ?? 0) > 0 && (
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<Select
value={field.value ?? "dokploy"}
onValueChange={(value) =>
field.onChange(value === "dokploy" ? null : value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
{showLocalOption && (
<SelectItem value="dokploy">
Dokploy Server
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<FormField
control={form.control}
name="cpu"
render={({ field }) => (
<FormItem>
<FormLabel>CPU cores</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min={0.1}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memoryMb"
render={({ field }) => (
<FormItem>
<FormLabel>Memory (MB)</FormLabel>
<FormControl>
<Input
type="number"
step="64"
min={64}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeoutMinutes"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (minutes)</FormLabel>
<FormControl>
<Input
type="number"
step="1"
min={1}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="networkMode"
render={({ field }) => (
<FormItem>
<FormLabel>Network</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="isolated">
Isolated (no network access)
</SelectItem>
<SelectItem value="internet">Internet access</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Sandboxes are never attached to dokploy-network or any
application network.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="envVars"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<FormControl>
<Textarea
placeholder={"KEY=value\nOTHER=value"}
className="font-mono min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
type="submit"
form="add-sandbox-form"
isLoading={isPending}
disabled={isPending}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,120 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Clock } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const schema = z.object({
minutes: z.number().min(1).max(1440),
});
interface Props {
sandboxId: string;
currentTimeoutMs: number;
}
export const ExtendSandboxTimeout = ({
sandboxId,
currentTimeoutMs,
}: Props) => {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isPending } = api.sandbox.setTimeout.useMutation();
const form = useForm<z.infer<typeof schema>>({
defaultValues: {
minutes: Math.max(1, Math.round(currentTimeoutMs / 60_000)),
},
resolver: zodResolver(schema),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Clock className="size-4" />
Extend timeout
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Extend timeout</DialogTitle>
<DialogDescription>
The sandbox will be killed this many minutes after its last
activity.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="extend-sandbox-form"
className="grid gap-4"
onSubmit={form.handleSubmit(async (values) => {
await mutateAsync({
sandboxId,
timeoutMs: Math.round(values.minutes * 60_000),
})
.then(async () => {
toast.success("Timeout updated");
await utils.sandbox.one.invalidate({ sandboxId });
setOpen(false);
})
.catch(() => toast.error("Error updating the timeout"));
})}
>
<FormField
control={form.control}
name="minutes"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (minutes)</FormLabel>
<FormControl>
<Input
type="number"
min={1}
step={1}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
type="submit"
form="extend-sandbox-form"
isLoading={isPending}
disabled={isPending}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,185 @@
import { Eraser, Loader2, Play, Terminal } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type Line = {
kind: "cmd" | "stdout" | "stderr" | "exit" | "error" | "info";
text: string;
};
type ServerMessage =
| { type: "ready"; sandboxId: string }
| { type: "stdout"; data: string }
| { type: "stderr"; data: string }
| { type: "exit"; exitCode: number; timedOut: boolean }
| { type: "error"; message: string };
interface Props {
sandboxId: string;
enabled: boolean;
workdir: string;
}
const lineClass: Record<Line["kind"], string> = {
cmd: "text-primary font-semibold",
stdout: "text-foreground",
stderr: "text-orange-500",
exit: "text-muted-foreground italic",
error: "text-destructive",
info: "text-muted-foreground",
};
export const SandboxConsole = ({ sandboxId, enabled, workdir }: Props) => {
const [lines, setLines] = useState<Line[]>([]);
const [command, setCommand] = useState("");
const [running, setRunning] = useState(false);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const outputRef = useRef<HTMLDivElement | null>(null);
const append = useCallback((line: Line) => {
setLines((prev) => {
const last = prev[prev.length - 1];
if (
last &&
(line.kind === "stdout" || line.kind === "stderr") &&
last.kind === line.kind &&
!last.text.endsWith("\n")
) {
return [...prev.slice(0, -1), { ...last, text: last.text + line.text }];
}
return [...prev, line];
});
}, []);
useEffect(() => {
if (!enabled) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${protocol}//${window.location.host}/sandbox-exec?sandboxId=${sandboxId}`,
);
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onclose = (event) => {
setConnected(false);
setRunning(false);
if (event.code >= 4000) {
append({ kind: "error", text: event.reason || "Connection closed" });
}
};
ws.onmessage = (event) => {
let message: ServerMessage;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "stdout")
append({ kind: "stdout", text: message.data });
else if (message.type === "stderr")
append({ kind: "stderr", text: message.data });
else if (message.type === "exit") {
append({
kind: "exit",
text: message.timedOut
? `[timed out, exit code ${message.exitCode}]`
: `[exit code ${message.exitCode}]`,
});
setRunning(false);
} else if (message.type === "error") {
append({ kind: "error", text: message.message });
setRunning(false);
}
};
return () => {
ws.close();
wsRef.current = null;
};
}, [sandboxId, enabled, append]);
useEffect(() => {
outputRef.current?.scrollTo({ top: outputRef.current.scrollHeight });
}, [lines]);
const run = () => {
const cmd = command.trim();
const ws = wsRef.current;
if (!cmd || !ws || ws.readyState !== WebSocket.OPEN || running) return;
append({ kind: "cmd", text: `$ ${cmd}` });
setRunning(true);
setCommand("");
ws.send(JSON.stringify({ type: "exec", cmd }));
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<Terminal className="size-5 text-muted-foreground" />
Console
<span className="text-xs font-normal text-muted-foreground">
{enabled
? connected
? "connected"
: "connecting..."
: "sandbox is not running"}
</span>
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setLines([])}
disabled={lines.length === 0}
>
<Eraser className="size-4" />
Clear
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div
ref={outputRef}
className="h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 font-mono text-xs whitespace-pre-wrap break-words"
>
{lines.length === 0 ? (
<span className="text-muted-foreground">
Commands run with `sh -c` inside {workdir}. Output streams here.
</span>
) : (
lines.map((line, index) => (
<div key={index} className={cn(lineClass[line.kind])}>
{line.text}
</div>
))
)}
</div>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
run();
}}
>
<Input
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder="echo hello"
className="font-mono"
disabled={!enabled || !connected || running}
autoComplete="off"
/>
<Button type="submit" disabled={!enabled || !connected || running}>
{running ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4" />
)}
Run
</Button>
</form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,16 @@
import { Badge } from "@/components/ui/badge";
type SandboxStatus = "creating" | "running" | "killed" | "error";
const variants: Record<SandboxStatus, "green" | "yellow" | "blank" | "red"> = {
running: "green",
creating: "yellow",
killed: "blank",
error: "red",
};
export const SandboxStatusBadge = ({ status }: { status: SandboxStatus }) => (
<Badge variant={variants[status]} className="capitalize">
{status}
</Badge>
);
@@ -0,0 +1,134 @@
import { formatDistanceToNow } from "date-fns";
import { Box, ServerIcon, Trash2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { SandboxStatusBadge } from "./sandbox-status-badge";
interface Props {
projectId: string;
environmentId: string;
}
export const formatExpiresIn = (expiresAt: Date | null, status: string) => {
if (status !== "running" || !expiresAt) return "—";
if (expiresAt.getTime() <= Date.now()) return "expired";
return formatDistanceToNow(expiresAt, { addSuffix: true });
};
export const ShowSandboxes = ({ projectId, environmentId }: Props) => {
const utils = api.useUtils();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: sandboxes, isLoading } = api.sandbox.list.useQuery(
{ environmentId },
{ refetchInterval: 10_000 },
);
const { mutateAsync: kill } = api.sandbox.kill.useMutation();
if (isLoading) {
return (
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
if (!sandboxes || sandboxes.length === 0) {
return (
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-2">
<Box className="size-8 text-muted-foreground" />
<span className="font-medium text-muted-foreground">
No sandboxes yet. Create one from the UI or through the API.
</span>
</div>
);
}
return (
<div className="rounded-lg border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Image</TableHead>
<TableHead>Status</TableHead>
<TableHead>Server</TableHead>
<TableHead>CPU / RAM</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sandboxes.map((sandbox) => (
<TableRow key={sandbox.sandboxId}>
<TableCell className="font-medium">
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`}
className="hover:underline"
>
{sandbox.name}
</Link>
</TableCell>
<TableCell className="font-mono text-xs">
{sandbox.image}
</TableCell>
<TableCell>
<SandboxStatusBadge status={sandbox.status} />
</TableCell>
<TableCell>
<span className="flex items-center gap-1.5 text-sm">
<ServerIcon className="size-3.5 text-muted-foreground" />
{sandbox.server?.name ?? "Dokploy Server"}
</span>
</TableCell>
<TableCell className="text-sm">
{sandbox.cpu} vCPU / {sandbox.memoryMb} MB
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatExpiresIn(sandbox.expiresAt, sandbox.status)}
</TableCell>
<TableCell className="text-sm">
<DateTooltip date={sandbox.createdAt} />
</TableCell>
<TableCell className="text-right">
{sandbox.status === "running" &&
permissions?.deployment.create && (
<DialogAction
title="Kill sandbox"
description={`The container for "${sandbox.name}" will be stopped and removed.`}
onClick={async () => {
await kill({ sandboxId: sandbox.sandboxId })
.then(() => {
toast.success("Sandbox killed");
utils.sandbox.list.invalidate({ environmentId });
})
.catch(() =>
toast.error("Error killing the sandbox"),
);
}}
>
<Button variant="ghost" size="icon">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
@@ -0,0 +1,28 @@
CREATE TYPE "public"."sandboxNetworkMode" AS ENUM('isolated', 'internet');--> statement-breakpoint
CREATE TYPE "public"."sandboxStatus" AS ENUM('creating', 'running', 'killed', 'error');--> statement-breakpoint
CREATE TABLE "sandbox" (
"sandboxId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"environmentId" text NOT NULL,
"serverId" text,
"image" text NOT NULL,
"template" text,
"containerId" text,
"status" "sandboxStatus" DEFAULT 'creating' NOT NULL,
"cpu" real DEFAULT 1 NOT NULL,
"memoryMb" integer DEFAULT 512 NOT NULL,
"pidsLimit" integer DEFAULT 256 NOT NULL,
"timeoutMs" integer DEFAULT 300000 NOT NULL,
"expiresAt" timestamp with time zone,
"lastActivityAt" timestamp with time zone,
"networkMode" "sandboxNetworkMode" DEFAULT 'isolated' NOT NULL,
"envVars" text,
"workdir" text DEFAULT '/home/user' NOT NULL,
"user" text,
"runtime" text,
"createdAt" text NOT NULL,
"killedAt" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1380,6 +1380,13 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1789022852219,
"tag": "0197_demonic_the_initiative",
"breakpoints": true
}
]
}
@@ -3,6 +3,7 @@ import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import {
Ban,
Box,
Check,
CheckCircle2,
ChevronsUpDown,
@@ -1062,6 +1063,14 @@ const EnvironmentPage = (
<ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button>
</ProjectEnvironment>
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes`}
>
<Button variant="outline">
<Box className="size-4" />
Sandboxes
</Button>
</Link>
{permissions?.service.create && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -0,0 +1,230 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { ArrowLeft, Box, Skull, Trash2 } from "lucide-react";
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import type { ReactElement } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { ExtendSandboxTimeout } from "@/components/dashboard/sandbox/extend-sandbox-timeout";
import { SandboxConsole } from "@/components/dashboard/sandbox/sandbox-console";
import { SandboxStatusBadge } from "@/components/dashboard/sandbox/sandbox-status-badge";
import { formatExpiresIn } from "@/components/dashboard/sandbox/show-sandboxes";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
const Field = ({ label, value }: { label: string; value: React.ReactNode }) => (
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="text-sm font-medium break-all">{value}</span>
</div>
);
const SandboxPage = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => {
const { sandboxId } = props;
const router = useRouter();
const utils = api.useUtils();
const { projectId, environmentId } = router.query as {
projectId: string;
environmentId: string;
};
const { data, refetch } = api.sandbox.one.useQuery(
{ sandboxId },
{ refetchInterval: 10_000 },
);
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: kill, isPending: isKilling } =
api.sandbox.kill.useMutation();
const { mutateAsync: remove } = api.sandbox.remove.useMutation();
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const listHref = `/dashboard/project/${projectId}/environment/${environmentId}/sandboxes`;
const isRunning = data?.status === "running";
return (
<div className="pb-10">
<AdvanceBreadcrumb />
<Head>
<title>
Sandbox: {data?.name} - {data?.environment?.project?.name} | {appName}
</title>
</Head>
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row flex-wrap justify-between items-start gap-4">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl flex items-center gap-2">
<Box className="size-6 text-muted-foreground" />
{data?.name}
{data && <SandboxStatusBadge status={data.status} />}
</CardTitle>
<span className="text-sm text-muted-foreground font-mono">
{data?.sandboxId}
</span>
</div>
<div className="flex flex-col items-end gap-2">
<Badge>{data?.server?.name || "Dokploy Server"}</Badge>
<div className="flex flex-wrap gap-2 justify-end">
<Link href={listHref}>
<Button variant="outline">
<ArrowLeft className="size-4" />
Sandboxes
</Button>
</Link>
{isRunning && permissions?.deployment.create && (
<ExtendSandboxTimeout
sandboxId={sandboxId}
currentTimeoutMs={data?.timeoutMs ?? 300_000}
/>
)}
{isRunning && permissions?.deployment.create && (
<DialogAction
title="Kill sandbox"
description="The container will be stopped and removed. Files inside it are lost."
onClick={async () => {
await kill({ sandboxId })
.then(async () => {
toast.success("Sandbox killed");
await refetch();
})
.catch(() => toast.error("Error killing the sandbox"));
}}
>
<Button variant="destructive" isLoading={isKilling}>
<Skull className="size-4" />
Kill
</Button>
</DialogAction>
)}
{permissions?.service.delete && (
<DialogAction
title="Delete sandbox"
description="Removes the sandbox record (and its container if still running)."
onClick={async () => {
await remove({ sandboxId })
.then(async () => {
toast.success("Sandbox deleted");
await utils.sandbox.list.invalidate({
environmentId,
});
router.push(listHref);
})
.catch(() => toast.error("Error deleting the sandbox"));
}}
>
<Button variant="ghost" size="icon">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-6 py-8 border-t">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Details</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-6">
<Field label="Image" value={data?.image} />
<Field label="Template" value={data?.template ?? "custom"} />
<Field label="CPU" value={`${data?.cpu} vCPU`} />
<Field label="Memory" value={`${data?.memoryMb} MB`} />
<Field label="PIDs limit" value={data?.pidsLimit} />
<Field label="Network" value={data?.networkMode} />
<Field label="Workdir" value={data?.workdir} />
<Field label="User" value={data?.user ?? "image default"} />
<Field
label="Timeout"
value={`${Math.round((data?.timeoutMs ?? 0) / 60_000)} min`}
/>
<Field
label="Expires"
value={
data ? formatExpiresIn(data.expiresAt, data.status) : "—"
}
/>
<Field
label="Created"
value={data ? <DateTooltip date={data.createdAt} /> : "—"}
/>
<Field
label="Container"
value={
<span className="font-mono text-xs">
{data?.containerId?.slice(0, 12) ?? "—"}
</span>
}
/>
</CardContent>
</Card>
{permissions?.deployment.create && (
<SandboxConsole
sandboxId={sandboxId}
enabled={isRunning}
workdir={data?.workdir ?? "/"}
/>
)}
</CardContent>
</div>
</Card>
</div>
);
};
export default SandboxPage;
SandboxPage.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ sandboxId: string }>,
) {
const { params, req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user) {
return { redirect: { permanent: false, destination: "/" } };
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
if (typeof params?.sandboxId === "string") {
try {
await helpers.sandbox.one.fetch({ sandboxId: params.sandboxId });
return {
props: {
trpcState: helpers.dehydrate(),
sandboxId: params.sandboxId,
},
};
} catch {
return { redirect: { permanent: false, destination: "/" } };
}
}
return { redirect: { permanent: false, destination: "/" } };
}
@@ -0,0 +1,135 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { ArrowLeft, Box } from "lucide-react";
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import type { ReactElement } from "react";
import superjson from "superjson";
import { AddSandbox } from "@/components/dashboard/sandbox/add-sandbox";
import { ShowSandboxes } from "@/components/dashboard/sandbox/show-sandboxes";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
const SandboxesPage = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => {
const { projectId, environmentId } = props;
const { data: environment } = api.environment.one.useQuery({ environmentId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
return (
<div className="pb-10">
<AdvanceBreadcrumb />
<Head>
<title>
Sandboxes: {environment?.name} | {environment?.project?.name} |{" "}
{appName}
</title>
</Head>
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row flex-wrap justify-between items-center gap-4">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl flex items-center gap-2">
<Box className="size-6 text-muted-foreground" />
Sandboxes
</CardTitle>
<CardDescription>
Ephemeral containers for agents and automations in{" "}
{environment?.project?.name} / {environment?.name}.
</CardDescription>
</div>
<div className="flex gap-2">
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}`}
>
<Button variant="outline">
<ArrowLeft className="size-4" />
Services
</Button>
</Link>
{permissions?.service.create && (
<AddSandbox
projectId={projectId}
environmentId={environmentId}
/>
)}
</div>
</CardHeader>
<CardContent className="py-8 border-t">
<ShowSandboxes
projectId={projectId}
environmentId={environmentId}
/>
</CardContent>
</div>
</Card>
</div>
);
};
export default SandboxesPage;
SandboxesPage.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ projectId: string; environmentId: string }>,
) {
const { params, req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user) {
return { redirect: { permanent: false, destination: "/" } };
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
if (
typeof params?.projectId === "string" &&
typeof params?.environmentId === "string"
) {
try {
await helpers.environment.one.fetch({
environmentId: params.environmentId,
});
await helpers.sandbox.list.prefetch({
environmentId: params.environmentId,
});
return {
props: {
trpcState: helpers.dehydrate(),
projectId: params.projectId,
environmentId: params.environmentId,
},
};
} catch {
return { redirect: { permanent: false, destination: "/" } };
}
}
return { redirect: { permanent: false, destination: "/" } };
}
+1
View File
@@ -62,6 +62,7 @@ async function generateOpenAPI() {
"rollback",
"volumeBackups",
"environment",
"sandbox",
],
});
+2
View File
@@ -45,6 +45,7 @@ import { redirectsRouter } from "./routers/redirects";
import { redisRouter } from "./routers/redis";
import { registryRouter } from "./routers/registry";
import { rollbackRouter } from "./routers/rollbacks";
import { sandboxRouter } from "./routers/sandbox";
import { scheduleRouter } from "./routers/schedule";
import { securityRouter } from "./routers/security";
import { serverRouter } from "./routers/server";
@@ -122,6 +123,7 @@ export const appRouter = createTRPCRouter({
patch: patchRouter,
overview: overviewRouter,
transfer: transferRouter,
sandbox: sandboxRouter,
});
// export type definition of API
+281
View File
@@ -0,0 +1,281 @@
import {
createSandbox,
execInSandbox,
findEnvironmentById,
findProjectById,
findSandboxById,
findSandboxesByEnvironmentId,
findSandboxesByProjectId,
getAccessibleServerIds,
getWebServerSettings,
IS_CLOUD,
killSandbox,
listSandboxFiles,
readSandboxFile,
removeSandbox,
SANDBOX_TEMPLATES,
setSandboxTimeout,
writeSandboxFile,
} from "@dokploy/server";
import {
addNewService,
checkEnvironmentAccess,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateSandbox,
apiExecSandbox,
apiFindOneSandbox,
apiListFilesSandbox,
apiListSandboxes,
apiReadFileSandbox,
apiSetTimeoutSandbox,
apiWriteFileSandbox,
} from "@/server/db/schema";
type Ctx = {
user: { id: string; role: string };
session: { activeOrganizationId: string };
};
const findAccessibleSandbox = async (
ctx: Ctx,
sandboxId: string,
mode: "read" | "use" | "delete",
) => {
if (mode === "read") {
await checkServiceAccess(ctx, sandboxId, "read");
} else if (mode === "delete") {
await checkServiceAccess(ctx, sandboxId, "delete");
} else {
await checkServicePermissionAndAccess(ctx, sandboxId, {
deployment: ["create"],
});
}
const sandbox = await findSandboxById(sandboxId);
if (
sandbox.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this sandbox",
});
}
return sandbox;
};
const stripSecrets = <T extends { envVars: string | null }>(sandbox: T) => {
const { envVars: _envVars, ...rest } = sandbox;
return rest;
};
export const sandboxRouter = createTRPCRouter({
templates: protectedProcedure.query(() =>
Object.entries(SANDBOX_TEMPLATES).map(([name, template]) => ({
name,
...template,
})),
),
create: protectedProcedure
.input(apiCreateSandbox)
.mutation(async ({ input, ctx }) => {
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
await checkServiceAccess(ctx, project.projectId, "create");
if (project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this project",
});
}
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a sandbox",
});
}
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const sandbox = await createSandbox(input);
await addNewService(ctx, sandbox.sandboxId);
await audit(ctx, {
action: "create",
resourceType: "sandbox",
resourceId: sandbox.sandboxId,
resourceName: sandbox.name,
});
return stripSecrets(sandbox);
}),
one: protectedProcedure
.input(apiFindOneSandbox)
.query(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
return stripSecrets(sandbox);
}),
list: protectedProcedure
.input(apiListSandboxes)
.query(async ({ input, ctx }) => {
let sandboxes: Awaited<ReturnType<typeof findSandboxesByEnvironmentId>>;
if (input.environmentId) {
await checkEnvironmentAccess(ctx, input.environmentId, "read");
const environment = await findEnvironmentById(input.environmentId);
if (
environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
sandboxes = await findSandboxesByEnvironmentId(input.environmentId);
} else {
const project = await findProjectById(input.projectId ?? "");
if (project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!member.accessedProjects.includes(project.projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
sandboxes = await findSandboxesByProjectId(project.projectId);
}
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
sandboxes = sandboxes.filter((sandbox) =>
member.accessedServices.includes(sandbox.sandboxId),
);
}
return sandboxes.map(stripSecrets);
}),
exec: protectedProcedure
.input(apiExecSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
const result = await execInSandbox(sandbox, {
cmd: input.cmd,
cwd: input.cwd,
env: input.env,
timeoutMs: input.timeoutMs,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
timedOut: result.timedOut,
truncated: result.truncated,
};
}),
writeFile: protectedProcedure
.input(apiWriteFileSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
return writeSandboxFile(sandbox, {
path: input.path,
content: Buffer.from(input.content, input.encoding),
mode: input.mode,
});
}),
readFile: protectedProcedure
.input(apiReadFileSandbox)
.query(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
const file = await readSandboxFile(sandbox, input.path);
return {
path: file.path,
size: file.size,
encoding: input.encoding,
content: file.content.toString(input.encoding),
};
}),
listFiles: protectedProcedure
.input(apiListFilesSandbox)
.query(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
return listSandboxFiles(sandbox, input.path);
}),
setTimeout: protectedProcedure
.input(apiSetTimeoutSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
const updated = await setSandboxTimeout(sandbox, input.timeoutMs);
await audit(ctx, {
action: "update",
resourceType: "sandbox",
resourceId: sandbox.sandboxId,
resourceName: sandbox.name,
metadata: { timeoutMs: input.timeoutMs },
});
return updated ? stripSecrets(updated) : null;
}),
kill: protectedProcedure
.input(apiFindOneSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
const killed = await killSandbox(sandbox.sandboxId);
await audit(ctx, {
action: "stop",
resourceType: "sandbox",
resourceId: sandbox.sandboxId,
resourceName: sandbox.name,
});
return killed ? stripSecrets(killed) : null;
}),
remove: protectedProcedure
.input(apiFindOneSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(
ctx,
input.sandboxId,
"delete",
);
await removeSandbox(sandbox.sandboxId);
await audit(ctx, {
action: "delete",
resourceType: "sandbox",
resourceId: sandbox.sandboxId,
resourceName: sandbox.name,
});
return true;
}),
});
@@ -707,6 +707,7 @@ export const settingsRouter = createTRPCRouter({
"licenseKey",
"organization",
"previewDeployment",
"sandbox",
],
});
+4
View File
@@ -8,6 +8,7 @@ import {
initCronJobs,
initEnterpriseBackupCronJobs,
initializeNetwork,
initSandboxReaper,
initSchedules,
initVolumeBackupsCronJobs,
sendDokployRestartNotifications,
@@ -21,6 +22,7 @@ import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-contai
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
import { setupDrawerLogsWebSocketServer } from "./wss/drawer-logs";
import { setupDeploymentLogsWebSocketServer } from "./wss/listen-deployment";
import { setupSandboxExecWebSocketServer } from "./wss/sandbox-exec";
import { setupTerminalWebSocketServer } from "./wss/terminal";
config({ path: ".env" });
@@ -52,6 +54,7 @@ void app.prepare().then(async () => {
setupDockerContainerLogsWebSocketServer(server);
setupDockerContainerTerminalWebSocketServer(server);
setupTerminalWebSocketServer(server);
setupSandboxExecWebSocketServer(server);
if (!IS_CLOUD) {
setupDockerStatsMonitoringSocketServer(server);
}
@@ -68,6 +71,7 @@ void app.prepare().then(async () => {
await sendDokployRestartNotifications();
}
await initEnterpriseBackupCronJobs();
void initSandboxReaper();
if (!IS_CLOUD) {
console.log("Starting Deployment Worker");
+136
View File
@@ -0,0 +1,136 @@
import type http from "node:http";
import {
execInSandbox,
findSandboxById,
validateRequest,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { type RawData, type WebSocket, WebSocketServer } from "ws";
import { z } from "zod";
import { apiExecSandbox, SANDBOX_DEFAULTS } from "@/server/db/schema";
const execMessage = apiExecSandbox.omit({ sandboxId: true }).extend({
type: z.literal("exec"),
});
type ServerMessage =
| { type: "ready"; sandboxId: string }
| { type: "stdout"; data: string }
| { type: "stderr"; data: string }
| { type: "exit"; exitCode: number; timedOut: boolean }
| { type: "error"; message: string };
const send = (ws: WebSocket, message: ServerMessage) => {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message));
};
export const setupSandboxExecWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
) => {
const wss = new WebSocketServer({ noServer: true, path: "/sandbox-exec" });
server.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url || "", `http://${req.headers.host}`);
if (pathname === "/sandbox-exec") {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
}
});
wss.on("connection", async (ws, req) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
const sandboxId = url.searchParams.get("sandboxId");
if (!sandboxId) {
ws.close(4000, "sandboxId not provided");
return;
}
let running = false;
const handle = async (raw: RawData) => {
let parsed: z.infer<typeof execMessage>;
try {
parsed = execMessage.parse(JSON.parse(raw.toString()));
} catch (error) {
send(ws, {
type: "error",
message: error instanceof Error ? error.message : "Invalid message",
});
return;
}
if (running) {
send(ws, { type: "error", message: "A command is already running" });
return;
}
running = true;
try {
const sandbox = await findSandboxById(sandboxId);
const result = await execInSandbox(sandbox, {
cmd: parsed.cmd,
cwd: parsed.cwd,
env: parsed.env,
timeoutMs: parsed.timeoutMs ?? SANDBOX_DEFAULTS.execTimeoutMs,
onStdout: (data) => send(ws, { type: "stdout", data }),
onStderr: (data) => send(ws, { type: "stderr", data }),
});
send(ws, {
type: "exit",
exitCode: result.exitCode,
timedOut: result.timedOut,
});
} catch (error) {
send(ws, {
type: "error",
message: error instanceof Error ? error.message : String(error),
});
} finally {
running = false;
}
};
// Clients may send as soon as the socket opens, before authorization
// finishes; queue those messages instead of dropping them.
let authorized = false;
const pending: RawData[] = [];
ws.on("message", (raw) => {
if (!authorized) {
pending.push(raw);
return;
}
void handle(raw);
});
const { user, session } = await validateRequest(req);
if (!user || !session?.activeOrganizationId) {
ws.close(4001, "Unauthorized");
return;
}
const ctx = {
user: { id: user.id },
session: { activeOrganizationId: session.activeOrganizationId },
};
try {
await checkServicePermissionAndAccess(ctx, sandboxId, {
deployment: ["create"],
});
const sandbox = await findSandboxById(sandboxId);
if (
sandbox.environment.project.organizationId !==
session.activeOrganizationId
) {
ws.close(4003, "Not authorized");
return;
}
} catch {
ws.close(4003, "Not authorized");
return;
}
authorized = true;
send(ws, { type: "ready", sandboxId });
for (const raw of pending.splice(0)) {
void handle(raw);
}
});
};
+218
View File
@@ -0,0 +1,218 @@
# Sandboxes
A sandbox is an ephemeral, resource-limited Docker container that lives inside a
Dokploy environment and is driven entirely through the API: create it, run
commands, read and write files, and kill it. It is meant for AI agents and
backends (SDK / REST / MCP), not for hosting services: it has no domain, no
published ports and no persistent volumes.
Sandboxes run on the Dokploy server or on any remote server registered in
Dokploy (the container is created through the existing SSH Docker connection).
## Lifecycle
```
create ─► running ─► killed
│ ▲
└──── error ────────────┘ (reaper / reconcile / failed command)
```
- `create` pulls the image if missing, creates the container, starts it and
returns the record with `status: "running"` and `expiresAt`.
- Every operation refreshes `lastActivityAt` and pushes `expiresAt` to
`now + timeoutMs`. An idle sandbox is killed by the reaper (runs every 15 s)
once `expiresAt` passes.
- `kill` stops the container (2 s grace, then SIGKILL) and removes it. The row
is kept with `status: "killed"` for history; `remove` deletes the row.
- On startup Dokploy reconciles every server: containers labelled
`dokploy.sandbox=true` without a running row are removed, and rows marked
running whose container is gone are set to `error`.
## Templates
| template | image (placeholder until `dokploy/sandbox-*` is published) | user | workdir |
| -------- | ---------------------------------------------------------- | --------- | ------------ |
| `base` | `ubuntu:24.04` | 1000:1000 | `/home/user` |
| `python` | `python:3.12-slim` | 1000:1000 | `/home/user` |
| `node` | `node:22-slim` | 1000:1000 | `/home/user` |
A custom `image` can be used instead of a template. It must contain a POSIX
`sh` and `tail`; it runs as the image's default user.
## REST API
All procedures of the `sandbox` tRPC router are exposed through the OpenAPI
layer at `/api/sandbox.<procedure>` (queries are `GET` with query-string
parameters, mutations are `POST` with a JSON body). Authenticate with an API
key from *Settings → API Keys*:
```bash
export DOKPLOY_URL=https://dokploy.example.com
export DOKPLOY_KEY=xxxxxxxx
```
### Create
```bash
curl -s -X POST "$DOKPLOY_URL/api/sandbox.create" \
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
-d '{
"environmentId": "ENV_ID",
"template": "python",
"name": "agent-run-42",
"cpu": 1,
"memoryMb": 512,
"timeoutMs": 300000,
"networkMode": "isolated",
"envVars": "OPENAI_API_KEY=sk-..."
}'
```
Optional fields: `image` (instead of `template`), `serverId` (omit for the
Dokploy server), `pidsLimit`, `workdir`. The response is the sandbox record
(`sandboxId`, `status`, `containerId`, `expiresAt`, …). `envVars` is never
returned.
### Run a command
```bash
curl -s -X POST "$DOKPLOY_URL/api/sandbox.exec" \
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
-d '{"sandboxId": "SANDBOX_ID", "cmd": "python3 -c \"print(1+1)\"", "timeoutMs": 60000}'
# {"stdout":"2\n","stderr":"","exitCode":0,"timedOut":false,"truncated":false}
```
`cmd` runs through `sh -c` in `cwd` (defaults to the sandbox workdir) with the
optional `env` object merged in. When the per-command `timeoutMs` (default
60 s, max 10 min) elapses the process tree is killed and `exitCode` is `124`.
`stdout`/`stderr` are capped at 1 MiB each (`truncated: true` when cut).
### Write and read files
```bash
# utf8 content
curl -s -X POST "$DOKPLOY_URL/api/sandbox.writeFile" \
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
-d '{"sandboxId": "SANDBOX_ID", "path": "/home/user/app/main.py", "content": "print(\"hi\")\n"}'
# binary content
curl -s -X POST "$DOKPLOY_URL/api/sandbox.writeFile" \
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
-d "{\"sandboxId\": \"SANDBOX_ID\", \"path\": \"/home/user/data.bin\", \"encoding\": \"base64\", \"content\": \"$(base64 < data.bin)\"}"
curl -s "$DOKPLOY_URL/api/sandbox.readFile?sandboxId=SANDBOX_ID&path=/home/user/app/main.py" \
-H "x-api-key: $DOKPLOY_KEY"
# {"path":"/home/user/app/main.py","size":12,"encoding":"utf8","content":"print(\"hi\")\n"}
curl -s "$DOKPLOY_URL/api/sandbox.listFiles?sandboxId=SANDBOX_ID&path=/home/user/app" \
-H "x-api-key: $DOKPLOY_KEY"
# {"path":"/home/user/app","entries":[{"name":"main.py","type":"file","size":12,"mode":"644","modifiedAt":"..."}]}
```
Parent directories are created with `mkdir -p`; files are owned by the sandbox
user. Files are capped at 10 MiB in both directions. `listFiles` falls back to
a name-only listing (`size`/`mode`/`modifiedAt` are `null`) on images whose
`find` has no `-printf` (busybox).
### Timeout, list, kill
```bash
curl -s -X POST "$DOKPLOY_URL/api/sandbox.setTimeout" \
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
-d '{"sandboxId": "SANDBOX_ID", "timeoutMs": 900000}'
curl -s "$DOKPLOY_URL/api/sandbox.list?environmentId=ENV_ID" -H "x-api-key: $DOKPLOY_KEY"
curl -s "$DOKPLOY_URL/api/sandbox.list?projectId=PROJECT_ID" -H "x-api-key: $DOKPLOY_KEY"
curl -s "$DOKPLOY_URL/api/sandbox.one?sandboxId=SANDBOX_ID" -H "x-api-key: $DOKPLOY_KEY"
curl -s -X POST "$DOKPLOY_URL/api/sandbox.kill" \
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
-d '{"sandboxId": "SANDBOX_ID"}'
```
`sandbox.remove` deletes the record (killing the container first if needed).
`sandbox.templates` lists the built-in templates.
### MCP
The MCP server is generated from `openapi.json`, so the tools are named after
the operation ids: `sandbox-create`, `sandbox-exec`, `sandbox-writeFile`,
`sandbox-readFile`, `sandbox-listFiles`, `sandbox-setTimeout`, `sandbox-kill`,
`sandbox-remove`, `sandbox-list`, `sandbox-one`, `sandbox-templates`.
## Streaming exec (WebSocket)
Long-running commands can stream their output over the WebSocket endpoint
`/sandbox-exec?sandboxId=SANDBOX_ID` (same origin as the dashboard, session
cookie or `x-api-key` header). Messages are JSON, one command at a time per
connection:
| direction | message |
| --------- | ------- |
| server → client | `{"type":"ready","sandboxId":"..."}` once authorized |
| client → server | `{"type":"exec","cmd":"...","cwd"?:"...","env"?:{...},"timeoutMs"?:60000}` |
| server → client | `{"type":"stdout","data":"..."}` / `{"type":"stderr","data":"..."}` chunks |
| server → client | `{"type":"exit","exitCode":0,"timedOut":false}` when the command finishes |
| server → client | `{"type":"error","message":"..."}` on validation / runtime errors |
The dashboard console on the sandbox page uses this protocol.
## Permissions
- `create` requires `service:create` on the project (same as creating any
service); the creator is granted access to the new sandbox.
- `one`, `list`, `readFile`, `listFiles` require `service:read` on the sandbox.
- `exec`, `writeFile`, `setTimeout`, `kill` and the WebSocket require
`deployment:create` on the sandbox (the same permission used to start/stop
services).
- `remove` requires `service:delete`.
- Every operation also checks the sandbox belongs to the caller's active
organization and, on creation, that the caller can access the target server.
## Security model
Each sandbox container is created with:
- `CapDrop: ["ALL"]` and `SecurityOpt: ["no-new-privileges"]`.
- `Init: true` (docker-init/tini as PID 1) so processes orphaned by a killed
command are reaped instead of lingering as zombies.
- CPU (`NanoCpus`), memory (`Memory` = `MemorySwap`, so no swap) and
`PidsLimit` quotas; `RestartPolicy: no`; JSON log capped at 10 MiB.
- A non-root user for the built-in templates (`1000:1000`); the workdir is
created and owned by that user through the Docker archive API, so no
capability is needed inside the container.
- A dedicated bridge network per server: `dokploy-sandboxes-isolated`
(`internal: true`, no egress) for `networkMode: "isolated"` (default) or
`dokploy-sandboxes` for `networkMode: "internet"`. Both are created with
`com.docker.network.bridge.enable_icc=false`, so sandboxes cannot reach
each other even on the same bridge (a network created by an earlier build
without that option is recreated automatically once no sandbox is attached
to it). Sandboxes are never attached to `dokploy-network` or to any
application network, and nothing is published through Traefik.
- Labels `dokploy.sandbox=true`, `dokploy.sandboxId`, `dokploy.projectId` and
`dokploy.environmentId` so the reaper/reconcile can find orphans.
- Commands run through `docker exec` with a per-command timeout; on timeout the
whole process tree started by that exec is killed (matched by a marker env
var, no `procps` needed). If that kill cannot run (for example the command
exhausted `PidsLimit`, so nothing can fork), the container is killed and
started again: every process is gone, files are kept and the sandbox stays
`running`. Only when the restart itself fails is the sandbox marked `error`.
Environment variables passed at creation are stored encrypted at rest and are
never returned by the API.
## Current limitations
- No in-container agent: everything goes through `docker exec`, `putArchive`
and `getArchive`. There is no PTY / interactive shell, no stdin, and no
process management beyond the per-command timeout.
- Plain Docker runtime only (`runtime` column reserved for gVisor/Kata later).
- No snapshots, pause/resume, warm pools, or billing.
- No public domains or exposed ports.
- Remote servers reuse the existing Dokploy SSH Docker client, which opens an
SSH session per Docker API request (same behaviour as the rest of Dokploy);
a pooled connection would make bursts of small operations faster.
- `listFiles` needs GNU `find` for metadata; busybox images get names only.
- Output of a single command is capped at 1 MiB per stream, files at 10 MiB.
- Sandboxes are not shown in the environment services grid; they live in the
*Sandboxes* view of the environment.
+2
View File
@@ -85,6 +85,7 @@
"shell-quote": "^1.8.4",
"slugify": "^1.6.6",
"ssh2": "~1.16.0",
"tar-stream": "3.2.1",
"toml": "3.0.0",
"ws": "8.16.0",
"yaml": "2.8.1",
@@ -105,6 +106,7 @@
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/tar-stream": "3.1.4",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.31.4",
"esbuild": "0.20.2",
+2 -1
View File
@@ -94,4 +94,5 @@ export type AuditResourceType =
| "compose"
| "network"
| "vaultProvider"
| "dnsProvider";
| "dnsProvider"
| "sandbox";
@@ -11,6 +11,7 @@ import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { projects } from "./project";
import { redis } from "./redis";
import { sandboxes } from "./sandbox";
import { encryptedText } from "./utils";
export const environments = pgTable("environment", {
@@ -45,6 +46,7 @@ export const environmentRelations = relations(
mysql: many(mysql),
postgres: many(postgres),
redis: many(redis),
sandboxes: many(sandboxes),
}),
);
+1
View File
@@ -32,6 +32,7 @@ export * from "./redirects";
export * from "./redis";
export * from "./registry";
export * from "./rollbacks";
export * from "./sandbox";
export * from "./schedule";
export * from "./scim";
export * from "./security";
+168
View File
@@ -0,0 +1,168 @@
import { relations } from "drizzle-orm";
import {
integer,
pgEnum,
pgTable,
real,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { environments } from "./environment";
import { server } from "./server";
import { encryptedText } from "./utils";
export const sandboxStatus = pgEnum("sandboxStatus", [
"creating",
"running",
"killed",
"error",
]);
export const sandboxNetworkMode = pgEnum("sandboxNetworkMode", [
"isolated",
"internet",
]);
export const SANDBOX_TEMPLATE_NAMES = ["base", "python", "node"] as const;
export type SandboxTemplateName = (typeof SANDBOX_TEMPLATE_NAMES)[number];
export const SANDBOX_DEFAULTS = {
cpu: 1,
memoryMb: 512,
pidsLimit: 256,
timeoutMs: 300_000,
workdir: "/home/user",
execTimeoutMs: 60_000,
} as const;
export const sandboxes = pgTable("sandbox", {
sandboxId: text("sandboxId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
environmentId: text("environmentId")
.notNull()
.references(() => environments.environmentId, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
image: text("image").notNull(),
template: text("template"),
containerId: text("containerId"),
status: sandboxStatus("status").notNull().default("creating"),
cpu: real("cpu").notNull().default(SANDBOX_DEFAULTS.cpu),
memoryMb: integer("memoryMb").notNull().default(SANDBOX_DEFAULTS.memoryMb),
pidsLimit: integer("pidsLimit").notNull().default(SANDBOX_DEFAULTS.pidsLimit),
timeoutMs: integer("timeoutMs").notNull().default(SANDBOX_DEFAULTS.timeoutMs),
expiresAt: timestamp("expiresAt", { withTimezone: true }),
lastActivityAt: timestamp("lastActivityAt", { withTimezone: true }),
networkMode: sandboxNetworkMode("networkMode").notNull().default("isolated"),
envVars: encryptedText("envVars"),
workdir: text("workdir").notNull().default(SANDBOX_DEFAULTS.workdir),
user: text("user"),
runtime: text("runtime"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
killedAt: timestamp("killedAt", { withTimezone: true }),
});
export const sandboxRelations = relations(sandboxes, ({ one }) => ({
environment: one(environments, {
fields: [sandboxes.environmentId],
references: [environments.environmentId],
}),
server: one(server, {
fields: [sandboxes.serverId],
references: [server.serverId],
}),
}));
const absolutePath = z
.string()
.min(1)
.max(4096)
.refine(
(value) => value.startsWith("/") && !value.includes("\0"),
"Path must be absolute",
);
export const apiCreateSandbox = z
.object({
environmentId: z.string().min(1),
name: z.string().min(1).max(64).optional(),
template: z.enum(SANDBOX_TEMPLATE_NAMES).optional(),
image: z.string().min(1).max(255).optional(),
serverId: z.string().nullable().optional(),
cpu: z.number().min(0.1).max(64).optional(),
memoryMb: z.number().int().min(64).max(262_144).optional(),
pidsLimit: z.number().int().min(16).max(65_536).optional(),
timeoutMs: z
.number()
.int()
.min(10_000)
.max(24 * 60 * 60 * 1000)
.optional(),
networkMode: z.enum(["isolated", "internet"]).optional(),
envVars: z.string().max(65_536).optional(),
workdir: absolutePath.optional(),
})
.refine((value) => !!value.template || !!value.image, {
message: "Either template or image is required",
path: ["image"],
});
export const apiFindOneSandbox = z.object({
sandboxId: z.string().min(1),
});
export const apiListSandboxes = z
.object({
environmentId: z.string().min(1).optional(),
projectId: z.string().min(1).optional(),
})
.refine((value) => !!value.environmentId || !!value.projectId, {
message: "Either environmentId or projectId is required",
path: ["environmentId"],
});
export const apiExecSandbox = z.object({
sandboxId: z.string().min(1),
cmd: z.string().min(1).max(65_536),
cwd: absolutePath.optional(),
env: z
.record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), z.string())
.optional(),
timeoutMs: z.number().int().min(1000).max(600_000).optional(),
});
export const apiWriteFileSandbox = z.object({
sandboxId: z.string().min(1),
path: absolutePath,
content: z.string(),
encoding: z.enum(["utf8", "base64"]).default("utf8"),
mode: z.number().int().min(0).max(0o7777).optional(),
});
export const apiReadFileSandbox = z.object({
sandboxId: z.string().min(1),
path: absolutePath,
encoding: z.enum(["utf8", "base64"]).default("utf8"),
});
export const apiListFilesSandbox = z.object({
sandboxId: z.string().min(1),
path: absolutePath.optional(),
});
export const apiSetTimeoutSandbox = z.object({
sandboxId: z.string().min(1),
timeoutMs: z
.number()
.int()
.min(10_000)
.max(24 * 60 * 60 * 1000),
});
+2
View File
@@ -22,6 +22,7 @@ import { mysql } from "./mysql";
import { network } from "./network";
import { postgres } from "./postgres";
import { redis } from "./redis";
import { sandboxes } from "./sandbox";
import { schedules } from "./schedule";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
@@ -125,6 +126,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
mongo: many(mongo),
mysql: many(mysql),
postgres: many(postgres),
sandboxes: many(sandboxes),
certificates: many(certificates),
networks: many(network),
organization: one(organization, {
+7
View File
@@ -48,6 +48,7 @@ export * from "./services/redirect";
export * from "./services/redis";
export * from "./services/registry";
export * from "./services/rollbacks";
export * from "./services/sandbox";
export * from "./services/schedule";
export * from "./services/security";
export * from "./services/server";
@@ -131,6 +132,12 @@ export * from "./utils/providers/github";
export * from "./utils/providers/github";
export * from "./utils/providers/gitlab";
export * from "./utils/providers/raw";
export * from "./utils/sandbox/container";
export * from "./utils/sandbox/exec";
export * from "./utils/sandbox/files";
export * from "./utils/sandbox/reaper";
export * from "./utils/sandbox/tar";
export * from "./utils/sandbox/templates";
export * from "./utils/schedules/index";
export * from "./utils/schedules/utils";
export * from "./utils/servers/remote-docker";
+509
View File
@@ -0,0 +1,509 @@
import { posix } from "node:path";
import { db } from "@dokploy/server/db";
import {
type apiCreateSandbox,
environments,
SANDBOX_DEFAULTS,
sandboxes,
} from "@dokploy/server/db/schema";
import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker";
import { TRPCError } from "@trpc/server";
import type Dockerode from "dockerode";
import { desc, eq, inArray } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import {
buildSandboxContainerOptions,
getSandboxNetworkName,
parseSandboxUser,
SANDBOX_LABEL,
type SandboxNetworkMode,
} from "../utils/sandbox/container";
import { runSandboxExec, type SandboxExecResult } from "../utils/sandbox/exec";
import {
buildSandboxFindCommand,
buildSandboxLsCommand,
parseSandboxFindOutput,
parseSandboxLsOutput,
} from "../utils/sandbox/files";
import {
buildSandboxDirectoryTar,
buildSandboxFileTar,
extractSandboxFile,
SandboxFileTooLargeError,
SandboxNotRegularFileError,
} from "../utils/sandbox/tar";
import { SANDBOX_TEMPLATES } from "../utils/sandbox/templates";
export type Sandbox = typeof sandboxes.$inferSelect;
export const SANDBOX_MAX_FILE_BYTES = 10 * 1024 * 1024;
const INTERNAL_EXEC_TIMEOUT_MS = 15_000;
const statusCodeOf = (error: unknown) =>
(error as { statusCode?: number } | null)?.statusCode;
const isNotFound = (error: unknown) => statusCodeOf(error) === 404;
export const findSandboxById = async (sandboxId: string) => {
const result = await db.query.sandboxes.findFirst({
where: eq(sandboxes.sandboxId, sandboxId),
with: {
environment: { with: { project: true } },
server: { columns: { serverId: true, name: true, serverStatus: true } },
},
});
if (!result) {
throw new TRPCError({ code: "NOT_FOUND", message: "Sandbox not found" });
}
return result;
};
export const findSandboxesByEnvironmentId = (environmentId: string) =>
db.query.sandboxes.findMany({
where: eq(sandboxes.environmentId, environmentId),
orderBy: desc(sandboxes.createdAt),
with: { server: { columns: { serverId: true, name: true } } },
});
export const findSandboxesByProjectId = async (projectId: string) => {
const projectEnvironments = await db.query.environments.findMany({
where: eq(environments.projectId, projectId),
columns: { environmentId: true },
});
if (projectEnvironments.length === 0) return [];
return db.query.sandboxes.findMany({
where: inArray(
sandboxes.environmentId,
projectEnvironments.map((env) => env.environmentId),
),
orderBy: desc(sandboxes.createdAt),
with: { server: { columns: { serverId: true, name: true } } },
});
};
export const updateSandboxById = async (
sandboxId: string,
data: Partial<Sandbox>,
) => {
const [result] = await db
.update(sandboxes)
.set(data)
.where(eq(sandboxes.sandboxId, sandboxId))
.returning();
return result;
};
export const removeSandboxById = async (sandboxId: string) => {
const [result] = await db
.delete(sandboxes)
.where(eq(sandboxes.sandboxId, sandboxId))
.returning();
return result;
};
export const ensureSandboxImage = async (docker: Dockerode, image: string) => {
try {
await docker.getImage(image).inspect();
return;
} catch (error) {
if (!isNotFound(error)) throw error;
}
await new Promise<void>((resolve, reject) => {
docker.pull(image, {}, (err, stream) => {
if (err) {
reject(err);
return;
}
docker.modem.followProgress(stream, (progressError: Error | null) => {
if (progressError) reject(progressError);
else resolve();
});
});
});
};
// Sandboxes share a bridge per server, so inter-container traffic must be
// disabled or two isolated sandboxes could talk to each other.
export const SANDBOX_NETWORK_OPTIONS = {
"com.docker.network.bridge.enable_icc": "false",
};
export const ensureSandboxNetwork = async (
docker: Dockerode,
mode: SandboxNetworkMode,
) => {
const name = getSandboxNetworkName(mode);
let existing: {
Options?: Record<string, string>;
Containers?: Record<string, unknown>;
} | null = null;
try {
existing = await docker.getNetwork(name).inspect();
} catch (error) {
if (!isNotFound(error)) throw error;
}
if (existing) {
if (
existing.Options?.["com.docker.network.bridge.enable_icc"] === "false"
) {
return name;
}
if (Object.keys(existing.Containers ?? {}).length > 0) {
console.warn(
`[Sandbox] Network ${name} allows inter-container traffic but is in use; it will be recreated once no sandbox is attached`,
);
return name;
}
await docker.getNetwork(name).remove();
}
try {
await docker.createNetwork({
Name: name,
Driver: "bridge",
Internal: mode === "isolated",
CheckDuplicate: true,
Options: { ...SANDBOX_NETWORK_OPTIONS },
Labels: { [SANDBOX_LABEL]: "true" },
});
} catch (error) {
// Another create raced us; the network exists now.
if (statusCodeOf(error) !== 409) throw error;
}
return name;
};
const resolveSandboxUser = async (
docker: Dockerode,
containerId: string,
fallback: string | null,
) => {
try {
const result = await runSandboxExec(docker, containerId, {
cmd: "id -u && id -g",
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
});
const [uid, gid] = result.stdout.trim().split(/\s+/);
if (result.exitCode === 0 && uid && gid) return `${uid}:${gid}`;
} catch {}
return fallback;
};
const prepareSandboxWorkdir = async (
docker: Dockerode,
containerId: string,
workdir: string,
user: string | null,
) => {
const parent = posix.dirname(workdir);
const name = posix.basename(workdir);
if (!name) return;
if (parent !== "/") {
await runSandboxExec(docker, containerId, {
cmd: `mkdir -p ${quote([parent])}`,
user: "root",
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
}).catch(() => {});
}
const ids = parseSandboxUser(user) ?? { uid: 0, gid: 0 };
// putArchive runs in the daemon, so it can create the directory owned by the
// sandbox user even though every capability (CAP_CHOWN included) is dropped.
const tar = await buildSandboxDirectoryTar({ name, ...ids });
await docker.getContainer(containerId).putArchive(tar, { path: parent });
};
const startSandboxContainer = async (sandbox: Sandbox, projectId: string) => {
const docker = await getRemoteDocker(sandbox.serverId);
await ensureSandboxImage(docker, sandbox.image);
await ensureSandboxNetwork(docker, sandbox.networkMode);
const container = await docker.createContainer(
buildSandboxContainerOptions({ ...sandbox, projectId }),
);
try {
await container.start();
const user = await resolveSandboxUser(docker, container.id, sandbox.user);
await prepareSandboxWorkdir(docker, container.id, sandbox.workdir, user);
const now = new Date();
return await updateSandboxById(sandbox.sandboxId, {
containerId: container.id,
user,
status: "running",
expiresAt: new Date(now.getTime() + sandbox.timeoutMs),
lastActivityAt: now,
});
} catch (error) {
await container.remove({ force: true }).catch(() => {});
throw error;
}
};
export const createSandbox = async (
input: z.infer<typeof apiCreateSandbox>,
) => {
const environment = await db.query.environments.findFirst({
where: eq(environments.environmentId, input.environmentId),
columns: { environmentId: true, projectId: true },
});
if (!environment) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Environment not found",
});
}
const template = input.template ? SANDBOX_TEMPLATES[input.template] : null;
const image = input.image ?? template?.image;
if (!image) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either template or image is required",
});
}
const [sandbox] = await db
.insert(sandboxes)
.values({
name: input.name ?? `sandbox-${Date.now().toString(36)}`,
environmentId: input.environmentId,
serverId: input.serverId ?? null,
image,
template: input.template ?? null,
cpu: input.cpu ?? SANDBOX_DEFAULTS.cpu,
memoryMb: input.memoryMb ?? SANDBOX_DEFAULTS.memoryMb,
pidsLimit: input.pidsLimit ?? SANDBOX_DEFAULTS.pidsLimit,
timeoutMs: input.timeoutMs ?? SANDBOX_DEFAULTS.timeoutMs,
networkMode: input.networkMode ?? "isolated",
envVars: input.envVars ?? null,
workdir: input.workdir ?? template?.workdir ?? SANDBOX_DEFAULTS.workdir,
user: template?.user ?? null,
status: "creating",
})
.returning();
if (!sandbox) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error inserting sandbox",
});
}
try {
const running = await startSandboxContainer(sandbox, environment.projectId);
if (!running) throw new Error("Sandbox row disappeared during creation");
return running;
} catch (error) {
await updateSandboxById(sandbox.sandboxId, { status: "error" });
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error creating sandbox: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
};
type RunningSandbox = Sandbox & { containerId: string };
export const assertSandboxRunning: (
sandbox: Sandbox,
) => asserts sandbox is RunningSandbox = (sandbox) => {
if (sandbox.status !== "running" || !sandbox.containerId) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Sandbox is ${sandbox.status}`,
});
}
};
export const touchSandbox = async (sandbox: Sandbox) => {
const now = new Date();
return updateSandboxById(sandbox.sandboxId, {
lastActivityAt: now,
expiresAt: new Date(now.getTime() + sandbox.timeoutMs),
});
};
export interface ExecInSandboxOptions {
cmd: string;
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
}
export const execInSandbox = async (
sandbox: Sandbox,
options: ExecInSandboxOptions,
): Promise<SandboxExecResult> => {
assertSandboxRunning(sandbox);
await touchSandbox(sandbox);
const docker = await getRemoteDocker(sandbox.serverId);
const result = await runSandboxExec(docker, sandbox.containerId, {
cmd: options.cmd,
cwd: options.cwd ?? sandbox.workdir,
env: options.env,
timeoutMs: options.timeoutMs ?? SANDBOX_DEFAULTS.execTimeoutMs,
onStdout: options.onStdout,
onStderr: options.onStderr,
});
if (result.containerKilled && !result.containerRestarted) {
await updateSandboxById(sandbox.sandboxId, { status: "error" });
}
return result;
};
export const writeSandboxFile = async (
sandbox: Sandbox,
options: { path: string; content: Buffer; mode?: number },
) => {
assertSandboxRunning(sandbox);
if (options.content.length > SANDBOX_MAX_FILE_BYTES) {
throw new TRPCError({
code: "PAYLOAD_TOO_LARGE",
message: `File exceeds the ${SANDBOX_MAX_FILE_BYTES} byte limit`,
});
}
const dir = posix.dirname(options.path);
const name = posix.basename(options.path);
if (!name) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Path must point to a file",
});
}
await touchSandbox(sandbox);
const docker = await getRemoteDocker(sandbox.serverId);
const mkdir = await runSandboxExec(docker, sandbox.containerId, {
cmd: `mkdir -p ${quote([dir])}`,
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
});
if (mkdir.exitCode !== 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: mkdir.stderr.trim() || `Unable to create directory ${dir}`,
});
}
const ids = parseSandboxUser(sandbox.user) ?? { uid: 0, gid: 0 };
const tar = await buildSandboxFileTar({
name,
content: options.content,
mode: options.mode,
...ids,
});
await docker.getContainer(sandbox.containerId).putArchive(tar, { path: dir });
return { path: options.path, size: options.content.length };
};
export const readSandboxFile = async (sandbox: Sandbox, path: string) => {
assertSandboxRunning(sandbox);
await touchSandbox(sandbox);
const docker = await getRemoteDocker(sandbox.serverId);
let archive: NodeJS.ReadableStream;
try {
archive = await docker
.getContainer(sandbox.containerId)
.getArchive({ path });
} catch (error) {
if (isNotFound(error)) {
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
}
throw error;
}
try {
const { content } = await extractSandboxFile(
archive,
SANDBOX_MAX_FILE_BYTES,
);
return { path, size: content.length, content };
} catch (error) {
if (error instanceof SandboxFileTooLargeError) {
throw new TRPCError({
code: "PAYLOAD_TOO_LARGE",
message: error.message,
});
}
if (error instanceof SandboxNotRegularFileError) {
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
}
throw error;
}
};
export const listSandboxFiles = async (sandbox: Sandbox, path?: string) => {
assertSandboxRunning(sandbox);
await touchSandbox(sandbox);
const target = path ?? sandbox.workdir;
const docker = await getRemoteDocker(sandbox.serverId);
const find = await runSandboxExec(docker, sandbox.containerId, {
cmd: buildSandboxFindCommand(target),
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
maxOutputBytes: 4 * 1024 * 1024,
});
if (find.exitCode === 0) {
return { path: target, entries: parseSandboxFindOutput(find.stdout) };
}
// busybox find has no -printf; fall back to a name-only listing.
const ls = await runSandboxExec(docker, sandbox.containerId, {
cmd: buildSandboxLsCommand(target),
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
maxOutputBytes: 4 * 1024 * 1024,
});
if (ls.exitCode === 0) {
return { path: target, entries: parseSandboxLsOutput(ls.stdout) };
}
throw new TRPCError({
code: "BAD_REQUEST",
message: (ls.stderr || find.stderr).trim() || "Unable to list files",
});
};
export const setSandboxTimeout = async (
sandbox: Sandbox,
timeoutMs: number,
) => {
assertSandboxRunning(sandbox);
const now = new Date();
return updateSandboxById(sandbox.sandboxId, {
timeoutMs,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + timeoutMs),
});
};
export const removeSandboxContainer = async (
serverId: string | null,
containerId: string,
) => {
const docker = await getRemoteDocker(serverId);
const container = docker.getContainer(containerId);
await container.stop({ t: 2 }).catch((error: unknown) => {
// 304 = already stopped, 404 = already gone
if (statusCodeOf(error) !== 304 && !isNotFound(error)) throw error;
});
await container.remove({ force: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error;
});
};
export const killSandbox = async (sandboxId: string) => {
const sandbox = await findSandboxById(sandboxId);
if (sandbox.status === "killed") return sandbox;
if (sandbox.containerId) {
try {
await removeSandboxContainer(sandbox.serverId, sandbox.containerId);
} catch (error) {
console.error(
`[Sandbox] Failed to remove container for ${sandboxId}:`,
error instanceof Error ? error.message : error,
);
}
}
return updateSandboxById(sandboxId, {
status: "killed",
killedAt: new Date(),
});
};
export const removeSandbox = async (sandboxId: string) => {
await killSandbox(sandboxId);
return removeSandboxById(sandboxId);
};
@@ -0,0 +1,88 @@
import type Dockerode from "dockerode";
export const SANDBOX_LABEL = "dokploy.sandbox";
export const SANDBOX_ID_LABEL = "dokploy.sandboxId";
export const SANDBOX_PROJECT_LABEL = "dokploy.projectId";
export const SANDBOX_ENVIRONMENT_LABEL = "dokploy.environmentId";
export const SANDBOX_ISOLATED_NETWORK = "dokploy-sandboxes-isolated";
export const SANDBOX_INTERNET_NETWORK = "dokploy-sandboxes";
export type SandboxNetworkMode = "isolated" | "internet";
export interface SandboxContainerSpec {
sandboxId: string;
projectId: string;
environmentId: string;
image: string;
cpu: number;
memoryMb: number;
pidsLimit: number;
networkMode: SandboxNetworkMode;
envVars?: string | null;
workdir: string;
user?: string | null;
}
export const getSandboxNetworkName = (mode: SandboxNetworkMode) =>
mode === "internet" ? SANDBOX_INTERNET_NETWORK : SANDBOX_ISOLATED_NETWORK;
export const getSandboxContainerName = (sandboxId: string) =>
`dokploy-sandbox-${sandboxId}`;
export const parseSandboxEnvVars = (envVars?: string | null): string[] => {
if (!envVars) return [];
return envVars
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#") && line.includes("="));
};
export const parseSandboxUser = (
user?: string | null,
): { uid: number; gid: number } | null => {
if (!user) return null;
const [uidPart, gidPart] = user.split(":");
const uid = Number.parseInt(uidPart ?? "", 10);
const gid = Number.parseInt(gidPart ?? uidPart ?? "", 10);
if (!Number.isInteger(uid) || uid < 0) return null;
return { uid, gid: Number.isInteger(gid) && gid >= 0 ? gid : uid };
};
export const buildSandboxContainerOptions = (
spec: SandboxContainerSpec,
): Dockerode.ContainerCreateOptions => {
const memory = Math.round(spec.memoryMb * 1024 * 1024);
return {
name: getSandboxContainerName(spec.sandboxId),
Image: spec.image,
// [""] resets the image entrypoint so Cmd always becomes PID 1.
Entrypoint: [""],
Cmd: ["tail", "-f", "/dev/null"],
WorkingDir: spec.workdir,
Env: [`HOME=${spec.workdir}`, ...parseSandboxEnvVars(spec.envVars)],
...(spec.user ? { User: spec.user } : {}),
Labels: {
[SANDBOX_LABEL]: "true",
[SANDBOX_ID_LABEL]: spec.sandboxId,
[SANDBOX_PROJECT_LABEL]: spec.projectId,
[SANDBOX_ENVIRONMENT_LABEL]: spec.environmentId,
},
HostConfig: {
// docker-init (tini) as PID 1 reaps the orphans left behind when a
// timed-out command's shell is killed; `tail` would leave zombies.
Init: true,
NanoCpus: Math.round(spec.cpu * 1e9),
Memory: memory,
MemorySwap: memory,
PidsLimit: spec.pidsLimit,
CapDrop: ["ALL"],
SecurityOpt: ["no-new-privileges"],
RestartPolicy: { Name: "no" },
LogConfig: {
Type: "json-file",
Config: { "max-size": "10m", "max-file": "1" },
},
NetworkMode: getSandboxNetworkName(spec.networkMode),
},
};
};
+184
View File
@@ -0,0 +1,184 @@
import { Writable } from "node:stream";
import { StringDecoder } from "node:string_decoder";
import type Dockerode from "dockerode";
import { nanoid } from "nanoid";
import { quote } from "shell-quote";
export const SANDBOX_EXEC_TIMEOUT_EXIT_CODE = 124;
export const SANDBOX_EXEC_MARKER_ENV = "DOKPLOY_EXEC_ID";
export const SANDBOX_EXEC_MAX_OUTPUT_BYTES = 1024 * 1024;
const KILL_GRACE_MS = 2000;
export interface SandboxExecOptions {
cmd: string;
cwd?: string;
env?: Record<string, string> | string[];
user?: string;
timeoutMs: number;
maxOutputBytes?: number;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
}
export interface SandboxExecResult {
stdout: string;
stderr: string;
exitCode: number;
timedOut: boolean;
truncated: boolean;
containerKilled: boolean;
containerRestarted: boolean;
}
export const toSandboxEnvArray = (env?: Record<string, string> | string[]) =>
Array.isArray(env)
? env
: Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`);
// Finds every process started by the exec (children included) through the
// marker env var and kills it with the shell builtin `kill`, so each
// iteration needs a single extra pid, which matters when the command
// exhausted PidsLimit. GNU grep reads the NUL-separated environ with -z;
// busybox grep has no -z, so it falls back to tr.
export const buildSandboxKillCommand = (marker: string) => {
const pattern = quote([`${SANDBOX_EXEC_MARKER_ENV}=${marker}`]);
const kill = 'p="${f#/proc/}"; kill -9 "${p%/environ}" 2>/dev/null';
const withZ = `for f in /proc/[0-9]*/environ; do if grep -qzxF ${pattern} "$f" 2>/dev/null; then ${kill}; fi; done`;
const withTr = `for f in /proc/[0-9]*/environ; do if tr '\\0' '\\n' < "$f" 2>/dev/null | grep -qxF ${pattern}; then ${kill}; fi; done`;
return `if grep -qzF x /dev/null 2>/dev/null; [ $? -ne 2 ]; then ${withZ}; else ${withTr}; fi; true`;
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createSink = (
maxBytes: number,
onChunk: ((chunk: string) => void) | undefined,
state: { truncated: boolean },
) => {
const chunks: Buffer[] = [];
const decoder = new StringDecoder("utf8");
let bytes = 0;
const sink = new Writable({
write(chunk: Buffer, _encoding, next) {
onChunk?.(decoder.write(chunk));
if (bytes < maxBytes) {
const slice = chunk.subarray(0, maxBytes - bytes);
chunks.push(slice);
bytes += slice.length;
if (slice.length < chunk.length) state.truncated = true;
} else {
state.truncated = true;
}
next();
},
});
return { sink, text: () => Buffer.concat(chunks).toString("utf8") };
};
const killByMarker = async (
docker: Dockerode,
containerId: string,
marker: string,
) => {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["sh", "-c", buildSandboxKillCommand(marker)],
AttachStdout: false,
AttachStderr: false,
});
await exec.start({ Detach: true });
};
export const runSandboxExec = async (
docker: Dockerode,
containerId: string,
options: SandboxExecOptions,
): Promise<SandboxExecResult> => {
const container = docker.getContainer(containerId);
const marker = nanoid();
const exec = await container.exec({
Cmd: ["sh", "-c", options.cmd],
AttachStdout: true,
AttachStderr: true,
Tty: false,
WorkingDir: options.cwd,
Env: [
...toSandboxEnvArray(options.env),
`${SANDBOX_EXEC_MARKER_ENV}=${marker}`,
],
...(options.user ? { User: options.user } : {}),
});
const stream = await exec.start({ hijack: true, stdin: false });
const maxBytes = options.maxOutputBytes ?? SANDBOX_EXEC_MAX_OUTPUT_BYTES;
const state = { truncated: false };
const stdout = createSink(maxBytes, options.onStdout, state);
const stderr = createSink(maxBytes, options.onStderr, state);
const finished = new Promise<void>((resolve, reject) => {
stream.once("end", resolve);
stream.once("close", resolve);
stream.once("error", reject);
});
docker.modem.demuxStream(stream, stdout.sink, stderr.sink);
let timedOut = false;
let containerKilled = false;
let containerRestarted = false;
let timeoutTask: Promise<void> | null = null;
const timer = setTimeout(() => {
timedOut = true;
timeoutTask = (async () => {
await killByMarker(docker, containerId, marker).catch(() => {});
const ended = await Promise.race([
finished.then(
() => true,
() => true,
),
sleep(KILL_GRACE_MS).then(() => false),
]);
if (!ended) {
// The marker kill could not run (e.g. PidsLimit exhausted, no
// grep). Killing and starting the container again drops every
// process while keeping the filesystem, so the sandbox stays usable.
await container
.kill({ signal: "SIGKILL" })
.then(() => {
containerKilled = true;
})
.catch(() => {});
stream.destroy();
if (containerKilled) {
await container
.start()
.then(() => {
containerRestarted = true;
})
.catch(() => {});
}
}
})();
}, options.timeoutMs);
try {
await finished;
} finally {
clearTimeout(timer);
}
if (timeoutTask) await timeoutTask;
let exitCode = SANDBOX_EXEC_TIMEOUT_EXIT_CODE;
if (!timedOut) {
const info = await exec.inspect();
exitCode = info.ExitCode ?? 1;
}
return {
stdout: stdout.text(),
stderr: stderr.text(),
exitCode,
timedOut,
truncated: state.truncated,
containerKilled,
containerRestarted,
};
};
@@ -0,0 +1,68 @@
import { quote } from "shell-quote";
export type SandboxFileType = "file" | "directory" | "symlink" | "other";
export interface SandboxFileEntry {
name: string;
type: SandboxFileType;
size: number | null;
mode: string | null;
modifiedAt: string | null;
}
export const buildSandboxFindCommand = (path: string) =>
`find ${quote([path])} -mindepth 1 -maxdepth 1 -printf '%y\\t%s\\t%T@\\t%m\\t%f\\n'`;
export const buildSandboxLsCommand = (path: string) =>
`ls -1Ap ${quote([path])}`;
const FIND_TYPES: Record<string, SandboxFileType> = {
f: "file",
d: "directory",
l: "symlink",
};
const sortEntries = (a: SandboxFileEntry, b: SandboxFileEntry) => {
if (a.type !== b.type) {
if (a.type === "directory") return -1;
if (b.type === "directory") return 1;
}
return a.name.localeCompare(b.name);
};
export const parseSandboxFindOutput = (stdout: string): SandboxFileEntry[] =>
stdout
.split("\n")
.filter(Boolean)
.flatMap((line) => {
const [type, size, mtime, mode, ...rest] = line.split("\t");
const name = rest.join("\t");
if (!name) return [];
const seconds = Number.parseFloat(mtime ?? "");
const bytes = Number.parseInt(size ?? "", 10);
return [
{
name,
type: FIND_TYPES[type ?? ""] ?? "other",
size: Number.isInteger(bytes) ? bytes : null,
mode: mode || null,
modifiedAt: Number.isFinite(seconds)
? new Date(Math.floor(seconds * 1000)).toISOString()
: null,
},
];
})
.sort(sortEntries);
export const parseSandboxLsOutput = (stdout: string): SandboxFileEntry[] =>
stdout
.split("\n")
.filter(Boolean)
.map((entry) => ({
name: entry.endsWith("/") ? entry.slice(0, -1) : entry,
type: (entry.endsWith("/") ? "directory" : "file") as SandboxFileType,
size: null,
mode: null,
modifiedAt: null,
}))
.sort(sortEntries);
+188
View File
@@ -0,0 +1,188 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { sandboxes, server } from "@dokploy/server/db/schema";
import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker";
import type Dockerode from "dockerode";
import { and, eq, inArray, isNotNull, isNull, lte } from "drizzle-orm";
import { killSandbox } from "../../services/sandbox";
import { SANDBOX_ID_LABEL, SANDBOX_LABEL } from "./container";
export const SANDBOX_REAPER_INTERVAL_MS = 15_000;
const STUCK_CREATING_MS = 10 * 60 * 1000;
let reaperTimer: NodeJS.Timeout | null = null;
let reaping = false;
export const findExpiredSandboxes = <
T extends { status: string; expiresAt: Date | null },
>(
list: T[],
now: Date,
) =>
list.filter(
(sandbox) =>
sandbox.status === "running" &&
!!sandbox.expiresAt &&
sandbox.expiresAt.getTime() <= now.getTime(),
);
export const reapExpiredSandboxes = async (
now = new Date(),
kill: (sandboxId: string) => Promise<unknown> = killSandbox,
) => {
const expired = await db.query.sandboxes.findMany({
where: and(eq(sandboxes.status, "running"), lte(sandboxes.expiresAt, now)),
columns: { sandboxId: true, status: true, expiresAt: true },
});
const killed: string[] = [];
for (const sandbox of findExpiredSandboxes(expired, now)) {
try {
await kill(sandbox.sandboxId);
killed.push(sandbox.sandboxId);
} catch (error) {
console.error(
`[Sandbox] Failed to reap ${sandbox.sandboxId}:`,
error instanceof Error ? error.message : error,
);
}
}
return killed;
};
export interface SandboxReconcilePlan {
removeContainers: string[];
markError: string[];
}
export const planSandboxReconcile = (
rows: { sandboxId: string; containerId: string | null; status: string }[],
containers: { Id: string; State: string; Labels: Record<string, string> }[],
): SandboxReconcilePlan => {
const rowsById = new Map(rows.map((row) => [row.sandboxId, row]));
const liveContainerIds = new Set<string>();
const removeContainers: string[] = [];
for (const container of containers) {
const sandboxId = container.Labels?.[SANDBOX_ID_LABEL];
const row = sandboxId ? rowsById.get(sandboxId) : undefined;
// A sandbox mid-creation has no containerId yet; leave it to createSandbox.
if (row?.status === "creating") continue;
if (
!row ||
row.containerId !== container.Id ||
container.State !== "running"
) {
removeContainers.push(container.Id);
continue;
}
liveContainerIds.add(container.Id);
}
const markError = rows
.filter(
(row) =>
row.status === "running" &&
(!row.containerId || !liveContainerIds.has(row.containerId)),
)
.map((row) => row.sandboxId);
return { removeContainers, markError };
};
const listSandboxContainers = (docker: Dockerode) =>
docker.listContainers({
all: true,
filters: { label: [`${SANDBOX_LABEL}=true`] },
});
const reconcileServer = async (serverId: string | null) => {
const docker = await getRemoteDocker(serverId);
const [containers, rows] = await Promise.all([
listSandboxContainers(docker),
db.query.sandboxes.findMany({
where: and(
inArray(sandboxes.status, ["running", "creating"]),
serverId
? eq(sandboxes.serverId, serverId)
: isNull(sandboxes.serverId),
),
columns: { sandboxId: true, containerId: true, status: true },
}),
]);
const plan = planSandboxReconcile(rows, containers);
for (const containerId of plan.removeContainers) {
await docker
.getContainer(containerId)
.remove({ force: true })
.catch(() => {});
}
if (plan.markError.length > 0) {
await db
.update(sandboxes)
.set({ status: "error" })
.where(inArray(sandboxes.sandboxId, plan.markError));
}
return plan;
};
export const reconcileSandboxes = async () => {
const stuckSince = new Date(Date.now() - STUCK_CREATING_MS);
await db
.update(sandboxes)
.set({ status: "error" })
.where(
and(
eq(sandboxes.status, "creating"),
lte(sandboxes.createdAt, stuckSince.toISOString()),
),
);
const serverIds: (string | null)[] = IS_CLOUD ? [] : [null];
const remoteServers = await db.query.server.findMany({
where: isNotNull(server.sshKeyId),
columns: { serverId: true },
});
serverIds.push(...remoteServers.map((remote) => remote.serverId));
for (const serverId of serverIds) {
try {
const plan = await reconcileServer(serverId);
if (plan.removeContainers.length || plan.markError.length) {
console.log(
`[Sandbox] Reconciled ${serverId ?? "local"}: removed ${plan.removeContainers.length} containers, errored ${plan.markError.length} sandboxes`,
);
}
} catch (error) {
console.error(
`[Sandbox] Reconcile failed for ${serverId ?? "local"}:`,
error instanceof Error ? error.message : error,
);
}
}
};
export const initSandboxReaper = async () => {
if (reaperTimer) return;
await reconcileSandboxes();
reaperTimer = setInterval(async () => {
if (reaping) return;
reaping = true;
try {
await reapExpiredSandboxes();
} catch (error) {
console.error(
"[Sandbox] Reaper failed:",
error instanceof Error ? error.message : error,
);
} finally {
reaping = false;
}
}, SANDBOX_REAPER_INTERVAL_MS);
reaperTimer.unref();
};
export const stopSandboxReaper = () => {
if (reaperTimer) clearInterval(reaperTimer);
reaperTimer = null;
};
+132
View File
@@ -0,0 +1,132 @@
import type { Readable } from "node:stream";
import { extract, pack } from "tar-stream";
export interface SandboxTarFile {
name: string;
content: Buffer;
mode?: number;
uid?: number;
gid?: number;
}
export interface SandboxTarDirectory {
name: string;
mode?: number;
uid?: number;
gid?: number;
}
const toBuffer = (chunk: unknown) =>
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array | string);
const collect = async (stream: AsyncIterable<unknown>) => {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(toBuffer(chunk));
}
return Buffer.concat(chunks);
};
export const buildSandboxFileTar = async (file: SandboxTarFile) => {
const tar = pack();
tar.entry(
{
name: file.name,
type: "file",
size: file.content.length,
mode: file.mode ?? 0o644,
uid: file.uid ?? 0,
gid: file.gid ?? 0,
mtime: new Date(),
},
file.content,
);
tar.finalize();
return collect(tar);
};
export const buildSandboxDirectoryTar = async (dir: SandboxTarDirectory) => {
const tar = pack();
tar.entry({
name: dir.name.endsWith("/") ? dir.name : `${dir.name}/`,
type: "directory",
mode: dir.mode ?? 0o755,
uid: dir.uid ?? 0,
gid: dir.gid ?? 0,
mtime: new Date(),
});
tar.finalize();
return collect(tar);
};
export class SandboxFileTooLargeError extends Error {
constructor(maxBytes: number) {
super(`File exceeds the ${maxBytes} byte limit`);
this.name = "SandboxFileTooLargeError";
}
}
export class SandboxNotRegularFileError extends Error {
constructor() {
super("Path is not a regular file");
this.name = "SandboxNotRegularFileError";
}
}
export const extractSandboxFile = (
source: NodeJS.ReadableStream,
maxBytes: number,
): Promise<{ name: string; content: Buffer }> =>
new Promise((resolve, reject) => {
const extractor = extract();
let found: { name: string; content: Buffer } | null = null;
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
extractor.destroy();
(source as Readable).destroy?.();
reject(error);
};
let first = true;
extractor.on("entry", (header, entryStream, next) => {
// Docker returns the requested path as the first entry; a directory
// tar would otherwise leak its first file as if it were the target.
if (first && header.type !== "file") {
fail(new SandboxNotRegularFileError());
return;
}
first = false;
if (found || header.type !== "file") {
entryStream.on("end", next);
entryStream.resume();
return;
}
const chunks: Buffer[] = [];
let size = 0;
entryStream.on("data", (data: unknown) => {
const chunk = toBuffer(data);
size += chunk.length;
if (size > maxBytes) {
fail(new SandboxFileTooLargeError(maxBytes));
return;
}
chunks.push(chunk);
});
entryStream.on("end", () => {
found = { name: header.name, content: Buffer.concat(chunks) };
next();
});
});
extractor.on("finish", () => {
if (settled) return;
settled = true;
if (found) resolve(found);
else reject(new SandboxNotRegularFileError());
});
extractor.on("error", fail);
source.on("error", fail);
source.pipe(extractor);
});
@@ -0,0 +1,30 @@
import type { SandboxTemplateName } from "@dokploy/server/db/schema";
export interface SandboxTemplate {
image: string;
user: string | null;
workdir: string;
description: string;
}
// Placeholder images until the dokploy/sandbox-* images are published.
export const SANDBOX_TEMPLATES: Record<SandboxTemplateName, SandboxTemplate> = {
base: {
image: "ubuntu:24.04",
user: "1000:1000",
workdir: "/home/user",
description: "Ubuntu 24.04 with a POSIX shell and coreutils",
},
python: {
image: "python:3.12-slim",
user: "1000:1000",
workdir: "/home/user",
description: "Python 3.12 (pip available)",
},
node: {
image: "node:22-slim",
user: "1000:1000",
workdir: "/home/user",
description: "Node.js 22 (npm available)",
},
};
+148
View File
@@ -740,6 +740,9 @@ importers:
ssh2:
specifier: ~1.16.0
version: 1.16.0
tar-stream:
specifier: 3.2.1
version: 3.2.1
toml:
specifier: 3.0.0
version: 3.0.0
@@ -795,6 +798,9 @@ importers:
'@types/ssh2':
specifier: 1.15.1
version: 1.15.1
'@types/tar-stream':
specifier: 3.1.4
version: 3.1.4
'@types/ws':
specifier: 8.5.10
version: 8.5.10
@@ -4435,6 +4441,9 @@ packages:
'@types/swagger-ui-react@4.19.0':
resolution: {integrity: sha512-uScp1xkLZJej0bt3/lO4U11ywWEBnI5CFCR0tqp+5Rvxl1Mj1v6VkGED0W70jJwqlBvbD+/a6bDiK8rjepCr8g==}
'@types/tar-stream@3.1.4':
resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==}
'@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
@@ -4861,6 +4870,14 @@ packages:
axios@1.18.0:
resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
b4a@1.8.1:
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -4871,6 +4888,43 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
bare-events@2.9.2:
resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-fs@4.8.1:
resolution: {integrity: sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==}
engines: {bare: '>=1.28.0'}
peerDependencies:
bare-buffer: '*'
peerDependenciesMeta:
bare-buffer:
optional: true
bare-path@3.1.2:
resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==}
bare-stream@2.13.4:
resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==}
peerDependencies:
bare-abort-controller: '*'
bare-buffer: '*'
bare-events: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-buffer:
optional: true
bare-events:
optional: true
bare-url@2.5.4:
resolution: {integrity: sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
@@ -5932,6 +5986,9 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -5993,6 +6050,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -8446,6 +8506,9 @@ packages:
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
engines: {node: '>=18'}
streamx@2.28.1:
resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@@ -8606,16 +8669,25 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar-stream@3.2.1:
resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==}
tar@7.5.22:
resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==}
engines: {node: '>=18'}
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
temporal-polyfill@0.2.5:
resolution: {integrity: sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==}
temporal-spec@0.2.4:
resolution: {integrity: sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==}
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -13626,6 +13698,10 @@ snapshots:
dependencies:
'@types/react': 18.3.5
'@types/tar-stream@3.1.4':
dependencies:
'@types/node': 24.10.13
'@types/tedious@4.0.14':
dependencies:
'@types/node': 24.10.13
@@ -13994,12 +14070,43 @@ snapshots:
- debug
- supports-color
b4a@1.8.1: {}
bail@2.0.2: {}
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
bare-events@2.9.2: {}
bare-fs@4.8.1:
dependencies:
bare-events: 2.9.2
bare-path: 3.1.2
bare-stream: 2.13.4(bare-events@2.9.2)
bare-url: 2.5.4
fast-fifo: 1.3.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
bare-path@3.1.2: {}
bare-stream@2.13.4(bare-events@2.9.2):
dependencies:
b4a: 1.8.1
streamx: 2.28.1
teex: 1.0.1
optionalDependencies:
bare-events: 2.9.2
transitivePeerDependencies:
- react-native-b4a
bare-url@2.5.4:
dependencies:
bare-path: 3.1.2
base64-arraybuffer@1.0.2: {}
base64-js@1.5.1: {}
@@ -14910,6 +15017,12 @@ snapshots:
eventemitter3@5.0.4: {}
events-universal@1.0.1:
dependencies:
bare-events: 2.9.2
transitivePeerDependencies:
- bare-abort-controller
events@3.3.0: {}
eventsource-parser@3.0.6: {}
@@ -15018,6 +15131,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -17755,6 +17870,15 @@ snapshots:
stdin-discarder@0.2.2: {}
streamx@2.28.1:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-argv@0.3.2: {}
string-width@4.2.3:
@@ -17998,6 +18122,17 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
tar-stream@3.2.1:
dependencies:
b4a: 1.8.1
bare-fs: 4.8.1
fast-fifo: 1.3.2
streamx: 2.28.1
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
tar@7.5.22:
dependencies:
'@isaacs/fs-minipass': 4.0.1
@@ -18006,12 +18141,25 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
teex@1.0.1:
dependencies:
streamx: 2.28.1
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
temporal-polyfill@0.2.5:
dependencies:
temporal-spec: 0.2.4
temporal-spec@0.2.4: {}
text-decoder@1.2.7:
dependencies:
b4a: 1.8.1
transitivePeerDependencies:
- react-native-b4a
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1