fix(compose): prevent unsupported models in Swarm stack deployments

Prevents Dokploy from running docker stack deploy when the effective Compose specification uses Compose models.
This commit is contained in:
EgerDev 2026-09-11 16:28:14 -07:00
parent 853ca33659
commit cac1325236
3 changed files with 465 additions and 14 deletions

View File

@ -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<typeof import("node:fs")>();
return {
...actual,
existsSync: (p: Parameters<typeof actual.existsSync>[0]) => {
const path = String(p);
if (path.includes("/code/") && path.endsWith("docker-compose.yml")) {
return true;
}
return actual.existsSync(p);
},
readFileSync: (
p: Parameters<typeof actual.readFileSync>[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<string, unknown> = {}): ComposeSpecification => ({
services: {
app: { image: "nginx:alpine" },
},
...extra,
});
const compose = (
overrides: Record<string, unknown> = {},
): Parameters<typeof writeDomainsToCompose>[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<typeof writeDomainsToCompose>[0];
const writeDeploy = (c: ReturnType<typeof compose>) =>
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<string, unknown> = {}) =>
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<string, unknown> = {}) =>
({
...compose(overrides),
type: "compose" as const,
}) as unknown as Parameters<typeof getBuildComposeCommand>[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);
});
});

View File

@ -31,7 +31,7 @@ export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
: ""; : "";
const exportEnvCommand = getExportEnvCommand(compose); const exportEnvCommand = getExportEnvCommand(compose);
const newCompose = await writeDomainsToCompose(compose, domains); const newCompose = await writeDomainsToCompose(compose, domains, command);
const logContent = ` const logContent = `
App Name: ${appName} App Name: ${appName}
Build Compose 🐳 Build Compose 🐳

View File

@ -110,24 +110,41 @@ export const readComposeFile = async (compose: Compose) => {
return null; 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 ( export const writeDomainsToCompose = async (
compose: Compose, compose: Compose,
domains: Domain[], domains: Domain[],
dockerCommand = "",
) => { ) => {
let composeConverted: ComposeSpecification | null;
try { try {
const composeConverted = await addDomainToCompose(compose, domains); 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}";`;
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : String(error ?? ""); error instanceof Error ? error.message : String(error ?? "");
@ -137,6 +154,26 @@ exit 1;
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 ( export const applyComposeFilePatch = async (
compose: Compose, compose: Compose,