diff --git a/napalm_netgear/netgear_plus.py b/napalm_netgear/netgear_plus.py index 0afcd54..fa5a4e0 100644 --- a/napalm_netgear/netgear_plus.py +++ b/napalm_netgear/netgear_plus.py @@ -26,7 +26,7 @@ import threading import time from typing import Any -from napalm_device_types import FingerprintRule, SwitchDriver +from napalm_device_types import FingerprintRule, FirmwareOfferMixin, SwitchDriver from napalm.base.exceptions import ConnectionException # --------------------------------------------------------------------------- @@ -144,7 +144,7 @@ def _fw_get_latest(model: str) -> str | None: return latest -class NetgearPlusDriver(SwitchDriver): +class NetgearPlusDriver(FirmwareOfferMixin, SwitchDriver): """HTTP-based NAPALM driver for Netgear Plus Smart Managed switches.""" VENDOR = "Netgear" @@ -753,50 +753,29 @@ class NetgearPlusDriver(SwitchDriver): Returns None to indicate SNMP health monitoring is not supported.""" return None - def get_device_warnings(self) -> list: - """Check for available firmware updates and return a warning if the - device is not running the latest known firmware. + def get_firmware_offer(self) -> dict | None: + """The firmware running and the newest Netgear offers for this model. - The latest version is looked up once per 24 hours (per model, per - worker process) via lightweight HEAD requests against Netgear's - download CDN. Returns an empty list when the model is unknown or - when the check fails. + The newest is looked up once per 24 hours (per model, per worker + process) with HEAD requests against Netgear's download CDN. None when + the model is unknown, a version cannot be read or compared, or the + check fails. Whether an older firmware is a warning is the caller's to + decide (napalm-device-types ``FirmwareOfferMixin``). """ - warnings: list = [] try: model_name: str = getattr( self._connector.switch_model, "MODEL_NAME", "" ) or self._connector.switch_model.__class__.__name__ current_fw: str = self._get_switch_infos().get("switch_firmware", "") - - if not model_name or not current_fw: - return warnings - - latest_fw = _fw_get_latest(model_name) + latest_fw = _fw_get_latest(model_name) if model_name and current_fw else None if latest_fw is None: - return warnings - - try: - current_t = _fw_version_tuple(current_fw) - latest_t = _fw_version_tuple(latest_fw) - except ValueError: - return warnings - - if latest_t > current_t: - warnings.append( - { - "code": "firmware_update_available", - "meta": { - "download_url": ( - f"{_FW_CDN_BASE}/{model_name}" - f"/{model_name}_{latest_fw}.zip" - ), - "latest_version": latest_fw, - "current_version": current_fw, - }, - } - ) + return None + newer = _fw_version_tuple(latest_fw) > _fw_version_tuple(current_fw) except Exception: - pass - - return warnings + return None + return { + "current_version": current_fw, + "latest_version": latest_fw, + "newer": newer, + "download_url": f"{_FW_CDN_BASE}/{model_name}/{model_name}_{latest_fw}.zip", + } diff --git a/napalm_netgear/netgear_smart.py b/napalm_netgear/netgear_smart.py index 8ed6dda..c07d485 100644 --- a/napalm_netgear/netgear_smart.py +++ b/napalm_netgear/netgear_smart.py @@ -1838,10 +1838,6 @@ class NetgearSmartDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): # ── SNMP / Health ────────────────────────────────────────────────────────── - def get_device_warnings(self) -> list: - """Return device warnings. SNMP detection handled by poll task.""" - return [] - def get_snmp_config(self): """Return SNMP config if a community is configured on the switch.""" try: diff --git a/pyproject.toml b/pyproject.toml index 2e047b7..85203ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-netgear" -version = "0.3.0" +version = "0.4.0" description = "NAPALM driver for Netgear switches (Plus/Smart)" readme = "README.md" license = { text = "Apache-2.0" } @@ -26,7 +26,7 @@ classifiers = [ ] dependencies = [ "napalm>=4.0.0", - "napalm-device-types>=4.13.0", + "napalm-device-types>=4.16.0", "netmiko>=4.0.0", "paramiko>=5.0.0", # CVE-2026-44405 "netaddr", diff --git a/requirements.txt b/requirements.txt index 5f53601..a5f74c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ napalm>=4.0.0 -napalm-device-types>=4.13.0 +napalm-device-types>=4.16.0 netmiko>=4.0.0 netaddr diff --git a/tests/unit/test_firmware_offer.py b/tests/unit/test_firmware_offer.py new file mode 100644 index 0000000..0f7a6ea --- /dev/null +++ b/tests/unit/test_firmware_offer.py @@ -0,0 +1,59 @@ +"""The newest firmware Netgear offers, as a fact (netOrk #1111). + +The Plus driver looked the newest firmware up in Netgear's download catalogue +and named ``firmware_update_available`` itself. It reports the offer now -- +what runs, what is offered, whether that is newer by Netgear's versioning -- +and the application decides on the warning. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from napalm_netgear import netgear_plus +from napalm_netgear.netgear_plus import NetgearPlusDriver +from napalm_netgear.netgear_smart import NetgearSmartDriver + + +def _driver(monkeypatch, *, model="GS308E", running="1.00.10", latest="1.00.12"): + d = NetgearPlusDriver("sw", "admin", "pw") + d._connector = SimpleNamespace(switch_model=SimpleNamespace(MODEL_NAME=model)) + monkeypatch.setattr(d, "_get_switch_infos", lambda: {"switch_firmware": running}) + monkeypatch.setattr(netgear_plus, "_fw_get_latest", lambda m: latest) + return d + + +def test_a_newer_offer(monkeypatch): + assert _driver(monkeypatch).get_firmware_offer() == { + "current_version": "1.00.10", + "latest_version": "1.00.12", + "newer": True, + "download_url": f"{netgear_plus._FW_CDN_BASE}/GS308E/GS308E_1.00.12.zip", + } + + +def test_running_the_newest(monkeypatch): + offer = _driver(monkeypatch, running="1.00.12").get_firmware_offer() + assert offer["newer"] is False + + +@pytest.mark.parametrize( + "kwargs", + [{"latest": None}, {"running": ""}, {"running": "beta"}], + ids=["catalogue-unknown", "no-version", "unparsable"], +) +def test_cannot_tell(monkeypatch, kwargs): + assert _driver(monkeypatch, **kwargs).get_firmware_offer() is None + + +def test_not_connected_cannot_tell(monkeypatch): + d = _driver(monkeypatch) + d._connector = None + assert d.get_firmware_offer() is None + + +@pytest.mark.parametrize("cls", [NetgearPlusDriver, NetgearSmartDriver]) +def test_no_warnings_of_their_own(cls): + assert "get_device_warnings" not in vars(cls)