feat(mini): send p4/p6 presence flags and connectivity metric from hbc_mini.py
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
This commit is contained in:
+41
-17
@@ -10,14 +10,20 @@ Built-in plugins (always available):
|
|||||||
os_info — OS name, kernel, distro, Python version
|
os_info — OS name, kernel, distro, Python version
|
||||||
ping_monitor — ICMP ping RTT to configured hosts
|
ping_monitor — ICMP ping RTT to configured hosts
|
||||||
nagios_runner — run Nagios-compatible check scripts
|
nagios_runner — run Nagios-compatible check scripts
|
||||||
|
network_monitor — per-interface TX/RX bytes/s (Linux); per-protocol connectivity (all platforms)
|
||||||
|
|
||||||
Linux-only plugins (via /proc, no extra tools needed):
|
Linux-only plugins (via /proc, no extra tools needed):
|
||||||
cpu_monitor — CPU %, load average, core count
|
cpu_monitor — CPU %, load average, core count
|
||||||
memory_monitor — RAM and swap usage
|
memory_monitor — RAM and swap usage
|
||||||
network_monitor — per-interface TX/RX bytes/s
|
|
||||||
|
|
||||||
Cross-platform (via df subprocess):
|
Cross-platform (via df subprocess):
|
||||||
disk_monitor — per-mount usage and capacity
|
disk_monitor — per-mount usage and capacity
|
||||||
|
|
||||||
|
Presence probe (built in): reports per protocol (IPv4/IPv6) whether the host has internet
|
||||||
|
connectivity, as p4/p6 flags on every heartbeat, so hbd stops treating a missing IPv6 (or
|
||||||
|
IPv4) path as an outage. Config, in the network_monitor section of ~/.hbc.json:
|
||||||
|
probe_enabled (default true), probe_v4 (default 1.1.1.1), probe_v6 (default
|
||||||
|
2606:4700:4700::1111; IP literals only), probe_interval (default 60 s)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -969,7 +975,7 @@ def _read_net_dev() -> Dict[str, Tuple[int, int]]:
|
|||||||
|
|
||||||
class NetworkMonitorPlugin(MonitorPlugin):
|
class NetworkMonitorPlugin(MonitorPlugin):
|
||||||
name = "network_monitor"
|
name = "network_monitor"
|
||||||
description = "Network I/O rates via /proc/net/dev (Linux)"
|
description = "Network I/O rates via /proc/net/dev (Linux) and per-protocol internet connectivity"
|
||||||
interval = 300
|
interval = 300
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
@@ -978,19 +984,25 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
self.interval = cfg.get("interval", 300)
|
self.interval = cfg.get("interval", 300)
|
||||||
self.skip_ifaces: set = set(cfg.get("skip_interfaces", ["lo"]))
|
self.skip_ifaces: set = set(cfg.get("skip_interfaces", ["lo"]))
|
||||||
self._prev: Optional[Tuple[float, Dict[str, Tuple[int, int]]]] = None
|
self._prev: Optional[Tuple[float, Dict[str, Tuple[int, int]]]] = None
|
||||||
|
self._have_dev = False
|
||||||
|
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
if platform.system() != "Linux":
|
dev = _read_net_dev() if platform.system() == "Linux" else {}
|
||||||
self.skip_reason = "Linux only (/proc/net/dev not available)"
|
self._have_dev = bool(dev)
|
||||||
|
if not self._have_dev and _probe is None:
|
||||||
|
self.skip_reason = "no /proc/net/dev (Linux only) and the connectivity probe is disabled"
|
||||||
return False
|
return False
|
||||||
dev = _read_net_dev()
|
if self._have_dev:
|
||||||
if not dev:
|
self._prev = (time.time(), dev) # prime first delta
|
||||||
self.skip_reason = "/proc/net/dev not readable"
|
|
||||||
return False
|
|
||||||
self._prev = (time.time(), dev) # prime first delta
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||||
|
data: Dict[str, Any] = await self._collect_interfaces() if self._have_dev else {}
|
||||||
|
if _probe is not None:
|
||||||
|
data["connectivity"] = _probe.as_metric()
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def _collect_interfaces(self) -> Dict[str, Any]:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
curr = _read_net_dev()
|
curr = _read_net_dev()
|
||||||
if not curr or self._prev is None:
|
if not curr or self._prev is None:
|
||||||
@@ -1251,15 +1263,21 @@ async def _handle_update(conn: AsyncConnection):
|
|||||||
# Heartbeat sender
|
# Heartbeat sender
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _heartbeat_sender(conn: AsyncConnection, interval: int):
|
def _build_heartbeat_msg(conn: AsyncConnection, interval: int,
|
||||||
|
probe: Optional[_Connectivity] = None) -> Dict[str, Any]:
|
||||||
|
"""HTB payload; carries p4/p6 presence flags when the probe knows them."""
|
||||||
|
msg: Dict[str, Any] = {"acks": conn.ackcount, "rtt": conn.rtts[-1], "interval": interval}
|
||||||
|
if probe is not None:
|
||||||
|
msg.update(probe.flags())
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
async def _heartbeat_sender(conn: AsyncConnection, interval: int,
|
||||||
|
probe: Optional[_Connectivity] = None):
|
||||||
log = logging.getLogger("hbc.hb")
|
log = logging.getLogger("hbc.hb")
|
||||||
while _running:
|
while _running:
|
||||||
try:
|
try:
|
||||||
await conn.sendto({
|
await conn.sendto(_build_heartbeat_msg(conn, interval, probe))
|
||||||
"acks": conn.ackcount,
|
|
||||||
"rtt": conn.rtts[-1],
|
|
||||||
"interval": interval,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("send: %s", e)
|
log.error("send: %s", e)
|
||||||
await _sleep(interval)
|
await _sleep(interval)
|
||||||
@@ -1367,7 +1385,7 @@ def _reconfigure_syslog(level: int):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _async_main(args, cfg: Dict[str, Any]) -> int:
|
async def _async_main(args, cfg: Dict[str, Any]) -> int:
|
||||||
global _running, _shutdown_event, _active_tasks, send_shutdown
|
global _running, _shutdown_event, _active_tasks, send_shutdown, _probe
|
||||||
_running = True
|
_running = True
|
||||||
_shutdown_event = asyncio.Event()
|
_shutdown_event = asyncio.Event()
|
||||||
_active_tasks = []
|
_active_tasks = []
|
||||||
@@ -1424,6 +1442,10 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
|
|||||||
c.close()
|
c.close()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# Per-protocol internet presence probe (shared with the network_monitor plugin)
|
||||||
|
net_cfg = dict((cfg.get("plugins") or {}).get("network_monitor") or cfg.get("network_monitor") or {})
|
||||||
|
_probe = _Connectivity.from_config(net_cfg) if net_cfg.get("probe_enabled", True) else None
|
||||||
|
|
||||||
plugins = await _load_plugins(cfg)
|
plugins = await _load_plugins(cfg)
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -1437,8 +1459,10 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
|
|||||||
|
|
||||||
loop.add_signal_handler(signal.SIGHUP, _sighup)
|
loop.add_signal_handler(signal.SIGHUP, _sighup)
|
||||||
|
|
||||||
|
if _probe is not None:
|
||||||
|
_active_tasks.append(asyncio.create_task(_probe.run()))
|
||||||
for conn in connections:
|
for conn in connections:
|
||||||
_active_tasks.append(asyncio.create_task(_heartbeat_sender(conn, interval)))
|
_active_tasks.append(asyncio.create_task(_heartbeat_sender(conn, interval, _probe)))
|
||||||
|
|
||||||
if plugins and connections:
|
if plugins and connections:
|
||||||
_active_tasks.append(asyncio.create_task(_plugin_collector(connections[0], plugins)))
|
_active_tasks.append(asyncio.create_task(_plugin_collector(connections[0], plugins)))
|
||||||
|
|||||||
@@ -304,3 +304,78 @@ def test_local_address_on_this_host_never_raises():
|
|||||||
except mini._ProbeUnavailable:
|
except mini._ProbeUnavailable:
|
||||||
continue
|
continue
|
||||||
assert r is None or isinstance(r, str)
|
assert r is None or isinstance(r, str)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- heartbeat message and plugin wiring ---------------------------------------------
|
||||||
|
|
||||||
|
class _Conn:
|
||||||
|
ackcount = 3
|
||||||
|
rtts = [0.0, 12.5]
|
||||||
|
|
||||||
|
|
||||||
|
class _FlagsProbe:
|
||||||
|
def __init__(self, flags):
|
||||||
|
self._flags = flags
|
||||||
|
|
||||||
|
def flags(self):
|
||||||
|
return self._flags
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_without_probe_is_unchanged():
|
||||||
|
assert mini._build_heartbeat_msg(_Conn(), 60) == {"acks": 3, "rtt": 12.5, "interval": 60}
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_carries_presence_flags():
|
||||||
|
msg = mini._build_heartbeat_msg(_Conn(), 60, _FlagsProbe({"p4": True, "p6": False}))
|
||||||
|
assert msg == {"acks": 3, "rtt": 12.5, "interval": 60, "p4": True, "p6": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_omits_unknown_flags():
|
||||||
|
msg = mini._build_heartbeat_msg(_Conn(), 60, _FlagsProbe({}))
|
||||||
|
assert "p4" not in msg and "p6" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_flags_survive_the_wire_encoding():
|
||||||
|
msg = mini._build_heartbeat_msg(_Conn(), 60, _FlagsProbe({"p4": True, "p6": False}))
|
||||||
|
out = mini._stodict(mini._dicttos("HTB", dict(msg, name="h", id=1)))
|
||||||
|
assert out["p4"] == 1 and out["p6"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeProbe:
|
||||||
|
def as_metric(self):
|
||||||
|
return {"ipv4": {"present": True, "addr": "192.0.2.5", "gateway": "192.0.2.1", "rtt": 9.0},
|
||||||
|
"ipv6": {"present": False, "addr": None, "gateway": None, "rtt": None}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_reports_connectivity_alongside_interfaces(monkeypatch):
|
||||||
|
monkeypatch.setattr(mini, "_probe", _FakeProbe())
|
||||||
|
monkeypatch.setattr(mini.platform, "system", lambda: "Linux")
|
||||||
|
plugin = mini.NetworkMonitorPlugin({})
|
||||||
|
assert asyncio.run(plugin.initialize()) is True
|
||||||
|
data = asyncio.run(plugin._collect_metrics())
|
||||||
|
assert data["connectivity"]["ipv4"]["present"] is True
|
||||||
|
assert data["connectivity"]["ipv6"]["present"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_probe_only_mode_on_non_linux(monkeypatch):
|
||||||
|
monkeypatch.setattr(mini, "_probe", _FakeProbe())
|
||||||
|
monkeypatch.setattr(mini.platform, "system", lambda: "FreeBSD")
|
||||||
|
plugin = mini.NetworkMonitorPlugin({})
|
||||||
|
assert asyncio.run(plugin.initialize()) is True
|
||||||
|
assert list(asyncio.run(plugin._collect_metrics())) == ["connectivity"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_still_skips_on_non_linux_without_probe(monkeypatch):
|
||||||
|
monkeypatch.setattr(mini, "_probe", None)
|
||||||
|
monkeypatch.setattr(mini.platform, "system", lambda: "FreeBSD")
|
||||||
|
plugin = mini.NetworkMonitorPlugin({})
|
||||||
|
assert asyncio.run(plugin.initialize()) is False
|
||||||
|
assert plugin.skip_reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_omits_connectivity_without_probe(monkeypatch):
|
||||||
|
monkeypatch.setattr(mini, "_probe", None)
|
||||||
|
monkeypatch.setattr(mini.platform, "system", lambda: "Linux")
|
||||||
|
plugin = mini.NetworkMonitorPlugin({})
|
||||||
|
assert asyncio.run(plugin.initialize()) is True
|
||||||
|
assert "connectivity" not in asyncio.run(plugin._collect_metrics())
|
||||||
|
|||||||
Reference in New Issue
Block a user