Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions apps/dokploy/__test__/deploy/deployment-error-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { promises as fsPromises } from "node:fs";
import os from "node:os";
import path from "node:path";
import * as execProcess from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
ExecError: class ExecError extends Error {},
}));

import { getDeploymentErrorMessage } from "@dokploy/server/services/deployment";

const FALLBACK = "Error building, check the logs for details.";

// The private key that gets echoed into the deploy command. The bug was that
// Node's exec error.message (the whole command string, including this echo)
// was sent to notifications. The real build error lives in the log file, which
// never contains the key, so reading the log must never surface it.
const PRIVATE_KEY = `-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
SECRETKEYMATERIALSHOULDNEVERLEAK
-----END OPENSSH PRIVATE KEY-----`;

describe("getDeploymentErrorMessage", () => {
let tmpDir: string;

beforeEach(async () => {
vi.clearAllMocks();
tmpDir = await fsPromises.mkdtemp(
path.join(os.tmpdir(), "dokploy-log-test-"),
);
});

afterEach(async () => {
await fsPromises.rm(tmpDir, { recursive: true, force: true });
});

describe("local deployments (no serverId)", () => {
it("returns the real build error from the log, not the fallback", async () => {
const logPath = path.join(tmpDir, "build.log");
const realError = [
"Cloning Repo Custom ...: ✅",
"#5 ERROR: failed to build: npm run build exited with code 1",
"error: cannot find module './missing'",
].join("\n");
await fsPromises.writeFile(logPath, realError);

const result = await getDeploymentErrorMessage({
logPath,
serverId: null,
fallback: FALLBACK,
});

expect(result).toContain("failed to build");
expect(result).toContain("cannot find module");
expect(result).not.toBe(FALLBACK);
});

it("returns only the log content, so the key in the command string never leaks", async () => {
// The bug: Node's exec error.message is the whole command string, which
// includes the `echo "<private key>"` from the clone step. This function
// only ever reads the log file (which never contains the key), so the
// notification message is sourced from the log, not that command string.
const logPath = path.join(tmpDir, "build.log");
const realError = "#5 ERROR: failed to build the application";
await fsPromises.writeFile(logPath, realError);

const result = await getDeploymentErrorMessage({
logPath,
serverId: null,
fallback: FALLBACK,
});

expect(result).toBe(realError);
expect(result).not.toContain(PRIVATE_KEY);
expect(result).not.toContain("BEGIN OPENSSH PRIVATE KEY");
});

it("returns the fallback when the log file does not exist", async () => {
const result = await getDeploymentErrorMessage({
logPath: path.join(tmpDir, "does-not-exist.log"),
serverId: null,
fallback: FALLBACK,
});

expect(result).toBe(FALLBACK);
});

it("returns the fallback when the log file is empty", async () => {
const logPath = path.join(tmpDir, "empty.log");
await fsPromises.writeFile(logPath, " \n \n");

const result = await getDeploymentErrorMessage({
logPath,
serverId: null,
fallback: FALLBACK,
});

expect(result).toBe(FALLBACK);
});

it("returns the fallback for an empty or '.' log path", async () => {
expect(
await getDeploymentErrorMessage({
logPath: "",
serverId: null,
fallback: FALLBACK,
}),
).toBe(FALLBACK);
expect(
await getDeploymentErrorMessage({
logPath: ".",
serverId: null,
fallback: FALLBACK,
}),
).toBe(FALLBACK);
});

it("only returns the last `maxLines` lines of the log", async () => {
const logPath = path.join(tmpDir, "long.log");
const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`);
await fsPromises.writeFile(logPath, lines.join("\n"));

const result = await getDeploymentErrorMessage({
logPath,
serverId: null,
fallback: FALLBACK,
maxLines: 10,
});

const resultLines = result.split("\n");
expect(resultLines).toHaveLength(10);
expect(resultLines[0]).toBe("line 91");
expect(resultLines[9]).toBe("line 100");
});
});

describe("remote deployments (with serverId)", () => {
it("reads the log tail over SSH and returns it", async () => {
vi.mocked(execProcess.execAsyncRemote).mockResolvedValue({
stdout: "#5 ERROR: failed to build on remote server\n",
stderr: "",
});

const result = await getDeploymentErrorMessage({
logPath: "/etc/dokploy/logs/test/build.log",
serverId: "server-1",
fallback: FALLBACK,
});

expect(result).toBe("#5 ERROR: failed to build on remote server");
expect(execProcess.execAsyncRemote).toHaveBeenCalledWith(
"server-1",
expect.stringContaining("tail -n 50 /etc/dokploy/logs/test/build.log"),
);
});

it("returns the fallback when the remote read fails", async () => {
vi.mocked(execProcess.execAsyncRemote).mockRejectedValue(
new Error("ssh connection refused"),
);

const result = await getDeploymentErrorMessage({
logPath: "/etc/dokploy/logs/test/build.log",
serverId: "server-1",
fallback: FALLBACK,
});

expect(result).toBe(FALLBACK);
});

it("returns the fallback when the remote log is empty", async () => {
vi.mocked(execProcess.execAsyncRemote).mockResolvedValue({
stdout: "\n \n",
stderr: "",
});

const result = await getDeploymentErrorMessage({
logPath: "/etc/dokploy/logs/test/build.log",
serverId: "server-1",
fallback: FALLBACK,
});

expect(result).toBe(FALLBACK);
});
});
});
35 changes: 35 additions & 0 deletions apps/dokploy/__test__/deploy/truncate-error-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { truncateErrorMessage } from "@dokploy/server/utils/notifications/build-error";
import { describe, expect, it } from "vitest";

describe("truncateErrorMessage", () => {
it("returns the message unchanged when within the limit", () => {
const message = "short build error";
expect(truncateErrorMessage(message)).toBe(message);
});

it("returns the message unchanged when exactly at the limit", () => {
const message = "x".repeat(800);
expect(truncateErrorMessage(message)).toBe(message);
});

it("keeps the END of the message, not the start", () => {
// The actual build error is at the bottom of the log; truncating from the
// front would hide it.
const startMarker = "TOP_OF_LOG_SHOULD_BE_DROPPED";
const noise = "noise ".repeat(200); // pushes the start past the 800 limit
const realError = "#5 ERROR: failed to build the application";
const message = `${startMarker}${noise}${realError}`;

const result = truncateErrorMessage(message);

expect(result.length).toBeLessThanOrEqual(800 + 3); // "..." prefix
expect(result).toContain(realError);
expect(result).not.toContain(startMarker);
expect(result.startsWith("...")).toBe(true);
});

it("respects a custom limit", () => {
const message = "abcdefghij"; // 10 chars
expect(truncateErrorMessage(message, 4)).toBe("...ghij");
});
});
10 changes: 8 additions & 2 deletions packages/server/src/services/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { getDokployUrl } from "./admin";
import {
createDeployment,
createDeploymentPreview,
getDeploymentErrorMessage,
updateDeployment,
updateDeploymentStatus,
} from "./deployment";
Expand Down Expand Up @@ -249,12 +250,17 @@ export const deployApplication = async ({
await updateDeploymentStatus(deployment.deploymentId, "error");
await updateApplicationStatus(applicationId, "error");

const errorMessage = await getDeploymentErrorMessage({
logPath: deployment.logPath,
serverId,
fallback: "Error building, check the logs for details.",
});

await sendBuildErrorNotifications({
projectName: application.environment.project.name,
applicationName: application.name,
applicationType: "application",
// @ts-ignore
errorMessage: error?.message || "Error building",
errorMessage,
buildLink,
organizationId: application.environment.project.organizationId,
});
Expand Down
10 changes: 8 additions & 2 deletions packages/server/src/services/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { encodeBase64 } from "../utils/docker/utils";
import { getDokployUrl } from "./admin";
import {
createDeploymentCompose,
getDeploymentErrorMessage,
updateDeployment,
updateDeploymentStatus,
} from "./deployment";
Expand Down Expand Up @@ -314,12 +315,17 @@ export const deployCompose = async ({
await updateCompose(composeId, {
composeStatus: "error",
});
const errorMessage = await getDeploymentErrorMessage({
logPath: deployment.logPath,
serverId: compose.serverId,
fallback: "Error building, check the logs for details.",
});

await sendBuildErrorNotifications({
projectName: compose.environment.project.name,
applicationName: compose.name,
applicationType: "compose",
// @ts-ignore
errorMessage: error?.message || "Error building",
errorMessage,
buildLink,
organizationId: compose.environment.project.organizationId,
});
Expand Down
34 changes: 34 additions & 0 deletions packages/server/src/services/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,40 @@ import { findVolumeBackupById } from "./volume-backups";

export type ServicePath = { href: string | null; label: string };

export const getDeploymentErrorMessage = async ({
logPath,
serverId,
fallback,
maxLines = 50,
}: {
logPath: string;
serverId: string | null;
fallback: string;
maxLines?: number;
}): Promise<string> => {
try {
if (!logPath || logPath === ".") return fallback;

let content = "";
if (serverId) {
const { stdout } = await execAsyncRemote(
serverId,
`tail -n ${maxLines} ${logPath}`,
);
content = stdout;
} else {
if (!existsSync(logPath)) return fallback;
const fileContent = await fsPromises.readFile(logPath, "utf-8");
content = fileContent.trim().split("\n").slice(-maxLines).join("\n");
}

const trimmed = content.trim();
return trimmed.length > 0 ? trimmed : fallback;
} catch {
return fallback;
}
};

export async function resolveServicePath(
orgId: string,
data: Record<string, unknown>,
Expand Down
18 changes: 12 additions & 6 deletions packages/server/src/utils/notifications/build-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ interface Props {
organizationId: string;
}

/**
* Truncates an error message for notification channels that have a length
* limit. Keeps the END of the message because build errors live at the bottom
* of the deployment log — truncating from the front would hide the actual
* error.
*/
export const truncateErrorMessage = (message: string, limit = 800): string =>
message.length > limit ? `...${message.slice(-limit)}` : message;

export const sendBuildErrorNotifications = async ({
projectName,
applicationName,
Expand Down Expand Up @@ -108,8 +117,7 @@ export const sendBuildErrorNotifications = async ({
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();

const limitCharacter = 800;
const truncatedErrorMessage = errorMessage.substring(0, limitCharacter);
const truncatedErrorMessage = truncateErrorMessage(errorMessage);
await sendDiscordNotification(discord, {
title: decorate(">", "`⚠️` Build Failed"),
color: 0xed4245,
Expand Down Expand Up @@ -289,8 +297,7 @@ ${errorMessage}
}

if (lark) {
const limitCharacter = 800;
const truncatedErrorMessage = errorMessage.substring(0, limitCharacter);
const truncatedErrorMessage = truncateErrorMessage(errorMessage);
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
Expand Down Expand Up @@ -409,8 +416,7 @@ ${errorMessage}
}

if (teams) {
const limitCharacter = 800;
const truncatedErrorMessage = errorMessage.substring(0, limitCharacter);
const truncatedErrorMessage = truncateErrorMessage(errorMessage);
await sendTeamsNotification(teams, {
title: "⚠️ Build Failed",
facts: [
Expand Down