diff --git a/apps/dokploy/__test__/deploy/deployment-error-message.test.ts b/apps/dokploy/__test__/deploy/deployment-error-message.test.ts new file mode 100644 index 0000000000..503789359e --- /dev/null +++ b/apps/dokploy/__test__/deploy/deployment-error-message.test.ts @@ -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 ""` 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); + }); + }); +}); diff --git a/apps/dokploy/__test__/deploy/truncate-error-message.test.ts b/apps/dokploy/__test__/deploy/truncate-error-message.test.ts new file mode 100644 index 0000000000..39fd61f4e2 --- /dev/null +++ b/apps/dokploy/__test__/deploy/truncate-error-message.test.ts @@ -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"); + }); +}); diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index c8ebb3be77..f375b19a17 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -35,6 +35,7 @@ import { getDokployUrl } from "./admin"; import { createDeployment, createDeploymentPreview, + getDeploymentErrorMessage, updateDeployment, updateDeploymentStatus, } from "./deployment"; @@ -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, }); diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 7a887cdc41..b2fc7d3949 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -38,6 +38,7 @@ import { encodeBase64 } from "../utils/docker/utils"; import { getDokployUrl } from "./admin"; import { createDeploymentCompose, + getDeploymentErrorMessage, updateDeployment, updateDeploymentStatus, } from "./deployment"; @@ -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, }); diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index 0431de18e2..65e79945f3 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -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 => { + 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, diff --git a/packages/server/src/utils/notifications/build-error.ts b/packages/server/src/utils/notifications/build-error.ts index 7c299809be..698a214627 100644 --- a/packages/server/src/utils/notifications/build-error.ts +++ b/packages/server/src/utils/notifications/build-error.ts @@ -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, @@ -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, @@ -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: { @@ -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: [