#5760 "One stalled NFS mount freezes the whole agent: statvfs has no deadline, so the host goes silent while it still answers ping". The agent read every mount inline in its only loop; a hard-mounted share whose server stops blocks statvfs in the kernel with no timeout. Each mount is now read in a daemon thread under one 5 s deadline. One that misses it is sent as stalled, and isn't probed again while its last probe is still blocked. A mount that errors (a stale handle) is left out instead of emptying the list. Steward counts them as mounts_stalled (alertable: "an NFS share stopped"), and skips the fullest-disk figure when every mount stalled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
5.1 KiB
Python
154 lines
5.1 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}]
|