diff --git a/apps/dokploy/__test__/monitoring/docker-disk-usage.test.ts b/apps/dokploy/__test__/monitoring/docker-disk-usage.test.ts index 157c7b3cb26..3630aa9e37a 100644 --- a/apps/dokploy/__test__/monitoring/docker-disk-usage.test.ts +++ b/apps/dokploy/__test__/monitoring/docker-disk-usage.test.ts @@ -124,6 +124,7 @@ describe("parseDockerDiskUsageVerbose", () => { { id: "274333ea2993", image: "2e7598a736d0", + imageId: "sha256:2e7598a736d0full", name: "dokploy.1.test", }, ], @@ -152,7 +153,7 @@ describe("parseDockerDiskUsageVerbose", () => { expect(enriched.images[0]).toEqual( expect.objectContaining({ name: ":", - subtitle: "Dangling local image", + subtitle: "Untagged local image", }), ); expect(enriched.images[0]?.meta).toEqual( @@ -184,6 +185,109 @@ describe("parseDockerDiskUsageVerbose", () => { ]), ); }); + + test("names untagged images after the references that created them", () => { + const details = parseDockerDiskUsageVerbose( + [ + "Images space usage:", + "", + "REPOSITORY TAG IMAGE ID CREATED SIZE SHARED SIZE UNIQUE SIZE CONTAINERS", + " d0ef856968db 2 hours ago 2.45GB 74.87MB 2.373GB 1", + " 3a02d38405dc 5 months ago 114MB 0B 114MB 2", + " b1c5c0aec2ab 9 hours ago 1.42GB 1.416GB 0B 0", + ].join("\n"), + ); + const redisDigest = + "sha256:09160599abd229764c0fb44cb6be640294e1d360a54b19985ab4843dcf2d90f1"; + + const enriched = enrichDockerDiskUsageDetails(details, { + containers: [ + { + id: "old-panel-task", + image: "ghcr.io/example/panel:dev", + imageId: "sha256:d0ef856968dbfull", + name: "panel.1.old", + state: "exited", + }, + { + id: "redis-a", + image: `redis:8-alpine@${redisDigest}`, + imageId: "sha256:3a02d38405dcfull", + name: "stack-redis-1", + state: "running", + }, + { + id: "redis-b", + image: `redis:8-alpine@${redisDigest}`, + imageId: "sha256:3a02d38405dcfull", + name: "stack-redis-rate-limit-1", + state: "running", + }, + ], + imageInspects: new Map([ + [ + "d0ef856968db", + { + Id: "sha256:d0ef856968dbfull", + RepoDigests: ["ghcr.io/example/panel@sha256:08556bfbd114b366"], + RepoTags: [], + }, + ], + [ + "3a02d38405dc", + { + Id: "sha256:3a02d38405dcfull", + RepoDigests: [`redis@${redisDigest}`], + RepoTags: [], + }, + ], + [ + "b1c5c0aec2ab", + { + Id: "sha256:b1c5c0aec2abfull", + RepoDigests: [ + "registry.example.com:5000/team/grafana@sha256:b01e2b61edddaf1f8ed9", + ], + RepoTags: [], + }, + ], + ]), + }); + const byId = new Map(enriched.images.map((image) => [image.id, image])); + + expect(byId.get("d0ef856968db")).toEqual( + expect.objectContaining({ + name: "ghcr.io/example/panel:dev", + subtitle: "Previous version: the tag now points to another image", + }), + ); + expect(byId.get("d0ef856968db")?.meta).toEqual( + expect.arrayContaining([ + { label: "Source", value: "ghcr.io/example/panel:dev" }, + { label: "Used by", value: "panel.1.old (exited)" }, + ]), + ); + expect(byId.get("3a02d38405dc")).toEqual( + expect.objectContaining({ + name: "redis:8-alpine", + subtitle: "Pulled by digest, no local tag", + }), + ); + expect(byId.get("3a02d38405dc")?.meta).toEqual( + expect.arrayContaining([ + { + label: "Used by", + value: "stack-redis-1, stack-redis-rate-limit-1", + }, + ]), + ); + expect(byId.get("b1c5c0aec2ab")).toEqual( + expect.objectContaining({ + name: "registry.example.com:5000/team/grafana@sha256:b01e2b61eddd", + subtitle: "Dangling local image", + }), + ); + }); }); describe("limitDockerDiskUsageDetails", () => { diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index c6f5874a3e5..e6c1fe02e1d 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -362,7 +362,9 @@ const sortDiskUsageDetails = (items: DockerDiskUsageDetailItem[]) => interface DockerDiskUsageContainerReference { id: string; image: string; + imageId: string; name: string; + state?: string; } interface DockerImageInspect { @@ -410,23 +412,59 @@ const parseJsonArray = (stdout: string): T[] => { const isDanglingImageName = (name: string) => name === ":"; -const isSameImageReference = ( - containerImage: string, - detail: DockerDiskUsageDetailItem, - inspect?: DockerImageInspect, -) => { - const fullId = inspect?.Id ?? ""; - const shortId = detail.id; +const isImageIdReference = (reference: string) => + /^(sha256:)?[a-f0-9]{12,64}$/.test(reference); - return ( - containerImage === detail.name || - containerImage === shortId || - containerImage.startsWith(shortId) || - (Boolean(fullId) && containerImage === fullId) || - (Boolean(fullId) && fullId.startsWith(`sha256:${containerImage}`)) +const splitImageReference = (reference: string) => { + const [repositoryAndTag = reference, digest] = reference.split("@"); + const lastPathSegment = repositoryAndTag.slice( + repositoryAndTag.lastIndexOf("/") + 1, ); + return { + digest, + hasTag: lastPathSegment.includes(":"), + repositoryAndTag, + }; }; +const getImageReferenceName = (reference: string) => { + const { digest, hasTag, repositoryAndTag } = splitImageReference(reference); + if (hasTag || !digest) { + return repositoryAndTag; + } + return `${repositoryAndTag}@${digest.slice(0, "sha256:".length + 12)}`; +}; + +// Docker drops the tag from an image once the tag moves to a newer pull or +// build, and never stores one for an image pulled by digest; the reference a +// container was created from still says what the image is. +const describeUntaggedImage = (references: string[], usedBy: string[]) => { + const hasMovedTag = references.some((reference) => { + const { digest, hasTag } = splitImageReference(reference); + return hasTag && !digest; + }); + if (hasMovedTag) { + return "Previous version: the tag now points to another image"; + } + if (references.length > 0) { + return "Pulled by digest, no local tag"; + } + return usedBy.length > 0 ? "Untagged local image" : "Dangling local image"; +}; + +const isContainerOfImage = ( + container: DockerDiskUsageContainerReference, + detail: DockerDiskUsageDetailItem, + inspect?: DockerImageInspect, +) => + container.imageId === inspect?.Id || + container.imageId.startsWith(`sha256:${detail.id}`); + +const formatContainerName = (container: DockerDiskUsageContainerReference) => + container.state && container.state !== "running" + ? `${container.name} (${container.state})` + : container.name; + const formatListValue = (items: string[], emptyValue = "--") => items.length > 0 ? items.join(", ") : emptyValue; @@ -452,18 +490,29 @@ export const enrichDockerDiskUsageDetails = ( inspect?.RepoTags?.filter((tag: string) => tag !== ":") ?? []; const repoDigests = inspect?.RepoDigests ?? []; - const usedBy = containers - .filter((container) => - isSameImageReference(container.image, detail, inspect), - ) - .map((container) => container.name); + const imageContainers = containers + .filter((container) => isContainerOfImage(container, detail, inspect)) + .sort( + (a, b) => + Number(b.state === "running") - Number(a.state === "running"), + ); + const usedBy = imageContainers.map(formatContainerName); + const isUntagged = isDanglingImageName(detail.name); + const references = [ + ...new Set( + imageContainers + .map((container) => container.image) + .filter((reference) => !isImageIdReference(reference)), + ), + ]; + const nameReference = references[0] ?? repoDigests[0]; const imagePath = getImagePathFromInspect(inspect); const extraMeta = [ { label: "Full image id", value: fullId }, { label: "Source", - value: isDanglingImageName(detail.name) - ? "dangling local image" + value: isUntagged + ? formatListValue(references, "dangling local image") : detail.name, }, ...(repoTags.length > 0 @@ -487,8 +536,12 @@ export const enrichDockerDiskUsageDetails = ( (meta) => meta.label !== "Containers" || usedBy.length === 0, ), ], - subtitle: isDanglingImageName(detail.name) - ? "Dangling local image" + name: + isUntagged && nameReference + ? getImageReferenceName(nameReference) + : detail.name, + subtitle: isUntagged + ? describeUntaggedImage(references, usedBy) : detail.subtitle, }; }), @@ -688,26 +741,51 @@ export const limitDockerDiskUsageDetails = ( volumes: limitDiskUsageDetailItems(details.volumes, detailLimit), }); +// docker ps has no image id and prints a bare id instead of the reference once +// the tag has moved on, so only inspect ties a container to an untagged image. +const dockerContainerReferenceFormat = + '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"imageId":{{json .Image}},"state":{{json .State.Status}}}'; + const getDockerDiskUsageContainerReferences = async (): Promise< DockerDiskUsageContainerReference[] > => { + const { stdout: idsStdout } = await execAsync("docker ps -aq --no-trunc"); + const containerIds = idsStdout + .split("\n") + .map((containerId) => containerId.trim()) + .filter(Boolean); + if (containerIds.length === 0) { + return []; + } + const { stdout } = await execAsync( - "docker ps -a --no-trunc --format '{{json .}}'", + `docker container inspect --format ${quoteShellArg( + dockerContainerReferenceFormat, + )} ${containerIds.map((containerId) => quoteShellArg(containerId)).join(" ")}`, ); return parseJsonLines<{ - ID?: string; - Image?: string; - Names?: string; + id?: string; + image?: string; + imageId?: string; + name?: string; + state?: string; }>(stdout).flatMap((container) => { - if (!container.ID || !container.Image || !container.Names) { + if ( + !container.id || + !container.image || + !container.imageId || + !container.name + ) { return []; } return { - id: container.ID, - image: container.Image, - name: container.Names, + id: container.id, + image: container.image, + imageId: container.imageId, + name: container.name.replace(/^\//, ""), + state: container.state, }; }); };