diff --git a/apps/dokploy/__test__/compose/stack-compose-models.test.ts b/apps/dokploy/__test__/compose/stack-compose-models.test.ts index f424c0158..42f82200a 100644 --- a/apps/dokploy/__test__/compose/stack-compose-models.test.ts +++ b/apps/dokploy/__test__/compose/stack-compose-models.test.ts @@ -214,6 +214,61 @@ services: expect(isStackDeployCommand("-cD remote stack deploy --help")).toBe(false); }); + it("treats stack up as the documented stack deploy alias", () => { + expect(isStackDeployCommand("stack up -c docker-compose.yml demo")).toBe( + true, + ); + expect( + isStackDeployCommand("'stack' 'up' -c docker-compose.yml demo"), + ).toBe(true); + expect( + isStackDeployCommand( + "--context remote stack up -c docker-compose.yml demo", + ), + ).toBe(true); + expect( + isStackDeployCommand( + "--context=remote stack up -c docker-compose.yml demo", + ), + ).toBe(true); + expect(isStackDeployCommand("-D stack up -c docker-compose.yml demo")).toBe( + true, + ); + expect( + isStackDeployCommand("st\"ack\" u'p' -c docker-compose.yml demo"), + ).toBe(true); + }); + + it("does not treat compose commands as stack up", () => { + expect(isStackDeployCommand("compose up")).toBe(false); + expect(isStackDeployCommand("compose run app stack up")).toBe(false); + expect(isStackDeployCommand("compose exec app stack up")).toBe(false); + expect(isStackDeployCommand("compose -f stack.yml up")).toBe(false); + expect(isStackDeployCommand("--context stack compose up")).toBe(false); + }); + + it("skips deprecated stack --orchestrator before deploy or up", () => { + expect( + isStackDeployCommand( + "stack --orchestrator swarm deploy -c docker-compose.yml demo", + ), + ).toBe(true); + expect( + isStackDeployCommand( + "stack --orchestrator=swarm up -c docker-compose.yml demo", + ), + ).toBe(true); + expect( + isStackDeployCommand( + "--context remote stack --orchestrator swarm up -c docker-compose.yml demo", + ), + ).toBe(true); + expect(isStackDeployCommand("stack --orchestrator deploy")).toBe(false); + expect( + isStackDeployCommand("stack --orchestrator deploy -c file.yml demo"), + ).toBe(false); + }); + it("classifies quoted shell argv the way /bin/sh does", () => { expect( isStackDeployCommand("'stack' 'deploy' -c docker-compose.yml demo"), @@ -407,6 +462,50 @@ services: ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); }); + it("rejects custom stack up commands when models are present", async () => { + const models = yaml(spec({ models: { llm: { model: "ai/smollm2" } } })); + for (const command of [ + "stack up -c docker-compose.yml demo", + "'stack' 'up' -c docker-compose.yml demo", + "--context remote stack up -c docker-compose.yml demo", + "--context=remote stack up -c docker-compose.yml demo", + "-D stack up -c docker-compose.yml demo", + "stack --orchestrator swarm up -c docker-compose.yml demo", + "stack --orchestrator=swarm deploy -c docker-compose.yml demo", + ]) { + await expect( + writeDeploy( + compose({ + composeType: "docker-compose", + command, + composeFile: models, + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + } + }); + + it("allows compose commands that only resemble stack up", async () => { + const models = yaml(spec({ models: { llm: { model: "ai/smollm2" } } })); + for (const command of [ + "compose up", + "compose run app stack up", + "compose exec app stack up", + "compose -f stack.yml up", + "--context stack compose up", + ]) { + await expect( + writeDeploy( + compose({ + composeType: "docker-compose", + command, + composeFile: models, + }), + ), + ).resolves.toContain("base64 -d"); + } + }); + it("does not treat a compose file token containing 'stack deploy' as stack deploy", async () => { await expect( writeDeploy( @@ -627,6 +726,27 @@ describe("getBuildComposeCommand stack models", () => { ), ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); }); + + it("does not emit stack up when models are present", async () => { + await expect( + getBuildComposeCommand( + buildArgs({ + composeType: "docker-compose", + command: "stack up -c docker-compose.yml demo", + composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })), + }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + await expect( + getBuildComposeCommand( + buildArgs({ + composeType: "docker-compose", + command: "--context remote stack up -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) => { @@ -753,6 +873,13 @@ process.stdout.write(JSON.stringify(process.argv.slice(2))); expect( fakeDockerArgv("'stack' 'deploy' -c file.yml demo", fakePath), ).toEqual(["stack", "deploy", "-c", "file.yml", "demo"]); + expect(fakeDockerArgv("'stack' 'up' -c file.yml demo", fakePath)).toEqual([ + "stack", + "up", + "-c", + "file.yml", + "demo", + ]); expect( fakeDockerArgv( '--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 4603186f8..1cb787acf 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -228,11 +228,28 @@ const skipDockerGlobalOptions = (tokens: string[]) => { return i; }; +const skipStackOrchestratorOption = (tokens: string[], i: number) => { + const token = tokens[i]; + if (!token) return i; + if (token === "--orchestrator") { + if (i + 1 >= tokens.length) return -1; + return i + 2; + } + if (token.startsWith("--orchestrator=")) return i + 1; + return i; +}; + +// Docker documents `stack up` as an alias of `stack deploy` (CLI 28.5+ / 29.x). +// Deprecated `--orchestrator` is a stack-level string flag that may sit between +// `stack` and `deploy`/`up`; `stack --orchestrator deploy` consumes `deploy` as +// the flag value and is not a deployment. export const isStackDeployCommand = (command: string) => { const tokens = tokenizeDockerCommand(command); const i = skipDockerGlobalOptions(tokens); - if (i < 0) return false; - return tokens[i] === "stack" && tokens[i + 1] === "deploy"; + if (i < 0 || tokens[i] !== "stack") return false; + const j = skipStackOrchestratorOption(tokens, i + 1); + if (j < 0) return false; + return tokens[j] === "deploy" || tokens[j] === "up"; }; export const writeDomainsToCompose = async (