Files
FabledSteward/tests/plugins/host_agent/test_agent_docker.py
T
bvandeusen c95194747d
CI / lint (push) Successful in 7s
CI / unit (push) Successful in 43s
CI / integration (push) Successful in 2m26s
CI / publish (push) Successful in 1m29s
feat(host-agent): collect container logs (incremental push, byte-capped) [M79 step 1]
Agent-side of Docker container-log collection (milestone 79). Each interval the
agent fetches new log lines per running container over the local Docker socket,
demuxes the multiplexed stream, and folds sample["docker_logs"] into the same
push as metrics — no inbound channel needed.

- since-cursor per container kept in rate-state; a container's first interval
  seeds from a short tail, then fetches incrementally via ?since=<cursor>
- _docker_request_raw + _demux_docker_logs + RFC3339Nano ts parsing (stdlib,
  TTY/unframed fallback); one row per (stream, ts, line)
- per-batch byte cap with a truncation marker; logs are dropped (never buffered)
  across a backoff so an outage keeps metrics but not stale logs
- docker_logs_enabled (default on) + docker_log_exclude per-host config keys
- AGENT_VERSION 1.6.0 -> 1.7.0

Server ignores the new sample key until the ingest step lands, so this commit is
CI-safe on its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C
2026-07-19 18:38:06 -04:00

591 lines
26 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"
# ── 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 "tail=" in path: # first interval seeds from a tail
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]
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 test_build_sample_includes_logs_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)
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: [{"name": "web", "status": "running"}])
monkeypatch.setattr(a, "collect_swarm", lambda _s: 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"]