diff --git a/apps/dokploy/__test__/compose/env-delivery.real.test.ts b/apps/dokploy/__test__/compose/env-delivery.real.test.ts new file mode 100644 index 000000000..58e3594b0 --- /dev/null +++ b/apps/dokploy/__test__/compose/env-delivery.real.test.ts @@ -0,0 +1,144 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose"; +import { afterAll, describe, expect, it } from "vitest"; + +// Real end-to-end: the same Environment Settings text must reach a *running* +// container with identical values under docker-compose and under swarm stack. +// Covers https://github.com/Dokploy/dokploy/issues/5095 and /5096. + +// exactly what a user types in the Environment Settings tab +const uiEnv = [ + "SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0", + "PASSWORD=mysecret#", + "MIDDLE=sec#ret", + "SHEBANG=#!secret", + "DOLLARS=pa$$word", + "SPACED=hello world", + 'JSON={"a":1}', + "COMMENTED=value # a real comment", + 'QUOTED_HASH="abc#de"', +].join("\n"); + +const expected: Record = { + SENTRY_DSN: "https://examplePublicKey@o0.ingest.sentry.io/0", + PASSWORD: "mysecret#", + MIDDLE: "sec#ret", + SHEBANG: "#!secret", + DOLLARS: "pa$$word", + SPACED: "hello world", + JSON: '{"a":1}', + COMMENTED: "value", + QUOTED_HASH: "abc#de", +}; + +const keys = Object.keys(expected); +const composeApp = `env-delivery-c${process.pid}`; +const stackApp = `env-delivery-s${process.pid}`; +const pathFor = (app: string) => + join(process.cwd(), ".docker", "compose", app, "code"); + +const sh = (cmd: string, args: string[], cwd?: string) => + execFileSync(cmd, args, { cwd, encoding: "utf8" }); + +const setup = ( + app: string, + composeType: "docker-compose" | "stack", + svc: string, +) => { + const codePath = pathFor(app); + mkdirSync(codePath, { recursive: true }); + sh("bash", [ + "-c", + getCreateEnvFileCommand({ + appName: app, + composePath: "docker-compose.yml", + composeType, + env: uiEnv, + randomize: false, + suffix: "", + serverId: null, + environment: { project: { env: "" }, env: "" }, + } as Parameters[0]), + ]); + writeFileSync(join(codePath, "docker-compose.yml"), svc); + return codePath; +}; + +// `printenv` per key, NUL-delimited so values keep their spaces and # +const dumpScript = keys.map((k) => `printf '%s\\0' "$${k}"`).join("; "); + +const parseDump = (out: string) => + Object.fromEntries( + out + .split("\0") + .slice(0, keys.length) + .map((v, i) => [keys[i] as string, v]), + ); + +afterAll(() => { + try { + sh("docker", ["stack", "rm", stackApp]); + } catch {} + try { + sh("docker", ["compose", "down", "--remove-orphans"], pathFor(composeApp)); + } catch {} + for (const app of [composeApp, stackApp]) { + rmSync(join(process.cwd(), ".docker", "compose", app), { + force: true, + recursive: true, + }); + } +}); + +describe("env delivery into a running container", () => { + it("docker-compose: values survive compose-go interpolation", () => { + const codePath = setup( + composeApp, + "docker-compose", + "services:\n app:\n image: busybox\n env_file: .env\n", + ); + + const out = sh( + "docker", + ["compose", "run", "--rm", "-T", "app", "sh", "-c", dumpScript], + codePath, + ); + + expect(parseDump(out)).toEqual(expected); + }, 120000); + + it("swarm stack: values survive the literal env-file parser", () => { + const codePath = setup( + stackApp, + "stack", + "services:\n app:\n image: busybox\n command: sleep 300\n env_file: .env\n", + ); + + sh( + "docker", + ["stack", "deploy", "-c", "docker-compose.yml", stackApp], + codePath, + ); + + let containerId = ""; + for (let i = 0; i < 60 && !containerId; i++) { + containerId = ( + sh("docker", [ + "ps", + "--filter", + `label=com.docker.swarm.service.name=${stackApp}_app`, + "--format", + "{{.ID}}", + ]).split("\n")[0] ?? "" + ).trim(); + if (!containerId) execFileSync("sleep", ["1"]); + } + expect(containerId, "swarm task never started").toBeTruthy(); + + const out = sh("docker", ["exec", containerId, "sh", "-c", dumpScript]); + + expect(parseDump(out)).toEqual(expected); + }, 180000); +}); diff --git a/apps/dokploy/__test__/compose/env-file-compose-type.test.ts b/apps/dokploy/__test__/compose/env-file-compose-type.test.ts new file mode 100644 index 000000000..426ce30a8 --- /dev/null +++ b/apps/dokploy/__test__/compose/env-file-compose-type.test.ts @@ -0,0 +1,79 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose"; +import { prepareEnvironmentVariablesForFile } from "@dokploy/server/utils/docker/utils"; +import { afterEach, describe, expect, it } from "vitest"; + +// https://github.com/Dokploy/dokploy/issues/5096 — compose-go interpolates the +// generated .env, docker stack deploy reads it literally; quoting cannot serve both. + +const appName = `env-file-compose-type-${process.pid}`; +const projectPath = join(process.cwd(), ".docker", "compose", appName); +const codePath = join(projectPath, "code"); + +afterEach(() => { + rmSync(projectPath, { force: true, recursive: true }); +}); + +const buildEnvFile = (composeType: "docker-compose" | "stack", env: string) => { + mkdirSync(codePath, { recursive: true }); + const command = getCreateEnvFileCommand({ + appName, + composePath: "docker-compose.yml", + composeType, + env, + randomize: false, + suffix: "", + serverId: null, + environment: { project: { env: "" }, env: "" }, + } as Parameters[0]); + execFileSync("bash", ["-c", command]); + return readFileSync(join(codePath, ".env"), "utf8"); +}; + +describe("prepareEnvironmentVariablesForFile", () => { + const env = "DSN=https://key@o0.io/0\nPASSWORD=pa$$word"; + + it("quotes and escapes by default, for the docker-compose .env", () => { + expect(prepareEnvironmentVariablesForFile(env, "", "")).toEqual([ + 'DSN="https://key@o0.io/0"', + 'PASSWORD="pa\\$\\$word"', + ]); + }); + + it("writes values untouched when raw", () => { + expect(prepareEnvironmentVariablesForFile(env, "", "", true)).toEqual([ + "DSN=https://key@o0.io/0", + "PASSWORD=pa$$word", + ]); + }); +}); + +describe("getCreateEnvFileCommand", () => { + it("writes a stack .env that docker reads back without the wrapping quotes", () => { + const contents = buildEnvFile("stack", "DSN=https://key@o0.io/0"); + expect(contents).toContain("DSN=https://key@o0.io/0"); + expect(contents).not.toContain('DSN="'); + + writeFileSync( + join(codePath, "docker-compose.yml"), + "services:\n test:\n image: busybox\n env_file: .env\n", + ); + + const rendered = execFileSync( + "docker", + ["stack", "config", "-c", "docker-compose.yml"], + { cwd: codePath, encoding: "utf8" }, + ); + + expect(rendered).toContain("DSN: https://key@o0.io/0"); + // #5096 symptom: quotes surviving into the value + expect(rendered).not.toContain("DSN: '\""); + }, 60000); + + it("keeps quoting the docker-compose .env so $ survives interpolation", () => { + const contents = buildEnvFile("docker-compose", "PASSWORD=pa$$word"); + expect(contents).toContain('PASSWORD="pa\\$\\$word"'); + }); +}); diff --git a/apps/dokploy/__test__/compose/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts index 2dbb7fa4e..2f5c1e461 100644 --- a/apps/dokploy/__test__/compose/env-file-literals.test.ts +++ b/apps/dokploy/__test__/compose/env-file-literals.test.ts @@ -34,13 +34,13 @@ const cases: Record = { MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----", }; -// How each value must be typed in the UI so the (unchanged) dotenv input -// parser resolves it to the raw string in `cases` above. +// How each value must be typed in the UI so the env input parser resolves it +// to the raw string in `cases` above. const inputEncoding: Record = { PASSWORD: "pa$$word", SPECIAL: `'!"#$%&/()=?'`, NESTED_JSON: '{"nested":{"a":1}}', - MAIL_PASSWORD: `"abc#de"`, + MAIL_PASSWORD: "abc#de", TRAILING_BACKSLASH: "trailing\\", QUOTE_INSIDE: 'she said "hi"', APOSTROPHE: "it's a test", diff --git a/apps/dokploy/__test__/env/comment-handling.test.ts b/apps/dokploy/__test__/env/comment-handling.test.ts new file mode 100644 index 000000000..e0c32e2aa --- /dev/null +++ b/apps/dokploy/__test__/env/comment-handling.test.ts @@ -0,0 +1,139 @@ +import { prepareEnvironmentVariables } from "@dokploy/server/index"; +import { describe, expect, it } from "vitest"; + +// https://github.com/Dokploy/dokploy/issues/5095 — expectations calibrated against docker compose v2 + +describe("prepareEnvironmentVariables (# in values)", () => { + it("preserves # when it is not an inline comment", () => { + const serviceEnv = [ + "TRAILING=secret#", + "MIDDLE=sec#ret", + "MULTI=a#b#c", + "LEADING=#!secret", + "NO_SPACE=value# not a comment", + "CONN=user=admin;password=b#c", + ].join("\n"); + + expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([ + "TRAILING=secret#", + "MIDDLE=sec#ret", + "MULTI=a#b#c", + "LEADING=#!secret", + "NO_SPACE=value# not a comment", + "CONN=user=admin;password=b#c", + ]); + }); + + it("strips an inline comment only after one or more spaces", () => { + const serviceEnv = [ + "ONE_SPACE=value # comment", + "TWO_SPACES=value # comment", + "TRAILING_WS=value ", + ].join("\n"); + + expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([ + "ONE_SPACE=value", + "TWO_SPACES=value", + "TRAILING_WS=value", + ]); + }); + + it("does not treat a tab-preceded # as a comment", () => { + const serviceEnv = "WITH_TAB=value\t# not a comment"; + + expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([ + "WITH_TAB=value\t# not a comment", + ]); + }); + + it("treats a bare `KEY= # ...` as an empty value", () => { + // deliberate divergence: compose reads "# this is a comment" here + const serviceEnv = "ONLY_HASH= # this is a comment"; + + expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([ + "ONLY_HASH=", + ]); + }); + + it("preserves # inside quoted values and still strips a trailing comment", () => { + const serviceEnv = [ + 'DOUBLE="val#ue"', + "SINGLE='val#ue'", + 'DOUBLE_THEN_COMMENT="a # b" # comment', + "SINGLE_THEN_COMMENT='c # d' # comment", + ].join("\n"); + + expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([ + "DOUBLE=val#ue", + "SINGLE=val#ue", + "DOUBLE_THEN_COMMENT=a # b", + "SINGLE_THEN_COMMENT=c # d", + ]); + }); + + it("still skips full-line comments", () => { + const serviceEnv = [ + "# leading comment", + "FIRST=one", + " # indented comment", + "SECOND=two", + ].join("\n"); + + expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([ + "FIRST=one", + "SECOND=two", + ]); + }); + + it("preserves # through project, environment and self references", () => { + const projectEnv = "DB_PASS=secret#end"; + const environmentEnv = "API_KEY=key#123"; + const serviceEnv = [ + "DATABASE_URL=postgres://user:${{project.DB_PASS}}@db:5432/app", + "TOKEN=${{environment.API_KEY}}", + "LOCAL_SECRET=self#value", + "COPY=${{LOCAL_SECRET}}", + ].join("\n"); + + expect( + prepareEnvironmentVariables(serviceEnv, projectEnv, environmentEnv), + ).toEqual([ + "DATABASE_URL=postgres://user:secret#end@db:5432/app", + "TOKEN=key#123", + "LOCAL_SECRET=self#value", + "COPY=self#value", + ]); + }); +}); + +describe("prepareEnvironmentVariables (dotenv parity)", () => { + it("keeps the behaviour dotenv had for everything except #", () => { + const serviceEnv = [ + "export EXPORTED=value", + 'ESCAPED="line1\\nline2"', + "EMPTY=", + 'EMPTY_DOUBLE=""', + "EMPTY_SINGLE=''", + "DOLLARS=pa$$word", + 'SPACED_QUOTED= "padded" ', + ].join("\n"); + + expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([ + "EXPORTED=value", + "ESCAPED=line1\nline2", + "EMPTY=", + "EMPTY_DOUBLE=", + "EMPTY_SINGLE=", + "DOLLARS=pa$$word", + "SPACED_QUOTED=padded", + ]); + }); + + it("supports multiline double-quoted values", () => { + const serviceEnv = 'MULTILINE="line1\nline2"'; + + expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([ + "MULTILINE=line1\nline2", + ]); + }); +}); diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 168de7fda..3a13537b0 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -161,6 +161,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => { envContent, compose.environment.project.env, compose.environment.env, + // docker stack deploy reads env_file literally, no unquoting + compose.composeType === "stack", ).join("\n"); const encodedContent = encodeBase64(envFileContent); diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index fccd979fd..34b1abc37 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -456,6 +456,16 @@ export const removeService = async ( } }; +// dotenv ends an unquoted value at the first `#`; compose only does so after a space +const HASH_PLACEHOLDER = "\u0000dokploy-hash\u0000"; + +const parseEnvVariables = (src: string): Record => + Object.fromEntries( + Object.entries(parse(src.replace(/(? [key, value.replaceAll(HASH_PLACEHOLDER, "#")], + ), + ); + export const prepareEnvironmentVariables = ( serviceEnv: string | null, projectEnv?: string | null, @@ -468,9 +478,9 @@ export const prepareEnvironmentVariables = ( ); } } - const projectVars = parse(projectEnv ?? ""); - const environmentVars = parse(environmentEnv ?? ""); - const serviceVars = parse(serviceEnv ?? ""); + const projectVars = parseEnvVariables(projectEnv ?? ""); + const environmentVars = parseEnvVariables(environmentEnv ?? ""); + const serviceVars = parseEnvVariables(serviceEnv ?? ""); const resolvedVars = Object.entries(serviceVars).map(([key, value]) => { let resolvedValue = value; @@ -536,6 +546,7 @@ export const prepareEnvironmentVariablesForFile = ( serviceEnv: string | null, projectEnv?: string | null, environmentEnv?: string | null, + rawValues = false, ): string[] => { const envVars = prepareEnvironmentVariables( serviceEnv, @@ -543,6 +554,8 @@ export const prepareEnvironmentVariablesForFile = ( environmentEnv, ); + if (rawValues) return envVars; + return envVars.map((pair) => { const [key, value] = parseEnvironmentKeyValuePair(pair); const escapedValue = value