Files
FabledSteward/tests/plugins/host_agent/test_agent_docker.py
T
bvandeusen 448258c5b4
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 44s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 1m6s
feat(docker): agent manager-only swarm collector (AGENT_VERSION 1.5.0)
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
2026-06-18 20:57:18 -04:00

340 lines
15 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)
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"