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>
This commit is contained in:
@@ -1,44 +1,125 @@
|
||||
"""Unit tests for the SNMP poller (no pysnmp / no network).
|
||||
"""The SNMP poller (milestone 491), against a stand-in for pysnmp.
|
||||
|
||||
The unit lane doesn't install the `snmp` extra, so these exercise the parts that
|
||||
don't need pysnmp: the version→mpModel mapping, that the poller is async, and the
|
||||
graceful "pysnmp missing → empty result" path. Live polling against a real device
|
||||
is verified out-of-band (CI has no SNMP target). Uses asyncio.run() directly so it
|
||||
doesn't depend on the pytest-asyncio mode.
|
||||
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
|
||||
|
||||
|
||||
def test_poll_device_is_coroutine():
|
||||
# The scheduler awaits it directly (no executor) — it must be async.
|
||||
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_mp_model_maps_version_to_int():
|
||||
assert poller._mp_model("1") == 0 # SNMP v1
|
||||
assert poller._mp_model("2c") == 1 # SNMP v2c
|
||||
assert poller._mp_model("2") == 1 # anything non-"1" → v2c model
|
||||
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_poll_device_without_pysnmp_returns_empty(monkeypatch):
|
||||
# When the optional dep is absent, polling is disabled gracefully (no raise).
|
||||
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)
|
||||
out = asyncio.run(
|
||||
poller.poll_device("192.0.2.1", 161, "public", "2c", [{"oid": "1.3.6.1.2.1.1.3.0"}])
|
||||
)
|
||||
assert out == {}
|
||||
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. These fakes stand in for the
|
||||
# engine since the unit lane has no pysnmp.
|
||||
# must release it across both pysnmp API shapes.
|
||||
|
||||
def test_close_engine_uses_engine_level_close_7x():
|
||||
# canonical pysnmp 7.x: close method lives directly on the engine.
|
||||
calls = []
|
||||
|
||||
class Engine:
|
||||
@@ -50,7 +131,6 @@ def test_close_engine_uses_engine_level_close_7x():
|
||||
|
||||
|
||||
def test_close_engine_falls_back_to_dispatcher_6x():
|
||||
# pysnmp-lextudio 6.2.x: no engine-level close; go through the dispatcher.
|
||||
calls = []
|
||||
|
||||
class Dispatcher:
|
||||
@@ -65,12 +145,9 @@ def test_close_engine_falls_back_to_dispatcher_6x():
|
||||
|
||||
|
||||
def test_close_engine_never_raises():
|
||||
# A failing close must not break the poll loop.
|
||||
class Engine:
|
||||
def close_dispatcher(self):
|
||||
raise OSError("boom")
|
||||
|
||||
poller._close_engine(Engine()) # no exception
|
||||
|
||||
# No close path at all (defensive) is also fine.
|
||||
poller._close_engine(Engine())
|
||||
poller._close_engine(object())
|
||||
|
||||
Reference in New Issue
Block a user