Compare commits

...

18 Commits

Author SHA1 Message Date
Mauricio Siu
a5abd46386 Merge pull request #3071 from Dokploy/fix/adjust-export-envs-stack
fix: improve Docker command execution by including environment variab…
2025-11-20 08:49:17 -06:00
Mauricio Siu
ad0e044740 chore: bump version to v0.25.10 in package.json 2025-11-20 08:48:33 -06:00
Mauricio Siu
7a0ff72f51 fix: improve Docker command execution by including environment variable exports
- Updated the Docker command execution to include environment variable exports directly in the command, enhancing the handling of environment variables during deployment.
- Simplified the export command structure for better readability and efficiency.

Fix https://github.com/Dokploy/dokploy/pull/3066#issuecomment-3558022350
2025-11-20 08:43:24 -06:00
Mauricio Siu
2e702dc41f Merge pull request #2952 from spacewaterbear/add_env_in_notifications
feat: display environnement in notification
2025-11-19 23:30:27 -06:00
Mauricio Siu
766f9244da Merge branch 'canary' into add_env_in_notifications 2025-11-19 23:24:39 -06:00
Mauricio Siu
6413fa54e6 chore: add shell-quote dependency and its type definitions
- Added `shell-quote` to dependencies for improved shell argument handling.
- Included `@types/shell-quote` in devDependencies for TypeScript support.
2025-11-19 22:55:53 -06:00
Mauricio Siu
1c9dcc0c9e Merge pull request #3066 from Dokploy/fix/nixpacks-builder
feat: enhance environment variable handling for shell commands
2025-11-19 21:27:04 -06:00
Mauricio Siu
fee802a57b refactor: remove outdated comment in railpack command builder
- Removed a comment regarding the use of shell-quote for escaping export statements, as the functionality is now handled by the `prepareEnvironmentVariablesForShell` function introduced in a previous commit.
2025-11-19 21:18:13 -06:00
Mauricio Siu
af2b053caa feat: enhance environment variable handling for shell commands
- Added `prepareEnvironmentVariablesForShell` function to properly escape environment variables for shell usage.
- Updated various builders (Docker, Heroku, Nixpacks, Paketo, Railpack) to utilize the new function for improved handling of special characters in environment variables.
- Introduced tests to validate the handling of environment variables with various special characters, ensuring robustness in shell command execution.
- Added `shell-quote` dependency to manage quoting of shell arguments effectively.
2025-11-19 21:17:09 -06:00
Mauricio Siu
42a4cc7fff chore: bump version to v0.25.9 in package.json 2025-11-19 10:14:20 -06:00
Mauricio Siu
2a7807c2b3 Merge pull request #3062 from Dokploy/3061-dokploy-instance-env-variables-override-compose-env
fix: update Docker command execution to use a clean environment
2025-11-19 10:00:58 -06:00
Mauricio Siu
425b8ec3c2 fix: update Docker command execution to use a clean environment
- Modified Docker command invocations in compose service functions to use `env -i PATH="$PATH"` for improved environment isolation.
- Ensured consistent handling of Docker commands across `removeCompose`, `startCompose`, and `stopCompose` functions in `compose.ts`.
- Updated command execution in the builders to maintain environment integrity during Docker operations.
2025-11-19 09:58:16 -06:00
Mauricio Siu
96dff0c1bb chore: bump version to v0.25.8 in package.json 2025-11-19 02:34:05 -06:00
Mauricio Siu
f53e1a6543 Merge pull request #3030 from AlexTMjugador/fix/compose-domains
fix: ensure Compose Traefik domain labels are written to local daemons
2025-11-19 02:33:46 -06:00
Mauricio Siu
9e2788e764 Merge pull request #3052 from Dokploy/360-request-for-adding-the-functionality-to-terminate-container-startup-process
feat: add KillBuild component and API mutation for terminating Docker…
2025-11-19 00:26:02 -06:00
Mauricio Siu
4884ee3352 feat: add KillBuild component and API mutation for terminating Docker builds
- Introduced a new KillBuild component that allows users to terminate ongoing Docker builds for both applications and compose setups.
- Implemented corresponding API mutations in the application and compose routers to handle build termination requests.
- Enhanced queue setup with a killDockerBuild function to execute the termination commands on the server.
2025-11-19 00:22:29 -06:00
Alejandro González
3a17c9b9e8 fix: ensure Compose Traefik domain labels are written to local daemons 2025-11-16 15:57:34 +01:00
spacewaterbear
63568a4887 feat: display environnement in notification 2025-11-03 23:27:18 +01:00
21 changed files with 588 additions and 47 deletions

