Compare commits

...

21 Commits

Author SHA1 Message Date
Mauricio Siu 9226c2d531 fix(sandbox): block inter-sandbox traffic, restart container when the timeout kill cannot fork, skip sandboxes mid-creation in reconcile 2026-09-10 01:38:43 -06:00
Mauricio Siu 0c38a498ef fix(sandbox): reap orphaned processes with docker-init, reject directory reads, buffer early websocket messages 2026-09-10 01:30:15 -06:00
Mauricio Siu fec13d604e docs(sandbox): API reference, security model and limitations 2026-09-10 01:16:57 -06:00
Mauricio Siu 3145478595 test(sandbox): container options, tar, file listing, exec runner and reaper 2026-09-10 01:16:56 -06:00
Mauricio Siu e3954f7915 feat(sandbox): sandboxes list and detail UI 2026-09-10 01:16:55 -06:00
Mauricio Siu ad00da50c1 feat(sandbox): tRPC router, OpenAPI exposure and streaming exec websocket 2026-09-10 01:16:54 -06:00
Mauricio Siu 084fe765f0 feat(sandbox): service, exec runner, file helpers and reaper 2026-09-10 01:16:53 -06:00
Mauricio Siu c8db68b637 feat(sandbox): add sandbox schema and migration 2026-09-10 00:49:49 -06:00
Mauricio Siu 88118cdb3f Merge pull request #5411 from Dokploy/fix/fresh-volumes-danger-zone
fix(compose): move Fresh Volumes into a Danger Zone with typed confirmation
2026-09-09 17:48:16 -06:00
Mauricio Siu 38d157adc0 fix(compose): move Fresh Volumes into a Danger Zone with typed confirmation 2026-09-09 17:47:16 -06:00
Mauricio Siu 3b1fec5409 Merge pull request #5410 from Dokploy/feat/compose-pull-images
feat(compose): pull latest images on deploy toggle
2026-09-09 17:14:43 -06:00
Mauricio Siu 49b8214e5d feat(compose): add pull latest images on deploy toggle 2026-09-09 17:13:24 -06:00
Mauricio Siu ec31739e0e Merge pull request #5396 from Dokploy/feat/transfer-service-between-servers
feat: transfer services between servers
2026-09-09 13:21:27 -06:00
Narciso E. Núñez Arias d5328703fa Merge pull request #5407 from imrja8/fix-sso-flash 2026-09-09 13:15:29 -04:00
Narciso E. Núñez Arias f4cb26cbf4 Merge pull request #5406 from imrja8/fix-settings-layout-width 2026-09-09 13:12:41 -04:00
Yash Kumar a6c1f3048f fix(ui): standardize settings layout widths to full width 2026-09-09 21:24:03 +05:30
Yash Kumar 9cae609a0a fix(auth): prefetch SSO state in SSR to prevent layout flash 2026-09-09 20:09:23 +05:30
Mauricio Siu 2c234d6e20 test(transfer): shrink pipe payload and set an explicit timeout so the suite does not time out in CI 2026-09-09 02:57:51 -06:00
Mauricio Siu 9dcdb53aa1 feat(transfer): move services between servers with their volumes, mounts and config
Adds a Transfer action on every service page that moves an application,
compose or database to another server without S3: volumes, bind mounts
and deployment logs are streamed through the panel (ssh2/spawn pipe),
file mounts and Traefik config are recreated on the target, the source
is cleaned up and the service is deployed on the target. Failures before
cleanup roll back to the source server.
2026-09-09 02:50:29 -06:00
Mauricio Siu bda8124291 Merge pull request #5351 from Dokploy/fix/dependabot-critical-security-updates
fix: resolve 6 critical Dependabot alerts
2026-09-08 16:29:38 -06:00
Narciso 19589694ea fix: resolve 6 critical Dependabot alerts
- tar >=7.5.19 (GHSA-23hp-3jrh-7fpw): decompression DoS, pinned via
  pnpm override within 7.x (transitive via @mapbox/node-pre-gyp)
- protobufjs >=7.5.5 (GHSA-xq3m-2v4x-88gg): arbitrary code execution,
  pinned via pnpm override within 7.x (transitive via @grpc/proto-loader,
  @opentelemetry/otlp-transformer)
- shell-quote ^1.8.4 (GHSA-w7jw-789q-3m8p): quote() didn't escape
  newlines in object .op values (direct dep, used for docker/ssh
  command building)
- vitest ^4.1.0 (GHSA-5xrq-8626-4rwp): arbitrary file read/execute via
  UI server (devDependency)
- github.com/gofiber/fiber/v2 v2.52.11 (GHSA-68rr-p4fp-j59v): insecure
  UUIDv4 fallback on crypto/rand failure (apps/monitoring)
- next (GHSA-f82v-jwr5-mffw): authorization bypass in middleware, was
  a transitive dep of react-email in the standalone
  packages/server/src/emails lockfile; regenerating against the
  current react-email version (6.9.3) drops next.js entirely, since
  it no longer uses it for the preview server

