Merge pull request #5753 from Dokploy/fix/compose-yaml-merge-keys

fix: resolve YAML merge keys when rewriting compose files
This commit is contained in:
Mauricio Siu
2026-10-22 14:32:42 -06:00
committed by GitHub
3 changed files with 154 additions and 0 deletions
@@ -0,0 +1,149 @@
import type { Compose } from "@dokploy/server/services/compose";
import type { Domain } from "@dokploy/server/services/domain";
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { describe, expect, it } from "vitest";
const listAnchorYaml = `
x-common: &common
restart: unless-stopped
labels:
- com.example.team=platform
networks: [default, dokploy-network]
services:
api:
<<: *common
image: nginx
worker:
<<: *common
image: busybox
`;
const mapAnchorYaml = `
x-common: &common
networks:
default: {}
shared:
aliases: [shared-api]
services:
api:
<<: *common
image: nginx
`;
const defaultOnlyAnchorYaml = `
x-common: &common
networks: [default]
services:
api:
<<: *common
image: nginx
worker:
<<: *common
image: busybox
`;
const buildCompose = (composeFile: string, isolatedDeployment = false) =>
({
appName: "merge-app",
composeType: "docker-compose",
composePath: "docker-compose.yml",
sourceType: "raw",
serverId: null,
isolatedDeployment,
isolatedDeploymentsVolume: false,
randomize: false,
suffix: "",
composeFile,
}) as unknown as Compose;
const domain: Domain = {
host: "api.example.com",
port: 80,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "compose-id",
domainType: "compose",
serviceName: "api",
domainId: "domain-id",
path: "/",
createdAt: "",
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("compose services using YAML merge keys (#5625)", () => {
it("keeps anchor networks when a domain is attached", async () => {
const result = await addDomainToCompose(buildCompose(listAnchorYaml), [
domain,
]);
expect(result?.services?.api).not.toHaveProperty("<<");
expect(result?.services?.api?.networks).toEqual(
expect.arrayContaining(["default", "dokploy-network"]),
);
expect(result?.services?.api?.restart).toBe("unless-stopped");
});
it("keeps anchor networks on isolated deployments", async () => {
const result = await addDomainToCompose(
buildCompose(listAnchorYaml, true),
[],
);
expect(result?.services?.api?.networks).toEqual(
expect.arrayContaining(["default", "dokploy-network", "merge-app"]),
);
expect(result?.services?.worker?.networks).toEqual(
expect.arrayContaining(["default", "dokploy-network", "merge-app"]),
);
});
it("keeps anchor labels next to the Dokploy labels", async () => {
const result = await addDomainToCompose(buildCompose(listAnchorYaml), [
domain,
]);
expect(result?.services?.api?.labels).toEqual(
expect.arrayContaining([
"com.example.team=platform",
"traefik.enable=true",
"traefik.docker.network=dokploy-network",
]),
);
});
it("keeps map-form anchor networks", async () => {
const result = await addDomainToCompose(buildCompose(mapAnchorYaml), [
domain,
]);
expect(result?.services?.api?.networks).toMatchObject({
default: {},
shared: { aliases: ["shared-api"] },
"dokploy-network": {},
});
});
it("does not leak injected networks into sibling services", async () => {
const result = await addDomainToCompose(
buildCompose(defaultOnlyAnchorYaml),
[domain],
);
expect(result?.services?.api?.networks).toEqual(
expect.arrayContaining(["default", "dokploy-network"]),
);
expect(result?.services?.worker?.networks).toEqual(["default"]);
});
});
@@ -20,6 +20,7 @@ export const randomizeComposeFile = async (
const composeFile = compose.composeFile;
const composeData = parse(composeFile, {
maxAliasCount: 10000,
merge: true,
}) as ComposeSpecification;
const randomSuffix = suffix || generateRandomHash();
@@ -70,6 +70,7 @@ export const loadDockerCompose = async (
const yamlStr = readFileSync(path, "utf8");
const parsedConfig = parse(yamlStr, {
maxAliasCount: 10000,
merge: true,
}) as ComposeSpecification;
return parsedConfig;
}
@@ -95,6 +96,7 @@ export const loadDockerComposeRemote = async (
if (!stdout) return null;
const parsedConfig = parse(stdout, {
maxAliasCount: 10000,
merge: true,
}) as ComposeSpecification;
return parsedConfig;
} catch {
@@ -164,6 +166,7 @@ export const applyComposeFilePatch = async (
try {
const parsed = parse(composeFilePatch.content, {
maxAliasCount: 10000,
merge: true,
}) as ComposeSpecification;
return parsed ?? null;
} catch {
@@ -273,6 +276,7 @@ export const addDomainToCompose = async (
if (compose.sourceType === "raw") {
result = parse(compose.composeFile, {
maxAliasCount: 10000,
merge: true,
}) as ComposeSpecification;
} else if (compose.serverId) {
result = await loadDockerComposeRemote(compose);