diff --git a/apps/dokploy/__test__/compose/stack-compose-models.test.ts b/apps/dokploy/__test__/compose/stack-compose-models.test.ts index 9842c4f1e..f424c0158 100644 --- a/apps/dokploy/__test__/compose/stack-compose-models.test.ts +++ b/apps/dokploy/__test__/compose/stack-compose-models.test.ts @@ -1,3 +1,7 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { db } from "@dokploy/server/db"; import { createCommand, @@ -11,7 +15,15 @@ import { 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 { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; import { parse, stringify } from "yaml"; const disk = vi.hoisted(() => ({ @@ -142,6 +154,112 @@ services: ).toBe(false); }); + it("skips Docker root global options before stack deploy", () => { + const file = "stack deploy -c docker-compose.yml demo"; + expect(isStackDeployCommand(`--context remote ${file}`)).toBe(true); + expect(isStackDeployCommand(`--context=remote ${file}`)).toBe(true); + expect(isStackDeployCommand(`-c remote ${file}`)).toBe(true); + expect(isStackDeployCommand(`-c=remote ${file}`)).toBe(true); + expect(isStackDeployCommand(`-cremote ${file}`)).toBe(true); + expect(isStackDeployCommand(`-D ${file}`)).toBe(true); + expect(isStackDeployCommand(`--debug ${file}`)).toBe(true); + expect(isStackDeployCommand(`--debug=true ${file}`)).toBe(true); + expect(isStackDeployCommand(`--tlsverify ${file}`)).toBe(true); + expect(isStackDeployCommand(`--config /tmp/config ${file}`)).toBe(true); + expect(isStackDeployCommand(`--config=deploy ${file}`)).toBe(true); + expect(isStackDeployCommand(`-H unix:///var/run/docker.sock ${file}`)).toBe( + true, + ); + expect(isStackDeployCommand(`-Hunix:///var/run/docker.sock ${file}`)).toBe( + true, + ); + expect( + isStackDeployCommand(`--context remote -D --tlsverify ${file}`), + ).toBe(true); + expect(isStackDeployCommand(`-- ${file}`)).toBe(true); + expect(isStackDeployCommand(`-Dc remote ${file}`)).toBe(true); + expect(isStackDeployCommand(`--config deploy ${file}`)).toBe(true); + expect(isStackDeployCommand(`--log-level=debug ${file}`)).toBe(true); + expect(isStackDeployCommand(`-v ${file}`)).toBe(true); + expect(isStackDeployCommand(`--config stack ${file}`)).toBe(true); + }); + + it("does not treat option values or compose args as stack deploy", () => { + expect(isStackDeployCommand("--context remote compose up")).toBe(false); + expect(isStackDeployCommand("-D compose up")).toBe(false); + expect(isStackDeployCommand("compose run app stack deploy")).toBe(false); + expect(isStackDeployCommand("compose exec app stack deploy")).toBe(false); + expect(isStackDeployCommand("compose -f my-stack deploy-file.yml up")).toBe( + false, + ); + expect(isStackDeployCommand("--context stack compose up")).toBe(false); + expect(isStackDeployCommand("compose run app stack deploy")).toBe(false); + expect(isStackDeployCommand("--context")).toBe(false); + expect(isStackDeployCommand("--config")).toBe(false); + expect(isStackDeployCommand("-H")).toBe(false); + expect(isStackDeployCommand("-l")).toBe(false); + expect( + isStackDeployCommand( + "--something-new value stack deploy -c file.yml demo", + ), + ).toBe(false); + expect(isStackDeployCommand("stack --context remote deploy")).toBe(false); + expect(isStackDeployCommand("-c stack deploy -c file.yml demo")).toBe( + false, + ); + expect(isStackDeployCommand("--debug false stack deploy --help")).toBe( + false, + ); + expect(isStackDeployCommand("-H stack deploy --help")).toBe(false); + expect(isStackDeployCommand("-cD remote stack deploy --help")).toBe(false); + }); + + it("classifies quoted shell argv the way /bin/sh does", () => { + expect( + isStackDeployCommand("'stack' 'deploy' -c docker-compose.yml demo"), + ).toBe(true); + expect( + isStackDeployCommand("st\"ack\" de'ploy' -c docker-compose.yml demo"), + ).toBe(true); + expect( + isStackDeployCommand( + '--config "/tmp/docker config" stack deploy -c file.yml demo', + ), + ).toBe(true); + expect( + isStackDeployCommand( + "--config '/tmp/docker config' stack deploy -c file.yml demo", + ), + ).toBe(true); + expect( + isStackDeployCommand('--context "remote" stack deploy -c file.yml demo'), + ).toBe(true); + expect( + isStackDeployCommand("-c 'remote' stack deploy -c file.yml demo"), + ).toBe(true); + expect(isStackDeployCommand("'compose' up")).toBe(false); + expect(isStackDeployCommand("compose run app 'stack' 'deploy'")).toBe( + false, + ); + expect(isStackDeployCommand("stack\tdeploy -c file.yml demo")).toBe(true); + expect(isStackDeployCommand('--context="" stack deploy --help')).toBe(true); + expect(isStackDeployCommand("--context= stack deploy --help")).toBe(true); + expect(isStackDeployCommand("--context '' stack deploy --help")).toBe(true); + expect(isStackDeployCommand("-c '' stack deploy --help")).toBe(true); + expect( + isStackDeployCommand("stack deploy -c file.yml demo # trailing comment"), + ).toBe(true); + expect(isStackDeployCommand("# stack deploy -c file.yml demo")).toBe(false); + expect(isStackDeployCommand("~ stack deploy --help")).toBe(false); + expect(isStackDeployCommand("'' stack deploy --help")).toBe(false); + }); + + it("does not crash on malformed quotes", () => { + expect(() => isStackDeployCommand("'stack deploy")).not.toThrow(); + expect(() => isStackDeployCommand('"stack deploy')).not.toThrow(); + expect(() => isStackDeployCommand('stack "deploy')).not.toThrow(); + }); + it("does not crash on malformed specs", () => { expect(composeSpecificationUsesModels(null)).toBe(false); expect(composeSpecificationUsesModels(undefined)).toBe(false); @@ -300,6 +418,92 @@ services: ), ).resolves.toContain("base64 -d"); }); + + it("rejects stack deploy after Docker root global options when models are present", async () => { + const models = yaml(spec({ models: { llm: { model: "ai/smollm2" } } })); + for (const command of [ + "--context remote stack deploy -c docker-compose.yml demo", + "--context=remote stack deploy -c docker-compose.yml demo", + "-c remote stack deploy -c docker-compose.yml demo", + "-D stack deploy -c docker-compose.yml demo", + "--tlsverify stack deploy -c docker-compose.yml demo", + "--config /tmp/config stack deploy -c docker-compose.yml demo", + "-H unix:///var/run/docker.sock stack deploy -c docker-compose.yml demo", + "--context remote -D --tlsverify stack deploy -c docker-compose.yml demo", + ]) { + await expect( + writeDeploy( + compose({ + composeType: "docker-compose", + command, + composeFile: models, + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + } + }); + + it("rejects quoted stack deploy custom commands when models are present", 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: + '--config "/tmp/docker config" stack deploy -c docker-compose.yml demo', + composeFile: models, + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + }); + + it("rejects later && docker stack deploy at sanitize time", () => { + expect(() => + createCommand( + compose({ + command: "compose up -d && docker stack deploy -c file.yml demo", + }) as never, + ), + ).toThrow(/Chained commands must strictly start with 'docker compose '/); + expect(() => + createCommand( + compose({ + command: + "compose up -d && docker --context remote stack deploy -c file.yml demo", + }) as never, + ), + ).toThrow(/Chained commands must strictly start with 'docker compose '/); + }); + + it("allows compose commands that only resemble stack deploy after globals", async () => { + const models = yaml(spec({ models: { llm: { model: "ai/smollm2" } } })); + for (const command of [ + "--context remote compose up -d", + "-D compose up -d", + "compose run app stack deploy", + "compose exec app stack deploy", + "--context stack compose up -d", + ]) { + await expect( + writeDeploy( + compose({ + composeType: "docker-compose", + command, + composeFile: models, + }), + ), + ).resolves.toContain("base64 -d"); + } + }); }); describe("compose-file patch order", () => { @@ -411,4 +615,157 @@ describe("getBuildComposeCommand stack models", () => { ), ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); }); + + it("does not emit stack deploy when global options precede stack deploy and models are present", async () => { + await expect( + getBuildComposeCommand( + buildArgs({ + composeType: "docker-compose", + command: "--context remote stack deploy -c docker-compose.yml demo", + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + }); +}); + +const fakeDockerArgv = (command: string, pathPrefix: string) => { + const result = spawnSync("sh", ["-c", `docker ${command}`], { + encoding: "utf8", + env: { ...process.env, PATH: `${pathPrefix}:${process.env.PATH ?? ""}` }, + }); + if (result.status !== 0) { + throw new Error(result.stderr || `fake docker failed: ${result.status}`); + } + return JSON.parse(result.stdout) as string[]; +}; + +const argvLooksLikeStackDeploy = (argv: string[]) => { + let i = 0; + while (i < argv.length) { + const token = argv[i]; + if (!token || token === "-" || !token.startsWith("-")) break; + if (token === "--") { + i += 1; + break; + } + if ( + token === "-D" || + token === "-v" || + token === "-h" || + token.startsWith("--debug") || + token.startsWith("--tls") || + token.startsWith("--help") || + token.startsWith("--version") + ) { + i += 1; + continue; + } + if ( + token.startsWith("--context") || + token.startsWith("--config") || + token.startsWith("--host") || + token.startsWith("--log-level") || + token.startsWith("--tlscacert") || + token.startsWith("--tlscert") || + token.startsWith("--tlskey") || + token === "-c" || + token.startsWith("-c") || + token === "-H" || + token.startsWith("-H") || + token === "-l" || + token.startsWith("-l") + ) { + if (token.includes("=") || (token.startsWith("-c") && token.length > 2)) { + i += 1; + continue; + } + i += 2; + continue; + } + break; + } + return argv[i] === "stack" && argv[i + 1] === "deploy"; +}; + +describe("fake-docker shell argv differential", () => { + let fakePath = ""; + + beforeAll(() => { + fakePath = mkdtempSync(join(tmpdir(), "dokploy-fake-docker-")); + const bin = join(fakePath, "docker"); + writeFileSync( + bin, + `#!/usr/bin/env node +process.stdout.write(JSON.stringify(process.argv.slice(2))); +`, + ); + chmodSync(bin, 0o755); + }); + + afterAll(() => { + if (fakePath) rmSync(fakePath, { recursive: true, force: true }); + }); + + const corpus = [ + "stack deploy -c docker-compose.yml demo", + "'stack' 'deploy' -c docker-compose.yml demo", + "st\"ack\" de'ploy' -c docker-compose.yml demo", + '--config "/tmp/docker config" stack deploy -c file.yml demo', + "--config '/tmp/docker config' stack deploy -c file.yml demo", + '--context "remote" stack deploy -c file.yml demo', + "-c 'remote' stack deploy -c file.yml demo", + "-D stack deploy -c file.yml demo", + "--debug=false stack deploy -c file.yml demo", + "--context=remote stack deploy -c file.yml demo", + "-c remote stack deploy -c file.yml demo", + "-H unix:///var/run/docker.sock stack deploy -c file.yml demo", + "'compose' up", + "compose run app 'stack' 'deploy'", + "--context remote compose up", + "-D compose up", + "--debug false stack deploy --help", + "-H stack deploy --help", + "compose -f my-stack deploy-file.yml up", + "stack\tdeploy -c file.yml demo", + " stack deploy -c file.yml demo", + ]; + + it("agrees with /bin/sh argv for the supported custom-command corpus", () => { + for (const command of corpus) { + const argv = fakeDockerArgv(command, fakePath); + expect(isStackDeployCommand(command), command).toBe( + argvLooksLikeStackDeploy(argv), + ); + } + }); + + it("classifies default createCommand stack deploy as stack deploy in generated-shell argv", () => { + const command = createCommand(compose({ command: "" }) as never); + expect(command.startsWith("stack deploy")).toBe(true); + expect(isStackDeployCommand(command)).toBe(true); + const argv = fakeDockerArgv(command, fakePath); + expect(argv[0]).toBe("stack"); + expect(argv[1]).toBe("deploy"); + }); + + it("expands quoted stack deploy to stack/deploy argv", () => { + expect( + fakeDockerArgv("'stack' 'deploy' -c file.yml demo", fakePath), + ).toEqual(["stack", "deploy", "-c", "file.yml", "demo"]); + expect( + fakeDockerArgv( + '--config "/tmp/docker config" stack deploy -c file.yml demo', + fakePath, + ), + ).toEqual([ + "--config", + "/tmp/docker config", + "stack", + "deploy", + "-c", + "file.yml", + "demo", + ]); + }); }); diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index b1421122f..4603186f8 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -6,7 +6,7 @@ import { network, patch } from "@dokploy/server/db/schema"; import type { Compose } from "@dokploy/server/services/compose"; import type { Domain } from "@dokploy/server/services/domain"; import { eq, inArray } from "drizzle-orm"; -import { quote } from "shell-quote"; +import { parse as parseShell, quote } from "shell-quote"; import { parse, stringify } from "yaml"; import { execAsyncRemote } from "../process/execAsync"; import { cloneBitbucketRepository } from "../providers/bitbucket"; @@ -132,9 +132,107 @@ export const composeSpecificationUsesModels = ( return false; }; +const DOCKER_VALUE_LONG = new Set([ + "config", + "context", + "host", + "log-level", + "tlscacert", + "tlscert", + "tlskey", +]); +const DOCKER_VALUE_SHORT = new Set(["c", "H", "l"]); +const DOCKER_BOOL_LONG = new Set([ + "debug", + "help", + "tls", + "tlsverify", + "version", +]); +const DOCKER_BOOL_SHORT = new Set(["D", "h", "v"]); + +const tokenizeDockerCommand = (command: string) => { + const tokens: string[] = []; + for (const entry of parseShell(command, {})) { + if (typeof entry === "string") { + tokens.push(entry); + continue; + } + if ("comment" in entry) continue; + if ("op" in entry) { + if (entry.op === "glob") { + tokens.push(entry.pattern); + continue; + } + if (entry.op === "&&") break; + return []; + } + } + return tokens; +}; + +const skipDockerGlobalOptions = (tokens: string[]) => { + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + if (!token || token === "-") break; + if (token === "--") return i + 1; + if (!token.startsWith("-")) break; + + if (token.startsWith("--")) { + const eq = token.indexOf("="); + const name = eq === -1 ? token.slice(2) : token.slice(2, eq); + if (DOCKER_BOOL_LONG.has(name)) { + i += 1; + continue; + } + if (DOCKER_VALUE_LONG.has(name)) { + if (eq !== -1) { + i += 1; + continue; + } + if (i + 1 >= tokens.length) return -1; + i += 2; + continue; + } + return -1; + } + + let k = 1; + let consumeNextValue = false; + while (k < token.length) { + const flag = token[k]; + if (!flag) break; + if (DOCKER_BOOL_SHORT.has(flag)) { + k += 1; + continue; + } + if (DOCKER_VALUE_SHORT.has(flag)) { + if (k + 1 < token.length) { + consumeNextValue = false; + k = token.length; + break; + } + consumeNextValue = true; + break; + } + return -1; + } + if (consumeNextValue) { + if (i + 1 >= tokens.length) return -1; + i += 2; + continue; + } + i += 1; + } + return i; +}; + export const isStackDeployCommand = (command: string) => { - const [first, second] = command.trim().split(/\s+/); - return first === "stack" && second === "deploy"; + const tokens = tokenizeDockerCommand(command); + const i = skipDockerGlobalOptions(tokens); + if (i < 0) return false; + return tokens[i] === "stack" && tokens[i + 1] === "deploy"; }; export const writeDomainsToCompose = async (