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..0da3c8513 --- /dev/null +++ b/apps/dokploy/__test__/compose/domain/merge-key.test.ts @@ -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"]); + }); +}); 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);