Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Raise and handle exceptions in SNMP module #76

Merged
merged 21 commits into from
Oct 6, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 92 additions & 74 deletions src/zino/snmp.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
getCmd,
nextCmd,
)
from pysnmp.proto.errind import RequestTimedOut
from pysnmp.proto.rfc1905 import errorStatus
from pysnmp.smi import builder, view
from pysnmp.smi.error import MibNotFoundError
from pysnmp.smi.error import MibNotFoundError as PysnmpMibNotFoundError

from zino.config.models import PollDevice
from zino.oid import OID
Expand Down Expand Up @@ -59,23 +61,57 @@
SupportedTypes = Union[univ.Integer, univ.OctetString, ObjectIdentity, ObjectType]


class SnmpError(Exception):
"""Base class for SNMP, MIB and OID specific errors"""

pass


class ErrorIndication(Exception):
"""Class for SNMP errors that occur locally,
as opposed to being reported from a different SNMP entitiy.
stveit marked this conversation as resolved.
Show resolved Hide resolved
"""


class MibNotFoundError(ErrorIndication):
"""Raised if a required MIB file could not be found"""

pass


class ErrorStatus(SnmpError):
"""Raised if a SNMP entity includes a non-zero error status in its response PDU.
stveit marked this conversation as resolved.
Show resolved Hide resolved
RFC 1905 defines the possible errors that can be specified in the error status field.
This can either be used directly or subclassed for one of these specific errors.
"""

pass


class NoSuchNameError(ErrorStatus):
"""Represents the "noSuchName" error. Raised if an object could not be found at an OID."""

pass


class SNMP:
"""Represents an SNMP management session for a single device"""

NON_REPEATERS = 0

def __init__(self, device: PollDevice):
def __init__(self, device: PollDevice, retries=5):
stveit marked this conversation as resolved.
Show resolved Hide resolved
self.device = device
self.retries = retries

async def get(self, *oid: str) -> Union[MibObject, None]:
async def get(self, *oid: str) -> MibObject:
"""SNMP-GETs the given oid
Example usage:
get("SNMPv2-MIB", "sysUpTime", 0)
get("1.3.6.1.2.1.1.3.0")

:param oid: Values for defining an OID. For detailed use see
https://github.com/pysnmp/pysnmp/blob/bc1fb3c39764f36c1b7c9551b52ef8246b9aea7c/pysnmp/smi/rfc1902.py#L35-L49
:return: A MibObject representing the resulting MIB variable or None if nothing could be found
:return: A MibObject representing the resulting MIB variable
"""
query = self._oid_to_object_type(*oid)
try:
Expand All @@ -86,51 +122,49 @@
ContextData(),
query,
)
except MibNotFoundError as error:
_log.error("%s: %s", self.device.name, error)
return
if self._handle_errors(error_indication, error_status, error_index, query):
return
for var_bind in var_binds:
return self._object_type_to_mib_object(var_bind)

def _handle_errors(self, error_indication: str, error_status: str, error_index: int, *query: ObjectType) -> bool:
"""Returns True if error occurred"""
except PysnmpMibNotFoundError as error:
raise MibNotFoundError(error)
self._raise_errors(error_indication, error_status, error_index, query)
return self._object_type_to_mib_object(var_binds[0])

def _raise_errors(self, error_indication: str, error_status: str, error_index: int, *query: ObjectType):
"""Raises a relevant exception if an error has occurred"""
# Local errors (timeout, config errors etc)
if error_indication:
_log.error("%s: %s", self.device.name, error_indication)
return True
if isinstance(error_indication, RequestTimedOut):
raise TimeoutError(str(error_indication))
else:
raise ErrorIndication(str(error_indication))

Check warning on line 137 in src/zino/snmp.py

View check run for this annotation

Codecov / codecov/patch

src/zino/snmp.py#L137

Added line #L137 was not covered by tests

# Remote errors from SNMP entity.
# if nonzero error_status, error_index point will point to the ariable-binding in query that caused the error.
if error_status:
_log.error(
"%s: %s at %s",
self.device.name,
error_status.prettyPrint(),
error_index and query[int(error_index) - 1][0] or "?",
)
return True
return False

async def getnext(self, *oid: str) -> Union[MibObject, None]:
error_object = self._object_type_to_mib_object(query[error_index - 1])
error_name = errorStatus.getNamedValues()[int(error_status)]
if error_name == "noSuchName":
raise NoSuchNameError(f"Could not find object at {error_object.oid}")
else:
raise ErrorStatus(f"SNMP operation failed with error {error_name} for {error_object.oid}")

