Files
FabledSteward/tests/plugins/host_agent/test_agent_docker.py
T
bvandeusen 7b80552a7d
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 2m16s
CI / unit (push) Successful in 4m28s
CI / publish (push) Successful in 1m4s
feat(docker): per-host collection via the host agent; drop central scrape
Docker collection moves off the central single-socket scrape onto the host
agent, giving Docker a real per-host dimension. The Steward host now reports
its own containers like any other host, and same-named containers on different
hosts no longer collide.

- agent: stdlib UDS Docker client (AF_UNIX HTTP/1.1, Connection: close,
  chunked-aware), collect_docker() ports the cpu%/mem math; sample["docker"]
  added best-effort (silent-skip on absent/unreadable socket). AGENT_VERSION
  1.2.0 → 1.3.0; optional docker_socket config key.
- ingest: host_agent ingest hands per-host container snapshots to the docker
  plugin via a new "docker.persist_host_samples" capability (no hard import,
  no-op when docker disabled), inside a SAVEPOINT so a docker failure never
  sinks the host metrics. Resource names are host-scoped ("<host>/<name>").
- schema: docker_containers re-keyed (host_id, name); docker_metrics gains
  host_id; docker_002 migration DROP+recreates (dev-only, rule 122).
- ui: Docker page + widgets grouped by host with host links; new per-host
  Docker panel embedded on the Hosts hub (gated on docker enabled via a new
  enabled_plugins template context). Replaces the SQLite-only strftime
  bucketing with DB-agnostic Python bucketing.
- provisioning: install/provision playbooks add steward-agent to the docker
  group (best-effort) so the agent can read the socket.
- removed central scrape: docker scheduler.py + scraper.py deleted; plugin.yaml
  socket_path/scrape_interval_seconds/include_stopped dropped (plugin 2.0.0).
- tests: agent docker collector units (math, chunked decode, silent-skip,
  sample shape, config) + integration (host-scoped schema + persistence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 17:54:36 -04:00

158 lines
6.3 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"},
]
# ── 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"}],
}
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}},
}
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
return [container] if path.startswith("/containers/json") else stats
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
def test_collect_docker_skips_stats_for_stopped(monkeypatch):
container = {"Id": "x" * 16, "Names": ["/db"], "Image": "pg", "State": "exited",
"Created": None, "Ports": []}
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
if path.startswith("/containers/json"):
return [container]
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
# ── build_sample wiring ───────────────────────────────────────────────────────
def test_build_sample_includes_docker_when_present(monkeypatch):
monkeypatch.setattr(a, "collect_docker", lambda _s: [{"name": "web", "status": "running"}])
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: [])
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
assert "docker" 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"