Skip to content

Commit 7eac0c4

Browse files
agentHitsclaude
andauthored
feat: optionally remove old Dokploy images on web server update (#34)
* feat: optionally remove old Dokploy images on web server update The update dialog gets a switch "Remove old Dokploy images, keep the last N" (N = 3, 4 or 5). The choice is stored on the dokploy service as DOKPLOY_KEEP_IMAGES, so it survives restarts and later manual updates. Cleanup runs when the new Dokploy container starts, because the update replaces the container that started it (stop-first). It removes exited dokploy task containers that pin old images, then the images beyond the newest N of the fork and official repositories. Nothing is forced: images used by running or foreign containers are left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show Dokploy images and what the cleanup will delete A button under the cleanup switch shows how many Dokploy images are on the host and their total size. Its popover lists each image with fork version, build date, size and the space deleting it frees, and marks which ones stay and which ones are deleted for the chosen keep count. A pending update counts as the newest image. Images used by other containers are shown as kept. settings.getDokployImages reads the images, their unique sizes from `docker system df -v` and the containers that use them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: classify the Dokploy image queries for super sessions settings.getDokployImageKeepCount and settings.getDokployImages only read Docker state, so they are "read" procedures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8dc237e commit 7eac0c4

13 files changed

Lines changed: 1038 additions & 35 deletions

File tree

‎apps/dokploy/__test__/settings/docker-server-boundary.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ const redactWebServerSettings = <T>(settings: T) => settings;
6767
vi.mock("@dokploy/server", () => ({
6868
CLEANUP_CRON_JOB: "0 0 * * *",
6969
DEFAULT_UPDATE_DATA: {},
70+
DOKPLOY_KEEP_IMAGES_ENV: "DOKPLOY_KEEP_IMAGES",
71+
DOKPLOY_KEEP_IMAGES_MAX: 5,
72+
DOKPLOY_KEEP_IMAGES_MIN: 3,
73+
getDokployImageKeepCount: vi.fn(),
74+
getDokployImages: vi.fn(),
7075
IS_CLOUD: false,
7176
checkGPUStatus: mocks.checkGPUStatus,
7277
checkPortInUse: mocks.checkPortInUse,
@@ -419,13 +424,45 @@ describe("settings Docker server boundary", () => {
419424
"v0.30.6",
420425
"off_v0.29.8/Fork_159+next",
421426
"v0.30.0",
427+
undefined,
422428
);
423429
expect(mocks.spawnAsync).toHaveBeenCalledWith("sh", [
424430
"-c",
425431
"agenthits update command",
426432
]);
427433
});
428434

435+
it("passes the image keep count to the AgentHits update command", async () => {
436+
mocks.getUpdateData.mockResolvedValue({
437+
latestVersion: "off_v0.29.8/Fork_159+next",
438+
updateAvailable: true,
439+
updateSource: "agenthits",
440+
latestOfficialVersion: "v0.30.0",
441+
});
442+
mocks.getAgentHitsUpdateCommand.mockReturnValue("agenthits update command");
443+
444+
await expect(createCaller().updateServer({ keepImages: 4 })).resolves.toBe(
445+
true,
446+
);
447+
448+
expect(mocks.getAgentHitsUpdateCommand).toHaveBeenCalledWith(
449+
"v0.30.6",
450+
"off_v0.29.8/Fork_159+next",
451+
"v0.30.0",
452+
4,
453+
);
454+
});
455+
456+
it("rejects image keep counts outside 3 to 5", async () => {
457+
await expect(
458+
createCaller().updateServer({ keepImages: 2 }),
459+
).rejects.toThrow();
460+
await expect(
461+
createCaller().updateServer({ keepImages: 10 }),
462+
).rejects.toThrow();
463+
expect(mocks.spawnAsync).not.toHaveBeenCalled();
464+
});
465+
429466
it("keeps official updates on the official Dokploy image path", async () => {
430467
mocks.getUpdateData.mockResolvedValue({
431468
latestVersion: "v0.30.0",
@@ -446,6 +483,29 @@ describe("settings Docker server boundary", () => {
446483
expect(mocks.getAgentHitsUpdateCommand).not.toHaveBeenCalled();
447484
});
448485

486+
it("turns the image cleanup off on official updates", async () => {
487+
mocks.getUpdateData.mockResolvedValue({
488+
latestVersion: "v0.30.0",
489+
updateAvailable: true,
490+
updateSource: "official",
491+
});
492+
493+
await expect(
494+
createCaller().updateServer({ keepImages: null }),
495+
).resolves.toBe(true);
496+
497+
expect(mocks.spawnAsync).toHaveBeenCalledWith("docker", [
498+
"service",
499+
"update",
500+
"--force",
501+
"--image",
502+
"dokploy/dokploy:v0.30.0",
503+
"--env-add",
504+
"DOKPLOY_KEEP_IMAGES=0",
505+
"dokploy",
506+
]);
507+
});
508+
449509
it("requires api.read before generating the OpenAPI document", async () => {
450510
const result = await createCaller().getOpenApiDocument();
451511

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { execAsync } = vi.hoisted(() => ({ execAsync: vi.fn() }));
4+
5+
vi.mock("@dokploy/server/db", () => ({
6+
db: {},
7+
}));
8+
9+
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
10+
execAsync,
11+
execAsyncRemote: vi.fn(),
12+
}));
13+
14+
const {
15+
cleanupOldDokployImages,
16+
getAgentHitsUpdateCommand,
17+
getDokployImageCleanupCommand,
18+
getDokployImageKeepCount,
19+
getDokployImageRepositories,
20+
getDokployImages,
21+
} = await import("@dokploy/server/services/settings");
22+
23+
describe("Dokploy image cleanup", () => {
24+
beforeEach(() => {
25+
vi.clearAllMocks();
26+
vi.stubEnv("DOKPLOY_KEEP_IMAGES", undefined);
27+
vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_IMAGE", undefined);
28+
vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_TAG", undefined);
29+
});
30+
31+
afterEach(() => {
32+
vi.unstubAllEnvs();
33+
});
34+
35+
it.each([
36+
[undefined, null],
37+
["0", null],
38+
["2", null],
39+
["3", 3],
40+
["5", 5],
41+
["6", null],
42+
["3.5", null],
43+
["abc", null],
44+
])("reads keep count %s as %s", (value, expected) => {
45+
vi.stubEnv("DOKPLOY_KEEP_IMAGES", value);
46+
expect(getDokployImageKeepCount()).toBe(expected);
47+
});
48+
49+
it("targets the fork and official image repositories without tags", () => {
50+
expect(getDokployImageRepositories()).toEqual([
51+
"ghcr.io/agenthits/dokploy",
52+
"dokploy/dokploy",
53+
]);
54+
55+
vi.stubEnv(
56+
"DOKPLOY_AGENTHITS_UPDATE_IMAGE",
57+
"registry.local:5000/team/dokploy:dev@sha256:abc",
58+
);
59+
expect(getDokployImageRepositories()).toEqual([
60+
"registry.local:5000/team/dokploy",
61+
"dokploy/dokploy",
62+
]);
63+
});
64+
65+
it("keeps the newest images and only removes exited dokploy task containers", () => {
66+
const command = getDokployImageCleanupCommand(4);
67+
68+
expect(command).toContain(
69+
"--filter reference\\=ghcr.io/agenthits/dokploy --filter reference\\=dokploy/dokploy",
70+
);
71+
expect(command).toContain("tail -n +5");
72+
expect(command).toContain("--filter status=exited");
73+
expect(command).toContain(
74+
"--filter label=com.docker.swarm.service.name=dokploy",
75+
);
76+
expect(command).not.toMatch(/\brm (-f|--force)\b/);
77+
});
78+
79+
it("does nothing at startup when the cleanup is off", async () => {
80+
await cleanupOldDokployImages();
81+
expect(execAsync).not.toHaveBeenCalled();
82+
});
83+
84+
it("runs the cleanup at startup when a keep count is set", async () => {
85+
vi.stubEnv("DOKPLOY_KEEP_IMAGES", "3");
86+
execAsync.mockResolvedValue({ stdout: "", stderr: "" });
87+
88+
await cleanupOldDokployImages();
89+
90+
expect(execAsync).toHaveBeenCalledWith(getDokployImageCleanupCommand(3));
91+
});
92+
93+
it("does not throw when the cleanup fails", async () => {
94+
vi.stubEnv("DOKPLOY_KEEP_IMAGES", "3");
95+
execAsync.mockRejectedValue(new Error("docker unavailable"));
96+
vi.spyOn(console, "error").mockImplementation(() => {});
97+
98+
await expect(cleanupOldDokployImages()).resolves.toBeUndefined();
99+
});
100+
101+
it("stores the keep count on the service only when it is given", () => {
102+
expect(getAgentHitsUpdateCommand("v0.30.6")).not.toContain(
103+
"DOKPLOY_KEEP_IMAGES",
104+
);
105+
expect(getAgentHitsUpdateCommand("v0.30.6", null, null, 3)).toContain(
106+
"--env-add DOKPLOY_KEEP_IMAGES\\=3 \\\n\tdokploy",
107+
);
108+
expect(getAgentHitsUpdateCommand("v0.30.6", null, null, null)).toContain(
109+
"--env-add DOKPLOY_KEEP_IMAGES\\=0",
110+
);
111+
});
112+
});
113+
114+
describe("Dokploy image list", () => {
115+
beforeEach(() => {
116+
vi.clearAllMocks();
117+
vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_IMAGE", undefined);
118+
});
119+
120+
afterEach(() => {
121+
vi.unstubAllEnvs();
122+
});
123+
124+
const image = (id: string, createdAt: string, fork: string) =>
125+
JSON.stringify({
126+
id: `sha256:${id}`,
127+
createdAt,
128+
size: 4455354111,
129+
tags: [`ghcr.io/agenthits/dokploy:agenthits-dev-${id}`],
130+
config: {
131+
Env: [
132+
"PATH=/usr/bin|/bin",
133+
`DOKPLOY_FORK_VERSION=${fork}`,
134+
"DOKPLOY_OFFICIAL_VERSION=v0.30.6",
135+
],
136+
},
137+
});
138+
const container = (id: string, running: boolean, service: string) =>
139+
JSON.stringify({ image: `sha256:${id}`, running, service });
140+
141+
it("reads versions, sizes and containers newest first", async () => {
142+
execAsync.mockImplementation(async (command: string) => {
143+
if (command.startsWith("docker image ls")) {
144+
return { stdout: "sha256:aaa\nsha256:bbb\nsha256:ccc\n", stderr: "" };
145+
}
146+
if (command.startsWith("docker image inspect")) {
147+
return {
148+
stdout: [
149+
image("aaa", "2026-10-07T10:26:35.154227243Z", "Fork_412+aaa"),
150+
image("ccc", "2026-10-07T08:50:17.72561574Z", "Fork_400+ccc"),
151+
image("bbb", "2026-10-07T09:30:23.012650643Z", "Fork_408+bbb"),
152+
].join("\n"),
153+
stderr: "",
154+
};
155+
}
156+
if (command.startsWith("docker system df")) {
157+
return {
158+
stdout: JSON.stringify({
159+
Images: [{ ID: "sha256:aaa", UniqueSize: "4.115GB" }],
160+
}),
161+
stderr: "",
162+
};
163+
}
164+
return {
165+
stdout: [
166+
container("aaa", true, "dokploy"),
167+
container("bbb", false, "dokploy"),
168+
container("bbb", false, "dokploy"),
169+
container("ccc", false, ""),
170+
].join("\n"),
171+
stderr: "",
172+
};
173+
});
174+
175+
const images = await getDokployImages();
176+
177+
expect(images.map((item) => item.forkVersion)).toEqual([
178+
"Fork_412+aaa",
179+
"Fork_408+bbb",
180+
"Fork_400+ccc",
181+
]);
182+
expect(images[0]).toMatchObject({
183+
isCurrent: true,
184+
uniqueSizeBytes: 4115000000,
185+
officialVersion: "v0.30.6",
186+
});
187+
expect(images[1]).toMatchObject({
188+
isCurrent: false,
189+
dokployTaskContainers: 2,
190+
otherContainers: 0,
191+
uniqueSizeBytes: null,
192+
});
193+
expect(images[2]).toMatchObject({ otherContainers: 1 });
194+
});
195+
196+
it("returns nothing when there are no Dokploy images", async () => {
197+
execAsync.mockResolvedValue({ stdout: "", stderr: "" });
198+
await expect(getDokployImages()).resolves.toEqual([]);
199+
expect(execAsync).toHaveBeenCalledTimes(1);
200+
});
201+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { DokployImageInfo } from "@dokploy/server/index";
2+
import { describe, expect, it } from "vitest";
3+
import { planDokployImageCleanup } from "@/components/dashboard/settings/web-server/dokploy-image-plan";
4+
5+
const image = (
6+
fork: string,
7+
overrides: Partial<DokployImageInfo> = {},
8+
): DokployImageInfo => ({
9+
id: `sha256:${fork}`,
10+
tags: [],
11+
createdAt: "2026-10-07T10:00:00Z",
12+
sizeBytes: 4_450_000_000,
13+
uniqueSizeBytes: 4_100_000_000,
14+
forkVersion: fork,
15+
officialVersion: "v0.30.6",
16+
isCurrent: false,
17+
dokployTaskContainers: 0,
18+
otherContainers: 0,
19+
...overrides,
20+
});
21+
22+
const actions = (plan: ReturnType<typeof planDokployImageCleanup>) =>
23+
plan.rows.map((row) => `${row.version}:${row.action}`);
24+
25+
describe("planDokployImageCleanup", () => {
26+
const images = [
27+
image("v4", { isCurrent: true }),
28+
image("v3", { dokployTaskContainers: 2 }),
29+
image("v2", { dokployTaskContainers: 1 }),
30+
image("v1"),
31+
];
32+
33+
it("keeps everything when the cleanup is off", () => {
34+
const plan = planDokployImageCleanup(images, null, "v5");
35+
expect(actions(plan)).toEqual([
36+
"v5:new",
37+
"v4:keep",
38+
"v3:keep",
39+
"v2:keep",
40+
"v1:keep",
41+
]);
42+
expect(plan.removeCount).toBe(0);
43+
});
44+
45+
it("counts the pending build as one of the kept images", () => {
46+
const plan = planDokployImageCleanup(images, 3, "v5");
47+
expect(actions(plan)).toEqual([
48+
"v5:new",
49+
"v4:keep",
50+
"v3:keep",
51+
"v2:remove",
52+
"v1:remove",
53+
]);
54+
expect(plan.freedBytes).toBe(8_200_000_000);
55+
expect(plan.removedContainers).toBe(1);
56+
});
57+
58+
it("does not add a pending row when the new build is already pulled", () => {
59+
const plan = planDokployImageCleanup(images, 3, "v4");
60+
expect(actions(plan)).toEqual([
61+
"v4:keep",
62+
"v3:keep",
63+
"v2:keep",
64+
"v1:remove",
65+
]);
66+
});
67+
68+
it("never plans to delete images that other containers or the running server use", () => {
69+
const plan = planDokployImageCleanup(
70+
[
71+
image("v3"),
72+
image("v2", { otherContainers: 1 }),
73+
image("v1", { isCurrent: true }),
74+
],
75+
1,
76+
null,
77+
);
78+
expect(actions(plan)).toEqual(["v3:keep", "v2:blocked", "v1:blocked"]);
79+
expect(plan.removeCount).toBe(0);
80+
});
81+
});

‎apps/dokploy/__test__/settings/request-log-authorization.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ const mocks = vi.hoisted(() => ({
9090
vi.mock("@dokploy/server", () => ({
9191
CLEANUP_CRON_JOB: "0 0 * * *",
9292
DEFAULT_UPDATE_DATA: {},
93+
DOKPLOY_KEEP_IMAGES_ENV: "DOKPLOY_KEEP_IMAGES",
94+
DOKPLOY_KEEP_IMAGES_MAX: 5,
95+
DOKPLOY_KEEP_IMAGES_MIN: 3,
96+
getDokployImageKeepCount: vi.fn(),
97+
getDokployImages: vi.fn(),
9398
IS_CLOUD: false,
9499
checkGPUStatus: mocks.checkGPUStatus,
95100
checkPortInUse: mocks.checkPortInUse,

0 commit comments

Comments
 (0)