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:
@@ -230,6 +230,60 @@ def test_swarm_topology_persisted(app):
|
||||
assert node[0] == "manager" and node[2] == "ready" and node[3] is True
|
||||
|
||||
|
||||
@_NEEDS_DB
|
||||
def test_disk_usage_persisted(app):
|
||||
"""persist_host_docker stores the /system/df summary + image rows, and
|
||||
replaces the image set on the next report (stale images pruned)."""
|
||||
from sqlalchemy import text
|
||||
from steward.models.hosts import Host
|
||||
|
||||
persist = _persist_fn(app)
|
||||
disk1 = {
|
||||
"layers_size": 1000, "images_total": 2, "images_active": 1,
|
||||
"images_size": 1000, "images_reclaimable": 700,
|
||||
"containers_count": 2, "containers_size": 100,
|
||||
"volumes_count": 1, "volumes_size": 40, "build_cache_size": 15,
|
||||
"images": [
|
||||
{"image_id": "aaaaaaaaaaaa", "repo_tag": "app:1", "size": 300,
|
||||
"shared_size": 50, "containers": 1},
|
||||
{"image_id": "bbbbbbbbbbbb", "repo_tag": "<none>", "size": 700,
|
||||
"shared_size": 0, "containers": 0},
|
||||
],
|
||||
}
|
||||
disk2 = {**disk1, "images_reclaimable": 0, "images": [disk1["images"][0]]}
|
||||
|
||||
async def _go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(text("DELETE FROM docker_images"))
|
||||
await s.execute(text("DELETE FROM docker_disk_usage"))
|
||||
h = Host(id=str(uuid.uuid4()), name="dsk", address="10.6.6.6")
|
||||
s.add(h)
|
||||
await s.flush()
|
||||
hid = h.id
|
||||
await persist(s, h, [], None, disk1)
|
||||
summary = (await s.execute(text(
|
||||
"SELECT images_reclaimable, images_total, build_cache_size "
|
||||
"FROM docker_disk_usage WHERE host_id=:h"), {"h": hid})).first()
|
||||
img_count = (await s.execute(text(
|
||||
"SELECT COUNT(*) FROM docker_images WHERE host_id=:h"), {"h": hid})).scalar()
|
||||
# Re-report with one image dropped — the stale row must be pruned.
|
||||
async with s.begin():
|
||||
await persist(s, await s.get(Host, hid), [], None, disk2)
|
||||
after = (await s.execute(text(
|
||||
"SELECT image_id FROM docker_images WHERE host_id=:h"), {"h": hid})).all()
|
||||
reclaimable2 = (await s.execute(text(
|
||||
"SELECT images_reclaimable FROM docker_disk_usage WHERE host_id=:h"),
|
||||
{"h": hid})).scalar()
|
||||
return summary, img_count, [r[0] for r in after], reclaimable2
|
||||
|
||||
summary, img_count, after_ids, reclaimable2 = asyncio.run(_go())
|
||||
assert summary[0] == 700 and summary[1] == 2 and summary[2] == 15
|
||||
assert img_count == 2
|
||||
assert after_ids == ["aaaaaaaaaaaa"] # the unreferenced image pruned on re-report
|
||||
assert reclaimable2 == 0 # summary upserted in place
|
||||
|
||||
|
||||
@_NEEDS_DB
|
||||
def test_retention_rollup_and_prune(app):
|
||||
"""Old raw metrics roll up to hourly averages then delete; stale rollup +
|
||||
|
||||
@@ -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