"""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())