diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index 1c0a446a38..adbed084c7 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -146,6 +146,7 @@ const baseApp: ApplicationNested = { dockerContextPath: null, rollbackActive: false, stopGracePeriodSwarm: null, + ulimitsSwarm: null, }; describe("unzipDrop using real zip files", () => { diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts index c12a272bc8..b212fe34f1 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -6,6 +6,7 @@ type MockCreateServiceOptions = { TaskTemplate?: { ContainerSpec?: { StopGracePeriod?: number; + Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>; }; }; [key: string]: unknown; @@ -57,6 +58,7 @@ const createApplication = ( }, replicas: 1, stopGracePeriodSwarm: 0n, + ulimitsSwarm: null, serverId: "server-id", ...overrides, }) as unknown as ApplicationNested; @@ -106,4 +108,50 @@ describe("mechanizeDockerContainer", () => { "StopGracePeriod", ); }); + + it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => { + const ulimits = [ + { Name: "nofile", Soft: 10000, Hard: 20000 }, + { Name: "nproc", Soft: 4096, Hard: 8192 }, + ]; + const application = createApplication({ ulimitsSwarm: ulimits }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits); + }); + + it("omits Ulimits when ulimitsSwarm is null", async () => { + const application = createApplication({ ulimitsSwarm: null }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); + }); + + it("omits Ulimits when ulimitsSwarm is an empty array", async () => { + const application = createApplication({ ulimitsSwarm: [] }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); + }); }); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 8e678413ce..41e5451c53 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -124,6 +124,7 @@ const baseApp: ApplicationNested = { username: null, dockerContextPath: null, stopGracePeriodSwarm: null, + ulimitsSwarm: null, }; const baseDomain: Domain = { diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx index aea30e49b6..509b9bb0d5 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -1,7 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { InfoIcon } from "lucide-react"; +import { InfoIcon, Plus, Trash2 } from "lucide-react"; import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -31,6 +31,14 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { api } from "@/utils/api"; const CPU_STEP = 0.25; @@ -50,13 +58,36 @@ const memoryConverter = createConverter(1024 * 1024, (mb) => { : `${formatNumber(mb)} MB`; }); +const ulimitSchema = z.object({ + Name: z.string().min(1, "Name is required"), + Soft: z.coerce.number().int().min(-1, "Must be >= -1"), + Hard: z.coerce.number().int().min(-1, "Must be >= -1"), +}); + const addResourcesSchema = z.object({ memoryReservation: z.string().optional(), cpuLimit: z.string().optional(), memoryLimit: z.string().optional(), cpuReservation: z.string().optional(), + ulimitsSwarm: z.array(ulimitSchema).optional(), }); +const ULIMIT_PRESETS = [ + { value: "nofile", label: "nofile (Open Files)" }, + { value: "nproc", label: "nproc (Processes)" }, + { value: "memlock", label: "memlock (Locked Memory)" }, + { value: "stack", label: "stack (Stack Size)" }, + { value: "core", label: "core (Core File Size)" }, + { value: "cpu", label: "cpu (CPU Time)" }, + { value: "data", label: "data (Data Segment)" }, + { value: "fsize", label: "fsize (File Size)" }, + { value: "locks", label: "locks (File Locks)" }, + { value: "msgqueue", label: "msgqueue (Message Queues)" }, + { value: "nice", label: "nice (Nice Priority)" }, + { value: "rtprio", label: "rtprio (Real-time Priority)" }, + { value: "sigpending", label: "sigpending (Pending Signals)" }, +]; + export type ServiceType = | "postgres" | "mongo" @@ -107,10 +138,16 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: "", memoryLimit: "", memoryReservation: "", + ulimitsSwarm: [], }, resolver: zodResolver(addResourcesSchema), }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "ulimitsSwarm", + }); + useEffect(() => { if (data) { form.reset({ @@ -118,6 +155,7 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: data?.cpuReservation || undefined, memoryLimit: data?.memoryLimit || undefined, memoryReservation: data?.memoryReservation || undefined, + ulimitsSwarm: (data as any)?.ulimitsSwarm || [], }); } }, [data, form, form.reset]); @@ -134,6 +172,10 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: formData.cpuReservation || null, memoryLimit: formData.memoryLimit || null, memoryReservation: formData.memoryReservation || null, + ulimitsSwarm: + formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0 + ? formData.ulimitsSwarm + : null, }) .then(async () => { toast.success("Resources Updated"); @@ -325,6 +367,145 @@ export const ShowResources = ({ id, type }: Props) => { }} /> + + {/* Ulimits Section */} +
+
+
+ Ulimits + + + + + + +

+ Set resource limits for the container. Each ulimit has + a soft limit (warning threshold) and hard limit + (maximum allowed). Use -1 for unlimited. +

+
+
+
+
+ +
+ + {fields.length > 0 && ( +
+ {fields.map((field, index) => ( +
+ ( + + Type + + + + )} + /> + ( + + + Soft Limit + + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> + ( + + + Hard Limit + + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> + +
+ ))} +
+ )} + + {fields.length === 0 && ( +

+ No ulimits configured. Click "Add Ulimit" to set + resource limits. +

+ )} +
+