View File

@@ -1,4 +1,7 @@
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import {
prepareEnvironmentVariables,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
@@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}}
"IS_DEV=0",
]);
});
it("handles environment variables with single quotes in values", () => {
const envWithSingleQuotes = `
ENV_VARIABLE='ENVITONME'NT'
ANOTHER_VAR='value with 'quotes' inside'
SIMPLE_VAR=no-quotes
`;
const serviceWithSingleQuotes = `
TEST_VAR=\${{environment.ENV_VARIABLE}}
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
SIMPLE=\${{environment.SIMPLE_VAR}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithSingleQuotes,
"",
envWithSingleQuotes,
);
expect(resolved).toEqual([
"TEST_VAR=ENVITONME'NT",
"ANOTHER_TEST=value with 'quotes' inside",
"SIMPLE=no-quotes",
]);
});
});
describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
it("escapes single quotes in environment variable values", () => {
const serviceEnv = `
ENV_VARIABLE='ENVITONME'NT'
ANOTHER_VAR='value with 'quotes' inside'
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote should wrap these in double quotes
expect(resolved).toEqual([
`"ENV_VARIABLE=ENVITONME'NT"`,
`"ANOTHER_VAR=value with 'quotes' inside"`,
]);
});
it("escapes double quotes in environment variable values", () => {
const serviceEnv = `
MESSAGE="Hello "World""
QUOTED_PATH="/path/to/"file""
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote wraps in single quotes when there are double quotes inside
expect(resolved).toEqual([
`'MESSAGE=Hello "World"'`,
`'QUOTED_PATH=/path/to/"file"'`,
]);
});
it("escapes dollar signs in environment variable values", () => {
const serviceEnv = `
PRICE=$100
VARIABLE=$HOME/path
TEMPLATE=Hello $USER
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Dollar signs should be escaped to prevent variable expansion
for (const env of resolved) {
expect(env).toContain("$");
}
});
it("escapes backticks in environment variable values", () => {
const serviceEnv = `
COMMAND=\`echo "test"\`
NESTED=value with \`backticks\` inside
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
expect(resolved.length).toBe(2);
expect(resolved[0]).toContain("COMMAND");
expect(resolved[1]).toContain("NESTED");
});
it("handles environment variables with spaces", () => {
const serviceEnv = `
FULL_NAME="John Doe"
MESSAGE='Hello World'
SENTENCE=This is a test
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote uses single quotes for strings with spaces
expect(resolved).toEqual([
`'FULL_NAME=John Doe'`,
`'MESSAGE=Hello World'`,
`'SENTENCE=This is a test'`,
]);
});
it("handles environment variables with backslashes", () => {
const serviceEnv = `
WINDOWS_PATH=C:\\Users\\Documents
ESCAPED=value\\with\\backslashes
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Backslashes should be properly escaped
expect(resolved.length).toBe(2);
for (const env of resolved) {
expect(env).toContain("\\");
}
});
it("handles simple environment variables without special characters", () => {
const serviceEnv = `
NODE_ENV=production
PORT=3000
DEBUG=true
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote escapes the = sign in some cases
expect(resolved).toEqual([
"NODE_ENV\\=production",
"PORT\\=3000",
"DEBUG\\=true",
]);
});
it("handles environment variables with mixed special characters", () => {
const serviceEnv = `
COMPLEX='value with "double" and 'single' quotes'
BASH_COMMAND=echo "$HOME" && echo 'test'
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// All should be escaped, none should throw errors
expect(resolved.length).toBe(3);
// Verify each can be safely used in shell
for (const env of resolved) {
expect(typeof env).toBe("string");
expect(env.length).toBeGreaterThan(0);
}
});
it("handles environment variables with newlines", () => {
const serviceEnv = `
MULTILINE="line1
line2
line3"
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(1);
expect(resolved[0]).toContain("MULTILINE");
});
it("handles empty environment variable values", () => {
const serviceEnv = `
EMPTY=
EMPTY_QUOTED=""
EMPTY_SINGLE=''
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote escapes the = sign for empty values
expect(resolved).toEqual([
"EMPTY\\=",
"EMPTY_QUOTED\\=",
"EMPTY_SINGLE\\=",
]);
});
it("handles environment variables with equals signs in values", () => {
const serviceEnv = `
EQUATION=a=b+c
CONNECTION_STRING=user=admin;password=test
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(2);
expect(resolved[0]).toContain("EQUATION");
expect(resolved[1]).toContain("CONNECTION_STRING");
});
it("resolves and escapes environment variables together", () => {
const projectEnv = `
BASE_URL=https://example.com
API_KEY='secret-key-with-quotes'
`;
const environmentEnv = `
ENV_NAME=production
DB_PASS='pa$$word'
`;
const serviceEnv = `
FULL_URL=\${{project.BASE_URL}}/api
AUTH_KEY=\${{project.API_KEY}}
ENVIRONMENT=\${{environment.ENV_NAME}}
DB_PASSWORD=\${{environment.DB_PASS}}
CUSTOM='value with 'quotes' inside'
`;
const resolved = prepareEnvironmentVariablesForShell(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(resolved.length).toBe(5);
// All resolved values should be properly escaped
for (const env of resolved) {
expect(typeof env).toBe("string");
}
});
it("handles environment variables with semicolons and ampersands", () => {
const serviceEnv = `
COMMAND=echo "test" && echo "test2"
MULTIPLE=cmd1; cmd2; cmd3
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
// These should be safely escaped to prevent command injection
for (const env of resolved) {
expect(typeof env).toBe("string");
expect(env.length).toBeGreaterThan(0);
}
});
it("handles environment variables with pipes and redirects", () => {
const serviceEnv = `
PIPE_COMMAND=cat file | grep test
REDIRECT=echo "test" > output.txt
BOTH=cat input.txt | grep pattern > output.txt
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
// Pipes and redirects should be safely quoted
expect(resolved[0]).toContain("PIPE_COMMAND");
expect(resolved[1]).toContain("REDIRECT");
expect(resolved[2]).toContain("BOTH");
// At least one should contain a pipe
const hasPipe = resolved.some((env) => env.includes("|"));
expect(hasPipe).toBe(true);
});
it("handles environment variables with parentheses and brackets", () => {
const serviceEnv = `
MATH=(a+b)*c
ARRAY=[1,2,3]
JSON={"key":"value"}
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
expect(resolved[0]).toContain("(");
expect(resolved[1]).toContain("[");
expect(resolved[2]).toContain("{");
});
it("handles very long environment variable values", () => {
const longValue = "a".repeat(10000);
const serviceEnv = `LONG_VAR=${longValue}`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(1);
expect(resolved[0]).toContain("LONG_VAR");
expect(resolved[0]?.length).toBeGreaterThan(10000);
});
it("handles special unicode characters in environment variables", () => {
const serviceEnv = `
EMOJI=Hello 🌍 World 🚀
CHINESE=你好世界
SPECIAL=café résumé naïve
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
expect(resolved[0]).toContain("🌍");
expect(resolved[1]).toContain("你好");
expect(resolved[2]).toContain("café");
});
});

