Skip to content

Commit

Permalink
common measurement helpers beginning with decorator require_lan
Browse files Browse the repository at this point in the history
`require_lan` causes decorated measurement function to *first* ensure that
host networking and lan are functioning (localhost and network gateway
are up).
  • Loading branch information
jesteria committed Jan 24, 2023
1 parent 714d30b commit 8a27743
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 0 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ packages = [{include = "netrics", from = "src"}]
[tool.poetry.dependencies]
python = "^3.8"
fate-scheduler = "0.1.0-rc.2"
netifaces = "^0.11.0"

[tool.poetry.dev-dependencies]

Expand Down
1 change: 1 addition & 0 deletions src/netrics/measurement/common/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .connectivity import require_lan # noqa: F401
5 changes: 5 additions & 0 deletions src/netrics/measurement/common/connectivity/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Measurement utilities to ensure network connectivity."""

from .command import ping_dest_once, ping_dest_succeed_once # noqa: F401

from .decorator import require_lan # noqa: F401
100 changes: 100 additions & 0 deletions src/netrics/measurement/common/connectivity/command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Common measurement commands to ensure network connectivity."""
import abc
import subprocess

from fate.util.abstract import abstractmember


DEFAULT_DEADLINE = 5
DEFAULT_ATTEMPTS = 3


def ping_dest_once(dest, deadline=DEFAULT_DEADLINE):
"""ping `dest` once (`-c 1`) with given `deadline` (`-w DEADLINE`).
`deadline` defaults to `{DEFAULT_DEADLINE}`.
Raises `subprocess.CalledProcessError` if a response packet is not
received after `deadline` or on any other network or ping error.
"""
subprocess.run(
(
'ping',
'-c', '1',
'-w', str(deadline),
dest,
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)


ping_dest_once.__doc__ = ping_dest_once.__doc__.format_map(globals())


class PingResult(abc.ABC):

_success_ = abstractmember()

def __init__(self, returncode, attempts):
self.returncode = returncode
self.attempts = attempts

def __bool__(self):
return self._success_


class PingSuccess(PingResult):

_success_ = True


class PingFailure(PingResult):

_success_ = False


def ping_dest_succeed_once(dest, attempts=DEFAULT_ATTEMPTS, **kwargs):
"""ping `dest` *until* a single response is received.
Returns an instance of a subclass of `PingResult` with the attribute
`attempts` reflecting the number of attempts made – `PingSuccess` if
a response is received within `attempts` requests, or `PingFailure`
if not. `PingSuccess` will evalute to `True` and `PingFailure` to
`False`.
`attempts` defaults to `{DEFAULT_ATTEMPTS}`.
See also: `ping_dest_once`.
"""
# note: this functionality was apparently in BSD ping (or something)
# but never in GNU...

if not isinstance(attempts, int):
raise TypeError(f'attempts expected int not {attempts.__class__.__name__}')

if attempts < 1:
raise ValueError("attempts must be at least 1")

for count in range(1, attempts + 1):
try:
ping_dest_once(dest, **kwargs)
except subprocess.CalledProcessError as exc:
failure_returncode = exc.returncode

if failure_returncode > 1:
# this is more than a response failure: quit and fail
break
else:
# received a response: success
return PingSuccess(0, count)

# returned 1 more than `attempts` times
# or returned a worse code once: fail
return PingFailure(failure_returncode, count)


ping_dest_succeed_once.__doc__ = ping_dest_succeed_once.__doc__.format_map(globals())
108 changes: 108 additions & 0 deletions src/netrics/measurement/common/connectivity/decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Measurement decorators to ensure network connectivity."""
import functools
import shutil
import subprocess

import netifaces

from netrics import task

from . import command


class RequirementError(Exception):

def __init__(self, returncode):
super().__init__(returncode)
self.returncode = returncode


class require_lan:
"""Decorator to extend a network measurement function with
preliminary network checks.
`require_lan` wraps the decorated function such that it will first
ping the host (`localhost`), and then the default gateway, prior to
proceeding with its own functionality. For example:
@require_lan
def main():
# Now we know at least that the LAN is operational.
#
# For example, let's now attempt to access the Google DNS servers:
#
result = subprocess.run(
['ping', '-c', '1', '8.8.8.8'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return result.returncode
"""
RequirementError = RequirementError

def __init__(self, func):
# assign func's __module__, __name__, etc.
# (but DON'T update __dict__)
#
# (also assigns __wrapped__)
functools.update_wrapper(self, func, updated=())

def __call__(self, *args, **kwargs):
try:
self.check_requirements()
except self.RequirementError as exc:
return exc.returncode

return self.__wrapped__(*args, **kwargs)

def check_requirements(self):
"""Check for ping executable, localhost and gateway."""

# ensure ping on PATH
ping_path = shutil.which('ping')
if ping_path is None:
task.log.critical("ping executable not found")
raise self.RequirementError(task.status.file_missing)

# check network interface up
try:
command.ping_dest_once('localhost')
except subprocess.CalledProcessError:
task.log.critical(
dest='localhost',
status='Error',
msg="host network interface down",
)
raise self.RequirementError(task.status.os_error)
else:
task.log.debug(dest='localhost', status='OK')

# check route to gateway
gateways = netifaces.gateways()

try:
(gateway_addr, _iface) = gateways['default'][netifaces.AF_INET]
except KeyError:
task.log.critical("default gateway not found")
raise self.RequirementError(task.status.os_error)

gateway_up = command.ping_dest_succeed_once(gateway_addr)

if gateway_up:
task.log.log(
'DEBUG' if gateway_up.attempts == 1 else 'WARNING',
dest='gateway',
addr=gateway_addr,
tries=gateway_up.attempts,
status='OK',
)
else:
task.log.critical(
dest='gateway',
addr=gateway_addr,
tries=gateway_up.attempts,
status=f'Error ({gateway_up.returncode})',
msg="network gateway inaccessible",
)
raise self.RequirementError(task.status.no_host)

0 comments on commit 8a27743

Please sign in to comment.