Skip to content
Merged
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
132 changes: 116 additions & 16 deletions apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { execFileSync } from "node:child_process";
import {
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { execAsync } = vi.hoisted(() => ({ execAsync: vi.fn() }));
Expand All @@ -25,10 +34,13 @@ const { UPDATE_IMAGE_PULLED_MARKER } = await import(
"@dokploy/server/services/web-server-update"
);

const PREVIOUS = `sha256:${"a".repeat(64)}`;

describe("Dokploy image cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("DOKPLOY_KEEP_IMAGES", undefined);
vi.stubEnv("DOKPLOY_KEEP_OLD_IMAGES", undefined);
vi.stubEnv("DOKPLOY_PREVIOUS_IMAGE", undefined);
vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_IMAGE", undefined);
vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_TAG", undefined);
});
Expand All @@ -39,18 +51,25 @@ describe("Dokploy image cleanup", () => {

it.each([
[undefined, null],
["0", null],
["2", null],
["3", 3],
["", null],
["off", null],
["0", 0],
["1", 1],
["5", 5],
["6", null],
["-1", null],
["3.5", null],
["abc", null],
])("reads keep count %s as %s", (value, expected) => {
vi.stubEnv("DOKPLOY_KEEP_IMAGES", value);
vi.stubEnv("DOKPLOY_KEEP_OLD_IMAGES", value);
expect(getDokployImageKeepCount()).toBe(expected);
});

it("ignores the old keep setting, whose 0 meant off", () => {
vi.stubEnv("DOKPLOY_KEEP_IMAGES", "0");
expect(getDokployImageKeepCount()).toBeNull();
});

it("targets the fork and official image repositories without tags", () => {
expect(getDokployImageRepositories()).toEqual([
"ghcr.io/agenthits/dokploy",
Expand All @@ -68,7 +87,7 @@ describe("Dokploy image cleanup", () => {
});

it("keeps the newest images and only removes exited dokploy task containers", () => {
const command = getDokployImageCleanupCommand(4);
const command = getDokployImageCleanupCommand(4, PREVIOUS);

expect(command).toContain(
"--filter reference\\=ghcr.io/agenthits/dokploy --filter reference\\=dokploy/dokploy",
Expand All @@ -81,22 +100,87 @@ describe("Dokploy image cleanup", () => {
expect(command).not.toMatch(/\brm (-f|--force)\b/);
});

it("skips one more image when the previous build was not recorded", () => {
expect(getDokployImageCleanupCommand(0, null)).toContain("tail -n +2");
expect(getDokployImageCleanupCommand(0, PREVIOUS)).toContain("tail -n +1");
});

it.each([
[1, "sha256:cur", ["sha256:old2", "sha256:old3"]],
[0, "sha256:cur", ["sha256:old1", "sha256:old2", "sha256:old3"]],
[0, null, ["sha256:old1", "sha256:old2", "sha256:old3"]],
[3, "sha256:cur", []],
])(
"with keep %s and previous %s, removes %j and never the running or previous image",
(keep, previous, expectedRemoved) => {
const dir = mkdtempSync(join(tmpdir(), "dokploy-cleanup-"));
const log = join(dir, "log");
try {
writeFileSync(
join(dir, "docker"),
`#!/bin/sh
echo "$*" >> "${log}"
case "$*" in
"image ls"*) printf 'sha256:new\nsha256:cur\nsha256:old1\nsha256:old2\nsha256:old3\n' ;;
"ps -q"*) echo task1 ;;
"inspect --format {{.Image}} task1") echo sha256:new ;;
"image inspect --format {{.Created}} {{.Id}}"*) printf '2026-10-07T05 sha256:new\n2026-10-07T04 sha256:cur\n2026-10-07T03 sha256:old1\n2026-10-07T02 sha256:old2\n2026-10-07T01 sha256:old3\n' ;;
esac
`,
);
chmodSync(join(dir, "docker"), 0o755);
writeFileSync(log, "");

execFileSync(
"sh",
["-c", getDokployImageCleanupCommand(keep, previous)],
{
env: { ...process.env, PATH: `${dir}:${process.env.PATH}` },
},
);

const removed = readFileSync(log, "utf8")
.split("\n")
.filter((line) => line.startsWith("image rm "))
.map((line) => line.slice("image rm ".length));
expect(removed).toEqual(expectedRemoved);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);

it("does nothing at startup when the cleanup is off", async () => {
await cleanupOldDokployImages();
expect(execAsync).not.toHaveBeenCalled();
});

it("runs the cleanup at startup when a keep count is set", async () => {
vi.stubEnv("DOKPLOY_KEEP_IMAGES", "3");
vi.stubEnv("DOKPLOY_KEEP_OLD_IMAGES", "0");
vi.stubEnv("DOKPLOY_PREVIOUS_IMAGE", PREVIOUS);
execAsync.mockResolvedValue({ stdout: "", stderr: "" });

await cleanupOldDokployImages();

expect(execAsync).toHaveBeenCalledWith(
getDokployImageCleanupCommand(0, PREVIOUS),
);
});

it("does not trust a malformed previous image id", async () => {
vi.stubEnv("DOKPLOY_KEEP_OLD_IMAGES", "1");
vi.stubEnv("DOKPLOY_PREVIOUS_IMAGE", "sha256:abc; rm -rf /");
execAsync.mockResolvedValue({ stdout: "", stderr: "" });

await cleanupOldDokployImages();

expect(execAsync).toHaveBeenCalledWith(getDokployImageCleanupCommand(3));
expect(execAsync).toHaveBeenCalledWith(
getDokployImageCleanupCommand(1, null),
);
});

it("does not throw when the cleanup fails", async () => {
vi.stubEnv("DOKPLOY_KEEP_IMAGES", "3");
vi.stubEnv("DOKPLOY_KEEP_OLD_IMAGES", "3");
execAsync.mockRejectedValue(new Error("docker unavailable"));
vi.spyOn(console, "error").mockImplementation(() => {});

Expand All @@ -105,15 +189,29 @@ describe("Dokploy image cleanup", () => {

it("stores the keep count on the service only when it is given", () => {
expect(getAgentHitsUpdateCommand("v0.30.6")).not.toContain(
"DOKPLOY_KEEP_IMAGES",
"DOKPLOY_KEEP_OLD_IMAGES",
);
expect(getAgentHitsUpdateCommand("v0.30.6", null, null, 3)).toContain(
"--env-add DOKPLOY_KEEP_IMAGES\\=3 \\\n\tdokploy",
expect(getAgentHitsUpdateCommand("v0.30.6", null, null, 0)).toContain(
"--env-add DOKPLOY_KEEP_OLD_IMAGES\\=0 \\\n",
);
expect(getAgentHitsUpdateCommand("v0.30.6", null, null, null)).toContain(
"--env-add DOKPLOY_KEEP_IMAGES\\=0",
"--env-add DOKPLOY_KEEP_OLD_IMAGES\\=off",
);
});

it("records the running image before replacing it", () => {
for (const command of [
getAgentHitsUpdateCommand("v0.30.6"),
getOfficialUpdateCommand("v0.30.7"),
]) {
const recordAt = command.indexOf("previous_image=$(docker inspect");
expect(recordAt).toBeGreaterThan(-1);
expect(recordAt).toBeLessThan(command.indexOf("docker service update"));
expect(command).toContain(
'--env-add "DOKPLOY_PREVIOUS_IMAGE=$previous_image"',
);
}
});
});

describe("Dokploy image list", () => {
Expand Down Expand Up @@ -243,10 +341,10 @@ describe("Dokploy update commands", () => {

expectPullBeforeUpdate(command, "docker pull dokploy/dokploy\\:v0.30.7");
expect(command).toContain(
"--image dokploy/dokploy\\:v0.30.7 --env-add DOKPLOY_KEEP_IMAGES\\=3 dokploy",
"--image dokploy/dokploy\\:v0.30.7 --env-add DOKPLOY_KEEP_OLD_IMAGES\\=3 ",
);
expect(getOfficialUpdateCommand("v0.30.7")).not.toContain(
"DOKPLOY_KEEP_IMAGES",
"DOKPLOY_KEEP_OLD_IMAGES",
);
});

Expand All @@ -256,6 +354,8 @@ describe("Dokploy update commands", () => {
await getDokployImages();

expect(execAsync.mock.calls[0]?.[0]).toMatch(/^docker image ls -a -q /);
expect(getDokployImageCleanupCommand(3)).toContain("docker image ls -a -q");
expect(getDokployImageCleanupCommand(3, null)).toContain(
"docker image ls -a -q",
);
});
});
53 changes: 38 additions & 15 deletions apps/dokploy/__test__/settings/dokploy-image-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,48 +34,71 @@ describe("planDokployImageCleanup", () => {
const plan = planDokployImageCleanup(images, null, "v5");
expect(actions(plan)).toEqual([
"v5:new",
"v4:keep",
"v4:current",
"v3:keep",
"v2:keep",
"v1:keep",
]);
expect(plan.removeCount).toBe(0);
});

it("counts the pending build as one of the kept images", () => {
const plan = planDokployImageCleanup(images, 3, "v5");
it("counts only the images older than the new and the installed build", () => {
const plan = planDokployImageCleanup(images, 1, "v5");
expect(actions(plan)).toEqual([
"v5:new",
"v4:keep",
"v4:current",
"v3:keep",
"v2:remove",
"v1:remove",
]);
expect(plan.rows.map((row) => row.position)).toEqual([null, null, 1, 2, 3]);
expect(plan.freedBytes).toBe(8_200_000_000);
expect(plan.removedContainers).toBe(1);
});

it("does not add a pending row when the new build is already pulled", () => {
const plan = planDokployImageCleanup(images, 3, "v4");
it("can delete every older image while the new and installed builds stay", () => {
const plan = planDokployImageCleanup(images, 0, "v5");
expect(actions(plan)).toEqual([
"v4:keep",
"v5:new",
"v4:current",
"v3:remove",
"v2:remove",
"v1:remove",
]);
});

it("protects an already pulled new build without adding a pending row", () => {
const plan = planDokployImageCleanup([image("v5"), ...images], 0, "v5");
expect(actions(plan)).toEqual([
"v5:new",
"v4:current",
"v3:remove",
"v2:remove",
"v1:remove",
]);
});

it("does not protect an extra image when the new build is the installed one", () => {
const plan = planDokployImageCleanup(images, 1, "v4");
expect(actions(plan)).toEqual([
"v4:current",
"v3:keep",
"v2:keep",
"v2:remove",
"v1:remove",
]);
});

it("never plans to delete images that other containers or the running server use", () => {
it("never plans to delete images that other containers use", () => {
const plan = planDokployImageCleanup(
[
image("v3"),
image("v2", { otherContainers: 1 }),
image("v1", { isCurrent: true }),
image("v3", { isCurrent: true }),
image("v2"),
image("v1", { otherContainers: 1 }),
],
1,
0,
null,
);
expect(actions(plan)).toEqual(["v3:keep", "v2:blocked", "v1:blocked"]);
expect(plan.removeCount).toBe(0);
expect(actions(plan)).toEqual(["v3:current", "v2:remove", "v1:blocked"]);
expect(plan.removeCount).toBe(1);
});
});
Loading
Loading