Skip to content

fix(swarm): sanitize healthcheck test serialization and persist swarm settings (#5171, #5223) - #5448

Open
fliptrigga13 wants to merge 1 commit into
Dokploy:canaryfrom
fliptrigga13:fix/issue-5171-5223-swarm-settings-healthcheck-persistence
Open

fliptrigga13 wants to merge 1 commit into
Dokploy:canaryfrom
fliptrigga13:fix/issue-5171-5223-swarm-settings-healthcheck-persistence

Conversation

@fliptrigga13

@fliptrigga13 fliptrigga13 commented Sep 13, 2026

Copy link
Copy Markdown

Overview

Fixes #5171 and #5223.

Resolves critical production outage and configuration bugs in Docker Swarm settings for Applications and Databases:

  1. Health Check Test Serialization Outage (Swarm Settings health check 'Test' field serializes as single string instead of JSON array, causing tasks to hang in 'Starting' forever #5171):
    • When configuring Health Checks under Advanced → Swarm Settings, pasting JSON array strings (e.g. ["CMD", "curl", "-f", "..."]) or single command strings (e.g. curl -f http://localhost:3000/health) resulted in malformed serialization (Test: ["[\"CMD\", ...]"] or single commands without CMD-SHELL). Docker Engine rejected this with Unknown healthcheck type '' (expected 'CMD'), leaving tasks stuck in Starting indefinitely and causing VIP/DNS resolution failures.
    • Also addressed fresh services or cleared healthchecks passing Test: [""] or { Test: [] } by introducing cleanHealthCheckSwarm(healthCheckSwarm):
      • Automatically cleans and parses JSON string arrays.
      • Automatically wraps bare shell commands with ["CMD-SHELL", command].
      • Preserves valid CMD, CMD-SHELL, and NONE directives.
      • Discards empty / whitespace-only tests so Docker does not receive invalid health checks.
  2. Swarm Settings UI Persistence & Schema Validation Rejections (Docker Swarm settings are not persisted from the UI #5223):
    • In UpdateConfigSwarmSchema, Parallelism and Order were strictly required (z.number() and z.string()), causing partial updates (such as updating only Monitor, Delay, or Order) from UpdateConfigForm and RollbackConfigForm to fail Zod validation with 400 Bad Request, reverting settings on refresh.
    • In stopGracePeriodSwarm, input values from HTML number inputs were strings, failing z.number().nullable() validation in backend router schemas. Converted to z.coerce.number().nullable() across application and database schemas (postgres, mysql, redis, mongo, mariadb, libsql) and normalized in StopGracePeriodForm.
    • In ShowClusterSettings, useEffect previously checked if (data?.command) to reset the form for replicas and registryId. Services without custom run commands failed to populate replicas and registryId properly. Changed to if (data) and updated toast messages.
  3. Safe ForceUpdate Increment:
    • In mechanizeDockerContainer and database service updates (postgres, redis, mysql, mongo, mariadb, traefik-setup, forward-auth-setup, rollbacks), changed ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1 to (inspect.Spec.TaskTemplate.ForceUpdate ?? 0) + 1 to prevent NaN when ForceUpdate was initially unset by Docker.

Verification & Testing

  1. Automated Unit Tests:
    • Added apps/dokploy/__test__/cluster/swarm-settings-healthcheck.test.ts with 15 test cases covering:
      • Null/empty healthcheck sanitation.
      • Empty Test: [""] discarding.
      • JSON array parsing and extraction.
      • Single command CMD-SHELL wrapping.
      • Prefix handling for CMD-SHELL, CMD, and NONE.
      • generateConfigContainer behavior for valid and invalid healthchecks.
      • Partial UpdateConfigSwarmSchema parsing.
      • StopGracePeriodSchema number coercion and null/undefined handling.
      • Safe ForceUpdate calculation preventing NaN.
    • All 15 unit tests pass (100% pass rate).
  2. Linting & Code Style:
    • Verified with Biome (npx @biomejs/biome check).

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because malformed directive-only or overpopulated health-check arrays can still reach Docker and defeat the outage fix.

Summary

  • Normalizes JSON-array and bare-command health-check input before generating service specifications.
  • Updates application and database schemas to accept numeric form values.
  • Corrects cluster-setting persistence and service-update defaults.
  • Adds regression tests, although they currently test local reimplementations rather than production behavior.

Reviews (1) · Last reviewed commit: "fix(swarm): sanitize healthcheck test se..."

Comment on lines +653 to +667
if (test.length > 0) {
const first = test[0];
if (first !== "NONE" && first !== "CMD" && first !== "CMD-SHELL") {
if (first.startsWith("CMD-SHELL ")) {
test = ["CMD-SHELL", first.slice(10).trim()];
} else if (first.startsWith("CMD ")) {
test = ["CMD", ...first.slice(4).trim().split(/\s+/)];
} else if (test.length === 1) {
test = ["CMD-SHELL", first];
} else {
test = ["CMD", ...test];
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Malformed directives remain valid

The sanitizer accepts any nonempty array beginning with NONE, CMD, or CMD-SHELL without checking the remaining entries. The form can produce values such as ["CMD-SHELL"] after removing an empty command, or ["NONE", "curl"] when an extra row is present. These malformed tests are then attached to the service specification, so Docker can reject the service update. Validate that NONE is the only element and that command directives contain a valid command.

Comment on lines +11 to +129
export const cleanHealthCheckSwarm = (
healthCheck?: HealthCheckSwarm | null,
): HealthCheckSwarm | undefined => {
if (!healthCheck) return undefined;

let test = healthCheck.Test;
if (test) {
test = test
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter(Boolean);

if (test.length === 1 && test[0].startsWith("[") && test[0].endsWith("]")) {
try {
const parsed = JSON.parse(test[0]);
if (Array.isArray(parsed)) {
test = parsed
.map((t) => (typeof t === "string" ? t.trim() : String(t).trim()))
.filter(Boolean);
}
} catch {
// Not valid JSON array, keep original
}
}

if (test.length > 0) {
const first = test[0];
if (first !== "NONE" && first !== "CMD" && first !== "CMD-SHELL") {
if (first.startsWith("CMD-SHELL ")) {
test = ["CMD-SHELL", first.slice(10).trim()];
} else if (first.startsWith("CMD ")) {
test = ["CMD", ...first.slice(4).trim().split(/\s+/)];
} else if (test.length === 1) {
test = ["CMD-SHELL", first];
} else {
test = ["CMD", ...test];
}
}
}
}

const hasValidTest = Boolean(test && test.length > 0);
const hasOptions =
healthCheck.Interval !== undefined ||
healthCheck.Timeout !== undefined ||
healthCheck.StartPeriod !== undefined ||
healthCheck.Retries !== undefined;

if (!hasValidTest && !hasOptions) {
return undefined;
}

return {
...(hasValidTest && { Test: test }),
...(healthCheck.Interval !== undefined && {
Interval: Number(healthCheck.Interval),
}),
...(healthCheck.Timeout !== undefined && {
Timeout: Number(healthCheck.Timeout),
}),
...(healthCheck.StartPeriod !== undefined && {
StartPeriod: Number(healthCheck.StartPeriod),
}),
...(healthCheck.Retries !== undefined && {
Retries: Number(healthCheck.Retries),
}),
};
};

export const generateConfigContainerMock = (application: {
healthCheckSwarm?: HealthCheckSwarm | null;
updateConfigSwarm?: any;
stopGracePeriodSwarm?: number | null;
}) => {
const cleanedHealthCheck = cleanHealthCheckSwarm(
application.healthCheckSwarm,
);
return {
...(cleanedHealthCheck && {
HealthCheck: cleanedHealthCheck,
}),
...(application.updateConfigSwarm
? { UpdateConfig: application.updateConfigSwarm }
: {
UpdateConfig: {
Parallelism: 1,
Order: "start-first",
FailureAction: "rollback",
},
}),
...(application.stopGracePeriodSwarm !== null &&
application.stopGracePeriodSwarm !== undefined && {
StopGracePeriod: application.stopGracePeriodSwarm,
}),
};
};

export const normalizeStopGracePeriod = (val: unknown): number | null => {
if (val === null || val === undefined || val === "") return null;
const num = Number(val);
return Number.isNaN(num) ? null : num;
};

export const validateUpdateConfigPartial = (config: Record<string, any>) => {
const result: Record<string, any> = {};
if (config.Parallelism !== undefined)
result.Parallelism = Number(config.Parallelism);
if (config.Delay !== undefined) result.Delay = Number(config.Delay);
if (config.FailureAction !== undefined)
result.FailureAction = String(config.FailureAction);
if (config.Monitor !== undefined) result.Monitor = Number(config.Monitor);
if (config.MaxFailureRatio !== undefined)
result.MaxFailureRatio = Number(config.MaxFailureRatio);
if (config.Order !== undefined) result.Order = String(config.Order);
return result;
};

export const computeForceUpdate = (existingForceUpdate?: number): number => {
return (existingForceUpdate ?? 0) + 1;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests bypass production code

This suite reimplements the health-check sanitizer, container-config generator, schema parsing, and ForceUpdate calculation inside the test file instead of importing the production implementations. As a result, these tests can remain green if the real outage fixes regress or diverge. Import and exercise the production utilities and schemas so the suite provides meaningful regression coverage.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Swarm Settings health check 'Test' field serializes as single string instead of JSON array, causing tasks to hang in 'Starting' forever

1 participant