From 5b24675a1292dfddba9a798747ec377781d00af8 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Sat, 17 Oct 2026 11:32:59 +0200 Subject: [PATCH] feat: report the licenses a BMC asks for as facts (0.5.0) get_licenses (napalm-device-types LicenseMixin, 4.15) on redfish and bmc, for NetOrk/netork#1111. - A BMC that refuses Redfish altogether reports each license it names as missing, with its message. - One that answers but withholds parts reports the license with withholds, e.g. DCMS withholding drives and the firmware inventory on an X10 with only SFT-OOB-LIC. - ipmi knows no licenses. get_device_warnings keeps its two codes until netOrk derives them. --- CHANGELOG.md | 15 +++++++++++++++ README.md | 4 ++++ napalm_redfish/__init__.py | 2 +- napalm_redfish/bmc.py | 8 ++++++-- napalm_redfish/driver.py | 26 ++++++++++++++++++++++++-- pyproject.toml | 4 ++-- tests/test_bmc_driver.py | 9 +++++++++ tests/test_driver.py | 14 ++++++++++++++ tests/test_x10_redfish.py | 11 +++++++++++ 9 files changed, 86 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e6120..a07f774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-10-17 + +### Added +- `get_licenses` (napalm-device-types `LicenseMixin`, 4.15) on `redfish` and `bmc`: the + licenses the BMC asks for, as facts (NetOrk/netork#1111). A BMC that refuses Redfish + altogether reports each license it names as `missing`, with its message. One that answers + but withholds parts reports the license with `withholds`, e.g. DCMS withholding + `drives` and `firmware inventory` on a Supermicro X10 with only SFT-OOB-LIC. `ipmi` + knows no licenses and has no `get_licenses`. + +### Changed +- Requires napalm-device-types 4.15 (`LicenseDict.withholds`). +- `get_device_warnings` still reports `bmc_license_required` / `bmc_license_partial` for + callers that do not read `get_licenses` yet; it goes once netOrk derives both. + ## [0.4.0] - 2026-10-17 ### Added diff --git a/README.md b/README.md index b53f72a..4fa4bd8 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ reads more than IPMI: CPUs, DIMMs, NICs, power draw, the BIOS version, the SMBIO with DCMS also its drives and firmware inventory. `optional_args`: those of `redfish`, plus `ipmi_port` (623). +`get_licenses()` (`redfish`, `bmc`) reports the licenses the BMC asks for as facts: +`{"name": "DCMS", "status": "missing", "withholds": ["drives", "firmware inventory"]}`. +Whether that is worth a warning is the caller's to decide. + Vendor quirks (Supermicro, Dell, HPE, Lenovo) live in one module per vendor, selected from the service root's `Vendor`/`Oem` data and `Manager.Model`. No separate entry point per vendor. diff --git a/napalm_redfish/__init__.py b/napalm_redfish/__init__.py index 57fea26..71f62eb 100644 --- a/napalm_redfish/__init__.py +++ b/napalm_redfish/__init__.py @@ -7,5 +7,5 @@ takes the first driver class of this package. ``IpmiDriver`` lives in from napalm_redfish.driver import RedfishDriver -__version__ = "0.4.0" +__version__ = "0.5.0" __all__ = ["RedfishDriver"] diff --git a/napalm_redfish/bmc.py b/napalm_redfish/bmc.py index e4ba95f..bdb512a 100644 --- a/napalm_redfish/bmc.py +++ b/napalm_redfish/bmc.py @@ -23,7 +23,7 @@ import logging from typing import Any from napalm.base.exceptions import ConnectionException -from napalm_device_types import HealthMetricsMixin, ManagementControllerDriver +from napalm_device_types import HealthMetricsMixin, LicenseMixin, ManagementControllerDriver from napalm_redfish.driver import BMC_HTTP_FINGERPRINT, BMC_PORT_SPECS, RedfishDriver from napalm_redfish.ipmi import IpmiDriver @@ -35,7 +35,7 @@ _JsonDict = dict[str, Any] _IPMI_PORT = 623 -class BmcDriver(HealthMetricsMixin, ManagementControllerDriver): +class BmcDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver): """One BMC over Redfish and IPMI: Redfish first, IPMI where Redfish does not answer.""" platform = "bmc" @@ -137,6 +137,10 @@ class BmcDriver(HealthMetricsMixin, ManagementControllerDriver): def get_snmp_information(self) -> _JsonDict: return self._call("get_snmp_information") + def get_licenses(self) -> list[_JsonDict]: + """Redfish's license facts, also while IPMI reads the BMC: IPMI knows none.""" + return self._redfish.get_licenses() + def get_device_warnings(self) -> list[_JsonDict]: """Redfish's signals; for a BMC that refuses Redfish, with ``ipmi_fallback`` set, as this driver reads it over IPMI meanwhile.""" diff --git a/napalm_redfish/driver.py b/napalm_redfish/driver.py index 8227bb0..22cb8b4 100644 --- a/napalm_redfish/driver.py +++ b/napalm_redfish/driver.py @@ -30,7 +30,7 @@ from datetime import datetime, timezone from typing import Any from napalm.base.exceptions import ConnectionException -from napalm_device_types import HealthMetricsMixin, ManagementControllerDriver +from napalm_device_types import HealthMetricsMixin, LicenseMixin, ManagementControllerDriver from napalm_device_types.base import FingerprintRule, PortSpec from napalm_device_types.models import POWER_ACTIONS @@ -134,7 +134,7 @@ def _status(item: _JsonDict) -> str: return item.get("health") or "" -class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver): +class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver): """NAPALM driver for Redfish BMCs (Supermicro, iDRAC, iLO, XCC and others). ``HealthMetricsMixin``: the BMC's own load, memory and interfaces over SNMP (UCD-MIB), @@ -397,6 +397,28 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver): return [{"code": "bmc_license_partial", "meta": {"licenses": " ".join(licenses), "parts": parts}}] + def get_licenses(self) -> list[_JsonDict]: + """The licenses the BMC asks for and does not have (``status`` "missing"). + + Refused altogether: one entry per license the service names, with its message. + Answering but withholding parts: one entry per license, with ``withholds`` + naming the parts. A BMC that names nothing missing reports nothing: Redfish has + no standard way to list the licenses a BMC does have. + """ + refused = self._license_refused + if refused is not None: + return [{"name": name, "status": "missing", "detail": refused.detail} + for name in refused.licenses.split() or [""]] + self._probe_withheld() + withheld: dict[str, list[str]] = {} + for part in _WITHHELD_ORDER: + if part not in self._withheld: + continue + for name in self._withheld[part].split() or [""]: + withheld.setdefault(name, []).append(part) + return [{"name": name, "status": "missing", "withholds": parts} + for name, parts in withheld.items()] + def _withhold(self, part: str, exc: RedfishFeatureLicenseError) -> None: LOG.info("%s: %s withheld by the BMC: %s", self.hostname, part, exc) self._withheld[part] = exc.licenses diff --git a/pyproject.toml b/pyproject.toml index c42657d..75f2ea4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-redfish" -version = "0.4.0" +version = "0.5.0" description = "NAPALM driver for server baseboard management controllers over DMTF Redfish" readme = "README.md" license = { file = "LICENSE" } @@ -12,7 +12,7 @@ requires-python = ">=3.10" dependencies = [ "napalm>=5.0", # Not on PyPI: install from git before this package (see README). - "napalm-device-types>=4.8", + "napalm-device-types>=4.15", "redfish>=3.2", "pyghmi>=1.5", ] diff --git a/tests/test_bmc_driver.py b/tests/test_bmc_driver.py index fb44af1..1ecb44b 100644 --- a/tests/test_bmc_driver.py +++ b/tests/test_bmc_driver.py @@ -156,3 +156,12 @@ def test_over_ipmi_the_protocol_netork_talks_is_known_to_be_on(unlicensed): def test_the_bmc_is_not_reset_over_ipmi(unlicensed): with pytest.raises(RuntimeError, match="needs Redfish"): unlicensed.reboot_host() + + +def test_the_license_facts_are_redfish_s_even_while_ipmi_reads(unlicensed): + assert [lic["name"] for lic in unlicensed.get_licenses()] == ["SUM", "DCMS", "OOB"] + + +def test_ipmi_knows_no_licenses(): + assert not hasattr(ipmi.IpmiDriver, "get_licenses") + assert callable(getattr(BmcDriver, "get_licenses", None)) diff --git a/tests/test_driver.py b/tests/test_driver.py index ce8e8e3..a5517c3 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -511,6 +511,20 @@ def test_unlicensed_bmc_still_opens(server): } +def test_unlicensed_bmc_names_every_license_it_asks_for_as_missing(server): + licenses = _unlicensed(server).get_licenses() + assert [(lic["name"], lic["status"]) for lic in licenses] == [ + ("SUM", "missing"), ("DCMS", "missing"), ("OOB", "missing")] + # Refused altogether: nothing is withheld in part. + assert all("withholds" not in lic and "Not licensed" in lic["detail"] for lic in licenses) + + +def test_a_licensed_bmc_reports_no_license_facts(server): + d = RedfishDriver("bmc.example.net", "netork", "s3cret", optional_args={"verify_ssl": False}) + d.open() + assert d.get_licenses() == [] + + def test_unlicensed_bmc_reports_the_missing_license(server): (warning,) = _unlicensed(server).get_device_warnings() assert warning["code"] == "bmc_license_required" diff --git a/tests/test_x10_redfish.py b/tests/test_x10_redfish.py index 9151220..558c281 100644 --- a/tests/test_x10_redfish.py +++ b/tests/test_x10_redfish.py @@ -89,3 +89,14 @@ def test_the_sensors_and_the_event_log_read(driver): assert len(env["temperature"]) == 19 assert len(env["fans"]) == 4 assert isinstance(driver.get_event_log(limit=5), list) + + +# ── licenses as facts (#1111) ─────────────────────────────────────────── + + +def test_the_missing_license_is_a_fact_with_what_it_withholds(driver): + assert driver.get_licenses() == [{ + "name": "DCMS", + "status": "missing", + "withholds": ["drives", "firmware inventory"], + }]