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
106 changes: 105 additions & 1 deletion apps/dokploy/__test__/monitoring/docker-disk-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ describe("parseDockerDiskUsageVerbose", () => {
{
id: "274333ea2993",
image: "2e7598a736d0",
imageId: "sha256:2e7598a736d0full",
name: "dokploy.1.test",
},
],
Expand Down Expand Up @@ -152,7 +153,7 @@ describe("parseDockerDiskUsageVerbose", () => {
expect(enriched.images[0]).toEqual(
expect.objectContaining({
name: "<none>:<none>",
subtitle: "Dangling local image",
subtitle: "Untagged local image",
}),
);
expect(enriched.images[0]?.meta).toEqual(
Expand Down Expand Up @@ -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",
"<none> <none> d0ef856968db 2 hours ago 2.45GB 74.87MB 2.373GB 1",
"<none> <none> 3a02d38405dc 5 months ago 114MB 0B 114MB 2",
"<none> <none> 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", () => {
Expand Down
138 changes: 108 additions & 30 deletions packages/server/src/utils/docker/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,9 @@ const sortDiskUsageDetails = (items: DockerDiskUsageDetailItem[]) =>
interface DockerDiskUsageContainerReference {
id: string;
image: string;
imageId: string;
name: string;
state?: string;
}

interface DockerImageInspect {
Expand Down Expand Up @@ -410,23 +412,59 @@ const parseJsonArray = <T>(stdout: string): T[] => {

const isDanglingImageName = (name: string) => name === "<none>:<none>";

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;

Expand All @@ -452,18 +490,29 @@ export const enrichDockerDiskUsageDetails = (
inspect?.RepoTags?.filter((tag: string) => tag !== "<none>:<none>") ??
[];
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
Expand All @@ -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,
};
}),
Expand Down Expand Up @@ -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,
};
});
};
Expand Down