Files
FabledSteward/tests/plugins/snmp/test_poller.py
T
bvandeusenandClaude Opus 5.5 d065cafd11
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m38s
CI / integration (push) Successful in 1m41s
CI / publish (push) Successful in 1m31s
feat(snmp): NAS presets for Synology and Asustor, reading every disk and volume (#5985, #5986)
A preset can now read a vendor's tables: one row per disk, volume, fan or
sensor. The scheduled poll discovers the rows by walking the device: on its
first poll, after a save, and then hourly, and only while it answers. Each row
becomes readings named by the device's own labels ("Disk 1 status"). Only the
columns a row answered are asked for.

A vendor status, whether a code (Synology) or a word (Asustor), is a state
reading. It is stored as a level (0 OK, 1 busy, 2 warning, 3 failed), so one
alert rule covers any vendor, and shown in the device's own word.

- Synology: system, power and fan status and temperature; per disk: status,
  health, temperature and bad sectors; per volume: RAID state (scrubbing,
  parity check, rebuild, degraded), free space and size.
- Asustor: per disk: status, SMART and temperature; per volume: status, free
  space and size; CPU and system temperature, free memory and fans.
  From ADM's ASUSTOR-DISK-MIB and ASUSTOR-SYSTEM-MIB.
- Linux server: plus each physical disk's 1-minute busy % (UCD-DISKIO-MIB).

Migration snmp_002_table_rows adds tables, tables_at and texts to
snmp_devices. Milestone 530.

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

194 lines
8.0 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_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")