Refactor sandbox schema and functionality

- Removed references to environments in the sandbox schema and replaced with organizationId.
- Updated sandbox creation logic to include maxLifetimeMs and deadlineAt.
- Enhanced sandbox templates to include additional programming environments (alpine, bun, deno, go, rust, ruby, java, php).
- Implemented sandbox activity tracking and timeout management.
- Removed the sandbox reaper utility as its functionality is now integrated into the sandbox lifecycle management.
- Updated access control to include sandbox permissions for roles.
- Adjusted sandbox execution and file management functions to accommodate new schema and timeout features.
This commit is contained in:
Mauricio Siu
2026-10-21 16:11:41 -06:00
parent f5a5d6a9de
commit d1894aeaad
27 changed files with 10138 additions and 745 deletions
@@ -1,17 +1,21 @@
import {
buildSandboxContainerOptions,
buildSandboxLifetimeCommand,
getSandboxExpiry,
getSandboxNetworkName,
parseSandboxEnvVars,
parseSandboxUser,
SANDBOX_ACTIVITY_FILE,
SANDBOX_INTERNET_NETWORK,
SANDBOX_ISOLATED_NETWORK,
toSandboxTimeoutSeconds,
withSandboxActivity,
} from "@dokploy/server/utils/sandbox/container";
import { describe, expect, it } from "vitest";
const spec = {
sandboxId: "sb_1",
projectId: "proj_1",
environmentId: "env_1",
organizationId: "org_1",
image: "python:3.12-slim",
cpu: 1.5,
memoryMb: 512,
@@ -20,6 +24,8 @@ const spec = {
envVars: "FOO=bar\n# comment\n\nBAZ=qux=1\ninvalid",
workdir: "/home/user",
user: "1000:1000",
timeoutMs: 300_000,
deadlineAt: new Date("2026-10-21T12:00:00.000Z"),
};
describe("buildSandboxContainerOptions", () => {
@@ -36,26 +42,28 @@ describe("buildSandboxContainerOptions", () => {
expect(options.HostConfig?.CapDrop).toEqual(["ALL"]);
expect(options.HostConfig?.SecurityOpt).toEqual(["no-new-privileges"]);
expect(options.HostConfig?.RestartPolicy).toEqual({ Name: "no" });
expect(options.HostConfig?.AutoRemove).toBe(true);
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", () => {
it("runs the lifetime command under an init", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.Entrypoint).toEqual([""]);
expect(options.Cmd).toEqual(["tail", "-f", "/dev/null"]);
expect(options.Cmd).toEqual(
buildSandboxLifetimeCommand(spec.deadlineAt, spec.timeoutMs),
);
expect(options.HostConfig?.Init).toBe(true);
expect(options.WorkingDir).toBe("/home/user");
});
it("sets the labels used by the reaper and reconcile", () => {
it("labels the container as a sandbox", () => {
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",
"dokploy.organizationId": "org_1",
});
});
@@ -100,3 +108,58 @@ describe("parseSandboxUser", () => {
expect(parseSandboxUser(null)).toBeNull();
});
});
describe("buildSandboxLifetimeCommand", () => {
const [shell, flag, script = ""] = buildSandboxLifetimeCommand(
new Date("2026-10-21T12:00:00.999Z"),
90_500,
);
it("sleeps in the main process until the absolute deadline", () => {
expect([shell, flag]).toEqual(["sh", "-c"]);
expect(script.trim().split("\n").at(-1)).toBe(
"exec sleep $(( 1792584000 - $(date +%s) ))",
);
});
it("seeds the idle timeout in whole seconds", () => {
expect(script).toContain("echo 91 2>/dev/null > /tmp/.dokploy-timeout");
});
it("runs the idle watcher in the background and kills the main sleep", () => {
expect(script).toContain(") &");
expect(script).toContain("while sleep 5; do");
expect(script).toContain("kill $$");
});
});
describe("getSandboxExpiry", () => {
const now = new Date("2026-10-21T10:00:00.000Z");
it("adds the idle timeout to now", () => {
const deadline = new Date("2026-10-22T10:00:00.000Z");
expect(getSandboxExpiry(now, 300_000, deadline)).toEqual(
new Date("2026-10-21T10:05:00.000Z"),
);
});
it("never goes past the hard deadline", () => {
const deadline = new Date("2026-10-21T10:02:00.000Z");
expect(getSandboxExpiry(now, 300_000, deadline)).toEqual(deadline);
});
});
describe("withSandboxActivity", () => {
it("marks activity with a builtin before the command", () => {
expect(withSandboxActivity("ls -la")).toBe(
`echo 1 2>/dev/null > ${SANDBOX_ACTIVITY_FILE}; ls -la`,
);
});
});
describe("toSandboxTimeoutSeconds", () => {
it("rounds up so the idle timeout is never shorter than asked", () => {
expect(toSandboxTimeoutSeconds(10_000)).toBe(10);
expect(toSandboxTimeoutSeconds(10_001)).toBe(11);
});
});
+2 -20
View File
@@ -169,10 +169,9 @@ describe("runSandboxExec", () => {
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 () => {
it("kills the container when the process survives the marker kill", async () => {
const { docker, kill, start } = createDocker({
frames: [],
exitCode: 0,
@@ -186,25 +185,8 @@ describe("runSandboxExec", () => {
expect(result.timedOut).toBe(true);
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
expect(kill).toHaveBeenCalledWith({ signal: "SIGKILL" });
expect(start).toHaveBeenCalledTimes(1);
expect(start).not.toHaveBeenCalled();
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);
});
@@ -1,117 +0,0 @@
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: [] });
});
});
@@ -1,3 +1,4 @@
import type { SandboxTemplateName } from "@dokploy/server/db/schema";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Box, PlusIcon } from "lucide-react";
import { useRouter } from "next/router";
@@ -41,7 +42,7 @@ const schema = z
.object({
name: z.string().max(64).optional(),
source: z.enum(["template", "image"]),
template: z.enum(["base", "python", "node"]),
template: z.string().min(1),
image: z.string().max(255).optional(),
serverId: z.string().nullable(),
cpu: z.number().min(0.1).max(64),
@@ -57,12 +58,7 @@ const schema = z
type FormValues = z.infer<typeof schema>;
interface Props {
environmentId: string;
projectId: string;
}
export const AddSandbox = ({ environmentId, projectId }: Props) => {
export const AddSandbox = () => {
const router = useRouter();
const utils = api.useUtils();
const [open, setOpen] = useState(false);
@@ -95,9 +91,11 @@ export const AddSandbox = ({ environmentId, projectId }: Props) => {
const onSubmit = async (values: FormValues) => {
try {
const sandbox = await mutateAsync({
environmentId,
name: values.name?.trim() || undefined,
template: values.source === "template" ? values.template : undefined,
template:
values.source === "template"
? (values.template as SandboxTemplateName)
: undefined,
image: values.source === "image" ? values.image?.trim() : undefined,
serverId: values.serverId,
cpu: values.cpu,
@@ -107,12 +105,10 @@ export const AddSandbox = ({ environmentId, projectId }: Props) => {
envVars: values.envVars?.trim() || undefined,
});
toast.success("Sandbox created");
await utils.sandbox.list.invalidate({ environmentId });
await utils.sandbox.list.invalidate();
setOpen(false);
form.reset();
router.push(
`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`,
);
router.push(`/dashboard/sandboxes/${sandbox.sandboxId}`);
} catch {
toast.error("Error creating the sandbox");
}
@@ -190,7 +186,7 @@ export const AddSandbox = ({ environmentId, projectId }: Props) => {
<span className="capitalize">{template.name}</span>
<span className="text-muted-foreground">
{" "}
· {template.image}
· {template.description}
</span>
</SelectItem>
))}
@@ -16,24 +16,18 @@ import {
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) => {
export const ShowSandboxes = () => {
const utils = api.useUtils();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: sandboxes, isLoading } = api.sandbox.list.useQuery(
{ environmentId },
{ refetchInterval: 10_000 },
);
const { data: sandboxes, isLoading } = api.sandbox.list.useQuery(undefined, {
refetchInterval: 10_000,
});
const { mutateAsync: kill } = api.sandbox.kill.useMutation();
if (isLoading) {
@@ -75,7 +69,7 @@ export const ShowSandboxes = ({ projectId, environmentId }: Props) => {
<TableRow key={sandbox.sandboxId}>
<TableCell className="font-medium">
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`}
href={`/dashboard/sandboxes/${sandbox.sandboxId}`}
className="hover:underline"
>
{sandbox.name}
@@ -104,7 +98,7 @@ export const ShowSandboxes = ({ projectId, environmentId }: Props) => {
</TableCell>
<TableCell className="text-right">
{sandbox.status === "running" &&
permissions?.deployment.create && (
permissions?.sandbox.create && (
<DialogAction
title="Kill sandbox"
description={`The container for "${sandbox.name}" will be stopped and removed.`}
@@ -112,7 +106,7 @@ export const ShowSandboxes = ({ projectId, environmentId }: Props) => {
await kill({ sandboxId: sandbox.sandboxId })
.then(() => {
toast.success("Sandbox killed");
utils.sandbox.list.invalidate({ environmentId });
utils.sandbox.list.invalidate();
})
.catch(() =>
toast.error("Error killing the sandbox"),
+8
View File
@@ -7,6 +7,7 @@ import {
BlocksIcon,
BookIcon,
BotIcon,
Box,
Boxes,
ChevronRight,
ChevronsUpDown,
@@ -203,6 +204,13 @@ const MENU: Menu = {
icon: Clock,
isEnabled: ({ permissions }) => !!permissions?.organization.update,
},
{
isSingle: true,
title: "Sandboxes",
url: "/dashboard/sandboxes",
icon: Box,
isEnabled: ({ permissions }) => !!permissions?.sandbox.read,
},
{
isSingle: true,
title: "Traefik File System",
@@ -97,6 +97,7 @@ const RESOURCE_LABELS: Record<string, string> = {
session: "Session",
network: "Network",
telemetryProvider: "Logs & Metrics",
sandbox: "Sandbox",
};
function MetadataCell({ metadata }: { metadata: string | null }) {
@@ -180,6 +180,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
},
sandbox: {
label: "Sandboxes",
description:
"Create ephemeral sandbox containers, run commands and manage files inside them",
},
telemetryProvider: {
label: "Logs & Metrics",
description:
@@ -485,6 +490,21 @@ const ACTION_META: Record<
description: "Remove DNS providers and delete their records",
},
},
sandbox: {
read: {
label: "Read",
description: "View sandboxes, their files and details",
},
create: {
label: "Create",
description:
"Create sandboxes, run commands, write files, extend timeouts and kill them",
},
delete: {
label: "Delete",
description: "Delete sandbox records and their containers",
},
},
telemetryProvider: {
read: {
label: "Read",
@@ -0,0 +1,30 @@
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,
"organizationId" 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,
"deadlineAt" timestamp with time zone NOT NULL,
"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 "webServerSettings" ALTER COLUMN "whitelabelingConfig" SET DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"footerText":null,"ogImageUrl":null,"logoLightUrl":null,"loginLogoLightUrl":null,"faviconLightUrl":null,"dualModeBranding":false}'::jsonb;--> statement-breakpoint
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1387,6 +1387,13 @@
"when": 1791456930312,
"tag": "0197_logs_and_metrics",
"breakpoints": true
},
{
"idx": 198,
"version": "7",
"when": 1792617619076,
"tag": "0198_productive_warhawk",
"breakpoints": true
}
]
}
@@ -3,7 +3,6 @@ import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import {
Ban,
Box,
Check,
CheckCircle2,
ChevronsUpDown,
@@ -1063,14 +1062,6 @@ 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>
@@ -16,7 +16,6 @@ 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";
@@ -41,10 +40,6 @@ const SandboxPage = (
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 },
@@ -55,15 +50,14 @@ const SandboxPage = (
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 listHref = "/dashboard/sandboxes";
const isRunning = data?.status === "running";
return (
<div className="pb-10">
<AdvanceBreadcrumb />
<Head>
<title>
Sandbox: {data?.name} - {data?.environment?.project?.name} | {appName}
Sandbox: {data?.name} | {appName}
</title>
</Head>
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
@@ -88,13 +82,13 @@ const SandboxPage = (
Sandboxes
</Button>
</Link>
{isRunning && permissions?.deployment.create && (
{isRunning && permissions?.sandbox.create && (
<ExtendSandboxTimeout
sandboxId={sandboxId}
currentTimeoutMs={data?.timeoutMs ?? 300_000}
/>
)}
{isRunning && permissions?.deployment.create && (
{isRunning && permissions?.sandbox.create && (
<DialogAction
title="Kill sandbox"
description="The container will be stopped and removed. Files inside it are lost."
@@ -113,7 +107,7 @@ const SandboxPage = (
</Button>
</DialogAction>
)}
{permissions?.service.delete && (
{permissions?.sandbox.delete && (
<DialogAction
title="Delete sandbox"
description="Removes the sandbox record (and its container if still running)."
@@ -121,9 +115,7 @@ const SandboxPage = (
await remove({ sandboxId })
.then(async () => {
toast.success("Sandbox deleted");
await utils.sandbox.list.invalidate({
environmentId,
});
await utils.sandbox.list.invalidate();
router.push(listHref);
})
.catch(() => toast.error("Error deleting the sandbox"));
@@ -161,6 +153,16 @@ const SandboxPage = (
data ? formatExpiresIn(data.expiresAt, data.status) : "—"
}
/>
<Field
label="Hard limit"
value={
data ? (
<DateTooltip date={data.deadlineAt.toISOString()} />
) : (
"—"
)
}
/>
<Field
label="Created"
value={data ? <DateTooltip date={data.createdAt} /> : "—"}
@@ -175,7 +177,7 @@ const SandboxPage = (
/>
</CardContent>
</Card>
{permissions?.deployment.create && (
{permissions?.sandbox.create && (
<SandboxConsole
sandboxId={sandboxId}
enabled={isRunning}
@@ -1,19 +1,13 @@
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 { Box } from "lucide-react";
import type { GetServerSidePropsContext } 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,
@@ -25,23 +19,15 @@ 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 SandboxesPage = () => {
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>
<title>Sandboxes | {appName}</title>
</Head>
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
@@ -52,32 +38,13 @@ const SandboxesPage = (
Sandboxes
</CardTitle>
<CardDescription>
Ephemeral containers for agents and automations in{" "}
{environment?.project?.name} / {environment?.name}.
Ephemeral containers for agents and automations.
</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>
{permissions?.sandbox.create && <AddSandbox />}
</CardHeader>
<CardContent className="py-8 border-t">
<ShowSandboxes
projectId={projectId}
environmentId={environmentId}
/>
<ShowSandboxes />
</CardContent>
</div>
</Card>
@@ -90,10 +57,8 @@ SandboxesPage.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ projectId: string; environmentId: string }>,
) {
const { params, req, res } = ctx;
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user) {
return { redirect: { permanent: false, destination: "/" } };
@@ -109,27 +74,10 @@ export async function getServerSideProps(
},
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: "/" } };
}
try {
await helpers.sandbox.list.fetch();
return { props: { trpcState: helpers.dehydrate() } };
} catch {
return { redirect: { permanent: false, destination: "/" } };
}
return { redirect: { permanent: false, destination: "/" } };
}
+55 -112
View File
@@ -1,11 +1,8 @@
import {
createSandbox,
execInSandbox,
findEnvironmentById,
findProjectById,
findSandboxById,
findSandboxesByEnvironmentId,
findSandboxesByProjectId,
findSandboxesByOrganizationId,
getAccessibleServerIds,
getWebServerSettings,
IS_CLOUD,
@@ -17,51 +14,25 @@ import {
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 { createTRPCRouter, withPermission } 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,
const findOrganizationSandbox = async (
sandboxId: string,
mode: "read" | "use" | "delete",
organizationId: string,
) => {
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
) {
if (sandbox.organizationId !== organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this sandbox",
@@ -76,27 +47,16 @@ const stripSecrets = <T extends { envVars: string | null }>(sandbox: T) => {
};
export const sandboxRouter = createTRPCRouter({
templates: protectedProcedure.query(() =>
templates: withPermission("sandbox", "read").query(() =>
Object.entries(SANDBOX_TEMPLATES).map(([name, template]) => ({
name,
...template,
})),
),
create: protectedProcedure
create: withPermission("sandbox", "create")
.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) &&
@@ -118,8 +78,10 @@ export const sandboxRouter = createTRPCRouter({
}
}
const sandbox = await createSandbox(input);
await addNewService(ctx, sandbox.sandboxId);
const sandbox = await createSandbox({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "create",
resourceType: "sandbox",
@@ -129,63 +91,30 @@ export const sandboxRouter = createTRPCRouter({
return stripSecrets(sandbox);
}),
one: protectedProcedure
one: withPermission("sandbox", "read")
.input(apiFindOneSandbox)
.query(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
const sandbox = await findOrganizationSandbox(
input.sandboxId,
ctx.session.activeOrganizationId,
);
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);
}
list: withPermission("sandbox", "read").query(async ({ ctx }) => {
const sandboxes = await findSandboxesByOrganizationId(
ctx.session.activeOrganizationId,
);
return sandboxes.map(stripSecrets);
}),
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
exec: withPermission("sandbox", "create")
.input(apiExecSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
const sandbox = await findOrganizationSandbox(
input.sandboxId,
ctx.session.activeOrganizationId,
);
const result = await execInSandbox(sandbox, {
cmd: input.cmd,
cwd: input.cwd,
@@ -201,10 +130,13 @@ export const sandboxRouter = createTRPCRouter({
};
}),
writeFile: protectedProcedure
writeFile: withPermission("sandbox", "create")
.input(apiWriteFileSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
const sandbox = await findOrganizationSandbox(
input.sandboxId,
ctx.session.activeOrganizationId,
);
return writeSandboxFile(sandbox, {
path: input.path,
content: Buffer.from(input.content, input.encoding),
@@ -212,10 +144,13 @@ export const sandboxRouter = createTRPCRouter({
});
}),
readFile: protectedProcedure
readFile: withPermission("sandbox", "read")
.input(apiReadFileSandbox)
.query(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
const sandbox = await findOrganizationSandbox(
input.sandboxId,
ctx.session.activeOrganizationId,
);
const file = await readSandboxFile(sandbox, input.path);
return {
path: file.path,
@@ -225,17 +160,23 @@ export const sandboxRouter = createTRPCRouter({
};
}),
listFiles: protectedProcedure
listFiles: withPermission("sandbox", "read")
.input(apiListFilesSandbox)
.query(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
const sandbox = await findOrganizationSandbox(
input.sandboxId,
ctx.session.activeOrganizationId,
);
return listSandboxFiles(sandbox, input.path);
}),
setTimeout: protectedProcedure
setTimeout: withPermission("sandbox", "create")
.input(apiSetTimeoutSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
const sandbox = await findOrganizationSandbox(
input.sandboxId,
ctx.session.activeOrganizationId,
);
const updated = await setSandboxTimeout(sandbox, input.timeoutMs);
await audit(ctx, {
action: "update",
@@ -247,10 +188,13 @@ export const sandboxRouter = createTRPCRouter({
return updated ? stripSecrets(updated) : null;
}),
kill: protectedProcedure
kill: withPermission("sandbox", "create")
.input(apiFindOneSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
const sandbox = await findOrganizationSandbox(
input.sandboxId,
ctx.session.activeOrganizationId,
);
const killed = await killSandbox(sandbox.sandboxId);
await audit(ctx, {
action: "stop",
@@ -261,13 +205,12 @@ export const sandboxRouter = createTRPCRouter({
return killed ? stripSecrets(killed) : null;
}),
remove: protectedProcedure
remove: withPermission("sandbox", "delete")
.input(apiFindOneSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(
ctx,
const sandbox = await findOrganizationSandbox(
input.sandboxId,
"delete",
ctx.session.activeOrganizationId,
);
await removeSandbox(sandbox.sandboxId);
await audit(ctx, {
-2
View File
@@ -8,7 +8,6 @@ import {
initCronJobs,
initEnterpriseBackupCronJobs,
initializeNetwork,
initSandboxReaper,
initSchedules,
initVolumeBackupsCronJobs,
sendDokployRestartNotifications,
@@ -71,7 +70,6 @@ void app.prepare().then(async () => {
await sendDokployRestartNotifications();
}
await initEnterpriseBackupCronJobs();
void initSandboxReaper();
if (!IS_CLOUD) {
console.log("Starting Deployment Worker");
+3 -8
View File
@@ -4,7 +4,7 @@ import {
findSandboxById,
validateRequest,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { checkPermission } 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";
@@ -111,14 +111,9 @@ export const setupSandboxExecWebSocketServer = (
};
try {
await checkServicePermissionAndAccess(ctx, sandboxId, {
deployment: ["create"],
});
await checkPermission(ctx, { sandbox: ["create"] });
const sandbox = await findSandboxById(sandboxId);
if (
sandbox.environment.project.organizationId !==
session.activeOrganizationId
) {
if (sandbox.organizationId !== session.activeOrganizationId) {
ws.close(4003, "Not authorized");
return;
}
+57 -33
View File
@@ -1,7 +1,7 @@
# 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
A sandbox is an ephemeral, resource-limited Docker container that belongs to a
Dokploy organization 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.
@@ -14,30 +14,57 @@ Dokploy (the container is created through the existing SSH Docker connection).
```
create ─► running ─► killed
│ ▲
└──── error ────────────┘ (reaper / reconcile / failed command)
└──── error (image without sh/sleep/date, stuck creation)
```
- `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.
- The container enforces its own lifetime; Dokploy runs no background job to
expire sandboxes:
- `deadlineAt` (`maxLifetimeMs` at creation, default and max 24 h) is a hard
limit. The container's main process is `sleep` until that moment, so it
stops even if Dokploy is down and even if the sandbox exhausted
`PidsLimit`.
- A background watcher inside the container (shell builtins plus
`sleep 5`) stops it after `timeoutMs` without activity. `exec`,
`writeFile` and `listFiles` mark activity; `readFile` does not.
`setTimeout` rewrites the timeout inside the container. If the watcher
cannot fork (`PidsLimit` exhausted) it exits and only `deadlineAt`
applies from then on.
- `AutoRemove` deletes the container once it stops.
- The stored status catches up when the sandbox is read: a `running` row whose
`expiresAt` or `deadlineAt` has passed becomes `killed`, and any operation
that finds the container gone marks it `killed` and fails with
`Sandbox is killed`. `expiresAt` never goes past `deadlineAt`.
- Images must include `sh`, `sleep` and `date`; otherwise `create` fails with
a clear error.
- `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` |
Every template runs as `1000:1000` with `/home/user` as workdir and `HOME`.
Images are placeholders until `dokploy/sandbox-*` is published.
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.
| template | image | extra env |
| -------- | ------------------------ | --------------------------------------------- |
| `base` | `ubuntu:24.04` | |
| `alpine` | `alpine:3.20` | |
| `python` | `python:3.13-slim` | |
| `node` | `node:22-slim` | |
| `bun` | `oven/bun:1` | |
| `deno` | `denoland/deno:debian` | `DENO_DIR` in the home |
| `go` | `golang:1.24` | |
| `rust` | `rust:1-slim` | `CARGO_HOME` in the home (toolchain unchanged) |
| `ruby` | `ruby:3.3-slim` | `GEM_HOME`, `BUNDLE_APP_CONFIG` in the home |
| `java` | `eclipse-temurin:21-jdk` | |
| `php` | `php:8.3-cli` | |
Template env vars come before the ones passed in `envVars`, so the caller can
override them.
A custom `image` can be used instead of a template. It must contain `sh`,
`sleep` and `date`; it runs as the image's default user.
## REST API
@@ -57,7 +84,6 @@ export DOKPLOY_KEY=xxxxxxxx
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,
@@ -121,8 +147,7 @@ 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.list" -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" \
@@ -159,13 +184,14 @@ 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`.
Sandboxes are an organization-level resource (`sandbox`), not tied to any
project. Owners and admins have full access; members have none unless a custom
role grants it.
- `templates`, `one`, `list`, `readFile`, `listFiles` require `sandbox:read`.
- `create`, `exec`, `writeFile`, `setTimeout`, `kill` and the WebSocket require
`sandbox:create`.
- `remove` requires `sandbox: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.
@@ -189,14 +215,13 @@ Each sandbox container is created with:
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.
- Labels `dokploy.sandbox=true`, `dokploy.sandboxId` and
`dokploy.organizationId`.
- 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`.
exhausted `PidsLimit`, so nothing can fork), the container is killed and,
through `AutoRemove`, deleted; the sandbox becomes `killed`.
Environment variables passed at creation are stored encrypted at rest and are
never returned by the API.
@@ -214,5 +239,4 @@ never returned by the API.
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.
- Sandboxes cannot join project or application networks.
@@ -11,7 +11,6 @@ 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", {
@@ -46,7 +45,6 @@ export const environmentRelations = relations(
mysql: many(mysql),
postgres: many(postgres),
redis: many(redis),
sandboxes: many(sandboxes),
}),
);
+27 -18
View File
@@ -9,7 +9,7 @@ import {
} from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { environments } from "./environment";
import { organization } from "./account";
import { server } from "./server";
import { encryptedText } from "./utils";
@@ -25,7 +25,19 @@ export const sandboxNetworkMode = pgEnum("sandboxNetworkMode", [
"internet",
]);
export const SANDBOX_TEMPLATE_NAMES = ["base", "python", "node"] as const;
export const SANDBOX_TEMPLATE_NAMES = [
"base",
"alpine",
"python",
"node",
"bun",
"deno",
"go",
"rust",
"ruby",
"java",
"php",
] as const;
export type SandboxTemplateName = (typeof SANDBOX_TEMPLATE_NAMES)[number];
export const SANDBOX_DEFAULTS = {
@@ -33,6 +45,7 @@ export const SANDBOX_DEFAULTS = {
memoryMb: 512,
pidsLimit: 256,
timeoutMs: 300_000,
maxLifetimeMs: 24 * 60 * 60 * 1000,
workdir: "/home/user",
execTimeoutMs: 60_000,
} as const;
@@ -43,9 +56,9 @@ export const sandboxes = pgTable("sandbox", {
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
environmentId: text("environmentId")
organizationId: text("organizationId")
.notNull()
.references(() => environments.environmentId, { onDelete: "cascade" }),
.references(() => organization.id, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
@@ -58,6 +71,7 @@ export const sandboxes = pgTable("sandbox", {
pidsLimit: integer("pidsLimit").notNull().default(SANDBOX_DEFAULTS.pidsLimit),
timeoutMs: integer("timeoutMs").notNull().default(SANDBOX_DEFAULTS.timeoutMs),
expiresAt: timestamp("expiresAt", { withTimezone: true }),
deadlineAt: timestamp("deadlineAt", { withTimezone: true }).notNull(),
lastActivityAt: timestamp("lastActivityAt", { withTimezone: true }),
networkMode: sandboxNetworkMode("networkMode").notNull().default("isolated"),
envVars: encryptedText("envVars"),
@@ -71,9 +85,9 @@ export const sandboxes = pgTable("sandbox", {
});
export const sandboxRelations = relations(sandboxes, ({ one }) => ({
environment: one(environments, {
fields: [sandboxes.environmentId],
references: [environments.environmentId],
organization: one(organization, {
fields: [sandboxes.organizationId],
references: [organization.id],
}),
server: one(server, {
fields: [sandboxes.serverId],
@@ -92,7 +106,6 @@ const absolutePath = z
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(),
@@ -106,6 +119,12 @@ export const apiCreateSandbox = z
.min(10_000)
.max(24 * 60 * 60 * 1000)
.optional(),
maxLifetimeMs: z
.number()
.int()
.min(60_000)
.max(SANDBOX_DEFAULTS.maxLifetimeMs)
.optional(),
networkMode: z.enum(["isolated", "internet"]).optional(),
envVars: z.string().max(65_536).optional(),
workdir: absolutePath.optional(),
@@ -119,16 +138,6 @@ 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),
-1
View File
@@ -143,7 +143,6 @@ 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";
@@ -29,6 +29,7 @@ export const statements = {
traefikFiles: ["read", "write"],
api: ["read"],
telemetryProvider: ["read", "create", "delete"],
sandbox: ["read", "create", "delete"],
// Enterprise-only resources (custom roles only)
volume: ["read", "create", "delete"],
@@ -123,6 +124,7 @@ export const ownerRole = ac.newRole({
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
sandbox: ["read", "create", "delete"],
ai: ["read", "create", "update", "delete"],
});
@@ -164,6 +166,7 @@ export const adminRole = ac.newRole({
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
sandbox: ["read", "create", "delete"],
ai: ["read", "create", "update", "delete"],
});
@@ -206,6 +209,7 @@ export const memberRole = ac.newRole({
destination: [],
notification: [],
telemetryProvider: [],
sandbox: [],
tag: ["read"],
auditLog: [],
// Members need provider/secret names for env editor autocomplete; values are never exposed
+172 -91
View File
@@ -2,22 +2,25 @@ 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 { desc, eq } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import {
buildSandboxContainerOptions,
getSandboxExpiry,
getSandboxNetworkName,
parseSandboxUser,
SANDBOX_LABEL,
SANDBOX_TIMEOUT_FILE,
type SandboxNetworkMode,
toSandboxTimeoutSeconds,
withSandboxActivity,
} from "../utils/sandbox/container";
import { runSandboxExec, type SandboxExecResult } from "../utils/sandbox/exec";
import {
@@ -39,47 +42,90 @@ export type Sandbox = typeof sandboxes.$inferSelect;
export const SANDBOX_MAX_FILE_BYTES = 10 * 1024 * 1024;
const INTERNAL_EXEC_TIMEOUT_MS = 15_000;
const STUCK_CREATING_MS = 10 * 60 * 1000;
const statusCodeOf = (error: unknown) =>
(error as { statusCode?: number } | null)?.statusCode;
const isNotFound = (error: unknown) => statusCodeOf(error) === 404;
const isContainerGone = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
return (
(statusCodeOf(error) === 404 && /no such container:/i.test(message)) ||
(statusCodeOf(error) === 409 &&
/is not running|removal .* in progress/i.test(message))
);
};
// Containers stop and remove themselves (see buildSandboxLifetimeCommand), so
// the stored status is brought up to date whenever a sandbox is read instead
// of by a background job.
const settleSandboxStatus = async <T extends Sandbox>(sandbox: T) => {
const now = Date.now();
let data: Partial<Sandbox> | null = null;
if (sandbox.status === "running") {
const endsAt = Math.min(
sandbox.expiresAt?.getTime() ?? Number.POSITIVE_INFINITY,
sandbox.deadlineAt.getTime(),
);
if (endsAt <= now) {
data = { status: "killed", killedAt: new Date(endsAt) };
}
} else if (
sandbox.status === "creating" &&
Date.parse(sandbox.createdAt) <= now - STUCK_CREATING_MS
) {
data = { status: "error" };
}
if (!data) return sandbox;
await updateSandboxById(sandbox.sandboxId, data);
return { ...sandbox, ...data };
};
const markSandboxGone = async (sandbox: Sandbox) => {
await updateSandboxById(sandbox.sandboxId, {
status: "killed",
killedAt: new Date(),
});
return new TRPCError({
code: "PRECONDITION_FAILED",
message: "Sandbox is killed",
});
};
const withLiveContainer = async <T>(
sandbox: Sandbox,
run: () => Promise<T>,
): Promise<T> => {
try {
return await run();
} catch (error) {
if (isContainerGone(error)) throw await markSandboxGone(sandbox);
throw error;
}
};
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;
return settleSandboxStatus(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),
),
export const findSandboxesByOrganizationId = async (organizationId: string) => {
const rows = await db.query.sandboxes.findMany({
where: eq(sandboxes.organizationId, organizationId),
orderBy: desc(sandboxes.createdAt),
with: { server: { columns: { serverId: true, name: true } } },
});
return Promise.all(rows.map(settleSandboxStatus));
};
export const updateSandboxById = async (
@@ -212,45 +258,50 @@ const prepareSandboxWorkdir = async (
await docker.getContainer(containerId).putArchive(tar, { path: parent });
};
const startSandboxContainer = async (sandbox: Sandbox, projectId: string) => {
class SandboxExitedOnStartError extends Error {
constructor() {
super(
"The container exited right after starting. Sandbox images must include sh, sleep and date.",
);
}
}
const startSandboxContainer = async (sandbox: Sandbox) => {
const docker = await getRemoteDocker(sandbox.serverId);
await ensureSandboxImage(docker, sandbox.image);
await ensureSandboxNetwork(docker, sandbox.networkMode);
const container = await docker.createContainer(
buildSandboxContainerOptions({ ...sandbox, projectId }),
buildSandboxContainerOptions(sandbox),
);
try {
await container.start();
const user = await resolveSandboxUser(docker, container.id, sandbox.user);
await prepareSandboxWorkdir(docker, container.id, sandbox.workdir, user);
const running = await container
.inspect()
.then((info) => info.State.Running)
.catch(() => false);
if (!running) throw new SandboxExitedOnStartError();
const now = new Date();
return await updateSandboxById(sandbox.sandboxId, {
containerId: container.id,
user,
status: "running",
expiresAt: new Date(now.getTime() + sandbox.timeoutMs),
expiresAt: getSandboxExpiry(now, sandbox.timeoutMs, sandbox.deadlineAt),
lastActivityAt: now,
});
} catch (error) {
await container.remove({ force: true }).catch(() => {});
// AutoRemove may delete a container that exits at once (missing sh,
// sleep or date) before any of the steps above reports why.
if (isContainerGone(error)) throw new SandboxExitedOnStartError();
throw error;
}
};
export const createSandbox = async (
input: z.infer<typeof apiCreateSandbox>,
input: z.infer<typeof apiCreateSandbox> & { organizationId: string },
) => {
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) {
@@ -264,7 +315,7 @@ export const createSandbox = async (
.insert(sandboxes)
.values({
name: input.name ?? `sandbox-${Date.now().toString(36)}`,
environmentId: input.environmentId,
organizationId: input.organizationId,
serverId: input.serverId ?? null,
image,
template: input.template ?? null,
@@ -272,8 +323,13 @@ export const createSandbox = async (
memoryMb: input.memoryMb ?? SANDBOX_DEFAULTS.memoryMb,
pidsLimit: input.pidsLimit ?? SANDBOX_DEFAULTS.pidsLimit,
timeoutMs: input.timeoutMs ?? SANDBOX_DEFAULTS.timeoutMs,
deadlineAt: new Date(
Date.now() + (input.maxLifetimeMs ?? SANDBOX_DEFAULTS.maxLifetimeMs),
),
networkMode: input.networkMode ?? "isolated",
envVars: input.envVars ?? null,
envVars:
[...(template?.env ?? []), input.envVars].filter(Boolean).join("\n") ||
null,
workdir: input.workdir ?? template?.workdir ?? SANDBOX_DEFAULTS.workdir,
user: template?.user ?? null,
status: "creating",
@@ -287,7 +343,7 @@ export const createSandbox = async (
}
try {
const running = await startSandboxContainer(sandbox, environment.projectId);
const running = await startSandboxContainer(sandbox);
if (!running) throw new Error("Sandbox row disappeared during creation");
return running;
} catch (error) {
@@ -317,7 +373,7 @@ export const touchSandbox = async (sandbox: Sandbox) => {
const now = new Date();
return updateSandboxById(sandbox.sandboxId, {
lastActivityAt: now,
expiresAt: new Date(now.getTime() + sandbox.timeoutMs),
expiresAt: getSandboxExpiry(now, sandbox.timeoutMs, sandbox.deadlineAt),
});
};
@@ -337,16 +393,21 @@ export const execInSandbox = async (
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" });
const result = await withLiveContainer(sandbox, () =>
runSandboxExec(docker, sandbox.containerId, {
cmd: withSandboxActivity(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) {
await updateSandboxById(sandbox.sandboxId, {
status: "killed",
killedAt: new Date(),
});
}
return result;
};
@@ -372,25 +433,29 @@ export const writeSandboxFile = async (
}
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}`,
return withLiveContainer(sandbox, async () => {
const mkdir = await runSandboxExec(docker, sandbox.containerId, {
cmd: withSandboxActivity(`mkdir -p ${quote([dir])}`),
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
});
}
const ids = parseSandboxUser(sandbox.user) ?? { uid: 0, gid: 0 };
const tar = await buildSandboxFileTar({
name,
content: options.content,
mode: options.mode,
...ids,
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 };
});
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) => {
@@ -403,6 +468,7 @@ export const readSandboxFile = async (sandbox: Sandbox, path: string) => {
.getContainer(sandbox.containerId)
.getArchive({ path });
} catch (error) {
if (isContainerGone(error)) throw await markSandboxGone(sandbox);
if (isNotFound(error)) {
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
}
@@ -433,26 +499,28 @@ export const listSandboxFiles = async (sandbox: Sandbox, path?: string) => {
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",
return withLiveContainer(sandbox, async () => {
const find = await runSandboxExec(docker, sandbox.containerId, {
cmd: withSandboxActivity(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",
});
});
};
@@ -461,11 +529,23 @@ export const setSandboxTimeout = async (
timeoutMs: number,
) => {
assertSandboxRunning(sandbox);
const docker = await getRemoteDocker(sandbox.serverId);
const ids = parseSandboxUser(sandbox.user) ?? { uid: 0, gid: 0 };
const tar = await buildSandboxFileTar({
name: posix.basename(SANDBOX_TIMEOUT_FILE),
content: Buffer.from(`${toSandboxTimeoutSeconds(timeoutMs)}\n`),
...ids,
});
await withLiveContainer(sandbox, () =>
docker
.getContainer(sandbox.containerId)
.putArchive(tar, { path: posix.dirname(SANDBOX_TIMEOUT_FILE) }),
);
const now = new Date();
return updateSandboxById(sandbox.sandboxId, {
timeoutMs,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + timeoutMs),
expiresAt: getSandboxExpiry(now, timeoutMs, sandbox.deadlineAt),
});
};
@@ -480,13 +560,13 @@ export const removeSandboxContainer = async (
if (statusCodeOf(error) !== 304 && !isNotFound(error)) throw error;
});
await container.remove({ force: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error;
// AutoRemove may already be deleting it after the stop above.
if (!isNotFound(error) && statusCodeOf(error) !== 409) 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);
@@ -497,6 +577,7 @@ export const killSandbox = async (sandboxId: string) => {
);
}
}
if (sandbox.status === "killed") return sandbox;
return updateSandboxById(sandboxId, {
status: "killed",
killedAt: new Date(),
+59 -8
View File
@@ -2,17 +2,18 @@ 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_ORGANIZATION_LABEL = "dokploy.organizationId";
export const SANDBOX_ISOLATED_NETWORK = "dokploy-sandboxes-isolated";
export const SANDBOX_INTERNET_NETWORK = "dokploy-sandboxes";
export const SANDBOX_ACTIVITY_FILE = "/tmp/.dokploy-activity";
export const SANDBOX_TIMEOUT_FILE = "/tmp/.dokploy-timeout";
export const SANDBOX_IDLE_CHECK_SECONDS = 5;
export type SandboxNetworkMode = "isolated" | "internet";
export interface SandboxContainerSpec {
sandboxId: string;
projectId: string;
environmentId: string;
organizationId: string;
image: string;
cpu: number;
memoryMb: number;
@@ -21,6 +22,8 @@ export interface SandboxContainerSpec {
envVars?: string | null;
workdir: string;
user?: string | null;
timeoutMs: number;
deadlineAt: Date;
}
export const getSandboxNetworkName = (mode: SandboxNetworkMode) =>
@@ -29,6 +32,54 @@ export const getSandboxNetworkName = (mode: SandboxNetworkMode) =>
export const getSandboxContainerName = (sandboxId: string) =>
`dokploy-sandbox-${sandboxId}`;
export const getSandboxExpiry = (
now: Date,
timeoutMs: number,
deadlineAt: Date,
) => new Date(Math.min(now.getTime() + timeoutMs, deadlineAt.getTime()));
export const toSandboxTimeoutSeconds = (timeoutMs: number) =>
Math.ceil(timeoutMs / 1000);
// Shell builtin only: it must not fork, so it still works when the command it
// prefixes is about to exhaust PidsLimit.
export const withSandboxActivity = (cmd: string) =>
`echo 1 2>/dev/null > ${SANDBOX_ACTIVITY_FILE}; ${cmd}`;
// The container enforces its own lifetime, so Dokploy never has to poll or
// kill expired sandboxes:
// - the main process is `sleep` until deadlineAt, which needs no further forks
// and therefore holds even if the sandbox exhausts PidsLimit;
// - a background loop built from shell builtins (plus `sleep`) kills that main
// process once nothing marked activity for the timeout read from
// SANDBOX_TIMEOUT_FILE. If `sleep` cannot fork the loop gives up and only the
// deadline applies. `$$` in the subshell is the parent, i.e. the main sleep.
export const buildSandboxLifetimeCommand = (
deadlineAt: Date,
timeoutMs: number,
) => {
const deadline = Math.floor(deadlineAt.getTime() / 1000);
const tick = SANDBOX_IDLE_CHECK_SECONDS;
const a = SANDBOX_ACTIVITY_FILE;
const t = SANDBOX_TIMEOUT_FILE;
const script = [
`echo ${toSandboxTimeoutSeconds(timeoutMs)} 2>/dev/null > ${t}`,
`echo 0 2>/dev/null > ${a}`,
"(",
"idle=0",
`while sleep ${tick}; do`,
`x=; read -r x 2>/dev/null < ${a}`,
`if [ "$x" = 1 ]; then echo 0 2>/dev/null > ${a}; idle=0; else idle=$((idle + ${tick})); fi`,
`i=; read -r i 2>/dev/null < ${t}`,
`case "$i" in ''|*[!0-9]*) continue;; esac`,
`if [ "$idle" -ge "$i" ]; then kill $$; exit 0; fi`,
"done",
") &",
`exec sleep $(( ${deadline} - $(date +%s) ))`,
];
return ["sh", "-c", script.join("\n")];
};
export const parseSandboxEnvVars = (envVars?: string | null): string[] => {
if (!envVars) return [];
return envVars
@@ -57,19 +108,18 @@ export const buildSandboxContainerOptions = (
Image: spec.image,
// [""] resets the image entrypoint so Cmd always becomes PID 1.
Entrypoint: [""],
Cmd: ["tail", "-f", "/dev/null"],
Cmd: buildSandboxLifetimeCommand(spec.deadlineAt, spec.timeoutMs),
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,
[SANDBOX_ORGANIZATION_LABEL]: spec.organizationId,
},
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.
// timed-out command's shell is killed; `sleep` would leave zombies.
Init: true,
NanoCpus: Math.round(spec.cpu * 1e9),
Memory: memory,
@@ -78,6 +128,7 @@ export const buildSandboxContainerOptions = (
CapDrop: ["ALL"],
SecurityOpt: ["no-new-privileges"],
RestartPolicy: { Name: "no" },
AutoRemove: true,
LogConfig: {
Type: "json-file",
Config: { "max-size": "10m", "max-file": "1" },
+1 -13
View File
@@ -27,7 +27,6 @@ export interface SandboxExecResult {
timedOut: boolean;
truncated: boolean;
containerKilled: boolean;
containerRestarted: boolean;
}
export const toSandboxEnvArray = (env?: Record<string, string> | string[]) =>
@@ -123,7 +122,6 @@ export const runSandboxExec = async (
let timedOut = false;
let containerKilled = false;
let containerRestarted = false;
let timeoutTask: Promise<void> | null = null;
const timer = setTimeout(() => {
timedOut = true;
@@ -138,8 +136,7 @@ export const runSandboxExec = async (
]);
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.
// grep), so the whole container goes; AutoRemove deletes it.
await container
.kill({ signal: "SIGKILL" })
.then(() => {
@@ -147,14 +144,6 @@ export const runSandboxExec = async (
})
.catch(() => {});
stream.destroy();
if (containerKilled) {
await container
.start()
.then(() => {
containerRestarted = true;
})
.catch(() => {});
}
}
})();
}, options.timeoutMs);
@@ -179,6 +168,5 @@ export const runSandboxExec = async (
timedOut,
truncated: state.truncated,
containerKilled,
containerRestarted,
};
};
-188
View File
@@ -1,188 +0,0 @@
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;
};
+61 -8
View File
@@ -5,26 +5,79 @@ export interface SandboxTemplate {
user: string | null;
workdir: string;
description: string;
env?: string[];
}
const NON_ROOT = { user: "1000:1000", workdir: "/home/user" };
// 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",
...NON_ROOT,
description: "Ubuntu 24.04 with a POSIX shell and coreutils",
},
alpine: {
image: "alpine:3.20",
...NON_ROOT,
description: "Alpine 3.20, the smallest image (busybox)",
},
python: {
image: "python:3.12-slim",
user: "1000:1000",
workdir: "/home/user",
description: "Python 3.12 (pip available)",
image: "python:3.13-slim",
...NON_ROOT,
description: "Python 3.13 (pip available)",
},
node: {
image: "node:22-slim",
user: "1000:1000",
workdir: "/home/user",
...NON_ROOT,
description: "Node.js 22 (npm available)",
},
bun: {
image: "oven/bun:1",
...NON_ROOT,
description: "Bun 1.x runtime and package manager",
},
deno: {
image: "denoland/deno:debian",
...NON_ROOT,
description: "Deno 2.x",
env: ["DENO_DIR=/home/user/.cache/deno"],
},
go: {
image: "golang:1.24",
...NON_ROOT,
description: "Go 1.24",
},
rust: {
image: "rust:1-slim",
...NON_ROOT,
description: "Rust stable (cargo available)",
// The image's CARGO_HOME is root-owned; keep the toolchain there and the
// registry cache in the sandbox user's home.
env: [
"CARGO_HOME=/home/user/.cargo",
"PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
],
},
ruby: {
image: "ruby:3.3-slim",
...NON_ROOT,
description: "Ruby 3.3 (gem and bundler available)",
// The image's GEM_HOME (/usr/local/bundle) is root-owned.
env: [
"GEM_HOME=/home/user/.gem",
"BUNDLE_APP_CONFIG=/home/user/.bundle",
"PATH=/home/user/.gem/bin:/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
],
},
java: {
image: "eclipse-temurin:21-jdk",
...NON_ROOT,
description: "Java 21 JDK (Eclipse Temurin)",
},
php: {
image: "php:8.3-cli",
...NON_ROOT,
description: "PHP 8.3 CLI",
},
};