From b4c4ec0b0bc6d5c111a575a05d7061f40cc3105c Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 16 Oct 2026 12:00:06 +0200 Subject: [PATCH] feat: SnmpHealthProfile, the OIDs a driver declares; deprecate HealthMetricsMixin (4.12.0) Drivers no longer collect over SNMP (docs/STRUCTURE.md). A driver whose device publishes its health values under OIDs of its own declares them as SNMP_HEALTH_PROFILE: per value either scalars asked in order or a table folded by first, average or max, optionally with a pattern for text answers; memory as total with used or free, or a percentage, in KiB or bytes; and how to read the interfaces. The application polls and computes. HealthMetricsMixin.get_health_metrics now warns that it is deprecated; it goes in 5.0. Closes #32 --- README.md | 17 +++++++++ napalm_device_types/__init__.py | 8 ++++ napalm_device_types/health_metrics.py | 18 ++++++++- napalm_device_types/models.py | 47 ++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_snmp_health_profile.py | 53 +++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 tests/test_snmp_health_profile.py diff --git a/README.md b/README.md index 817a8fa..d784b09 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,23 @@ is a thin bundle over them — `PackageManagementMixin`, `HealthMetricsMixin`, `HostRebootMixin` (`reboot_host`) is mixed into `DeviceTypeDriver` itself, since any device may be restartable; like the others it only declares. +**SNMP is the application's** (`docs/STRUCTURE.md`). `HealthMetricsMixin` is deprecated +since 4.12 and goes in 5.0. A driver does not collect over SNMP; a driver whose device +publishes its health values under OIDs of its own declares them as data: + +```python +class MySwitchDriver(SwitchDriver): + SNMP_HEALTH_PROFILE: ClassVar[SnmpHealthProfile] = { + "uptime": {"oids": ["1.3.6.1.2.1.1.3.0"]}, + "cpu": {"oids": ["", ""]}, + "memory": {"total": {"oids": [""]}, "free": {"oids": [""]}, "unit": "KiB"}, + } +``` + +A value is `oids` (the first that answers wins) or a `table` folded by `first`, `average` +or `max`, with an optional `pattern` for an agent that answers with text. What the profile +does not name, the application reads from the standard UCD-MIB and IF-MIB. + `KernelFactsMixin` (`get_kernel_facts`) is the exception that is mixed in by a driver rather than by a role base: what a Linux kernel has built and loaded is read the same way everywhere, so the command and its parse are concrete here and a driver supplies only diff --git a/napalm_device_types/__init__.py b/napalm_device_types/__init__.py index bad9703..60fcabc 100644 --- a/napalm_device_types/__init__.py +++ b/napalm_device_types/__init__.py @@ -108,6 +108,10 @@ from napalm_device_types.models import ( ManagementProtocolsDict, PowerAction, PowerStateDict, + SnmpHealthProfile, + SnmpInterfacesSpec, + SnmpMemorySpec, + SnmpValueSpec, ) from napalm_device_types.nat_vpn import NatVpnMixin from napalm_device_types.packages import PackageManagementMixin @@ -153,6 +157,10 @@ from napalm_device_types.storage import StorageDriver from napalm_device_types.switch import SwitchDriver __all__ = [ + "SnmpHealthProfile", + "SnmpInterfacesSpec", + "SnmpMemorySpec", + "SnmpValueSpec", "AccessPointDriver", "ByteStream", "CommandChannelMixin", diff --git a/napalm_device_types/health_metrics.py b/napalm_device_types/health_metrics.py index 631251d..50a54a5 100644 --- a/napalm_device_types/health_metrics.py +++ b/napalm_device_types/health_metrics.py @@ -1,6 +1,11 @@ # -*- coding: utf-8 -*- """SNMP health collection, shared by every device type that speaks UCD-MIB. +**Deprecated** (docs/STRUCTURE.md): drivers do not collect over SNMP. The +application polls; a driver whose device publishes values under its own OIDs +declares them as ``SNMP_HEALTH_PROFILE`` (:class:`~napalm_device_types.models.SnmpHealthProfile`). +This mixin goes in 5.0. + The collection itself is generic: CPU, memory, uptime, load and per-interface counters come from the same standard OIDs on any UCD-MIB/IF-MIB capable device. Only two details vary by device type, and both are class attributes rather than @@ -15,6 +20,7 @@ implement ``get_health_metrics`` themselves. from __future__ import annotations import re +import warnings from typing import Any, Awaitable, Callable, ClassVar, Dict, cast from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics @@ -39,7 +45,17 @@ class HealthMetricsMixin: snmp_get: Callable[..., Awaitable[Any]], snmp_walk: Callable[..., Awaitable[Dict[str, Any]]], ) -> HealthMetricsDict: - """Collect SNMP health metrics (CPU, memory, uptime, load, interfaces).""" + """Collect SNMP health metrics (CPU, memory, uptime, load, interfaces). + + Deprecated: the application polls; see the module docstring. + """ + warnings.warn( + "HealthMetricsMixin.get_health_metrics is deprecated and goes in 5.0: " + "the application polls over SNMP; a driver declares only its OIDs, " + "as SNMP_HEALTH_PROFILE", + DeprecationWarning, + stacklevel=2, + ) # collect_ucd_metrics is untyped shared plumbing; the shape it returns is # the contract HealthMetricsDict describes. return cast( diff --git a/napalm_device_types/models.py b/napalm_device_types/models.py index 5dff889..6be7d50 100644 --- a/napalm_device_types/models.py +++ b/napalm_device_types/models.py @@ -993,6 +993,53 @@ class HealthMetricsDict(TypedDict): interfaces: NotRequired[Dict[str, HealthMetricsIfaceDict]] +class SnmpValueSpec(TypedDict, total=False): + """Where an SNMP agent publishes one value, inside a ``SnmpHealthProfile``. + + Either ``oids``, scalars asked in order (the first non-empty answer wins), or + ``table``, a column walked and folded to one value by ``fold``. ``pattern`` is a + regular expression whose first group is the number, for an agent that answers + with text. + """ + + oids: List[str] + table: str + fold: Literal["first", "average", "max"] + pattern: str + + +class SnmpMemorySpec(TypedDict, total=False): + """Memory: ``total`` with ``used`` or ``free``, or ``percent`` alone.""" + + total: SnmpValueSpec + used: SnmpValueSpec + free: SnmpValueSpec + percent: SnmpValueSpec + unit: Literal["KiB", "bytes"] # of total, used and free; KiB, as UCD-MIB counts, by default + + +class SnmpInterfacesSpec(TypedDict, total=False): + """How the IF-MIB counters of a device are to be read.""" + + skip: str # a pattern of interface names whose counters are noise, not signal + tx_errors_are_drops: bool # the device counts dropped packets as TX errors (OPNsense) + + +class SnmpHealthProfile(TypedDict, total=False): + """Where a device's SNMP agent publishes its health values. + + A driver declares it as ``SNMP_HEALTH_PROFILE`` when its device does not answer + the standard UCD-MIB and IF-MIB; the application polls and computes. What a + profile does not name is read the standard way. Uptime is in TimeTicks, CPU in + percent busy. + """ + + uptime: SnmpValueSpec + cpu: SnmpValueSpec + memory: SnmpMemorySpec + interfaces: SnmpInterfacesSpec + + class NICConfigDict(TypedDict): """Network interface configuration for VM provisioning.""" diff --git a/pyproject.toml b/pyproject.toml index 4cc9830..2923ea5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-device-types" -version = "4.11.0" +version = "4.12.0" description = "Abstract device-type base classes for NAPALM drivers" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_snmp_health_profile.py b/tests/test_snmp_health_profile.py new file mode 100644 index 0000000..c5b306b --- /dev/null +++ b/tests/test_snmp_health_profile.py @@ -0,0 +1,53 @@ +"""SNMP health: drivers declare data, the application polls (docs/STRUCTURE.md). + +``SnmpHealthProfile`` is the shape a driver states its device's OIDs in. +``HealthMetricsMixin``, which collected in the base package, is deprecated. +""" + +from __future__ import annotations + +import asyncio +from typing import ClassVar, get_type_hints + +import pytest + +from napalm_device_types import ( + HealthMetricsMixin, + SnmpHealthProfile, + SnmpInterfacesSpec, + SnmpMemorySpec, + SnmpValueSpec, +) + + +def test_a_profile_says_where_each_value_lives(): + assert set(get_type_hints(SnmpHealthProfile)) == {"uptime", "cpu", "memory", "interfaces"} + assert set(get_type_hints(SnmpValueSpec)) == {"oids", "table", "fold", "pattern"} + assert set(get_type_hints(SnmpMemorySpec)) == {"total", "used", "free", "percent", "unit"} + assert set(get_type_hints(SnmpInterfacesSpec)) == {"skip", "tx_errors_are_drops"} + + +def test_every_key_is_optional(): + for spec in (SnmpHealthProfile, SnmpValueSpec, SnmpMemorySpec, SnmpInterfacesSpec): + assert spec.__required_keys__ == frozenset(), spec.__name__ + + +def test_a_driver_declares_its_profile_as_a_class_attribute(): + class Driver: + SNMP_HEALTH_PROFILE: ClassVar[SnmpHealthProfile] = { + "cpu": {"oids": ["1.3.6.1.4.1.9.1", "1.3.6.1.4.1.9.2"]}, + "memory": {"percent": {"table": "1.3.6.1.4.1.9.3", "fold": "first"}}, + } + + assert Driver.SNMP_HEALTH_PROFILE["cpu"]["oids"][0] == "1.3.6.1.4.1.9.1" + + +def test_the_collector_in_the_base_package_warns_that_it_is_deprecated(): + async def get(oid): + return None + + async def walk(oid): + return {} + + with pytest.warns(DeprecationWarning, match="SNMP_HEALTH_PROFILE"): + asyncio.run(HealthMetricsMixin.get_health_metrics(get, walk))