Check warning on line 147 in src/zino/snmp.py

View check run for this annotation

Codecov / codecov/patch

src/zino/snmp.py#L147

Added line #L147 was not covered by tests

async def getnext(self, *oid: str) -> MibObject:
"""SNMP-GETNEXTs the given oid
Example usage:
getnext("SNMPv2-MIB", "sysUpTime")
getnext("1.3.6.1.2.1.1.3")

:param oid: Values for defining an OID. For detailed use see
https://github.com/pysnmp/pysnmp/blob/bc1fb3c39764f36c1b7c9551b52ef8246b9aea7c/pysnmp/smi/rfc1902.py#L35-L49
:return: A MibObject representing the resulting MIB variable or None if nothing could be found
:return: A MibObject representing the resulting MIB variable
"""
query = self._oid_to_object_type(*oid)
object_type = await self._getnext(query)
if not object_type:
return None
return self._object_type_to_mib_object(object_type)

async def _getnext(self, object_type: ObjectType) -> Union[ObjectType, None]:
async def _getnext(self, object_type: ObjectType) -> ObjectType:
"""SNMP-GETNEXTs the given object_type

:param object_type: An ObjectType representing the object you want to query
:return: An ObjectType representing the resulting MIB variable or None if nothing could be found
:return: An ObjectType representing the resulting MIB variable
"""
try:
error_indication, error_status, error_index, var_binds = await nextCmd(
Expand All @@ -140,14 +174,11 @@
ContextData(),
object_type,
)
except MibNotFoundError as error:
_log.error("%s: %s", self.device.name, error)
return
if self._handle_errors(error_indication, error_status, error_index, object_type):
return
except PysnmpMibNotFoundError as error:
raise MibNotFoundError(error)
self._raise_errors(error_indication, error_status, error_index, object_type)
# var_binds should be a sequence of sequences with one inner sequence that contains the result.
if var_binds and var_binds[0]:
return var_binds[0][0]
return var_binds[0][0]

async def walk(self, *oid: str) -> list[MibObject]:
"""Uses SNMP-GETNEXT calls to get all objects in the subtree with oid as root
Expand All @@ -161,21 +192,17 @@
"""
results = []
current_object = self._oid_to_object_type(*oid)
try:
self._resolve_object(current_object)
except MibNotFoundError as error:
_log.error("%s: %s", self.device.name, error)
return results
self._resolve_object(current_object)
original_oid = OID(str(current_object[0]))
while True:
current_object = await self._getnext(current_object)
if not current_object or not original_oid.is_a_prefix_of(str(current_object[0])):
if not original_oid.is_a_prefix_of(str(current_object[0])):
break
mib_object = self._object_type_to_mib_object(current_object)
results.append(mib_object)
return results

async def getbulk(self, *oid: str, max_repetitions: int = 1) -> list[MibObject]:
async def getbulk(self, *oid: str, max_repetitions: int = 10) -> list[MibObject]:
stveit marked this conversation as resolved.
Show resolved Hide resolved
"""SNMP-BULKs the given oid
Example usage:
getbulk("IF-MIB", "ifName", max_repetitions=5)
Expand Down Expand Up @@ -206,13 +233,9 @@
max_repetitions,
object_type,
)
except MibNotFoundError as error:
_log.error("%s: %s", self.device.name, error)
return []
if self._handle_errors(error_indication, error_status, error_index, object_type):
return []
if not var_binds:
return []
except PysnmpMibNotFoundError as error:
raise MibNotFoundError(error)
self._raise_errors(error_indication, error_status, error_index, object_type)
return var_binds[0]

async def getbulk2(self, *variables: Sequence[str], max_repetitions: int = 10) -> Sequence[Sequence[SNMPVarBind]]:
Expand Down Expand Up @@ -250,12 +273,10 @@
max_repetitions,
*variables,
)
except MibNotFoundError as error:
_log.error("%s: %s", self.device.name, error)
return []
if self._handle_errors(error_indication, error_status, error_index, *variables):
return []
return var_bind_table or []
except PysnmpMibNotFoundError as error:
raise MibNotFoundError(error)
self._raise_errors(error_indication, error_status, error_index, *variables)
return var_bind_table

async def bulkwalk(self, *oid: str, max_repetitions: int = 10) -> list[MibObject]:
"""Uses SNMP-BULK calls to get all objects in the subtree with oid as root
Expand All @@ -270,11 +291,7 @@
"""
results = []
query_object = self._oid_to_object_type(*oid)
try:
self._resolve_object(query_object)
except MibNotFoundError as error:
_log.error("%s: %s", self.device.name, error)
return results
self._resolve_object(query_object)
start_oid = OID(str(query_object[0]))
while True:
response = await self._getbulk(query_object, max_repetitions)
Expand All @@ -301,11 +318,7 @@
OID('.2'): {"ifName": "2", "ifAlias": "next-sw.example.org"}}
"""
query_objects = [self._oid_to_object_type(*var) for var in variables]
try:
[self._resolve_object(obj) for obj in query_objects]
except MibNotFoundError as error:
_log.error("%s: %s", self.device.name, error)
return {}
[self._resolve_object(obj) for obj in query_objects]

