mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-18 03:02:56 +00:00
Merge pull request #4962 from Dokploy/fix/compose-env-file-special-chars
fix: preserve special characters in Compose-generated .env values
(cherry picked from commit abe774650f)
[skip ci]
This commit is contained in:
committed by
Dokploy Bot
parent
98af5a2f07
commit
b50b1d4c86
@@ -0,0 +1,96 @@
|
|||||||
|
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 { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 —
|
||||||
|
// values must survive Docker Compose's own `.env` parsing, not just base64 decode.
|
||||||
|
const appName = `env-file-literals-${process.pid}`;
|
||||||
|
const projectPath = join(process.cwd(), ".docker", "compose", appName);
|
||||||
|
const codePath = join(projectPath, "code");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
execFileSync("docker", ["compose", "down", "--remove-orphans"], {
|
||||||
|
cwd: codePath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Project may not have been created (e.g. an earlier assertion failed).
|
||||||
|
}
|
||||||
|
rmSync(projectPath, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const cases: Record<string, string> = {
|
||||||
|
PASSWORD: "pa$$word",
|
||||||
|
SPECIAL: '!"#$%&/()=?',
|
||||||
|
NESTED_JSON: '{"nested":{"a":1}}',
|
||||||
|
MAIL_PASSWORD: "abc#de",
|
||||||
|
TRAILING_BACKSLASH: "trailing\\",
|
||||||
|
QUOTE_INSIDE: 'she said "hi"',
|
||||||
|
APOSTROPHE: "it's a test",
|
||||||
|
UNICODE: "héllo wörld 日本語 🚀",
|
||||||
|
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.
|
||||||
|
const inputEncoding: Record<string, string> = {
|
||||||
|
PASSWORD: "pa$$word",
|
||||||
|
SPECIAL: `'!"#$%&/()=?'`,
|
||||||
|
NESTED_JSON: '{"nested":{"a":1}}',
|
||||||
|
MAIL_PASSWORD: `"abc#de"`,
|
||||||
|
TRAILING_BACKSLASH: "trailing\\",
|
||||||
|
QUOTE_INSIDE: 'she said "hi"',
|
||||||
|
APOSTROPHE: "it's a test",
|
||||||
|
UNICODE: "héllo wörld 日本語 🚀",
|
||||||
|
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("getCreateEnvFileCommand", () => {
|
||||||
|
it("writes special environment values that Docker Compose reads back literally", () => {
|
||||||
|
mkdirSync(codePath, { recursive: true });
|
||||||
|
|
||||||
|
const serviceEnv = Object.entries(inputEncoding)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const command = getCreateEnvFileCommand({
|
||||||
|
appName,
|
||||||
|
composePath: "docker-compose.yml",
|
||||||
|
env: serviceEnv,
|
||||||
|
randomize: false,
|
||||||
|
suffix: "",
|
||||||
|
serverId: null,
|
||||||
|
environment: { project: { env: "" }, env: "" },
|
||||||
|
} as Parameters<typeof getCreateEnvFileCommand>[0]);
|
||||||
|
|
||||||
|
execFileSync("bash", ["-c", command]);
|
||||||
|
|
||||||
|
const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys(
|
||||||
|
cases,
|
||||||
|
)
|
||||||
|
.map((key) => ` - ${key}=\${${key}}`)
|
||||||
|
.join("\n")}\n`;
|
||||||
|
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
|
||||||
|
|
||||||
|
const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`;
|
||||||
|
|
||||||
|
const out = execFileSync(
|
||||||
|
"docker",
|
||||||
|
["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript],
|
||||||
|
{ cwd: codePath, encoding: "utf8" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const parts = out.split("\0");
|
||||||
|
const actual: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < parts.length - 1; i += 2) {
|
||||||
|
actual[parts[i] as string] = parts[i + 1] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(cases)) {
|
||||||
|
expect(actual[key], key).toBe(value);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils";
|
||||||
|
import { parse } from "dotenv";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Unlike compose's .env, this one is read by the app's own build tooling
|
||||||
|
// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped.
|
||||||
|
const appName = `env-file-literals-dockerfile-${process.pid}`;
|
||||||
|
const projectPath = join(process.cwd(), ".docker", "compose", appName);
|
||||||
|
const codePath = join(projectPath, "code");
|
||||||
|
const dockerFilePath = join(codePath, "Dockerfile");
|
||||||
|
|
||||||
|
afterEach(() => rmSync(projectPath, { force: true, recursive: true }));
|
||||||
|
|
||||||
|
const cases: Record<string, string> = {
|
||||||
|
PASSWORD: "pa$$word",
|
||||||
|
NESTED_JSON: '{"nested":{"a":1}}',
|
||||||
|
QUOTE_INSIDE: 'she said "hi"',
|
||||||
|
BACKSLASH: "back\\slash",
|
||||||
|
UNICODE: "héllo wörld 日本語 🚀",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createEnvFileCommand", () => {
|
||||||
|
it("writes special environment values that a generic dotenv parser reads back literally", () => {
|
||||||
|
mkdirSync(codePath, { recursive: true });
|
||||||
|
|
||||||
|
const serviceEnv = Object.entries(cases)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", "");
|
||||||
|
execFileSync("bash", ["-c", command]);
|
||||||
|
|
||||||
|
const written = readFileSync(join(codePath, ".env"), "utf8");
|
||||||
|
const parsed = parse(written);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(cases)) {
|
||||||
|
expect(parsed[key], key).toBe(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain";
|
|||||||
import {
|
import {
|
||||||
encodeBase64,
|
encodeBase64,
|
||||||
getEnvironmentVariablesObject,
|
getEnvironmentVariablesObject,
|
||||||
prepareEnvironmentVariables,
|
prepareEnvironmentVariablesForFile,
|
||||||
} from "../docker/utils";
|
} from "../docker/utils";
|
||||||
|
|
||||||
export type ComposeNested = InferResultType<
|
export type ComposeNested = InferResultType<
|
||||||
@@ -127,7 +127,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
|
|||||||
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
|
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const envFileContent = prepareEnvironmentVariables(
|
const envFileContent = prepareEnvironmentVariablesForFile(
|
||||||
envContent,
|
envContent,
|
||||||
compose.environment.project.env,
|
compose.environment.project.env,
|
||||||
compose.environment.env,
|
compose.environment.env,
|
||||||
|
|||||||
@@ -465,6 +465,27 @@ export const prepareEnvironmentVariablesForShell = (
|
|||||||
return envVars.map((env) => quote([env]));
|
return envVars.map((env) => quote([env]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const prepareEnvironmentVariablesForFile = (
|
||||||
|
serviceEnv: string | null,
|
||||||
|
projectEnv?: string | null,
|
||||||
|
environmentEnv?: string | null,
|
||||||
|
): string[] => {
|
||||||
|
const envVars = prepareEnvironmentVariables(
|
||||||
|
serviceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
return envVars.map((pair) => {
|
||||||
|
const [key, value] = parseEnvironmentKeyValuePair(pair);
|
||||||
|
const escapedValue = value
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\$/g, "\\$");
|
||||||
|
return `${key}="${escapedValue}"`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const parseEnvironmentKeyValuePair = (
|
export const parseEnvironmentKeyValuePair = (
|
||||||
pair: string,
|
pair: string,
|
||||||
): [string, string] => {
|
): [string, string] => {
|
||||||
|
|||||||
Reference in New Issue
Block a user