Files
FabledSteward/tests/plugins/snmp/test_poller.py
T
bvandeusenandClaude Opus 5.5 bd1c32d2a4
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m50s
CI / publish (push) Successful in 1m29s
fix(snmp): a disk listed late in UCD-DISKIO is read, and Asustor disks are named by bay (#5992)
The table walk stopped after 64 raw entries, counting the RAM disks, loops,
partitions and md arrays that rows_like then drops, so a Synology's eighth
disk was never reached. The walk is now bounded at 1,024 entries per column;
64 still caps the rows kept.

ADM labels its disks "Disk0".."Disk4" while their indexes run 1..5; the
Asustor disk table now names each row by its index ("Disk 1"). Keys are
unchanged, so history carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:16:25 -04:00

203 lines
8.5 KiB
Python

"""The SNMP poller (milestone 491), against a stand-in for pysnmp.
The unit lane doesn't install the `snmp` extra, so a fake API answers in
pysnmp's shape: one GET per device, answers in request order, an agent's "no
such object" as a value of that type, v1's whole-request error with an index,
and a timeout as an error indication. Live polling is read from the instance.
"""
import asyncio
import inspect
from types import SimpleNamespace as NS
from plugins.snmp import poller
from plugins.snmp.poller import Credentials
class NoSuchObject:
pass
class _Status:
def __init__(self, text):
self.text = text
def __bool__(self):
return True
def prettyPrint(self):
return self.text
def _fake_api(answer, calls):
"""answer(oids) -> (error_indication, error_status, error_index, values)."""
async def get(engine, auth, transport, ctx, *objs, **opts):
oids = [o for o in objs]
calls.append((auth, oids, opts))
ei, es, eidx, values = answer(oids)
return ei, es, eidx, [(oid, v) for oid, v in zip(oids, values)]
class Transport:
def __init__(self, addr, **kw):
self.addr = addr
hl = NS(SnmpEngine=lambda: NS(), ContextData=lambda: "ctx", ObjectType=lambda x: x,
ObjectIdentity=lambda oid: oid, UdpTransportTarget=Transport,
CommunityData=lambda c, mpModel: ("community", c, mpModel),
UsmUserData=lambda user, **kw: ("usm", user, kw))
auth = NS(**{name: name for name in list(poller.AUTH_PROTOCOLS.values()) + list(poller.PRIV_PROTOCOLS.values())})
return NS(hl=hl, auth=auth, get=get, walk=None, async_transport=False)
def _poll(monkeypatch, answer, oids, creds=None):
calls = []
monkeypatch.setattr(poller, "_pysnmp_available", lambda: True)
monkeypatch.setattr(poller, "_api", lambda: _fake_api(answer, calls))
result = asyncio.run(poller.poll_device("10.0.0.2", 161, creds or Credentials(community="public"), oids))
return result, calls
def test_poll_device_is_a_coroutine():
assert inspect.iscoroutinefunction(poller.poll_device)
def test_one_get_carries_every_oid_and_answers_come_back_by_key(monkeypatch):
result, calls = _poll(monkeypatch, lambda oids: (None, None, None, [4200.0, NoSuchObject(), "eth0"]),
[("1.1", "uptime"), ("1.2", "fan"), ("1.3", "label")])
assert len(calls) == 1 and calls[0][1] == ["1.1", "1.2", "1.3"] and calls[0][2] == {"lookupMib": False}
assert calls[0][0] == ("community", "public", 1)
assert result.readings == {"uptime": 4200.0} and result.missing == ["fan"] and result.error is None
assert result.texts == {"label": "eth0"} # a word, for a state reading
def test_v1_drops_the_oid_it_does_not_know_and_asks_again(monkeypatch):
def answer(oids):
if "1.2" in oids:
return None, _Status("noSuchName"), oids.index("1.2") + 1, []
return None, None, None, [float(len(o)) for o in oids]
result, calls = _poll(monkeypatch, answer, [("1.1", "a"), ("1.2", "b"), ("1.33", "c")],
Credentials(version="1", community="public"))
assert len(calls) == 2 and calls[0][0] == ("community", "public", 0)
assert result.readings == {"a": 3.0, "c": 4.0} and result.missing == ["b"]
def test_no_reply_says_what_to_check(monkeypatch):
result, _ = _poll(monkeypatch, lambda oids: ("No SNMP response received before timeout", None, None, []),
[("1.1", "uptime")])
assert result.readings == {} and result.error.startswith("No reply from 10.0.0.2:161 within 5 s")
assert "the community" in result.error
def test_an_answer_without_any_reading_is_an_error(monkeypatch):
result, _ = _poll(monkeypatch, lambda oids: (None, None, None, [NoSuchObject()]), [("1.1", "x")])
assert result.error == "The device answered but has none of these readings." and result.missing == ["x"]
def test_v3_signs_in_as_its_user_with_the_chosen_protocols(monkeypatch):
creds = Credentials(version="3", user="mon", auth="sha256", auth_key="authpass1",
priv="aes128", priv_key="privpass1")
_, calls = _poll(monkeypatch, lambda oids: (None, None, None, [1.0]), [("1.1", "x")], creds)
kind, user, kw = calls[0][0]
assert (kind, user) == ("usm", "mon")
assert kw == {"authKey": "authpass1", "privKey": "privpass1",
"authProtocol": "usmHMAC192SHA256AuthProtocol", "privProtocol": "usmAesCfb128Protocol"}
def test_without_pysnmp_the_poll_says_so(monkeypatch):
monkeypatch.setattr(poller, "_pysnmp_available", lambda: False)
result = asyncio.run(poller.poll_device("10.0.0.2", 161, Credentials(), [("1.1", "x")]))
assert result.readings == {} and "pysnmp" in result.error
def test_errors_read_as_what_to_check():
v3 = Credentials(version="3", user="mon")
assert "doesn't know the v3 user “mon”" in poller.explain("Unknown USM user", "h", 161, v3)
assert "authentication" in poller.explain("Wrong SNMP PDU digest", "h", 161, v3)
assert "the v3 user “mon”" in poller.explain("No SNMP response received before timeout", "h", 161, v3)
assert poller.explain("Something odd", "h", 161, v3) == "SNMP error from h:161: Something odd"
# --- _close_engine: the fd-leak guard ---------------------------------------
# A fresh SnmpEngine per poll leaks its UDP socket unless closed; _close_engine
# must release it across both pysnmp API shapes.
def test_close_engine_uses_engine_level_close_7x():
calls = []
class Engine:
def close_dispatcher(self):
calls.append("engine")
poller._close_engine(Engine())
assert calls == ["engine"]
def test_close_engine_falls_back_to_dispatcher_6x():
calls = []
class Dispatcher:
def closeDispatcher(self):
calls.append("dispatcher")
class Engine:
transportDispatcher = Dispatcher()
poller._close_engine(Engine())
assert calls == ["dispatcher"]
def test_close_engine_never_raises():
class Engine:
def close_dispatcher(self):
raise OSError("boom")
poller._close_engine(Engine())
poller._close_engine(object())
def _walk(monkeypatch, columns, error=None):
"""Walk tables against a device whose columns are {column oid: {index: value}}."""
walked = []
async def walk(engine, auth, transport, ctx, obj, **opts):
walked.append(obj)
if error:
yield error, None, None, []
return
for index, value in columns.get(obj, {}).items():
yield None, None, None, [(f"{obj}.{index}", value)]
monkeypatch.setattr(poller, "_pysnmp_available", lambda: True)
api = _fake_api(lambda oids: (None, None, None, []), [])
api.walk = walk
monkeypatch.setattr(poller, "_api", lambda: api)
return walked
def test_a_table_walk_finds_each_row_its_label_and_the_columns_it_answered(monkeypatch):
from plugins.snmp.readings import Column, Table
disks = Table("t", "disk", "9.2", "Disk", (Column("9.5", "status", "status"), Column("9.6", "temp", "temp")),
rows_like=r"Disk \d+")
walked = _walk(monkeypatch, {"9.2": {"1": "Disk 1", "0": "Disk 0", "7": "Cache"},
"9.5": {"0": 1, "1": 1, "7": 1}, "9.6": {"1": 38}})
scan = asyncio.run(poller.walk_tables("10.0.0.2", 161, Credentials(community="public"), [disks]))
assert walked == ["9.2", "9.5", "9.6"] and scan.error is None
assert scan.rows == {"t": [{"index": "0", "label": "Disk 0", "columns": ["status"]},
{"index": "1", "label": "Disk 1", "columns": ["status", "temp"]}]}
def test_a_disk_listed_after_many_partitions_and_loops_is_still_found(monkeypatch):
from plugins.snmp.readings import TABLES
[diskio] = TABLES["linux"]
labels = {str(i): f"loop{i}" for i in range(1, 80)} | {"80": "sdh", "81": "sdh1"}
_walk(monkeypatch, {diskio.label: labels, diskio.columns[0].oid: {i: 5 for i in labels}})
scan = asyncio.run(poller.walk_tables("10.0.0.2", 161, Credentials(community="public"), [diskio]))
assert scan.rows == {"ucd_diskio": [{"index": "80", "label": "sdh", "columns": ["busy"]}]}
def test_a_table_walk_with_no_reply_says_what_to_check(monkeypatch):
from plugins.snmp.readings import Column, Table
_walk(monkeypatch, {}, error="No SNMP response received before timeout")
scan = asyncio.run(poller.walk_tables("10.0.0.2", 161, Credentials(community="public"),
[Table("t", "", "", "Fan", (Column("9.2", "fan", "Fan speed"),))]))
assert scan.rows == {} and scan.error.startswith("No reply from 10.0.0.2:161")