From 722996833f8df54800f2d8bcf5e6e647a5494ec5 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 22 Oct 2026 13:40:46 -0600 Subject: [PATCH] fix: resolve YAML merge keys when rewriting compose files (#5625) --- .../__test__/compose/domain/merge-key.test.ts | 129 ++++++++++++++++++ packages/server/src/utils/docker/compose.ts | 1 + packages/server/src/utils/docker/domain.ts | 4 + 3 files changed, 134 insertions(+) create mode 100644 apps/dokploy/__test__/compose/domain/merge-key.test.ts diff --git a/apps/dokploy/__test__/compose/domain/merge-key.test.ts b/apps/dokploy/__test__/compose/domain/merge-key.test.ts new file mode 100644 index 000000000..1b34b1248 --- /dev/null +++ b/apps/dokploy/__test__/compose/domain/merge-key.test.ts @@ -0,0 +1,129 @@ +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 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), []); + + expect(result?.services?.worker?.labels).toEqual( + expect.arrayContaining(["com.example.team=platform"]), + ); + }); + + 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(listAnchorYaml), [ + domain, + ]); + + expect(result?.services?.worker?.networks).toEqual([ + "default", + "dokploy-network", + ]); + }); +}); diff --git a/packages/server/src/utils/docker/compose.ts b/packages/server/src/utils/docker/compose.ts index 2e2011b03..7beb1e55b 100644 --- a/packages/server/src/utils/docker/compose.ts +++ b/packages/server/src/utils/docker/compose.ts @@ -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(); diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index e9fd373ae..bee1513d2 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -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);