Add parsers for the Redfish resources in redfish-map.md

Pure functions for ServiceRoot, ComputerSystem (facts, power state, reset action and ActionInfo), Processors, Memory, Storage/Drives/SimpleStorage, Chassis Thermal and Power as well as ThermalSubsystem/PowerSubsystem/Sensors (with environment_sources to pick whichever exists), Manager, EthernetInterfaces, ManagerNetworkProtocol, accounts, firmware inventory and log services/entries.
This commit is contained in:
Christian Manivong committed 2026-10-12 23:42:14 +00:00
1 parent 8998e1342e
commit cab1c84cba
18 files changed
+1926

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
"""Pure parsers: one Redfish resource (a dict) in, plain Python values out.
No I/O here. The transport fetches, these functions read; tests run them on recorded
responses under ``tests/fixtures/``.
"""
from .accounts import parse_account, to_napalm_users
from .environment import (
build_environment,
environment_sources,
parse_fan,
parse_power,
parse_power_subsystem,
parse_power_supply,
parse_sensor,
parse_thermal,
parse_thermal_metrics,
parse_thermal_subsystem,
)
from .firmware import parse_firmware
from .inventory import (
parse_drive,
parse_memory,
parse_processor,
parse_simple_storage,
parse_storage,
)
from .logs import parse_log_entry, parse_log_service
from .manager import parse_ethernet_interface, parse_manager, parse_network_protocol
from .service_root import parse_service_root
from .system import parse_action_info, parse_power_state, parse_reset_action, parse_system
__all__ = [
"build_environment",
"environment_sources",
"parse_account",
"parse_action_info",
"parse_drive",
"parse_ethernet_interface",
"parse_fan",
"parse_firmware",
"parse_log_entry",
"parse_log_service",
"parse_manager",
"parse_memory",
"parse_network_protocol",
"parse_power",
"parse_power_state",
"parse_power_subsystem",
"parse_power_supply",
"parse_processor",
"parse_reset_action",
"parse_sensor",
"parse_service_root",
"parse_simple_storage",
"parse_storage",
"parse_system",
"parse_thermal",
"parse_thermal_metrics",
"parse_thermal_subsystem",
"to_napalm_users",
]
+40
View File
@@ -0,0 +1,40 @@
"""AccountService accounts (``AccountService/Accounts/*``)."""
from __future__ import annotations
from typing import Any, Iterable, Mapping
from .common import boolean, text
# Privilege level for NAPALM's get_users, from the Redfish standard roles.
ROLE_LEVELS = {"Administrator": 15, "Operator": 5, "ReadOnly": 1}
def parse_account(account: Mapping[str, Any]) -> dict[str, Any] | None:
"""One account; ``None`` for an unused slot (no UserName), as iDRAC and Supermicro list."""
username = text(account.get("UserName"))
if username is None:
return None
types = account.get("AccountTypes")
return {
"id": text(account.get("Id")),
"username": username,
"role_id": text(account.get("RoleId")),
"enabled": boolean(account.get("Enabled")),
"locked": boolean(account.get("Locked")),
"account_types": [str(t) for t in types] if isinstance(types, list) else [],
}
def to_napalm_users(accounts: Iterable[Mapping[str, Any] | None]) -> dict[str, dict[str, Any]]:
"""NAPALM ``get_users`` from parsed accounts. Unknown (OEM) roles get level 0."""
users = {}
for acc in accounts:
if not acc:
continue
users[acc["username"]] = {
"level": ROLE_LEVELS.get(acc.get("role_id") or "", 0),
"password": "",
"sshkeys": [],
}
return users
+134
View File
@@ -0,0 +1,134 @@
"""Helpers shared by the parsers. All pure: dict in, plain values out."""
from __future__ import annotations
from typing import Any, Mapping
# DMI defaults that board vendors leave in place (Supermicro boards ship most of them).
PLACEHOLDERS = frozenset({
"to be filled by o.e.m.",
"default string",
"not specified",
"not applicable",
"n/a",
"none",
"0123456789",
"system serial number",
"system product name",
"system manufacturer",
})
def text(value: Any) -> str | None:
"""A stripped string, ``None`` for missing or blank values."""
if value is None:
return None
value = str(value).strip()
return value or None
def ident(value: Any) -> str | None:
"""Like ``text``, but DMI placeholder strings count as missing too."""
value = text(value)
if value is None or value.lower() in PLACEHOLDERS:
return None
return value
def number(value: Any) -> float | None:
"""A float, ``None`` if the value is missing or not a number."""
if isinstance(value, bool) or value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def integer(value: Any) -> int | None:
if isinstance(value, bool) or value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def boolean(value: Any) -> bool | None:
return value if isinstance(value, bool) else None
def odata_id(ref: Any) -> str | None:
"""The ``@odata.id`` of a reference object, or ``None``."""
if isinstance(ref, Mapping):
return text(ref.get("@odata.id"))
return None
def link(resource: Mapping[str, Any], *keys: str) -> str | None:
"""Follow ``keys`` into nested objects and return the ``@odata.id`` found there."""
value: Any = resource
for key in keys:
if not isinstance(value, Mapping):
return None
value = value.get(key)
return odata_id(value)
def links(resource: Mapping[str, Any]) -> dict[str, str]:
"""All top-level properties of ``resource`` that are single references."""
found = {}
for key, value in resource.items():
if key.startswith("@") or key == "Links":
continue
path = odata_id(value)
if path is not None:
found[key] = path
return found
def link_list(resource: Mapping[str, Any], *keys: str) -> list[str]:
"""The ``@odata.id`` of each reference in the array at ``keys``."""
value: Any = resource
for key in keys:
if not isinstance(value, Mapping):
return []
value = value.get(key)
if not isinstance(value, list):
return []
return [path for path in (odata_id(ref) for ref in value) if path is not None]
def status(resource: Mapping[str, Any]) -> tuple[str | None, str | None]:
"""``(Status.State, Status.Health)``."""
st = resource.get("Status")
if not isinstance(st, Mapping):
return None, None
return text(st.get("State")), text(st.get("Health"))
def present(resource: Mapping[str, Any]) -> bool:
return status(resource)[0] != "Absent"
def location_label(resource: Mapping[str, Any]) -> str | None:
"""``Location.PartLocation.ServiceLabel``, the printed label of a slot or bay."""
loc = resource.get("Location")
if not isinstance(loc, Mapping):
return None
part = loc.get("PartLocation")
if not isinstance(part, Mapping):
return None
return text(part.get("ServiceLabel"))
def unique_name(names: Mapping[str, Any], name: str, suffix: Any) -> str:
"""``name``, or ``name (suffix)`` if it is already taken."""
if name not in names:
return name
candidate = f"{name} ({suffix})"
n = 2
while candidate in names:
candidate = f"{name} ({suffix}-{n})"
n += 1
return candidate
+307
View File
@@ -0,0 +1,307 @@
"""Chassis environment: legacy Thermal/Power, or ThermalSubsystem/PowerSubsystem/Sensors.
Services from Redfish 2020.4 on link ``ThermalSubsystem``, ``PowerSubsystem`` and
``Sensors`` from the Chassis and deprecate ``Thermal`` and ``Power``; older ones (such as
Supermicro X10) have only the latter. ``environment_sources`` picks per part, and both
paths end in the same shape, the parts of NAPALM's ``get_environment``:
- ``fans``: ``{name: {"status": bool}}``
- ``temperature``: ``{name: {"temperature": float, "is_alert": bool, "is_critical": bool}}``
- ``power``: ``{name: {"status": bool, "capacity": float, "output": float}}`` (-1.0 unknown)
plus ``voltages`` (``{name: {"voltage", "is_alert", "is_critical"}}``) and
``consumed_watts`` for the whole chassis. Absent parts and sensors without a reading are
left out.
"""
from __future__ import annotations
from typing import Any, Iterable, Mapping
from .common import link, number, present, status, text, unique_name
_SENSOR_TYPES_BY_UNITS = {"Cel": "Temperature", "V": "Voltage", "W": "Power", "A": "Current",
"RPM": "Rotational", "%": "Percent", "Hz": "Frequency"}
def environment_sources(chassis: Mapping[str, Any]) -> dict[str, Any]:
"""Which resources to read for a Chassis, preferring the newer ones per part.
Returns ``{"thermal": {"schema", "path"} | None, "power": ... | None,
"sensors": path | None}``.
"""
def pick(new: str, old: str) -> dict[str, str] | None:
for schema in (new, old):
path = link(chassis, schema)
if path is not None:
return {"schema": schema, "path": path}
return None
return {
"thermal": pick("ThermalSubsystem", "Thermal"),
"power": pick("PowerSubsystem", "Power"),
"sensors": link(chassis, "Sensors"),
}
def _ok(resource: Mapping[str, Any]) -> bool:
"""Working: enabled (or no state given) and healthy (or no health given)."""
state, health = status(resource)
return state in (None, "Enabled") and health in (None, "OK")
def _health_flags(resource: Mapping[str, Any]) -> tuple[bool, bool]:
_, health = status(resource)
return health in ("Warning", "Critical"), health == "Critical"
def _legacy_flags(item: Mapping[str, Any], reading: float) -> tuple[bool, bool]:
"""Alert/critical from Thermal/Power v1 threshold properties and health."""
alert, critical = _health_flags(item)
def beyond(suffix: str) -> bool:
upper = number(item.get(f"UpperThreshold{suffix}"))
lower = number(item.get(f"LowerThreshold{suffix}"))
return (upper is not None and reading >= upper) or (lower is not None and reading <= lower)
if beyond("Critical") or beyond("Fatal"):
return True, True
if beyond("NonCritical"):
alert = True
return alert, critical
def _sensor_flags(sensor: Mapping[str, Any], reading: float) -> tuple[bool, bool]:
"""Alert/critical from Sensor ``Thresholds`` and health."""
alert, critical = _health_flags(sensor)
thresholds = sensor.get("Thresholds") if isinstance(sensor.get("Thresholds"), Mapping) else {}
def beyond(level: str) -> bool:
def limit(name: str) -> float | None:
t = thresholds.get(name)
return number(t.get("Reading")) if isinstance(t, Mapping) else None
upper, lower = limit(f"Upper{level}"), limit(f"Lower{level}")
return (upper is not None and reading >= upper) or (lower is not None and reading <= lower)
if beyond("Critical") or beyond("Fatal"):
return True, True
if beyond("Caution"):
alert = True
return alert, critical
def _name(item: Mapping[str, Any], *keys: str) -> str:
for key in keys:
name = text(item.get(key))
if name:
return name
return text(item.get("MemberId")) or text(item.get("Id")) or "unknown"
def _members(resource: Mapping[str, Any], key: str) -> Iterable[tuple[int, Mapping[str, Any]]]:
items = resource.get(key)
for index, item in enumerate(items if isinstance(items, list) else []):
if isinstance(item, Mapping):
yield index, item
# --- legacy -------------------------------------------------------------------------------
def parse_thermal(thermal: Mapping[str, Any]) -> dict[str, Any]:
"""``Chassis/{id}/Thermal``: ``fans`` and ``temperature``."""
temperature: dict[str, Any] = {}
for index, item in _members(thermal, "Temperatures"):
reading = number(item.get("ReadingCelsius"))
if reading is None or not present(item):
continue
alert, critical = _legacy_flags(item, reading)
name = unique_name(temperature, _name(item, "Name"), item.get("MemberId", index))
temperature[name] = {"temperature": reading, "is_alert": alert, "is_critical": critical}
fans: dict[str, Any] = {}
for index, item in _members(thermal, "Fans"):
if not present(item):
continue
name = unique_name(fans, _name(item, "Name", "FanName"), item.get("MemberId", index))
fans[name] = {"status": _ok(item)}
return {"fans": fans, "temperature": temperature}
def parse_power(power: Mapping[str, Any]) -> dict[str, Any]:
"""``Chassis/{id}/Power``: ``power`` (supplies), ``voltages``, ``consumed_watts``."""
supplies: dict[str, Any] = {}
for index, item in _members(power, "PowerSupplies"):
if not present(item):
continue
name = unique_name(supplies, _name(item, "Name"), item.get("MemberId", index))
capacity = number(item.get("PowerCapacityWatts"))
output = number(item.get("LastPowerOutputWatts"))
supplies[name] = {
"status": _ok(item),
"capacity": capacity if capacity is not None else -1.0,
"output": output if output is not None else -1.0,
}
voltages: dict[str, Any] = {}
for index, item in _members(power, "Voltages"):
reading = number(item.get("ReadingVolts"))
if reading is None or not present(item):
continue
alert, critical = _legacy_flags(item, reading)
name = unique_name(voltages, _name(item, "Name"), item.get("MemberId", index))
voltages[name] = {"voltage": reading, "is_alert": alert, "is_critical": critical}
consumed = None
for _, item in _members(power, "PowerControl"):
watts = number(item.get("PowerConsumedWatts"))
if watts is not None:
consumed = (consumed or 0.0) + watts
return {"power": supplies, "voltages": voltages, "consumed_watts": consumed}
# --- ThermalSubsystem, PowerSubsystem, Sensors --------------------------------------------
def parse_thermal_subsystem(subsystem: Mapping[str, Any]) -> dict[str, Any]:
"""``Chassis/{id}/ThermalSubsystem``: where the fans and metrics are."""
state, health = status(subsystem)
return {
"fans_path": link(subsystem, "Fans"),
"thermal_metrics_path": link(subsystem, "ThermalMetrics"),
"state": state,
"health": health,
}
def parse_power_subsystem(subsystem: Mapping[str, Any]) -> dict[str, Any]:
"""``Chassis/{id}/PowerSubsystem``: where the supplies are, total capacity."""
state, health = status(subsystem)
return {
"power_supplies_path": link(subsystem, "PowerSupplies"),
"capacity_watts": number(subsystem.get("CapacityWatts")),
"state": state,
"health": health,
}
def _excerpt(resource: Mapping[str, Any], key: str, field: str = "Reading") -> float | None:
value = resource.get(key)
return number(value.get(field)) if isinstance(value, Mapping) else None
def parse_fan(fan: Mapping[str, Any]) -> dict[str, Any]:
"""One member of ``ThermalSubsystem/Fans``."""
state, health = status(fan)
rpm = _excerpt(fan, "SpeedPercent", "SpeedRPM")
return {
"id": text(fan.get("Id")),
"name": _name(fan, "Name"),
"present": present(fan),
"status": _ok(fan),
"speed_rpm": int(rpm) if rpm is not None else None,
"speed_percent": _excerpt(fan, "SpeedPercent"),
"state": state,
"health": health,
}
def parse_power_supply(supply: Mapping[str, Any],
metrics: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""One member of ``PowerSubsystem/PowerSupplies``, with its ``Metrics`` if read."""
state, health = status(supply)
capacity = number(supply.get("PowerCapacityWatts"))
output = _excerpt(metrics, "OutputPowerWatts") if metrics else None
return {
"id": text(supply.get("Id")),
"name": _name(supply, "Name"),
"present": present(supply),
"status": _ok(supply),
"capacity": capacity if capacity is not None else -1.0,
"output": output if output is not None else -1.0,
"manufacturer": text(supply.get("Manufacturer")),
"model": text(supply.get("Model")),
"serial_number": text(supply.get("SerialNumber")),
"part_number": text(supply.get("PartNumber")),
"firmware_version": text(supply.get("FirmwareVersion")),
"state": state,
"health": health,
}
def parse_sensor(sensor: Mapping[str, Any]) -> dict[str, Any]:
"""One member of ``Chassis/{id}/Sensors``.
``reading_type`` falls back to one derived from ``ReadingUnits`` (some sensors, such as
temperatures in the DMTF mockups, carry only units).
"""
state, health = status(sensor)
units = text(sensor.get("ReadingUnits"))
reading = number(sensor.get("Reading"))
alert, critical = _sensor_flags(sensor, reading) if reading is not None else (False, False)
return {
"id": text(sensor.get("Id")),
"name": _name(sensor, "Name"),
"reading_type": text(sensor.get("ReadingType")) or _SENSOR_TYPES_BY_UNITS.get(units or ""),
"reading": reading,
"units": units,
"physical_context": text(sensor.get("PhysicalContext")),
"electrical_context": text(sensor.get("ElectricalContext")),
"state": state,
"health": health,
"is_alert": alert,
"is_critical": critical,
}
def parse_thermal_metrics(metrics: Mapping[str, Any]) -> dict[str, Any]:
"""``ThermalSubsystem/ThermalMetrics``: temperatures without thresholds.
A fallback for services with a ThermalSubsystem but no Sensors collection.
"""
temperature: dict[str, Any] = {}
for index, item in _members(metrics, "TemperatureReadingsCelsius"):
reading = number(item.get("Reading"))
if reading is None:
continue
name = unique_name(temperature, _name(item, "DeviceName"), index)
temperature[name] = {"temperature": reading, "is_alert": False, "is_critical": False}
return {"temperature": temperature}
def build_environment(*, 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.
``consumed_watts`` comes from the chassis total power sensor
(``ReadingType`` Power, ``ElectricalContext`` Total, ``PhysicalContext`` Chassis).
"""
env: dict[str, Any] = {"fans": {}, "temperature": {}, "power": {}, "voltages": {},
"consumed_watts": None}
for fan in fans:
if fan["present"]:
name = unique_name(env["fans"], fan["name"], fan["id"])
env["fans"][name] = {"status": fan["status"]}
for psu in power_supplies:
if psu["present"]:
name = unique_name(env["power"], psu["name"], psu["id"])
env["power"][name] = {"status": psu["status"], "capacity": psu["capacity"],
"output": psu["output"]}
for sensor in sensors:
reading = sensor["reading"]
if reading is None:
continue
flags = {"is_alert": sensor["is_alert"], "is_critical": sensor["is_critical"]}
if sensor["reading_type"] == "Temperature":
name = unique_name(env["temperature"], sensor["name"], sensor["id"])
env["temperature"][name] = {"temperature": reading, **flags}
elif sensor["reading_type"] == "Voltage":
name = unique_name(env["voltages"], sensor["name"], sensor["id"])
env["voltages"][name] = {"voltage": reading, **flags}
elif (sensor["reading_type"] == "Power" and sensor["electrical_context"] == "Total"
and sensor["physical_context"] == "Chassis" and env["consumed_watts"] is None):
env["consumed_watts"] = reading
return env
+23
View File
@@ -0,0 +1,23 @@
"""UpdateService firmware inventory (``UpdateService/FirmwareInventory/*``)."""
from __future__ import annotations
from typing import Any, Mapping
from .common import boolean, link_list, status, text
def parse_firmware(item: Mapping[str, Any]) -> dict[str, Any]:
"""One SoftwareInventory member: what it is, its version, whether it can be updated."""
state, health = status(item)
return {
"id": text(item.get("Id")),
"name": text(item.get("Name")),
"version": text(item.get("Version")),
"updateable": boolean(item.get("Updateable")),
"manufacturer": text(item.get("Manufacturer")),
"release_date": text(item.get("ReleaseDate")),
"state": state,
"health": health,
"related": link_list(item, "RelatedItem"),
}
+128
View File
@@ -0,0 +1,128 @@
"""Hardware of the managed server: Processors, Memory, Storage, Drives, SimpleStorage."""
from __future__ import annotations
from typing import Any, Mapping
from .common import boolean, ident, integer, link, link_list, location_label, present, status, text
def parse_processor(cpu: Mapping[str, Any]) -> dict[str, Any]:
"""One member of ``Systems/{id}/Processors``."""
state, health = status(cpu)
return {
"id": text(cpu.get("Id")),
"name": text(cpu.get("Name")),
"socket": text(cpu.get("Socket")) or location_label(cpu),
"processor_type": text(cpu.get("ProcessorType")),
"manufacturer": ident(cpu.get("Manufacturer")),
"model": ident(cpu.get("Model")),
"architecture": text(cpu.get("ProcessorArchitecture")),
"instruction_set": text(cpu.get("InstructionSet")),
"max_speed_mhz": integer(cpu.get("MaxSpeedMHz")),
"cores": integer(cpu.get("TotalCores")),
"threads": integer(cpu.get("TotalThreads")),
"serial_number": ident(cpu.get("SerialNumber")),
"present": present(cpu),
"state": state,
"health": health,
}
def parse_memory(dimm: Mapping[str, Any]) -> dict[str, Any]:
"""One member of ``Systems/{id}/Memory``.
An enabled slot with a capacity of 0 counts as empty: some BMCs list empty slots so.
"""
state, health = status(dimm)
capacity = integer(dimm.get("CapacityMiB"))
return {
"id": text(dimm.get("Id")),
"name": text(dimm.get("Name")),
"location": (location_label(dimm) or text(dimm.get("DeviceLocator"))
or text(dimm.get("Name"))),
"capacity_mib": capacity,
"memory_device_type": text(dimm.get("MemoryDeviceType")),
"base_module_type": text(dimm.get("BaseModuleType")),
"speed_mhz": integer(dimm.get("OperatingSpeedMhz")),
"manufacturer": ident(dimm.get("Manufacturer")),
"part_number": ident(dimm.get("PartNumber")),
"serial_number": ident(dimm.get("SerialNumber")),
"present": present(dimm) and capacity != 0,
"state": state,
"health": health,
}
def _controller(ctrl: Mapping[str, Any]) -> dict[str, Any]:
state, health = status(ctrl)
return {
"name": text(ctrl.get("Name")),
"manufacturer": ident(ctrl.get("Manufacturer")),
"model": ident(ctrl.get("Model")),
"serial_number": ident(ctrl.get("SerialNumber")),
"firmware_version": text(ctrl.get("FirmwareVersion")),
"speed_gbps": ctrl.get("SpeedGbps") if isinstance(ctrl.get("SpeedGbps"), (int, float))
else None,
"state": state,
"health": health,
}
def parse_storage(storage: Mapping[str, Any]) -> dict[str, Any]:
"""One member of ``Systems/{id}/Storage``.
Drives are separate resources: ``drive_paths`` lists them for ``parse_drive``. Storage
1.15 and later move controllers into a collection at ``controllers_path``.
"""
state, health = status(storage)
controllers = storage.get("StorageControllers")
return {
"id": text(storage.get("Id")),
"name": text(storage.get("Name")),
"state": state,
"health": health,
"controllers": [_controller(c) for c in controllers if isinstance(c, Mapping)]
if isinstance(controllers, list) else [],
"controllers_path": link(storage, "Controllers"),
"drive_paths": link_list(storage, "Drives"),
}
def parse_drive(drive: Mapping[str, Any]) -> dict[str, Any]:
"""A Drive resource, as linked from Storage."""
state, health = status(drive)
return {
"id": text(drive.get("Id")),
"name": text(drive.get("Name")),
"manufacturer": ident(drive.get("Manufacturer")),
"model": ident(drive.get("Model")),
"serial_number": ident(drive.get("SerialNumber")),
"part_number": ident(drive.get("PartNumber")),
"revision": text(drive.get("Revision")),
"capacity_bytes": integer(drive.get("CapacityBytes")),
"media_type": text(drive.get("MediaType")),
"protocol": text(drive.get("Protocol")),
"failure_predicted": boolean(drive.get("FailurePredicted")),
"present": present(drive),
"state": state,
"health": health,
}
def parse_simple_storage(storage: Mapping[str, Any]) -> dict[str, Any]:
"""One member of ``Systems/{id}/SimpleStorage`` (older services, no Storage).
Its devices come out with the same keys as ``parse_drive``.
"""
state, health = status(storage)
devices = storage.get("Devices")
drives = [parse_drive(dev) for dev in devices if isinstance(dev, Mapping)] \
if isinstance(devices, list) else []
return {
"id": text(storage.get("Id")),
"name": text(storage.get("Name")),
"state": state,
"health": health,
"drives": drives,
}
+41
View File
@@ -0,0 +1,41 @@
"""LogServices and their entries (the SEL lives under Systems or Managers, by vendor)."""
from __future__ import annotations
from typing import Any, Mapping
from .common import boolean, integer, link, status, text
def parse_log_service(service: Mapping[str, Any]) -> dict[str, Any]:
"""One member of ``.../LogServices``."""
state, health = status(service)
return {
"id": text(service.get("Id")),
"name": text(service.get("Name")),
"enabled": boolean(service.get("ServiceEnabled")),
"entries_path": link(service, "Entries"),
"max_records": integer(service.get("MaxNumberOfRecords")),
"overwrite_policy": text(service.get("OverWritePolicy")),
"entry_type": text(service.get("LogEntryType")),
"state": state,
"health": health,
}
def parse_log_entry(entry: Mapping[str, Any]) -> dict[str, Any]:
"""One LogEntry (SEL record or Redfish event)."""
args = entry.get("MessageArgs")
return {
"id": text(entry.get("Id")),
"created": text(entry.get("Created")),
"severity": text(entry.get("Severity")),
"message": text(entry.get("Message")),
"message_id": text(entry.get("MessageId")),
"message_args": [str(a) for a in args] if isinstance(args, list) else [],
"entry_type": text(entry.get("EntryType")),
"entry_code": text(entry.get("EntryCode")),
"sensor_type": text(entry.get("SensorType")),
"sensor_number": integer(entry.get("SensorNumber")),
"origin": link(entry, "Links", "OriginOfCondition"),
}
+150
View File
@@ -0,0 +1,150 @@
"""Manager (the BMC itself), its EthernetInterfaces and its NetworkProtocol."""
from __future__ import annotations
import ipaddress
from typing import Any, Mapping
from .common import boolean, integer, link_list, links, number, status, text
def parse_manager(manager: Mapping[str, Any]) -> dict[str, Any]:
"""``Managers/{id}``: model, firmware and links of the BMC."""
state, health = status(manager)
return {
"id": text(manager.get("Id")),
"name": text(manager.get("Name")),
"manager_type": text(manager.get("ManagerType")),
"model": text(manager.get("Model")),
"firmware_version": text(manager.get("FirmwareVersion")),
"uuid": text(manager.get("UUID")),
"power_state": text(manager.get("PowerState")),
"date_time": text(manager.get("DateTime")),
"state": state,
"health": health,
"links": links(manager),
"manager_for_servers": link_list(manager, "Links", "ManagerForServers"),
"manager_for_chassis": link_list(manager, "Links", "ManagerForChassis"),
}
def _mac(value: Any) -> str:
mac = text(value)
return mac.upper().replace("-", ":") if mac else ""
def _ipv4(entries: Any) -> dict[str, dict[str, int]]:
found = {}
for entry in entries if isinstance(entries, list) else []:
if not isinstance(entry, Mapping):
continue
try:
addr = ipaddress.IPv4Address(text(entry.get("Address")) or "")
mask = text(entry.get("SubnetMask"))
prefix = ipaddress.IPv4Network(f"0.0.0.0/{mask}").prefixlen if mask else 32
except ValueError:
continue
if addr.is_unspecified:
continue
found[str(addr)] = {"prefix_length": prefix}
return found
def _ipv6(entries: Any) -> dict[str, dict[str, int]]:
found = {}
for entry in entries if isinstance(entries, list) else []:
if not isinstance(entry, Mapping):
continue
try:
addr = ipaddress.IPv6Address(text(entry.get("Address")) or "")
except ValueError:
continue
if addr.is_unspecified:
continue
prefix = integer(entry.get("PrefixLength"))
found[str(addr)] = {"prefix_length": prefix if prefix is not None else 128}
return found
def parse_ethernet_interface(iface: Mapping[str, Any]) -> dict[str, Any]:
"""One EthernetInterface, as NAPALM ``get_interfaces``/``get_interfaces_ip`` parts.
``interface`` is the ``get_interfaces`` value, ``ipv4``/``ipv6`` the ``get_interfaces_ip``
one. Without ``LinkStatus`` (older schemas) an enabled interface counts as up. Unset
addresses (``0.0.0.0``, ``::``) are left out.
"""
state, _ = status(iface)
enabled = boolean(iface.get("InterfaceEnabled"))
if enabled is None:
enabled = state == "Enabled"
link_status = text(iface.get("LinkStatus"))
is_up = enabled and (link_status == "LinkUp" if link_status else state == "Enabled")
speed = number(iface.get("SpeedMbps"))
vlan = iface.get("VLAN")
vlan_id = None
if isinstance(vlan, Mapping) and vlan.get("VLANEnable"):
vlan_id = integer(vlan.get("VLANId"))
return {
"name": text(iface.get("Id")),
"interface": {
"is_up": bool(is_up),
"is_enabled": bool(enabled),
"description": text(iface.get("Description")) or "",
"last_flapped": -1.0,
"speed": speed if speed is not None else -1.0,
"mtu": integer(iface.get("MTUSize")) or 0,
"mac_address": _mac(iface.get("MACAddress")),
},
"ipv4": _ipv4(iface.get("IPv4Addresses")),
"ipv6": _ipv6(iface.get("IPv6Addresses")),
"hostname": text(iface.get("HostName")),
"fqdn": text(iface.get("FQDN")),
"vlan": vlan_id,
"permanent_mac_address": _mac(iface.get("PermanentMACAddress")) or None,
}
def parse_network_protocol(proto: Mapping[str, Any]) -> dict[str, Any]:
"""``Managers/{id}/NetworkProtocol``: which services the BMC runs, NTP and SNMP.
``protocols`` holds every property with a ``ProtocolEnabled`` flag, so OEM-free services
the schema adds later show up without code changes. NTP servers and the SNMP details
exist only in newer schema versions; missing values are ``None``.
"""
protocols = {}
for key, value in proto.items():
if isinstance(value, Mapping) and "ProtocolEnabled" in value:
protocols[key] = {
"enabled": boolean(value.get("ProtocolEnabled")),
"port": integer(value.get("Port")),
}
ntp = proto.get("NTP") if isinstance(proto.get("NTP"), Mapping) else {}
servers: list[str] = []
for server in ntp.get("NTPServers") or []:
server = text(server)
if server and server not in servers:
servers.append(server)
snmp = proto.get("SNMP") if isinstance(proto.get("SNMP"), Mapping) else {}
communities = []
for community in snmp.get("CommunityStrings") or []:
if isinstance(community, Mapping):
# The community string itself is never kept, even if a BMC returns it.
communities.append({"name": text(community.get("Name")),
"access": text(community.get("AccessMode"))})
return {
"hostname": text(proto.get("HostName")),
"fqdn": text(proto.get("FQDN")),
"protocols": protocols,
"ntp": {"enabled": boolean(ntp.get("ProtocolEnabled")), "servers": servers},
"snmp": {
"enabled": boolean(snmp.get("ProtocolEnabled")),
"port": integer(snmp.get("Port")),
"v1": boolean(snmp.get("EnableSNMPv1")),
"v2c": boolean(snmp.get("EnableSNMPv2c")),
"v3": boolean(snmp.get("EnableSNMPv3")),
"communities": communities,
},
}
+29
View File
@@ -0,0 +1,29 @@
"""ServiceRoot: ``GET /redfish/v1/`` (readable without credentials)."""
from __future__ import annotations
from typing import Any, Mapping
from .common import link, links, text
def parse_service_root(root: Mapping[str, Any]) -> dict[str, Any]:
"""Version, identity and top-level links of a Redfish service.
``vendor`` and ``product`` exist from ServiceRoot 1.5 on; ``oem_vendors`` (the keys under
``Oem``) is what older services offer for picking a quirk module.
"""
found = links(root)
sessions = link(root, "Links", "Sessions")
if sessions is not None:
found["Sessions"] = sessions
oem = root.get("Oem")
return {
"name": text(root.get("Name")),
"redfish_version": text(root.get("RedfishVersion")),
"uuid": text(root.get("UUID")),
"vendor": text(root.get("Vendor")),
"product": text(root.get("Product")),
"oem_vendors": sorted(oem) if isinstance(oem, Mapping) else [],
"links": found,
}
+73
View File
@@ -0,0 +1,73 @@
"""ComputerSystem (``Systems/{id}``): the managed server, its power state and reset action."""
from __future__ import annotations
from typing import Any, Mapping
from .common import ident, integer, link_list, links, number, status, text
def parse_system(system: Mapping[str, Any]) -> dict[str, Any]:
"""Identity, summaries and links of the managed server."""
state, health = status(system)
cpus = system.get("ProcessorSummary") if isinstance(system.get("ProcessorSummary"),
Mapping) else {}
memory = system.get("MemorySummary") if isinstance(system.get("MemorySummary"),
Mapping) else {}
return {
"id": text(system.get("Id")),
"name": text(system.get("Name")),
"manufacturer": ident(system.get("Manufacturer")),
"model": ident(system.get("Model")),
"serial_number": ident(system.get("SerialNumber")),
"uuid": ident(system.get("UUID")),
"hostname": text(system.get("HostName")),
"sku": ident(system.get("SKU")),
"part_number": ident(system.get("PartNumber")),
"asset_tag": ident(system.get("AssetTag")),
"bios_version": text(system.get("BiosVersion")),
"system_type": text(system.get("SystemType")),
"power_state": parse_power_state(system),
"state": state,
"health": health,
"processor_count": integer(cpus.get("Count")),
"processor_model": text(cpus.get("Model")),
"memory_gib": number(memory.get("TotalSystemMemoryGiB")),
"links": links(system),
"chassis": link_list(system, "Links", "Chassis"),
"managed_by": link_list(system, "Links", "ManagedBy"),
}
def parse_power_state(system: Mapping[str, Any]) -> str | None:
"""``PowerState``: On, Off, PoweringOn, PoweringOff or Paused; ``None`` if not reported."""
return text(system.get("PowerState"))
def parse_reset_action(resource: Mapping[str, Any],
action: str = "#ComputerSystem.Reset") -> dict[str, Any]:
"""Target and allowed ``ResetType`` values of a reset action.
``allowed`` is ``None`` when the service does not list the values inline. Then
``action_info`` may name the ActionInfo resource that does (see ``parse_action_info``).
Works for ``#Manager.Reset`` too.
"""
actions = resource.get("Actions")
entry = actions.get(action) if isinstance(actions, Mapping) else None
if not isinstance(entry, Mapping):
return {"target": None, "allowed": None, "action_info": None}
allowed = entry.get("ResetType@Redfish.AllowableValues")
return {
"target": text(entry.get("target")),
"allowed": [str(v) for v in allowed] if isinstance(allowed, list) else None,
"action_info": text(entry.get("@Redfish.ActionInfo")),
}
def parse_action_info(info: Mapping[str, Any], parameter: str = "ResetType") -> list[str] | None:
"""``AllowableValues`` of one parameter in an ActionInfo resource."""
for param in info.get("Parameters") or []:
if isinstance(param, Mapping) and param.get("Name") == parameter:
values = param.get("AllowableValues")
return [str(v) for v in values] if isinstance(values, list) else None
return None