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
60 changes: 60 additions & 0 deletions apps/dokploy/__test__/settings/docker-server-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ const redactWebServerSettings = <T>(settings: T) => settings;
vi.mock("@dokploy/server", () => ({
CLEANUP_CRON_JOB: "0 0 * * *",
DEFAULT_UPDATE_DATA: {},
DOKPLOY_KEEP_IMAGES_ENV: "DOKPLOY_KEEP_IMAGES",
DOKPLOY_KEEP_IMAGES_MAX: 5,
DOKPLOY_KEEP_IMAGES_MIN: 3,
getDokployImageKeepCount: vi.fn(),
getDokployImages: vi.fn(),
IS_CLOUD: false,
checkGPUStatus: mocks.checkGPUStatus,
checkPortInUse: mocks.checkPortInUse,
Expand Down Expand Up @@ -419,13 +424,45 @@ describe("settings Docker server boundary", () => {
"v0.30.6",
"off_v0.29.8/Fork_159+next",
"v0.30.0",
undefined,
);
expect(mocks.spawnAsync).toHaveBeenCalledWith("sh", [
"-c",
"agenthits update command",
]);
});

it("passes the image keep count to the AgentHits update command", async () => {
mocks.getUpdateData.mockResolvedValue({
latestVersion: "off_v0.29.8/Fork_159+next",
updateAvailable: true,
updateSource: "agenthits",
latestOfficialVersion: "v0.30.0",
});
mocks.getAgentHitsUpdateCommand.mockReturnValue("agenthits update command");

await expect(createCaller().updateServer({ keepImages: 4 })).resolves.toBe(
true,
);

expect(mocks.getAgentHitsUpdateCommand).toHaveBeenCalledWith(
"v0.30.6",
"off_v0.29.8/Fork_159+next",
"v0.30.0",
4,
);
});

it("rejects image keep counts outside 3 to 5", async () => {
await expect(
createCaller().updateServer({ keepImages: 2 }),
).rejects.toThrow();
await expect(
createCaller().updateServer({ keepImages: 10 }),
).rejects.toThrow();
expect(mocks.spawnAsync).not.toHaveBeenCalled();
});

it("keeps official updates on the official Dokploy image path", async () => {
mocks.getUpdateData.mockResolvedValue({
latestVersion: "v0.30.0",
Expand All @@ -446,6 +483,29 @@ describe("settings Docker server boundary", () => {
expect(mocks.getAgentHitsUpdateCommand).not.toHaveBeenCalled();
});

it("turns the image cleanup off on official updates", async () => {
mocks.getUpdateData.mockResolvedValue({
latestVersion: "v0.30.0",
updateAvailable: true,
updateSource: "official",
});

await expect(
createCaller().updateServer({ keepImages: null }),
).resolves.toBe(true);

expect(mocks.spawnAsync).toHaveBeenCalledWith("docker", [
"service",
"update",
"--force",
"--image",
"dokploy/dokploy:v0.30.0",
"--env-add",
"DOKPLOY_KEEP_IMAGES=0",
"dokploy",
]);
});

it("requires api.read before generating the OpenAPI document", async () => {
const result = await createCaller().getOpenApiDocument();

Expand Down
201 changes: 201 additions & 0 deletions apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { execAsync } = vi.hoisted(() => ({ execAsync: vi.fn() }));

vi.mock("@dokploy/server/db", () => ({
db: {},
}));

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

const {
cleanupOldDokployImages,
getAgentHitsUpdateCommand,
getDokployImageCleanupCommand,
getDokployImageKeepCount,
getDokployImageRepositories,
getDokployImages,
} = await import("@dokploy/server/services/settings");

describe("Dokploy image cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("DOKPLOY_KEEP_IMAGES", undefined);
vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_IMAGE", undefined);
vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_TAG", undefined);
});

afterEach(() => {
vi.unstubAllEnvs();
});

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

it("targets the fork and official image repositories without tags", () => {
expect(getDokployImageRepositories()).toEqual([
"ghcr.io/agenthits/dokploy",
"dokploy/dokploy",
]);

vi.stubEnv(
"DOKPLOY_AGENTHITS_UPDATE_IMAGE",
"registry.local:5000/team/dokploy:dev@sha256:abc",
);
expect(getDokployImageRepositories()).toEqual([
"registry.local:5000/team/dokploy",
"dokploy/dokploy",
]);
});

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

expect(command).toContain(
"--filter reference\\=ghcr.io/agenthits/dokploy --filter reference\\=dokploy/dokploy",
);
expect(command).toContain("tail -n +5");
expect(command).toContain("--filter status=exited");
expect(command).toContain(
"--filter label=com.docker.swarm.service.name=dokploy",
);
expect(command).not.toMatch(/\brm (-f|--force)\b/);
});

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");
execAsync.mockResolvedValue({ stdout: "", stderr: "" });

await cleanupOldDokployImages();

expect(execAsync).toHaveBeenCalledWith(getDokployImageCleanupCommand(3));
});

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

await expect(cleanupOldDokployImages()).resolves.toBeUndefined();
});

it("stores the keep count on the service only when it is given", () => {
expect(getAgentHitsUpdateCommand("v0.30.6")).not.toContain(
"DOKPLOY_KEEP_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, null)).toContain(
"--env-add DOKPLOY_KEEP_IMAGES\\=0",
);
});
});

