Files
FabledSteward/tests/plugins/host_agent/test_agent_docker.py
T
bvandeusenandClaude Opus 5.5 fdc5c07183
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m32s
CI / unit (push) Successful in 1m45s
CI / publish (push) Successful in 17s
feat(swarm): services short, nodes down and managers lost are alertable (#5788)
On 2026-10-14 nine swarm services sat at 0/1 and nothing fired. From each
manager's swarm report the docker plugin now records three figures on
resource "swarm": swarm_services_short, swarm_nodes_down and
swarm_managers_unreachable. Drained nodes are left out of the last two,
since a drain is deliberate. The figures are recorded at most once every
30 s whichever manager reported, and are offered to alert rules.

Agent 1.19.0 sends each manager's ManagerStatus.Reachability. Older agents
fall back to the node's status.

A maintenance job on a swarm node also keeps a window over docker/swarm,
so its drain and reboot hold those alerts until the job ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:23:52 -04:00

817 lines
37 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 what the agent
says when Docker cannot be asked, in isolation.
"""
import socket
import pytest
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_says_unavailable_when_no_socket():
# A Docker-less host needs no configuration: it reports there is no Docker.
assert a.collect_docker("/nonexistent/does-not-exist.sock") == (
"unavailable", "no Docker socket at /nonexistent/does-not-exist.sock", [])
@pytest.mark.parametrize("exc, status, detail", [
(FileNotFoundError(2, "x"), "unavailable", "no Docker socket at /s"),
(ConnectionRefusedError(111, "x"), "unavailable", "Docker is not accepting connections on /s"),
(PermissionError(13, "x"), "error", "permission denied on /s"),
(socket.timeout("timed out"), "error", "Docker did not answer within 5 s"),
(OSError("docker API /containers/json → 500"), "error", "docker API /containers/json → 500"),
(ValueError("Expecting value"), "error", "Expecting value"),
])
def test_collect_docker_says_why_it_could_not_list(monkeypatch, exc, status, detail):
def fail(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
raise exc
monkeypatch.setattr(a, "_docker_request", fail)
assert a.collect_docker("/s") == (status, detail, [])
def test_collect_docker_with_no_containers_is_ok_and_empty(monkeypatch):
monkeypatch.setattr(a, "_docker_request", lambda s, p, timeout=a.DOCKER_API_TIMEOUT: [])
assert a.collect_docker("/s") == ("ok", None, [])
def test_collect_docker_assembles_container(monkeypatch):
container = {
"Id": "abc123def4567890",
"Names": ["/web"],
"Image": "nginx:latest",
"State": "running",
"Created": 1_600_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,
"StartedAt": "2023-11-14T22:13:20.123456789Z"}}
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)
status, detail, out = a.collect_docker("/var/run/docker.sock")
assert (status, detail) == ("ok", None)
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"}]
# When it last started (inspect), not when it was created (2020).
assert c["started_at"] == "2023-11-14T22:13:20.123456+00:00"
# 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 [t["state"] for t in out["web"].pop("tasks")] == ["running", "running", "failed"]
assert len(out["agent"].pop("tasks")) == 2
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_task_keeps_why_it_stopped():
t = a._swarm_task({
"ID": "t1", "Slot": 1, "NodeID": "n2", "DesiredState": "Shutdown",
"Status": {"State": "failed", "Timestamp": "2026-10-06T13:19:51.5Z",
"Message": "started", "Err": "task: non-zero exit (1)" + "x" * 400,
"ContainerStatus": {"ContainerID": "c1", "ExitCode": 1}}})
assert t["task_id"] == "t1" and t["slot"] == 1 and t["node_id"] == "n2"
assert t["desired_state"] == "shutdown" and t["state"] == "failed" and t["exit_code"] == 1
assert t["at"] == "2026-10-06T13:19:51.5Z" and t["message"] == "started"
assert t["error"].startswith("task: non-zero exit (1)") and len(t["error"]) == 300
# A task that never got a container has no exit code.
bare = a._swarm_task({"ID": "t2", "DesiredState": "running",
"Status": {"State": "rejected", "Err": "No such image: x"}})
assert bare["exit_code"] is None and bare["slot"] is None and bare["error"] == "No such image: x"
def test_swarm_services_keeps_the_newest_tasks():
services = [{"ID": "s", "Spec": {"Name": "db", "Mode": {"Replicated": {"Replicas": 1}}}}]
tasks = [{"ID": f"t{i}", "ServiceID": "s", "Slot": 1, "DesiredState": "shutdown",
"Status": {"State": "failed", "Timestamp": f"2026-10-06T13:{i:02d}:00Z"}}
for i in range(a.SWARM_TASKS_PER_SERVICE + 5)]
(svc,) = a._swarm_services(services, tasks)
ids = [t["task_id"] for t in svc["tasks"]]
newest = a.SWARM_TASKS_PER_SERVICE + 4
assert len(ids) == a.SWARM_TASKS_PER_SERVICE and ids[0] == f"t{newest}"
def test_swarm_nodes_normalises_fields():
nodes = [
{"ID": "n1", "Description": {"Hostname": "mgr"},
"Spec": {"Role": "manager", "Availability": "active"},
"Status": {"State": "ready"}, "ManagerStatus": {"Leader": True, "Reachability": "Reachable"}},
{"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,
"reachability": "reachable"}
assert out["n2"] == {"node_id": "n2", "hostname": "wrk", "role": "worker",
"availability": "drain", "status": "down", "leader": False,
"reachability": ""}
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") == (False, 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)
is_manager, out = a.collect_swarm("/var/run/docker.sock")
assert is_manager is True
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_cannot_say_when_info_fails():
# Not knowing is not "no longer a manager": Steward must keep the rows.
assert a.collect_swarm("/nonexistent/does-not-exist.sock") == (None, None)
def test_collect_swarm_manager_whose_listing_fails_sends_nothing(monkeypatch):
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
if path == "/info":
return {"Swarm": {"ControlAvailable": True}}
raise OSError("docker API /services → 503")
monkeypatch.setattr(a, "_docker_request", fake_request)
assert a.collect_swarm("/var/run/docker.sock") == (True, None)
# ── build_sample wiring ───────────────────────────────────────────────────────
def test_build_sample_includes_docker_when_present(monkeypatch):
monkeypatch.setattr(a, "collect_docker",
lambda _s: ("ok", None, [{"name": "web", "status": "running"}]))
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, 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"}]
assert sample["docker_status"] == "ok" and "docker_detail" not in sample
def test_build_sample_sends_an_empty_list_when_docker_has_no_containers(monkeypatch):
# "No containers" must reach Steward, so it can clear the host's list (#5152).
monkeypatch.setattr(a, "collect_docker", lambda _s: ("ok", None, []))
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, None))
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
assert sample["docker"] == [] and sample["docker_status"] == "ok"
def test_build_sample_says_why_docker_could_not_be_asked(monkeypatch):
monkeypatch.setattr(a, "collect_docker",
lambda _s: ("error", "permission denied on /var/run/docker.sock", []))
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, None))
def no_df(_s):
raise AssertionError("/system/df must not be asked when Docker did not answer")
monkeypatch.setattr(a, "collect_disk_usage", no_df)
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
assert "docker" not in sample # not "no containers": unknown
assert sample["docker_status"] == "error"
assert sample["docker_detail"] == "permission denied on /var/run/docker.sock"
def test_build_sample_includes_swarm_on_manager(monkeypatch):
monkeypatch.setattr(a, "collect_docker", lambda _s: ("ok", None, []))
monkeypatch.setattr(a, "collect_swarm",
lambda _s: (True, {"services": [], "nodes": [{"node_id": "n1"}]}))
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
assert sample["swarm"]["nodes"] == [{"node_id": "n1"}]
assert "swarm_manager" not in sample
@pytest.mark.parametrize("swarm, flag", [
((False, None), False), # /info says not a manager: say so
((None, None), None), # /info failed: say nothing
((True, None), None), # a manager whose listing failed: say nothing
])
def test_build_sample_says_when_a_node_is_not_a_manager(monkeypatch, swarm, flag):
monkeypatch.setattr(a, "collect_docker", lambda _s: ("ok", None, []))
monkeypatch.setattr(a, "collect_swarm", lambda _s: swarm)
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
assert sample.get("swarm_manager") is flag and "swarm" not in sample
def test_build_sample_omits_swarm_off_manager(monkeypatch):
monkeypatch.setattr(a, "collect_docker", lambda _s: ("ok", None, []))
monkeypatch.setattr(a, "collect_swarm", lambda _s: (False, None))
monkeypatch.setattr(a, "collect_disk_usage", 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: ("ok", None, [{"name": "web", "status": "running"}]))
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, 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 Docker → 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: ("unavailable", "no Docker socket at /s", []))
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, 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"
# ── container logs (m79) ──────────────────────────────────────────────────────
def _frame(stream: int, payload: bytes) -> bytes:
"""Build one Docker multiplexed-log frame (8-byte header + payload)."""
return bytes([stream, 0, 0, 0]) + len(payload).to_bytes(4, "big") + payload
def test_demux_docker_logs_framed():
raw = _frame(1, b"out line\n") + _frame(2, b"err line\n")
assert a._demux_docker_logs(raw) == [
("stdout", b"out line\n"), ("stderr", b"err line\n")]
def test_demux_docker_logs_tty_fallback():
# A TTY container's stream isn't framed — the leading byte ('2') is not a
# valid stream id, so the whole blob is treated as one stdout payload.
raw = b"2023-11-14T12:00:00.000000000Z hi\n"
assert a._demux_docker_logs(raw) == [("stdout", raw)]
def test_demux_docker_logs_empty():
assert a._demux_docker_logs(b"") == []
def test_parse_log_ts_handles_nanoseconds_and_z():
dt = a._parse_log_ts("2023-11-14T12:00:00.123456789Z")
assert dt is not None
assert dt.year == 2023 and dt.microsecond == 123456 # ns trimmed to µs
assert dt.utcoffset().total_seconds() == 0
assert a._parse_log_ts("not-a-time") is None
assert a._parse_log_ts("") is None
def test_parse_container_logs_splits_streams_and_ts():
raw = (_frame(1, b"2023-11-14T12:00:00.000000001Z hello world\n")
+ _frame(2, b"2023-11-14T12:00:01.000000000Z oops\n"))
parsed = a._parse_container_logs(raw)
assert len(parsed) == 2
dt0, s0, l0 = parsed[0]
dt1, s1, l1 = parsed[1]
assert (s0, l0) == ("stdout", "hello world")
assert (s1, l1) == ("stderr", "oops")
assert dt1 > dt0
def test_parse_container_logs_keeps_unparseable_line():
# No timestamp prefix (e.g. a partial write) → dt is None, full line kept.
parsed = a._parse_container_logs(_frame(1, b"no-timestamp-here\n"))
assert parsed == [(None, "stdout", "no-timestamp-here")]
def test_collect_docker_logs_since_cursor_and_dedup(monkeypatch):
containers = [{"name": "web", "status": "running"}]
calls = []
t1 = "2023-11-14T12:00:00.000000000Z"
t2 = "2023-11-14T12:00:01.000000000Z"
t3 = "2023-11-14T12:00:02.000000000Z"
def fake_raw(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
calls.append(path)
if "since=" not in path: # first interval seeds from a tail alone
return _frame(1, f"{t1} a\n".encode()) + _frame(1, f"{t2} b\n".encode())
# second interval: daemon re-returns the boundary line (t2) + a new one
return _frame(1, f"{t2} b\n".encode()) + _frame(1, f"{t3} c\n".encode())
monkeypatch.setattr(a, "_docker_request_raw", fake_raw)
state: dict = {}
first = a.collect_docker_logs("/sock", containers, state)
assert [r["line"] for r in first] == ["a", "b"]
assert "tail=" in calls[0]
second = a.collect_docker_logs("/sock", containers, state)
# boundary line b (t2) deduped against the cursor; only c (t3) is new
assert [r["line"] for r in second] == ["c"]
assert "since=" in calls[1]
assert f"tail={a.DOCKER_LOG_MAX_LINES}" in calls[1] # bounded even since the cursor (#5764)
def test_collect_docker_logs_excludes_and_skips_non_running(monkeypatch):
containers = [
{"name": "web", "status": "running"},
{"name": "noisy", "status": "running"},
{"name": "db", "status": "exited"},
]
seen = []
def fake_raw(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
seen.append(path)
return _frame(1, b"2023-11-14T12:00:00.000000000Z x\n")
monkeypatch.setattr(a, "_docker_request_raw", fake_raw)
out = a.collect_docker_logs("/sock", containers, {}, exclude=["noisy"])
# only web is fetched: noisy is excluded, db isn't running
assert seen and all("/containers/web/" in p for p in seen)
assert {r["container"] for r in out} == {"web"}
def test_collect_docker_logs_byte_cap_truncates(monkeypatch):
containers = [{"name": "web", "status": "running"}]
blob = b"".join(
_frame(1, f"2023-11-14T12:00:{i:02d}.000000000Z {'x' * 20}\n".encode())
for i in range(10))
monkeypatch.setattr(a, "_docker_request_raw",
lambda s, p, timeout=a.DOCKER_API_TIMEOUT: blob)
out = a.collect_docker_logs("/sock", containers, {}, max_bytes=50)
# Once the cap is crossed a single marker line is appended and we stop.
assert out[-1]["container"] == "_steward"
assert "truncated" in out[-1]["line"]
real = [r for r in out if r["container"] == "web"]
assert 0 < len(real) < 10
def test_collect_docker_logs_forgets_gone_container_cursors(monkeypatch):
monkeypatch.setattr(
a, "_docker_request_raw",
lambda s, p, timeout=a.DOCKER_API_TIMEOUT:
_frame(1, b"2023-11-14T12:00:00.000000000Z x\n"))
state: dict = {}
a.collect_docker_logs("/sock", [{"name": "web", "status": "running"}], state)
assert "web" in state["docker_log_cursors"]
# web is gone next interval → its cursor is pruned so state can't grow forever
a.collect_docker_logs("/sock", [{"name": "db", "status": "running"}], state)
assert "web" not in state["docker_log_cursors"]
assert "db" in state["docker_log_cursors"]
def _final_raw(seen):
def fake_raw(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
seen.append(path)
return (_frame(1, b"2026-10-06T12:00:00.000000000Z booting\n")
+ _frame(2, b"2026-10-06T12:00:01.000000000Z fatal: no config\n"))
return fake_raw
def test_collect_docker_logs_reads_a_stopped_container_once(monkeypatch):
# #5049: a container that was being read and has stopped is read one last
# time since its cursor, then not again until it stops again.
seen: list = []
monkeypatch.setattr(a, "_docker_request_raw", _final_raw(seen))
state = {"docker_log_cursors": {"web": 1791288000.5}} # 2026-10-06T12:00:00.5Z
down = [{"name": "web", "status": "exited", "exit_code": 1,
"finished_at": "2026-10-06T12:00:02+00:00"}]
out = a.collect_docker_logs("/sock", down, state)
assert [r["line"] for r in out] == ["fatal: no config"] # since the cursor
assert "since=" in seen[0]
assert a.collect_docker_logs("/sock", down, state) == [] # same stop: not again
assert len(seen) == 1
again = [{**down[0], "finished_at": "2026-10-06T12:05:00+00:00"}]
a.collect_docker_logs("/sock", again, state) # a new stop is read
assert len(seen) == 2
@pytest.mark.parametrize("exit_code,stopped_ago,read", [
(1, 30, True), # crashed between two samples: its last lines
(0, 30, False), # finished cleanly: nothing to explain
(None, 30, False), # inspect failed: no claim
(1, 3600, False), # an old crash already on the host when the agent came up
])
def test_collect_docker_logs_tails_a_container_first_seen_crashed(monkeypatch, exit_code,
stopped_ago, read):
seen: list = []
monkeypatch.setattr(a, "_docker_request_raw", _final_raw(seen))
stopped = 1791288000.0
down = [{"name": "codex_db.1.x", "status": "exited", "exit_code": exit_code,
"finished_at": "2026-10-06T12:00:00+00:00"}]
out = a.collect_docker_logs("/sock", down, {}, now=stopped + stopped_ago)
assert bool(out) is read
if read:
assert f"tail={a.DOCKER_LOG_FINAL_TAIL}" in seen[0]
assert out[-1]["line"] == "fatal: no config"
def test_collect_docker_logs_forgets_finals_of_unlisted_containers(monkeypatch):
seen: list = []
monkeypatch.setattr(a, "_docker_request_raw", _final_raw(seen))
state: dict = {}
down = [{"name": "job", "status": "exited", "exit_code": 2,
"finished_at": "2026-10-06T12:00:00+00:00"}]
a.collect_docker_logs("/sock", down, state, now=1791288010.0)
assert "job" in state["docker_log_finals"]
a.collect_docker_logs("/sock", [], state, now=1791288020.0)
assert state["docker_log_finals"] == {} and state["docker_log_cursors"] == {}
def test_finished_at_only_for_a_stopped_container(monkeypatch):
insp = {"State": {"StartedAt": "2026-10-06T11:00:00Z", "FinishedAt": "2026-10-06T12:00:00Z",
"ExitCode": 1}}
monkeypatch.setattr(a, "_docker_request", lambda s, p, timeout=a.DOCKER_API_TIMEOUT: insp)
rec = a._collect_one_container("/sock", {"Id": "abc", "Names": ["/job"], "State": "exited"})
assert rec["finished_at"].startswith("2026-10-06T12:00:00")
insp["State"]["FinishedAt"] = "0001-01-01T00:00:00Z" # never stopped
rec = a._collect_one_container("/sock", {"Id": "abc", "Names": ["/job"], "State": "exited"})
assert rec["finished_at"] is None
def test_build_sample_includes_logs_when_present(monkeypatch):
monkeypatch.setattr(a, "collect_docker",
lambda _s: ("ok", None, [{"name": "web", "status": "running"}]))
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, None))
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
monkeypatch.setattr(
a, "collect_docker_logs",
lambda *args, **kw: [{"container": "web", "stream": "stdout",
"ts": "t", "line": "hello"}])
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
assert sample["docker_logs"][0]["line"] == "hello"
def test_build_sample_omits_logs_when_disabled(monkeypatch):
monkeypatch.setattr(a, "collect_docker",
lambda _s: ("ok", None, [{"name": "web", "status": "running"}]))
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, None))
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
called = {"logs": False}
def _logs(*args, **kw):
called["logs"] = True
return [{"container": "web", "line": "x"}]
monkeypatch.setattr(a, "collect_docker_logs", _logs)
sample = a.build_sample(["/"], {}, "/var/run/docker.sock",
docker_logs_enabled=False)
assert "docker_logs" not in sample
assert called["logs"] is False # collection skipped entirely, not just dropped
def test_drop_logs_strips_only_logs():
sample = {"ts": "t", "cpu_pct": 5.0,
"docker": [{"name": "web"}], "docker_logs": [{"line": "x"}]}
out = a._drop_logs(sample)
assert "docker_logs" not in out
assert out["docker"] == [{"name": "web"}] and out["cpu_pct"] == 5.0 # metrics kept
assert a._drop_logs(out) is out # idempotent
def test_read_config_docker_logs_default_on(tmp_path):
p = tmp_path / "agent.conf"
p.write_text("url = x\ntoken = y\n")
cfg = a.read_config(str(p))
assert cfg["docker_logs_enabled"] is True
assert cfg["docker_log_exclude"] == []
def test_read_config_disables_docker_logs(tmp_path):
p = tmp_path / "agent.conf"
p.write_text("url = x\ntoken = y\ndocker_logs_enabled = false\n")
cfg = a.read_config(str(p))
assert cfg["docker_logs_enabled"] is False
def test_read_config_parses_docker_log_exclude(tmp_path):
p = tmp_path / "agent.conf"
p.write_text("url = x\ntoken = y\ndocker_log_exclude = watchtower, foo\n")
cfg = a.read_config(str(p))
assert cfg["docker_log_exclude"] == ["watchtower", "foo"]
@pytest.mark.parametrize("insp, started", [
({"State": {"StartedAt": "2026-10-06T12:00:01.5Z"}}, "2026-10-06T12:00:01.500000+00:00"),
({"State": {"StartedAt": "2026-10-06T12:00:01Z"}}, "2026-10-06T12:00:01+00:00"),
({"State": {"StartedAt": "0001-01-01T00:00:00Z"}}, None), # never started
({"State": {}}, None),
(None, None), # inspect failed
({"State": {"StartedAt": "yesterday"}}, None),
])
def test_docker_started_at(insp, started):
assert a._docker_started_at(insp) == started
def test_collect_docker_past_its_deadline_says_so_and_lists_nothing(monkeypatch):
# A partial list would read as removals; a record without inspect as restarts.
import threading
release = threading.Event()
def one(socket_path, c):
if c["Names"] == ["/stuck"]:
release.wait(2)
return {"name": c["Names"][0].lstrip("/")}
listing = [{"Id": "a" * 16, "Names": ["/fine"]}, {"Id": "b" * 16, "Names": ["/stuck"]}]
monkeypatch.setattr(a, "_docker_request", lambda s, p, timeout=a.DOCKER_API_TIMEOUT: listing)
monkeypatch.setattr(a, "_collect_one_container", one)
monkeypatch.setattr(a, "DOCKER_DEADLINE_SECONDS", 0.05)
try:
assert a.collect_docker("/s") == ("error", "1 of 2 containers not read within 0.05 s", [])
finally:
release.set()
def test_disk_usage_is_asked_on_its_own_cadence(monkeypatch):
calls = []
clock = {"t": 1000.0}
monkeypatch.setattr(a.time, "monotonic", lambda: clock["t"])
monkeypatch.setattr(a, "collect_docker", lambda _s: ("ok", None, []))
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, None))
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: calls.append(clock["t"]) or {"images_total": 1})
state = {}
seen = []
for t in (1000.0, 1030.0, 1000.0 + a.DOCKER_DF_INTERVAL_SECONDS):
clock["t"] = t
seen.append("docker_disk" in a.build_sample(["/"], state, "/s"))
assert seen == [True, False, True]
assert calls == [1000.0, 1000.0 + a.DOCKER_DF_INTERVAL_SECONDS]