Files
FabledSteward/tests/plugins/host_agent/test_agent_collectors.py
T
bvandeusenandClaude Opus 5.5 3f9aae7f46
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m32s
CI / integration (push) Successful in 1m31s
CI / publish (push) Skipped
feat(host_agent): NFS delay per mount, read from /proc/self/mountstats (#5996)
Each sample carries each NFS mount's requests since the last one: how many
a second, their round trip and execute time (READ and WRITE on their own),
and how many were sent again or hit a major timeout. mountstats is the
kernel's own count and never touches the share, so it reads while one is
stuck. Steward stores them per mount and the slowest on the host as
nfs_exe_max_ms, for an "NFS slow" rule and the host page's NFS chart beside
mounts_stalled; each mount's bar says what a request took.

Agent 1.22.0 (which also carries the backlog order from 41fc807).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:46:14 -04:00

203 lines
7.6 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)
assert got["/nfs/data"]["NULL"] == (0, 0, 0, 0, 0, 0)
assert got["/nfs/my media"] == {"GETATTR": (3, 3, 0, 0, 3, 3)}
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), "WRITE": (4, 4, 0, 2, 20, 24)},
"/nfs/idle": {"GETATTR": (3, 3, 0, 0, 3, 3)},
"/nfs/remounted": {"READ": (50, 50, 0, 0, 50, 50)}}
cur = {"/nfs/data": {"READ": (20, 21, 1, 7, 130, 240), "WRITE": (4, 4, 0, 2, 20, 24)},
"/nfs/idle": {"GETATTR": (3, 3, 0, 0, 3, 3)},
"/nfs/remounted": {"READ": (2, 2, 0, 0, 2, 2)},
"/nfs/new": {"READ": (5, 5, 0, 0, 5, 5)}}
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
assert got["/nfs/data"] == {"mount": "/nfs/data", "ops_per_sec": 1.0, "retrans": 1, "timeouts": 1,
"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}
assert set(got) == {"/nfs/data", "/nfs/idle"} # counters gone back, or no baseline yet
assert a.nfs_delays(cur, prev, 0) == []