roots = [OID(o[0]) for o in query_objects] # used to determine which responses are in scope
results: dict[OID, dict[str, Any]] = defaultdict(dict)
Expand Down Expand Up @@ -341,11 +354,14 @@
@classmethod
def _resolve_object(cls, object_type: ObjectType):
"""Raises MibNotFoundError if oid in `object` can not be found"""
engine = _get_engine()
controller = engine.getUserContext("mibViewController")
if not controller:
controller = view.MibViewController(engine.getMibBuilder())
object_type.resolveWithMib(controller)
try:
engine = _get_engine()
controller = engine.getUserContext("mibViewController")
if not controller:
controller = view.MibViewController(engine.getMibBuilder())

Check warning on line 361 in src/zino/snmp.py

View check run for this annotation

Codecov / codecov/patch

src/zino/snmp.py#L361

Added line #L361 was not covered by tests
object_type.resolveWithMib(controller)
except PysnmpMibNotFoundError as error:
raise MibNotFoundError(error)

@classmethod
def _oid_to_object_type(cls, *oid: str) -> ObjectType:
Expand All @@ -362,7 +378,9 @@

@property
def udp_transport_target(self) -> UdpTransportTarget:
return UdpTransportTarget((str(self.device.address), self.device.port))
return UdpTransportTarget(
(str(self.device.address), self.device.port), timeout=self.device.timeout, retries=self.retries
stveit marked this conversation as resolved.
Show resolved Hide resolved
)


def _convert_varbind(ident: ObjectIdentity, value: ObjectType) -> SNMPVarBind:
Expand Down
12 changes: 8 additions & 4 deletions src/zino/tasks/reachabletask.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ async def run(self):
"""Checks if device is reachable. Schedules extra reachability checks if not."""
if self._extra_job_is_running():
return
result = await self._get_sysuptime()
if not result:
try:
await self._get_sysuptime()
except TimeoutError:
Comment on lines +24 to +26
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A note for future reference (not to be fixed by this PR): Depending on the original Zino implementation, this function should probably record the device boot time to its state. I've added a utility function to DeviceState for this in one of the PRs I'm currently working on, as the original link state checker did this.

_logger.debug("Device %s is not reachable", self.device.name)
event, created = self.state.events.get_or_create_event(self.device.name, None, ReachabilityEvent)
if created:
Expand All @@ -38,8 +39,11 @@ async def run(self):
self._update_reachability_event_as_reachable()

async def _run_extra_job(self):
result = await self._get_sysuptime()
if result:
try:
await self._get_sysuptime()
except TimeoutError:
return
else:
_logger.debug("Device %s is reachable", self.device.name)
self._update_reachability_event_as_reachable()
self._deschedule_extra_job()
Expand Down
7 changes: 1 addition & 6 deletions src/zino/tasks/vendor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ class VendorTask(Task):
async def run(self):
vendor = await self._get_enterprise_id()
_logger.debug("%s enterprise id: %r", self.device.name, vendor)
if not vendor:
return

device = self.state.devices.get(self.device.name)
if device.enterprise_id != vendor:
Expand All @@ -24,14 +22,11 @@ async def run(self):

async def _get_enterprise_id(self) -> Optional[int]:
sysobjectid = await self._get_sysobjectid()
if not sysobjectid:
return
# This part can probably be a whole lot prettier if we learned how to utilize PySNMP properly:
if sysobjectid[: len(ENTERPRISES)] == ENTERPRISES:
return sysobjectid[len(ENTERPRISES)]

async def _get_sysobjectid(self) -> Optional[Tuple[int, ...]]:
snmp = SNMP(self.device)
result = await snmp.get("SNMPv2-MIB", "sysObjectID", 0)
if result:
return result.value
return result.value
Loading
Loading