diff --git a/hbd/client/pingutil.py b/hbd/client/pingutil.py new file mode 100644 index 0000000..70d5b4c --- /dev/null +++ b/hbd/client/pingutil.py @@ -0,0 +1,73 @@ +"""System ping helpers shared by ping_monitor and the connectivity probe.""" + +import asyncio +import re +import sys +from typing import Dict, List, Optional + +_INF = float("inf") + + +class PingUnavailable(Exception): + """The system ping binary could not be executed.""" + + +def ping_command(host: str, count: int, timeout: int, family: Optional[int] = None) -> List[str]: + """Build the platform ping command. family is 4, 6 or None (let ping decide).""" + if sys.platform == "win32": + cmd = ["ping", "-n", str(count), "-w", str(timeout * 1000)] + if family: + cmd.append(f"-{family}") + return cmd + [host] + if sys.platform == "darwin" and family: + if family == 6: + return ["ping6", "-c", str(count), host] + return ["ping", "-c", str(count), "-W", str(timeout * 1000), host] + cmd = ["ping", "-c", str(count), "-W", str(timeout)] + if family: + cmd.append(f"-{family}") + return cmd + [host] + + +def parse_ping_output(output: str) -> Dict[str, float]: + """Parse rtt_min/avg/max/loss from ping output (Linux, macOS, BSD).""" + loss = 100.0 + loss_match = re.search(r"(\d+(?:\.\d+)?)\s*%\s*packet\s*loss", output) + if loss_match: + loss = float(loss_match.group(1)) + + # Linux: "rtt min/avg/max/mdev = x/x/x/x ms"; macOS: "round-trip min/avg/max/stddev = ..." + rtt_match = re.search( + r"(?:rtt|round-trip)\s+min/avg/max/\S+\s*=\s*([\d.]+)/([\d.]+)/([\d.]+)", + output, + ) + if rtt_match: + return { + "rtt_min": float(rtt_match.group(1)), + "rtt_avg": float(rtt_match.group(2)), + "rtt_max": float(rtt_match.group(3)), + "loss": loss, + } + return {"rtt_min": _INF, "rtt_avg": _INF, "rtt_max": _INF, "loss": loss} + + +async def run_ping(host: str, count: int, timeout: int, family: Optional[int] = None) -> Dict[str, float]: + """Run ping and return parsed metrics. + + Raises PingUnavailable if the ping binary is missing and asyncio.TimeoutError + (after killing the process) if it hangs. + """ + cmd = ping_command(host, count, timeout, family) + try: + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError as e: + raise PingUnavailable(str(e)) from e + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout * count + 2) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise + return parse_ping_output(stdout.decode(errors="replace")) diff --git a/hbd/client/plugins/ping_monitor.py b/hbd/client/plugins/ping_monitor.py index 67ca4c3..9b16ef3 100644 --- a/hbd/client/plugins/ping_monitor.py +++ b/hbd/client/plugins/ping_monitor.py @@ -40,9 +40,9 @@ threshold_configs: import asyncio import re -import sys from typing import Any, Dict, Optional +from hbd.client.pingutil import PingUnavailable, run_ping from hbd.client.plugin import MonitorPlugin @@ -84,51 +84,13 @@ class PingMonitorPlugin(MonitorPlugin): async def _ping(self, host: str) -> Dict[str, float]: """Run a system ping command and return rtt_min/avg/max/loss.""" - if sys.platform == "win32": - cmd = ["ping", "-n", str(self.count), "-w", str(self.timeout * 1000), host] - else: - cmd = ["ping", "-c", str(self.count), "-W", str(self.timeout), host] - try: - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, _ = await asyncio.wait_for( - proc.communicate(), - timeout=self.timeout * self.count + 2, - ) - output = stdout.decode(errors="replace") - except (asyncio.TimeoutError, FileNotFoundError, OSError) as e: + return await run_ping(host, self.count, self.timeout) + except (asyncio.TimeoutError, PingUnavailable, OSError) as e: self.logger.warning("ping_monitor: ping failed for %s: %s", host, e) return {"rtt_min": float("inf"), "rtt_avg": float("inf"), "rtt_max": float("inf"), "loss": 100.0} - # Parse packet loss - loss = 100.0 - loss_match = re.search(r"(\d+(?:\.\d+)?)\s*%\s*packet\s*loss", output) - if loss_match: - loss = float(loss_match.group(1)) - - # Parse rtt min/avg/max — Linux: "rtt min/avg/max/mdev = x/x/x/x ms" - # macOS: "round-trip min/avg/max/stddev = x/x/x/x ms" - rtt_match = re.search( - r"(?:rtt|round-trip)\s+min/avg/max/\S+\s*=\s*([\d.]+)/([\d.]+)/([\d.]+)", - output, - ) - if rtt_match: - return { - "rtt_min": float(rtt_match.group(1)), - "rtt_avg": float(rtt_match.group(2)), - "rtt_max": float(rtt_match.group(3)), - "loss": loss, - } - - # Host unreachable or all packets lost - return {"rtt_min": float("inf"), "rtt_avg": float("inf"), - "rtt_max": float("inf"), "loss": loss} - async def _collect_metrics(self) -> Dict[str, Any]: data: Dict[str, Any] = {} tasks = {host: asyncio.create_task(self._ping(host)) for host in self.hosts} diff --git a/tests/test_pingutil.py b/tests/test_pingutil.py new file mode 100644 index 0000000..092b09c --- /dev/null +++ b/tests/test_pingutil.py @@ -0,0 +1,60 @@ +import sys + +import pytest + +from hbd.client import pingutil + +LINUX_OK = """PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data. +64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=11.2 ms + +--- 1.1.1.1 ping statistics --- +1 packets transmitted, 1 received, 0% packet loss, time 0ms +rtt min/avg/max/mdev = 11.234/11.234/11.234/0.000 ms +""" + +MACOS_OK = """PING 1.1.1.1 (1.1.1.1): 56 data bytes +64 bytes from 1.1.1.1: icmp_seq=0 ttl=57 time=12.3 ms + +--- 1.1.1.1 ping statistics --- +1 packets transmitted, 1 packets received, 0.0% packet loss +round-trip min/avg/max/stddev = 12.3/12.3/12.3/0.000 ms +""" + +LOST = """--- 1.1.1.1 ping statistics --- +1 packets transmitted, 0 received, 100% packet loss, time 0ms +""" + + +def test_parse_linux(): + r = pingutil.parse_ping_output(LINUX_OK) + assert r == {"rtt_min": 11.234, "rtt_avg": 11.234, "rtt_max": 11.234, "loss": 0.0} + + +def test_parse_macos(): + r = pingutil.parse_ping_output(MACOS_OK) + assert r["rtt_avg"] == 12.3 and r["loss"] == 0.0 + + +def test_parse_lost(): + r = pingutil.parse_ping_output(LOST) + assert r["rtt_avg"] == float("inf") and r["loss"] == 100.0 + + +def test_parse_garbage_is_unreachable(): + r = pingutil.parse_ping_output("ping: connect: Network is unreachable") + assert r["rtt_avg"] == float("inf") and r["loss"] == 100.0 + + +@pytest.mark.parametrize("platform,family,expected", [ + ("linux", None, ["ping", "-c", "3", "-W", "5", "h"]), + ("linux", 6, ["ping", "-c", "3", "-W", "5", "-6", "h"]), + ("linux", 4, ["ping", "-c", "3", "-W", "5", "-4", "h"]), + ("win32", None, ["ping", "-n", "3", "-w", "5000", "h"]), + ("win32", 6, ["ping", "-n", "3", "-w", "5000", "-6", "h"]), + ("darwin", None, ["ping", "-c", "3", "-W", "5", "h"]), + ("darwin", 4, ["ping", "-c", "3", "-W", "5000", "h"]), + ("darwin", 6, ["ping6", "-c", "3", "h"]), +]) +def test_ping_command(monkeypatch, platform, family, expected): + monkeypatch.setattr(sys, "platform", platform) + assert pingutil.ping_command("h", 3, 5, family) == expected