mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 909ee9a387 |
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user