Files
FabledSteward/tests/plugins/host_agent/test_agent_collectors.py
T
bvandeusenandClaude Opus 5.5 613ed4e78b
CI / lint (push) Successful in 3s
CI / unit (push) Failing after 1m30s
CI / integration (push) Successful in 1m38s
CI / publish (push) Skipped
feat(traffic): each container's network rate, and each NFS mount's bytes, so a host's traffic has a sender (#6115)
Docker02's outbound traffic tops out the internet connection and Steward
couldn't say who sends it: a container's counters were kept only as a
running total, and NFS traffic belongs to no container.

- Docker ingest turns each running container's cumulative network counters
  into net_rx_bps / net_tx_bps on "<host>/<container>" (net_rates), counting
  from the stored row so a restart of Steward loses nothing; a container
  new, recreated or restarted has no rate until its next snapshot.
- Agent 1.25.0 reads the bytes sent and received per NFS mount from
  mountstats (already parsed for the delays) as tx_bps / rx_bps; Steward
  stores them as nfs_tx_bps / nfs_rx_bps on "<host>:<mount>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:35:24 -04:00

208 lines
8.0 KiB
Python

# tests/plugins/host_agent/test_agent_collectors.py
"""Unit tests for per-resource collectors, driven by fixture /proc files."""
from unittest.mock import patch
import pytest
from plugins.host_agent import agent as a
PROC_STAT_SAMPLE_1 = "cpu 100 0 50 850 0 0 0 0 0 0\n"
PROC_STAT_SAMPLE_2 = "cpu 200 0 100 1700 0 0 0 0 0 0\n"
PROC_MEMINFO = (
"MemTotal: 16000000 kB\n"
"MemFree: 2000000 kB\n"
"MemAvailable: 6000000 kB\n"
"SwapTotal: 4000000 kB\n"
"SwapFree: 3500000 kB\n"
)
PROC_LOADAVG = "0.42 0.55 0.61 1/234 5678\n"
PROC_UPTIME = "482934.12 1000000.00\n"
OS_RELEASE = 'PRETTY_NAME="Ubuntu 24.04 LTS"\nNAME="Ubuntu"\n'
def test_collect_memory_uses_available(tmp_path):
p = tmp_path / "meminfo"
p.write_text(PROC_MEMINFO)
with patch.object(a, "MEMINFO_PATH", str(p)):
mem = a.collect_memory()
assert mem["total_bytes"] == 16000000 * 1024
assert mem["available_bytes"] == 6000000 * 1024
assert mem["used_bytes"] == (16000000 - 6000000) * 1024
assert mem["swap_used_bytes"] == (4000000 - 3500000) * 1024
def test_collect_load(tmp_path):
p = tmp_path / "loadavg"
p.write_text(PROC_LOADAVG)
with patch.object(a, "LOADAVG_PATH", str(p)):
load = a.collect_load()
assert load == {"1m": 0.42, "5m": 0.55, "15m": 0.61}
def test_collect_uptime(tmp_path):
p = tmp_path / "uptime"
p.write_text(PROC_UPTIME)
with patch.object(a, "UPTIME_PATH", str(p)):
assert a.collect_uptime() == 482934
def test_collect_cpu_reads_twice():
# Two sequential _read_file calls return two stat snapshots.
reads = iter([PROC_STAT_SAMPLE_1, PROC_STAT_SAMPLE_2])
def _read(_path):
return next(reads)
with patch.object(a, "_read_file", _read), \
patch.object(a.time, "sleep", lambda _s: None):
pct, cores = a.collect_cpu(sample_window=0.0)
# totals: 1000 → 2000 (delta 1000). idle+iowait: 850 → 1700 (delta 850).
# busy = 150. pct = 15.0. No cpuN lines in the fixture → no per-core values.
assert pct == pytest.approx(15.0, abs=0.1)
assert cores == []
def test_collect_storage():
fake = {"/": (1000, 400, 600), "/mnt/data": (2000, 500, 1500)}
def _fake_usage(path):
total, used, free = fake[path]
class R:
pass
r = R()
r.total = total
r.used = used
r.free = free
return r
# Inodes (#5745): "/" has 100 with 1 free; /mnt/data (btrfs-like) reports none.
inodes = {"/": (100, 1), "/mnt/data": (0, 0)}
def _fake_statvfs(path):
files, ffree = inodes[path]
return type("S", (), {"f_files": files, "f_ffree": ffree})()
with patch.object(a.shutil, "disk_usage", side_effect=_fake_usage), \
patch.object(a.os, "statvfs", side_effect=_fake_statvfs):
out = a.collect_storage(["/", "/mnt/data"])
assert out == [
{"mount": "/", "total_bytes": 1000, "used_bytes": 400,
"inodes_total": 100, "inodes_used": 99},
{"mount": "/mnt/data", "total_bytes": 2000, "used_bytes": 500},
]
def test_collect_metadata(tmp_path):
p = tmp_path / "os-release"
p.write_text(OS_RELEASE)
class Uname:
release = "6.8.0-45-generic"
machine = "x86_64"
with patch.object(a, "OS_RELEASE_PATH", str(p)), \
patch.object(a.os, "uname", return_value=Uname()):
meta = a.collect_metadata()
assert meta["kernel"] == "6.8.0-45-generic"
assert meta["arch"] == "x86_64"
assert "Ubuntu 24.04" in meta["distro"]
def test_a_mount_that_does_not_answer_is_reported_stalled_and_not_probed_again():
"""#5760: statvfs on a stalled NFS share blocks with no timeout, and read
inline that froze the whole agent. The sample goes on without it, and a
probe still blocked isn't stacked on by the next sample."""
import threading
release = threading.Event()
calls: list[str] = []
def _usage(path):
calls.append(path)
if path == "/nfs/media":
release.wait(5)
return type("R", (), {"total": 1000, "used": 400, "free": 600})()
a._stuck_mounts.clear()
try:
with patch.object(a.shutil, "disk_usage", side_effect=_usage), \
patch.object(a.os, "statvfs", side_effect=OSError):
first = a.collect_storage(["/", "/nfs/media"], deadline=0.2)
second = a.collect_storage(["/", "/nfs/media"], deadline=0.2)
assert first == second == [
{"mount": "/", "total_bytes": 1000, "used_bytes": 400},
{"mount": "/nfs/media", "stalled": True},
]
assert calls.count("/nfs/media") == 1
finally:
release.set()
a._stuck_mounts.clear()
def test_a_mount_that_errors_is_left_out_not_the_whole_list():
def _usage(path):
if path == "/nfs/gone":
raise OSError(116, "Stale file handle")
return type("R", (), {"total": 10, "used": 1, "free": 9})()
with patch.object(a.shutil, "disk_usage", side_effect=_usage), \
patch.object(a.os, "statvfs", side_effect=OSError):
out = a.collect_storage(["/", "/nfs/gone"])
assert out == [{"mount": "/", "total_bytes": 10, "used_bytes": 1}]
# ─── NFS delay per mount (#5996) ─────────────────────────────────────────────
MOUNTSTATS = (
"device proc mounted on /proc with fstype proc\n"
"device 192.168.0.7:/volume1/data mounted on /nfs/data with fstype nfs4 statvers=1.1\n"
"\topts:\trw,vers=4.1,hard,proto=tcp\n"
"\tper-op statistics\n"
"\t NULL: 0 0 0 0 0 0 0 0 0\n"
"\t READ: 10 10 0 1200 40960 5 30 40 0\n"
"\t WRITE: 4 4 0 8192 512 2 20 24 0\n"
"\n"
"device 192.168.0.5:/volume1/My Media mounted on /nfs/my\\040media with fstype nfs statvers=1.1\n"
"\tper-op statistics\n"
"\t GETATTR: 3 3 0 300 300 0 3 3 0\n"
"device /dev/sda1 mounted on / with fstype ext4\n"
)
def test_mountstats_reads_each_nfs_mount_s_ops_and_skips_the_rest():
got = a.read_mountstats(MOUNTSTATS)
assert set(got) == {"/nfs/data", "/nfs/my media"}
assert got["/nfs/data"]["READ"] == (10, 10, 0, 5, 30, 40, 1200, 40960)
assert got["/nfs/data"]["NULL"] == (0, 0, 0, 0, 0, 0, 0, 0)
assert got["/nfs/my media"] == {"GETATTR": (3, 3, 0, 0, 3, 3, 300, 300)}
def test_mountstats_that_cannot_be_read_is_no_mounts(tmp_path):
with patch.object(a, "MOUNTSTATS_PATH", str(tmp_path / "missing")):
assert a.collect_nfs_raw() == {}
def test_nfs_delay_is_what_the_requests_since_the_last_sample_took():
prev = {"/nfs/data": {"READ": (10, 10, 0, 5, 30, 40, 1200, 40960),
"WRITE": (4, 4, 0, 2, 20, 24, 8192, 512)},
"/nfs/idle": {"GETATTR": (3, 3, 0, 0, 3, 3, 300, 300)},
"/nfs/remounted": {"READ": (50, 50, 0, 0, 50, 50, 5000, 5000)}}
cur = {"/nfs/data": {"READ": (20, 21, 1, 7, 130, 240, 21680, 450560),
"WRITE": (4, 4, 0, 2, 20, 24, 8192, 512)},
"/nfs/idle": {"GETATTR": (3, 3, 0, 0, 3, 3, 300, 300)},
"/nfs/remounted": {"READ": (2, 2, 0, 0, 2, 2, 200, 200)},
"/nfs/new": {"READ": (5, 5, 0, 0, 5, 5, 500, 500)}}
got = {r["mount"]: r for r in a.nfs_delays(cur, prev, 10.0)}
# ten READs in 10 s, 100 ms of round trip and 200 ms of waiting between
# them, one sent twice and one that hit a major timeout; no WRITE finished.
# 20,480 bytes sent and 409,600 received on the wire.
assert got["/nfs/data"] == {"mount": "/nfs/data", "ops_per_sec": 1.0, "retrans": 1, "timeouts": 1,
"tx_bps": 2048.0, "rx_bps": 40960.0,
"rtt_ms": 10.0, "exe_ms": 20.0, "read_ms": 20.0}
assert got["/nfs/idle"] == {"mount": "/nfs/idle", "ops_per_sec": 0.0, "retrans": 0, "timeouts": 0,
"tx_bps": 0.0, "rx_bps": 0.0}
assert set(got) == {"/nfs/data", "/nfs/idle"} # counters gone back, or no baseline yet
assert a.nfs_delays(cur, prev, 0) == []