diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts index daf2dbe54..39d377afb 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -5,6 +5,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; type MockCreateServiceOptions = { TaskTemplate?: { ContainerSpec?: { + HealthCheck?: { + Test?: string[]; + }; StopGracePeriod?: number; Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>; }; @@ -158,4 +161,27 @@ describe("mechanizeDockerContainer", () => { const [settings] = call; expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); }); + + it("normalizes pasted health check JSON before creating a service", async () => { + const application = createApplication({ + healthCheckSwarm: { + Test: ['["CMD","wget","-q","http://localhost/"]'], + }, + }); + + 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?.HealthCheck?.Test).toEqual([ + "CMD", + "wget", + "-q", + "http://localhost/", + ]); + }); }); diff --git a/apps/dokploy/__test__/server/swarm-health-check.test.ts b/apps/dokploy/__test__/server/swarm-health-check.test.ts new file mode 100644 index 000000000..85272f9da --- /dev/null +++ b/apps/dokploy/__test__/server/swarm-health-check.test.ts @@ -0,0 +1,106 @@ +import { + normalizeSwarmHealthCheck, + normalizeSwarmHealthCheckTest, +} from "@dokploy/server/utils/docker/health-check"; +import { generateConfigContainer } from "@dokploy/server/utils/docker/utils"; +import { describe, expect, it } from "vitest"; + +describe("Swarm health check normalization", () => { + it("preserves a valid exec-form health check", () => { + expect(normalizeSwarmHealthCheckTest(["CMD", "curl", "-f", "/"])).toEqual([ + "CMD", + "curl", + "-f", + "/", + ]); + }); + + it("parses a JSON array pasted into a single Test field", () => { + expect( + normalizeSwarmHealthCheckTest([ + '["CMD","curl","-f","http://localhost:8080/"]', + ]), + ).toEqual(["CMD", "curl", "-f", "http://localhost:8080/"]); + }); + + it("wraps a single command as CMD-SHELL", () => { + expect( + normalizeSwarmHealthCheckTest(["curl -f http://localhost/"]), + ).toEqual(["CMD-SHELL", "curl -f http://localhost/"]); + }); + + it("keeps bracket-style shell conditions as shell commands", () => { + expect(normalizeSwarmHealthCheckTest(["[ -f /tmp/ready ]"])).toEqual([ + "CMD-SHELL", + "[ -f /tmp/ready ]", + ]); + }); + + it("turns a legacy blank Test value into an explicit disabled health check", () => { + expect(normalizeSwarmHealthCheckTest([""])).toEqual(["NONE"]); + }); + + it("omits a completely empty health check", () => { + expect(normalizeSwarmHealthCheck({ Test: [] })).toBeUndefined(); + }); + + it("preserves timing options when Test is absent", () => { + expect( + normalizeSwarmHealthCheck({ + Test: [], + Interval: 10_000_000_000, + Retries: 3, + }), + ).toEqual({ Interval: 10_000_000_000, Retries: 3 }); + }); + + it("rejects malformed pasted JSON", () => { + expect(() => normalizeSwarmHealthCheckTest(['["CMD",'])).toThrow( + "Health check Test must be a JSON array of strings", + ); + }); + + it("rejects pasted JSON arrays containing non-string values", () => { + expect(() => normalizeSwarmHealthCheckTest(['["CMD",42]'])).toThrow( + "Health check Test must be a JSON array of strings", + ); + }); + + it("rejects a pasted JSON array beginning with a non-string value", () => { + expect(() => normalizeSwarmHealthCheckTest(["[1]"])).toThrow( + "Health check Test must be a JSON array of strings", + ); + }); + + it("rejects an unsupported multi-item instruction", () => { + expect(() => normalizeSwarmHealthCheckTest(["curl", "-f", "/"])).toThrow( + "Health check Test must begin with CMD, CMD-SHELL, or NONE", + ); + }); + + it("rejects NONE combined with another command", () => { + expect(() => normalizeSwarmHealthCheckTest(["NONE", "curl /"])).toThrow( + "NONE cannot be combined with another health check command", + ); + }); + + it("normalizes health checks at the Docker service boundary", () => { + const config = generateConfigContainer({ + healthCheckSwarm: { + Test: ['["CMD-SHELL","wget -q --spider http://localhost/"]'], + Interval: 5_000_000_000, + }, + }); + + expect(config.HealthCheck).toEqual({ + Test: ["CMD-SHELL", "wget -q --spider http://localhost/"], + Interval: 5_000_000_000, + }); + }); + + it("does not add a Docker health check when none is configured", () => { + const config = generateConfigContainer({ healthCheckSwarm: null }); + + expect(config).not.toHaveProperty("HealthCheck"); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx index 761f9a864..902d30edf 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx @@ -1,3 +1,4 @@ +import { normalizeSwarmHealthCheck } from "@dokploy/server/utils/docker/health-check"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -103,13 +104,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { const onSubmit = async (formData: z.infer) => { setIsLoading(true); try { - // Check if all values are empty, if so, send null to clear the database - const hasAnyValue = - (formData.Test && formData.Test.length > 0) || - formData.Interval !== undefined || - formData.Timeout !== undefined || - formData.StartPeriod !== undefined || - formData.Retries !== undefined; + const normalizedHealthCheck = normalizeSwarmHealthCheck(formData); await mutateAsync({ applicationId: id || "", @@ -119,13 +114,15 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { mariadbId: id || "", mongoId: id || "", libsqlId: id || "", - healthCheckSwarm: hasAnyValue ? formData : null, + healthCheckSwarm: normalizedHealthCheck ?? null, }); toast.success("Health check updated successfully"); refetch(); - } catch { - toast.error("Error updating health check"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error updating health check", + ); } finally { setIsLoading(false); } @@ -154,8 +151,9 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Test Commands - Command to run for health check (e.g., ["CMD-SHELL", "curl -f - http://localhost:3000/health"]) + Add one array item per field, paste a JSON array into one field, or + enter a single shell command. Example: ["CMD-SHELL", "curl -f + http://localhost:3000/health"]
{testCommands.map((cmd: string, index: number) => ( diff --git a/packages/server/src/utils/docker/health-check.ts b/packages/server/src/utils/docker/health-check.ts new file mode 100644 index 000000000..90a744af8 --- /dev/null +++ b/packages/server/src/utils/docker/health-check.ts @@ -0,0 +1,104 @@ +export interface SwarmHealthCheck { + Test?: string[] | undefined; + Interval?: number | undefined; + Timeout?: number | undefined; + StartPeriod?: number | undefined; + Retries?: number | undefined; +} + +const HEALTH_CHECK_INSTRUCTIONS = new Set(["CMD", "CMD-SHELL", "NONE"]); + +const invalidJsonArrayError = () => + new Error("Health check Test must be a JSON array of strings"); + +export const normalizeSwarmHealthCheckTest = ( + test: string[] | undefined, +): string[] | undefined => { + if (!test || test.length === 0) { + return undefined; + } + + if (test.length === 1) { + const value = test[0]?.trim() ?? ""; + if (!value) { + return ["NONE"]; + } + + if (value.startsWith("[")) { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + // POSIX test expressions are valid shell commands, not JSON arrays. + if (/^\[\s+[^,\r\n]+\s+\]$/.test(value)) { + return ["CMD-SHELL", value]; + } + throw invalidJsonArrayError(); + } + + if ( + !Array.isArray(parsed) || + parsed.some((command) => typeof command !== "string") + ) { + throw invalidJsonArrayError(); + } + + return normalizeSwarmHealthCheckTest(parsed); + } + + const instruction = value.toUpperCase(); + if (instruction === "NONE") { + return ["NONE"]; + } + if (instruction === "CMD" || instruction === "CMD-SHELL") { + throw new Error(`${instruction} health checks require a command`); + } + + return ["CMD-SHELL", value]; + } + + const instruction = test[0]?.trim().toUpperCase() ?? ""; + if (!HEALTH_CHECK_INSTRUCTIONS.has(instruction)) { + throw new Error( + "Health check Test must begin with CMD, CMD-SHELL, or NONE", + ); + } + + if (instruction === "NONE") { + if (test.slice(1).some((command) => command.trim().length > 0)) { + throw new Error( + "NONE cannot be combined with another health check command", + ); + } + return ["NONE"]; + } + + if (!test.slice(1).some((command) => command.trim().length > 0)) { + throw new Error(`${instruction} health checks require a command`); + } + + return [instruction, ...test.slice(1)]; +}; + +export const normalizeSwarmHealthCheck = ( + healthCheck: SwarmHealthCheck | null | undefined, +): SwarmHealthCheck | undefined => { + if (!healthCheck) { + return undefined; + } + + const { Test, ...options } = healthCheck; + const normalizedTest = normalizeSwarmHealthCheckTest(Test); + const hasOptions = Object.values(options).some( + (value) => value !== undefined, + ); + + if (!normalizedTest && !hasOptions) { + return undefined; + } + + return { + ...options, + ...(normalizedTest && { Test: normalizedTest }), + }; +}; diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index e342eb450..34176c812 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -16,6 +16,7 @@ import type { RedisNested } from "../databases/redis"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { spawnAsync } from "../process/spawnAsync"; import { getRemoteDocker } from "../servers/remote-docker"; +import { normalizeSwarmHealthCheck } from "./health-check"; interface RegistryAuth { username: string; @@ -642,10 +643,11 @@ export const generateConfigContainer = ( } = application; const haveMounts = mounts && mounts.length > 0; + const normalizedHealthCheck = normalizeSwarmHealthCheck(healthCheckSwarm); return { - ...(healthCheckSwarm && { - HealthCheck: healthCheckSwarm, + ...(normalizedHealthCheck && { + HealthCheck: normalizedHealthCheck, }), ...(restartPolicySwarm && { RestartPolicy: restartPolicySwarm,