feat(ping): add a generic ping sweep every driver inherits
Sweeping a range is orchestration, not device mechanics: the only vendor-specific part is executing a single ping, and NAPALM already standardises that. PingSweepMixin therefore owns the loop, the reply parsing, the target cap and the progress reporting, and is mixed into DeviceTypeDriver so any driver implementing ping() becomes a usable sweep source without writing sweep code of its own. driver_supports_ping() answers "can this driver ping?" by introspection instead of a hand-maintained list, with SUPPORTS_PING = False as the opt-out for a driver that inherits a ping it cannot actually use. The generic implementation is deliberately sequential — a NAPALM connection is a single session and not safe to drive from several threads at once. A driver whose device offers something faster overrides ping_sweep and keeps the return shape; see napalm-opnsense's batched job API version.
This commit is contained in:
@@ -36,6 +36,7 @@ from napalm_device_types.config_lifecycle import ConfigLifecycleMixin
|
||||
from napalm_device_types.firewall import FirewallDriver
|
||||
from napalm_device_types.hypervisor import HypervisorDriver
|
||||
from napalm_device_types.os import OSDriver
|
||||
from napalm_device_types.ping_sweep import PingSweepMixin, driver_supports_ping
|
||||
from napalm_device_types.residential_gateway import ResidentialGatewayDriver
|
||||
from napalm_device_types.storage import StorageDriver
|
||||
from napalm_device_types.switch import SwitchDriver
|
||||
@@ -48,8 +49,10 @@ __all__ = [
|
||||
"FirewallDriver",
|
||||
"HypervisorDriver",
|
||||
"OSDriver",
|
||||
"PingSweepMixin",
|
||||
"PortSpec",
|
||||
"ResidentialGatewayDriver",
|
||||
"StorageDriver",
|
||||
"SwitchDriver",
|
||||
"driver_supports_ping",
|
||||
]
|
||||
|
||||
@@ -12,6 +12,8 @@ from typing import NamedTuple
|
||||
|
||||
from napalm.base import NetworkDriver
|
||||
|
||||
from napalm_device_types.ping_sweep import PingSweepMixin
|
||||
|
||||
|
||||
class FingerprintRule(NamedTuple):
|
||||
"""Single pattern-matching rule for device fingerprinting.
|
||||
@@ -45,13 +47,15 @@ class PortSpec(NamedTuple):
|
||||
mandatory: bool = False
|
||||
|
||||
|
||||
class DeviceTypeDriver(NetworkDriver):
|
||||
class DeviceTypeDriver(PingSweepMixin, NetworkDriver):
|
||||
"""Common base for all netOrk device-type drivers.
|
||||
|
||||
Sits between napalm.base.NetworkDriver and the type-specific abstract
|
||||
classes (FirewallDriver, SwitchDriver, …). Adds the fingerprinting
|
||||
interface consumed by the discovery subsystem; does not implement any
|
||||
NAPALM abstract methods.
|
||||
interface consumed by the discovery subsystem plus the generic
|
||||
``ping_sweep()`` from :class:`~napalm_device_types.ping_sweep.PingSweepMixin`
|
||||
(usable by every driver that implements NAPALM's ``ping()``); does not
|
||||
implement any NAPALM abstract methods.
|
||||
|
||||
Override these class attributes in each concrete driver:
|
||||
|
||||
|
||||
@@ -801,3 +801,26 @@ class StorageTargetDict(TypedDict):
|
||||
type: str # Backend type: "dir", "lvmthin", "zfspool", "nfs", etc.
|
||||
total_gb: float # Total capacity in gigabytes
|
||||
available_gb: float # Free capacity in gigabytes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ping sweep (shared across device types)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PingSweepEntryDict(TypedDict):
|
||||
"""Outcome of a single ``ping`` inside a sweep (see ``PingSweepMixin``)."""
|
||||
|
||||
ip: str # destination that was probed
|
||||
alive: bool # True if at least one probe was answered
|
||||
rtt_ms: Optional[float] # average round-trip time in ms; None if unreachable
|
||||
error: NotRequired[str] # driver/transport error for this destination
|
||||
|
||||
|
||||
class PingSweepResultDict(TypedDict):
|
||||
"""Result of a ``ping_sweep()`` call."""
|
||||
|
||||
entries: List[PingSweepEntryDict] # one entry per probed destination, in input order
|
||||
scanned: int # destinations actually probed
|
||||
alive_count: int # entries with alive=True
|
||||
truncated: bool # True if targets were dropped at the sweep cap
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Generic ICMP sweep on top of the NAPALM-standard ``ping()``.
|
||||
|
||||
Sweeping a range of addresses is orchestration, not device mechanics: the
|
||||
only vendor-specific part is how a single ``ping`` is executed, and NAPALM
|
||||
already standardises that. So the loop, the reply parsing, the target cap
|
||||
and the progress reporting live here once, and a concrete driver only has
|
||||
to implement ``ping()`` to become a usable sweep source.
|
||||
|
||||
A driver whose device offers a *faster* sweep mechanism (a batch API, a
|
||||
single shell command that pings many hosts in parallel, an ARP-assisted
|
||||
scan) overrides :meth:`PingSweepMixin.ping_sweep` and keeps the same return
|
||||
shape — see ``napalm-opnsense`` for an example.
|
||||
|
||||
The generic implementation is deliberately **sequential**: a NAPALM
|
||||
connection is a single session (SSH channel, HTTP client) and is not safe to
|
||||
drive from several threads at once. Callers that need many addresses covered
|
||||
quickly should either use a driver with its own parallel override or cap the
|
||||
target list (see ``PING_SWEEP_MAX_TARGETS``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterable, List, Optional
|
||||
|
||||
from napalm.base import NetworkDriver
|
||||
|
||||
from napalm_device_types.models import PingSweepEntryDict, PingSweepResultDict
|
||||
|
||||
|
||||
def driver_supports_ping(driver_cls: type) -> bool:
|
||||
"""Whether *driver_cls* can actually execute ``ping()``.
|
||||
|
||||
True when the class provides its own ``ping`` implementation instead of
|
||||
inheriting NAPALM's ``NotImplementedError`` stub. A driver that inherits
|
||||
a working ``ping`` but cannot use it (unsupported firmware, disabled
|
||||
service) opts out by setting ``SUPPORTS_PING = False``.
|
||||
"""
|
||||
if getattr(driver_cls, "SUPPORTS_PING", None) is False:
|
||||
return False
|
||||
ping_impl = getattr(driver_cls, "ping", None)
|
||||
if ping_impl is None:
|
||||
return False
|
||||
return ping_impl is not getattr(NetworkDriver, "ping", None)
|
||||
|
||||
|
||||
class PingSweepMixin:
|
||||
"""Adds :meth:`ping_sweep` to any driver that implements ``ping()``.
|
||||
|
||||
Mixed into :class:`~napalm_device_types.base.DeviceTypeDriver`, so every
|
||||
device-type driver inherits it; drivers without a ``ping()`` of their own
|
||||
simply report ``supports_ping() is False`` and raise from ``ping_sweep``.
|
||||
"""
|
||||
|
||||
#: Upper bound on destinations probed in one sweep. The sequential
|
||||
#: default implementation costs roughly ``timeout`` seconds per silent
|
||||
#: host, so an uncapped /24 would keep a device session busy for minutes.
|
||||
#: Drivers with a parallel mechanism raise this.
|
||||
PING_SWEEP_MAX_TARGETS: ClassVar[int] = 256
|
||||
|
||||
#: ``False`` opts a driver out of ping sweeps even though it implements
|
||||
#: ``ping()``. ``None`` (the default) means "decide by introspection".
|
||||
SUPPORTS_PING: ClassVar[Optional[bool]] = None
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def ping(
|
||||
self,
|
||||
destination: str,
|
||||
source: str = "",
|
||||
ttl: int = 255,
|
||||
timeout: int = 2,
|
||||
size: int = 100,
|
||||
count: int = 5,
|
||||
vrf: str = "",
|
||||
) -> Dict[str, Any]: ...
|
||||
|
||||
@classmethod
|
||||
def supports_ping(cls) -> bool:
|
||||
"""Whether this driver class can be used as a ping-sweep source."""
|
||||
return driver_supports_ping(cls)
|
||||
|
||||
def ping_sweep(
|
||||
self,
|
||||
destinations: Iterable[str],
|
||||
*,
|
||||
count: int = 1,
|
||||
timeout: int = 1,
|
||||
max_targets: Optional[int] = None,
|
||||
on_progress: Optional[Callable[[int, int], None]] = None,
|
||||
should_stop: Optional[Callable[[], bool]] = None,
|
||||
) -> PingSweepResultDict:
|
||||
"""Ping every address in *destinations* and report who answered.
|
||||
|
||||
:param destinations: IP addresses / hostnames to probe, in order.
|
||||
:param count: probes per destination — 1 is enough for liveness.
|
||||
:param timeout: seconds to wait for a reply per destination.
|
||||
:param max_targets: cap for this call; defaults to
|
||||
``PING_SWEEP_MAX_TARGETS``. Excess destinations are dropped and
|
||||
``truncated`` is set in the result.
|
||||
:param on_progress: called as ``(done, total)`` after each probe.
|
||||
:param should_stop: polled before each probe; returning True ends the
|
||||
sweep early (cancelled job, shutting-down worker).
|
||||
:raises NotImplementedError: if the driver has no ``ping()``.
|
||||
"""
|
||||
if not self.supports_ping():
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not implement ping(); cannot run a ping sweep"
|
||||
)
|
||||
|
||||
limit = self.PING_SWEEP_MAX_TARGETS if max_targets is None else max_targets
|
||||
targets = list(destinations)
|
||||
truncated = len(targets) > limit
|
||||
if truncated:
|
||||
targets = targets[:limit]
|
||||
|
||||
total = len(targets)
|
||||
entries: List[PingSweepEntryDict] = []
|
||||
for done, destination in enumerate(targets, start=1):
|
||||
if should_stop is not None and should_stop():
|
||||
break
|
||||
entries.append(self._ping_once(destination, count=count, timeout=timeout))
|
||||
if on_progress is not None:
|
||||
on_progress(done, total)
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"scanned": len(entries),
|
||||
"alive_count": sum(1 for entry in entries if entry["alive"]),
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────────────
|
||||
|
||||
def _ping_once(self, destination: str, *, count: int, timeout: int) -> PingSweepEntryDict:
|
||||
"""One probe, never raising — a dead session must not abort the sweep."""
|
||||
try:
|
||||
reply = self.ping(destination, count=count, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - any driver error is just "no answer"
|
||||
return {"ip": destination, "alive": False, "rtt_ms": None, "error": str(exc)}
|
||||
return self._parse_ping_reply(destination, reply)
|
||||
|
||||
@staticmethod
|
||||
def _parse_ping_reply(destination: str, reply: Any) -> PingSweepEntryDict:
|
||||
"""Map a NAPALM ``ping()`` reply onto a sweep entry."""
|
||||
if not isinstance(reply, dict) or "success" not in reply:
|
||||
error = "malformed ping reply"
|
||||
if isinstance(reply, dict) and reply.get("error"):
|
||||
error = str(reply["error"])
|
||||
return {"ip": destination, "alive": False, "rtt_ms": None, "error": error}
|
||||
|
||||
success = reply.get("success") or {}
|
||||
probes_sent = _as_int(success.get("probes_sent"))
|
||||
packet_loss = _as_int(success.get("packet_loss"), default=probes_sent)
|
||||
alive = bool(success.get("results")) or probes_sent > packet_loss
|
||||
if not alive:
|
||||
return {"ip": destination, "alive": False, "rtt_ms": None}
|
||||
return {"ip": destination, "alive": True, "rtt_ms": _as_float(success.get("rtt_avg"))}
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _as_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
Reference in New Issue
Block a user