chore: adopt the NAPALM driver standard #3

Merged
christianmanivong merged 4 commits from chore/driver-standard into main 2026-10-23 09:36:20 +00:00
34 changed files with 1108 additions and 478 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
jobs:
driver:
uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main
with:
standard-ref: main
secrets:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
+3
View File
@@ -5,3 +5,6 @@ build/
dist/ dist/
.venv/ .venv/
.pytest_cache/ .pytest_cache/
.coverage
.mypy_cache/
.ruff_cache/
+15
View File
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.6.0] - 2026-10-23
The first release on PyPI.
### Changed
- The driver follows the NAPALM driver standard
(https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, mypy, the
tests on Python 3.10–3.13 and at the lowest declared versions, bandit, pip-audit and
a build check, and a release tag publishes to PyPI. Python 3.10 stays the floor; the
package now ships `py.typed`, and the license is declared as the SPDX string
`Apache-2.0`. napalm-device-types now comes from PyPI (4.21.1 or later).
- Requires redfish 3.4.1 and pyghmi 1.5.1: older redfish releases return no answer
with `retries=0`, and pyghmi 1.5.0 misses a dependency it imports.
- A failed IPMI logout on close is now logged at debug level.
## [0.5.0] - 2026-10-17 ## [0.5.0] - 2026-10-17
### Added ### Added
+9 -9
View File
@@ -78,8 +78,6 @@ These follow the other drivers in the `NAPALM` organisation:
- Parsing lives in pure functions, tested on recorded responses under - Parsing lives in pure functions, tested on recorded responses under
`tests/fixtures/<vendor>-<model>-<firmware>/`, with a `COMMANDS.txt` (here: the request `tests/fixtures/<vendor>-<model>-<firmware>/`, with a `COMMANDS.txt` (here: the request
paths) listing what produced each file. paths) listing what produced each file.
- `napalm-device-types` is not on PyPI: install it from
`git+https://git.netork.io/NAPALM/napalm-device-types.git` before this package.
## Usage ## Usage
@@ -87,10 +85,11 @@ These follow the other drivers in the `NAPALM` organisation:
import napalm import napalm
driver = napalm.get_network_driver("redfish") driver = napalm.get_network_driver("redfish")
with driver("bmc01.example.net", "netork", "secret", with driver(
optional_args={"verify_ssl": "/etc/netork/bmc-ca.pem"}) as bmc: "bmc01.example.net", "netork", "secret", optional_args={"verify_ssl": "/etc/netork/bmc-ca.pem"}
bmc.get_facts() # the BMC: firmware as os_version, its ports ) as bmc:
bmc.get_power_state() # the server: {"state": "On", "actions": [...]} bmc.get_facts() # the BMC: firmware as os_version, its ports
bmc.get_power_state() # the server: {"state": "On", "actions": [...]}
bmc.get_hardware_inventory() bmc.get_hardware_inventory()
``` ```
@@ -103,11 +102,12 @@ to basic auth only if the service cannot create sessions), `retries` (1).
## Development ## Development
The rules are in [STANDARDS.md](https://git.netork.io/NAPALM/driver-standard/src/branch/main/STANDARDS.md).
```bash ```bash
python -m venv .venv && . .venv/bin/activate python -m venv .venv && . .venv/bin/activate
pip install git+https://git.netork.io/NAPALM/napalm-device-types.git pip install -e '.[dev]'
pip install -e '.[test]' pytest --cov
pytest
``` ```
Fixtures from the DMTF mockups live under `tests/fixtures/dmtf-<mockup>/`, unmodified, with Fixtures from the DMTF mockups live under `tests/fixtures/dmtf-<mockup>/`, unmodified, with
+17 -11
View File
@@ -18,24 +18,30 @@ package updates and service control for a BMC.
```python ```python
class ManagementControllerDriver(DeviceTypeDriver): class ManagementControllerDriver(DeviceTypeDriver):
"""Contract, not code.""" """Contract, not code."""
ROLE: str = "management_controller" # key exposed as device_class
ROLE: str = "management_controller" # key exposed as device_class
TYPE_LABEL: str = "Management Controller" TYPE_LABEL: str = "Management Controller"
USES_SSH: bool = False # Redfish is HTTPS; IPMI is UDP USES_SSH: bool = False # Redfish is HTTPS; IPMI is UDP
if TYPE_CHECKING: if TYPE_CHECKING:
# The managed machine # The managed machine
def get_power_state(self) -> PowerStateDict: ... def get_power_state(self) -> PowerStateDict: ...
def set_power_state(self, action: PowerAction) -> None: ... # On, GracefulShutdown, def set_power_state(self, action: PowerAction) -> None:
# ForceOff, GracefulRestart, ... # On, GracefulShutdown,
# ForceRestart, PowerCycle # ForceOff, GracefulRestart,
def get_hardware_inventory(self) -> HardwareInventoryDict: ... # CPUs, DIMMs, drives, # ForceRestart, PowerCycle
# PSUs, NICs, serials
def get_hardware_inventory(self) -> HardwareInventoryDict:
... # CPUs, DIMMs, drives,
# PSUs, NICs, serials
def get_event_log(self, limit: int = 500) -> List[EventLogEntryDict]: ... # SEL def get_event_log(self, limit: int = 500) -> List[EventLogEntryDict]: ... # SEL
# The controller itself # The controller itself
def get_firmware_inventory(self) -> List[FirmwareDict]: ... # BMC, BIOS, others def get_firmware_inventory(self) -> List[FirmwareDict]: ... # BMC, BIOS, others
def get_management_protocols(self) -> ManagementProtocolsDict: ... # IPMI, SSH, def get_management_protocols(self) -> ManagementProtocolsDict:
# HTTP(S), SNMP, ... # IPMI, SSH,
# KVM, virtual media # HTTP(S), SNMP,
# KVM, virtual media
``` ```
Points to settle in napalm-device-types: Points to settle in napalm-device-types:
+1 -2
View File
@@ -108,8 +108,7 @@ class BmcDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver):
except (RedfishError, ConnectionException) as exc: except (RedfishError, ConnectionException) as exc:
if not has_ipmi: if not has_ipmi:
raise raise
LOG.info("%s: %s failed over Redfish (%s); asking IPMI", self.hostname, name, LOG.info("%s: %s failed over Redfish (%s); asking IPMI", self.hostname, name, exc)
exc)
if not has_ipmi: if not has_ipmi:
raise NotImplementedError(f"{self.hostname}: {name} needs Redfish") raise NotImplementedError(f"{self.hostname}: {name} needs Redfish")
return getattr(self._ipmi_conn(), name)(*args) return getattr(self._ipmi_conn(), name)(*args)
+144 -87
View File
@@ -190,8 +190,9 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
except RedfishLicenseError as exc: except RedfishLicenseError as exc:
self._license_refused = exc self._license_refused = exc
except RedfishError as exc: except RedfishError as exc:
raise ConnectionException(f"Cannot connect to Redfish at {self.hostname}: {exc}") \ raise ConnectionException(
from exc f"Cannot connect to Redfish at {self.hostname}: {exc}"
) from exc
def close(self) -> None: def close(self) -> None:
self.transport.close() self.transport.close()
@@ -271,26 +272,37 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
return parse_network_protocol(raw or {}) return parse_network_protocol(raw or {})
def _manager_interfaces(self) -> list[_JsonDict]: def _manager_interfaces(self) -> list[_JsonDict]:
return [parse_ethernet_interface(raw) return [
for raw in self._members_of(self._manager_raw(), "EthernetInterfaces")] parse_ethernet_interface(raw)
for raw in self._members_of(self._manager_raw(), "EthernetInterfaces")
]
# --- NAPALM getters ---------------------------------------------------------------- # --- NAPALM getters ----------------------------------------------------------------
def get_facts(self) -> _JsonDict: def get_facts(self) -> _JsonDict:
if self._license_refused is not None: if self._license_refused is not None:
return {"uptime": -1.0, "vendor": "", "model": "", "hostname": self.hostname, return {
"fqdn": self.hostname, "os_version": "", "serial_number": "", "uptime": -1.0,
"interface_list": []} "vendor": "",
"model": "",
"hostname": self.hostname,
"fqdn": self.hostname,
"os_version": "",
"serial_number": "",
"interface_list": [],
}
system = parse_system(self._system_raw()) system = parse_system(self._system_raw())
manager = parse_manager(self._manager_raw()) manager = parse_manager(self._manager_raw())
interfaces = self._manager_interfaces() interfaces = self._manager_interfaces()
protocol = self._network_protocol() protocol = self._network_protocol()
hostname = (protocol["hostname"] hostname = (
or next((i["hostname"] for i in interfaces if i["hostname"]), None) protocol["hostname"]
or self.hostname) or next((i["hostname"] for i in interfaces if i["hostname"]), None)
fqdn = (protocol["fqdn"] or self.hostname
or next((i["fqdn"] for i in interfaces if i["fqdn"]), None) )
or hostname) fqdn = (
protocol["fqdn"] or next((i["fqdn"] for i in interfaces if i["fqdn"]), None) or hostname
)
return { return {
"uptime": -1.0, # Redfish has no uptime "uptime": -1.0, # Redfish has no uptime
"vendor": system["manufacturer"] or "", "vendor": system["manufacturer"] or "",
@@ -327,11 +339,13 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
fans, temperature = legacy["fans"], legacy["temperature"] fans, temperature = legacy["fans"], legacy["temperature"]
elif thermal: elif thermal:
subsystem = parse_thermal_subsystem(self.transport.get(thermal["path"])) subsystem = parse_thermal_subsystem(self.transport.get(thermal["path"]))
parsed_fans = [parse_fan(raw) for raw in self.transport.members( parsed_fans = (
subsystem["fans_path"])] if subsystem["fans_path"] else [] [parse_fan(raw) for raw in self.transport.members(subsystem["fans_path"])]
if subsystem["fans_path"]
else []
)
if sources["sensors"]: if sources["sensors"]:
sensors = [parse_sensor(raw) sensors = [parse_sensor(raw) for raw in self.transport.members(sources["sensors"])]
for raw in self.transport.members(sources["sensors"])]
temperature = build_environment(sensors=sensors)["temperature"] temperature = build_environment(sensors=sensors)["temperature"]
elif subsystem["thermal_metrics_path"]: elif subsystem["thermal_metrics_path"]:
metrics = self.transport.get_optional(subsystem["thermal_metrics_path"]) metrics = self.transport.get_optional(subsystem["thermal_metrics_path"])
@@ -342,12 +356,13 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
if supply and supply["schema"] == "Power": if supply and supply["schema"] == "Power":
power = parse_power(self.transport.get(supply["path"]))["power"] power = parse_power(self.transport.get(supply["path"]))["power"]
elif supply: elif supply:
psus = [parse_power_supply(raw, self.transport.follow(raw, "Metrics")) psus = [
for raw in self._power_supplies_raw(supply["path"])] parse_power_supply(raw, self.transport.follow(raw, "Metrics"))
for raw in self._power_supplies_raw(supply["path"])
]
power = build_environment(power_supplies=psus)["power"] power = build_environment(power_supplies=psus)["power"]
return {"fans": fans, "temperature": temperature, "power": power, return {"fans": fans, "temperature": temperature, "power": power, "cpu": {}, "memory": {}}
"cpu": {}, "memory": {}}
def _power_supplies_raw(self, subsystem_path: str) -> list[_JsonDict]: def _power_supplies_raw(self, subsystem_path: str) -> list[_JsonDict]:
subsystem = parse_power_subsystem(self.transport.get(subsystem_path)) subsystem = parse_power_subsystem(self.transport.get(subsystem_path))
@@ -358,8 +373,7 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
service = self._service("AccountService") service = self._service("AccountService")
if service is None: if service is None:
return {} return {}
return to_napalm_users(parse_account(raw) for raw in self._members_of(service, return to_napalm_users(parse_account(raw) for raw in self._members_of(service, "Accounts"))
"Accounts"))
def get_ntp_servers(self) -> _JsonDict: def get_ntp_servers(self) -> _JsonDict:
return {server: {} for server in self._network_protocol()["ntp"]["servers"]} return {server: {} for server in self._network_protocol()["ntp"]["servers"]}
@@ -387,15 +401,23 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
""" """
refused = self._license_refused refused = self._license_refused
if refused is not None: if refused is not None:
return [{"code": "bmc_license_required", return [
"meta": {"licenses": refused.licenses, "message": refused.detail}}] {
"code": "bmc_license_required",
"meta": {"licenses": refused.licenses, "message": refused.detail},
}
]
self._probe_withheld() self._probe_withheld()
if not self._withheld: if not self._withheld:
return [] return []
licenses = sorted({name for names in self._withheld.values() for name in names.split()}) licenses = sorted({name for names in self._withheld.values() for name in names.split()})
parts = [part for part in _WITHHELD_ORDER if part in self._withheld] parts = [part for part in _WITHHELD_ORDER if part in self._withheld]
return [{"code": "bmc_license_partial", return [
"meta": {"licenses": " ".join(licenses), "parts": parts}}] {
"code": "bmc_license_partial",
"meta": {"licenses": " ".join(licenses), "parts": parts},
}
]
def get_licenses(self) -> list[_JsonDict]: def get_licenses(self) -> list[_JsonDict]:
"""The licenses the BMC asks for and does not have (``status`` "missing"). """The licenses the BMC asks for and does not have (``status`` "missing").
@@ -407,8 +429,10 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
""" """
refused = self._license_refused refused = self._license_refused
if refused is not None: if refused is not None:
return [{"name": name, "status": "missing", "detail": refused.detail} return [
for name in refused.licenses.split() or [""]] {"name": name, "status": "missing", "detail": refused.detail}
for name in refused.licenses.split() or [""]
]
self._probe_withheld() self._probe_withheld()
withheld: dict[str, list[str]] = {} withheld: dict[str, list[str]] = {}
for part in _WITHHELD_ORDER: for part in _WITHHELD_ORDER:
@@ -416,8 +440,10 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
continue continue
for name in self._withheld[part].split() or [""]: for name in self._withheld[part].split() or [""]:
withheld.setdefault(name, []).append(part) withheld.setdefault(name, []).append(part)
return [{"name": name, "status": "missing", "withholds": parts} return [
for name, parts in withheld.items()] {"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)
@@ -460,8 +486,7 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
def get_power_state(self) -> _JsonDict: def get_power_state(self) -> _JsonDict:
system = self.transport.get(self._system_path()) system = self.transport.get(self._system_path())
return {"state": parse_power_state(system) or "", return {"state": parse_power_state(system) or "", "actions": self._accepted_actions(system)}
"actions": self._accepted_actions(system)}
def set_power_state(self, action: str) -> None: def set_power_state(self, action: str) -> None:
if action not in POWER_ACTIONS: if action not in POWER_ACTIONS:
@@ -475,7 +500,8 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
raise RuntimeError( raise RuntimeError(
f"{self.hostname}: {action} not accepted while the server is " f"{self.hostname}: {action} not accepted while the server is "
f"{parse_power_state(system) or 'in an unknown state'} " f"{parse_power_state(system) or 'in an unknown state'} "
f"(accepted: {', '.join(accepted) or 'none'})") f"(accepted: {', '.join(accepted) or 'none'})"
)
try: try:
self.transport.post(target, {"ResetType": action}) self.transport.post(target, {"ResetType": action})
except RedfishError as exc: except RedfishError as exc:
@@ -490,11 +516,13 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
raise RuntimeError(f"{self.hostname}: the BMC offers no reset action") raise RuntimeError(f"{self.hostname}: the BMC offers no reset action")
body: _JsonDict = {} body: _JsonDict = {}
if reset["allowed"] is not None: if reset["allowed"] is not None:
reset_type = next((t for t in ("GracefulRestart", "ForceRestart") reset_type = next(
if t in reset["allowed"]), None) (t for t in ("GracefulRestart", "ForceRestart") if t in reset["allowed"]), None
)
if reset_type is None: if reset_type is None:
raise RuntimeError(f"{self.hostname}: BMC reset types " raise RuntimeError(
f"{reset['allowed']} include no restart") f"{self.hostname}: BMC reset types {reset['allowed']} include no restart"
)
body["ResetType"] = reset_type body["ResetType"] = reset_type
try: try:
self.transport.post(reset["target"], body) self.transport.post(reset["target"], body)
@@ -524,9 +552,15 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
cpu = parse_processor(raw) cpu = parse_processor(raw)
if cpu["processor_type"] not in (None, "CPU"): if cpu["processor_type"] not in (None, "CPU"):
continue # GPUs, FPGAs, accelerators continue # GPUs, FPGAs, accelerators
cpus.append({"socket": cpu["socket"] or cpu["id"] or "", "model": cpu["model"] or "", cpus.append(
"cores": cpu["cores"], "threads": cpu["threads"], {
"status": _status(cpu)}) "socket": cpu["socket"] or cpu["id"] or "",
"model": cpu["model"] or "",
"cores": cpu["cores"],
"threads": cpu["threads"],
"status": _status(cpu),
}
)
return cpus return cpus
def _dimms(self, system: _JsonDict) -> list[_JsonDict]: def _dimms(self, system: _JsonDict) -> list[_JsonDict]:
@@ -534,15 +568,17 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
for raw in self._members_of(system, "Memory"): for raw in self._members_of(system, "Memory"):
dimm = parse_memory(raw) dimm = parse_memory(raw)
size = dimm["capacity_mib"] size = dimm["capacity_mib"]
dimms.append({ dimms.append(
"slot": dimm["location"] or dimm["id"] or "", {
"size_bytes": size * 1024 * 1024 if size else None, "slot": dimm["location"] or dimm["id"] or "",
"type": dimm["memory_device_type"] or "", "size_bytes": size * 1024 * 1024 if size else None,
"speed_mts": dimm["speed_mhz"], # Redfish: MHz or MT/s "as appropriate" "type": dimm["memory_device_type"] or "",
"part_number": dimm["part_number"] or "", "speed_mts": dimm["speed_mhz"], # Redfish: MHz or MT/s "as appropriate"
"serial_number": dimm["serial_number"] or "", "part_number": dimm["part_number"] or "",
"status": _status(dimm), "serial_number": dimm["serial_number"] or "",
}) "status": _status(dimm),
}
)
return dimms return dimms
def _storage_drives(self, system: _JsonDict) -> list[_JsonDict] | None: def _storage_drives(self, system: _JsonDict) -> list[_JsonDict] | None:
@@ -572,15 +608,18 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
parsed = [] parsed = []
for storage in self._members_of(system, "SimpleStorage"): for storage in self._members_of(system, "SimpleStorage"):
parsed.extend(parse_simple_storage(storage)["drives"]) parsed.extend(parse_simple_storage(storage)["drives"])
return [{ return [
"slot": d["name"] or d["id"] or "", {
"model": d["model"] or "", "slot": d["name"] or d["id"] or "",
"serial_number": d["serial_number"] or "", "model": d["model"] or "",
"size_bytes": d["capacity_bytes"], "serial_number": d["serial_number"] or "",
"media": d["media_type"] or "", "size_bytes": d["capacity_bytes"],
"protocol": d["protocol"] or "", "media": d["media_type"] or "",
"status": _status(d), "protocol": d["protocol"] or "",
} for d in parsed] "status": _status(d),
}
for d in parsed
]
def _psus(self) -> list[_JsonDict]: def _psus(self) -> list[_JsonDict]:
source = environment_sources(self._chassis_raw())["power"] source = environment_sources(self._chassis_raw())["power"]
@@ -595,13 +634,15 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
if not isinstance(raw, dict): if not isinstance(raw, dict):
continue continue
psu = parse_power_supply(raw) psu = parse_power_supply(raw)
psus.append({ psus.append(
"slot": psu["name"], {
"model": psu["model"] or "", "slot": psu["name"],
"serial_number": psu["serial_number"] or "", "model": psu["model"] or "",
"capacity_watts": int(psu["capacity"]) if psu["capacity"] >= 0 else None, "serial_number": psu["serial_number"] or "",
"status": _status(psu), "capacity_watts": int(psu["capacity"]) if psu["capacity"] >= 0 else None,
}) "status": _status(psu),
}
)
return psus return psus
def _nics(self, system: _JsonDict) -> list[_JsonDict]: def _nics(self, system: _JsonDict) -> list[_JsonDict]:
@@ -610,13 +651,16 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
nic = parse_ethernet_interface(raw) nic = parse_ethernet_interface(raw)
speed = nic["interface"]["speed"] speed = nic["interface"]["speed"]
link_up = {"LinkUp": True, "LinkDown": False, "NoLink": False}.get( link_up = {"LinkUp": True, "LinkDown": False, "NoLink": False}.get(
nic["link_status"] or "") nic["link_status"] or ""
nics.append({ )
"name": nic["name"] or "", nics.append(
"mac_address": nic["interface"]["mac_address"].lower(), {
"speed_mbps": int(speed) if speed >= 0 else None, "name": nic["name"] or "",
"link_up": link_up, "mac_address": nic["interface"]["mac_address"].lower(),
}) "speed_mbps": int(speed) if speed >= 0 else None,
"link_up": link_up,
}
)
return nics return nics
# --- role: event log, firmware, protocols -------------------------------------------- # --- role: event log, firmware, protocols --------------------------------------------
@@ -640,20 +684,24 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
try: try:
raw_entries = self.transport.members(service["entries_path"]) raw_entries = self.transport.members(service["entries_path"])
except RedfishNotFound: except RedfishNotFound:
LOG.warning("%s: log %s has no entries collection", self.hostname, LOG.warning(
service["id"]) "%s: log %s has no entries collection", self.hostname, service["id"]
)
continue continue
for raw in raw_entries: for raw in raw_entries:
entry = parse_log_entry(raw) entry = parse_log_entry(raw)
severity = entry["severity"] severity = entry["severity"]
entries.append({ entries.append(
"id": odata_id(raw) or f"{service['id']}/{entry['id']}", {
"timestamp": _timestamp(entry["created"]), "id": odata_id(raw) or f"{service['id']}/{entry['id']}",
"severity": severity if severity in _SEVERITIES else "OK", "timestamp": _timestamp(entry["created"]),
"message": entry["message"] or "", "severity": severity if severity in _SEVERITIES else "OK",
}) "message": entry["message"] or "",
entries.sort(key=lambda e: (e["timestamp"] is not None, e["timestamp"] or 0.0), }
reverse=True) )
entries.sort(
key=lambda e: (e["timestamp"] is not None, e["timestamp"] or 0.0), reverse=True
)
return entries[:limit] return entries[:limit]
def _update_service(self) -> _JsonDict | None: def _update_service(self) -> _JsonDict | None:
@@ -673,11 +721,20 @@ class RedfishDriver(HealthMetricsMixin, LicenseMixin, ManagementControllerDriver
return [] return []
bmc = parse_manager(self._manager_raw())["firmware_version"] bmc = parse_manager(self._manager_raw())["firmware_version"]
bios = self._system_raw().get("BiosVersion") bios = self._system_raw().get("BiosVersion")
return [{"name": name, "version": version, "updateable": None} return [
for name, version in (("BMC", bmc), ("BIOS", bios)) if version] {"name": name, "version": version, "updateable": None}
for name, version in (("BMC", bmc), ("BIOS", bios))
if version
]
items = [parse_firmware(raw) for raw in self._members_of(service, "FirmwareInventory")] items = [parse_firmware(raw) for raw in self._members_of(service, "FirmwareInventory")]
return [{"name": fw["name"] or fw["id"] or "", "version": fw["version"] or "", return [
"updateable": fw["updateable"]} for fw in items] {
"name": fw["name"] or fw["id"] or "",
"version": fw["version"] or "",
"updateable": fw["updateable"],
}
for fw in items
]
def get_management_protocols(self) -> _JsonDict: def get_management_protocols(self) -> _JsonDict:
"""What the BMC listens on. A protocol the BMC does not report counts as off.""" """What the BMC listens on. A protocol the BMC does not report counts as off."""
+25 -16
View File
@@ -8,14 +8,19 @@ sensors, the power state and the system event log, and switches the server's pow
from __future__ import annotations from __future__ import annotations
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, ManagementControllerDriver
from napalm_device_types.models import POWER_ACTIONS from napalm_device_types.models import POWER_ACTIONS
from pyghmi.exceptions import IpmiException
from pyghmi.ipmi.command import Command # IPMI is this driver's protocol: the fallback for BMCs without Redfish (see above).
from pyghmi.ipmi.private.session import Session from pyghmi.exceptions import IpmiException # nosec B415
from pyghmi.ipmi.command import Command # nosec B415
from pyghmi.ipmi.private.session import Session # nosec B415
LOG = logging.getLogger(__name__)
_JsonDict = dict[str, Any] _JsonDict = dict[str, Any]
@@ -85,8 +90,7 @@ class IpmiDriver(HealthMetricsMixin, ManagementControllerDriver):
port=self.port, port=self.port,
) )
except Exception as exc: except Exception as exc:
raise ConnectionException(f"Cannot connect to IPMI at {self.hostname}: {exc}") \ raise ConnectionException(f"Cannot connect to IPMI at {self.hostname}: {exc}") from exc
from exc
def close(self) -> None: def close(self) -> None:
ipmi, self._ipmi = self._ipmi, None ipmi, self._ipmi = self._ipmi, None
@@ -94,7 +98,7 @@ class IpmiDriver(HealthMetricsMixin, ManagementControllerDriver):
try: try:
ipmi.ipmi_session.logout() ipmi.ipmi_session.logout()
except Exception: # a session left open times out on the BMC except Exception: # a session left open times out on the BMC
pass LOG.debug("%s: ignored", self.hostname, exc_info=True)
self._forget_session() self._forget_session()
def _forget_session(self) -> None: def _forget_session(self) -> None:
@@ -219,7 +223,8 @@ class IpmiDriver(HealthMetricsMixin, ManagementControllerDriver):
if action not in accepted: if action not in accepted:
raise RuntimeError( raise RuntimeError(
f"{self.hostname}: {action} not accepted while the server is " f"{self.hostname}: {action} not accepted while the server is "
f"{state or 'in an unknown state'} (accepted: {', '.join(accepted) or 'none'})") f"{state or 'in an unknown state'} (accepted: {', '.join(accepted) or 'none'})"
)
try: try:
self._cmd().set_power(_CHASSIS_CONTROL[action], wait=False) self._cmd().set_power(_CHASSIS_CONTROL[action], wait=False)
except IpmiException as exc: except IpmiException as exc:
@@ -235,7 +240,8 @@ class IpmiDriver(HealthMetricsMixin, ManagementControllerDriver):
"serial_number": "", "serial_number": "",
"capacity_watts": None, "capacity_watts": None,
"status": _severity(int(reading.health or 0)) "status": _severity(int(reading.health or 0))
if _supply_present(reading) else "Absent", if _supply_present(reading)
else "Absent",
} }
for reading in self._sensors() for reading in self._sensors()
if reading.type == "Power Supply" if reading.type == "Power Supply"
@@ -259,14 +265,17 @@ class IpmiDriver(HealthMetricsMixin, ManagementControllerDriver):
component = event.get("component") or "" component = event.get("component") or ""
text = event.get("event") or "" text = event.get("event") or ""
timecode = event.get("timecode") timecode = event.get("timecode")
entries.append({ entries.append(
"id": str(event.get("record_id", "")), {
"timestamp": float(timecode) if timecode else None, "id": str(event.get("record_id", "")),
"severity": _severity(int(event.get("severity") or 0)), "timestamp": float(timecode) if timecode else None,
"message": f"{component}: {text}" if component and text else component or text, "severity": _severity(int(event.get("severity") or 0)),
}) "message": f"{component}: {text}" if component and text else component or text,
entries.sort(key=lambda e: (e["timestamp"] is not None, e["timestamp"] or 0.0), }
reverse=True) )
entries.sort(
key=lambda e: (e["timestamp"] is not None, e["timestamp"] or 0.0), reverse=True
)
return entries[:limit] return entries[:limit]
def get_firmware_inventory(self) -> list[_JsonDict]: def get_firmware_inventory(self) -> list[_JsonDict]:
+4 -2
View File
@@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Iterable, Mapping from collections.abc import Iterable, Mapping
from typing import Any
from .common import boolean, text from .common import boolean, text
@@ -34,7 +35,8 @@ def to_napalm_users(accounts: Iterable[Mapping[str, Any] | None]) -> dict[str, d
continue continue
users[acc["username"]] = { users[acc["username"]] = {
"level": ROLE_LEVELS.get(acc.get("role_id") or "", 0), "level": ROLE_LEVELS.get(acc.get("role_id") or "", 0),
"password": "", # NAPALM's get_users field; Redfish never returns a password, so it stays empty.
"password": "", # nosec B105
"sshkeys": [], "sshkeys": [],
} }
return users return users
+16 -13
View File
@@ -2,21 +2,24 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Mapping from collections.abc import Mapping
from typing import Any
# DMI defaults that board vendors leave in place (Supermicro boards ship most of them). # DMI defaults that board vendors leave in place (Supermicro boards ship most of them).
PLACEHOLDERS = frozenset({ PLACEHOLDERS = frozenset(
"to be filled by o.e.m.", {
"default string", "to be filled by o.e.m.",
"not specified", "default string",
"not applicable", "not specified",
"n/a", "not applicable",
"none", "n/a",
"0123456789", "none",
"system serial number", "0123456789",
"system product name", "system serial number",
"system manufacturer", "system product name",
}) "system manufacturer",
}
)
def text(value: Any) -> str | None: def text(value: Any) -> str | None:
+43 -16
View File
@@ -16,12 +16,20 @@ left out.
from __future__ import annotations from __future__ import annotations
from typing import Any, Iterable, Mapping from collections.abc import Iterable, Mapping
from typing import Any
from .common import link, number, present, status, text, unique_name from .common import link, number, present, status, text, unique_name
_SENSOR_TYPES_BY_UNITS = {"Cel": "Temperature", "V": "Voltage", "W": "Power", "A": "Current", _SENSOR_TYPES_BY_UNITS = {
"RPM": "Rotational", "%": "Percent", "Hz": "Frequency"} "Cel": "Temperature",
"V": "Voltage",
"W": "Power",
"A": "Current",
"RPM": "Rotational",
"%": "Percent",
"Hz": "Frequency",
}
def environment_sources(chassis: Mapping[str, Any]) -> dict[str, Any]: def environment_sources(chassis: Mapping[str, Any]) -> dict[str, Any]:
@@ -138,8 +146,11 @@ def parse_power(power: Mapping[str, Any]) -> dict[str, Any]:
if not psu["present"]: if not psu["present"]:
continue continue
name = unique_name(supplies, psu["name"], item.get("MemberId", index)) name = unique_name(supplies, psu["name"], item.get("MemberId", index))
supplies[name] = {"status": psu["status"], "capacity": psu["capacity"], supplies[name] = {
"output": psu["output"]} "status": psu["status"],
"capacity": psu["capacity"],
"output": psu["output"],
}
voltages: dict[str, Any] = {} voltages: dict[str, Any] = {}
for index, item in _members(power, "Voltages"): for index, item in _members(power, "Voltages"):
@@ -204,8 +215,9 @@ def parse_fan(fan: Mapping[str, Any]) -> dict[str, Any]:
} }
def parse_power_supply(supply: Mapping[str, Any], def parse_power_supply(
metrics: Mapping[str, Any] | None = None) -> dict[str, Any]: supply: Mapping[str, Any], metrics: Mapping[str, Any] | None = None
) -> dict[str, Any]:
"""One member of ``PowerSubsystem/PowerSupplies``, with its ``Metrics`` if read. """One member of ``PowerSubsystem/PowerSupplies``, with its ``Metrics`` if read.
An item of the legacy ``Power.PowerSupplies`` array parses too (``MemberId`` as id, An item of the legacy ``Power.PowerSupplies`` array parses too (``MemberId`` as id,
@@ -273,16 +285,24 @@ def parse_thermal_metrics(metrics: Mapping[str, Any]) -> dict[str, Any]:
return {"temperature": temperature} return {"temperature": temperature}
def build_environment(*, fans: Iterable[Mapping[str, Any]] = (), def build_environment(
power_supplies: Iterable[Mapping[str, Any]] = (), *,
sensors: Iterable[Mapping[str, Any]] = ()) -> dict[str, Any]: fans: Iterable[Mapping[str, Any]] = (),
power_supplies: Iterable[Mapping[str, Any]] = (),
sensors: Iterable[Mapping[str, Any]] = (),
) -> dict[str, Any]:
"""Combine parsed fans, power supplies and sensors into the shape of the legacy parsers. """Combine parsed fans, power supplies and sensors into the shape of the legacy parsers.
``consumed_watts`` comes from the chassis total power sensor ``consumed_watts`` comes from the chassis total power sensor
(``ReadingType`` Power, ``ElectricalContext`` Total, ``PhysicalContext`` Chassis). (``ReadingType`` Power, ``ElectricalContext`` Total, ``PhysicalContext`` Chassis).
""" """
env: dict[str, Any] = {"fans": {}, "temperature": {}, "power": {}, "voltages": {}, env: dict[str, Any] = {
"consumed_watts": None} "fans": {},
"temperature": {},
"power": {},
"voltages": {},
"consumed_watts": None,
}
for fan in fans: for fan in fans:
if fan["present"]: if fan["present"]:
name = unique_name(env["fans"], fan["name"], fan["id"]) name = unique_name(env["fans"], fan["name"], fan["id"])
@@ -290,8 +310,11 @@ def build_environment(*, fans: Iterable[Mapping[str, Any]] = (),
for psu in power_supplies: for psu in power_supplies:
if psu["present"]: if psu["present"]:
name = unique_name(env["power"], psu["name"], psu["id"]) name = unique_name(env["power"], psu["name"], psu["id"])
env["power"][name] = {"status": psu["status"], "capacity": psu["capacity"], env["power"][name] = {
"output": psu["output"]} "status": psu["status"],
"capacity": psu["capacity"],
"output": psu["output"],
}
for sensor in sensors: for sensor in sensors:
reading = sensor["reading"] reading = sensor["reading"]
if reading is None: if reading is None:
@@ -303,7 +326,11 @@ def build_environment(*, fans: Iterable[Mapping[str, Any]] = (),
elif sensor["reading_type"] == "Voltage": elif sensor["reading_type"] == "Voltage":
name = unique_name(env["voltages"], sensor["name"], sensor["id"]) name = unique_name(env["voltages"], sensor["name"], sensor["id"])
env["voltages"][name] = {"voltage": reading, **flags} env["voltages"][name] = {"voltage": reading, **flags}
elif (sensor["reading_type"] == "Power" and sensor["electrical_context"] == "Total" elif (
and sensor["physical_context"] == "Chassis" and env["consumed_watts"] is None): sensor["reading_type"] == "Power"
and sensor["electrical_context"] == "Total"
and sensor["physical_context"] == "Chassis"
and env["consumed_watts"] is None
):
env["consumed_watts"] = reading env["consumed_watts"] = reading
return env return env
+2 -1
View File
@@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Mapping from collections.abc import Mapping
from typing import Any
from .common import boolean, link_list, status, text from .common import boolean, link_list, status, text
+14 -7
View File
@@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Mapping from collections.abc import Mapping
from typing import Any
from .common import boolean, ident, integer, link, link_list, location_label, present, status, text from .common import boolean, ident, integer, link, link_list, location_label, present, status, text
@@ -39,8 +40,9 @@ def parse_memory(dimm: Mapping[str, Any]) -> dict[str, Any]:
return { return {
"id": text(dimm.get("Id")), "id": text(dimm.get("Id")),
"name": text(dimm.get("Name")), "name": text(dimm.get("Name")),
"location": (location_label(dimm) or text(dimm.get("DeviceLocator")) "location": (
or text(dimm.get("Name"))), location_label(dimm) or text(dimm.get("DeviceLocator")) or text(dimm.get("Name"))
),
"capacity_mib": capacity, "capacity_mib": capacity,
"memory_device_type": text(dimm.get("MemoryDeviceType")), "memory_device_type": text(dimm.get("MemoryDeviceType")),
"base_module_type": text(dimm.get("BaseModuleType")), "base_module_type": text(dimm.get("BaseModuleType")),
@@ -62,7 +64,8 @@ def _controller(ctrl: Mapping[str, Any]) -> dict[str, Any]:
"model": ident(ctrl.get("Model")), "model": ident(ctrl.get("Model")),
"serial_number": ident(ctrl.get("SerialNumber")), "serial_number": ident(ctrl.get("SerialNumber")),
"firmware_version": text(ctrl.get("FirmwareVersion")), "firmware_version": text(ctrl.get("FirmwareVersion")),
"speed_gbps": ctrl.get("SpeedGbps") if isinstance(ctrl.get("SpeedGbps"), (int, float)) "speed_gbps": ctrl.get("SpeedGbps")
if isinstance(ctrl.get("SpeedGbps"), (int, float))
else None, else None,
"state": state, "state": state,
"health": health, "health": health,
@@ -83,7 +86,8 @@ def parse_storage(storage: Mapping[str, Any]) -> dict[str, Any]:
"state": state, "state": state,
"health": health, "health": health,
"controllers": [_controller(c) for c in controllers if isinstance(c, Mapping)] "controllers": [_controller(c) for c in controllers if isinstance(c, Mapping)]
if isinstance(controllers, list) else [], if isinstance(controllers, list)
else [],
"controllers_path": link(storage, "Controllers"), "controllers_path": link(storage, "Controllers"),
"drive_paths": link_list(storage, "Drives"), "drive_paths": link_list(storage, "Drives"),
} }
@@ -117,8 +121,11 @@ def parse_simple_storage(storage: Mapping[str, Any]) -> dict[str, Any]:
""" """
state, health = status(storage) state, health = status(storage)
devices = storage.get("Devices") devices = storage.get("Devices")
drives = [parse_drive(dev) for dev in devices if isinstance(dev, Mapping)] \ drives = (
if isinstance(devices, list) else [] [parse_drive(dev) for dev in devices if isinstance(dev, Mapping)]
if isinstance(devices, list)
else []
)
return { return {
"id": text(storage.get("Id")), "id": text(storage.get("Id")),
"name": text(storage.get("Name")), "name": text(storage.get("Name")),
+2 -1
View File
@@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Mapping from collections.abc import Mapping
from typing import Any
from .common import boolean, integer, link, status, text from .common import boolean, integer, link, status, text
+5 -3
View File
@@ -3,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
from typing import Any, Mapping from collections.abc import Mapping
from typing import Any
from .common import boolean, integer, link_list, links, number, status, text from .common import boolean, integer, link_list, links, number, status, text
@@ -132,8 +133,9 @@ def parse_network_protocol(proto: Mapping[str, Any]) -> dict[str, Any]:
for community in snmp.get("CommunityStrings") or []: for community in snmp.get("CommunityStrings") or []:
if isinstance(community, Mapping): if isinstance(community, Mapping):
# The community string itself is never kept, even if a BMC returns it. # The community string itself is never kept, even if a BMC returns it.
communities.append({"name": text(community.get("Name")), communities.append(
"access": text(community.get("AccessMode"))}) {"name": text(community.get("Name")), "access": text(community.get("AccessMode"))}
)
return { return {
"hostname": text(proto.get("HostName")), "hostname": text(proto.get("HostName")),
+2 -1
View File
@@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Mapping from collections.abc import Mapping
from typing import Any
from .common import link, links, text from .common import link, links, text
+11 -7
View File
@@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Mapping from collections.abc import Mapping
from typing import Any
from .common import ident, integer, link_list, links, number, status, text from .common import ident, integer, link_list, links, number, status, text
@@ -10,10 +11,12 @@ from .common import ident, integer, link_list, links, number, status, text
def parse_system(system: Mapping[str, Any]) -> dict[str, Any]: def parse_system(system: Mapping[str, Any]) -> dict[str, Any]:
"""Identity, summaries and links of the managed server.""" """Identity, summaries and links of the managed server."""
state, health = status(system) state, health = status(system)
cpus = system.get("ProcessorSummary") if isinstance(system.get("ProcessorSummary"), cpus = (
Mapping) else {} system.get("ProcessorSummary")
memory = system.get("MemorySummary") if isinstance(system.get("MemorySummary"), if isinstance(system.get("ProcessorSummary"), Mapping)
Mapping) else {} else {}
)
memory = system.get("MemorySummary") if isinstance(system.get("MemorySummary"), Mapping) else {}
return { return {
"id": text(system.get("Id")), "id": text(system.get("Id")),
"name": text(system.get("Name")), "name": text(system.get("Name")),
@@ -44,8 +47,9 @@ def parse_power_state(system: Mapping[str, Any]) -> str | None:
return text(system.get("PowerState")) return text(system.get("PowerState"))
def parse_reset_action(resource: Mapping[str, Any], def parse_reset_action(
action: str = "#ComputerSystem.Reset") -> dict[str, Any]: resource: Mapping[str, Any], action: str = "#ComputerSystem.Reset"
) -> dict[str, Any]:
"""Target and allowed ``ResetType`` values of a reset action. """Target and allowed ``ResetType`` values of a reset action.
``allowed`` is ``None`` when the service does not list the values inline. Then ``allowed`` is ``None`` when the service does not list the values inline. Then
View File
Whitespace-only changes.
+28 -14
View File
@@ -20,7 +20,8 @@ import ipaddress
import logging import logging
import os import os
import re import re
from typing import Any, Mapping from collections.abc import Mapping
from typing import Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
import requests import requests
@@ -80,8 +81,14 @@ class RedfishFeatureLicenseError(RedfishAuthError):
refuses ``Systems/1/Storage`` and ``UpdateService`` for want of DCMS. refuses ``Systems/1/Storage`` and ``UpdateService`` for want of DCMS.
""" """
def __init__(self, message: str, *, licenses: str = "", status: int | None = None, def __init__(
path: str | None = None): self,
message: str,
*,
licenses: str = "",
status: int | None = None,
path: str | None = None,
):
super().__init__(message, status=status, path=path) super().__init__(message, status=status, path=path)
#: The licenses the service names, e.g. "DCMS"; empty when it names none. #: The licenses the service names, e.g. "DCMS"; empty when it names none.
self.licenses = licenses self.licenses = licenses
@@ -270,8 +277,9 @@ class RedfishTransport:
return "session" return "session"
if self.auth == "session": if self.auth == "session":
raise RedfishAuthError(f"{self.base_url}: session login returned no X-Auth-Token") raise RedfishAuthError(f"{self.base_url}: session login returned no X-Auth-Token")
LOG.info("%s: session login returned no token, falling back to basic auth", LOG.info(
self.base_url) "%s: session login returned no token, falling back to basic auth", self.base_url
)
self._call(client.login, auth=AuthMethod.BASIC) self._call(client.login, auth=AuthMethod.BASIC)
return "basic" return "basic"
@@ -281,8 +289,9 @@ class RedfishTransport:
except InvalidCredentialsError as exc: except InvalidCredentialsError as exc:
raise RedfishAuthError(f"{self.base_url}: invalid credentials", status=401) from exc raise RedfishAuthError(f"{self.base_url}: invalid credentials", status=401) from exc
except RedfishPasswordChangeRequiredError as exc: except RedfishPasswordChangeRequiredError as exc:
raise RedfishAuthError(f"{self.base_url}: the account must change its password " raise RedfishAuthError(
"before it can be used") from exc f"{self.base_url}: the account must change its password before it can be used"
) from exc
except SessionCreationError as exc: except SessionCreationError as exc:
raise _SessionUnsupported(str(exc).strip()) from exc raise _SessionUnsupported(str(exc).strip()) from exc
except (RetriesExhaustedError, ServerDownOrUnreachableError) as exc: except (RetriesExhaustedError, ServerDownOrUnreachableError) as exc:
@@ -300,7 +309,7 @@ class RedfishTransport:
LOG.warning("%s: could not delete the Redfish session: %s", self.base_url, exc) LOG.warning("%s: could not delete the Redfish session: %s", self.base_url, exc)
client._session.close() client._session.close()
def __enter__(self) -> "RedfishTransport": def __enter__(self) -> RedfishTransport:
self.open() self.open()
return self return self
@@ -340,20 +349,25 @@ class RedfishTransport:
data = resp.dict data = resp.dict
except Exception as exc: except Exception as exc:
if status < 400: if status < 400:
raise RedfishError(f"{method} {path}: response is not JSON", raise RedfishError(
status=status, path=path) from exc f"{method} {path}: response is not JSON", status=status, path=path
) from exc
data = {} data = {}
if status < 400: if status < 400:
if not isinstance(data, dict): if not isinstance(data, dict):
raise RedfishError(f"{method} {path}: response is not a JSON object", raise RedfishError(
status=status, path=path) f"{method} {path}: response is not a JSON object", status=status, path=path
)
return data return data
detail = _error_message(data) detail = _error_message(data)
message = f"{method} {path}: HTTP {status}" + (f": {detail}" if detail else "") message = f"{method} {path}: HTTP {status}" + (f": {detail}" if detail else "")
if status == 403 and any(i.endswith(LICENSE_REFUSED) for i in _message_ids(data)): if status == 403 and any(i.endswith(LICENSE_REFUSED) for i in _message_ids(data)):
raise RedfishFeatureLicenseError( raise RedfishFeatureLicenseError(
message, licenses=" ".join(" ".join(_message_args(data)).split()), message,
status=status, path=path) licenses=" ".join(" ".join(_message_args(data)).split()),
status=status,
path=path,
)
if status in (401, 403): if status in (401, 403):
raise RedfishAuthError(message, status=status, path=path) raise RedfishAuthError(message, status=status, path=path)
if status == 404: if status == 404:
+61 -12
View File
@@ -1,33 +1,36 @@
[build-system] [build-system]
requires = ["setuptools>=64"] requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "napalm-redfish" name = "napalm-redfish"
version = "0.5.0" version = "0.6.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 = "Apache-2.0"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"napalm>=5.0", "napalm>=5.0",
# Not on PyPI: install from git before this package (see README). "napalm-device-types>=4.21.1",
"napalm-device-types>=4.15", # Before 3.4.1, max_retry=0 (retries=0) gets no response back at all.
"redfish>=3.2", "redfish>=3.4.1",
"pyghmi>=1.5", # 1.5.0 imports python-dateutil without declaring it.
"pyghmi>=1.5.1",
] ]
classifiers = [ classifiers = [
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Topic :: System :: Hardware", "Topic :: System :: Hardware",
"Topic :: System :: Networking", "Topic :: System :: Networking",
] ]
[project.optional-dependencies] [project.optional-dependencies]
test = [ # responses mocks the BMC's HTTP answers in the tests.
"pytest>=7", dev = ["pytest>=8", "pytest-cov>=5", "mypy>=1.11", "ruff>=0.6", "responses>=0.23"]
"responses>=0.23",
] [project.urls]
Repository = "https://git.netork.io/NAPALM/napalm-redfish"
Issues = "https://git.netork.io/NAPALM/napalm-redfish/issues"
Changelog = "https://git.netork.io/NAPALM/napalm-redfish/src/branch/main/CHANGELOG.md"
[project.entry-points."napalm.drivers"] [project.entry-points."napalm.drivers"]
redfish = "napalm_redfish:RedfishDriver" redfish = "napalm_redfish:RedfishDriver"
@@ -37,5 +40,51 @@ bmc = "napalm_redfish.bmc:BmcDriver"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["napalm_redfish*"] include = ["napalm_redfish*"]
[tool.setuptools.package-data]
napalm_redfish = ["py.typed"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.ruff.lint.per-file-ignores]
# Recorded device output keeps its lines.
"tests/**" = ["E501"]
[tool.mypy]
python_version = "3.10"
strict = true
# napalm itself is untyped, so every driver subclasses Any.
disallow_subclassing_any = false
[[tool.mypy.overrides]]
module = ["napalm.*", "redfish.*", "pyghmi.*", "requests.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
# Not yet under strict mypy (quality baseline); may only shrink.
module = [
"napalm_redfish.bmc",
"napalm_redfish.driver",
"napalm_redfish.ipmi",
"napalm_redfish.parsers.common",
"napalm_redfish.parsers.environment",
"napalm_redfish.parsers.manager",
"napalm_redfish.parsers.system",
"napalm_redfish.transport",
]
ignore_errors = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
# The tests import their helpers as tests.conftest, also under a bare pytest.
pythonpath = ["."]
[tool.coverage.run]
source = ["napalm_redfish"]
[tool.coverage.report]
fail_under = 94
+29 -10
View File
@@ -17,7 +17,7 @@ def fixture_file(mockup: str, path: str) -> Path:
path = path.split("#", 1)[0].split("?", 1)[0].rstrip("/") path = path.split("#", 1)[0].split("?", 1)[0].rstrip("/")
if not path.startswith(PREFIX): if not path.startswith(PREFIX):
raise ValueError(f"not a Redfish path: {path}") raise ValueError(f"not a Redfish path: {path}")
relative = path[len(PREFIX):].lstrip("/") relative = path[len(PREFIX) :].lstrip("/")
return FIXTURES / mockup / relative / "index.json" return FIXTURES / mockup / relative / "index.json"
@@ -64,8 +64,14 @@ class MockupServer:
for method in (responses.GET, responses.POST, responses.DELETE): for method in (responses.GET, responses.POST, responses.DELETE):
rsps.add_callback(method, anything, callback=self._handle) rsps.add_callback(method, anything, callback=self._handle)
def override(self, method: str, path: str, status: int, body: dict | str | None = None, def override(
headers: dict | None = None): self,
method: str,
path: str,
status: int,
body: dict | str | None = None,
headers: dict | None = None,
):
self.overrides[(method, path)] = (status, body if body is not None else {}, headers or {}) self.overrides[(method, path)] = (status, body if body is not None else {}, headers or {})
@staticmethod @staticmethod
@@ -82,8 +88,11 @@ class MockupServer:
if key in self.overrides: if key in self.overrides:
return self._reply(*self.overrides[key]) return self._reply(*self.overrides[key])
if request.method == "POST" and parts.path == SESSIONS: if request.method == "POST" and parts.path == SESSIONS:
return self._reply(201, {"@odata.id": SESSION_PATH, "Id": "0123456789abcdef"}, return self._reply(
{"X-Auth-Token": TOKEN, "Location": BASE_URL + SESSION_PATH}) 201,
{"@odata.id": SESSION_PATH, "Id": "0123456789abcdef"},
{"X-Auth-Token": TOKEN, "Location": BASE_URL + SESSION_PATH},
)
if request.method == "DELETE" and parts.path == SESSION_PATH: if request.method == "DELETE" and parts.path == SESSION_PATH:
return self._reply(204, {}) return self._reply(204, {})
if request.method == "GET": if request.method == "GET":
@@ -91,13 +100,23 @@ class MockupServer:
return self._reply(200, load(self.mockup, parts.path)) return self._reply(200, load(self.mockup, parts.path))
except (FileNotFoundError, ValueError): except (FileNotFoundError, ValueError):
pass pass
return self._reply(404, {"error": {"code": "Base.1.0.ResourceMissingAtURI", return self._reply(
"message": f"{parts.path} not found"}}) 404,
{
"error": {
"code": "Base.1.0.ResourceMissingAtURI",
"message": f"{parts.path} not found",
}
},
)
def calls(self, method: str | None = None, path: str | None = None): def calls(self, method: str | None = None, path: str | None = None):
return [c for c in self.rsps.calls return [
if (method is None or c.request.method == method) c
and (path is None or urlsplit(c.request.url).path == path)] for c in self.rsps.calls
if (method is None or c.request.method == method)
and (path is None or urlsplit(c.request.url).path == path)
]
@pytest.fixture @pytest.fixture
+33 -8
View File
@@ -34,14 +34,39 @@ def test_account_defaults():
def test_to_napalm_users(rackmount): def test_to_napalm_users(rackmount):
accounts = [parse_account(rackmount(f"/redfish/v1/AccountService/Accounts/{i}")) accounts = [
for i in (1, 2)] parse_account(rackmount(f"/redfish/v1/AccountService/Accounts/{i}")) for i in (1, 2)
accounts.append({"id": "3", "username": "netork", "role_id": "ReadOnly", "enabled": True, ]
"locked": False, "account_types": []}) accounts.append(
accounts.append({"id": "4", "username": "ops", "role_id": "Operator", "enabled": False, {
"locked": False, "account_types": []}) "id": "3",
accounts.append({"id": "5", "username": "custom", "role_id": "OemSpecial", "enabled": True, "username": "netork",
"locked": False, "account_types": []}) "role_id": "ReadOnly",
"enabled": True,
"locked": False,
"account_types": [],
}
)
accounts.append(
{
"id": "4",
"username": "ops",
"role_id": "Operator",
"enabled": False,
"locked": False,
"account_types": [],
}
)
accounts.append(
{
"id": "5",
"username": "custom",
"role_id": "OemSpecial",
"enabled": True,
"locked": False,
"account_types": [],
}
)
accounts.append(None) accounts.append(None)
assert to_napalm_users(accounts) == { assert to_napalm_users(accounts) == {
"Administrator": {"level": 15, "password": "", "sshkeys": []}, "Administrator": {"level": 15, "password": "", "sshkeys": []},
+105 -51
View File
@@ -39,10 +39,12 @@ def test_sources_legacy_only(localstorage):
def test_sources_mixed_and_missing(): def test_sources_mixed_and_missing():
src = environment_sources({ src = environment_sources(
"ThermalSubsystem": {"@odata.id": "/redfish/v1/Chassis/X/ThermalSubsystem"}, {
"Power": {"@odata.id": "/redfish/v1/Chassis/X/Power"}, "ThermalSubsystem": {"@odata.id": "/redfish/v1/Chassis/X/ThermalSubsystem"},
}) "Power": {"@odata.id": "/redfish/v1/Chassis/X/Power"},
}
)
assert src["thermal"]["schema"] == "ThermalSubsystem" assert src["thermal"]["schema"] == "ThermalSubsystem"
assert src["power"]["schema"] == "Power" assert src["power"]["schema"] == "Power"
assert environment_sources({}) == {"thermal": None, "power": None, "sensors": None} assert environment_sources({}) == {"thermal": None, "power": None, "sensors": None}
@@ -68,42 +70,70 @@ def test_thermal(rackmount):
"reading, health, alert, critical", "reading, health, alert, critical",
[ [
(41, "OK", False, False), (41, "OK", False, False),
(42, "OK", True, False), # at UpperThresholdNonCritical (42, "OK", True, False), # at UpperThresholdNonCritical
(45, "OK", True, True), # at UpperThresholdCritical (45, "OK", True, True), # at UpperThresholdCritical
(49, "OK", True, True), # above UpperThresholdFatal (49, "OK", True, True), # above UpperThresholdFatal
(4, "OK", True, True), # below LowerThresholdCritical (4, "OK", True, True), # below LowerThresholdCritical
(9, "OK", True, False), # below LowerThresholdNonCritical (9, "OK", True, False), # below LowerThresholdNonCritical
(30, "Warning", True, False), (30, "Warning", True, False),
(30, "Critical", True, True), (30, "Critical", True, True),
], ],
) )
def test_thermal_thresholds(reading, health, alert, critical): def test_thermal_thresholds(reading, health, alert, critical):
env = parse_thermal({"Temperatures": [{ env = parse_thermal(
"Name": "T", "ReadingCelsius": reading, "Status": {"State": "Enabled", "Health": health}, {
"UpperThresholdNonCritical": 42, "UpperThresholdCritical": 45, "UpperThresholdFatal": 48, "Temperatures": [
"LowerThresholdNonCritical": 10, "LowerThresholdCritical": 5, {
}]}) "Name": "T",
"ReadingCelsius": reading,
"Status": {"State": "Enabled", "Health": health},
"UpperThresholdNonCritical": 42,
"UpperThresholdCritical": 45,
"UpperThresholdFatal": 48,
"LowerThresholdNonCritical": 10,
"LowerThresholdCritical": 5,
}
]
}
)
assert env["temperature"]["T"] == { assert env["temperature"]["T"] == {
"temperature": float(reading), "is_alert": alert, "is_critical": critical} "temperature": float(reading),
"is_alert": alert,
"is_critical": critical,
}
def test_thermal_old_schema_names_and_duplicates(): def test_thermal_old_schema_names_and_duplicates():
# Thermal 1.0 uses FanName; Supermicro repeats names across members. # Thermal 1.0 uses FanName; Supermicro repeats names across members.
env = parse_thermal({ env = parse_thermal(
"Fans": [ {
{"MemberId": "0", "FanName": "FAN1", "Reading": 3000, "Status": {"State": "Enabled", "Fans": [
"Health": "OK"}}, {
{"MemberId": "1", "FanName": "FAN2", "Status": {"State": "Absent"}}, "MemberId": "0",
{"MemberId": "2", "FanName": "FAN3", "Reading": 0, "FanName": "FAN1",
"Status": {"State": "Enabled", "Health": "Critical"}}, "Reading": 3000,
{"MemberId": "3", "FanName": "FAN1", "Reading": 2900, "Status": {"State": "Enabled", "Health": "OK"},
"Status": {"State": "Enabled", "Health": "OK"}}, },
], {"MemberId": "1", "FanName": "FAN2", "Status": {"State": "Absent"}},
"Temperatures": [ {
{"MemberId": "0", "Name": "System Temp", "ReadingCelsius": 30}, "MemberId": "2",
{"MemberId": "1", "Name": "System Temp", "ReadingCelsius": 31}, "FanName": "FAN3",
], "Reading": 0,
}) "Status": {"State": "Enabled", "Health": "Critical"},
},
{
"MemberId": "3",
"FanName": "FAN1",
"Reading": 2900,
"Status": {"State": "Enabled", "Health": "OK"},
},
],
"Temperatures": [
{"MemberId": "0", "Name": "System Temp", "ReadingCelsius": 30},
{"MemberId": "1", "Name": "System Temp", "ReadingCelsius": 31},
],
}
)
assert env["fans"] == { assert env["fans"] == {
"FAN1": {"status": True}, "FAN1": {"status": True},
"FAN3": {"status": False}, "FAN3": {"status": False},
@@ -126,10 +156,14 @@ def test_power(rackmount):
def test_power_unknown_values_are_minus_one(): def test_power_unknown_values_are_minus_one():
env = parse_power({"PowerSupplies": [ env = parse_power(
{"MemberId": "0", "Name": "PS1", "Status": {"State": "Enabled", "Health": "OK"}}, {
{"MemberId": "1", "Name": "PS2", "Status": {"State": "Absent"}}, "PowerSupplies": [
]}) {"MemberId": "0", "Name": "PS1", "Status": {"State": "Enabled", "Health": "OK"}},
{"MemberId": "1", "Name": "PS2", "Status": {"State": "Absent"}},
]
}
)
assert env["power"] == {"PS1": {"status": True, "capacity": -1.0, "output": -1.0}} assert env["power"] == {"PS1": {"status": True, "capacity": -1.0, "output": -1.0}}
assert env["consumed_watts"] is None assert env["consumed_watts"] is None
assert env["voltages"] == {} assert env["voltages"] == {}
@@ -211,8 +245,8 @@ def test_sensor_temperature_by_units(rackmount):
assert s["units"] == "Cel" assert s["units"] == "Cel"
assert s["physical_context"] == "CPU" assert s["physical_context"] == "CPU"
assert s["health"] == "Warning" assert s["health"] == "Warning"
assert s["is_alert"] is True # 44 >= UpperCaution 42 assert s["is_alert"] is True # 44 >= UpperCaution 42
assert s["is_critical"] is False # 44 < UpperCritical 45 assert s["is_critical"] is False # 44 < UpperCritical 45
def test_sensor_power(rackmount): def test_sensor_power(rackmount):
@@ -234,28 +268,37 @@ def test_sensor_voltage_and_fan(rackmount):
def test_sensor_without_reading(): def test_sensor_without_reading():
s = parse_sensor({"Id": "X", "Name": "X", "ReadingUnits": "Cel", s = parse_sensor(
"Status": {"State": "Disabled"}}) {"Id": "X", "Name": "X", "ReadingUnits": "Cel", "Status": {"State": "Disabled"}}
)
assert s["reading"] is None assert s["reading"] is None
assert s["is_alert"] is False assert s["is_alert"] is False
def test_thermal_metrics(rackmount): def test_thermal_metrics(rackmount):
env = parse_thermal_metrics(rackmount(CHASSIS + "/ThermalSubsystem/ThermalMetrics")) env = parse_thermal_metrics(rackmount(CHASSIS + "/ThermalSubsystem/ThermalMetrics"))
assert env == {"temperature": { assert env == {
"Intake": {"temperature": 24.8, "is_alert": False, "is_critical": False}, "temperature": {
"Exhaust": {"temperature": 40.5, "is_alert": False, "is_critical": False}, "Intake": {"temperature": 24.8, "is_alert": False, "is_critical": False},
}} "Exhaust": {"temperature": 40.5, "is_alert": False, "is_critical": False},
}
}
def test_build_environment_from_new_resources(rackmount): def test_build_environment_from_new_resources(rackmount):
fans = [parse_fan(rackmount(f"{CHASSIS}/ThermalSubsystem/Fans/{f}")) fans = [
for f in ("Bay1", "Bay2", "CPU1", "CPU2")] parse_fan(rackmount(f"{CHASSIS}/ThermalSubsystem/Fans/{f}"))
for f in ("Bay1", "Bay2", "CPU1", "CPU2")
]
bay1 = f"{CHASSIS}/PowerSubsystem/PowerSupplies/Bay1" bay1 = f"{CHASSIS}/PowerSubsystem/PowerSupplies/Bay1"
psus = [parse_power_supply(rackmount(bay1), rackmount(bay1 + "/Metrics")), psus = [
parse_power_supply(rackmount(f"{CHASSIS}/PowerSubsystem/PowerSupplies/Bay2"))] parse_power_supply(rackmount(bay1), rackmount(bay1 + "/Metrics")),
sensors = [parse_sensor(rackmount(f"{CHASSIS}/Sensors/{s}")) parse_power_supply(rackmount(f"{CHASSIS}/PowerSubsystem/PowerSupplies/Bay2")),
for s in ("CPU1Temp", "IntakeTemp", "CPUFan1", "TotalPower", "PS1InputVoltage")] ]
sensors = [
parse_sensor(rackmount(f"{CHASSIS}/Sensors/{s}"))
for s in ("CPU1Temp", "IntakeTemp", "CPUFan1", "TotalPower", "PS1InputVoltage")
]
env = build_environment(fans=fans, power_supplies=psus, sensors=sensors) env = build_environment(fans=fans, power_supplies=psus, sensors=sensors)
assert env["fans"] == { assert env["fans"] == {
"Fan Bay 1": {"status": True}, "Fan Bay 1": {"status": True},
@@ -269,18 +312,29 @@ def test_build_environment_from_new_resources(rackmount):
assert env["temperature"] == { assert env["temperature"] == {
"CPU #1 Temperature": {"temperature": 44.0, "is_alert": True, "is_critical": False}, "CPU #1 Temperature": {"temperature": 44.0, "is_alert": True, "is_critical": False},
"Front Panel Intake Temperature": { "Front Panel Intake Temperature": {
"temperature": 24.8, "is_alert": False, "is_critical": False}, "temperature": 24.8,
"is_alert": False,
"is_critical": False,
},
} }
assert env["consumed_watts"] == 374.0 assert env["consumed_watts"] == 374.0
assert env["voltages"] == { assert env["voltages"] == {
"Power Supply #1 Input Voltage": { "Power Supply #1 Input Voltage": {
"voltage": 119.27, "is_alert": False, "is_critical": False}, "voltage": 119.27,
"is_alert": False,
"is_critical": False,
},
} }
def test_build_environment_empty(): def test_build_environment_empty():
assert build_environment() == { assert build_environment() == {
"fans": {}, "temperature": {}, "power": {}, "voltages": {}, "consumed_watts": None} "fans": {},
"temperature": {},
"power": {},
"voltages": {},
"consumed_watts": None,
}
def test_power_supply_legacy_item(rackmount): def test_power_supply_legacy_item(rackmount):
+31 -17
View File
@@ -83,12 +83,20 @@ def test_memory_absent(rackmount):
def test_memory_older_schema(): def test_memory_older_schema():
# Memory 1.0-1.x: DeviceLocator instead of Location, speed in OperatingSpeedMhz. # Memory 1.0-1.x: DeviceLocator instead of Location, speed in OperatingSpeedMhz.
dimm = parse_memory({ dimm = parse_memory(
"Id": "1", "Name": "Memory 1", "DeviceLocator": "P1-DIMMA1", "CapacityMiB": 16384, {
"OperatingSpeedMhz": 2133, "Manufacturer": "Samsung ", "PartNumber": "M393A2G40DB0-CPB", "Id": "1",
"SerialNumber": "12345678", "MemoryDeviceType": "DDR4", "Name": "Memory 1",
"Status": {"State": "Enabled", "Health": "OK"}, "DeviceLocator": "P1-DIMMA1",
}) "CapacityMiB": 16384,
"OperatingSpeedMhz": 2133,
"Manufacturer": "Samsung ",
"PartNumber": "M393A2G40DB0-CPB",
"SerialNumber": "12345678",
"MemoryDeviceType": "DDR4",
"Status": {"State": "Enabled", "Health": "OK"},
}
)
assert dimm["location"] == "P1-DIMMA1" assert dimm["location"] == "P1-DIMMA1"
assert dimm["speed_mhz"] == 2133 assert dimm["speed_mhz"] == 2133
assert dimm["manufacturer"] == "Samsung" assert dimm["manufacturer"] == "Samsung"
@@ -110,16 +118,18 @@ def test_storage(localstorage):
assert st["name"] == "Local Storage Controller" assert st["name"] == "Local Storage Controller"
assert st["state"] == "Enabled" assert st["state"] == "Enabled"
assert st["health"] == "OK" assert st["health"] == "OK"
assert st["controllers"] == [{ assert st["controllers"] == [
"name": "Contoso Integrated RAID", {
"manufacturer": "Contoso", "name": "Contoso Integrated RAID",
"model": "12Gbs Integrated RAID", "manufacturer": "Contoso",
"serial_number": "2M220100SL", "model": "12Gbs Integrated RAID",
"firmware_version": "1.0.0.7", "serial_number": "2M220100SL",
"speed_gbps": 12, "firmware_version": "1.0.0.7",
"state": "Enabled", "speed_gbps": 12,
"health": "OK", "state": "Enabled",
}] "health": "OK",
}
]
assert st["drive_paths"] == [ assert st["drive_paths"] == [
"/redfish/v1/Chassis/1U/Drives/35D38F11ACEF7BD3", "/redfish/v1/Chassis/1U/Drives/35D38F11ACEF7BD3",
"/redfish/v1/Chassis/1U/Drives/3F5A8C54207B7233", "/redfish/v1/Chassis/1U/Drives/3F5A8C54207B7233",
@@ -163,7 +173,11 @@ def test_simple_storage(rackmount):
assert st["name"] == "Simple Storage Controller" assert st["name"] == "Simple Storage Controller"
assert st["health"] == "OK" assert st["health"] == "OK"
assert [d["name"] for d in st["drives"]] == [ assert [d["name"] for d in st["drives"]] == [
"SATA Bay 1", "SATA Bay 2", "SATA Bay 3", "SATA Bay 4"] "SATA Bay 1",
"SATA Bay 2",
"SATA Bay 3",
"SATA Bay 4",
]
bay1, bay2, bay3, _ = st["drives"] bay1, bay2, bay3, _ = st["drives"]
assert bay1["model"] == "3000GT8" assert bay1["model"] == "3000GT8"
assert bay1["capacity_bytes"] == 8000000000000 assert bay1["capacity_bytes"] == 8000000000000
+43 -23
View File
@@ -88,29 +88,33 @@ def test_ethernet_link_state(props, is_up, is_enabled):
def test_ethernet_unset_addresses_and_formats(): def test_ethernet_unset_addresses_and_formats():
# Supermicro and others report unset addresses as 0.0.0.0 or "::" and MACs in lower case. # Supermicro and others report unset addresses as 0.0.0.0 or "::" and MACs in lower case.
eth = parse_ethernet_interface({ eth = parse_ethernet_interface(
"Id": "1", {
"MACAddress": "0c:c4:7a:aa:bb:cc", "Id": "1",
"SpeedMbps": None, "MACAddress": "0c:c4:7a:aa:bb:cc",
"IPv4Addresses": [ "SpeedMbps": None,
{"Address": "0.0.0.0", "SubnetMask": "0.0.0.0"}, "IPv4Addresses": [
{"Address": "10.1.2.3", "SubnetMask": "255.255.255.0"}, {"Address": "0.0.0.0", "SubnetMask": "0.0.0.0"},
{"Address": "10.1.2.4", "SubnetMask": "garbage"}, {"Address": "10.1.2.3", "SubnetMask": "255.255.255.0"},
{"Address": None}, {"Address": "10.1.2.4", "SubnetMask": "garbage"},
], {"Address": None},
"IPv6Addresses": [ ],
{"Address": "::", "PrefixLength": 0}, "IPv6Addresses": [
{"Address": "2001:DB8::5", "PrefixLength": 64}, {"Address": "::", "PrefixLength": 0},
{"Address": "2001:db8::6"}, {"Address": "2001:DB8::5", "PrefixLength": 64},
], {"Address": "2001:db8::6"},
}) ],
}
)
assert eth["interface"]["mac_address"] == "0C:C4:7A:AA:BB:CC" assert eth["interface"]["mac_address"] == "0C:C4:7A:AA:BB:CC"
assert eth["interface"]["speed"] == -1.0 assert eth["interface"]["speed"] == -1.0
assert eth["interface"]["mtu"] == 0 assert eth["interface"]["mtu"] == 0
assert eth["interface"]["description"] == "" assert eth["interface"]["description"] == ""
assert eth["ipv4"] == {"10.1.2.3": {"prefix_length": 24}} assert eth["ipv4"] == {"10.1.2.3": {"prefix_length": 24}}
assert eth["ipv6"] == {"2001:db8::5": {"prefix_length": 64}, assert eth["ipv6"] == {
"2001:db8::6": {"prefix_length": 128}} "2001:db8::5": {"prefix_length": 64},
"2001:db8::6": {"prefix_length": 128},
}
# --- NetworkProtocol ----------------------------------------------------------------------- # --- NetworkProtocol -----------------------------------------------------------------------
@@ -133,7 +137,11 @@ def test_network_protocol(rackmount):
} }
assert np["ntp"] == {"enabled": None, "servers": []} assert np["ntp"] == {"enabled": None, "servers": []}
assert np["snmp"] == { assert np["snmp"] == {
"enabled": True, "port": 161, "v1": True, "v2c": False, "v3": True, "enabled": True,
"port": 161,
"v1": True,
"v2c": False,
"v3": True,
"communities": [ "communities": [
{"name": "Read-only community", "access": "Limited"}, {"name": "Read-only community", "access": "Limited"},
{"name": "Full access community", "access": "Full"}, {"name": "Full access community", "access": "Full"},
@@ -149,8 +157,14 @@ def test_network_protocol_ntp(telemetry):
} }
assert np["protocols"]["NTP"] == {"enabled": True, "port": None} assert np["protocols"]["NTP"] == {"enabled": True, "port": None}
# Older SNMP schema: only ProtocolEnabled and Port. # Older SNMP schema: only ProtocolEnabled and Port.
assert np["snmp"] == {"enabled": True, "port": 161, "v1": None, "v2c": None, "v3": None, assert np["snmp"] == {
"communities": []} "enabled": True,
"port": 161,
"v1": None,
"v2c": None,
"v3": None,
"communities": [],
}
def test_network_protocol_port_without_flag(): def test_network_protocol_port_without_flag():
@@ -162,5 +176,11 @@ def test_network_protocol_ntp_blank_entries():
servers = ["", None, " pool.ntp.org ", "pool.ntp.org"] servers = ["", None, " pool.ntp.org ", "pool.ntp.org"]
np = parse_network_protocol({"NTP": {"ProtocolEnabled": True, "NTPServers": servers}}) np = parse_network_protocol({"NTP": {"ProtocolEnabled": True, "NTPServers": servers}})
assert np["ntp"]["servers"] == ["pool.ntp.org"] assert np["ntp"]["servers"] == ["pool.ntp.org"]
assert np["snmp"] == {"enabled": None, "port": None, "v1": None, "v2c": None, "v3": None, assert np["snmp"] == {
"communities": []} "enabled": None,
"port": None,
"v1": None,
"v2c": None,
"v3": None,
"communities": [],
}
+9 -7
View File
@@ -26,13 +26,15 @@ def test_service_root_links(rackmount):
def test_service_root_vendor_and_oem(): def test_service_root_vendor_and_oem():
root = parse_service_root({ root = parse_service_root(
"RedfishVersion": "1.0.1", {
"Vendor": "Supermicro", "RedfishVersion": "1.0.1",
"Product": "X10DRL-i", "Vendor": "Supermicro",
"Oem": {"Supermicro": {"@odata.type": "#SmcServiceRoot"}}, "Product": "X10DRL-i",
"Systems": {"@odata.id": "/redfish/v1/Systems"}, "Oem": {"Supermicro": {"@odata.type": "#SmcServiceRoot"}},
}) "Systems": {"@odata.id": "/redfish/v1/Systems"},
}
)
assert root["vendor"] == "Supermicro" assert root["vendor"] == "Supermicro"
assert root["product"] == "X10DRL-i" assert root["product"] == "X10DRL-i"
assert root["oem_vendors"] == ["Supermicro"] assert root["oem_vendors"] == ["Supermicro"]
+37 -16
View File
@@ -47,16 +47,18 @@ def test_system_links(rackmount):
def test_system_placeholders_and_blanks_are_none(): def test_system_placeholders_and_blanks_are_none():
# Supermicro boards report the DMI defaults the board vendor never filled in. # Supermicro boards report the DMI defaults the board vendor never filled in.
s = parse_system({ s = parse_system(
"Id": "1", {
"Manufacturer": "Supermicro", "Id": "1",
"Model": "X10DRL-i", "Manufacturer": "Supermicro",
"SerialNumber": "0123456789", "Model": "X10DRL-i",
"SKU": "To Be Filled By O.E.M.", "SerialNumber": "0123456789",
"HostName": "", "SKU": "To Be Filled By O.E.M.",
"UUID": " ", "HostName": "",
"PartNumber": "Default string", "UUID": " ",
}) "PartNumber": "Default string",
}
)
assert s["manufacturer"] == "Supermicro" assert s["manufacturer"] == "Supermicro"
assert s["model"] == "X10DRL-i" assert s["model"] == "X10DRL-i"
assert s["serial_number"] is None assert s["serial_number"] is None
@@ -88,8 +90,15 @@ def test_reset_action_inline_allowable_values(rackmount):
reset = parse_reset_action(rackmount(SYSTEM)) reset = parse_reset_action(rackmount(SYSTEM))
assert reset["target"] == SYSTEM + "/Actions/ComputerSystem.Reset" assert reset["target"] == SYSTEM + "/Actions/ComputerSystem.Reset"
assert reset["allowed"] == [ assert reset["allowed"] == [
"On", "ForceOff", "GracefulShutdown", "GracefulRestart", "ForceRestart", "On",
"Nmi", "ForceOn", "PushPowerButton", "FullPowerCycle", "ForceOff",
"GracefulShutdown",
"GracefulRestart",
"ForceRestart",
"Nmi",
"ForceOn",
"PushPowerButton",
"FullPowerCycle",
] ]
assert reset["action_info"] is None assert reset["action_info"] is None
@@ -104,13 +113,25 @@ def test_reset_action_with_action_info(telemetry):
def test_action_info(telemetry): def test_action_info(telemetry):
allowed = parse_action_info(telemetry("/redfish/v1/Systems/1/ResetActionInfo")) allowed = parse_action_info(telemetry("/redfish/v1/Systems/1/ResetActionInfo"))
assert allowed == ["On", "ForceOff", "GracefulShutdown", "GracefulRestart", assert allowed == [
"ForceRestart", "Nmi", "ForceOn", "PushPowerButton"] "On",
"ForceOff",
"GracefulShutdown",
"GracefulRestart",
"ForceRestart",
"Nmi",
"ForceOn",
"PushPowerButton",
]
def test_action_info_other_parameter(): def test_action_info_other_parameter():
info = {"Parameters": [{"Name": "ResetType", "AllowableValues": ["On"]}, info = {
{"Name": "Delay", "AllowableValues": ["0", "5"]}]} "Parameters": [
{"Name": "ResetType", "AllowableValues": ["On"]},
{"Name": "Delay", "AllowableValues": ["0", "5"]},
]
}
assert parse_action_info(info, "Delay") == ["0", "5"] assert parse_action_info(info, "Delay") == ["0", "5"]
assert parse_action_info(info, "Missing") is None assert parse_action_info(info, "Missing") is None
assert parse_action_info({}) is None assert parse_action_info({}) is None
+25
View File
@@ -0,0 +1,25 @@
{
"coverage_floor": 94.0,
"function_complexity": {
"napalm_redfish/driver.py::RedfishDriver._accepted_actions": 14,
"napalm_redfish/driver.py::RedfishDriver.get_environment": 14,
"napalm_redfish/driver.py::RedfishDriver.get_facts": 15,
"napalm_redfish/parsers/environment.py::build_environment": 13,
"napalm_redfish/parsers/manager.py::parse_network_protocol": 14,
"napalm_redfish/transport.py::RedfishTransport._request": 14,
"napalm_redfish/transport.py::RedfishTransport.members": 15
},
"function_lines": {},
"mypy_relaxed": [
"napalm_redfish.bmc",
"napalm_redfish.driver",
"napalm_redfish.ipmi",
"napalm_redfish.parsers.common",
"napalm_redfish.parsers.environment",
"napalm_redfish.parsers.manager",
"napalm_redfish.parsers.system",
"napalm_redfish.transport"
],
"python_file_lines": {},
"untyped_public": []
}
+20 -10
View File
@@ -35,8 +35,9 @@ def fake_pyghmi(monkeypatch):
def x10(http): def x10(http):
server = MockupServer(http, MOCKUP) server = MockupServer(http, MOCKUP)
for path, status in json.loads((FIXTURES / MOCKUP / "STATUS.json").read_text()).items(): for path, status in json.loads((FIXTURES / MOCKUP / "STATUS.json").read_text()).items():
body = json.loads((FIXTURES / MOCKUP / path[len("/redfish/v1/"):] / "index.json") body = json.loads(
.read_text()) (FIXTURES / MOCKUP / path[len("/redfish/v1/") :] / "index.json").read_text()
)
server.override("GET", path, status, body) server.override("GET", path, status, body)
return server return server
@@ -49,8 +50,9 @@ def _open(**optional):
@pytest.fixture @pytest.fixture
def unlicensed(x10): def unlicensed(x10):
x10.override("GET", "/redfish/v1/", 403, x10.override(
json.loads((X10 / "service_root_403.json").read_text())) "GET", "/redfish/v1/", 403, json.loads((X10 / "service_root_403.json").read_text())
)
d = _open() d = _open()
yield d yield d
d.close() d.close()
@@ -84,7 +86,11 @@ def test_redfish_is_used_and_ipmi_is_not_even_opened(x10):
assert d.get_facts()["model"] == "X10DRL-i" assert d.get_facts()["model"] == "X10DRL-i"
assert len(d.get_hardware_inventory()["cpus"]) == 2 assert len(d.get_hardware_inventory()["cpus"]) == 2
assert d.get_power_state()["actions"] == [ assert d.get_power_state()["actions"] == [
"GracefulShutdown", "ForceOff", "GracefulRestart", "ForceRestart"] "GracefulShutdown",
"ForceOff",
"GracefulRestart",
"ForceRestart",
]
finally: finally:
d.close() d.close()
assert FakeCommand.instances == [] assert FakeCommand.instances == []
@@ -117,10 +123,12 @@ def test_the_ipmi_port_is_its_own(x10):
def test_the_partial_license_warning_passes_through(x10): def test_the_partial_license_warning_passes_through(x10):
d = _open() d = _open()
try: try:
assert d.get_device_warnings() == [{ assert d.get_device_warnings() == [
"code": "bmc_license_partial", {
"meta": {"licenses": "DCMS", "parts": ["drives", "firmware inventory"]}, "code": "bmc_license_partial",
}] "meta": {"licenses": "DCMS", "parts": ["drives", "firmware inventory"]},
}
]
finally: finally:
d.close() d.close()
@@ -134,7 +142,9 @@ def test_an_unlicensed_bmc_is_read_over_ipmi(unlicensed):
assert facts["model"] == "X10DRL-i" assert facts["model"] == "X10DRL-i"
assert facts["os_version"] == "3.91" assert facts["os_version"] == "3.91"
assert unlicensed.get_power_state() == { assert unlicensed.get_power_state() == {
"state": "On", "actions": ["GracefulShutdown", "ForceOff", "ForceRestart"]} "state": "On",
"actions": ["GracefulShutdown", "ForceOff", "ForceRestart"],
}
def test_an_unlicensed_bmc_switches_power_over_ipmi(unlicensed): def test_an_unlicensed_bmc_switches_power_over_ipmi(unlicensed):
+1 -1
View File
@@ -53,7 +53,7 @@ def test_idrac_and_ilo_landing_pages_are_recognised():
def test_a_switch_page_scores_nothing(): def test_a_switch_page_scores_nothing():
page = '<html><head><title>Loading...</title></head><body>Web Switch</body></html>' page = "<html><head><title>Loading...</title></head><body>Web Switch</body></html>"
assert _score(page) == 0.0 assert _score(page) == 0.0
+212 -73
View File
@@ -34,10 +34,22 @@ def ts(iso: str) -> float:
@pytest.fixture @pytest.fixture
def driver(server): def driver(server):
# The mockup's system log advertises a nextLink page it does not contain. # The mockup's system log advertises a nextLink page it does not contain.
server.override("GET", SYSTEM_LOG + "?$skiptoken=2", 200, { server.override(
"Members": [{"@odata.id": SYSTEM_LOG + "/3", "Id": "3", "Severity": "Warning", "GET",
"Created": "2012-03-07T14:46:00Z", "Message": "Fan redundancy lost"}], SYSTEM_LOG + "?$skiptoken=2",
}) 200,
{
"Members": [
{
"@odata.id": SYSTEM_LOG + "/3",
"Id": "3",
"Severity": "Warning",
"Created": "2012-03-07T14:46:00Z",
"Message": "Fan redundancy lost",
}
],
},
)
d = RedfishDriver("bmc.example.net", "netork", "s3cret", optional_args={"verify_ssl": False}) d = RedfishDriver("bmc.example.net", "netork", "s3cret", optional_args={"verify_ssl": False})
d.open() d.open()
yield d yield d
@@ -59,9 +71,15 @@ def test_role():
assert primary_role_of(RedfishDriver) == "management_controller" assert primary_role_of(RedfishDriver) == "management_controller"
assert RedfishDriver.USES_SSH is False assert RedfishDriver.USES_SSH is False
assert RedfishDriver.DRIVER_NAME == "redfish" assert RedfishDriver.DRIVER_NAME == "redfish"
for method in ("get_power_state", "set_power_state", "get_hardware_inventory", for method in (
"get_event_log", "get_firmware_inventory", "get_management_protocols", "get_power_state",
"reboot_host"): "set_power_state",
"get_hardware_inventory",
"get_event_log",
"get_firmware_inventory",
"get_management_protocols",
"reboot_host",
):
assert hasattr(RedfishDriver, method), method assert hasattr(RedfishDriver, method), method
@@ -92,8 +110,13 @@ def test_open_failure_is_connection_exception(server):
def test_optional_args(server): def test_optional_args(server):
d = RedfishDriver("bmc.example.net", "u", "p", timeout=12, d = RedfishDriver(
optional_args={"port": 8443, "verify_ssl": False, "auth": "basic"}) "bmc.example.net",
"u",
"p",
timeout=12,
optional_args={"port": 8443, "verify_ssl": False, "auth": "basic"},
)
assert d.transport.base_url == "https://bmc.example.net:8443" assert d.transport.base_url == "https://bmc.example.net:8443"
assert d.transport.verify is False assert d.transport.verify is False
assert d.transport.auth == "basic" assert d.transport.auth == "basic"
@@ -141,10 +164,14 @@ def test_get_interfaces(driver):
def test_get_interfaces_ip(driver): def test_get_interfaces_ip(driver):
assert driver.get_interfaces_ip() == { assert driver.get_interfaces_ip() == {
"eth0": {"ipv4": {"192.168.0.10": {"prefix_length": 22}}, "eth0": {
"ipv6": {"fe80::1ec1:deff:fe6f:1e24": {"prefix_length": 64}}}, "ipv4": {"192.168.0.10": {"prefix_length": 22}},
"ToHost": {"ipv4": {"192.168.20.55": {"prefix_length": 24}}, "ipv6": {"fe80::1ec1:deff:fe6f:1e24": {"prefix_length": 64}},
"ipv6": {"fe80::1ec1:deff:fe6f:1e24": {"prefix_length": 64}}}, },
"ToHost": {
"ipv4": {"192.168.20.55": {"prefix_length": 24}},
"ipv6": {"fe80::1ec1:deff:fe6f:1e24": {"prefix_length": 64}},
},
} }
@@ -164,23 +191,33 @@ def test_get_environment_new_resources(driver, server):
} }
assert len(env["temperature"]) == 8 assert len(env["temperature"]) == 8
assert env["temperature"]["CPU #1 Temperature"] == { assert env["temperature"]["CPU #1 Temperature"] == {
"temperature": 44.0, "is_alert": True, "is_critical": False} "temperature": 44.0,
"is_alert": True,
"is_critical": False,
}
# The legacy resources were not read. # The legacy resources were not read.
assert not server.calls("GET", CHASSIS + "/Thermal") assert not server.calls("GET", CHASSIS + "/Thermal")
assert not server.calls("GET", CHASSIS + "/Power") assert not server.calls("GET", CHASSIS + "/Power")
def test_get_environment_legacy(driver, server): def test_get_environment_legacy(driver, server):
server.override("GET", CHASSIS, 200, server.override(
without(CHASSIS, "ThermalSubsystem", "PowerSubsystem", "Sensors")) "GET", CHASSIS, 200, without(CHASSIS, "ThermalSubsystem", "PowerSubsystem", "Sensors")
)
env = driver.get_environment() env = driver.get_environment()
assert_shape(env, napalm_models.EnvironmentDict) assert_shape(env, napalm_models.EnvironmentDict)
assert env["fans"] == {"BaseBoard System Fan": {"status": True}, assert env["fans"] == {
"BaseBoard System Fan Backup": {"status": True}} "BaseBoard System Fan": {"status": True},
"BaseBoard System Fan Backup": {"status": True},
}
assert env["temperature"]["CPU1 Temp"] == { assert env["temperature"]["CPU1 Temp"] == {
"temperature": 41.0, "is_alert": False, "is_critical": False} "temperature": 41.0,
assert env["power"] == {"Power Supply Bay": {"status": False, "capacity": 800.0, "is_alert": False,
"output": 325.0}} "is_critical": False,
}
assert env["power"] == {
"Power Supply Bay": {"status": False, "capacity": 800.0, "output": 325.0}
}
assert not server.calls("GET", CHASSIS + "/Sensors") assert not server.calls("GET", CHASSIS + "/Sensors")
@@ -202,10 +239,17 @@ def test_get_users(driver):
def test_get_ntp_servers(driver, server): def test_get_ntp_servers(driver, server):
assert driver.get_ntp_servers() == {} assert driver.get_ntp_servers() == {}
server.override("GET", MANAGER + "/NetworkProtocol", 200, server.override(
load("dmtf-public-telemetry", "/redfish/v1/Managers/1/NetworkProtocol")) "GET",
MANAGER + "/NetworkProtocol",
200,
load("dmtf-public-telemetry", "/redfish/v1/Managers/1/NetworkProtocol"),
)
assert driver.get_ntp_servers() == { assert driver.get_ntp_servers() == {
"0.pool.ntp.org": {}, "1.pool.ntp.org": {}, "time-a.nist.gov": {}} "0.pool.ntp.org": {},
"1.pool.ntp.org": {},
"time-a.nist.gov": {},
}
def test_get_snmp_information(driver): def test_get_snmp_information(driver):
@@ -234,9 +278,10 @@ def test_get_power_state(driver):
state = driver.get_power_state() state = driver.get_power_state()
assert_shape(state, models.PowerStateDict) assert_shape(state, models.PowerStateDict)
# On: "On" is not offered; Redfish-only values (Nmi, ForceOn, FullPowerCycle) are dropped. # On: "On" is not offered; Redfish-only values (Nmi, ForceOn, FullPowerCycle) are dropped.
assert state == {"state": "On", assert state == {
"actions": ["GracefulShutdown", "ForceOff", "GracefulRestart", "state": "On",
"ForceRestart"]} "actions": ["GracefulShutdown", "ForceOff", "GracefulRestart", "ForceRestart"],
}
def test_get_power_state_off(driver, server): def test_get_power_state_off(driver, server):
@@ -252,8 +297,16 @@ def test_get_power_state_from_action_info(driver, server):
del reset["ResetType@Redfish.AllowableValues"] del reset["ResetType@Redfish.AllowableValues"]
reset["@Redfish.ActionInfo"] = SYSTEM + "/ResetActionInfo" reset["@Redfish.ActionInfo"] = SYSTEM + "/ResetActionInfo"
server.override("GET", SYSTEM, 200, system) server.override("GET", SYSTEM, 200, system)
server.override("GET", SYSTEM + "/ResetActionInfo", 200, {"Parameters": [ server.override(
{"Name": "ResetType", "AllowableValues": ["On", "ForceOff", "PowerCycle"]}]}) "GET",
SYSTEM + "/ResetActionInfo",
200,
{
"Parameters": [
{"Name": "ResetType", "AllowableValues": ["On", "ForceOff", "PowerCycle"]}
]
},
)
assert driver.get_power_state()["actions"] == ["ForceOff", "PowerCycle"] assert driver.get_power_state()["actions"] == ["ForceOff", "PowerCycle"]
@@ -263,7 +316,12 @@ def test_get_power_state_unlisted_values(driver, server):
del system["Actions"]["#ComputerSystem.Reset"]["ResetType@Redfish.AllowableValues"] del system["Actions"]["#ComputerSystem.Reset"]["ResetType@Redfish.AllowableValues"]
server.override("GET", SYSTEM, 200, system) server.override("GET", SYSTEM, 200, system)
assert driver.get_power_state()["actions"] == [ assert driver.get_power_state()["actions"] == [
"GracefulShutdown", "ForceOff", "GracefulRestart", "ForceRestart", "PowerCycle"] "GracefulShutdown",
"ForceOff",
"GracefulRestart",
"ForceRestart",
"PowerCycle",
]
def test_get_power_state_without_reset_action(driver, server): def test_get_power_state_without_reset_action(driver, server):
@@ -351,42 +409,73 @@ def test_get_hardware_inventory(driver):
assert_shape(cpu, models.BmcCpuDict) assert_shape(cpu, models.BmcCpuDict)
# The FPGA is a processor but not a CPU. # The FPGA is a processor but not a CPU.
assert inv["cpus"] == [ assert inv["cpus"] == [
{"socket": "CPU 1", "model": "Multi-Core Intel(R) Xeon(R) processor 7xxx Series", {
"cores": 8, "threads": 16, "status": "OK"}, "socket": "CPU 1",
"model": "Multi-Core Intel(R) Xeon(R) processor 7xxx Series",
"cores": 8,
"threads": 16,
"status": "OK",
},
{"socket": "CPU 2", "model": "", "cores": None, "threads": None, "status": "Absent"}, {"socket": "CPU 2", "model": "", "cores": None, "threads": None, "status": "Absent"},
] ]
for dimm in inv["memory"]: for dimm in inv["memory"]:
assert_shape(dimm, models.BmcDimmDict) assert_shape(dimm, models.BmcDimmDict)
assert [d["slot"] for d in inv["memory"]] == ["DIMM 1", "DIMM 2", "DIMM 3", "DIMM 4"] assert [d["slot"] for d in inv["memory"]] == ["DIMM 1", "DIMM 2", "DIMM 3", "DIMM 4"]
assert inv["memory"][0] == {"slot": "DIMM 1", "size_bytes": 32768 * 1024 * 1024, assert inv["memory"][0] == {
"type": "DDR4", "speed_mts": None, "part_number": "", "slot": "DIMM 1",
"serial_number": "", "status": "OK"} "size_bytes": 32768 * 1024 * 1024,
"type": "DDR4",
"speed_mts": None,
"part_number": "",
"serial_number": "",
"status": "OK",
}
assert inv["memory"][3]["status"] == "Absent" assert inv["memory"][3]["status"] == "Absent"
for drive in inv["drives"]: for drive in inv["drives"]:
assert_shape(drive, models.BmcDriveDict) assert_shape(drive, models.BmcDriveDict)
# This system has SimpleStorage only. # This system has SimpleStorage only.
assert inv["drives"][0] == {"slot": "SATA Bay 1", "model": "3000GT8", "serial_number": "", assert inv["drives"][0] == {
"size_bytes": 8000000000000, "media": "", "protocol": "", "slot": "SATA Bay 1",
"status": "OK"} "model": "3000GT8",
"serial_number": "",
"size_bytes": 8000000000000,
"media": "",
"protocol": "",
"status": "OK",
}
assert [d["status"] for d in inv["drives"]] == ["OK", "Warning", "Absent", "Absent"] assert [d["status"] for d in inv["drives"]] == ["OK", "Warning", "Absent", "Absent"]
for psu in inv["power_supplies"]: for psu in inv["power_supplies"]:
assert_shape(psu, models.BmcPsuDict) assert_shape(psu, models.BmcPsuDict)
assert inv["power_supplies"] == [ assert inv["power_supplies"] == [
{"slot": "Power Supply Bay 1", "model": "RKS-440DC", "serial_number": "3488247", {
"capacity_watts": 400, "status": "Warning"}, "slot": "Power Supply Bay 1",
{"slot": "Power Supply Bay 2", "model": "", "serial_number": "", "model": "RKS-440DC",
"capacity_watts": None, "status": "Absent"}, "serial_number": "3488247",
"capacity_watts": 400,
"status": "Warning",
},
{
"slot": "Power Supply Bay 2",
"model": "",
"serial_number": "",
"capacity_watts": None,
"status": "Absent",
},
] ]
for nic in inv["nics"]: for nic in inv["nics"]:
assert_shape(nic, models.BmcNicDict) assert_shape(nic, models.BmcNicDict)
nics = {n["name"]: n for n in inv["nics"]} nics = {n["name"]: n for n in inv["nics"]}
assert set(nics) == {"12446A3B0411", "12446A3B8890", "ToManager", "VLAN1"} assert set(nics) == {"12446A3B0411", "12446A3B8890", "ToManager", "VLAN1"}
assert nics["12446A3B0411"] == {"name": "12446A3B0411", "mac_address": "12:44:6a:3b:04:11", assert nics["12446A3B0411"] == {
"speed_mbps": 1000, "link_up": True} "name": "12446A3B0411",
"mac_address": "12:44:6a:3b:04:11",
"speed_mbps": 1000,
"link_up": True,
}
assert nics["ToManager"]["link_up"] is None assert nics["ToManager"]["link_up"] is None
@@ -396,25 +485,38 @@ def test_get_hardware_inventory_storage_drives(driver, server):
del system["SimpleStorage"] del system["SimpleStorage"]
system["Storage"] = {"@odata.id": SYSTEM + "/Storage"} system["Storage"] = {"@odata.id": SYSTEM + "/Storage"}
server.override("GET", SYSTEM, 200, system) server.override("GET", SYSTEM, 200, system)
server.override("GET", SYSTEM + "/Storage", 200, server.override(
load("dmtf-public-localstorage", SYSTEM + "/Storage")) "GET", SYSTEM + "/Storage", 200, load("dmtf-public-localstorage", SYSTEM + "/Storage")
server.override("GET", SYSTEM + "/Storage/1", 200, )
load("dmtf-public-localstorage", SYSTEM + "/Storage/1")) server.override(
"GET", SYSTEM + "/Storage/1", 200, load("dmtf-public-localstorage", SYSTEM + "/Storage/1")
)
for drive in ("35D38F11ACEF7BD3", "3F5A8C54207B7233", "32ADF365C6C1B7BD", "3D58ECBC375FD9F2"): for drive in ("35D38F11ACEF7BD3", "3F5A8C54207B7233", "32ADF365C6C1B7BD", "3D58ECBC375FD9F2"):
path = f"{CHASSIS}/Drives/{drive}" path = f"{CHASSIS}/Drives/{drive}"
server.override("GET", path, 200, load("dmtf-public-localstorage", path)) server.override("GET", path, 200, load("dmtf-public-localstorage", path))
drives = driver.get_hardware_inventory()["drives"] drives = driver.get_hardware_inventory()["drives"]
assert len(drives) == 4 assert len(drives) == 4
assert drives[0] == {"slot": "Drive Sample", "model": "C123", "serial_number": "1234567", assert drives[0] == {
"size_bytes": 899527000064, "media": "HDD", "protocol": "SAS", "slot": "Drive Sample",
"status": "OK"} "model": "C123",
"serial_number": "1234567",
"size_bytes": 899527000064,
"media": "HDD",
"protocol": "SAS",
"status": "OK",
}
def test_get_hardware_inventory_legacy_power(driver, server): def test_get_hardware_inventory_legacy_power(driver, server):
server.override("GET", CHASSIS, 200, without(CHASSIS, "PowerSubsystem")) server.override("GET", CHASSIS, 200, without(CHASSIS, "PowerSubsystem"))
assert driver.get_hardware_inventory()["power_supplies"] == [ assert driver.get_hardware_inventory()["power_supplies"] == [
{"slot": "Power Supply Bay", "model": "499253-B21", "serial_number": "1Z0000001", {
"capacity_watts": 800, "status": "Warning"}, "slot": "Power Supply Bay",
"model": "499253-B21",
"serial_number": "1Z0000001",
"capacity_watts": 800,
"status": "Warning",
},
] ]
@@ -423,14 +525,30 @@ def test_get_event_log(driver):
for entry in log: for entry in log:
assert_shape(entry, models.EventLogEntryDict) assert_shape(entry, models.EventLogEntryDict)
assert log == [ assert log == [
{"id": SYSTEM_LOG + "/3", "timestamp": ts("2012-03-07T14:46:00Z"), {
"severity": "Warning", "message": "Fan redundancy lost"}, "id": SYSTEM_LOG + "/3",
{"id": SYSTEM_LOG + "/2", "timestamp": ts("2012-03-07T14:45:00Z"), "timestamp": ts("2012-03-07T14:46:00Z"),
"severity": "Critical", "message": "Temperature threshold exceeded"}, "severity": "Warning",
{"id": SYSTEM_LOG + "/1", "timestamp": ts("2012-03-07T14:44:00Z"), "message": "Fan redundancy lost",
"severity": "Critical", "message": "Temperature threshold exceeded"}, },
{"id": MANAGER + "/LogServices/Log/Entries/1", "timestamp": ts("2012-03-07T14:44:00Z"), {
"severity": "Critical", "message": "System May be Melting"}, "id": SYSTEM_LOG + "/2",
"timestamp": ts("2012-03-07T14:45:00Z"),
"severity": "Critical",
"message": "Temperature threshold exceeded",
},
{
"id": SYSTEM_LOG + "/1",
"timestamp": ts("2012-03-07T14:44:00Z"),
"severity": "Critical",
"message": "Temperature threshold exceeded",
},
{
"id": MANAGER + "/LogServices/Log/Entries/1",
"timestamp": ts("2012-03-07T14:44:00Z"),
"severity": "Critical",
"message": "System May be Melting",
},
] ]
@@ -441,11 +559,18 @@ def test_get_event_log_limit(driver):
def test_get_event_log_timestamps(driver, server): def test_get_event_log_timestamps(driver, server):
entries = SYSTEM_LOG + "?$skiptoken=2" entries = SYSTEM_LOG + "?$skiptoken=2"
server.override("GET", entries, 200, {"Members": [ server.override(
{"Id": "3", "Created": "2026-10-13T08:00:00+02:00", "Message": "offset"}, "GET",
{"Id": "4", "Message": "no time", "Severity": "Informational"}, entries,
{"Id": "5", "Created": "garbage", "Message": "bad time"}, 200,
]}) {
"Members": [
{"Id": "3", "Created": "2026-10-13T08:00:00+02:00", "Message": "offset"},
{"Id": "4", "Message": "no time", "Severity": "Informational"},
{"Id": "5", "Created": "garbage", "Message": "bad time"},
]
},
)
log = {e["message"]: e for e in driver.get_event_log()} log = {e["message"]: e for e in driver.get_event_log()}
assert log["offset"]["timestamp"] == ts("2026-10-13T06:00:00Z") assert log["offset"]["timestamp"] == ts("2026-10-13T06:00:00Z")
assert log["no time"]["timestamp"] is None assert log["no time"]["timestamp"] is None
@@ -482,8 +607,12 @@ def test_get_management_protocols(driver):
def test_get_management_protocols_missing(driver, server): def test_get_management_protocols_missing(driver, server):
server.override("GET", MANAGER + "/NetworkProtocol", 200, server.override(
{"HTTPS": {"ProtocolEnabled": True, "Port": 443}, "IPMI": {"Port": 623}}) "GET",
MANAGER + "/NetworkProtocol",
200,
{"HTTPS": {"ProtocolEnabled": True, "Port": 443}, "IPMI": {"Port": 623}},
)
protocols = driver.get_management_protocols() protocols = driver.get_management_protocols()
assert protocols["https"] == {"enabled": True, "port": 443} assert protocols["https"] == {"enabled": True, "port": 443}
assert protocols["ipmi"] == {"enabled": False, "port": 623} assert protocols["ipmi"] == {"enabled": False, "port": 623}
@@ -493,8 +622,9 @@ def test_get_management_protocols_missing(driver, server):
def _unlicensed(server): def _unlicensed(server):
# A Supermicro X10 refuses Redfish entirely until a license is activated. # A Supermicro X10 refuses Redfish entirely until a license is activated.
x10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91" x10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91"
server.override("GET", "/redfish/v1/", 403, server.override(
json.loads((x10 / "service_root_403.json").read_text())) "GET", "/redfish/v1/", 403, json.loads((x10 / "service_root_403.json").read_text())
)
d = RedfishDriver("bmc.example.net", "netork", "s3cret", optional_args={"verify_ssl": False}) d = RedfishDriver("bmc.example.net", "netork", "s3cret", optional_args={"verify_ssl": False})
d.open() d.open()
return d return d
@@ -506,15 +636,24 @@ def test_unlicensed_bmc_still_opens(server):
d = _unlicensed(server) d = _unlicensed(server)
assert d.is_alive() == {"is_alive": True} assert d.is_alive() == {"is_alive": True}
assert d.get_facts() == { assert d.get_facts() == {
"uptime": -1.0, "vendor": "", "model": "", "hostname": "bmc.example.net", "uptime": -1.0,
"fqdn": "bmc.example.net", "os_version": "", "serial_number": "", "interface_list": [], "vendor": "",
"model": "",
"hostname": "bmc.example.net",
"fqdn": "bmc.example.net",
"os_version": "",
"serial_number": "",
"interface_list": [],
} }
def test_unlicensed_bmc_names_every_license_it_asks_for_as_missing(server): def test_unlicensed_bmc_names_every_license_it_asks_for_as_missing(server):
licenses = _unlicensed(server).get_licenses() licenses = _unlicensed(server).get_licenses()
assert [(lic["name"], lic["status"]) for lic in licenses] == [ assert [(lic["name"], lic["status"]) for lic in licenses] == [
("SUM", "missing"), ("DCMS", "missing"), ("OOB", "missing")] ("SUM", "missing"),
("DCMS", "missing"),
("OOB", "missing"),
]
# Refused altogether: nothing is withheld in part. # Refused altogether: nothing is withheld in part.
assert all("withholds" not in lic and "Not licensed" in lic["detail"] for lic in licenses) assert all("withholds" not in lic and "Not licensed" in lic["detail"] for lic in licenses)
+90 -34
View File
@@ -105,8 +105,12 @@ def test_open_logs_in_on_the_ipmi_port_and_close_logs_out():
d = IpmiDriver("192.0.2.150", "netork", "s3cret", optional_args={"port": 6230}) d = IpmiDriver("192.0.2.150", "netork", "s3cret", optional_args={"port": 6230})
d.open() d.open()
(cmd,) = FakeCommand.instances (cmd,) = FakeCommand.instances
assert cmd.args == {"bmc": "192.0.2.150", "userid": "netork", "password": "s3cret", assert cmd.args == {
"port": 6230} "bmc": "192.0.2.150",
"userid": "netork",
"password": "s3cret",
"port": 6230,
}
assert d.is_alive() == {"is_alive": True} assert d.is_alive() == {"is_alive": True}
d.close() d.close()
assert cmd.logged_out assert cmd.logged_out
@@ -167,8 +171,11 @@ def test_get_environment(driver):
assert set(env) == {"fans", "temperature", "power", "cpu", "memory"} assert set(env) == {"fans", "temperature", "power", "cpu", "memory"}
assert env["cpu"] == {} and env["memory"] == {} assert env["cpu"] == {} and env["memory"] == {}
assert len(env["temperature"]) == 19 assert len(env["temperature"]) == 19
assert env["temperature"]["CPU1 Temp"] == {"temperature": 34.0, "is_alert": False, assert env["temperature"]["CPU1 Temp"] == {
"is_critical": False} "temperature": 34.0,
"is_alert": False,
"is_critical": False,
}
assert env["fans"] == {name: {"status": True} for name in ("FAN2", "FAN3", "FAN4", "FANB")} assert env["fans"] == {name: {"status": True} for name in ("FAN2", "FAN3", "FAN4", "FANB")}
# Only supplies that report themselves present. # Only supplies that report themselves present.
assert env["power"] == {"PS1 Status": {"status": True, "capacity": -1.0, "output": -1.0}} assert env["power"] == {"PS1 Status": {"status": True, "capacity": -1.0, "output": -1.0}}
@@ -176,17 +183,46 @@ def test_get_environment(driver):
def test_sensor_health_flags(driver, monkeypatch): def test_sensor_health_flags(driver, monkeypatch):
readings = [ readings = [
{"name": "Hot", "type": "Temperature", "value": 91.0, "units": "°C", "health": 1, {
"states": [], "unavailable": 0}, "name": "Hot",
{"name": "Melting", "type": "Temperature", "value": 105.0, "units": "°C", "health": 2, "type": "Temperature",
"states": [], "unavailable": 0}, "value": 91.0,
{"name": "Gone", "type": "Temperature", "value": None, "units": "°C", "health": 0, "units": "°C",
"states": [], "unavailable": 1}, "health": 1,
{"name": "FAN1", "type": "Fan", "value": 0.0, "units": "RPM", "health": 2, "states": [],
"states": [], "unavailable": 0}, "unavailable": 0,
},
{
"name": "Melting",
"type": "Temperature",
"value": 105.0,
"units": "°C",
"health": 2,
"states": [],
"unavailable": 0,
},
{
"name": "Gone",
"type": "Temperature",
"value": None,
"units": "°C",
"health": 0,
"states": [],
"unavailable": 1,
},
{
"name": "FAN1",
"type": "Fan",
"value": 0.0,
"units": "RPM",
"health": 2,
"states": [],
"unavailable": 0,
},
] ]
monkeypatch.setattr(FakeCommand, "get_sensor_data", monkeypatch.setattr(
lambda self: iter(SimpleNamespace(**r) for r in readings)) FakeCommand, "get_sensor_data", lambda self: iter(SimpleNamespace(**r) for r in readings)
)
env = driver.get_environment() env = driver.get_environment()
assert env["temperature"] == { assert env["temperature"] == {
"Hot": {"temperature": 91.0, "is_alert": True, "is_critical": False}, "Hot": {"temperature": 91.0, "is_alert": True, "is_critical": False},
@@ -201,8 +237,7 @@ def test_sensor_health_flags(driver, monkeypatch):
def test_get_power_state(driver): def test_get_power_state(driver):
state = driver.get_power_state() state = driver.get_power_state()
assert_shape(state, models.PowerStateDict) assert_shape(state, models.PowerStateDict)
assert state == {"state": "On", assert state == {"state": "On", "actions": ["GracefulShutdown", "ForceOff", "ForceRestart"]}
"actions": ["GracefulShutdown", "ForceOff", "ForceRestart"]}
def test_a_server_that_is_off_can_only_be_switched_on(driver): def test_a_server_that_is_off_can_only_be_switched_on(driver):
@@ -216,12 +251,15 @@ def test_every_offered_action_is_a_role_power_action(driver):
assert set(driver.get_power_state()["actions"]) <= set(models.POWER_ACTIONS) assert set(driver.get_power_state()["actions"]) <= set(models.POWER_ACTIONS)
@pytest.mark.parametrize("action, powerstate, expected", [ @pytest.mark.parametrize(
("GracefulShutdown", "on", "shutdown"), "action, powerstate, expected",
("ForceOff", "on", "off"), [
("ForceRestart", "on", "reset"), ("GracefulShutdown", "on", "shutdown"),
("On", "off", "on"), ("ForceOff", "on", "off"),
]) ("ForceRestart", "on", "reset"),
("On", "off", "on"),
],
)
def test_set_power_state_sends_the_chassis_control(driver, action, powerstate, expected): def test_set_power_state_sends_the_chassis_control(driver, action, powerstate, expected):
cmd = FakeCommand.instances[0] cmd = FakeCommand.instances[0]
cmd.powerstate = powerstate cmd.powerstate = powerstate
@@ -236,12 +274,15 @@ def test_set_power_state_rejects_an_unknown_action(driver):
assert FakeCommand.instances[0].power_requests == [] assert FakeCommand.instances[0].power_requests == []
@pytest.mark.parametrize("action, powerstate", [ @pytest.mark.parametrize(
("On", "on"), "action, powerstate",
("ForceOff", "off"), [
("GracefulRestart", "on"), # IPMI has no graceful restart ("On", "on"),
("PowerCycle", "on"), ("ForceOff", "off"),
]) ("GracefulRestart", "on"), # IPMI has no graceful restart
("PowerCycle", "on"),
],
)
def test_set_power_state_refuses_what_is_not_offered_now(driver, action, powerstate): def test_set_power_state_refuses_what_is_not_offered_now(driver, action, powerstate):
cmd = FakeCommand.instances[0] cmd = FakeCommand.instances[0]
cmd.powerstate = powerstate cmd.powerstate = powerstate
@@ -268,8 +309,13 @@ def test_get_hardware_inventory(driver):
assert inv["system_uuid"] == "00000000-534d-c40c-0000-000000000001" assert inv["system_uuid"] == "00000000-534d-c40c-0000-000000000001"
assert inv["power_supplies"] == [ assert inv["power_supplies"] == [
{"slot": "PS1", "model": "", "serial_number": "", "capacity_watts": None, "status": "OK"}, {"slot": "PS1", "model": "", "serial_number": "", "capacity_watts": None, "status": "OK"},
{"slot": "PS2", "model": "", "serial_number": "", "capacity_watts": None, {
"status": "Absent"}, "slot": "PS2",
"model": "",
"serial_number": "",
"capacity_watts": None,
"status": "Absent",
},
] ]
assert inv["cpus"] == [] and inv["memory"] == [] and inv["drives"] == [] and inv["nics"] == [] assert inv["cpus"] == [] and inv["memory"] == [] and inv["drives"] == [] and inv["nics"] == []
@@ -277,14 +323,24 @@ def test_get_hardware_inventory(driver):
def test_get_event_log(driver): def test_get_event_log(driver):
(entry,) = driver.get_event_log() (entry,) = driver.get_event_log()
assert_shape(entry, models.EventLogEntryDict) assert_shape(entry, models.EventLogEntryDict)
assert entry == {"id": "1", "timestamp": 1790788051.0, "severity": "OK", assert entry == {
"message": "PS2 Status: Recovered"} "id": "1",
"timestamp": 1790788051.0,
"severity": "OK",
"message": "PS2 Status: Recovered",
}
def test_event_log_newest_first_and_limited(driver, monkeypatch): def test_event_log_newest_first_and_limited(driver, monkeypatch):
sel = [ sel = [
{"record_id": i, "timecode": 1000 + i, "component": "FAN1", "event": "Lower critical", {
"severity": 2, "deassertion": False} "record_id": i,
"timecode": 1000 + i,
"component": "FAN1",
"event": "Lower critical",
"severity": 2,
"deassertion": False,
}
for i in range(1, 6) for i in range(1, 6)
] ]
monkeypatch.setattr(FakeCommand, "get_event_log", lambda self: iter(sel)) monkeypatch.setattr(FakeCommand, "get_event_log", lambda self: iter(sel))
+37 -13
View File
@@ -189,8 +189,13 @@ def test_login_url_from_service_root(server):
custom = dict(_root(), Links={"Sessions": {"@odata.id": "/redfish/v1/Custom/Sessions"}}) custom = dict(_root(), Links={"Sessions": {"@odata.id": "/redfish/v1/Custom/Sessions"}})
server.override("GET", "/redfish/v1/", 200, custom) server.override("GET", "/redfish/v1/", 200, custom)
server.override("GET", "/redfish/v1", 200, custom) server.override("GET", "/redfish/v1", 200, custom)
server.override("POST", "/redfish/v1/Custom/Sessions", 201, {}, server.override(
{"X-Auth-Token": TOKEN, "Location": SESSION_PATH}) "POST",
"/redfish/v1/Custom/Sessions",
201,
{},
{"X-Auth-Token": TOKEN, "Location": SESSION_PATH},
)
t = make() t = make()
t.open() t.open()
assert server.calls("POST", "/redfish/v1/Custom/Sessions") assert server.calls("POST", "/redfish/v1/Custom/Sessions")
@@ -314,9 +319,18 @@ def test_get_optional_returns_none_on_404(transport):
def test_get_error_carries_redfish_message(transport, server): def test_get_error_carries_redfish_message(transport, server):
server.override("GET", "/redfish/v1/Managers", 500, { server.override(
"error": {"code": "Base.1.0.InternalError", "message": "General error", "GET",
"@Message.ExtendedInfo": [{"Message": "The BMC is busy."}]}}) "/redfish/v1/Managers",
500,
{
"error": {
"code": "Base.1.0.InternalError",
"message": "General error",
"@Message.ExtendedInfo": [{"Message": "The BMC is busy."}],
}
},
)
with pytest.raises(RedfishHTTPError) as exc: with pytest.raises(RedfishHTTPError) as exc:
transport.get("/redfish/v1/Managers") transport.get("/redfish/v1/Managers")
assert exc.value.status == 500 assert exc.value.status == 500
@@ -366,20 +380,30 @@ def test_members_uses_expanded_members_without_extra_requests(transport, server)
def test_members_follows_next_link(transport, server): def test_members_follows_next_link(transport, server):
path = "/redfish/v1/Systems/437XR1138R2/LogServices/Log1/Entries" path = "/redfish/v1/Systems/437XR1138R2/LogServices/Log1/Entries"
server.override("GET", path + "?$skiptoken=2", 200, { server.override(
"Members": [{"@odata.id": path + "/3", "Id": "3", "Message": "third"}], "GET",
"@odata.id": path, path + "?$skiptoken=2",
}) 200,
{
"Members": [{"@odata.id": path + "/3", "Id": "3", "Message": "third"}],
"@odata.id": path,
},
)
entries = transport.members(path) entries = transport.members(path)
assert [e["Id"] for e in entries] == ["1", "2", "3"] assert [e["Id"] for e in entries] == ["1", "2", "3"]
def test_members_next_link_loop_terminates(transport, server): def test_members_next_link_loop_terminates(transport, server):
path = "/redfish/v1/Managers" path = "/redfish/v1/Managers"
server.override("GET", path, 200, { server.override(
"Members": [{"@odata.id": "/redfish/v1/Managers/BMC"}], "GET",
"Members@odata.nextLink": path, path,
}) 200,
{
"Members": [{"@odata.id": "/redfish/v1/Managers/BMC"}],
"Members@odata.nextLink": path,
},
)
assert [m["Id"] for m in transport.members(path)] == ["BMC"] assert [m["Id"] for m in transport.members(path)] == ["BMC"]
+19 -13
View File
@@ -23,16 +23,18 @@ REFUSED = json.loads((FIXTURES / MOCKUP / "STATUS.json").read_text())
def x10(http): def x10(http):
server = MockupServer(http, MOCKUP) server = MockupServer(http, MOCKUP)
for path, status in REFUSED.items(): for path, status in REFUSED.items():
body = json.loads((FIXTURES / MOCKUP / path[len("/redfish/v1/"):] / "index.json") body = json.loads(
.read_text()) (FIXTURES / MOCKUP / path[len("/redfish/v1/") :] / "index.json").read_text()
)
server.override("GET", path, status, body) server.override("GET", path, status, body)
return server return server
@pytest.fixture @pytest.fixture
def driver(x10): def driver(x10):
d = RedfishDriver(BASE_URL.removeprefix("https://"), "netork", "secret", d = RedfishDriver(
optional_args={"verify_ssl": False}) BASE_URL.removeprefix("https://"), "netork", "secret", optional_args={"verify_ssl": False}
)
d.open() d.open()
yield d yield d
d.close() d.close()
@@ -65,10 +67,12 @@ def test_the_firmware_comes_from_the_manager_and_the_system_without_updateservic
def test_the_missing_license_is_named_with_what_it_withholds(driver): def test_the_missing_license_is_named_with_what_it_withholds(driver):
assert driver.get_device_warnings() == [{ assert driver.get_device_warnings() == [
"code": "bmc_license_partial", {
"meta": {"licenses": "DCMS", "parts": ["drives", "firmware inventory"]}, "code": "bmc_license_partial",
}] "meta": {"licenses": "DCMS", "parts": ["drives", "firmware inventory"]},
}
]
def test_the_power_actions_are_the_ones_the_bmc_advertises(driver): def test_the_power_actions_are_the_ones_the_bmc_advertises(driver):
@@ -95,8 +99,10 @@ def test_the_sensors_and_the_event_log_read(driver):
def test_the_missing_license_is_a_fact_with_what_it_withholds(driver): def test_the_missing_license_is_a_fact_with_what_it_withholds(driver):
assert driver.get_licenses() == [{ assert driver.get_licenses() == [
"name": "DCMS", {
"status": "missing", "name": "DCMS",
"withholds": ["drives", "firmware inventory"], "status": "missing",
}] "withholds": ["drives", "firmware inventory"],
}
]