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:
18 files changed
+1926
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,52 @@
|
||||
from napalm_redfish.parsers import parse_account, to_napalm_users
|
||||
|
||||
|
||||
def test_account(rackmount):
|
||||
acc = parse_account(rackmount("/redfish/v1/AccountService/Accounts/1"))
|
||||
assert acc == {
|
||||
"id": "1",
|
||||
"username": "Administrator",
|
||||
"role_id": "Administrator",
|
||||
"enabled": True,
|
||||
"locked": False,
|
||||
"account_types": ["Redfish"],
|
||||
}
|
||||
|
||||
|
||||
def test_account_snmp_user(rackmount):
|
||||
acc = parse_account(rackmount("/redfish/v1/AccountService/Accounts/2"))
|
||||
assert acc["username"] == "contoso_employee457"
|
||||
assert acc["account_types"] == ["Redfish", "SNMP"]
|
||||
|
||||
|
||||
def test_empty_account_slot_is_none():
|
||||
# iDRAC and Supermicro list every slot, unused ones with an empty UserName.
|
||||
assert parse_account({"Id": "3", "UserName": "", "Enabled": False, "RoleId": "None"}) is None
|
||||
assert parse_account({"Id": "4"}) is None
|
||||
|
||||
|
||||
def test_account_defaults():
|
||||
acc = parse_account({"Id": "2", "UserName": "ADMIN"})
|
||||
assert acc["enabled"] is None
|
||||
assert acc["locked"] is None
|
||||
assert acc["role_id"] is None
|
||||
assert acc["account_types"] == []
|
||||
|
||||
|
||||
def test_to_napalm_users(rackmount):
|
||||
accounts = [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({"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)
|
||||
assert to_napalm_users(accounts) == {
|
||||
"Administrator": {"level": 15, "password": "", "sshkeys": []},
|
||||
"contoso_employee457": {"level": 15, "password": "", "sshkeys": []},
|
||||
"netork": {"level": 1, "password": "", "sshkeys": []},
|
||||
"ops": {"level": 5, "password": "", "sshkeys": []},
|
||||
"custom": {"level": 0, "password": "", "sshkeys": []},
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import pytest
|
||||
|
||||
from napalm_redfish.parsers 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,
|
||||
)
|
||||
|
||||
CHASSIS = "/redfish/v1/Chassis/1U"
|
||||
|
||||
|
||||
# --- choosing legacy or new resources ------------------------------------------------------
|
||||
|
||||
|
||||
def test_sources_prefer_new_resources(rackmount):
|
||||
# This chassis links both; the new ones are current, Thermal/Power are deprecated.
|
||||
src = environment_sources(rackmount(CHASSIS))
|
||||
assert src == {
|
||||
"thermal": {"schema": "ThermalSubsystem", "path": CHASSIS + "/ThermalSubsystem"},
|
||||
"power": {"schema": "PowerSubsystem", "path": CHASSIS + "/PowerSubsystem"},
|
||||
"sensors": CHASSIS + "/Sensors",
|
||||
}
|
||||
|
||||
|
||||
def test_sources_legacy_only(localstorage):
|
||||
src = environment_sources(localstorage(CHASSIS))
|
||||
assert src == {
|
||||
"thermal": {"schema": "Thermal", "path": CHASSIS + "/Thermal"},
|
||||
"power": {"schema": "Power", "path": CHASSIS + "/Power"},
|
||||
"sensors": None,
|
||||
}
|
||||
|
||||
|
||||
def test_sources_mixed_and_missing():
|
||||
src = environment_sources({
|
||||
"ThermalSubsystem": {"@odata.id": "/redfish/v1/Chassis/X/ThermalSubsystem"},
|
||||
"Power": {"@odata.id": "/redfish/v1/Chassis/X/Power"},
|
||||
})
|
||||
assert src["thermal"]["schema"] == "ThermalSubsystem"
|
||||
assert src["power"]["schema"] == "Power"
|
||||
assert environment_sources({}) == {"thermal": None, "power": None, "sensors": None}
|
||||
|
||||
|
||||
# --- legacy Thermal and Power --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_thermal(rackmount):
|
||||
env = parse_thermal(rackmount(CHASSIS + "/Thermal"))
|
||||
assert env["temperature"] == {
|
||||
"CPU1 Temp": {"temperature": 41.0, "is_alert": False, "is_critical": False},
|
||||
# "CPU2 Temp" is Disabled and has no reading: left out.
|
||||
"Chassis Intake Temp": {"temperature": 25.0, "is_alert": False, "is_critical": False},
|
||||
}
|
||||
assert env["fans"] == {
|
||||
"BaseBoard System Fan": {"status": True},
|
||||
"BaseBoard System Fan Backup": {"status": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reading, health, alert, critical",
|
||||
[
|
||||
(41, "OK", False, False),
|
||||
(42, "OK", True, False), # at UpperThresholdNonCritical
|
||||
(45, "OK", True, True), # at UpperThresholdCritical
|
||||
(49, "OK", True, True), # above UpperThresholdFatal
|
||||
(4, "OK", True, True), # below LowerThresholdCritical
|
||||
(9, "OK", True, False), # below LowerThresholdNonCritical
|
||||
(30, "Warning", True, False),
|
||||
(30, "Critical", True, True),
|
||||
],
|
||||
)
|
||||
def test_thermal_thresholds(reading, health, alert, critical):
|
||||
env = parse_thermal({"Temperatures": [{
|
||||
"Name": "T", "ReadingCelsius": reading, "Status": {"State": "Enabled", "Health": health},
|
||||
"UpperThresholdNonCritical": 42, "UpperThresholdCritical": 45, "UpperThresholdFatal": 48,
|
||||
"LowerThresholdNonCritical": 10, "LowerThresholdCritical": 5,
|
||||
}]})
|
||||
assert env["temperature"]["T"] == {
|
||||
"temperature": float(reading), "is_alert": alert, "is_critical": critical}
|
||||
|
||||
|
||||
def test_thermal_old_schema_names_and_duplicates():
|
||||
# Thermal 1.0 uses FanName; Supermicro repeats names across members.
|
||||
env = parse_thermal({
|
||||
"Fans": [
|
||||
{"MemberId": "0", "FanName": "FAN1", "Reading": 3000, "Status": {"State": "Enabled",
|
||||
"Health": "OK"}},
|
||||
{"MemberId": "1", "FanName": "FAN2", "Status": {"State": "Absent"}},
|
||||
{"MemberId": "2", "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"] == {
|
||||
"FAN1": {"status": True},
|
||||
"FAN3": {"status": False},
|
||||
"FAN1 (3)": {"status": True},
|
||||
}
|
||||
assert list(env["temperature"]) == ["System Temp", "System Temp (1)"]
|
||||
|
||||
|
||||
def test_power(rackmount):
|
||||
env = parse_power(rackmount(CHASSIS + "/Power"))
|
||||
assert env["power"] == {
|
||||
# Health Warning: the supply works but reports a problem.
|
||||
"Power Supply Bay": {"status": False, "capacity": 800.0, "output": 325.0},
|
||||
}
|
||||
assert env["consumed_watts"] == 344.0
|
||||
assert env["voltages"] == {
|
||||
"VRM1 Voltage": {"voltage": 12.0, "is_alert": False, "is_critical": False},
|
||||
"VRM2 Voltage": {"voltage": 5.0, "is_alert": False, "is_critical": False},
|
||||
}
|
||||
|
||||
|
||||
def test_power_unknown_values_are_minus_one():
|
||||
env = parse_power({"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["consumed_watts"] is None
|
||||
assert env["voltages"] == {}
|
||||
|
||||
|
||||
# --- ThermalSubsystem, PowerSubsystem, Sensors ---------------------------------------------
|
||||
|
||||
|
||||
def test_thermal_subsystem(rackmount):
|
||||
ts = parse_thermal_subsystem(rackmount(CHASSIS + "/ThermalSubsystem"))
|
||||
assert ts == {
|
||||
"fans_path": CHASSIS + "/ThermalSubsystem/Fans",
|
||||
"thermal_metrics_path": CHASSIS + "/ThermalSubsystem/ThermalMetrics",
|
||||
"state": "Enabled",
|
||||
"health": "OK",
|
||||
}
|
||||
|
||||
|
||||
def test_power_subsystem(rackmount):
|
||||
ps = parse_power_subsystem(rackmount(CHASSIS + "/PowerSubsystem"))
|
||||
assert ps == {
|
||||
"power_supplies_path": CHASSIS + "/PowerSubsystem/PowerSupplies",
|
||||
"capacity_watts": 2000.0,
|
||||
"state": "Enabled",
|
||||
"health": "OK",
|
||||
}
|
||||
|
||||
|
||||
def test_fan(rackmount):
|
||||
fan = parse_fan(rackmount(CHASSIS + "/ThermalSubsystem/Fans/CPU1"))
|
||||
assert fan == {
|
||||
"id": "CPU1",
|
||||
"name": "Fan for CPU 1",
|
||||
"present": True,
|
||||
"status": True,
|
||||
"speed_rpm": 1490,
|
||||
"speed_percent": 45.0,
|
||||
"state": "Enabled",
|
||||
"health": "OK",
|
||||
}
|
||||
|
||||
|
||||
def test_power_supply_with_metrics(rackmount):
|
||||
base = CHASSIS + "/PowerSubsystem/PowerSupplies/Bay1"
|
||||
psu = parse_power_supply(rackmount(base), rackmount(base + "/Metrics"))
|
||||
assert psu == {
|
||||
"id": "Bay1",
|
||||
"name": "Power Supply Bay 1",
|
||||
"present": True,
|
||||
"status": False, # predicted failure, Health Warning
|
||||
"capacity": 400.0,
|
||||
"output": 937.4,
|
||||
"manufacturer": "Contoso Power",
|
||||
"model": "RKS-440DC",
|
||||
"serial_number": "3488247",
|
||||
"part_number": "23456-133",
|
||||
"firmware_version": "1.00",
|
||||
"state": "Enabled",
|
||||
"health": "Warning",
|
||||
}
|
||||
assert psu["output"] == 937.4
|
||||
|
||||
|
||||
def test_power_supply_without_metrics(rackmount):
|
||||
base = CHASSIS + "/PowerSubsystem/PowerSupplies/Bay1"
|
||||
assert parse_power_supply(rackmount(base))["output"] == -1.0
|
||||
absent = parse_power_supply(rackmount(CHASSIS + "/PowerSubsystem/PowerSupplies/Bay2"))
|
||||
assert absent["present"] is False
|
||||
assert absent["capacity"] == -1.0
|
||||
|
||||
|
||||
def test_sensor_temperature_by_units(rackmount):
|
||||
# No ReadingType on this sensor: "Cel" makes it a temperature.
|
||||
s = parse_sensor(rackmount(CHASSIS + "/Sensors/CPU1Temp"))
|
||||
assert s["id"] == "CPU1Temp"
|
||||
assert s["name"] == "CPU #1 Temperature"
|
||||
assert s["reading_type"] == "Temperature"
|
||||
assert s["reading"] == 44.0
|
||||
assert s["units"] == "Cel"
|
||||
assert s["physical_context"] == "CPU"
|
||||
assert s["health"] == "Warning"
|
||||
assert s["is_alert"] is True # 44 >= UpperCaution 42
|
||||
assert s["is_critical"] is False # 44 < UpperCritical 45
|
||||
|
||||
|
||||
def test_sensor_power(rackmount):
|
||||
s = parse_sensor(rackmount(CHASSIS + "/Sensors/TotalPower"))
|
||||
assert s["reading_type"] == "Power"
|
||||
assert s["reading"] == 374.0
|
||||
assert s["electrical_context"] == "Total"
|
||||
assert s["is_alert"] is False
|
||||
|
||||
|
||||
def test_sensor_voltage_and_fan(rackmount):
|
||||
v = parse_sensor(rackmount(CHASSIS + "/Sensors/PS1InputVoltage"))
|
||||
assert v["reading_type"] == "Voltage"
|
||||
assert v["reading"] == 119.27
|
||||
assert v["is_alert"] is False
|
||||
fan = parse_sensor(rackmount(CHASSIS + "/Sensors/CPUFan1"))
|
||||
assert fan["reading_type"] == "Percent"
|
||||
assert fan["reading"] == 45.0
|
||||
|
||||
|
||||
def test_sensor_without_reading():
|
||||
s = parse_sensor({"Id": "X", "Name": "X", "ReadingUnits": "Cel",
|
||||
"Status": {"State": "Disabled"}})
|
||||
assert s["reading"] is None
|
||||
assert s["is_alert"] is False
|
||||
|
||||
|
||||
def test_thermal_metrics(rackmount):
|
||||
env = parse_thermal_metrics(rackmount(CHASSIS + "/ThermalSubsystem/ThermalMetrics"))
|
||||
assert env == {"temperature": {
|
||||
"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):
|
||||
fans = [parse_fan(rackmount(f"{CHASSIS}/ThermalSubsystem/Fans/{f}"))
|
||||
for f in ("Bay1", "Bay2", "CPU1", "CPU2")]
|
||||
bay1 = f"{CHASSIS}/PowerSubsystem/PowerSupplies/Bay1"
|
||||
psus = [parse_power_supply(rackmount(bay1), rackmount(bay1 + "/Metrics")),
|
||||
parse_power_supply(rackmount(f"{CHASSIS}/PowerSubsystem/PowerSupplies/Bay2"))]
|
||||
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)
|
||||
assert env["fans"] == {
|
||||
"Fan Bay 1": {"status": True},
|
||||
"Fan Bay 2": {"status": True},
|
||||
"Fan for CPU 1": {"status": True},
|
||||
"Fan for CPU 2": {"status": True},
|
||||
}
|
||||
assert env["power"] == {
|
||||
"Power Supply Bay 1": {"status": False, "capacity": 400.0, "output": 937.4},
|
||||
}
|
||||
assert env["temperature"] == {
|
||||
"CPU #1 Temperature": {"temperature": 44.0, "is_alert": True, "is_critical": False},
|
||||
"Front Panel Intake Temperature": {
|
||||
"temperature": 24.8, "is_alert": False, "is_critical": False},
|
||||
}
|
||||
assert env["consumed_watts"] == 374.0
|
||||
assert env["voltages"] == {
|
||||
"Power Supply #1 Input Voltage": {
|
||||
"voltage": 119.27, "is_alert": False, "is_critical": False},
|
||||
}
|
||||
|
||||
|
||||
def test_build_environment_empty():
|
||||
assert build_environment() == {
|
||||
"fans": {}, "temperature": {}, "power": {}, "voltages": {}, "consumed_watts": None}
|
||||
@@ -0,0 +1,31 @@
|
||||
from napalm_redfish.parsers import parse_firmware
|
||||
|
||||
INVENTORY = "/redfish/v1/UpdateService/FirmwareInventory"
|
||||
|
||||
|
||||
def test_firmware_bmc(rackmount):
|
||||
fw = parse_firmware(rackmount(INVENTORY + "/BMC"))
|
||||
assert fw == {
|
||||
"id": "BMC",
|
||||
"name": "Contoso BMC Firmware",
|
||||
"version": "1.45.455b66-rev4",
|
||||
"updateable": True,
|
||||
"manufacturer": "Contoso",
|
||||
"release_date": "2017-08-22T12:00:00Z",
|
||||
"state": "Enabled",
|
||||
"health": "OK",
|
||||
"related": ["/redfish/v1/Managers/BMC"],
|
||||
}
|
||||
|
||||
|
||||
def test_firmware_bios(rackmount):
|
||||
fw = parse_firmware(rackmount(INVENTORY + "/BIOS"))
|
||||
assert fw["version"] == "P79 v1.45"
|
||||
assert fw["related"] == ["/redfish/v1/Systems/437XR1138R2/Bios"]
|
||||
|
||||
|
||||
def test_firmware_minimal():
|
||||
fw = parse_firmware({"Id": "BMC", "Name": "BMC", "Version": "03.91"})
|
||||
assert fw["version"] == "03.91"
|
||||
assert fw["updateable"] is None
|
||||
assert fw["related"] == []
|
||||
@@ -0,0 +1,175 @@
|
||||
from napalm_redfish.parsers import (
|
||||
parse_drive,
|
||||
parse_memory,
|
||||
parse_processor,
|
||||
parse_simple_storage,
|
||||
parse_storage,
|
||||
)
|
||||
|
||||
SYSTEM = "/redfish/v1/Systems/437XR1138R2"
|
||||
|
||||
|
||||
# --- Processors ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_processor(rackmount):
|
||||
cpu = parse_processor(rackmount(SYSTEM + "/Processors/CPU1"))
|
||||
assert cpu == {
|
||||
"id": "CPU1",
|
||||
"name": "Processor",
|
||||
"socket": "CPU 1",
|
||||
"processor_type": "CPU",
|
||||
"manufacturer": "Intel(R) Corporation",
|
||||
"model": "Multi-Core Intel(R) Xeon(R) processor 7xxx Series",
|
||||
"architecture": "x86",
|
||||
"instruction_set": "x86-64",
|
||||
"max_speed_mhz": 3700,
|
||||
"cores": 8,
|
||||
"threads": 16,
|
||||
"serial_number": None,
|
||||
"present": True,
|
||||
"state": "Enabled",
|
||||
# The CPU itself is OK; its HealthRollup (temperature warning) is not its health.
|
||||
"health": "OK",
|
||||
}
|
||||
|
||||
|
||||
def test_processor_absent(rackmount):
|
||||
cpu = parse_processor(rackmount(SYSTEM + "/Processors/CPU2"))
|
||||
assert cpu["present"] is False
|
||||
assert cpu["state"] == "Absent"
|
||||
assert cpu["health"] is None
|
||||
assert cpu["socket"] == "CPU 2"
|
||||
assert cpu["model"] is None
|
||||
assert cpu["cores"] is None
|
||||
|
||||
|
||||
def test_processor_fpga(rackmount):
|
||||
fpga = parse_processor(rackmount(SYSTEM + "/Processors/FPGA1"))
|
||||
assert fpga["processor_type"] == "FPGA"
|
||||
assert fpga["model"] == "Stratix 10"
|
||||
# No Socket property: the location label stands in.
|
||||
assert fpga["socket"] == "U168"
|
||||
|
||||
|
||||
# --- Memory --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_memory(rackmount):
|
||||
dimm = parse_memory(rackmount(SYSTEM + "/Memory/DIMM1"))
|
||||
assert dimm == {
|
||||
"id": "DIMM1",
|
||||
"name": "DIMM Slot 1",
|
||||
"location": "DIMM 1",
|
||||
"capacity_mib": 32768,
|
||||
"memory_device_type": "DDR4",
|
||||
"base_module_type": "RDIMM",
|
||||
"speed_mhz": None,
|
||||
"manufacturer": None,
|
||||
"part_number": None,
|
||||
"serial_number": None,
|
||||
"present": True,
|
||||
"state": "Enabled",
|
||||
"health": "OK",
|
||||
}
|
||||
|
||||
|
||||
def test_memory_absent(rackmount):
|
||||
dimm = parse_memory(rackmount(SYSTEM + "/Memory/DIMM4"))
|
||||
assert dimm["present"] is False
|
||||
assert dimm["capacity_mib"] is None
|
||||
assert dimm["location"] == "DIMM 4"
|
||||
|
||||
|
||||
def test_memory_older_schema():
|
||||
# Memory 1.0-1.x: DeviceLocator instead of Location, speed in OperatingSpeedMhz.
|
||||
dimm = parse_memory({
|
||||
"Id": "1", "Name": "Memory 1", "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["speed_mhz"] == 2133
|
||||
assert dimm["manufacturer"] == "Samsung"
|
||||
assert dimm["serial_number"] == "12345678"
|
||||
|
||||
|
||||
def test_memory_zero_capacity_is_empty_slot():
|
||||
# Some BMCs list empty slots as enabled with a capacity of 0.
|
||||
dimm = parse_memory({"Id": "DIMM9", "CapacityMiB": 0, "Status": {"State": "Enabled"}})
|
||||
assert dimm["present"] is False
|
||||
|
||||
|
||||
# --- Storage -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_storage(localstorage):
|
||||
st = parse_storage(localstorage(SYSTEM + "/Storage/1"))
|
||||
assert st["id"] == "1"
|
||||
assert st["name"] == "Local Storage Controller"
|
||||
assert st["state"] == "Enabled"
|
||||
assert st["health"] == "OK"
|
||||
assert st["controllers"] == [{
|
||||
"name": "Contoso Integrated RAID",
|
||||
"manufacturer": "Contoso",
|
||||
"model": "12Gbs Integrated RAID",
|
||||
"serial_number": "2M220100SL",
|
||||
"firmware_version": "1.0.0.7",
|
||||
"speed_gbps": 12,
|
||||
"state": "Enabled",
|
||||
"health": "OK",
|
||||
}]
|
||||
assert st["drive_paths"] == [
|
||||
"/redfish/v1/Chassis/1U/Drives/35D38F11ACEF7BD3",
|
||||
"/redfish/v1/Chassis/1U/Drives/3F5A8C54207B7233",
|
||||
"/redfish/v1/Chassis/1U/Drives/32ADF365C6C1B7BD",
|
||||
"/redfish/v1/Chassis/1U/Drives/3D58ECBC375FD9F2",
|
||||
]
|
||||
assert st["controllers_path"] is None
|
||||
|
||||
|
||||
def test_storage_with_controller_collection():
|
||||
# Storage 1.15 and later link a StorageController collection instead of an array.
|
||||
st = parse_storage({"Id": "RAID", "Controllers": {"@odata.id": "/redfish/v1/x/Controllers"}})
|
||||
assert st["controllers"] == []
|
||||
assert st["controllers_path"] == "/redfish/v1/x/Controllers"
|
||||
assert st["drive_paths"] == []
|
||||
|
||||
|
||||
def test_drive(localstorage):
|
||||
drive = parse_drive(localstorage("/redfish/v1/Chassis/1U/Drives/35D38F11ACEF7BD3"))
|
||||
assert drive == {
|
||||
"id": "35D38F11ACEF7BD3",
|
||||
"name": "Drive Sample",
|
||||
"manufacturer": "Contoso",
|
||||
"model": "C123",
|
||||
"serial_number": "1234567",
|
||||
"part_number": "C123-1111",
|
||||
"revision": "100A",
|
||||
"capacity_bytes": 899527000064,
|
||||
"media_type": "HDD",
|
||||
"protocol": "SAS",
|
||||
"failure_predicted": False,
|
||||
"present": True,
|
||||
"state": "Enabled",
|
||||
"health": "OK",
|
||||
}
|
||||
|
||||
|
||||
def test_simple_storage(rackmount):
|
||||
st = parse_simple_storage(rackmount(SYSTEM + "/SimpleStorage/1"))
|
||||
assert st["id"] == "1"
|
||||
assert st["name"] == "Simple Storage Controller"
|
||||
assert st["health"] == "OK"
|
||||
assert [d["name"] for d in st["drives"]] == [
|
||||
"SATA Bay 1", "SATA Bay 2", "SATA Bay 3", "SATA Bay 4"]
|
||||
bay1, bay2, bay3, _ = st["drives"]
|
||||
assert bay1["model"] == "3000GT8"
|
||||
assert bay1["capacity_bytes"] == 8000000000000
|
||||
assert bay1["present"] is True
|
||||
assert bay2["health"] == "Warning"
|
||||
assert bay3["present"] is False
|
||||
assert bay3["model"] is None
|
||||
# Same keys as parse_drive, so callers can treat both alike.
|
||||
assert set(bay1) == set(parse_drive({}))
|
||||
@@ -0,0 +1,64 @@
|
||||
from napalm_redfish.parsers import parse_log_entry, parse_log_service
|
||||
|
||||
SYSTEM_LOG = "/redfish/v1/Systems/437XR1138R2/LogServices/Log1"
|
||||
MANAGER_LOG = "/redfish/v1/Managers/BMC/LogServices/Log"
|
||||
|
||||
|
||||
def test_log_service(rackmount):
|
||||
ls = parse_log_service(rackmount(MANAGER_LOG))
|
||||
assert ls == {
|
||||
"id": "Log",
|
||||
"name": "System Log Service",
|
||||
"enabled": True,
|
||||
"entries_path": MANAGER_LOG + "/Entries",
|
||||
"max_records": 1000,
|
||||
"overwrite_policy": "WrapsWhenFull",
|
||||
"entry_type": None,
|
||||
"state": "Enabled",
|
||||
"health": "OK",
|
||||
}
|
||||
|
||||
|
||||
def test_log_service_sel():
|
||||
entries = {"@odata.id": "/redfish/v1/Systems/1/LogServices/SEL/Entries"}
|
||||
ls = parse_log_service({"Id": "SEL", "LogEntryType": "SEL", "Entries": entries})
|
||||
assert ls["entry_type"] == "SEL"
|
||||
assert ls["entries_path"] == "/redfish/v1/Systems/1/LogServices/SEL/Entries"
|
||||
assert ls["enabled"] is None
|
||||
|
||||
|
||||
def test_sel_entry(rackmount):
|
||||
(first, second) = rackmount(SYSTEM_LOG + "/Entries")["Members"]
|
||||
entry = parse_log_entry(first)
|
||||
assert entry == {
|
||||
"id": "1",
|
||||
"created": "2012-03-07T14:44:00Z",
|
||||
"severity": "Critical",
|
||||
"message": "Temperature threshold exceeded",
|
||||
"message_id": "0x592A28",
|
||||
"message_args": [],
|
||||
"entry_type": "SEL",
|
||||
"entry_code": "Upper Critical - going high",
|
||||
"sensor_type": "Temperature",
|
||||
"sensor_number": 1,
|
||||
"origin": "/redfish/v1/Chassis/1U/Thermal",
|
||||
}
|
||||
assert parse_log_entry(second)["sensor_number"] == 2
|
||||
|
||||
|
||||
def test_event_entry(rackmount):
|
||||
(entry,) = rackmount(MANAGER_LOG + "/Entries")["Members"]
|
||||
parsed = parse_log_entry(entry)
|
||||
assert parsed["entry_type"] == "Event"
|
||||
assert parsed["message"] == "System May be Melting"
|
||||
assert parsed["message_id"] == "Event.1.0.TempWayTooHot"
|
||||
assert parsed["message_args"] == ["88"]
|
||||
assert parsed["entry_code"] is None
|
||||
|
||||
|
||||
def test_entry_minimal():
|
||||
entry = parse_log_entry({"Id": "7", "Message": " Power supply AC lost \n"})
|
||||
assert entry["message"] == "Power supply AC lost"
|
||||
assert entry["created"] is None
|
||||
assert entry["severity"] is None
|
||||
assert entry["origin"] is None
|
||||
@@ -0,0 +1,159 @@
|
||||
import pytest
|
||||
|
||||
from napalm_redfish.parsers import (
|
||||
parse_ethernet_interface,
|
||||
parse_manager,
|
||||
parse_network_protocol,
|
||||
)
|
||||
|
||||
MANAGER = "/redfish/v1/Managers/BMC"
|
||||
|
||||
|
||||
def test_manager(rackmount):
|
||||
m = parse_manager(rackmount(MANAGER))
|
||||
assert m["id"] == "BMC"
|
||||
assert m["name"] == "Manager"
|
||||
assert m["manager_type"] == "BMC"
|
||||
assert m["model"] == "Joo Janta 200"
|
||||
assert m["firmware_version"] == "1.45.455b66-rev4"
|
||||
assert m["uuid"] == "58893887-8974-2487-2389-841168418919"
|
||||
assert m["power_state"] == "On"
|
||||
assert m["date_time"] == "2015-03-13T04:14:33+06:00"
|
||||
assert m["state"] == "Enabled"
|
||||
assert m["health"] == "OK"
|
||||
assert m["links"]["EthernetInterfaces"] == MANAGER + "/EthernetInterfaces"
|
||||
assert m["links"]["NetworkProtocol"] == MANAGER + "/NetworkProtocol"
|
||||
assert m["links"]["LogServices"] == MANAGER + "/LogServices"
|
||||
assert m["manager_for_servers"] == ["/redfish/v1/Systems/437XR1138R2"]
|
||||
assert m["manager_for_chassis"] == ["/redfish/v1/Chassis/1U"]
|
||||
|
||||
|
||||
def test_manager_minimal():
|
||||
m = parse_manager({"Id": "1", "FirmwareVersion": " 3.91 "})
|
||||
assert m["firmware_version"] == "3.91"
|
||||
assert m["model"] is None
|
||||
assert m["links"] == {}
|
||||
assert m["manager_for_servers"] == []
|
||||
|
||||
|
||||
# --- EthernetInterfaces --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ethernet_interface(rackmount):
|
||||
eth = parse_ethernet_interface(rackmount(MANAGER + "/EthernetInterfaces/eth0"))
|
||||
assert eth["name"] == "eth0"
|
||||
assert eth["interface"] == {
|
||||
"is_up": True,
|
||||
"is_enabled": True,
|
||||
"description": "Management network interface on a dedicated port",
|
||||
"last_flapped": -1.0,
|
||||
"speed": 100.0,
|
||||
"mtu": 1500,
|
||||
"mac_address": "23:11:8A:33:CF:EA",
|
||||
}
|
||||
assert eth["ipv4"] == {"192.168.0.10": {"prefix_length": 22}}
|
||||
assert eth["ipv6"] == {"fe80::1ec1:deff:fe6f:1e24": {"prefix_length": 64}}
|
||||
assert eth["hostname"] == "web483-bmc"
|
||||
assert eth["fqdn"] == "web483-bmc.dmtf.org"
|
||||
assert eth["vlan"] == 101
|
||||
assert eth["permanent_mac_address"] == "23:11:8A:33:CF:EA"
|
||||
|
||||
|
||||
def test_ethernet_interface_without_link_status(rackmount):
|
||||
# No LinkStatus (as on older schemas): enabled and Status Enabled counts as up.
|
||||
eth = parse_ethernet_interface(rackmount(MANAGER + "/EthernetInterfaces/ToHost"))
|
||||
assert eth["interface"]["is_up"] is True
|
||||
assert eth["ipv4"] == {"192.168.20.55": {"prefix_length": 24}}
|
||||
assert eth["vlan"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"props, is_up, is_enabled",
|
||||
[
|
||||
({"LinkStatus": "LinkDown", "InterfaceEnabled": True}, False, True),
|
||||
({"LinkStatus": "NoLink", "InterfaceEnabled": True}, False, True),
|
||||
({"LinkStatus": "LinkUp", "InterfaceEnabled": False}, False, False),
|
||||
({"Status": {"State": "Disabled"}}, False, False),
|
||||
({"Status": {"State": "Enabled"}}, True, True),
|
||||
({}, False, False),
|
||||
],
|
||||
)
|
||||
def test_ethernet_link_state(props, is_up, is_enabled):
|
||||
eth = parse_ethernet_interface({"Id": "1", **props})
|
||||
assert eth["interface"]["is_up"] is is_up
|
||||
assert eth["interface"]["is_enabled"] is is_enabled
|
||||
|
||||
|
||||
def test_ethernet_unset_addresses_and_formats():
|
||||
# Supermicro and others report unset addresses as 0.0.0.0 or "::" and MACs in lower case.
|
||||
eth = parse_ethernet_interface({
|
||||
"Id": "1",
|
||||
"MACAddress": "0c:c4:7a:aa:bb:cc",
|
||||
"SpeedMbps": None,
|
||||
"IPv4Addresses": [
|
||||
{"Address": "0.0.0.0", "SubnetMask": "0.0.0.0"},
|
||||
{"Address": "10.1.2.3", "SubnetMask": "255.255.255.0"},
|
||||
{"Address": "10.1.2.4", "SubnetMask": "garbage"},
|
||||
{"Address": None},
|
||||
],
|
||||
"IPv6Addresses": [
|
||||
{"Address": "::", "PrefixLength": 0},
|
||||
{"Address": "2001:DB8::5", "PrefixLength": 64},
|
||||
{"Address": "2001:db8::6"},
|
||||
],
|
||||
})
|
||||
assert eth["interface"]["mac_address"] == "0C:C4:7A:AA:BB:CC"
|
||||
assert eth["interface"]["speed"] == -1.0
|
||||
assert eth["interface"]["mtu"] == 0
|
||||
assert eth["interface"]["description"] == ""
|
||||
assert eth["ipv4"] == {"10.1.2.3": {"prefix_length": 24}}
|
||||
assert eth["ipv6"] == {"2001:db8::5": {"prefix_length": 64},
|
||||
"2001:db8::6": {"prefix_length": 128}}
|
||||
|
||||
|
||||
# --- NetworkProtocol -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_network_protocol(rackmount):
|
||||
np = parse_network_protocol(rackmount(MANAGER + "/NetworkProtocol"))
|
||||
assert np["hostname"] == "web483-bmc"
|
||||
assert np["fqdn"] == "web483-bmc.dmtf.org"
|
||||
assert np["protocols"] == {
|
||||
"HTTP": {"enabled": True, "port": 80},
|
||||
"HTTPS": {"enabled": True, "port": 443},
|
||||
"IPMI": {"enabled": True, "port": 623},
|
||||
"SSH": {"enabled": True, "port": 22},
|
||||
"SNMP": {"enabled": True, "port": 161},
|
||||
"VirtualMedia": {"enabled": True, "port": 17988},
|
||||
"SSDP": {"enabled": True, "port": 1900},
|
||||
"Telnet": {"enabled": True, "port": 23},
|
||||
"KVMIP": {"enabled": True, "port": 5288},
|
||||
}
|
||||
assert np["ntp"] == {"enabled": None, "servers": []}
|
||||
assert np["snmp"] == {
|
||||
"enabled": True, "port": 161, "v1": True, "v2c": False, "v3": True,
|
||||
"communities": [
|
||||
{"name": "Read-only community", "access": "Limited"},
|
||||
{"name": "Full access community", "access": "Full"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_network_protocol_ntp(telemetry):
|
||||
np = parse_network_protocol(telemetry("/redfish/v1/Managers/1/NetworkProtocol"))
|
||||
assert np["ntp"] == {
|
||||
"enabled": True,
|
||||
"servers": ["0.pool.ntp.org", "1.pool.ntp.org", "time-a.nist.gov"],
|
||||
}
|
||||
assert np["protocols"]["NTP"] == {"enabled": True, "port": None}
|
||||
# Older SNMP schema: only ProtocolEnabled and Port.
|
||||
assert np["snmp"] == {"enabled": True, "port": 161, "v1": None, "v2c": None, "v3": None,
|
||||
"communities": []}
|
||||
|
||||
|
||||
def test_network_protocol_ntp_blank_entries():
|
||||
servers = ["", None, " pool.ntp.org ", "pool.ntp.org"]
|
||||
np = parse_network_protocol({"NTP": {"ProtocolEnabled": True, "NTPServers": servers}})
|
||||
assert np["ntp"]["servers"] == ["pool.ntp.org"]
|
||||
assert np["snmp"] == {"enabled": None, "port": None, "v1": None, "v2c": None, "v3": None,
|
||||
"communities": []}
|
||||
@@ -0,0 +1,46 @@
|
||||
from napalm_redfish.parsers import parse_service_root
|
||||
|
||||
|
||||
def test_service_root(rackmount):
|
||||
root = parse_service_root(rackmount("/redfish/v1/"))
|
||||
assert root["redfish_version"] == "1.15.0"
|
||||
assert root["uuid"] == "92384634-2938-2342-8820-489239905423"
|
||||
assert root["name"] == "Root Service"
|
||||
# Vendor and Product only exist from ServiceRoot 1.5 on, and this mockup has neither.
|
||||
assert root["vendor"] is None
|
||||
assert root["product"] is None
|
||||
assert root["oem_vendors"] == []
|
||||
|
||||
|
||||
def test_service_root_links(rackmount):
|
||||
links = parse_service_root(rackmount("/redfish/v1/"))["links"]
|
||||
assert links["Systems"] == "/redfish/v1/Systems"
|
||||
assert links["Chassis"] == "/redfish/v1/Chassis"
|
||||
assert links["Managers"] == "/redfish/v1/Managers"
|
||||
assert links["AccountService"] == "/redfish/v1/AccountService"
|
||||
assert links["UpdateService"] == "/redfish/v1/UpdateService"
|
||||
assert links["SessionService"] == "/redfish/v1/SessionService"
|
||||
# Links.Sessions is where the session login goes.
|
||||
assert links["Sessions"] == "/redfish/v1/SessionService/Sessions"
|
||||
assert "@odata.id" not in links
|
||||
|
||||
|
||||
def test_service_root_vendor_and_oem():
|
||||
root = parse_service_root({
|
||||
"RedfishVersion": "1.0.1",
|
||||
"Vendor": "Supermicro",
|
||||
"Product": "X10DRL-i",
|
||||
"Oem": {"Supermicro": {"@odata.type": "#SmcServiceRoot"}},
|
||||
"Systems": {"@odata.id": "/redfish/v1/Systems"},
|
||||
})
|
||||
assert root["vendor"] == "Supermicro"
|
||||
assert root["product"] == "X10DRL-i"
|
||||
assert root["oem_vendors"] == ["Supermicro"]
|
||||
assert root["uuid"] is None
|
||||
assert root["links"] == {"Systems": "/redfish/v1/Systems"}
|
||||
|
||||
|
||||
def test_service_root_empty():
|
||||
root = parse_service_root({})
|
||||
assert root["redfish_version"] is None
|
||||
assert root["links"] == {}
|
||||
@@ -0,0 +1,129 @@
|
||||
import pytest
|
||||
|
||||
from napalm_redfish.parsers import (
|
||||
parse_action_info,
|
||||
parse_power_state,
|
||||
parse_reset_action,
|
||||
parse_system,
|
||||
)
|
||||
|
||||
SYSTEM = "/redfish/v1/Systems/437XR1138R2"
|
||||
|
||||
|
||||
def test_system_facts(rackmount):
|
||||
s = parse_system(rackmount(SYSTEM))
|
||||
assert s["id"] == "437XR1138R2"
|
||||
assert s["name"] == "WebFrontEnd483"
|
||||
assert s["manufacturer"] == "Contoso"
|
||||
assert s["model"] == "3500"
|
||||
assert s["serial_number"] == "437XR1138R2"
|
||||
assert s["uuid"] == "38947555-7742-3448-3784-823347823834"
|
||||
assert s["hostname"] == "web483"
|
||||
assert s["sku"] == "8675309"
|
||||
assert s["part_number"] == "224071-J23"
|
||||
assert s["asset_tag"] == "Chicago-45Z-2381"
|
||||
assert s["bios_version"] == "P79 v1.45 (12/06/2017)"
|
||||
assert s["system_type"] == "Physical"
|
||||
assert s["power_state"] == "On"
|
||||
assert s["state"] == "Enabled"
|
||||
assert s["health"] == "OK"
|
||||
assert s["processor_count"] == 2
|
||||
assert s["processor_model"] == "Multi-Core Intel(R) Xeon(R) processor 7xxx Series"
|
||||
assert s["memory_gib"] == 96
|
||||
|
||||
|
||||
def test_system_links(rackmount):
|
||||
links = parse_system(rackmount(SYSTEM))["links"]
|
||||
assert links["Processors"] == SYSTEM + "/Processors"
|
||||
assert links["Memory"] == SYSTEM + "/Memory"
|
||||
assert links["SimpleStorage"] == SYSTEM + "/SimpleStorage"
|
||||
assert links["EthernetInterfaces"] == SYSTEM + "/EthernetInterfaces"
|
||||
assert links["LogServices"] == SYSTEM + "/LogServices"
|
||||
assert "Storage" not in links
|
||||
s = parse_system(rackmount(SYSTEM))
|
||||
assert s["chassis"] == ["/redfish/v1/Chassis/1U"]
|
||||
assert s["managed_by"] == ["/redfish/v1/Managers/BMC"]
|
||||
|
||||
|
||||
def test_system_placeholders_and_blanks_are_none():
|
||||
# Supermicro boards report the DMI defaults the board vendor never filled in.
|
||||
s = parse_system({
|
||||
"Id": "1",
|
||||
"Manufacturer": "Supermicro",
|
||||
"Model": "X10DRL-i",
|
||||
"SerialNumber": "0123456789",
|
||||
"SKU": "To Be Filled By O.E.M.",
|
||||
"HostName": "",
|
||||
"UUID": " ",
|
||||
"PartNumber": "Default string",
|
||||
})
|
||||
assert s["manufacturer"] == "Supermicro"
|
||||
assert s["model"] == "X10DRL-i"
|
||||
assert s["serial_number"] is None
|
||||
assert s["sku"] is None
|
||||
assert s["hostname"] is None
|
||||
assert s["uuid"] is None
|
||||
assert s["part_number"] is None
|
||||
assert s["processor_count"] is None
|
||||
assert s["memory_gib"] is None
|
||||
assert s["links"] == {}
|
||||
assert s["chassis"] == []
|
||||
|
||||
|
||||
def test_power_state(rackmount):
|
||||
assert parse_power_state(rackmount(SYSTEM)) == "On"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["On", "Off", "PoweringOn", "PoweringOff", "Paused"])
|
||||
def test_power_state_values(value):
|
||||
assert parse_power_state({"PowerState": value}) == value
|
||||
|
||||
|
||||
def test_power_state_missing():
|
||||
assert parse_power_state({}) is None
|
||||
assert parse_power_state({"PowerState": None}) is None
|
||||
|
||||
|
||||
def test_reset_action_inline_allowable_values(rackmount):
|
||||
reset = parse_reset_action(rackmount(SYSTEM))
|
||||
assert reset["target"] == SYSTEM + "/Actions/ComputerSystem.Reset"
|
||||
assert reset["allowed"] == [
|
||||
"On", "ForceOff", "GracefulShutdown", "GracefulRestart", "ForceRestart",
|
||||
"Nmi", "ForceOn", "PushPowerButton", "FullPowerCycle",
|
||||
]
|
||||
assert reset["action_info"] is None
|
||||
|
||||
|
||||
def test_reset_action_with_action_info(telemetry):
|
||||
reset = parse_reset_action(telemetry("/redfish/v1/Systems/1"))
|
||||
assert reset["target"] == "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset"
|
||||
# Values live in the ActionInfo resource, not inline: unknown until that is read.
|
||||
assert reset["allowed"] is None
|
||||
assert reset["action_info"] == "/redfish/v1/Systems/1/ResetActionInfo"
|
||||
|
||||
|
||||
def test_action_info(telemetry):
|
||||
allowed = parse_action_info(telemetry("/redfish/v1/Systems/1/ResetActionInfo"))
|
||||
assert allowed == ["On", "ForceOff", "GracefulShutdown", "GracefulRestart",
|
||||
"ForceRestart", "Nmi", "ForceOn", "PushPowerButton"]
|
||||
|
||||
|
||||
def test_action_info_other_parameter():
|
||||
info = {"Parameters": [{"Name": "ResetType", "AllowableValues": ["On"]},
|
||||
{"Name": "Delay", "AllowableValues": ["0", "5"]}]}
|
||||
assert parse_action_info(info, "Delay") == ["0", "5"]
|
||||
assert parse_action_info(info, "Missing") is None
|
||||
assert parse_action_info({}) is None
|
||||
|
||||
|
||||
def test_reset_action_manager(rackmount):
|
||||
reset = parse_reset_action(rackmount("/redfish/v1/Managers/BMC"), "#Manager.Reset")
|
||||
assert reset["target"] == "/redfish/v1/Managers/BMC/Actions/Manager.Reset"
|
||||
assert reset["allowed"] == ["ForceRestart", "GracefulRestart"]
|
||||
|
||||
|
||||
def test_reset_action_missing():
|
||||
assert parse_reset_action({}) == {"target": None, "allowed": None, "action_info": None}
|
||||
# Reset advertised without allowable values: allowed stays unknown, not empty.
|
||||
reset = parse_reset_action({"Actions": {"#ComputerSystem.Reset": {"target": "/x"}}})
|
||||
assert reset == {"target": "/x", "allowed": None, "action_info": None}
|
||||
Reference in new issue
Block a user