describe("Dokploy image list", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_IMAGE", undefined);
});

afterEach(() => {
vi.unstubAllEnvs();
});

const image = (id: string, createdAt: string, fork: string) =>
JSON.stringify({
id: `sha256:${id}`,
createdAt,
size: 4455354111,
tags: [`ghcr.io/agenthits/dokploy:agenthits-dev-${id}`],
config: {
Env: [
"PATH=/usr/bin|/bin",
`DOKPLOY_FORK_VERSION=${fork}`,
"DOKPLOY_OFFICIAL_VERSION=v0.30.6",
],
},
});
const container = (id: string, running: boolean, service: string) =>
JSON.stringify({ image: `sha256:${id}`, running, service });

it("reads versions, sizes and containers newest first", async () => {
execAsync.mockImplementation(async (command: string) => {
if (command.startsWith("docker image ls")) {
return { stdout: "sha256:aaa\nsha256:bbb\nsha256:ccc\n", stderr: "" };
}
if (command.startsWith("docker image inspect")) {
return {
stdout: [
image("aaa", "2026-10-07T10:26:35.154227243Z", "Fork_412+aaa"),
image("ccc", "2026-10-07T08:50:17.72561574Z", "Fork_400+ccc"),
image("bbb", "2026-10-07T09:30:23.012650643Z", "Fork_408+bbb"),
].join("\n"),
stderr: "",
};
}
if (command.startsWith("docker system df")) {
return {
stdout: JSON.stringify({
Images: [{ ID: "sha256:aaa", UniqueSize: "4.115GB" }],
}),
stderr: "",
};
}
return {
stdout: [
container("aaa", true, "dokploy"),
container("bbb", false, "dokploy"),
container("bbb", false, "dokploy"),
container("ccc", false, ""),
].join("\n"),
stderr: "",
};
});

const images = await getDokployImages();

expect(images.map((item) => item.forkVersion)).toEqual([
"Fork_412+aaa",
"Fork_408+bbb",
"Fork_400+ccc",
]);
expect(images[0]).toMatchObject({
isCurrent: true,
uniqueSizeBytes: 4115000000,
officialVersion: "v0.30.6",
});
expect(images[1]).toMatchObject({
isCurrent: false,
dokployTaskContainers: 2,
otherContainers: 0,
uniqueSizeBytes: null,
});
expect(images[2]).toMatchObject({ otherContainers: 1 });
});

it("returns nothing when there are no Dokploy images", async () => {
execAsync.mockResolvedValue({ stdout: "", stderr: "" });
await expect(getDokployImages()).resolves.toEqual([]);
expect(execAsync).toHaveBeenCalledTimes(1);
});
});
81 changes: 81 additions & 0 deletions apps/dokploy/__test__/settings/dokploy-image-plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { DokployImageInfo } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
import { planDokployImageCleanup } from "@/components/dashboard/settings/web-server/dokploy-image-plan";

const image = (
fork: string,
overrides: Partial<DokployImageInfo> = {},
): DokployImageInfo => ({
id: `sha256:${fork}`,
tags: [],
createdAt: "2026-10-07T10:00:00Z",
sizeBytes: 4_450_000_000,
uniqueSizeBytes: 4_100_000_000,
forkVersion: fork,
officialVersion: "v0.30.6",
isCurrent: false,
dokployTaskContainers: 0,
otherContainers: 0,
...overrides,
});

const actions = (plan: ReturnType<typeof planDokployImageCleanup>) =>
plan.rows.map((row) => `${row.version}:${row.action}`);

describe("planDokployImageCleanup", () => {
const images = [
image("v4", { isCurrent: true }),
image("v3", { dokployTaskContainers: 2 }),
image("v2", { dokployTaskContainers: 1 }),
image("v1"),
];

it("keeps everything when the cleanup is off", () => {
const plan = planDokployImageCleanup(images, null, "v5");
expect(actions(plan)).toEqual([
"v5:new",
"v4:keep",
"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");
expect(actions(plan)).toEqual([
"v5:new",
"v4:keep",
"v3:keep",
"v2:remove",
"v1:remove",
]);
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");
expect(actions(plan)).toEqual([
"v4:keep",
"v3:keep",
"v2:keep",
"v1:remove",
]);
});

it("never plans to delete images that other containers or the running server use", () => {
const plan = planDokployImageCleanup(
[
image("v3"),
image("v2", { otherContainers: 1 }),
image("v1", { isCurrent: true }),
],
1,
null,
);
expect(actions(plan)).toEqual(["v3:keep", "v2:blocked", "v1:blocked"]);
expect(plan.removeCount).toBe(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ const mocks = vi.hoisted(() => ({
vi.mock("@dokploy/server", () => ({
CLEANUP_CRON_JOB: "0 0 * * *",
DEFAULT_UPDATE_DATA: {},
DOKPLOY_KEEP_IMAGES_ENV: "DOKPLOY_KEEP_IMAGES",
DOKPLOY_KEEP_IMAGES_MAX: 5,
DOKPLOY_KEEP_IMAGES_MIN: 3,
getDokployImageKeepCount: vi.fn(),
getDokployImages: vi.fn(),
IS_CLOUD: false,
checkGPUStatus: mocks.checkGPUStatus,
checkPortInUse: mocks.checkPortInUse,
Expand Down
Loading
Loading