View File

@@ -0,0 +1,65 @@
import { Scissors } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
interface Props {
id: string;
type: "application" | "compose";
}
export const KillBuild = ({ id, type }: Props) => {
const { mutateAsync, isLoading } =
type === "application"
? api.application.killBuild.useMutation()
: api.compose.killBuild.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}>
Kill Build
<Scissors className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure to kill the build?</AlertDialogTitle>
<AlertDialogDescription>
This will kill the build process
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
})
.then(() => {
toast.success("Build killed successfully");
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -25,6 +25,7 @@ import {
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
@@ -143,6 +144,9 @@ export const ShowDeployments = ({
</CardDescription>
</div>
<div className="flex flex-row items-center gap-2">
{(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.25.7",
"version": "v0.25.10",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -98,6 +98,7 @@
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.4.2",
"shell-quote": "^1.8.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
@@ -157,6 +158,7 @@
"zod-form-data": "^2.0.7"
},
"devDependencies": {
"@types/shell-quote": "^1.7.5",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/js-cookie": "^3.0.6",

View File

@@ -58,7 +58,11 @@ import {
applications,
} from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup";
import {
cleanQueuesByApplication,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { uploadFileSchema } from "@/utils/schema";
@@ -725,7 +729,21 @@ export const applicationRouter = createTRPCRouter({
}
await cleanQueuesByApplication(input.applicationId);
}),
killBuild: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to kill this build",
});
}
await killDockerBuild("application", application.serverId);
}),
readTraefikConfig: protectedProcedure
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {

View File

@@ -59,7 +59,11 @@ import {
compose as composeTable,
} from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
import {
cleanQueuesByCompose,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { generatePassword } from "@/templates/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
@@ -248,6 +252,21 @@ export const composeRouter = createTRPCRouter({
await cleanQueuesByCompose(input.composeId);
return { success: true, message: "Queues cleaned successfully" };
}),
killBuild: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to kill this build",
});
}
await killDockerBuild("compose", compose.serverId);
}),
loadServices: protectedProcedure
.input(apiFetchServices)

