diff --git a/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts b/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts new file mode 100644 index 000000000..51aafe8a0 --- /dev/null +++ b/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts @@ -0,0 +1,202 @@ +import { clearOldDeployments } from "@dokploy/server"; +import { db } from "@dokploy/server/db"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), +})); + +vi.mock("@dokploy/server/services/application", () => ({ + findApplicationById: vi.fn(), +})); + +vi.mock("@dokploy/server/services/compose", () => ({ + findComposeById: vi.fn(), +})); + +vi.mock("@dokploy/server/services/rollbacks", () => ({ + removeRollbackById: vi.fn().mockResolvedValue(undefined), +})); + +import { findApplicationById } from "@dokploy/server/services/application"; +import { findComposeById } from "@dokploy/server/services/compose"; +import { removeRollbackById } from "@dokploy/server/services/rollbacks"; + +const makeDeployment = (overrides: Record) => ({ + deploymentId: "", + logPath: ".", + serverId: null, + status: "done", + rollbackId: null, + createdAt: new Date().toISOString(), + ...overrides, +}); + +describe("clearOldDeployments", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.delete).mockReturnValue({ + where: () => ({ + returning: () => Promise.resolve([]), + }), + } as any); + vi.mocked(findApplicationById).mockResolvedValue({ + serverId: null, + } as any); + vi.mocked(findComposeById).mockResolvedValue({ + serverId: null, + } as any); + }); + + it("keeps the most recent successful deployment and deletes the rest", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "failed-1", status: "error" }), + makeDeployment({ deploymentId: "success-1", status: "done" }), + makeDeployment({ deploymentId: "success-2", status: "done" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + // deploymentList order is newest-first (query orders by desc createdAt); + // first "done" entry (success-1) is kept, failed-1 and success-2 are deleted. + expect(db.delete).toHaveBeenCalledTimes(2); + }); + + it("falls back to the newest deployment when none succeeded", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "failed-1", status: "error" }), + makeDeployment({ deploymentId: "failed-2", status: "error" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + // failed-1 (newest) is kept as fallback, only failed-2 is deleted. + expect(db.delete).toHaveBeenCalledTimes(1); + }); + + it("removes nothing when there is a single deployment", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "only-1", status: "done" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("compose-1", "compose"); + + expect(db.delete).not.toHaveBeenCalled(); + }); + + it("never deletes the currently-running deployment, even if newer than the last success", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "running-1", status: "running" }), + makeDeployment({ deploymentId: "success-1", status: "done" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + // running-1 is excluded from deletion candidates entirely, and + // success-1 is kept as the most recent successful deployment. + expect(db.delete).not.toHaveBeenCalled(); + }); + + it("deletes older non-running deployments while a build is in progress", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "running-1", status: "running" }), + makeDeployment({ deploymentId: "success-1", status: "done" }), + makeDeployment({ deploymentId: "failed-1", status: "error" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + // running-1 is protected; only failed-1 (older than the kept success) is removed. + expect(db.delete).toHaveBeenCalledTimes(1); + }); + + it("continues cleaning up remaining deployments after one removal fails", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "success-1", status: "done" }), + makeDeployment({ deploymentId: "failed-1", status: "error" }), + makeDeployment({ deploymentId: "failed-2", status: "error" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + vi.mocked(db.delete) + .mockImplementationOnce(() => { + throw new Error("db unavailable"); + }) + .mockReturnValue({ + where: () => ({ + returning: () => Promise.resolve([]), + }), + } as any); + + await expect( + clearOldDeployments("app-1", "application"), + ).resolves.not.toThrow(); + + // failed-1's removal throws but doesn't stop failed-2 from being attempted. + expect(db.delete).toHaveBeenCalledTimes(2); + }); + + it("removes the rollback (and its image) before deleting a deployment that has one", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "success-1", status: "done" }), + makeDeployment({ + deploymentId: "failed-1", + status: "error", + rollbackId: "rollback-1", + }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + expect(removeRollbackById).toHaveBeenCalledWith("rollback-1"); + expect(db.delete).toHaveBeenCalledTimes(1); + }); + + it("removes remote logs via execAsyncRemote using the application's server, not local execAsync", async () => { + vi.mocked(findApplicationById).mockResolvedValue({ + serverId: "server-1", + } as any); + const deploymentList = [ + makeDeployment({ deploymentId: "success-1", status: "done" }), + makeDeployment({ + deploymentId: "failed-1", + status: "error", + logPath: "/logs/failed-1.log", + serverId: null, + }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + // the batched remote cleanup uses the application's server, independent + // of the (often-unset) per-deployment serverId column. + expect(execAsyncRemote).toHaveBeenCalledWith( + "server-1", + expect.stringContaining("rm -rf /logs/failed-1.log;"), + ); + }); +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 01a9534d9..4e7f47a43 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -870,7 +870,7 @@ export const applicationRouter = createTRPCRouter({ deployment: ["create"], }); const application = await findApplicationById(input.applicationId); - await clearOldDeployments(application.appName, application.serverId); + await clearOldDeployments(application.applicationId, "application"); await audit(ctx, { action: "delete", resourceType: "application", diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 1de66824d..bbf2151ef 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -293,7 +293,7 @@ export const composeRouter = createTRPCRouter({ deployment: ["create"], }); const compose = await findComposeById(input.composeId); - await clearOldDeployments(compose.appName, compose.serverId); + await clearOldDeployments(compose.composeId, "compose"); await audit(ctx, { action: "update", resourceType: "compose", diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index fd61fc106..c84210666 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -1058,17 +1058,69 @@ export const findAllDeploymentsByServerId = async (serverId: string) => { }; export const clearOldDeployments = async ( - appName: string, - serverId: string | null, + id: string, + type: "application" | "compose", ) => { - const { LOGS_PATH } = paths(!!serverId); - const folder = path.join(LOGS_PATH, appName); - const command = ` - rm -rf ${folder}; - `; + const serverId = + type === "application" + ? (await findApplicationById(id)).serverId + : (await findComposeById(id)).serverId; + + const deploymentList = await db.query.deployments.findMany({ + where: eq(deployments[`${type}Id`], id), + orderBy: desc(deployments.createdAt), + }); + const deletable = deploymentList.filter( + (deployment) => deployment.status !== "running", + ); + const mostRecentSuccessful = deletable.find( + (deployment) => deployment.status === "done", + ); + const deploymentToKeep = mostRecentSuccessful ?? deletable[0]; + + const toRemove = deletable.filter( + (deployment) => deployment.deploymentId !== deploymentToKeep?.deploymentId, + ); + if (serverId) { - await execAsyncRemote(serverId, command); + let command = ""; + for (const deployment of toRemove) { + try { + if (deployment.rollbackId) { + await removeRollbackById(deployment.rollbackId); + } + const logPath = path.join(deployment.logPath); + if (logPath && logPath !== ".") { + command += `rm -rf ${logPath};`; + } + await removeDeployment(deployment.deploymentId); + } catch (err) { + console.error( + `Failed to remove deployment ${deployment.deploymentId} during cleanup:`, + err, + ); + } + } + if (command) { + await execAsyncRemote(serverId, command); + } } else { - await execAsync(command); + for (const deployment of toRemove) { + try { + if (deployment.rollbackId) { + await removeRollbackById(deployment.rollbackId); + } + const logPath = path.join(deployment.logPath); + if (logPath && logPath !== "." && existsSync(logPath)) { + await fsPromises.unlink(logPath); + } + await removeDeployment(deployment.deploymentId); + } catch (err) { + console.error( + `Failed to remove deployment ${deployment.deploymentId} during cleanup:`, + err, + ); + } + } } };