feat(docker): collect + persist /system/df image/disk usage (agent 1.6.0)
Backend for the image/disk panel (milestone 77 #942). Agent gains collect_disk_usage() — one /system/df call (gated on containers existing, so Docker-less hosts pay nothing), surfacing reclaimable bytes (image size held by unreferenced images), layers/containers/volumes/build-cache sizes, and the top 50 images by size. Emitted as sample["docker_disk"]; host_agent ingest tracks the newest sample's copy and hands it to the docker capability as a 5th arg. New current-state tables docker_disk_usage (one row/host) + docker_images (per-host image rows), docker_007 migration; ingest upserts the summary and replaces the image set per host (stale images pruned). Unit tests for the df parsing/reclaimable math + build_sample gating; integration test for persistence + image-set replacement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
@@ -20,7 +20,7 @@ from collections import deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
|
||||
AGENT_VERSION = "1.5.0"
|
||||
AGENT_VERSION = "1.6.0"
|
||||
|
||||
# Default path to the local Docker Engine socket. Overridable via the
|
||||
# `docker_socket` config key; collection is silently skipped if it's absent or
|
||||
@@ -761,6 +761,74 @@ def collect_swarm(socket_path: str):
|
||||
}
|
||||
|
||||
|
||||
# ─── disk usage (image storage) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _disk_images(images: list) -> list:
|
||||
"""Normalise /system/df image rows → compact per-image storage records.
|
||||
|
||||
Sorted largest-first and capped so a host with a huge image cache doesn't
|
||||
bloat the payload; the panel only needs the heavy hitters. `containers` is
|
||||
how many containers reference the image (0 ⇒ reclaimable).
|
||||
"""
|
||||
out = []
|
||||
for im in images:
|
||||
if not isinstance(im, dict):
|
||||
continue
|
||||
tags = im.get("RepoTags") or []
|
||||
# Untagged/dangling images report ["<none>:<none>"] or null.
|
||||
tag = next((t for t in tags if t and t != "<none>:<none>"), None)
|
||||
raw_id = (im.get("Id") or "").split(":", 1)[-1]
|
||||
out.append({
|
||||
"image_id": raw_id[:12],
|
||||
"repo_tag": tag or "<none>",
|
||||
"size": int(im.get("Size") or 0),
|
||||
"shared_size": int(im.get("SharedSize") or 0) if im.get("SharedSize", -1) >= 0 else 0,
|
||||
"containers": int(im.get("Containers") or 0) if (im.get("Containers") or 0) > 0 else 0,
|
||||
})
|
||||
out.sort(key=lambda r: r["size"], reverse=True)
|
||||
return out[:50]
|
||||
|
||||
|
||||
def collect_disk_usage(socket_path: str):
|
||||
"""Docker disk usage from `/system/df`, or None if unavailable.
|
||||
|
||||
One bounded API call (the daemon walks images/layers/volumes). Reclaimable =
|
||||
bytes held by images no container references — the same intent as
|
||||
`docker system df`'s RECLAIMABLE column. Silent-skip (None) on any transport
|
||||
failure, same contract as collect_docker/collect_swarm.
|
||||
"""
|
||||
try:
|
||||
df = _docker_request(socket_path, "/system/df")
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(df, dict):
|
||||
return None
|
||||
|
||||
images = [im for im in (df.get("Images") or []) if isinstance(im, dict)]
|
||||
containers = [c for c in (df.get("Containers") or []) if isinstance(c, dict)]
|
||||
volumes = [v for v in (df.get("Volumes") or []) if isinstance(v, dict)]
|
||||
build_cache = [b for b in (df.get("BuildCache") or []) if isinstance(b, dict)]
|
||||
|
||||
def _vol_size(v):
|
||||
return int((v.get("UsageData") or {}).get("Size") or 0)
|
||||
|
||||
return {
|
||||
"layers_size": int(df.get("LayersSize") or 0),
|
||||
"images_total": len(images),
|
||||
"images_active": sum(1 for im in images if (im.get("Containers") or 0) > 0),
|
||||
"images_size": sum(int(im.get("Size") or 0) for im in images),
|
||||
"images_reclaimable": sum(
|
||||
int(im.get("Size") or 0) for im in images if (im.get("Containers") or 0) <= 0),
|
||||
"containers_count": len(containers),
|
||||
"containers_size": sum(int(c.get("SizeRw") or 0) for c in containers),
|
||||
"volumes_count": len(volumes),
|
||||
"volumes_size": sum(_vol_size(v) for v in volumes if _vol_size(v) > 0),
|
||||
"build_cache_size": sum(int(b.get("Size") or 0) for b in build_cache),
|
||||
"images": _disk_images(images),
|
||||
}
|
||||
|
||||
|
||||
# ─── ring buffer ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -861,6 +929,17 @@ def build_sample(mounts: list[str], state: dict,
|
||||
if swarm is not None:
|
||||
sample["swarm"] = swarm
|
||||
|
||||
# Image/disk usage — host-level docker fact (one /system/df call), omitted on
|
||||
# Docker-less hosts. Only meaningful when there's docker here at all, so skip
|
||||
# the call entirely when collect_docker found nothing.
|
||||
if docker:
|
||||
try:
|
||||
disk = collect_disk_usage(docker_socket)
|
||||
except Exception:
|
||||
disk = None
|
||||
if disk is not None:
|
||||
sample["docker_disk"] = disk
|
||||
|
||||
return sample
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user