feat(docker): collect + persist /system/df image/disk usage (agent 1.6.0)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 47s
CI / integration (push) Failing after 2m23s
CI / publish (push) Has been skipped

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:
2026-06-18 21:51:14 -04:00
parent faecac3ec6
commit a840d6f823
7 changed files with 367 additions and 3 deletions
+80 -1
View File
@@ -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