Verified: typecheck (server + app) clean, full vitest suite
(947 passed / 5 env-conditional skips, 0 failures), go build/vet/test
clean for apps/monitoring.
2026-09-03 17:51:40 -04:00
96 changed files with 25757 additions and 4260 deletions
@@ -0,0 +1,44 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "github" as const,
composePath: "./docker-compose.yml",
command: "",
};
describe("compose createCommand --pull always", () => {
it("adds --pull always when pullImages is enabled", () => {
const cmd = createCommand({ ...base, pullImages: true } as any);
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
});
it("omits --pull when pullImages is disabled", () => {
const cmd = createCommand({ ...base, pullImages: false } as any);
expect(cmd).not.toContain("--pull");
});
it("does not add --pull to stack deploy", () => {
const cmd = createCommand({
...base,
composeType: "stack",
pullImages: true,
} as any);
expect(cmd).not.toContain("--pull");
});
it("leaves a custom command untouched", () => {
const cmd = createCommand({
...base,
command: "compose -p compose-app up -d",
pullImages: true,
} as any);
expect(cmd).toBe("compose -p compose-app up -d");
});
});
@@ -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,60 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(),
}));
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
describe("pipeBetweenServers", () => {
it("delivers a short source stream that ends before the target is ready", async () => {
const bytes = await pipeBetweenServers({
source: { serverId: null, command: "printf 'hello world'" },
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
});
expect(bytes).toBe(11);
});
it("pipes data larger than the pipe buffer unchanged", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
const source = path.join(dir, "src.bin");
const target = path.join(dir, "dst.bin");
const size = 1024 * 1024;
const progress: number[] = [];
const bytes = await pipeBetweenServers({
source: {
serverId: null,
command: `head -c ${size} /dev/urandom | tee '${source}'`,
},
target: { serverId: null, command: `cat > '${target}'` },
onProgress: (transferred) => progress.push(transferred),
});
expect(bytes).toBe(size);
expect(progress.at(-1)).toBe(size);
expect(await fs.readFile(target)).toEqual(await fs.readFile(source));
await fs.rm(dir, { recursive: true, force: true });
}, 30_000);
it("reports a target failure with its stderr", async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "printf x" },
target: { serverId: null, command: "echo boom >&2; exit 3" },
}),
).rejects.toThrow("target exited with code 3: boom");
});
it("reports a source failure", async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "exit 2" },
target: { serverId: null, command: "cat > /dev/null" },
}),
).rejects.toThrow("source exited with code 2");
});
});
@@ -22,6 +22,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
interface Props {
@@ -30,6 +31,7 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
pullImages: z.boolean(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -57,22 +59,25 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
pullImages: false,
},
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
if (data?.command) {
if (data) {
form.reset({
command: data?.command || "",
command: data.command || "",
pullImages: data.pullImages ?? false,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (data: AddCommand) => {
const onSubmit = async (formData: AddCommand) => {
await mutateAsync({
composeId,
command: data?.command,
command: formData.command,
pullImages: formData.pullImages,
})
.then(async () => {
toast.success("Command Updated");
@@ -109,6 +114,30 @@ export const AddCommandCompose = ({ composeId }: Props) => {
<strong>docker</strong>.
</AlertBlock>
<div className="flex flex-col gap-4">
{data?.composeType === "docker-compose" && (
<FormField
control={form.control}
name="pullImages"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
<div className="space-y-0.5">
<FormLabel>Pull latest images on deploy</FormLabel>
<FormDescription>
Adds <strong>--pull always</strong> to the default
command so every deploy fetches the newest image for
each tag. Has no effect when a custom command is set.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="command"
@@ -0,0 +1,216 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { AlertTriangle, Copy, HardDriveDownload } 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 freshVolumesSchema = z.object({
confirmName: z.string().min(1, {
message: "Compose name is required",
}),
});
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
interface Props {
composeId: string;
}
export const FreshVolumes = ({ composeId }: Props) => {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.compose.one.useQuery(
{ composeId },
{ enabled: !!composeId },
);
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
const form = useForm<FreshVolumesForm>({
defaultValues: { confirmName: "" },
resolver: zodResolver(freshVolumesSchema),
});
const expectedName = `${data?.name}/${data?.appName}`;
const isRunning = data?.composeStatus === "running";
const onSubmit = async (formData: FreshVolumesForm) => {
if (formData.confirmName !== expectedName) {
form.setError("confirmName", {
message: `Compose name must match "${expectedName}"`,
});
return;
}
await deploy({ composeId, freshVolumes: true })
.then(() => {
toast.success("Compose deployed with fresh volumes");
setIsOpen(false);
form.reset();
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
};
if (!canDeploy || data?.composeType !== "docker-compose") return null;
return (
<Card className="bg-background border-destructive/50">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h3 className="text-base font-semibold">
Deploy with Fresh Volumes
</h3>
<p className="text-sm text-muted-foreground">
Permanently wipes every volume of this compose and redeploys it
from a clean state. All persistent data (databases, uploads,
caches) will be lost.
</p>
</div>
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) form.reset();
}}
>
<DialogTrigger asChild>
<Button
isLoading={isPending}
variant="outline"
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
>
<HardDriveDownload className="mr-2 h-4 w-4" />
Deploy with Fresh Volumes
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current compose</li>
<li>Delete all volumes and their data</li>
<li>Redeploy the compose with empty volumes</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</div>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-fresh-volumes"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="confirmName"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
copy(expectedName);
toast.success(
"Copied to clipboard. Be careful!",
);
}
}}
>
{expectedName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input
placeholder="Enter compose name to confirm"
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{isRunning && (
<AlertBlock type="warning" className="w-full">
Cannot deploy with fresh volumes while a deployment is
running. Please wait for it to finish and then try again.
</AlertBlock>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
isLoading={isPending}
disabled={isRunning}
form="hook-form-fresh-volumes"
type="submit"
variant="destructive"
>
Wipe volumes and deploy
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
);
};
@@ -1,11 +1,4 @@
import {
Ban,
CheckCircle2,
HardDriveDownload,
RefreshCcw,
Rocket,
Terminal,
} from "lucide-react";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
@@ -89,51 +82,6 @@ export const ComposeActions = ({ composeId }: Props) => {
</Button>
</DialogAction>
)}
{canDeploy && data?.composeType === "docker-compose" && (
<DialogAction
title="Deploy with Fresh Volumes"
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
type="destructive"
onClick={async () => {
await deploy({
composeId: composeId,
freshVolumes: true,
})
.then(() => {
toast.success("Compose deployed with fresh volumes");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="outline"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<HardDriveDownload className="size-4 mr-1" />
Fresh Volumes
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Deploy with fresh volumes (removes all persistent data)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Rebuild Compose"
@@ -114,7 +114,7 @@ export const ShowContainers = ({ serverId }: Props) => {
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by name..."
@@ -189,7 +189,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<>
<div className="flex flex-col gap-6 w-full ">
<div className="mt-6 grid gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by hostname..."
@@ -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>
);
};
@@ -24,7 +24,7 @@ export const AiForm = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between">
<div>
@@ -62,7 +62,7 @@ export const AiForm = () => {
key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div>
<span className="text-sm font-medium">
{config.name}
@@ -22,7 +22,7 @@ export const ShowApiKeys = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -29,7 +29,7 @@ export const ShowBillingInvoices = () => {
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -111,7 +111,7 @@ export const ShowCertificates = () => {
key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -20,7 +20,7 @@ export const ShowRegistry = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -55,7 +55,7 @@ export const ShowRegistry = () => {
key={registry.registryId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -19,7 +19,7 @@ export const ShowDestinations = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
key={destination.destinationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col gap-1">
<span className="text-sm">
{index + 1}. {destination.name}
@@ -35,7 +35,7 @@ export const ShowDnsProviders = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full max-w-5xl mx-auto">
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -52,7 +52,7 @@ export const ShowDnsZones = ({ dnsProviderId }: Props) => {
api.dnsProvider.listZones.useQuery({ dnsProviderId });
return (
<div className="w-full max-w-5xl mx-auto">
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -69,7 +69,7 @@ export const ShowGitProviders = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -31,7 +31,7 @@ export const ShowNotifications = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -70,7 +70,7 @@ export const ShowNotifications = () => {
key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg">
@@ -150,7 +150,7 @@ export const ProfileForm = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -49,7 +49,7 @@ export const ShowServers = () => {
return (
<div className="w-full">
{query?.success && isCloud && <WelcomeSubscription />}
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -21,7 +21,7 @@ export const ShowDestinations = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -57,7 +57,7 @@ export const ShowDestinations = () => {
key={sshKey.sshKeyId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
@@ -22,7 +22,7 @@ export const TagManager = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -320,7 +320,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
{isCustomRole && (
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground">
@@ -40,7 +40,7 @@ export const ShowInvitations = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -52,7 +52,7 @@ export const ShowUsers = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -33,7 +33,7 @@ export const ShowVaultProviders = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -123,7 +123,7 @@ export const WebDomain = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div className="flex flex-col gap-1">
@@ -24,7 +24,7 @@ export const WebServer = () => {
return (
<div className="w-full">
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -0,0 +1,245 @@
import type { ServiceType } from "@dokploy/server/db/schema";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { ArrowRightLeft } from "lucide-react";
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 { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { type LogLine, parseLogs } from "../docker/logs/utils";
const LOCAL_SERVER = "dokploy";
const transferSchema = z.object({
targetServerId: z.string().min(1, { message: "Select a target server" }),
removeSourceData: z.boolean(),
});
type TransferForm = z.infer<typeof transferSchema>;
interface Props {
id: string;
type: ServiceType;
serverId?: string | null;
}
export const TransferService = ({ id, type, serverId }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const [isOpen, setIsOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [logs, setLogs] = useState<LogLine[]>([]);
const [request, setRequest] = useState<{
targetServerId: string | null;
removeSourceData: boolean;
} | null>(null);
const targets = [
...(!isCloud && serverId
? [{ serverId: LOCAL_SERVER, name: "Dokploy Server" }]
: []),
...(servers ?? []).filter((server) => server.serverId !== serverId),
];
const form = useForm<TransferForm>({
defaultValues: { targetServerId: "", removeSourceData: false },
resolver: zodResolver(transferSchema),
});
api.transfer.start.useSubscription(
{
serviceType: type,
serviceId: id,
targetServerId: request?.targetServerId ?? null,
removeSourceData: request?.removeSourceData ?? false,
},
{
enabled: isTransferring && request !== null,
onData(line) {
setLogs((prev) => [...prev, ...parseLogs(line)]);
if (line.startsWith("Transfer completed")) {
setIsTransferring(false);
toast.success("Service transferred successfully");
utils.invalidate();
} else if (line.startsWith("Transfer failed")) {
setIsTransferring(false);
toast.error("Transfer failed, check the logs");
utils.invalidate();
}
},
onError(error) {
setIsTransferring(false);
toast.error(error.message);
},
},
);
const onSubmit = (values: TransferForm) => {
setRequest({
targetServerId:
values.targetServerId === LOCAL_SERVER ? null : values.targetServerId,
removeSourceData: values.removeSourceData,
});
setLogs([]);
setIsDrawerOpen(true);
setIsTransferring(true);
setIsOpen(false);
};
return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
isLoading={isTransferring}
>
<ArrowRightLeft className="size-4 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Transfer to another server</DialogTitle>
<DialogDescription>
Moves this service with its volumes, bind mounts, file mounts and
configuration to the selected server, then deploys it there.
</DialogDescription>
</DialogHeader>
{targets.length === 0 ? (
<AlertBlock type="info">
There are no other servers available to transfer this service to.
</AlertBlock>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-transfer-service"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="targetServerId"
render={({ field }) => (
<FormItem>
<FormLabel>Target server</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{targets.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="removeSourceData"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="ml-2">
Remove volumes from the source server after the
transfer
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
<AlertBlock type="warning">
<ul className="list-disc pl-4 space-y-1">
<li>The service is stopped while its data is copied.</li>
<li>
Point your DNS records to the new server, certificates are
issued again there.
</li>
<li>
Networks that only exist on the current server are
detached.
</li>
<li>
Bind mount host paths are copied but never deleted from
the source server.
</li>
</ul>
</AlertBlock>
</form>
</Form>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
{targets.length > 0 && (
<Button
isLoading={isTransferring}
form="hook-form-transfer-service"
type="submit"
>
Transfer
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
filteredLogs={logs}
/>
</>
);
};
@@ -605,7 +605,7 @@ type CreateRoleSchema = z.infer<typeof createRoleSchema>;
export const ManageCustomRoles = () => {
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -0,0 +1 @@
ALTER TABLE "compose" ADD COLUMN "pullImages" boolean DEFAULT false NOT NULL;
@@ -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
File diff suppressed because it is too large Load Diff
+14
View File
@@ -1373,6 +1373,20 @@
"when": 1788867370653,
"tag": "0195_classy_whirlwind",
"breakpoints": true
},
{
"idx": 196,
"version": "7",
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1789022852219,
"tag": "0197_demonic_the_initiative",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -133,7 +133,7 @@
"resend": "^6.0.2",
"semver": "7.7.3",
"shadcn": "^4.11.0",
"shell-quote": "^1.8.1",
"shell-quote": "^1.8.4",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "~1.16.0",
@@ -180,7 +180,7 @@
"tsx": "^4.22.4",
"typescript": "^7.0.2",
"vite-tsconfig-paths": "4.3.2",
"vitest": "^4.0.18"
"vitest": "^4.1.0"
},
"ct3aMetadata": {
"initVersion": "7.25.2"
@@ -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: "/" } };
}
@@ -36,6 +36,7 @@ import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -194,6 +195,13 @@ const Service = (
{permissions?.service.create && (
<UpdateApplication applicationId={applicationId} />
)}
{permissions?.service.create && (
<TransferService
id={applicationId}
type="application"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={applicationId} type="application" />
)}
@@ -23,6 +23,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { FreshVolumes } from "@/components/dashboard/compose/advanced/fresh-volumes";
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
@@ -33,6 +34,7 @@ import { ShowBackups } from "@/components/dashboard/database/backups/show-backup
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -184,6 +186,13 @@ const Service = (
<UpdateCompose composeId={composeId} />
)}
{permissions?.service.create && (
<TransferService
id={composeId}
type="compose"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={composeId} type="compose" />
)}
@@ -434,6 +443,7 @@ const Service = (
<ShowImport composeId={composeId} />
<AssignComposeNetworks composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} />
<FreshVolumes composeId={composeId} />
</div>
</TabsContent>
)}
@@ -23,6 +23,7 @@ import { UpdateLibsql } from "@/components/dashboard/libsql/update-libsql";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { LibsqlIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -145,6 +146,13 @@ const Libsql = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateLibsql libsqlId={libsqlId} />
{(auth?.role === "owner" || auth?.canCreateServices) && (
<TransferService
id={libsqlId}
type="libsql"
serverId={data?.serverId}
/>
)}
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={libsqlId} type="libsql" />
)}
@@ -23,6 +23,7 @@ import { UpdateMariadb } from "@/components/dashboard/mariadb/update-mariadb";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { MariadbIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -159,6 +160,13 @@ const Mariadb = (
{permissions?.service.create && (
<UpdateMariadb mariadbId={mariadbId} />
)}
{permissions?.service.create && (
<TransferService
id={mariadbId}
type="mariadb"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={mariadbId} type="mariadb" />
)}
@@ -23,6 +23,7 @@ import { UpdateMongo } from "@/components/dashboard/mongo/update-mongo";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { MongodbIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -159,6 +160,13 @@ const Mongo = (
{permissions?.service.create && (
<UpdateMongo mongoId={mongoId} />
)}
{permissions?.service.create && (
<TransferService
id={mongoId}
type="mongo"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={mongoId} type="mongo" />
)}
@@ -23,6 +23,7 @@ import { ShowGeneralMysql } from "@/components/dashboard/mysql/general/show-gene
import { ShowInternalMysqlCredentials } from "@/components/dashboard/mysql/general/show-internal-mysql-credentials";
import { UpdateMysql } from "@/components/dashboard/mysql/update-mysql";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { MysqlIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -159,6 +160,13 @@ const MySql = (
{permissions?.service.create && (
<UpdateMysql mysqlId={mysqlId} />
)}
{permissions?.service.create && (
<TransferService
id={mysqlId}
type="mysql"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={mysqlId} type="mysql" />
)}
@@ -23,6 +23,7 @@ import { ShowGeneralPostgres } from "@/components/dashboard/postgres/general/sho
import { ShowInternalPostgresCredentials } from "@/components/dashboard/postgres/general/show-internal-postgres-credentials";
import { UpdatePostgres } from "@/components/dashboard/postgres/update-postgres";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { PostgresqlIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -158,6 +159,13 @@ const Postgresql = (
{permissions?.service.create && (
<UpdatePostgres postgresId={postgresId} />
)}
{permissions?.service.create && (
<TransferService
id={postgresId}
type="postgres"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={postgresId} type="postgres" />
)}
@@ -22,6 +22,7 @@ import { ShowGeneralRedis } from "@/components/dashboard/redis/general/show-gene
import { ShowInternalRedisCredentials } from "@/components/dashboard/redis/general/show-internal-redis-credentials";
import { UpdateRedis } from "@/components/dashboard/redis/update-redis";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { RedisIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -158,6 +159,13 @@ const Redis = (
{permissions?.service.create && (
<UpdateRedis redisId={redisId} />
)}
{permissions?.service.create && (
<TransferService
id={redisId}
type="redis"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={redisId} type="redis" />
)}
@@ -21,7 +21,7 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
@@ -11,7 +11,7 @@ import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
@@ -16,7 +16,7 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<ProfileForm />
{isCloud && <LinkingAccount />}
{permissions?.api.read && <ShowApiKeys />}
@@ -15,7 +15,7 @@ const Page = () => {
const { data: user } = api.user.get.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<WebDomain />
<WebServer />
<div className="w-full flex flex-col gap-4">
@@ -25,7 +25,7 @@ interface Props {
const Page = ({ isCloud }: Props) => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
@@ -12,7 +12,7 @@ import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
+1
View File
@@ -498,6 +498,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
// Prefetch the public branding so the login/onboarding logo and app name
// render correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
await helpers.sso.showSignInWithSSO.prefetch();
if (IS_CLOUD) {
try {
+1
View File
@@ -62,6 +62,7 @@ async function generateOpenAPI() {
"rollback",
"volumeBackups",
"environment",
"sandbox",
],
});
+4
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";
@@ -53,6 +54,7 @@ import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { tagRouter } from "./routers/tag";
import { transferRouter } from "./routers/transfer";
import { userRouter } from "./routers/user";
import { vaultProviderRouter } from "./routers/vault-provider";
import { volumeBackupsRouter } from "./routers/volume-backups";
@@ -120,6 +122,8 @@ export const appRouter = createTRPCRouter({
tag: tagRouter,
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",
],
});
@@ -0,0 +1,87 @@
import {
getAccessibleServerIds,
IS_CLOUD,
transferService,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } 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 { apiTransferService } from "@/server/db/schema";
export const transferRouter = createTRPCRouter({
start: protectedProcedure
.meta({
openapi: {
path: "/transfer/start",
method: "POST",
override: true,
enabled: false,
},
})
.input(apiTransferService)
.subscription(async function* ({ input, ctx, signal }) {
await checkServicePermissionAndAccess(ctx, input.serviceId, {
service: ["create"],
deployment: ["create"],
});
if (input.targetServerId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.targetServerId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to the target server",
});
}
} else if (IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The Dokploy server is not available as a target",
});
}
const queue: string[] = [];
let done = false;
const log = (line: string) => queue.push(line);
const run = async () => {
const result = await transferService(
{
...input,
organizationId: ctx.session.activeOrganizationId,
},
log,
);
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: input.serviceId,
resourceName: result.appName,
metadata: { targetServerId: input.targetServerId },
});
};
run()
.then(() => log("Transfer completed successfully!"))
.catch((error) => {
log(
`Transfer failed ❌ ${error instanceof Error ? error.message : String(error)}`,
);
})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
});
+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);
}
});
};
+2 -2
View File
@@ -3,9 +3,10 @@ module github.com/mauriciogm/dokploy/apps/monitoring
go 1.20
require (
github.com/gofiber/fiber/v2 v2.52.6
github.com/gofiber/fiber/v2 v2.52.11
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v3 v3.24.5
)
@@ -20,7 +21,6 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect
+2 -4
View File
@@ -3,8 +3,8 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -49,8 +49,6 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0=
github.com/zcalusic/sysinfo v1.1.3/go.mod h1:NX+qYnWGtJVPV0yWldff9uppNKU4h40hJIRPf/pGLv4=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+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.
+3 -1
View File
@@ -48,7 +48,9 @@
"overrides": {
"esbuild": "0.20.2",
"better-call": "1.3.7",
"@better-fetch/fetch": "1.3.1"
"@better-fetch/fetch": "1.3.1",
"tar": ">=7.5.19 <8.0.0",
"protobufjs": ">=7.5.5 <8.0.0"
},
"peerDependencyRules": {
"ignoreMissing": [
+3 -1
View File
@@ -82,9 +82,10 @@
"react-dom": "19.2.7",
"resend": "^6.0.2",
"semver": "7.7.3",
"shell-quote": "^1.8.1",
"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";
+1
View File
@@ -97,6 +97,7 @@ export const compose = pgTable("compose", {
isolatedDeploymentsVolume: boolean("isolatedDeploymentsVolume")
.notNull()
.default(false),
pullImages: boolean("pullImages").notNull().default(false),
triggerType: triggerType("triggerType").default("push"),
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
icon: text("icon"),
@@ -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),
}),
);
+2
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";
@@ -41,6 +42,7 @@ export * from "./shared";
export * from "./ssh-key";
export * from "./sso";
export * from "./tag";
export * from "./transfer";
export * from "./user";
export * from "./utils";
export * from "./vault-provider";
+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, {
@@ -0,0 +1,9 @@
import { z } from "zod";
import { serviceType } from "./mount";
export const apiTransferService = z.object({
serviceType: z.enum(serviceType.enumValues),
serviceId: z.string().min(1),
targetServerId: z.string().min(1).nullable(),
removeSourceData: z.boolean().default(false),
});
+1255 -3935
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -48,12 +48,14 @@ 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";
export * from "./services/server-health";
export * from "./services/settings";
export * from "./services/ssh-key";
export * from "./services/transfer";
export * from "./services/user";
export * from "./services/vault-provider";
export * from "./services/volume-backups";
@@ -120,6 +122,7 @@ export * from "./utils/notifications/dokploy-restart";
export * from "./utils/notifications/server-threshold";
export * from "./utils/notifications/utils";
export * from "./utils/process/execAsync";
export * from "./utils/process/remoteStream";
export * from "./utils/process/spawnAsync";
export * from "./utils/providers/bitbucket";
export * from "./utils/providers/docker";
@@ -129,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);
};
+601
View File
@@ -0,0 +1,601 @@
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import {
type apiTransferService,
deployments,
network,
type ServiceType,
} from "@dokploy/server/db/schema";
import { removeService } from "@dokploy/server/utils/docker/utils";
import {
removeDirectoryCode,
removeMonitoringDirectory,
} from "@dokploy/server/utils/filesystem/directory";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
import {
readConfig,
readRemoteConfig,
removeTraefikConfig,
writeConfig,
writeConfigRemote,
} from "@dokploy/server/utils/traefik/application";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import { removeForwardAuthMiddleware } from "@dokploy/server/utils/traefik/forward-auth";
import {
deleteAllMiddlewares,
removePathMiddlewares,
} from "@dokploy/server/utils/traefik/middleware";
import { createRedirectMiddleware } from "@dokploy/server/utils/traefik/redirect";
import { createSecurityMiddleware } from "@dokploy/server/utils/traefik/security";
import { TRPCError } from "@trpc/server";
import { eq, inArray, sql } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import {
deployApplication,
findApplicationById,
updateApplication,
} from "./application";
import {
deployCompose,
findComposeById,
removeCompose,
startCompose,
stopCompose,
updateCompose,
} from "./compose";
import { deployLibsql, findLibsqlById, updateLibsqlById } from "./libsql";
import { deployMariadb, findMariadbById, updateMariadbById } from "./mariadb";
import { deployMongo, findMongoById, updateMongoById } from "./mongo";
import { createFileMount } from "./mount";
import { deployMySql, findMySqlById, updateMySqlById } from "./mysql";
import {
deployPostgres,
findPostgresById,
updatePostgresById,
} from "./postgres";
import { deployRedis, findRedisById, updateRedisById } from "./redis";
import { findServerById } from "./server";
export type TransferServiceInput = z.infer<typeof apiTransferService> & {
organizationId: string;
};
type Logger = (line: string) => void;
type ServiceStatus = "idle" | "running" | "done" | "error";
const findService = async (serviceType: ServiceType, serviceId: string) => {
switch (serviceType) {
case "application":
return await findApplicationById(serviceId);
case "compose":
return await findComposeById(serviceId);
case "postgres":
return await findPostgresById(serviceId);
case "mysql":
return await findMySqlById(serviceId);
case "mariadb":
return await findMariadbById(serviceId);
case "mongo":
return await findMongoById(serviceId);
case "redis":
return await findRedisById(serviceId);
case "libsql":
return await findLibsqlById(serviceId);
}
};
type TransferableService = Awaited<ReturnType<typeof findService>>;
type ApplicationService = Awaited<ReturnType<typeof findApplicationById>>;
type ComposeService = Awaited<ReturnType<typeof findComposeById>>;
const isApplication = (
service: TransferableService,
): service is ApplicationService => "applicationId" in service;
const isCompose = (service: TransferableService): service is ComposeService =>
"composeId" in service;
const updateServer = async (
serviceType: ServiceType,
serviceId: string,
data: { serverId: string | null; networkIds?: string[] | null },
) => {
switch (serviceType) {
case "application":
return await updateApplication(serviceId, data);
case "compose":
return await updateCompose(serviceId, { serverId: data.serverId });
case "postgres":
return await updatePostgresById(serviceId, data);
case "mysql":
return await updateMySqlById(serviceId, data);
case "mariadb":
return await updateMariadbById(serviceId, data);
case "mongo":
return await updateMongoById(serviceId, data);
case "redis":
return await updateRedisById(serviceId, data);
case "libsql":
return await updateLibsqlById(serviceId, data);
}
};
const updateStatus = async (
serviceType: ServiceType,
serviceId: string,
status: ServiceStatus,
) => {
switch (serviceType) {
case "application":
return await updateApplication(serviceId, { applicationStatus: status });
case "compose":
return await updateCompose(serviceId, { composeStatus: status });
case "postgres":
return await updatePostgresById(serviceId, {
applicationStatus: status,
});
case "mysql":
return await updateMySqlById(serviceId, { applicationStatus: status });
case "mariadb":
return await updateMariadbById(serviceId, { applicationStatus: status });
case "mongo":
return await updateMongoById(serviceId, { applicationStatus: status });
case "redis":
return await updateRedisById(serviceId, { applicationStatus: status });
case "libsql":
return await updateLibsqlById(serviceId, { applicationStatus: status });
}
};
const q = (value: string) => quote([value]);
const runOn = (serverId: string | null, command: string) =>
serverId ? execAsyncRemote(serverId, command) : execAsync(command);
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
const directoryExists = async (serverId: string | null, directory: string) => {
const { stdout } = await runOn(
serverId,
`[ -d ${q(directory)} ] && echo yes || echo no`,
);
return stdout.trim() === "yes";
};
const formatBytes = (bytes: number) => {
const mib = bytes / (1024 * 1024);
if (mib < 1) return `${(bytes / 1024).toFixed(1)} KiB`;
if (mib < 1024) return `${mib.toFixed(1)} MiB`;
return `${(mib / 1024).toFixed(2)} GiB`;
};
export const volumeExportCommand = (volume: string) =>
`docker run --rm -v ${q(volume)}:/data:ro alpine tar -czf - --numeric-owner -C /data .`;
export const volumeImportCommand = (volume: string) =>
`docker volume create ${q(volume)} >/dev/null && docker run --rm -i -v ${q(volume)}:/data alpine tar -xzf - --numeric-owner -C /data`;
export const directoryExportCommand = (directory: string) =>
`COPYFILE_DISABLE=1 tar -czf - --numeric-owner -C ${q(directory)} .`;
export const directoryImportCommand = (directory: string) =>
`mkdir -p ${q(directory)} && tar -xzf - --numeric-owner -C ${q(directory)}`;
const PROGRESS_STEP = 64 * 1024 * 1024;
const copyStream = async (
label: string,
source: { serverId: string | null; command: string },
target: { serverId: string | null; command: string },
log: Logger,
) => {
let reported = 0;
const bytes = await pipeBetweenServers({
source,
target,
onProgress: (transferred) => {
if (transferred - reported >= PROGRESS_STEP) {
reported = transferred;
log(` ${label}: ${formatBytes(transferred)} transferred`);
}
},
});
log(` ${label}: done (${formatBytes(bytes)})`);
};
const listVolumes = async (service: TransferableService) => {
const names = new Set<string>();
for (const mount of service.mounts) {
if (mount.type === "volume" && mount.volumeName) {
names.add(mount.volumeName);
}
}
if (isCompose(service)) {
const project = q(service.appName);
const { stdout } = await runOn(
service.serverId ?? null,
`docker volume ls -q --filter label=com.docker.compose.project=${project}; docker volume ls -q --filter label=com.docker.stack.namespace=${project}`,
);
for (const line of stdout.split("\n")) {
const name = line.trim();
if (name) names.add(name);
}
}
return [...names];
};
const listBindMounts = (service: TransferableService) => [
...new Set(
service.mounts
.filter((mount) => mount.type === "bind" && mount.hostPath)
.map((mount) => mount.hostPath as string),
),
];
const stopOnSource = async (service: TransferableService, log: Logger) => {
if (isCompose(service)) {
try {
await stopCompose(service.composeId);
} catch (error) {
log(` Could not stop compose: ${errorMessage(error)}`);
}
return;
}
await runOn(
service.serverId ?? null,
`docker service scale ${q(service.appName)}=0 >/dev/null 2>&1 || true`,
);
};
const startOnSource = async (service: TransferableService, log: Logger) => {
if (isCompose(service)) {
if (service.composeType === "stack") {
log(
" Stack was removed on the source server, run Deploy to bring it back",
);
return;
}
try {
await startCompose(service.composeId);
} catch (error) {
log(` Could not start compose: ${errorMessage(error)}`);
}
return;
}
const replicas = isApplication(service) ? service.replicas : 1;
await runOn(
service.serverId ?? null,
`docker service scale ${q(service.appName)}=${replicas} >/dev/null 2>&1 || true`,
);
};
const resolveNetworkIds = async (
networkIds: string[] | null | undefined,
targetServerId: string | null,
) => {
if (!networkIds || networkIds.length === 0) {
return { kept: [] as string[], dropped: [] as string[] };
}
const rows = await db.query.network.findMany({
where: inArray(network.networkId, networkIds),
columns: { networkId: true, name: true, serverId: true },
});
return {
kept: rows
.filter((row) => (row.serverId ?? null) === targetServerId)
.map((row) => row.networkId),
dropped: rows
.filter((row) => (row.serverId ?? null) !== targetServerId)
.map((row) => row.name),
};
};
const moveTraefikConfig = async (
source: ApplicationService,
moved: ApplicationService,
log: Logger,
) => {
const config = source.serverId
? await readRemoteConfig(source.serverId, source.appName)
: readConfig(source.appName);
if (config) {
if (moved.serverId) {
await writeConfigRemote(moved.serverId, moved.appName, config);
} else {
writeConfig(moved.appName, config);
}
}
for (const domain of moved.domains) {
await manageDomain(moved, domain);
}
for (const security of moved.security) {
await createSecurityMiddleware(moved, security);
}
for (const redirect of moved.redirects) {
await createRedirectMiddleware(moved, redirect);
}
log(` Traefik configuration recreated (${moved.domains.length} domains)`);
};
const removeSourceTraefikConfig = async (source: ApplicationService) => {
for (const domain of source.domains) {
await removePathMiddlewares(source, domain.uniqueConfigKey);
await removeForwardAuthMiddleware(source, domain.uniqueConfigKey);
}
await deleteAllMiddlewares(source);
await removeTraefikConfig(source.appName, source.serverId);
};
const moveDeploymentLogs = async (
service: ApplicationService | ComposeService,
targetServerId: string | null,
log: Logger,
) => {
const sourceServerId = service.serverId ?? null;
const from = path.join(paths(!!sourceServerId).LOGS_PATH, service.appName);
const to = path.join(paths(!!targetServerId).LOGS_PATH, service.appName);
if (!(await directoryExists(sourceServerId, from))) return;
log("Copying deployment logs");
await copyStream(
"deployment logs",
{ serverId: sourceServerId, command: directoryExportCommand(from) },
{ serverId: targetServerId, command: directoryImportCommand(to) },
log,
);
if (from !== to) {
await db
.update(deployments)
.set({ logPath: sql`replace(${deployments.logPath}, ${from}, ${to})` })
.where(
isCompose(service)
? eq(deployments.composeId, service.composeId)
: eq(deployments.applicationId, service.applicationId),
);
}
await runOn(sourceServerId, `rm -rf ${q(from)}`);
};
const cleanupSource = async (
service: TransferableService,
volumes: string[],
removeSourceData: boolean,
log: Logger,
) => {
const serverId = service.serverId ?? null;
const steps: Array<() => Promise<unknown>> = [];
if (isApplication(service)) {
steps.push(
() => removeSourceTraefikConfig(service),
() => removeService(service.appName, serverId),
() => removeDirectoryCode(service.appName, serverId),
() => removeMonitoringDirectory(service.appName, serverId),
);
} else if (isCompose(service)) {
steps.push(() => removeCompose(service, removeSourceData));
} else {
steps.push(
() => removeService(service.appName, serverId),
() => removeDirectoryCode(service.appName, serverId),
);
}
if (removeSourceData && volumes.length > 0) {
steps.push(() =>
runOn(
serverId,
`docker volume rm ${volumes.map(q).join(" ")} >/dev/null 2>&1 || true`,
),
);
}
for (const step of steps) {
try {
await step();
} catch (error) {
log(` Warning: ${errorMessage(error)}`);
}
}
if (!removeSourceData && volumes.length > 0) {
log(` Volumes kept on the source server: ${volumes.join(", ")}`);
}
};
const deployOnTarget = async (
serviceType: ServiceType,
serviceId: string,
targetName: string,
log: Logger,
) => {
log(`Deploying on ${targetName}`);
const deployment = {
titleLog: "Deploy after transfer",
descriptionLog: `Transferred to ${targetName}`,
};
switch (serviceType) {
case "application":
log("Follow the Deployments tab for the build log");
await updateStatus(serviceType, serviceId, "running");
return await deployApplication({
applicationId: serviceId,
...deployment,
});
case "compose":
log("Follow the Deployments tab for the build log");
await updateStatus(serviceType, serviceId, "running");
return await deployCompose({ composeId: serviceId, ...deployment });
case "postgres":
return await deployPostgres(serviceId, log);
case "mysql":
return await deployMySql(serviceId, log);
case "mariadb":
return await deployMariadb(serviceId, log);
case "mongo":
return await deployMongo(serviceId, log);
case "redis":
return await deployRedis(serviceId, log);
case "libsql":
return await deployLibsql(serviceId, log);
}
};
export const transferService = async (
input: TransferServiceInput,
log: Logger,
) => {
const { serviceType, serviceId, targetServerId, removeSourceData } = input;
const service = await findService(serviceType, serviceId);
if (service.environment.project.organizationId !== input.organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to transfer this service",
});
}
const sourceServerId = service.serverId ?? null;
if (sourceServerId === targetServerId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The service is already on the selected server",
});
}
const targetServer = targetServerId
? await findServerById(targetServerId)
: null;
if (targetServer) {
if (targetServer.organizationId !== input.organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to use the target server",
});
}
if (
targetServer.serverStatus !== "active" ||
targetServer.serverType !== "deploy"
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The target server is not available for deployments",
});
}
}
const sourceName = service.server?.name ?? "Dokploy Server";
const targetName = targetServer?.name ?? "Dokploy Server";
const originalNetworkIds =
"networkIds" in service ? service.networkIds : undefined;
let switched = false;
log(`Transferring ${service.appName} from ${sourceName} to ${targetName}`);
try {
log(`Stopping service on ${sourceName}`);
await stopOnSource(service, log);
await updateStatus(serviceType, serviceId, "running");
const volumes = await listVolumes(service);
const bindMounts = listBindMounts(service);
log(
`Found ${volumes.length} volume(s) and ${bindMounts.length} bind mount(s)`,
);
for (const volume of volumes) {
log(`Copying volume ${volume}`);
await copyStream(
volume,
{ serverId: sourceServerId, command: volumeExportCommand(volume) },
{ serverId: targetServerId, command: volumeImportCommand(volume) },
log,
);
}
for (const hostPath of bindMounts) {
if (!(await directoryExists(sourceServerId, hostPath))) {
log(`Skipping bind mount ${hostPath}: not found on ${sourceName}`);
continue;
}
log(`Copying bind mount ${hostPath}`);
await copyStream(
hostPath,
{
serverId: sourceServerId,
command: directoryExportCommand(hostPath),
},
{
serverId: targetServerId,
command: directoryImportCommand(hostPath),
},
log,
);
}
const networks = await resolveNetworkIds(
originalNetworkIds,
targetServerId,
);
if (networks.dropped.length > 0) {
log(
`Detaching networks not available on ${targetName}: ${networks.dropped.join(", ")}`,
);
}
await updateServer(serviceType, serviceId, {
serverId: targetServerId,
networkIds: originalNetworkIds === undefined ? undefined : networks.kept,
});
switched = true;
const moved = await findService(serviceType, serviceId);
const fileMounts = moved.mounts.filter((mount) => mount.type === "file");
if (fileMounts.length > 0) {
log(`Recreating ${fileMounts.length} file mount(s) on ${targetName}`);
for (const mount of fileMounts) {
await createFileMount(mount.mountId);
}
}
if (isApplication(service) && isApplication(moved)) {
log(`Recreating Traefik configuration on ${targetName}`);
await moveTraefikConfig(service, moved, log);
}
if (isApplication(service) || isCompose(service)) {
await moveDeploymentLogs(service, targetServerId, log);
}
log(`Cleaning up ${sourceName}`);
await cleanupSource(service, volumes, removeSourceData, log);
await updateStatus(serviceType, serviceId, "idle");
} catch (error) {
if (switched) {
await updateServer(serviceType, serviceId, {
serverId: sourceServerId,
networkIds: originalNetworkIds,
});
}
log(`Data already copied to ${targetName} was left in place`);
log(`Restoring service on ${sourceName}`);
await startOnSource(service, log);
await updateStatus(serviceType, serviceId, "error");
throw error;
}
try {
await deployOnTarget(serviceType, serviceId, targetName, log);
} catch (error) {
await updateStatus(serviceType, serviceId, "error");
log(
`Data was transferred but the deployment on ${targetName} failed, run Deploy to retry`,
);
throw error;
}
return {
serviceType,
serviceId,
appName: service.appName,
targetServerId,
targetName,
};
};
@@ -139,7 +139,8 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
const envFileFlag = compose.createEnvFile
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
: "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans`;
const pullFlag = compose.pullImages ? " --pull always" : "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans${pullFlag}`;
} else if (composeType === "stack") {
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}
@@ -0,0 +1,127 @@
import { spawn } from "node:child_process";
import { findServerById } from "@dokploy/server/services/server";
import { Client } from "ssh2";
export interface ProcessStream {
stdin: NodeJS.WritableStream;
stdout: NodeJS.ReadableStream;
stderr: NodeJS.ReadableStream;
exit: Promise<number>;
close: () => void;
}
export const openProcessStream = async (
serverId: string | null,
command: string,
): Promise<ProcessStream> => {
if (!serverId) {
const child = spawn("sh", ["-c", command]);
const exit = new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code ?? 1));
});
return {
stdin: child.stdin,
stdout: child.stdout,
stderr: child.stderr,
exit,
close: () => child.kill(),
};
}
const server = await findServerById(serverId);
if (!server.sshKeyId) throw new Error("No SSH key available for this server");
return new Promise((resolve, reject) => {
const conn = new Client();
conn
.once("ready", () => {
conn.exec(command, (err, stream) => {
if (err) {
conn.end();
reject(err);
return;
}
let exitCode = 1;
stream.once("exit", (code: number | null) => {
exitCode = code ?? 1;
});
const exit = new Promise<number>((resolveExit) => {
stream.once("close", () => {
conn.end();
resolveExit(exitCode);
});
});
resolve({
stdin: stream,
stdout: stream,
stderr: stream.stderr,
exit,
close: () => {
stream.close();
conn.end();
},
});
});
})
.once("error", (err) => {
conn.end();
reject(err);
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
readyTimeout: 30_000,
keepaliveInterval: 15_000,
});
});
};
const collectOutput = (stream: NodeJS.ReadableStream) => {
const chunks: string[] = [];
stream.on("data", (chunk) => chunks.push(chunk.toString()));
return () => chunks.join("").trim();
};
export const pipeBetweenServers = async ({
source,
target,
onProgress,
}: {
source: { serverId: string | null; command: string };
target: { serverId: string | null; command: string };
onProgress?: (bytes: number) => void;
}) => {
const to = await openProcessStream(target.serverId, target.command);
const targetError = collectOutput(to.stderr);
to.stdout.resume();
to.stdin.on("error", () => {});
const from = await openProcessStream(source.serverId, source.command);
const sourceError = collectOutput(from.stderr);
let bytes = 0;
from.stdout.on("data", (chunk: Buffer) => {
bytes += chunk.length;
onProgress?.(bytes);
});
from.stdout.pipe(to.stdin);
const targetExit = to.exit.then((code) => {
if (code !== 0) from.close();
return code;
});
const [sourceCode, targetCode] = await Promise.all([from.exit, targetExit]);
const failures = [
sourceCode !== 0 &&
`source exited with code ${sourceCode}: ${sourceError()}`,
targetCode !== 0 &&
`target exited with code ${targetCode}: ${targetError()}`,
].filter(Boolean);
if (failures.length > 0) {
throw new Error(failures.join("\n"));
}
return bytes;
};
@@ -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)",
},
};
+357 -220
View File
File diff suppressed because it is too large Load Diff