diff --git a/napalm_redfish/parsers/__init__.py b/napalm_redfish/parsers/__init__.py new file mode 100644 index 0000000..1399060 --- /dev/null +++ b/napalm_redfish/parsers/__init__.py @@ -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", +] diff --git a/napalm_redfish/parsers/accounts.py b/napalm_redfish/parsers/accounts.py new file mode 100644 index 0000000..14508d5 --- /dev/null +++ b/napalm_redfish/parsers/accounts.py @@ -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 diff --git a/napalm_redfish/parsers/common.py b/napalm_redfish/parsers/common.py new file mode 100644 index 0000000..7e6a567 --- /dev/null +++ b/napalm_redfish/parsers/common.py @@ -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 diff --git a/napalm_redfish/parsers/environment.py b/napalm_redfish/parsers/environment.py new file mode 100644 index 0000000..e2f2f95 --- /dev/null +++ b/napalm_redfish/parsers/environment.py @@ -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 diff --git a/napalm_redfish/parsers/firmware.py b/napalm_redfish/parsers/firmware.py new file mode 100644 index 0000000..1c008e1 --- /dev/null +++ b/napalm_redfish/parsers/firmware.py @@ -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"), + } diff --git a/napalm_redfish/parsers/inventory.py b/napalm_redfish/parsers/inventory.py new file mode 100644 index 0000000..6b7f4bb --- /dev/null +++ b/napalm_redfish/parsers/inventory.py @@ -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, + } diff --git a/napalm_redfish/parsers/logs.py b/napalm_redfish/parsers/logs.py new file mode 100644 index 0000000..dfe3ff9 --- /dev/null +++ b/napalm_redfish/parsers/logs.py @@ -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"), + } diff --git a/napalm_redfish/parsers/manager.py b/napalm_redfish/parsers/manager.py new file mode 100644 index 0000000..a9ac778 --- /dev/null +++ b/napalm_redfish/parsers/manager.py @@ -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, + }, + } diff --git a/napalm_redfish/parsers/service_root.py b/napalm_redfish/parsers/service_root.py new file mode 100644 index 0000000..2bf8f66 --- /dev/null +++ b/napalm_redfish/parsers/service_root.py @@ -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, + } diff --git a/napalm_redfish/parsers/system.py b/napalm_redfish/parsers/system.py new file mode 100644 index 0000000..753dbee --- /dev/null +++ b/napalm_redfish/parsers/system.py @@ -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 diff --git a/tests/parsers/test_accounts.py b/tests/parsers/test_accounts.py new file mode 100644 index 0000000..8b9349c --- /dev/null +++ b/tests/parsers/test_accounts.py @@ -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": []}, + } diff --git a/tests/parsers/test_environment.py b/tests/parsers/test_environment.py new file mode 100644 index 0000000..86f3b53 --- /dev/null +++ b/tests/parsers/test_environment.py @@ -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} diff --git a/tests/parsers/test_firmware.py b/tests/parsers/test_firmware.py new file mode 100644 index 0000000..ff08211 --- /dev/null +++ b/tests/parsers/test_firmware.py @@ -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"] == [] diff --git a/tests/parsers/test_inventory.py b/tests/parsers/test_inventory.py new file mode 100644 index 0000000..5ed9e72 --- /dev/null +++ b/tests/parsers/test_inventory.py @@ -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({})) diff --git a/tests/parsers/test_logs.py b/tests/parsers/test_logs.py new file mode 100644 index 0000000..50296ea --- /dev/null +++ b/tests/parsers/test_logs.py @@ -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 diff --git a/tests/parsers/test_manager.py b/tests/parsers/test_manager.py new file mode 100644 index 0000000..7052c26 --- /dev/null +++ b/tests/parsers/test_manager.py @@ -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": []} diff --git a/tests/parsers/test_service_root.py b/tests/parsers/test_service_root.py new file mode 100644 index 0000000..2be1313 --- /dev/null +++ b/tests/parsers/test_service_root.py @@ -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"] == {} diff --git a/tests/parsers/test_system.py b/tests/parsers/test_system.py new file mode 100644 index 0000000..4080f2c --- /dev/null +++ b/tests/parsers/test_system.py @@ -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}