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:
@@ -295,6 +295,7 @@ def test_collect_swarm_silent_skip_when_no_socket():
|
||||
def test_build_sample_includes_docker_when_present(monkeypatch):
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: [{"name": "web", "status": "running"}])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
|
||||
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||
assert sample["docker"] == [{"name": "web", "status": "running"}]
|
||||
|
||||
@@ -321,6 +322,69 @@ def test_build_sample_omits_swarm_off_manager(monkeypatch):
|
||||
assert "swarm" not in sample
|
||||
|
||||
|
||||
# ── disk usage (/system/df) ─────────────────────────────────────────────────
|
||||
|
||||
def test_disk_images_normalises_and_caps():
|
||||
images = [
|
||||
{"Id": "sha256:aaaaaaaaaaaa1111", "RepoTags": ["nginx:latest"],
|
||||
"Size": 200, "SharedSize": 50, "Containers": 2},
|
||||
{"Id": "sha256:bbbbbbbbbbbb2222", "RepoTags": ["<none>:<none>"],
|
||||
"Size": 500, "SharedSize": -1, "Containers": 0},
|
||||
]
|
||||
out = a._disk_images(images)
|
||||
# Sorted largest-first; dangling tag normalised; short id; SharedSize<0 → 0.
|
||||
assert out[0]["repo_tag"] == "<none>" and out[0]["size"] == 500
|
||||
assert out[0]["shared_size"] == 0 and out[0]["containers"] == 0
|
||||
assert out[1]["repo_tag"] == "nginx:latest" and out[1]["image_id"] == "aaaaaaaaaaaa"
|
||||
|
||||
|
||||
def test_collect_disk_usage_computes_reclaimable(monkeypatch):
|
||||
df = {
|
||||
"LayersSize": 1000,
|
||||
"Images": [
|
||||
{"Id": "sha256:a", "RepoTags": ["app:1"], "Size": 300, "Containers": 1},
|
||||
{"Id": "sha256:b", "RepoTags": None, "Size": 700, "Containers": 0},
|
||||
],
|
||||
"Containers": [{"Id": "c1", "SizeRw": 25}, {"Id": "c2", "SizeRw": 75}],
|
||||
"Volumes": [{"Name": "v1", "UsageData": {"Size": 40}},
|
||||
{"Name": "v2", "UsageData": {"Size": -1}}],
|
||||
"BuildCache": [{"Size": 10}, {"Size": 5}],
|
||||
}
|
||||
monkeypatch.setattr(a, "_docker_request",
|
||||
lambda s, p, timeout=a.DOCKER_API_TIMEOUT: df)
|
||||
out = a.collect_disk_usage("/var/run/docker.sock")
|
||||
assert out["layers_size"] == 1000
|
||||
assert out["images_total"] == 2 and out["images_active"] == 1
|
||||
assert out["images_size"] == 1000
|
||||
assert out["images_reclaimable"] == 700 # only the unreferenced image
|
||||
assert out["containers_count"] == 2 and out["containers_size"] == 100
|
||||
assert out["volumes_count"] == 2 and out["volumes_size"] == 40 # negative skipped
|
||||
assert out["build_cache_size"] == 15
|
||||
assert len(out["images"]) == 2
|
||||
|
||||
|
||||
def test_collect_disk_usage_silent_skip_when_no_socket():
|
||||
assert a.collect_disk_usage("/nonexistent/does-not-exist.sock") is None
|
||||
|
||||
|
||||
def test_build_sample_includes_disk_when_docker_present(monkeypatch):
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: [{"name": "web", "status": "running"}])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: {"images_reclaimable": 700})
|
||||
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||
assert sample["docker_disk"] == {"images_reclaimable": 700}
|
||||
|
||||
|
||||
def test_build_sample_omits_disk_when_no_docker(monkeypatch):
|
||||
# No containers → the /system/df call is skipped entirely (cost guard). The
|
||||
# sentinel would land under docker_disk if the gate ever called it.
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: [])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: {"sentinel": True})
|
||||
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||
assert "docker_disk" not in sample
|
||||
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_read_config_defaults_docker_socket(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user