From aeb0db0c5ccabb4301af4b0385ddb819f8c4dfe0 Mon Sep 17 00:00:00 2001 From: Jafar TP Date: Sun, 25 Oct 2026 22:55:55 +0530 Subject: [PATCH 1/2] fix(resources): validate memory and CPU values before deploy The resource fields accepted any string and were read with parseInt, so "1792MB" became 1792 bytes and "1.5" CPU became 1 nanoCPU, which Swarm then rejected at deploy time. Require whole numbers no lower than Docker's minimums (empty or 0 still means no limit) and fix the reservation tooltips. --- .../__test__/server/resource-schema.test.ts | 154 ++++++++++++++++++ .../application/advanced/show-resources.tsx | 42 +++-- packages/server/src/db/schema/application.ts | 10 +- packages/server/src/db/schema/libsql.ts | 10 +- packages/server/src/db/schema/mariadb.ts | 10 +- packages/server/src/db/schema/mongo.ts | 10 +- packages/server/src/db/schema/mysql.ts | 10 +- packages/server/src/db/schema/postgres.ts | 10 +- packages/server/src/db/schema/redis.ts | 10 +- packages/server/src/db/schema/shared.ts | 28 ++++ 10 files changed, 256 insertions(+), 38 deletions(-) create mode 100644 apps/dokploy/__test__/server/resource-schema.test.ts diff --git a/apps/dokploy/__test__/server/resource-schema.test.ts b/apps/dokploy/__test__/server/resource-schema.test.ts new file mode 100644 index 00000000000..1be7fd6e8a9 --- /dev/null +++ b/apps/dokploy/__test__/server/resource-schema.test.ts @@ -0,0 +1,154 @@ +import { + apiUpdateApplication, + apiUpdateLibsql, + apiUpdateMariaDB, + apiUpdateMongo, + apiUpdateMySql, + apiUpdatePostgres, + apiUpdateRedis, +} from "@dokploy/server/db/schema"; +import { + resourceBytesSchema, + resourceNanoCpusSchema, +} from "@dokploy/server/db/schema/shared"; +import { calculateResources } from "@dokploy/server/utils/docker/utils"; +import { describe, expect, it } from "vitest"; + +const messagesOf = (result: { success: boolean; error?: unknown }) => + result.success + ? [] + : (result.error as { issues: { message: string }[] }).issues.map( + (issue) => issue.message, + ); + +describe("resourceBytesSchema", () => { + it.each([undefined, null, "", "0", "00", "6291456", "1879048192"])( + "accepts %j", + (value) => { + expect(resourceBytesSchema.safeParse(value).success).toBe(true); + }, + ); + + it.each(["1792MB", "1.5", "2e9", "-1", " 1073741824", "1GB"])( + "refuses %j as not a whole number of bytes", + (value) => { + expect(messagesOf(resourceBytesSchema.safeParse(value))).toEqual([ + "Must be a whole number of bytes", + ]); + }, + ); + + it.each(["1792", "6291455"])("refuses %j as below 6 MiB", (value) => { + expect(messagesOf(resourceBytesSchema.safeParse(value))).toEqual([ + "Must be at least 6291456 bytes", + ]); + }); +}); + +describe("resourceNanoCpusSchema", () => { + it.each([undefined, null, "", "0", "00", "1000000", "1500000000"])( + "accepts %j", + (value) => { + expect(resourceNanoCpusSchema.safeParse(value).success).toBe(true); + }, + ); + + it.each(["1.5", "2e9", "1792MB", "-1"])( + "refuses %j as not a whole number of nanoCPUs", + (value) => { + expect(messagesOf(resourceNanoCpusSchema.safeParse(value))).toEqual([ + "Must be a whole number of nanoCPUs (1000000000 = 1 CPU)", + ]); + }, + ); + + it.each(["1", "999999"])("refuses %j as below 0.001 CPU", (value) => { + expect(messagesOf(resourceNanoCpusSchema.safeParse(value))).toEqual([ + "Must be at least 1000000 nanoCPUs (1000000000 = 1 CPU)", + ]); + }); +}); + +describe("update schemas validate resource values", () => { + it.each([ + ["application", apiUpdateApplication, { applicationId: "id" }], + ["libsql", apiUpdateLibsql, { libsqlId: "id" }], + ["mariadb", apiUpdateMariaDB, { mariadbId: "id" }], + ["mongo", apiUpdateMongo, { mongoId: "id" }], + ["mysql", apiUpdateMySql, { mysqlId: "id" }], + ["postgres", apiUpdatePostgres, { postgresId: "id" }], + ["redis", apiUpdateRedis, { redisId: "id" }], + ] as const)("%s", (_, schema, ids) => { + const parse = (input: Record) => + schema.safeParse({ ...ids, ...input }).success; + + expect(parse({ memoryLimit: "1792MB" })).toBe(false); + expect(parse({ memoryReservation: "1792MB" })).toBe(false); + expect(parse({ cpuLimit: "1.5" })).toBe(false); + expect(parse({ cpuReservation: "1.5" })).toBe(false); + + expect( + parse({ + memoryLimit: "1879048192", + memoryReservation: "268435456", + cpuLimit: "1500000000", + cpuReservation: "250000000", + }), + ).toBe(true); + expect( + parse({ + memoryLimit: null, + memoryReservation: null, + cpuLimit: null, + cpuReservation: null, + }), + ).toBe(true); + expect(parse({})).toBe(true); + }); + + it("reports the field and unit for the application", () => { + const result = apiUpdateApplication.safeParse({ + applicationId: "id", + memoryLimit: "1792MB", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toMatchObject([ + { + path: ["memoryLimit"], + message: "Must be a whole number of bytes", + }, + ]); + } + }); +}); + +describe("calculateResources", () => { + it("leaves empty values unset", () => { + expect( + calculateResources({ + memoryLimit: null, + memoryReservation: "", + cpuLimit: null, + cpuReservation: "", + }), + ).toEqual({ + Limits: { MemoryBytes: undefined, NanoCPUs: undefined }, + Reservations: { MemoryBytes: undefined, NanoCPUs: undefined }, + }); + }); + + it("converts digit strings to numbers", () => { + expect( + calculateResources({ + memoryLimit: "1879048192", + memoryReservation: "268435456", + cpuLimit: "1500000000", + cpuReservation: "250000000", + }), + ).toEqual({ + Limits: { MemoryBytes: 1879048192, NanoCPUs: 1500000000 }, + Reservations: { MemoryBytes: 268435456, NanoCPUs: 250000000 }, + }); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx index f2a48bae704..85e82330e7c 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -64,11 +64,31 @@ const ulimitSchema = z.object({ Hard: z.coerce.number().int().min(-1, "Must be >= -1"), }); +// Same rules as the API: empty or zero means no limit, otherwise a whole +// number no lower than Docker's minimum (6 MiB of memory, 0.001 CPU). +const resourceValueSchema = (minimum: number, unit: string) => + z + .string() + .regex(/^\d*$/, { + message: `Must be a whole number of ${unit}`, + abort: true, + }) + .refine((value) => /^0*$/.test(value) || Number(value) >= minimum, { + message: `Must be at least ${minimum} ${unit}`, + }) + .optional(); + +const memorySchema = resourceValueSchema(6 * 1024 * 1024, "bytes"); +const cpuSchema = resourceValueSchema( + 1_000_000, + "nanoCPUs (1000000000 = 1 CPU)", +); + const addResourcesSchema = z.object({ - memoryReservation: z.string().optional(), - cpuLimit: z.string().optional(), - memoryLimit: z.string().optional(), - cpuReservation: z.string().optional(), + memoryReservation: memorySchema, + cpuLimit: cpuSchema, + memoryLimit: memorySchema, + cpuReservation: cpuSchema, ulimitsSwarm: z.array(ulimitSchema).optional(), }); @@ -268,9 +288,10 @@ export const ShowResources = ({ id, type }: Props) => {

- Memory soft limit in bytes. Example: 256MB = - 268435456 bytes. Use +/- buttons to adjust by 256 - MB. + Memory in bytes reserved on the node when + scheduling; it does not limit the container. + Example: 256MB = 268435456 bytes. Use +/- buttons + to adjust by 256 MB.

@@ -348,9 +369,10 @@ export const ShowResources = ({ id, type }: Props) => {

- CPU shares (relative weight). Example: 1 CPU = - 1000000000. Use +/- buttons to adjust by 0.25 - CPU. + CPU in units of 10^-9 CPUs reserved on the node + when scheduling; it does not limit the + container. Example: 1 CPU = 1000000000. Use +/- + buttons to adjust by 0.25 CPU.

diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index def6c344544..e7640978042 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -42,6 +42,8 @@ import { PlacementSwarmSchema, type RestartPolicySwarm, RestartPolicySwarmSchema, + resourceBytesSchema, + resourceNanoCpusSchema, type ServiceModeSwarm, ServiceModeSwarmSchema, triggerType, @@ -317,10 +319,10 @@ const createSchema = createInsertSchema(applications, { buildSecrets: z.string().optional(), name: z.string().min(1), description: z.string().optional(), - memoryReservation: z.string().optional(), - memoryLimit: z.string().optional(), - cpuReservation: z.string().optional(), - cpuLimit: z.string().optional(), + memoryReservation: resourceBytesSchema, + memoryLimit: resourceBytesSchema, + cpuReservation: resourceNanoCpusSchema, + cpuLimit: resourceNanoCpusSchema, title: z.string().optional(), enabled: z.boolean().optional(), subtitle: z.string().optional(), diff --git a/packages/server/src/db/schema/libsql.ts b/packages/server/src/db/schema/libsql.ts index 9658ad93a23..7b659b4267e 100644 --- a/packages/server/src/db/schema/libsql.ts +++ b/packages/server/src/db/schema/libsql.ts @@ -28,6 +28,8 @@ import { PlacementSwarmSchema, type RestartPolicySwarm, RestartPolicySwarmSchema, + resourceBytesSchema, + resourceNanoCpusSchema, type ServiceModeSwarm, ServiceModeSwarmSchema, sqldNode, @@ -131,10 +133,10 @@ const createSchema = createInsertSchema(libsql, { .default("ghcr.io/tursodatabase/libsql-server:v0.24.32"), command: z.string().optional(), env: z.string().optional(), - memoryReservation: z.string().optional(), - memoryLimit: z.string().optional(), - cpuReservation: z.string().optional(), - cpuLimit: z.string().optional(), + memoryReservation: resourceBytesSchema, + memoryLimit: resourceBytesSchema, + cpuReservation: resourceNanoCpusSchema, + cpuLimit: resourceNanoCpusSchema, environmentId: z.string(), applicationStatus: z.enum(["idle", "running", "done", "error"]), externalPort: z.number(), diff --git a/packages/server/src/db/schema/mariadb.ts b/packages/server/src/db/schema/mariadb.ts index 48b48f5b633..7c955899e22 100644 --- a/packages/server/src/db/schema/mariadb.ts +++ b/packages/server/src/db/schema/mariadb.ts @@ -28,6 +28,8 @@ import { PlacementSwarmSchema, type RestartPolicySwarm, RestartPolicySwarmSchema, + resourceBytesSchema, + resourceNanoCpusSchema, type ServiceModeSwarm, ServiceModeSwarmSchema, type UlimitsSwarm, @@ -139,10 +141,10 @@ const createSchema = createInsertSchema(mariadb, { command: z.string().optional(), args: z.array(z.string()).optional(), env: z.string().optional(), - memoryReservation: z.string().optional(), - memoryLimit: z.string().optional(), - cpuReservation: z.string().optional(), - cpuLimit: z.string().optional(), + memoryReservation: resourceBytesSchema, + memoryLimit: resourceBytesSchema, + cpuReservation: resourceNanoCpusSchema, + cpuLimit: resourceNanoCpusSchema, environmentId: z.string(), applicationStatus: z.enum(["idle", "running", "done", "error"]), externalPort: z.number(), diff --git a/packages/server/src/db/schema/mongo.ts b/packages/server/src/db/schema/mongo.ts index 5e91a72d128..9bae438ec6e 100644 --- a/packages/server/src/db/schema/mongo.ts +++ b/packages/server/src/db/schema/mongo.ts @@ -28,6 +28,8 @@ import { PlacementSwarmSchema, type RestartPolicySwarm, RestartPolicySwarmSchema, + resourceBytesSchema, + resourceNanoCpusSchema, type ServiceModeSwarm, ServiceModeSwarmSchema, type UlimitsSwarm, @@ -129,10 +131,10 @@ const createSchema = createInsertSchema(mongo, { command: z.string().optional(), args: z.array(z.string()).optional(), env: z.string().optional(), - memoryReservation: z.string().optional(), - memoryLimit: z.string().optional(), - cpuReservation: z.string().optional(), - cpuLimit: z.string().optional(), + memoryReservation: resourceBytesSchema, + memoryLimit: resourceBytesSchema, + cpuReservation: resourceNanoCpusSchema, + cpuLimit: resourceNanoCpusSchema, environmentId: z.string(), applicationStatus: z.enum(["idle", "running", "done", "error"]), externalPort: z.number(), diff --git a/packages/server/src/db/schema/mysql.ts b/packages/server/src/db/schema/mysql.ts index 3a58f5aad2d..2343ce0bd67 100644 --- a/packages/server/src/db/schema/mysql.ts +++ b/packages/server/src/db/schema/mysql.ts @@ -28,6 +28,8 @@ import { PlacementSwarmSchema, type RestartPolicySwarm, RestartPolicySwarmSchema, + resourceBytesSchema, + resourceNanoCpusSchema, type ServiceModeSwarm, ServiceModeSwarmSchema, type UlimitsSwarm, @@ -137,10 +139,10 @@ const createSchema = createInsertSchema(mysql, { command: z.string().optional(), args: z.array(z.string()).optional(), env: z.string().optional(), - memoryReservation: z.string().optional(), - memoryLimit: z.string().optional(), - cpuReservation: z.string().optional(), - cpuLimit: z.string().optional(), + memoryReservation: resourceBytesSchema, + memoryLimit: resourceBytesSchema, + cpuReservation: resourceNanoCpusSchema, + cpuLimit: resourceNanoCpusSchema, applicationStatus: z.enum(["idle", "running", "done", "error"]), externalPort: z.number(), description: z.string().optional(), diff --git a/packages/server/src/db/schema/postgres.ts b/packages/server/src/db/schema/postgres.ts index 067b83e43b3..b45c1d8c5cd 100644 --- a/packages/server/src/db/schema/postgres.ts +++ b/packages/server/src/db/schema/postgres.ts @@ -28,6 +28,8 @@ import { PlacementSwarmSchema, type RestartPolicySwarm, RestartPolicySwarmSchema, + resourceBytesSchema, + resourceNanoCpusSchema, type ServiceModeSwarm, ServiceModeSwarmSchema, type UlimitsSwarm, @@ -130,10 +132,10 @@ const createSchema = createInsertSchema(postgres, { command: z.string().optional(), args: z.array(z.string()).optional(), env: z.string().optional(), - memoryReservation: z.string().optional(), - memoryLimit: z.string().optional(), - cpuReservation: z.string().optional(), - cpuLimit: z.string().optional(), + memoryReservation: resourceBytesSchema, + memoryLimit: resourceBytesSchema, + cpuReservation: resourceNanoCpusSchema, + cpuLimit: resourceNanoCpusSchema, environmentId: z.string(), applicationStatus: z.enum(["idle", "running", "done", "error"]), externalPort: z.number(), diff --git a/packages/server/src/db/schema/redis.ts b/packages/server/src/db/schema/redis.ts index 95ffd8503e8..2b277e1a7df 100644 --- a/packages/server/src/db/schema/redis.ts +++ b/packages/server/src/db/schema/redis.ts @@ -27,6 +27,8 @@ import { PlacementSwarmSchema, type RestartPolicySwarm, RestartPolicySwarmSchema, + resourceBytesSchema, + resourceNanoCpusSchema, type ServiceModeSwarm, ServiceModeSwarmSchema, type UlimitsSwarm, @@ -120,10 +122,10 @@ const createSchema = createInsertSchema(redis, { command: z.string().optional(), args: z.array(z.string()).optional(), env: z.string().optional(), - memoryReservation: z.string().optional(), - memoryLimit: z.string().optional(), - cpuReservation: z.string().optional(), - cpuLimit: z.string().optional(), + memoryReservation: resourceBytesSchema, + memoryLimit: resourceBytesSchema, + cpuReservation: resourceNanoCpusSchema, + cpuLimit: resourceNanoCpusSchema, environmentId: z.string(), applicationStatus: z.enum(["idle", "running", "done", "error"]), externalPort: z.number(), diff --git a/packages/server/src/db/schema/shared.ts b/packages/server/src/db/schema/shared.ts index 6566968c649..db886b582c2 100644 --- a/packages/server/src/db/schema/shared.ts +++ b/packages/server/src/db/schema/shared.ts @@ -209,3 +209,31 @@ export const UlimitSwarmSchema = z .strict(); export const UlimitsSwarmSchema = z.array(UlimitSwarmSchema); + +// Resource values are stored as strings and handed to Docker as integers. +// Empty or zero means no limit; anything else must be a whole number Docker +// accepts, so a bad value is rejected on save instead of failing the deploy. +const resourceValueSchema = (minimum: number, unit: string) => + z + .string() + .regex(/^\d*$/, { + message: `Must be a whole number of ${unit}`, + abort: true, + }) + .refine((value) => /^0*$/.test(value) || Number(value) >= minimum, { + message: `Must be at least ${minimum} ${unit}`, + }) + .nullable() + .optional(); + +// The engine refuses memory limits below 6 MiB. +export const resourceBytesSchema = resourceValueSchema( + 6 * 1024 * 1024, + "bytes", +); + +// Swarm refuses CPU values below 0.001 CPU. +export const resourceNanoCpusSchema = resourceValueSchema( + 1_000_000, + "nanoCPUs (1000000000 = 1 CPU)", +); From becaf6cac06742d563eecc44df871c91112bb976 Mon Sep 17 00:00:00 2001 From: Jafar TP Date: Sun, 25 Oct 2026 23:19:37 +0530 Subject: [PATCH 2/2] fix(resources): reject values above the safe integer range Digit strings past Number.MAX_SAFE_INTEGER lose precision or become Infinity when converted, so the value sent to Docker would differ from the one saved. --- .../__test__/server/resource-schema.test.ts | 30 +++++++++++++++++++ .../application/advanced/show-resources.tsx | 3 ++ packages/server/src/db/schema/shared.ts | 3 ++ 3 files changed, 36 insertions(+) diff --git a/apps/dokploy/__test__/server/resource-schema.test.ts b/apps/dokploy/__test__/server/resource-schema.test.ts index 1be7fd6e8a9..11d0c1f873e 100644 --- a/apps/dokploy/__test__/server/resource-schema.test.ts +++ b/apps/dokploy/__test__/server/resource-schema.test.ts @@ -43,6 +43,21 @@ describe("resourceBytesSchema", () => { "Must be at least 6291456 bytes", ]); }); + + it("accepts the largest safe integer", () => { + expect(resourceBytesSchema.safeParse("9007199254740991").success).toBe( + true, + ); + }); + + it.each(["9007199254740992", "9007199254740993", "1".repeat(40)])( + "refuses %j as above the safe integer range", + (value) => { + expect(messagesOf(resourceBytesSchema.safeParse(value))).toEqual([ + "Must be at most 9007199254740991 bytes", + ]); + }, + ); }); describe("resourceNanoCpusSchema", () => { @@ -67,6 +82,21 @@ describe("resourceNanoCpusSchema", () => { "Must be at least 1000000 nanoCPUs (1000000000 = 1 CPU)", ]); }); + + it("accepts the largest safe integer", () => { + expect(resourceNanoCpusSchema.safeParse("9007199254740991").success).toBe( + true, + ); + }); + + it.each(["9007199254740992", "9007199254740993", "1".repeat(40)])( + "refuses %j as above the safe integer range", + (value) => { + expect(messagesOf(resourceNanoCpusSchema.safeParse(value))).toEqual([ + "Must be at most 9007199254740991 nanoCPUs (1000000000 = 1 CPU)", + ]); + }, + ); }); describe("update schemas validate resource values", () => { diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx index 85e82330e7c..7fac0236fdf 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -73,6 +73,9 @@ const resourceValueSchema = (minimum: number, unit: string) => message: `Must be a whole number of ${unit}`, abort: true, }) + .refine((value) => Number.isSafeInteger(Number(value)), { + message: `Must be at most ${Number.MAX_SAFE_INTEGER} ${unit}`, + }) .refine((value) => /^0*$/.test(value) || Number(value) >= minimum, { message: `Must be at least ${minimum} ${unit}`, }) diff --git a/packages/server/src/db/schema/shared.ts b/packages/server/src/db/schema/shared.ts index db886b582c2..f1dcaa190e0 100644 --- a/packages/server/src/db/schema/shared.ts +++ b/packages/server/src/db/schema/shared.ts @@ -220,6 +220,9 @@ const resourceValueSchema = (minimum: number, unit: string) => message: `Must be a whole number of ${unit}`, abort: true, }) + .refine((value) => Number.isSafeInteger(Number(value)), { + message: `Must be at most ${Number.MAX_SAFE_INTEGER} ${unit}`, + }) .refine((value) => /^0*$/.test(value) || Number(value) >= minimum, { message: `Must be at least ${minimum} ${unit}`, })