Compare commits

...

1 Commits

Author SHA1 Message Date
Narciso 909ee9a387 fix: preserve # in env values and write the swarm stack env_file raw 2026-08-18 10:30:09 -04:00
6 changed files with 383 additions and 6 deletions
@@ -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<string, string> = {
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<typeof getCreateEnvFileCommand>[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);
});
@@ -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<typeof getCreateEnvFileCommand>[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"');
});
});
@@ -34,13 +34,13 @@ const cases: Record<string, string> = {
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<string, string> = {
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",
+139
View File
@@ -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",
]);
});
});
@@ -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);
+16 -3
View File
@@ -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<string, string> =>
Object.fromEntries(
Object.entries(parse(src.replace(/(?<! )#/g, HASH_PLACEHOLDER))).map(
([key, value]) => [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