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:
1 parent
e9ad7951cd
commit
5b24675a12
9 files changed
+86
-7
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
}]
|
||||||
Reference in new issue
Block a user