Files
FabledSteward/tests/plugins/snmp/test_poller.py
T
bvandeusenandClaude Opus 5.5 9ca2e38fa1
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 55s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped
feat(snmp): devices you add, test and read on the SNMP page (milestone 491)
The operator, reviewing SNMP on #5214: "this system looks incomplete like
there's something missing from it". The one device shown was plugin.yaml's
example (core-switch 192.168.1.1, community public), polled every minute;
devices could only be defined by editing YAML in the image; a failed poll was
logged at DEBUG and stored nowhere, so the page said "waiting for the next
poll" forever; v3 ran as v2c; counters showed as lifetime totals.

- Devices are rows (snmp_devices, migration snmp_001): address, port,
  v1/v2c community or a v3 user with auth/priv protocols and keys (secrets
  encrypted), presets plus custom readings, an optional host binding, and
  the newest poll's outcome. The YAML device list and its example are gone.
- The SNMP page adds, edits and deletes them (admin, audited, with
  get_audit_entities so the audit log names and links a device). Readings
  come from presets (system uptime, interface traffic by number, UPS-MIB,
  Linux net-snmp) and custom "OID name [unit]" lines.
- One GET per device with every OID, devices polled at once. The outcome is
  written to the row in words ("No reply from … within 5 s. Check the address
  and port, that SNMP is on, and the community"), with readings it lacks.
  v1's whole-request error drops that OID and asks again. SNMPv3 signs in
  with USM (MD5/SHA-1/SHA-2, DES/3DES/AES).
- Test asks the unsaved form's device for its name, description, uptime and
  interfaces by number. It is an admin's explicit action; every page reads
  only stored rows (System 178's ruling).
- Each reading has a unit; a counter is stored as a per-second rate (bytes as
  bit/s), a reset or wrap skipped. The page, widget and host panel say values
  in units and why a device is silent; the device page draws a chart per
  reading on a dated axis.

Tests: readings and presets, the form and its secrets, v3 checks, rates,
the poller against a stand-in pysnmp (one GET, v1 retry, timeout wording,
USM), the pages, and an integration test of the poll writing outcomes and
rates on Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:54:11 -04:00

154 lines
6.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", "label"] and result.error is None
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())