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>
203 lines
8.5 KiB
Python
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")
|