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 all 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
167 changes: 95 additions & 72 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 import errind
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,6 +61,39 @@
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 entity.
"""


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

pass


class ErrorStatus(SnmpError):
"""Raised if an SNMP entity includes a non-zero error status in its response PDU.
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"""

Expand All @@ -67,15 +102,15 @@
def __init__(self, device: PollDevice):
self.device = device

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 +121,55 @@
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: Union[str, errind.ErrorIndication],
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, errind.RequestTimedOut):
raise TimeoutError(str(error_indication))
else:
raise ErrorIndication(str(error_indication))

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

View check run for this annotation

Codecov / codecov/patch

src/zino/snmp.py#L142

Added line #L142 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 152 in src/zino/snmp.py

View check run for this annotation

Codecov / codecov/patch

src/zino/snmp.py#L152

Added line #L152 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 +179,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,15 +197,11 @@
"""
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)
Expand Down Expand Up @@ -206,13 +238,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 +278,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 +296,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 +323,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 +359,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 366 in src/zino/snmp.py

View check run for this annotation

Codecov / codecov/patch

src/zino/snmp.py#L366

Added line #L366 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 +383,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.device.retries
)


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