mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-24 01:12:27 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+17
-15
@@ -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}
|
||||
+14
-66
@@ -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: "/" } };
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user