View File

@@ -1,3 +1,7 @@
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { Queue } from "bullmq";
import { redisConfig } from "./redis-connection";
@@ -41,4 +45,31 @@ export const cleanQueuesByCompose = async (composeId: string) => {
}
};
export const killDockerBuild = async (
type: "application" | "compose",
serverId: string | null,
) => {
try {
if (type === "application") {
const command = `pkill -2 -f "docker build"`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
} else if (type === "compose") {
const command = `pkill -2 -f "docker compose"`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
}
} catch (error) {
console.error(error);
}
};
export { myQueue };

View File

@@ -75,6 +75,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"rotating-file-stream": "3.2.3",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"ssh2": "1.15.0",
"toml": "3.0.0",
@@ -93,6 +94,7 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.30.6",

View File

@@ -19,6 +19,7 @@ export type TemplateProps = {
applicationType: string;
buildLink: string;
date: string;
environmentName: string;
};
export const BuildSuccessEmail = ({
@@ -27,6 +28,7 @@ export const BuildSuccessEmail = ({
applicationType = "application",
buildLink = "https://dokploy.com/projects/dokploy-test/applications/dokploy-test",
date = "2023-05-01T00:00:00.000Z",
environmentName = "production",
}: TemplateProps) => {
const previewText = `Build success for ${applicationName}`;
return (
@@ -74,6 +76,9 @@ export const BuildSuccessEmail = ({
<Text className="!leading-3">
Application Name: <strong>{applicationName}</strong>
</Text>
<Text className="!leading-3">
Environment: <strong>{environmentName}</strong>
</Text>
<Text className="!leading-3">
Application Type: <strong>{applicationType}</strong>
</Text>

View File

@@ -225,6 +225,7 @@ export const deployApplication = async ({
buildLink,
organizationId: application.environment.project.organizationId,
domains: application.domains,
environmentName: application.environment.name,
});
} catch (error) {
const command = `echo "Error occurred ❌, check the logs for details." >> ${deployment.logPath};`;
@@ -273,6 +274,7 @@ export const rebuildApplication = async ({
descriptionLog: string;
}) => {
const application = await findApplicationById(applicationId);
const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`;
const deployment = await createDeployment({
applicationId: applicationId,
@@ -293,6 +295,27 @@ export const rebuildApplication = async ({
await mechanizeDockerContainer(application);
await updateDeploymentStatus(deployment.deploymentId, "done");
await updateApplicationStatus(applicationId, "done");
if (application.rollbackActive) {
const tagImage =
application.sourceType === "docker"
? application.dockerImage
: application.appName;
await createRollback({
appName: tagImage || "",
deploymentId: deployment.deploymentId,
});
}
await sendBuildSuccessNotifications({
projectName: application.environment.project.name,
applicationName: application.name,
applicationType: "application",
buildLink,
organizationId: application.environment.project.organizationId,
domains: application.domains,
environmentName: application.environment.name,
});
} catch (error) {
await updateDeploymentStatus(deployment.deploymentId, "error");
await updateApplicationStatus(applicationId, "error");

View File

@@ -267,6 +267,7 @@ export const deployCompose = async ({
buildLink,
organizationId: compose.environment.project.organizationId,
domains: compose.domains,
environmentName: compose.environment.name,
});
} catch (error) {
await updateDeploymentStatus(deployment.deploymentId, "error");
@@ -375,7 +376,7 @@ export const removeCompose = async (
} else {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;
cd ${projectPath} && docker compose -p ${compose.appName} down ${
cd ${projectPath} && env -i PATH="$PATH" docker compose -p ${compose.appName} down ${
deleteVolumes ? "--volumes" : ""
} && rm -rf ${projectPath}`;
@@ -402,7 +403,7 @@ export const startCompose = async (composeId: string) => {
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const path =
compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
const baseCommand = `docker compose -p ${compose.appName} -f ${path} up -d`;
const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`;
if (compose.composeType === "docker-compose") {
if (compose.serverId) {
await execAsyncRemote(
@@ -437,14 +438,17 @@ export const stopCompose = async (composeId: string) => {
if (compose.serverId) {
await execAsyncRemote(
compose.serverId,
`cd ${join(COMPOSE_PATH, compose.appName)} && docker compose -p ${
`cd ${join(COMPOSE_PATH, compose.appName)} && env -i PATH="$PATH" docker compose -p ${
compose.appName
} stop`,
);
} else {
await execAsync(`docker compose -p ${compose.appName} stop`, {
cwd: join(COMPOSE_PATH, compose.appName),
});
await execAsync(
`env -i PATH="$PATH" docker compose -p ${compose.appName} stop`,
{
cwd: join(COMPOSE_PATH, compose.appName),
},
);
}
}

View File

@@ -2,7 +2,8 @@ import { dirname, join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { InferResultType } from "@dokploy/server/types/with";
import boxen from "boxen";
import { writeDomainsToComposeRemote } from "../docker/domain";
import { quote } from "shell-quote";
import { writeDomainsToCompose } from "../docker/domain";
import {
encodeBase64,
getEnviromentVariablesObject,
@@ -22,7 +23,7 @@ export const getBuildComposeCommand = async (compose: ComposeNested) => {
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const exportEnvCommand = getExportEnvCommand(compose);
const newCompose = await writeDomainsToComposeRemote(compose, domains);
const newCompose = await writeDomainsToCompose(compose, domains);
const logContent = `
App Name: ${appName}
Build Compose 🐳
@@ -52,9 +53,8 @@ Compose Type: ${composeType} ✅`;
cd "${projectPath}";
${exportEnvCommand}
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}` : ""}
docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
env -i PATH="$PATH" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
echo "Docker Compose Deployed: ✅";
@@ -65,7 +65,6 @@ Compose Type: ${composeType} ✅`;
`;
return bashCommand;
// return await execAsyncRemote(compose.serverId, bashCommand);
};
const sanitizeCommand = (command: string) => {
@@ -137,8 +136,8 @@ const getExportEnvCommand = (compose: ComposeNested) => {
compose.environment.project.env,
);
const exports = Object.entries(envVars)
.map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
.join("\n");
.map(([key, value]) => `${key}=${quote([value])}`)
.join(" ");
return exports ? `\n# Export environment variables\n${exports}\n` : "";
return exports ? `${exports}` : "";
};

View File

@@ -1,7 +1,8 @@
import {
getEnviromentVariablesObject,
prepareEnvironmentVariables,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/utils/docker/utils";
import { quote } from "shell-quote";
import {
getBuildAppDirectory,
getDockerContextPath,
@@ -40,14 +41,14 @@ export const getDockerCommand = (application: ApplicationNested) => {
commandArgs.push("--no-cache");
}
const args = prepareEnvironmentVariables(
const args = prepareEnvironmentVariablesForShell(
buildArgs,
application.environment.project.env,
application.environment.env,
);
for (const arg of args) {
commandArgs.push("--build-arg", `'${arg}'`);
commandArgs.push("--build-arg", arg);
}
const secrets = getEnviromentVariablesObject(
@@ -57,7 +58,7 @@ export const getDockerCommand = (application: ApplicationNested) => {
);
const joinedSecrets = Object.entries(secrets)
.map(([key, value]) => `${key}='${value.replace(/'/g, "'\"'\"'")}'`)
.map(([key, value]) => `${key}=${quote([value])}`)
.join(" ");
for (const key in secrets) {

View File

@@ -1,4 +1,4 @@
import { prepareEnvironmentVariables } from "../docker/utils";
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -6,7 +6,7 @@ export const getHerokuCommand = (application: ApplicationNested) => {
const { env, appName, cleanCache } = application;
const buildAppDirectory = getBuildAppDirectory(application);
const envVariables = prepareEnvironmentVariables(
const envVariables = prepareEnvironmentVariablesForShell(
env,
application.environment.project.env,
application.environment.env,
@@ -26,7 +26,7 @@ export const getHerokuCommand = (application: ApplicationNested) => {
}
for (const env of envVariables) {
args.push("--env", `'${env}'`);
args.push("--env", env);
}
const command = `pack ${args.join(" ")}`;

View File

@@ -1,7 +1,7 @@
import path from "node:path";
import { getStaticCommand } from "@dokploy/server/utils/builders/static";
import { nanoid } from "nanoid";
import { prepareEnvironmentVariables } from "../docker/utils";
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -10,7 +10,7 @@ export const getNixpacksCommand = (application: ApplicationNested) => {
const buildAppDirectory = getBuildAppDirectory(application);
const buildContainerId = `${appName}-${nanoid(10)}`;
const envVariables = prepareEnvironmentVariables(
const envVariables = prepareEnvironmentVariablesForShell(
env,
application.environment.project.env,
application.environment.env,
@@ -23,7 +23,7 @@ export const getNixpacksCommand = (application: ApplicationNested) => {
}
for (const env of envVariables) {
args.push("--env", `'${env}'`);
args.push("--env", env);
}
if (publishDirectory) {

View File

@@ -1,4 +1,4 @@
import { prepareEnvironmentVariables } from "../docker/utils";
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -6,7 +6,7 @@ export const getPaketoCommand = (application: ApplicationNested) => {
const { env, appName, cleanCache } = application;
const buildAppDirectory = getBuildAppDirectory(application);
const envVariables = prepareEnvironmentVariables(
const envVariables = prepareEnvironmentVariablesForShell(
env,
application.environment.project.env,
application.environment.env,
@@ -26,7 +26,7 @@ export const getPaketoCommand = (application: ApplicationNested) => {
}
for (const env of envVariables) {
args.push("--env", `'${env}'`);
args.push("--env", env);
}
const command = `pack ${args.join(" ")}`;

View File

@@ -1,8 +1,10 @@
import { createHash } from "node:crypto";
import { nanoid } from "nanoid";
import { quote } from "shell-quote";
import {
parseEnvironmentKeyValuePair,
prepareEnvironmentVariables,
prepareEnvironmentVariablesForShell,
} from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -18,7 +20,7 @@ const calculateSecretsHash = (envVariables: string[]): string => {
export const getRailpackCommand = (application: ApplicationNested) => {
const { env, appName, cleanCache } = application;
const buildAppDirectory = getBuildAppDirectory(application);
const envVariables = prepareEnvironmentVariables(
const envVariables = prepareEnvironmentVariablesForShell(
env,
application.environment.project.env,
application.environment.env,
@@ -35,7 +37,7 @@ export const getRailpackCommand = (application: ApplicationNested) => {
];
for (const env of envVariables) {
prepareArgs.push("--env", `'${env}'`);
prepareArgs.push("--env", env);
}
// Calculate secrets hash for layer invalidation
@@ -63,12 +65,18 @@ export const getRailpackCommand = (application: ApplicationNested) => {
];
// Add secrets properly formatted
// Use prepareEnvironmentVariables (without ForShell) to get raw values for parsing
const rawEnvVariables = prepareEnvironmentVariables(
env,
application.environment.project.env,
application.environment.env,
);
const exportEnvs = [];
for (const pair of envVariables) {
for (const pair of rawEnvVariables) {
const [key, value] = parseEnvironmentKeyValuePair(pair);
if (key && value) {
buildArgs.push("--secret", `id=${key},env=${key}`);
exportEnvs.push(`export ${key}='${value}'`);
exportEnvs.push(`export ${key}=${quote([value])}`);
}
}

View File

@@ -102,7 +102,7 @@ export const readComposeFile = async (compose: Compose) => {
return null;
};
export const writeDomainsToComposeRemote = async (
export const writeDomainsToCompose = async (
compose: Compose,
domains: Domain[],
) => {
@@ -120,19 +120,16 @@ echo "❌ Error: Compose file not found";
exit 1;
`;
}
if (compose.serverId) {
const composeString = stringify(composeConverted, { lineWidth: 1000 });
const encodedContent = encodeBase64(composeString);
return `echo "${encodedContent}" | base64 -d > "${path}";`;
}
const composeString = stringify(composeConverted, { lineWidth: 1000 });
const encodedContent = encodeBase64(composeString);
return `echo "${encodedContent}" | base64 -d > "${path}";`;
} catch (error) {
// @ts-ignore
return `echo "❌ Has occured an error: ${error?.message || error}";
return `echo "❌ Has occurred an error: ${error?.message || error}";
exit 1;
`;
}
return "";
};
export const addDomainToCompose = async (
compose: Compose,

View File

@@ -5,6 +5,7 @@ import { docker, paths } from "@dokploy/server/constants";
import type { Compose } from "@dokploy/server/services/compose";
import type { ContainerInfo, ResourceRequirements } from "dockerode";
import { parse } from "dotenv";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders";
import type { MariadbNested } from "../databases/mariadb";
import type { MongoNested } from "../databases/mongo";
@@ -310,6 +311,21 @@ export const prepareEnvironmentVariables = (
return resolvedVars;
};
export const prepareEnvironmentVariablesForShell = (
serviceEnv: string | null,
projectEnv?: string | null,
environmentEnv?: string | null,
): string[] => {
const envVars = prepareEnvironmentVariables(
serviceEnv,
projectEnv,
environmentEnv,
);
// Using shell-quote library to properly escape shell arguments
// This is the standard way to handle special characters in shell commands
return envVars.map((env) => quote([env]));
};
export const parseEnvironmentKeyValuePair = (
pair: string,
): [string, string] => {

View File

@@ -22,6 +22,7 @@ interface Props {
buildLink: string;
organizationId: string;
domains: Domain[];
environmentName: string;
}
export const sendBuildSuccessNotifications = async ({
@@ -31,6 +32,7 @@ export const sendBuildSuccessNotifications = async ({
buildLink,
organizationId,
domains,
environmentName,
}: Props) => {
const date = new Date();
const unixDate = ~~(Number(date) / 1000);
@@ -62,6 +64,7 @@ export const sendBuildSuccessNotifications = async ({
applicationType,
buildLink,
date: date.toLocaleString(),
environmentName,
}),
).catch();
await sendEmailNotification(email, "Build success for dokploy", template);
@@ -72,7 +75,7 @@ export const sendBuildSuccessNotifications = async ({
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title: decorate(">", "`✅` Build Success"),
title: decorate(">", "`✅` Build Successes"),
color: 0x57f287,
fields: [
{
@@ -85,6 +88,11 @@ export const sendBuildSuccessNotifications = async ({
value: applicationName,
inline: true,
},
{
name: decorate("`🌍`", "Environment"),
value: environmentName,
inline: true,
},
{
name: decorate("`❔`", "Type"),
value: applicationType,
@@ -125,6 +133,7 @@ export const sendBuildSuccessNotifications = async ({
decorate("✅", "Build Success"),
`${decorate("🛠️", `Project: ${projectName}`)}` +
`${decorate("⚙️", `Application: ${applicationName}`)}` +
`${decorate("🌍", `Environment: ${environmentName}`)}` +
`${decorate("❔", `Type: ${applicationType}`)}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${decorate("🔗", `Build details:\n${buildLink}`)}`,
@@ -139,6 +148,7 @@ export const sendBuildSuccessNotifications = async ({
`view, Build details, ${buildLink}, clear=true;`,
`🛠Project: ${projectName}\n` +
`Application: ${applicationName}\n` +
`🌍Environment: ${environmentName}\n` +
`❔Type: ${applicationType}\n` +
`🕒Date: ${date.toLocaleString()}`,
);
@@ -167,7 +177,7 @@ export const sendBuildSuccessNotifications = async ({
await sendTelegramNotification(
telegram,
`<b>✅ Build Success</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Type:</b> ${applicationType}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
`<b>✅ Build Success</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Environment:</b> ${environmentName}\n<b>Type:</b> ${applicationType}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
inlineButton,
);
}
@@ -191,6 +201,11 @@ export const sendBuildSuccessNotifications = async ({
value: applicationName,
short: true,
},
{
title: "Environment",
value: environmentName,
short: true,
},
{
title: "Type",
value: applicationType,
@@ -260,6 +275,12 @@ export const sendBuildSuccessNotifications = async ({
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Environment:**\n${environmentName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Type:**\n${applicationType}`,

17
pnpm-lock.yaml generated
View File

@@ -406,6 +406,9 @@ importers:
rotating-file-stream:
specifier: 3.2.3
version: 3.2.3
shell-quote:
specifier: ^1.8.1
version: 1.8.2
slugify:
specifier: ^1.6.6
version: 1.6.6
@@ -488,6 +491,9 @@ importers:
'@types/react-dom':
specifier: 18.3.0
version: 18.3.0
'@types/shell-quote':
specifier: ^1.7.5
version: 1.7.5
'@types/ssh2':
specifier: 1.15.1
version: 1.15.1
@@ -726,6 +732,9 @@ importers:
rotating-file-stream:
specifier: 3.2.3
version: 3.2.3
shell-quote:
specifier: ^1.8.1
version: 1.8.2
slugify:
specifier: ^1.6.6
version: 1.6.6
@@ -778,6 +787,9 @@ importers:
'@types/react-dom':
specifier: 18.3.0
version: 18.3.0
'@types/shell-quote':
specifier: ^1.7.5
version: 1.7.5
'@types/ssh2':
specifier: 1.15.1
version: 1.15.1
@@ -4033,6 +4045,9 @@ packages:
'@types/readable-stream@4.0.20':
resolution: {integrity: sha512-eLgbR5KwUh8+6pngBDxS32MymdCsCHnGtwHTrC0GDorbc7NbcnkZAWptDLgZiRk9VRas+B6TyRgPDucq4zRs8g==}
'@types/shell-quote@1.7.5':
resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==}
'@types/shimmer@1.2.0':
resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==}
@@ -11383,6 +11398,8 @@ snapshots:
dependencies:
'@types/node': 20.17.51
'@types/shell-quote@1.7.5': {}
'@types/shimmer@1.2.0': {}
'@types/ssh2@1.15.1':