448258c5b4
Adds collect_swarm(socket_path) to the host agent. Self-detects a Swarm
manager via /info (Swarm.ControlAvailable) — workers and non-swarm daemons
return None and never touch the manager-only endpoints (one cheap /info call,
no 503s). On a manager it queries /services, /tasks, /nodes and emits
sample["swarm"] = {services, nodes}:
* services roll desired-vs-running replicas up from the task list (replica
health isn't on the service object), handle replicated + global mode, strip
the @sha256 image digest, and carry cross-node task→node placement.
* nodes normalise role / availability / status + the manager leader flag.
build_sample omits the swarm key entirely off managers, same silent contract
as collect_docker. Unit tests cover manager detection, replica roll-up +
placement (replicated & global), node normalisation, worker silent-skip, and
build_sample wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
340 lines
15 KiB
Python
340 lines
15 KiB
Python
# 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)
|
||
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
|
||
|
||
|
||
# ── 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"
|