refactor: extract ping helpers into pingutil
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
This commit is contained in:
@@ -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"))
|
||||||
@@ -40,9 +40,9 @@ threshold_configs:
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from hbd.client.pingutil import PingUnavailable, run_ping
|
||||||
from hbd.client.plugin import MonitorPlugin
|
from hbd.client.plugin import MonitorPlugin
|
||||||
|
|
||||||
|
|
||||||
@@ -84,51 +84,13 @@ class PingMonitorPlugin(MonitorPlugin):
|
|||||||
|
|
||||||
async def _ping(self, host: str) -> Dict[str, float]:
|
async def _ping(self, host: str) -> Dict[str, float]:
|
||||||
"""Run a system ping command and return rtt_min/avg/max/loss."""
|
"""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:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
return await run_ping(host, self.count, self.timeout)
|
||||||
*cmd,
|
except (asyncio.TimeoutError, PingUnavailable, OSError) as e:
|
||||||
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:
|
|
||||||
self.logger.warning("ping_monitor: ping failed for %s: %s", host, e)
|
self.logger.warning("ping_monitor: ping failed for %s: %s", host, e)
|
||||||
return {"rtt_min": float("inf"), "rtt_avg": float("inf"),
|
return {"rtt_min": float("inf"), "rtt_avg": float("inf"),
|
||||||
"rtt_max": float("inf"), "loss": 100.0}
|
"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]:
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
data: Dict[str, Any] = {}
|
data: Dict[str, Any] = {}
|
||||||
tasks = {host: asyncio.create_task(self._ping(host)) for host in self.hosts}
|
tasks = {host: asyncio.create_task(self._ping(host)) for host in self.hosts}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user