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.
This commit is contained in:
Christian Manivong committed 2026-10-17 11:32:59 +02:00
1 parent e9ad7951cd
commit 5b24675a12
9 files changed
+86 -7

No files matched your search

+15
View File
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.4.0] - 2026-10-17
### Added ### Added
+4
View File
@@ -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 with DCMS also its drives and firmware inventory. `optional_args`: those of `redfish`, plus
`ipmi_port` (623). `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 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 the service root's `Vendor`/`Oem` data and `Manager.Model`. No separate entry point per
vendor. vendor.
+1 -1
View File
@@ -7,5 +7,5 @@ takes the first driver class of this package. ``IpmiDriver`` lives in
from napalm_redfish.driver import RedfishDriver from napalm_redfish.driver import RedfishDriver
__version__ = "0.4.0" __version__ = "0.5.0"
__all__ = ["RedfishDriver"] __all__ = ["RedfishDriver"]
+6 -2
View File
@@ -23,7 +23,7 @@ import logging
from typing import Any from typing import Any
from napalm.base.exceptions import ConnectionException 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.driver import BMC_HTTP_FINGERPRINT, BMC_PORT_SPECS, RedfishDriver
from napalm_redfish.ipmi import IpmiDriver from napalm_redfish.ipmi import IpmiDriver
@@ -35,7 +35,7 @@ _JsonDict = dict[str, Any]
_IPMI_PORT = 623 _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.""" """One BMC over Redfish and IPMI: Redfish first, IPMI where Redfish does not answer."""
platform = "bmc" platform = "bmc"
@@ -137,6 +137,10 @@ class BmcDriver(HealthMetricsMixin, ManagementControllerDriver):
def get_snmp_information(self) -> _JsonDict: def get_snmp_information(self) -> _JsonDict:
return self._call("get_snmp_information") 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]: def get_device_warnings(self) -> list[_JsonDict]:
"""Redfish's signals; for a BMC that refuses Redfish, with ``ipmi_fallback`` set, as """Redfish's signals; for a BMC that refuses Redfish, with ``ipmi_fallback`` set, as
this driver reads it over IPMI meanwhile.""" this driver reads it over IPMI meanwhile."""
+24 -2
View File
@@ -30,7 +30,7 @@ from datetime import datetime, timezone
from typing import Any from typing import Any
from napalm.base.exceptions import ConnectionException 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.base import FingerprintRule, PortSpec
from napalm_device_types.models import POWER_ACTIONS from napalm_device_types.models import POWER_ACTIONS
@@ -134,7 +134,7 @@ def _status(item: _JsonDict) -> str:
return item.get("health") or "" 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). """NAPALM driver for Redfish BMCs (Supermicro, iDRAC, iLO, XCC and others).
``HealthMetricsMixin``: the BMC's own load, memory and interfaces over SNMP (UCD-MIB), ``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", return [{"code": "bmc_license_partial",
"meta": {"licenses": " ".join(licenses), "parts": parts}}] "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: def _withhold(self, part: str, exc: RedfishFeatureLicenseError) -> None:
LOG.info("%s: %s withheld by the BMC: %s", self.hostname, part, exc) LOG.info("%s: %s withheld by the BMC: %s", self.hostname, part, exc)
self._withheld[part] = exc.licenses self._withheld[part] = exc.licenses
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "napalm-redfish" name = "napalm-redfish"
version = "0.4.0" version = "0.5.0"
description = "NAPALM driver for server baseboard management controllers over DMTF Redfish" description = "NAPALM driver for server baseboard management controllers over DMTF Redfish"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
@@ -12,7 +12,7 @@ requires-python = ">=3.10"
dependencies = [ dependencies = [
"napalm>=5.0", "napalm>=5.0",
# Not on PyPI: install from git before this package (see README). # Not on PyPI: install from git before this package (see README).
"napalm-device-types>=4.8", "napalm-device-types>=4.15",
"redfish>=3.2", "redfish>=3.2",
"pyghmi>=1.5", "pyghmi>=1.5",
] ]
+9
View File
@@ -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): def test_the_bmc_is_not_reset_over_ipmi(unlicensed):
with pytest.raises(RuntimeError, match="needs Redfish"): with pytest.raises(RuntimeError, match="needs Redfish"):
unlicensed.reboot_host() 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))
+14
View File
@@ -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): def test_unlicensed_bmc_reports_the_missing_license(server):
(warning,) = _unlicensed(server).get_device_warnings() (warning,) = _unlicensed(server).get_device_warnings()
assert warning["code"] == "bmc_license_required" assert warning["code"] == "bmc_license_required"
+11
View File
@@ -89,3 +89,14 @@ def test_the_sensors_and_the_event_log_read(driver):
assert len(env["temperature"]) == 19 assert len(env["temperature"]) == 19
assert len(env["fans"]) == 4 assert len(env["fans"]) == 4
assert isinstance(driver.get_event_log(limit=5), list) 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"],
}]