From cac13252360b0f3c88be1e9c7279a422bd59dca7 Mon Sep 17 00:00:00 2001 From: EgerDev <285544328+EgerDev@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:28:14 -0700 Subject: [PATCH] fix(compose): prevent unsupported models in Swarm stack deployments Prevents Dokploy from running docker stack deploy when the effective Compose specification uses Compose models. --- .../compose/stack-compose-models.test.ts | 414 ++++++++++++++++++ packages/server/src/utils/builders/compose.ts | 2 +- packages/server/src/utils/docker/domain.ts | 63 ++- 3 files changed, 465 insertions(+), 14 deletions(-) create mode 100644 apps/dokploy/__test__/compose/stack-compose-models.test.ts diff --git a/apps/dokploy/__test__/compose/stack-compose-models.test.ts b/apps/dokploy/__test__/compose/stack-compose-models.test.ts new file mode 100644 index 000000000..9842c4f1e --- /dev/null +++ b/apps/dokploy/__test__/compose/stack-compose-models.test.ts @@ -0,0 +1,414 @@ +import { db } from "@dokploy/server/db"; +import { + createCommand, + getBuildComposeCommand, +} from "@dokploy/server/utils/builders/compose"; +import { + addDomainToCompose, + composeSpecificationUsesModels, + isStackDeployCommand, + STACK_COMPOSE_MODELS_ERROR, + writeDomainsToCompose, +} from "@dokploy/server/utils/docker/domain"; +import type { ComposeSpecification } from "@dokploy/server/utils/docker/types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parse, stringify } from "yaml"; + +const disk = vi.hoisted(() => ({ + yaml: "services:\n app:\n image: nginx:alpine\n", +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (p: Parameters[0]) => { + const path = String(p); + if (path.includes("/code/") && path.endsWith("docker-compose.yml")) { + return true; + } + return actual.existsSync(p); + }, + readFileSync: ( + p: Parameters[0], + enc?: BufferEncoding, + ) => { + const path = String(p); + if (path.includes("/code/") && path.endsWith("docker-compose.yml")) { + return disk.yaml; + } + return actual.readFileSync(p, enc as never); + }, + }; +}); + +const yaml = (spec: unknown) => stringify(spec, { lineWidth: 1000 }); + +const spec = (extra: Record = {}): ComposeSpecification => ({ + services: { + app: { image: "nginx:alpine" }, + }, + ...extra, +}); + +const compose = ( + overrides: Record = {}, +): Parameters[0] => + ({ + appName: "demo", + composeFile: yaml(spec()), + composePath: "docker-compose.yml", + composeType: "stack", + command: "", + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: null, + sourceType: "raw", + suffix: "", + env: "", + createEnvFile: false, + mounts: [], + domains: [], + environment: { project: { env: "" }, env: "" }, + ...overrides, + }) as unknown as Parameters[0]; + +const writeDeploy = (c: ReturnType) => + writeDomainsToCompose(c, [], createCommand(c as never)); + +describe("composeSpecificationUsesModels", () => { + it("detects top-level and service-level models structurally", () => { + expect(composeSpecificationUsesModels(spec())).toBe(false); + expect( + composeSpecificationUsesModels( + spec({ models: { llm: { model: "ai/smollm2" } } }), + ), + ).toBe(true); + expect( + composeSpecificationUsesModels({ + services: { app: { image: "nginx:alpine", models: ["llm"] } }, + }), + ).toBe(true); + expect(composeSpecificationUsesModels(spec({ models: {} }))).toBe(true); + expect(composeSpecificationUsesModels(spec({ models: null }))).toBe(true); + expect(composeSpecificationUsesModels(spec({ models: [] }))).toBe(true); + expect(composeSpecificationUsesModels(spec({ models: "foo" }))).toBe(true); + expect(composeSpecificationUsesModels(spec({ models: 123 }))).toBe(true); + expect( + composeSpecificationUsesModels({ + services: { app: { image: "nginx:alpine", models: [] } }, + }), + ).toBe(true); + expect( + composeSpecificationUsesModels({ + services: { app: { image: "nginx:alpine", models: null } }, + } as unknown as ComposeSpecification), + ).toBe(true); + }); + + it("does not treat x-models or the word models in strings as models", () => { + expect( + composeSpecificationUsesModels( + spec({ "x-models": { llm: { model: "ai/smollm2" } } }), + ), + ).toBe(false); + const fromStrings = parse(` +services: + app: + image: nginx:alpine + # models: fake + environment: + NOTE: "models: in env" + labels: + info: "models: in label" + command: ["echo", "models: in command"] +`) as ComposeSpecification; + expect(composeSpecificationUsesModels(fromStrings)).toBe(false); + }); + + it("detects stack deploy from the first two command tokens", () => { + expect( + isStackDeployCommand("stack deploy -c docker-compose.yml demo"), + ).toBe(true); + expect( + isStackDeployCommand(" stack deploy -c docker-compose.yml demo"), + ).toBe(true); + expect( + isStackDeployCommand("compose -p demo -f docker-compose.yml up -d"), + ).toBe(false); + expect( + isStackDeployCommand("compose -f my-stack deploy-file.yml up -d"), + ).toBe(false); + }); + + it("does not crash on malformed specs", () => { + expect(composeSpecificationUsesModels(null)).toBe(false); + expect(composeSpecificationUsesModels(undefined)).toBe(false); + expect(composeSpecificationUsesModels("nope" as never)).toBe(false); + expect( + composeSpecificationUsesModels({ + services: null, + } as unknown as ComposeSpecification), + ).toBe(false); + expect( + composeSpecificationUsesModels({ + services: { app: "bad" }, + } as unknown as ComposeSpecification), + ).toBe(false); + }); +}); + +describe("stack deploy compatibility", () => { + it("allows docker compose with models and stack without models", async () => { + const withModels = compose({ + composeType: "docker-compose", + composeFile: yaml( + spec({ + models: { llm: { model: "ai/smollm2" } }, + services: { app: { image: "nginx:alpine", models: ["llm"] } }, + }), + ), + }); + await expect(writeDeploy(withModels)).resolves.toContain("base64 -d"); + await expect(writeDeploy(compose())).resolves.toContain("base64 -d"); + }); + + it("rejects default stack deploy when the final spec has models", async () => { + const top = compose({ + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }); + const svc = compose({ + composeFile: yaml({ + services: { app: { image: "nginx:alpine", models: ["llm"] } }, + }), + }); + const both = compose({ + composeFile: yaml({ + services: { app: { image: "nginx:alpine", models: ["llm"] } }, + models: { llm: { model: "ai/smollm2" } }, + }), + }); + await expect(writeDeploy(top)).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + await expect(writeDeploy(svc)).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + await expect(writeDeploy(both)).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + }); + + it("allows x-models and textual 'models:' that are not Compose models keys", async () => { + await expect( + writeDeploy( + compose({ + composeFile: yaml( + spec({ "x-models": { llm: { model: "ai/smollm2" } } }), + ), + }), + ), + ).resolves.toContain("base64 -d"); + await expect( + writeDeploy( + compose({ + composeFile: ` +services: + app: + image: nginx:alpine + # models: fake + environment: + NOTE: "models: in env" + labels: + info: "models: in label" + command: ["echo", "models: in command"] +`, + }), + ), + ).resolves.toContain("base64 -d"); + }); + + it("still converts stack compose with models for preview", async () => { + const converted = await addDomainToCompose( + compose({ + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + [], + ); + expect(converted?.models).toEqual({ llm: { model: "ai/smollm2" } }); + }); + + it("inspects the transformed spec so randomization keeps models detectable", async () => { + await expect( + writeDeploy( + compose({ + randomize: true, + suffix: "abc123", + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + await expect( + writeDeploy( + compose({ + isolatedDeployment: true, + suffix: "iso", + composeFile: yaml({ + services: { app: { image: "nginx:alpine", models: ["llm"] } }, + }), + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + }); + + it("allows stack records whose custom command is compose up", async () => { + await expect( + writeDeploy( + compose({ + command: "compose -p demo -f docker-compose.yml up -d", + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + ), + ).resolves.toContain("base64 -d"); + }); + + it("rejects docker-compose records whose custom command is stack deploy", async () => { + const models = yaml(spec({ models: { llm: { model: "ai/smollm2" } } })); + await expect( + writeDeploy( + compose({ + composeType: "docker-compose", + command: "stack deploy -c docker-compose.yml demo", + composeFile: models, + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + await expect( + writeDeploy( + compose({ + composeType: "docker-compose", + command: " stack deploy -c docker-compose.yml demo", + composeFile: models, + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + }); + + it("does not treat a compose file token containing 'stack deploy' as stack deploy", async () => { + await expect( + writeDeploy( + compose({ + composeType: "docker-compose", + command: "compose -f my-stack deploy-file.yml up -d", + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + ), + ).resolves.toContain("base64 -d"); + }); +}); + +describe("compose-file patch order", () => { + afterEach(() => { + vi.restoreAllMocks(); + disk.yaml = yaml(spec()); + }); + + const gitCompose = (overrides: Record = {}) => + compose({ + sourceType: "github", + composeId: "compose-1", + composePath: "docker-compose.yml", + ...overrides, + }); + + it("rejects when a patch adds models to a stack deploy", async () => { + disk.yaml = yaml(spec()); + vi.spyOn(db.query.patch, "findMany").mockResolvedValue([ + { + enabled: true, + type: "update", + filePath: "docker-compose.yml", + content: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }, + ] as never); + await expect(writeDeploy(gitCompose())).rejects.toThrow( + STACK_COMPOSE_MODELS_ERROR, + ); + }); + + it("allows stack deploy when a patch removes models", async () => { + disk.yaml = yaml(spec({ models: { llm: { model: "ai/smollm2" } } })); + vi.spyOn(db.query.patch, "findMany").mockResolvedValue([ + { + enabled: true, + type: "update", + filePath: "docker-compose.yml", + content: yaml(spec()), + }, + ] as never); + await expect(writeDeploy(gitCompose())).resolves.toContain("base64 -d"); + }); +}); + +describe("getBuildComposeCommand stack models", () => { + const buildArgs = (overrides: Record = {}) => + ({ + ...compose(overrides), + type: "compose" as const, + }) as unknown as Parameters[0]; + + it("does not emit stack deploy for default stack + models", async () => { + await expect( + getBuildComposeCommand( + buildArgs({ + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + }); + + it("still emits stack deploy without models and compose up with models", async () => { + const stack = await getBuildComposeCommand(buildArgs()); + expect(stack).toContain("stack deploy"); + expect(stack).not.toContain("docker network inspect"); + const up = await getBuildComposeCommand( + buildArgs({ + composeType: "docker-compose", + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + ); + expect(up).toContain("compose -p demo"); + expect(up).toContain("up -d"); + expect(up).not.toContain("stack deploy"); + }); + + it("does not create an isolated overlay network when stack + models is rejected", async () => { + await expect( + getBuildComposeCommand( + buildArgs({ + isolatedDeployment: true, + suffix: "iso", + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + }); + + it("keeps a custom compose command for stack + models", async () => { + const command = await getBuildComposeCommand( + buildArgs({ + command: "compose -p demo -f docker-compose.yml up -d", + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + ); + expect(command).toContain("compose -p demo"); + expect(command).not.toContain("stack deploy"); + }); + + it("does not emit stack deploy for a docker-compose record with a custom stack command and models", async () => { + await expect( + getBuildComposeCommand( + buildArgs({ + composeType: "docker-compose", + command: "stack deploy -c docker-compose.yml demo", + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + }); +}); diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index d81eecf0a..15a52cfeb 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -31,7 +31,7 @@ export const getBuildComposeCommand = async (rawCompose: ComposeNested) => { : ""; const exportEnvCommand = getExportEnvCommand(compose); - const newCompose = await writeDomainsToCompose(compose, domains); + const newCompose = await writeDomainsToCompose(compose, domains, command); const logContent = ` App Name: ${appName} Build Compose 🐳 diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 0d83d4a48..b1421122f 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -110,24 +110,41 @@ export const readComposeFile = async (compose: Compose) => { return null; }; +export const STACK_COMPOSE_MODELS_ERROR = + "Compose models are not supported with Docker Swarm stack deployments. Use Docker Compose deployment type or remove the models configuration."; + +export const composeSpecificationUsesModels = ( + spec: ComposeSpecification | null | undefined, +): boolean => { + if (!spec || typeof spec !== "object") return false; + if (Object.hasOwn(spec, "models")) return true; + const services = spec.services; + if (!services || typeof services !== "object") return false; + for (const service of Object.values(services)) { + if ( + service && + typeof service === "object" && + Object.hasOwn(service, "models") + ) { + return true; + } + } + return false; +}; + +export const isStackDeployCommand = (command: string) => { + const [first, second] = command.trim().split(/\s+/); + return first === "stack" && second === "deploy"; +}; + export const writeDomainsToCompose = async ( compose: Compose, domains: Domain[], + dockerCommand = "", ) => { + let composeConverted: ComposeSpecification | null; try { - const composeConverted = await addDomainToCompose(compose, domains); - const path = getComposePath(compose); - - if (!composeConverted) { - return ` -echo "❌ Error: Compose file not found"; -exit 1; - `; - } - - const composeString = stringify(composeConverted, { lineWidth: 1000 }); - const encodedContent = encodeBase64(composeString); - return `echo "${encodedContent}" | base64 -d > "${path}";`; + composeConverted = await addDomainToCompose(compose, domains); } catch (error) { const message = error instanceof Error ? error.message : String(error ?? ""); @@ -137,6 +154,26 @@ exit 1; exit 1; `; } + + const path = getComposePath(compose); + + if (!composeConverted) { + return ` +echo "❌ Error: Compose file not found"; +exit 1; + `; + } + + if ( + isStackDeployCommand(dockerCommand) && + composeSpecificationUsesModels(composeConverted) + ) { + throw new Error(STACK_COMPOSE_MODELS_ERROR); + } + + const composeString = stringify(composeConverted, { lineWidth: 1000 }); + const encodedContent = encodeBase64(composeString); + return `echo "${encodedContent}" | base64 -d > "${path}";`; }; export const applyComposeFilePatch = async ( compose: Compose,