fix: resolve YAML merge keys when rewriting compose files (#5625)

This commit is contained in:
Mauricio Siu
2026-10-22 13:40:46 -06:00
parent b38a7fb4fd
commit 722996833f
3 changed files with 134 additions and 0 deletions
@@ -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",
]);
});
});
@@ -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);