This commit is contained in:
Aditya Nandlal 2026-09-11 13:11:54 -04:00 committed by GitHub
commit 066be86c0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 250 additions and 14 deletions

View File

@ -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/",
]);
});
});

View File

@ -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");
});
});

View File

@ -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<typeof healthCheckFormSchema>) => {
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) => {
<div>
<FormLabel>Test Commands</FormLabel>
<FormDescription>
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"]
</FormDescription>
<div className="space-y-2 mt-2">
{testCommands.map((cmd: string, index: number) => (

View File

@ -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 }),
};
};

View File

@ -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,