mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
Merge b16a66c1c9 into 853ca33659
This commit is contained in:
commit
066be86c0c
@ -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/",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
106
apps/dokploy/__test__/server/swarm-health-check.test.ts
Normal file
106
apps/dokploy/__test__/server/swarm-health-check.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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) => (
|
||||
|
||||
104
packages/server/src/utils/docker/health-check.ts
Normal file
104
packages/server/src/utils/docker/health-check.ts
Normal 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 }),
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user