Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dokploy/__test__/drop/drop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ const baseApp: ApplicationNested = {
dockerContextPath: null,
rollbackActive: false,
stopGracePeriodSwarm: null,
ulimitsSwarm: null,
};

describe("unzipDrop using real zip files", () => {
Expand Down
48 changes: 48 additions & 0 deletions apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type MockCreateServiceOptions = {
TaskTemplate?: {
ContainerSpec?: {
StopGracePeriod?: number;
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>;
};
};
[key: string]: unknown;
Expand Down Expand Up @@ -57,6 +58,7 @@ const createApplication = (
},
replicas: 1,
stopGracePeriodSwarm: 0n,
ulimitsSwarm: null,
serverId: "server-id",
...overrides,
}) as unknown as ApplicationNested;
Expand Down Expand Up @@ -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");
});
});
1 change: 1 addition & 0 deletions apps/dokploy/__test__/traefik/traefik.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ const baseApp: ApplicationNested = {
username: null,
dockerContextPath: null,
stopGracePeriodSwarm: null,
ulimitsSwarm: null,
};

const baseDomain: Domain = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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"
Expand Down Expand Up @@ -107,17 +138,24 @@ 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({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
});
}
}, [data, form, form.reset]);
Expand All @@ -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");
Expand Down Expand Up @@ -325,6 +367,145 @@ export const ShowResources = ({ id, type }: Props) => {
}}
/>
</div>

{/* Ulimits Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FormLabel className="text-base">Ulimits</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>
Set resource limits for the container. Each ulimit has
a soft limit (warning threshold) and hard limit
(maximum allowed). Use -1 for unlimited.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({ Name: "nofile", Soft: 65535, Hard: 65535 })
}
>
<Plus className="h-4 w-4 mr-1" />
Add Ulimit
</Button>
</div>

{fields.length > 0 && (
<div className="space-y-3">
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
>
<FormField
control={form.control}
name={`ulimitsSwarm.${index}.Name`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-xs">Type</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select ulimit" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ULIMIT_PRESETS.map((preset) => (
<SelectItem
key={preset.value}
value={preset.value}
>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ulimitsSwarm.${index}.Soft`}
render={({ field }) => (
<FormItem className="w-32">
<FormLabel className="text-xs">
Soft Limit
</FormLabel>
<FormControl>
<Input
type="number"
min={-1}
placeholder="65535"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ulimitsSwarm.${index}.Hard`}
render={({ field }) => (
<FormItem className="w-32">
<FormLabel className="text-xs">
Hard Limit
</FormLabel>
<FormControl>
<Input
type="number"
min={-1}
placeholder="65535"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-6 text-destructive hover:text-destructive"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}

{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No ulimits configured. Click &quot;Add Ulimit&quot; to set
resource limits.
</p>
)}
</div>

<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
Expand Down
6 changes: 6 additions & 0 deletions apps/dokploy/drizzle/0134_oval_shinobi_shaw.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ALTER TABLE "application" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "ulimitsSwarm" json;
Loading