# plugins/docker/disk_view.py """What Docker keeps on each host's disk, over time and by image repository (#5304–#5306). Model-free, so it is unit-tested without a database. The agent reports `docker system df` as current state; ingest.py records its totals as metrics too (DISK_METRICS, resource = the host's name), so each host's growth has a history and an alert rule can watch it. """ from __future__ import annotations from datetime import datetime # metric name → the df summary field it records; "disk_total_bytes" is their sum. DISK_PARTS = { "disk_images_bytes": "images_size", "disk_containers_bytes": "containers_size", "disk_volumes_bytes": "volumes_size", "disk_build_cache_bytes": "build_cache_size", } DISK_METRICS = [*DISK_PARTS, "disk_total_bytes", "disk_reclaimable_bytes"] def disk_metrics(disk: dict) -> dict[str, float]: """The metric samples one df report gives: each part, their total, and what unused images hold.""" out = {m: float(int(disk.get(f) or 0)) for m, f in DISK_PARTS.items()} out["disk_total_bytes"] = sum(out.values()) out["disk_reclaimable_bytes"] = float(int(disk.get("images_reclaimable") or 0)) return out def growth(points: list[tuple[datetime, float]]) -> dict | None: """How much a total changed over the points given (oldest first): the change, the first point's time and value. None with fewer than two.""" if len(points) < 2: return None (first_at, first), (_, last) = points[0], points[-1] return {"change": last - first, "since": first_at, "from": first, "to": last, "pct": (last - first) / first * 100 if first else None} def image_repo(repo_tag: str | None) -> tuple[str | None, str | None]: """("ghcr.io/x/app", "1.2") for "ghcr.io/x/app:1.2"; a registry's port is part of the repository ("reg:5000/app" has no tag); a digest reference keeps its digest as the tag; (None, None) for an untagged image.""" if not repo_tag or repo_tag == "" or repo_tag.startswith(""): return None, None if "@" in repo_tag: repo, _, digest = repo_tag.partition("@") return repo, digest[:19] head, sep, tag = repo_tag.rpartition(":") if not sep or "/" in tag: return repo_tag, None return head, tag def group_images(images: list[dict]) -> list[dict]: """Images folded under their repository, the most space first. Each image: {repo_tag, image_id, size, shared_size, containers}. A group's `own` counts each version's size less the layers it shares with other images, so a repository is not charged twice for one base layer; `reclaimable` is that, for the versions no container uses. Untagged images form one group of their own (repo None).""" groups: dict[str | None, dict] = {} for im in images: repo, tag = image_repo(im.get("repo_tag")) g = groups.setdefault(repo, {"repo": repo, "versions": [], "own": 0, "size": 0, "reclaimable": 0, "containers": 0}) own = max(int(im.get("size") or 0) - int(im.get("shared_size") or 0), 0) used = int(im.get("containers") or 0) g["versions"].append({"tag": tag, "image_id": im.get("image_id"), "size": int(im.get("size") or 0), "shared": int(im.get("shared_size") or 0), "own": own, "containers": used}) g["own"] += own g["size"] = max(g["size"], int(im.get("size") or 0)) g["containers"] += used if not used: g["reclaimable"] += own for g in groups.values(): g["versions"].sort(key=lambda v: (v["containers"] == 0, -v["size"])) g["unused"] = sum(1 for v in g["versions"] if not v["containers"]) return sorted(groups.values(), key=lambda g: (g["repo"] is None, -g["own"], g["repo"] or ""))