Files
FabledSteward/tests/plugins/host_agent/test_agent_docker.py
T
bvandeusen a840d6f823
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 47s
CI / integration (push) Failing after 2m23s
CI / publish (push) Has been skipped
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
2026-06-18 21:51:14 -04:00

404 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# tests/plugins/host_agent/test_agent_docker.py
"""Unit tests for the agent's stdlib Docker collector.
No socket / no network — the UDS request layer is monkeypatched so we exercise
the cpu/mem math, chunked-body decoding, sample assembly, and the silent-skip
contract in isolation.
"""
from plugins.host_agent import agent as a
# ── chunked transfer decoding ─────────────────────────────────────────────────
def test_dechunk_reassembles_body():
chunked = b"4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n"
assert a._dechunk(chunked) == b"Wikipedia"
def test_dechunk_ignores_chunk_extensions():
assert a._dechunk(b"3;name=v\r\nabc\r\n0\r\n\r\n") == b"abc"
# ── cpu / mem math (ported from the old central scraper) ──────────────────────
def test_docker_cpu_pct():
stats = {
"cpu_stats": {
"cpu_usage": {"total_usage": 200_000_000},
"system_cpu_usage": 2_000_000_000,
"online_cpus": 4,
},
"precpu_stats": {
"cpu_usage": {"total_usage": 100_000_000},
"system_cpu_usage": 1_000_000_000,
},
}
# cpu_delta=1e8, sys_delta=1e9 → 0.1 * 4 cpus * 100 = 40.0%
assert a._docker_cpu_pct(stats) == 40.0
def test_docker_cpu_pct_guards_bad_stats():
assert a._docker_cpu_pct({}) == 0.0
# No forward progress in system time → 0, never a divide error.
assert a._docker_cpu_pct({
"cpu_stats": {"cpu_usage": {"total_usage": 5}, "system_cpu_usage": 10},
"precpu_stats": {"cpu_usage": {"total_usage": 5}, "system_cpu_usage": 10},
}) == 0.0
def test_docker_mem_subtracts_cache():
stats = {"memory_stats": {
"usage": 200 * 1024 * 1024,
"limit": 1000 * 1024 * 1024,
"stats": {"cache": 50 * 1024 * 1024},
}}
usage, limit, pct = a._docker_mem(stats)
assert usage == 150 * 1024 * 1024 # working set = usage cache
assert limit == 1000 * 1024 * 1024
assert pct == 15.0
def test_docker_ports_keeps_only_published():
ports = [
{"PublicPort": 8080, "PrivatePort": 80, "Type": "tcp"},
{"PrivatePort": 5432, "Type": "tcp"}, # unpublished → dropped
]
assert a._docker_ports(ports) == [
{"host_port": 8080, "container_port": 80, "protocol": "tcp"},
]
# ── enrichment helpers ────────────────────────────────────────────────────────
def test_docker_grouping_from_labels():
g = a._docker_grouping({
"com.docker.compose.project": "myproj",
"com.docker.swarm.service.name": "web",
"com.docker.swarm.task.id": "t123",
"com.docker.swarm.node.id": "n456",
})
assert g == {"compose_project": "myproj", "service_name": "web",
"task_id": "t123", "node_id": "n456"}
def test_docker_grouping_empty():
assert a._docker_grouping(None) == {
"compose_project": None, "service_name": None, "task_id": None, "node_id": None}
def test_docker_inspect_fields():
insp = {"RestartCount": 3,
"State": {"Health": {"Status": "unhealthy"}, "ExitCode": 137, "OOMKilled": True}}
assert a._docker_inspect_fields(insp) == ("unhealthy", 3, 137, True)
def test_docker_inspect_fields_no_healthcheck():
# Containers without a HEALTHCHECK have no State.Health → health is None.
health, restarts, exit_code, oom = a._docker_inspect_fields(
{"RestartCount": 0, "State": {"ExitCode": 0, "OOMKilled": False}})
assert health is None and restarts == 0 and exit_code == 0 and oom is False
def test_docker_net_io_sums_counters():
stats = {
"networks": {"eth0": {"rx_bytes": 1000, "tx_bytes": 2000},
"eth1": {"rx_bytes": 5, "tx_bytes": 7}},
"blkio_stats": {"io_service_bytes_recursive": [
{"op": "Read", "value": 4096}, {"op": "Write", "value": 8192}]},
}
assert a._docker_net_io(stats) == (1005, 2007, 4096, 8192)
# ── collect_docker assembly + silent skip ─────────────────────────────────────
def test_collect_docker_silent_skip_when_no_socket():
# Absent socket path must degrade to [] (zero-config on Docker-less hosts).
assert a.collect_docker("/nonexistent/does-not-exist.sock") == []
def test_collect_docker_assembles_container(monkeypatch):
container = {
"Id": "abc123def4567890",
"Names": ["/web"],
"Image": "nginx:latest",
"State": "running",
"Created": 1_700_000_000,
"Ports": [{"PublicPort": 8080, "PrivatePort": 80, "Type": "tcp"}],
"Labels": {"com.docker.compose.project": "myproj",
"com.docker.swarm.service.name": "web"},
}
inspect = {"RestartCount": 2,
"State": {"Health": {"Status": "healthy"}, "ExitCode": 0, "OOMKilled": False}}
stats = {
"cpu_stats": {"cpu_usage": {"total_usage": 200_000_000},
"system_cpu_usage": 2_000_000_000, "online_cpus": 4},
"precpu_stats": {"cpu_usage": {"total_usage": 100_000_000},
"system_cpu_usage": 1_000_000_000},
"memory_stats": {"usage": 200 * 1024 * 1024, "limit": 1000 * 1024 * 1024,
"stats": {"cache": 50 * 1024 * 1024}},
"networks": {"eth0": {"rx_bytes": 1000, "tx_bytes": 2000}},
"blkio_stats": {"io_service_bytes_recursive": [
{"op": "Read", "value": 4096}, {"op": "Write", "value": 8192}]},
}
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
if path.startswith("/containers/json"):
return [container]
if "/stats" in path:
return stats
if path.endswith("/json"): # inspect
return inspect
raise AssertionError(f"unexpected path {path}")
monkeypatch.setattr(a, "_docker_request", fake_request)
out = a.collect_docker("/var/run/docker.sock")
assert len(out) == 1
c = out[0]
assert c["name"] == "web"
assert c["container_id"] == "abc123def456" # 12-char short id
assert c["image"] == "nginx:latest"
assert c["status"] == "running"
assert c["cpu_pct"] == 40.0
assert c["mem_pct"] == 15.0
assert c["ports"] == [{"host_port": 8080, "container_port": 80, "protocol": "tcp"}]
assert c["started_at"].startswith("2023-11-14T") # epoch → ISO-8601
# enrichment
assert c["health"] == "healthy" and c["restart_count"] == 2
assert c["exit_code"] == 0 and c["oom_killed"] is False
assert c["net_rx_bytes"] == 1000 and c["net_tx_bytes"] == 2000
assert c["blk_read_bytes"] == 4096 and c["blk_write_bytes"] == 8192
assert c["compose_project"] == "myproj" and c["service_name"] == "web"
def test_collect_docker_skips_stats_for_stopped(monkeypatch):
container = {"Id": "x" * 16, "Names": ["/db"], "Image": "pg", "State": "exited",
"Created": None, "Ports": [], "Labels": {}}
inspect = {"RestartCount": 0,
"State": {"ExitCode": 137, "OOMKilled": True}} # no Health block
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
if path.startswith("/containers/json"):
return [container]
if path.endswith("/json"): # inspect — fetched for stopped too (exit code)
return inspect
raise AssertionError("stats must not be fetched for a stopped container")
monkeypatch.setattr(a, "_docker_request", fake_request)
out = a.collect_docker("/var/run/docker.sock")
assert out[0]["status"] == "exited"
assert out[0]["cpu_pct"] is None and out[0]["started_at"] is None
# inspect still gives us why it died
assert out[0]["exit_code"] == 137 and out[0]["oom_killed"] is True
assert out[0]["health"] is None
# ── swarm (manager-only) ──────────────────────────────────────────────────────
def test_swarm_is_manager():
assert a._swarm_is_manager({"Swarm": {"ControlAvailable": True}}) is True
# Worker: in a swarm but no control plane.
assert a._swarm_is_manager({"Swarm": {"ControlAvailable": False}}) is False
# Non-swarm daemon: no Swarm block at all.
assert a._swarm_is_manager({}) is False
def test_swarm_services_rolls_up_replicas():
services = [
{"ID": "svc1", "Spec": {
"Name": "web", "Mode": {"Replicated": {"Replicas": 3}},
"TaskTemplate": {"ContainerSpec": {"Image": "nginx:1@sha256:deadbeef"}}}},
{"ID": "svc2", "Spec": {
"Name": "agent", "Mode": {"Global": {}},
"TaskTemplate": {"ContainerSpec": {"Image": "agent:latest"}}}},
]
tasks = [
# web: 2 of 3 desired replicas actually running, both on node n1
{"ServiceID": "svc1", "NodeID": "n1", "DesiredState": "running",
"Status": {"State": "running"}},
{"ServiceID": "svc1", "NodeID": "n1", "DesiredState": "running",
"Status": {"State": "running"}},
{"ServiceID": "svc1", "NodeID": "n2", "DesiredState": "running",
"Status": {"State": "failed"}},
# agent: global, 2 tasks desired+running across 2 nodes
{"ServiceID": "svc2", "NodeID": "n1", "DesiredState": "running",
"Status": {"State": "running"}},
{"ServiceID": "svc2", "NodeID": "n2", "DesiredState": "running",
"Status": {"State": "running"}},
]
out = {s["service_name"]: s for s in a._swarm_services(services, tasks)}
assert out["web"] == {"service_name": "web", "mode": "replicated",
"desired": 3, "running": 2, "image": "nginx:1",
"placement": [{"node_id": "n1", "running": 2}]}
# Global mode has no fixed replica count → desired derived from desired tasks.
assert out["agent"] == {"service_name": "agent", "mode": "global",
"desired": 2, "running": 2, "image": "agent:latest",
"placement": [{"node_id": "n1", "running": 1},
{"node_id": "n2", "running": 1}]}
def test_swarm_nodes_normalises_fields():
nodes = [
{"ID": "n1", "Description": {"Hostname": "mgr"},
"Spec": {"Role": "manager", "Availability": "active"},
"Status": {"State": "ready"}, "ManagerStatus": {"Leader": True}},
{"ID": "n2", "Description": {"Hostname": "wrk"},
"Spec": {"Role": "worker", "Availability": "drain"},
"Status": {"State": "down"}},
]
out = {n["node_id"]: n for n in a._swarm_nodes(nodes)}
assert out["n1"] == {"node_id": "n1", "hostname": "mgr", "role": "manager",
"availability": "active", "status": "ready", "leader": True}
assert out["n2"] == {"node_id": "n2", "hostname": "wrk", "role": "worker",
"availability": "drain", "status": "down", "leader": False}
def test_collect_swarm_skips_on_worker(monkeypatch):
# A worker reports ControlAvailable=False → None, and never hits /services.
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
if path == "/info":
return {"Swarm": {"ControlAvailable": False}}
raise AssertionError("manager-only endpoint queried on a worker")
monkeypatch.setattr(a, "_docker_request", fake_request)
assert a.collect_swarm("/var/run/docker.sock") is None
def test_collect_swarm_assembles_on_manager(monkeypatch):
services = [{"ID": "svc1", "Spec": {
"Name": "web", "Mode": {"Replicated": {"Replicas": 1}},
"TaskTemplate": {"ContainerSpec": {"Image": "nginx"}}}}]
tasks = [{"ServiceID": "svc1", "DesiredState": "running",
"Status": {"State": "running"}}]
nodes = [{"ID": "n1", "Description": {"Hostname": "mgr"},
"Spec": {"Role": "manager", "Availability": "active"},
"Status": {"State": "ready"}, "ManagerStatus": {"Leader": True}}]
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
return {
"/info": {"Swarm": {"ControlAvailable": True}},
"/services": services, "/tasks": tasks, "/nodes": nodes,
}[path]
monkeypatch.setattr(a, "_docker_request", fake_request)
out = a.collect_swarm("/var/run/docker.sock")
assert out["services"][0]["service_name"] == "web"
assert out["services"][0]["running"] == 1 and out["services"][0]["desired"] == 1
assert out["nodes"][0]["hostname"] == "mgr" and out["nodes"][0]["leader"] is True
def test_collect_swarm_silent_skip_when_no_socket():
assert a.collect_swarm("/nonexistent/does-not-exist.sock") is None
# ── build_sample wiring ───────────────────────────────────────────────────────
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"}]
def test_build_sample_omits_docker_when_empty(monkeypatch):
monkeypatch.setattr(a, "collect_docker", lambda _s: [])
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
assert "docker" not in sample
def test_build_sample_includes_swarm_on_manager(monkeypatch):
monkeypatch.setattr(a, "collect_docker", lambda _s: [])
monkeypatch.setattr(a, "collect_swarm",
lambda _s: {"services": [], "nodes": [{"node_id": "n1"}]})
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
assert sample["swarm"]["nodes"] == [{"node_id": "n1"}]
def test_build_sample_omits_swarm_off_manager(monkeypatch):
monkeypatch.setattr(a, "collect_docker", lambda _s: [])
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
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):
cfg_file = tmp_path / "agent.conf"
cfg_file.write_text("url = https://steward.example\ntoken = abc\n")
cfg = a.read_config(str(cfg_file))
assert cfg["docker_socket"] == a.DEFAULT_DOCKER_SOCKET
def test_read_config_honours_explicit_docker_socket(tmp_path):
cfg_file = tmp_path / "agent.conf"
cfg_file.write_text(
"url = https://steward.example\ntoken = abc\n"
"docker_socket = /run/user/1000/docker.sock\n")
cfg = a.read_config(str(cfg_file))
assert cfg["docker_socket"] == "/run/user/1000/docker.sock"