[#301] Merged ping backend and plugin.

Also, removed `ping` response types.
This commit is contained in:
Fabio Manganiello 2023-11-24 02:06:53 +01:00
parent 71ccffa698
commit 3bb7c02572
Signed by: blacklight
GPG key ID: D90FBA7F76362774
7 changed files with 238 additions and 158 deletions

View file

@ -1,76 +0,0 @@
import time
from typing import List, Tuple
from platypush.backend import Backend
from platypush.context import get_plugin
from platypush.message.event.ping import HostUpEvent, HostDownEvent
from platypush.utils.workers import Worker, Workers
class PingBackend(Backend):
"""
This backend allows you to ping multiple remote hosts at regular intervals.
"""
class Pinger(Worker):
def __init__(self, *args, **kwargs):
self.timeout = kwargs.pop('timeout')
self.count = kwargs.pop('count')
super().__init__(*args, **kwargs)
def process(self, host: str) -> Tuple[str, bool]:
pinger = get_plugin('ping')
response = pinger.ping(host, timeout=self.timeout, count=self.count).output
return host, response['success'] is True
def __init__(
self,
hosts: List[str],
timeout: float = 5.0,
interval: float = 60.0,
count: int = 1,
*args,
**kwargs
):
"""
:param hosts: List of IP addresses or host names to monitor.
:param timeout: Ping timeout.
:param interval: Interval between two scans.
:param count: Number of pings per host. A host will be considered down
if all the ping requests fail.
"""
super().__init__(*args, **kwargs)
self.hosts = {h: None for h in hosts}
self.timeout = timeout
self.interval = interval
self.count = count
def run(self):
super().run()
self.logger.info(
'Starting ping backend with {} hosts to monitor'.format(len(self.hosts))
)
while not self.should_stop():
workers = Workers(10, self.Pinger, timeout=self.timeout, count=self.count)
with workers:
for host in self.hosts.keys():
workers.put(host)
for response in workers.responses:
host, is_up = response
if is_up != self.hosts[host]:
if is_up:
self.bus.post(HostUpEvent(host))
else:
self.bus.post(HostDownEvent(host))
self.hosts[host] = is_up
time.sleep(self.interval)
# vim:sw=4:ts=4:et:

View file

@ -1,14 +0,0 @@
manifest:
events:
platypush.message.event.ping.HostDownEvent: if a host stops responding ping requests
platypush.message.event.ping.HostUpEvent: if a host starts responding ping requests
install:
apt:
- iputils-ping
dnf:
- iputils
pacman:
- iputils
package: platypush.backend.ping
type: backend

View file

@ -2,21 +2,22 @@ from platypush.message.event import Event
class PingEvent(Event):
""" Ping event, used for testing purposes """
"""Ping event, used for testing purposes"""
def __init__(self, message=None, *args, **kwargs):
def __init__(self, *args, message=None, **kwargs):
"""
:param message: Ping message
:type message: object
"""
super().__init__(message=message, *args, **kwargs)
super().__init__(*args, message=message, **kwargs)
class HostDownEvent(Event):
"""
Event triggered when a remote host stops responding ping requests.
"""
def __init__(self, host: str, *args, **kwargs):
super().__init__(host=host, *args, **kwargs)
@ -25,8 +26,36 @@ class HostUpEvent(Event):
"""
Event triggered when a remote host starts responding ping requests.
"""
def __init__(self, host: str, *args, **kwargs):
super().__init__(host=host, *args, **kwargs)
class PingResponseEvent(Event):
"""
Event triggered when a ping response is received.
"""
def __init__(
self,
host: str,
min: float,
max: float,
avg: float,
mdev: float,
*args,
**kwargs
):
"""
:param host: Remote host IP or name.
:param min: Minimum round-trip time (in ms).
:param max: Maximum round-trip time (in ms).
:param avg: Average round-trip time (in ms).
:param mdev: Standard deviation of the round-trip time (in ms).
"""
super().__init__(
host=host, min=min, max=max, avg=avg, mdev=mdev, *args, **kwargs
)
# vim:sw=4:ts=4:et:

View file

@ -1,27 +0,0 @@
from typing import Optional
from platypush.message.response import Response
class PingResponse(Response):
# noinspection PyShadowingBuiltins
def __init__(self,
host: str,
success: bool,
*args,
min: Optional[float] = None,
max: Optional[float] = None,
avg: Optional[float] = None,
mdev: Optional[float] = None,
**kwargs):
super().__init__(*args, output={
'host': host,
'success': success,
'min': min,
'max': max,
'avg': avg,
'mdev': mdev,
}, **kwargs)
# vim:sw=4:ts=4:et:

View file

@ -1,43 +1,113 @@
import logging
import re
import subprocess
import sys
from concurrent.futures import ProcessPoolExecutor
from typing import Dict, Optional, List
from typing import Optional, List
from platypush.message.response.ping import PingResponse
from platypush.plugins import Plugin, action
from platypush.message.event.ping import HostUpEvent, HostDownEvent, PingResponseEvent
from platypush.plugins import RunnablePlugin, action
from platypush.schemas.ping import PingResponseSchema
PING_MATCHER = re.compile(
r"(?P<min>\d+.\d+)/(?P<avg>\d+.\d+)/(?P<max>\d+.\d+)/(?P<mdev>\d+.\d+)"
)
PING_MATCHER_BUSYBOX = re.compile(
r"(?P<min>\d+.\d+)/(?P<avg>\d+.\d+)/(?P<max>\d+.\d+)"
)
PING_MATCHER_BUSYBOX = re.compile(r"(?P<min>\d+.\d+)/(?P<avg>\d+.\d+)/(?P<max>\d+.\d+)")
WIN32_PING_MATCHER = re.compile(r"(?P<min>\d+)ms.+(?P<max>\d+)ms.+(?P<avg>\d+)ms")
class PingPlugin(Plugin):
def ping(host: str, ping_cmd: List[str], logger: logging.Logger) -> dict:
err_response = dict(
PingResponseSchema().dump(
{
"host": host,
"success": False,
}
)
)
with subprocess.Popen(
ping_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
) as pinger:
try:
out = pinger.communicate()
if sys.platform == "win32":
match = WIN32_PING_MATCHER.search(str(out).rsplit("\n", maxsplit=1)[-1])
min_val, avg_val, max_val = match.groups()
mdev_val = None
elif "max/" not in str(out):
match = PING_MATCHER_BUSYBOX.search(
str(out).rsplit("\n", maxsplit=1)[-1]
)
assert match is not None, f"No match found in {out}"
min_val, avg_val, max_val = match.groups()
mdev_val = None
else:
match = PING_MATCHER.search(str(out).rsplit("\n", maxsplit=1)[-1])
assert match is not None, f"No match found in {out}"
min_val, avg_val, max_val, mdev_val = match.groups()
return dict(
PingResponseSchema().dump(
{
"host": host,
"success": True,
"min": float(min_val),
"max": float(max_val),
"avg": float(avg_val),
"mdev": float(mdev_val) if mdev_val is not None else None,
}
)
)
except Exception as e:
if not isinstance(e, (subprocess.CalledProcessError, KeyboardInterrupt)):
logger.warning("Error while pinging host %s: %s", host, e)
logger.exception(e)
pinger.kill()
pinger.wait()
return err_response
class PingPlugin(RunnablePlugin):
"""
Perform ICMP network ping on remote hosts.
This integration allows you to:
1. Programmatic ping a remote host.
2. Monitor the status of a remote host.
"""
def __init__(self, executable: str = 'ping', count: int = 1, timeout: float = 5.0, **kwargs):
def __init__(
self,
executable: str = 'ping',
count: int = 1,
timeout: float = 5.0,
hosts: Optional[List[str]] = None,
poll_interval: float = 10.0,
**kwargs,
):
"""
:param executable: Path to the ``ping`` executable. Default: the first ``ping`` executable found in PATH.
:param count: Default number of packets that should be sent (default: 1).
:param timeout: Default timeout before failing a ping request (default: 5 seconds).
:param hosts: List of hosts to monitor. If not specified then no hosts will be monitored.
:param poll_interval: How often the hosts should be monitored (default: 10 seconds).
"""
super().__init__(**kwargs)
super().__init__(poll_interval=poll_interval, **kwargs)
self.executable = executable
self.count = count
self.timeout = timeout
self.hosts: Dict[str, Optional[bool]] = {h: None for h in (hosts or [])}
def _get_ping_cmd(self, host: str, count: int, timeout: float) -> List[str]:
if sys.platform == 'win32':
return [
return [ # noqa
self.executable,
'-n',
str(count or self.count),
@ -58,38 +128,78 @@ class PingPlugin(Plugin):
]
@action
def ping(self, host: str, count: Optional[int] = None, timeout: Optional[float] = None) -> PingResponse:
def ping(
self, host: str, count: Optional[int] = None, timeout: Optional[float] = None
) -> dict:
"""
Ping a remote host.
:param host: Remote host IP or name
:param count: Number of packets that should be sent (default: 1).
:param timeout: Timeout before failing a ping request (default: 5 seconds).
:param count: Overrides the configured number of packets that should be
sent (default: 1).
:param timeout: Overrides the configured timeout before failing a ping
request (default: 5 seconds).
:return: .. schema:: ping.PingResponseSchema
"""
return self._ping(host=host, count=count, timeout=timeout)
count = count or self.count
timeout = timeout or self.timeout
def _ping(
self, host: str, count: Optional[int] = None, timeout: Optional[float] = None
) -> dict:
return ping(
host=host,
ping_cmd=self._get_ping_cmd(
host=host, count=count or self.count, timeout=timeout or self.timeout
),
logger=self.logger,
)
pinger = subprocess.Popen(
self._get_ping_cmd(host, count=count, timeout=timeout),
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
def _process_ping_result(self, result: dict):
host = result.get("host")
if host is None:
return
try:
out = pinger.communicate()
if sys.platform == "win32":
match = WIN32_PING_MATCHER.search(str(out).split("\n")[-1])
min_val, avg_val, max_val = match.groups()
mdev_val = None
elif "max/" not in str(out):
match = PING_MATCHER_BUSYBOX.search(str(out).split("\n")[-1])
min_val, avg_val, max_val = match.groups()
mdev_val = None
else:
match = PING_MATCHER.search(str(out).split("\n")[-1])
min_val, avg_val, max_val, mdev_val = match.groups()
success = result.get("success")
if success is None:
return
return PingResponse(host=host, success=True, min=min_val, max=max_val, avg=avg_val, mdev=mdev_val)
except (subprocess.CalledProcessError, AttributeError):
return PingResponse(host=host, success=False)
if success:
if self.hosts.get(host) in (False, None):
self._bus.post(HostUpEvent(host=host))
result.pop("success", None)
self._bus.post(PingResponseEvent(**result))
self.hosts[host] = True
else:
if self.hosts.get(host) in (True, None):
self._bus.post(HostDownEvent(host=host))
self.hosts[host] = False
def main(self):
# Don't start the thread if no monitored hosts are configured
if not self.hosts:
self.wait_stop()
return
while not self.should_stop():
try:
with ProcessPoolExecutor() as executor:
for result in executor.map(
ping,
self.hosts.keys(),
[
self._get_ping_cmd(h, self.count, self.timeout)
for h in self.hosts.keys()
],
[self.logger] * len(self.hosts),
):
self._process_ping_result(result)
except KeyboardInterrupt:
break
except Exception as e:
self.logger.warning("Error while pinging hosts: %s", e)
self.logger.exception(e)
finally:
self.wait_stop(self.poll_interval)
# vim:sw=4:ts=4:et:

View file

@ -1,5 +1,8 @@
manifest:
events: {}
events:
- platypush.message.event.ping.HostDownEvent
- platypush.message.event.ping.HostUpEvent
- platypush.message.event.ping.PingResponseEvent
install:
apt:
- iputils-ping
@ -7,6 +10,5 @@ manifest:
- iputils
pacman:
- iputils
package: platypush.plugins.ping
type: plugin

56
platypush/schemas/ping.py Normal file
View file

@ -0,0 +1,56 @@
from marshmallow import fields
from marshmallow.schema import Schema
class PingResponseSchema(Schema):
"""
Ping response schema.
"""
host = fields.String(
required=True,
metadata={
"description": "Remote host IP or name",
"example": "platypush.tech",
},
)
success = fields.Boolean(
required=True,
metadata={
"description": "True if the ping was successful, False otherwise",
"example": True,
},
)
min = fields.Float(
required=False,
metadata={
"description": "Minimum round-trip time (in ms)",
"example": 0.1,
},
)
max = fields.Float(
required=False,
metadata={
"description": "Maximum round-trip time (in ms)",
"example": 0.2,
},
)
avg = fields.Float(
required=False,
metadata={
"description": "Average round-trip time (in ms)",
"example": 0.15,
},
)
mdev = fields.Float(
required=False,
metadata={
"description": "Standard deviation of the round-trip time (in ms)",
"example": 0.05,
},
)