# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
Redfish Inspect Interface
"""
from oslo_log import log
from oslo_utils import netutils
from oslo_utils import units
import sushy
from ironic.common import boot_modes
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common.inspection_rules import engine
from ironic.common import states
from ironic.common import utils
from ironic.conf import CONF
from ironic.drivers import base
from ironic.drivers.modules import inspect_utils
from ironic.drivers.modules.redfish import utils as redfish_utils
from ironic.drivers import utils as drivers_utils
LOG = log.getLogger(__name__)
PROCESSOR_INSTRUCTION_SET_MAP = {
sushy.InstructionSet.ARM_A32: 'arm',
sushy.InstructionSet.ARM_A64: 'aarch64',
sushy.InstructionSet.IA_64: 'ia64',
sushy.InstructionSet.MIPS32: 'mips',
sushy.InstructionSet.MIPS64: 'mips64',
sushy.InstructionSet.OEM: None,
sushy.InstructionSet.X86: 'i686',
sushy.InstructionSet.X86_64: 'x86_64'
}
BOOT_MODE_MAP = {
sushy.BOOT_SOURCE_MODE_UEFI: boot_modes.UEFI,
sushy.BOOT_SOURCE_MODE_BIOS: boot_modes.LEGACY_BIOS
}
[docs]
class RedfishInspect(base.InspectInterface):
def __init__(self):
super().__init__()
enabled_hooks = [x.strip()
for x in CONF.redfish.inspection_hooks.split(',')
if x.strip()]
self.hooks = inspect_utils.validate_inspection_hooks("redfish",
enabled_hooks)
[docs]
def get_properties(self):
"""Return the properties of the interface.
:returns: dictionary of <property name>:<property description> entries.
"""
return redfish_utils.COMMON_PROPERTIES.copy()
[docs]
def validate(self, task):
"""Validate the driver-specific Node deployment info.
This method validates whether the 'driver_info' properties of
the task's node contains the required information for this
interface to function.
This method is often executed synchronously in API requests, so it
should not conduct long-running checks.
:param task: A TaskManager instance containing the node to act on.
:raises: InvalidParameterValue on malformed parameter(s)
:raises: MissingParameterValue on missing parameter(s)
"""
redfish_utils.parse_driver_info(task.node)
[docs]
def inspect_hardware(self, task):
"""Inspect hardware to get the hardware properties.
Inspects hardware to get the essential properties.
It fails if any of the essential properties
are not received from the node.
:param task: a TaskManager instance.
:raises: HardwareInspectionFailure if essential properties
could not be retrieved successfully.
:returns: The resulting state of inspection.
"""
system = redfish_utils.get_system(task.node)
# get the essential properties and update the node properties
# with it.
inspected_properties = task.node.properties
inventory = {}
if system.memory_summary and system.memory_summary.size_gib:
memory = system.memory_summary.size_gib * units.Ki
inspected_properties['memory_mb'] = memory
inventory['memory'] = {'physical_mb': memory}
# match the inventory data of ironic-inspector / ironic-python-agent
# to make existing inspection hooks and rules work by defaulting
# the values
inventory['cpu'] = {
'count': 0,
'architecture': '',
}
proc_info = self._get_processor_info(task, system)
inventory['cpu'].update(proc_info)
# TODO(etingof): should we respect root device hints here?
local_gb = self._detect_local_gb(task, system)
if local_gb:
inspected_properties['local_gb'] = str(local_gb)
else:
LOG.warning("Could not provide a valid storage size configured "
"for node %(node)s. Assuming this is a disk-less node",
{'node': task.node.uuid})
inspected_properties['local_gb'] = '0'
if storages := system.storage or system.simple_storage:
disks = list()
for storage in storages.get_members():
drives = storage.drives if hasattr(
storage, 'drives') else storage.devices
for drive in drives:
disk = {}
disk['name'] = drive.name
disk['size'] = drive.capacity_bytes
disks.append(disk)
inventory['disks'] = disks
storage_controllers = self._get_storage_controllers(task, system)
if storage_controllers:
inventory['storage_controllers'] = storage_controllers
inventory['interfaces'] = self._get_interface_info(task, system)
pcie_devices = self._get_pcie_devices(system.pcie_devices)
if pcie_devices:
inventory['pci_devices'] = pcie_devices
system_vendor = {}
if system.model:
system_vendor['product_name'] = str(system.model)
if system.serial_number:
system_vendor['serial_number'] = str(system.serial_number)
if system.manufacturer:
system_vendor['manufacturer'] = str(system.manufacturer)
if system.uuid:
system_vendor['system_uuid'] = str(system.uuid)
if system_vendor:
inventory['system_vendor'] = system_vendor
if system.boot.mode:
if not drivers_utils.get_node_capability(task.node, 'boot_mode'):
capabilities = utils.get_updated_capabilities(
inspected_properties.get('capabilities', ''),
{'boot_mode': BOOT_MODE_MAP[system.boot.mode]})
inspected_properties['capabilities'] = capabilities
inventory['boot'] = {'current_boot_mode':
BOOT_MODE_MAP[system.boot.mode]}
self._create_ports(task, system)
pxe_port_macs = self._get_pxe_port_macs(task)
# existing data format only allows one mac so use that for now
if pxe_port_macs:
inventory['boot']['pxe_interface'] = pxe_port_macs[0]
plugin_data = {}
# Collect LLDP data from Redfish NetworkAdapter Ports
# This method can be overridden by vendor-specific implementations
lldp_raw_data = self._collect_lldp_data(task, system)
if lldp_raw_data:
plugin_data['parsed_lldp'] = lldp_raw_data
LOG.info('Collected LLDP data for %(count)d interface(s) on '
'node %(node)s',
{'count': len(lldp_raw_data), 'node': task.node.uuid})
inspect_utils.run_inspection_hooks(task, inventory, plugin_data,
self.hooks, None)
inspect_utils.store_inspection_data(task.node,
inventory,
plugin_data,
task.context)
engine.apply_rules(task, inventory, plugin_data, 'main')
valid_keys = self.ESSENTIAL_PROPERTIES
missing_keys = valid_keys - set(inspected_properties)
if missing_keys:
error = (_('Failed to discover the following properties: '
'%(missing_keys)s on node %(node)s') %
{'missing_keys': ', '.join(missing_keys),
'node': task.node.uuid})
raise exception.HardwareInspectionFailure(error=error)
task.node.properties = inspected_properties
task.node.save()
LOG.debug("Node properties for %(node)s are updated as "
"%(properties)s", {'properties': inspected_properties,
'node': task.node.uuid})
return states.MANAGEABLE
def _create_ports(self, task, system):
enabled_macs = redfish_utils.get_enabled_macs(task, system)
if enabled_macs:
inspect_utils.create_ports_if_not_exist(task, list(enabled_macs))
else:
LOG.warning("Not attempting to create any port as no NICs "
"were discovered in 'enabled' state for node "
"%(node)s: %(mac_data)s",
{'mac_data': enabled_macs, 'node': task.node.uuid})
def _detect_local_gb(self, task, system):
simple_storage_size = 0
try:
LOG.debug("Attempting to discover system simple storage size for "
"node %(node)s", {'node': task.node.uuid})
if (system.simple_storage
and system.simple_storage.disks_sizes_bytes):
simple_storage_size = [
size for size in system.simple_storage.disks_sizes_bytes
if size >= 4 * units.Gi
] or [0]
simple_storage_size = simple_storage_size[0]
except sushy.exceptions.SushyError as ex:
LOG.debug("No simple storage information discovered "
"for node %(node)s: %(err)s", {'node': task.node.uuid,
'err': ex})
storage_size = 0
try:
LOG.debug("Attempting to discover system storage volume size for "
"node %(node)s", {'node': task.node.uuid})
if system.storage and system.storage.volumes_sizes_bytes:
storage_size = [
size for size in system.storage.volumes_sizes_bytes
if size >= 4 * units.Gi
] or [0]
storage_size = storage_size[0]
except sushy.exceptions.SushyError as ex:
LOG.debug("No storage volume information discovered "
"for node %(node)s: %(err)s", {'node': task.node.uuid,
'err': ex})
try:
if not storage_size:
LOG.debug("Attempting to discover system storage drive size "
"for node %(node)s", {'node': task.node.uuid})
if system.storage and system.storage.drives_sizes_bytes:
storage_size = [
size for size in system.storage.drives_sizes_bytes
if size >= 4 * units.Gi
] or [0]
storage_size = storage_size[0]
except sushy.exceptions.SushyError as ex:
LOG.debug("No storage drive information discovered "
"for node %(node)s: %(err)s", {'node': task.node.uuid,
'err': ex})
# NOTE(etingof): pick the smallest disk larger than 4G among available
if simple_storage_size and storage_size:
local_gb = min(simple_storage_size, storage_size)
else:
local_gb = max(simple_storage_size, storage_size)
# Note(deray): Convert the received size to GiB and reduce the
# value by 1 GB as consumers like Ironic requires the ``local_gb``
# to be returned 1 less than actual size.
return max(0, int(local_gb / units.Gi - 1))
def _get_pxe_port_macs(self, task):
"""Get a list of PXE port MAC addresses.
:param task: a TaskManager instance.
:returns: Returns list of PXE port MAC addresses.
If cannot be determined, returns None.
"""
return None
def _get_interface_info(self, task, system):
"""Extract ethernet interface info."""
ret = []
if not system.ethernet_interfaces:
return ret
for eth in system.ethernet_interfaces.get_members():
if not netutils.is_valid_mac(eth.mac_address):
LOG.warning(_("Ignoring NIC address '%(address)s' for "
"interface %(inf)s on node %(node)s because it "
"is not a valid MAC"),
{'address': eth.mac_address,
'inf': eth.identity,
'node': task.node.uuid})
continue
intf = {
'mac_address': eth.mac_address,
'name': eth.identity
}
try:
intf['speed_mbps'] = int(eth.speed_mbps)
except Exception:
pass
ret.append(intf)
return ret
def _get_processor_info(self, task, system):
# NOTE(JayF): Checking truthiness here is better than checking for None
# because if we have an empty list, we'll raise a
# ValueError.
cpu = {}
if not system.processors:
return cpu
if system.processors.summary:
cpu['count'], _ = system.processors.summary
processor = system.processors.get_members()[0]
if processor.model is not None:
cpu['model_name'] = str(processor.model)
if processor.max_speed_mhz is not None:
cpu['frequency'] = processor.max_speed_mhz
cpu['architecture'] = PROCESSOR_INSTRUCTION_SET_MAP.get(
processor.instruction_set) or ''
return cpu
def _get_storage_controllers(self, task, system):
"""Extract storage controller and drive information.
Collects storage information from all Redfish Storage resources
including their sub-controllers and drives.
:param task: a TaskManager instance.
:param system: Sushy system object.
:returns: List of storage controller dictionaries.
"""
controllers = []
if not system.storage:
return controllers
try:
members = system.storage.get_members()
except sushy.exceptions.SushyError as ex:
LOG.debug('Failed to get storage members for node %(node)s: '
'%(error)s', {'node': task.node.uuid, 'error': ex})
return controllers
for storage in members:
controller = self._parse_storage_controller(task, storage)
controllers.append(controller)
return controllers
@staticmethod
def _enum_to_str(value):
"""Convert an enum value to string, or return as-is."""
return value.value if hasattr(value, 'value') else value
@staticmethod
def _status_to_dict(status):
"""Convert a Sushy StatusField to a serializable dict."""
if not status:
return None
result = {}
if hasattr(status, 'health') and status.health is not None:
result['health'] = RedfishInspect._enum_to_str(status.health)
if hasattr(status, 'state') and status.state is not None:
result['state'] = RedfishInspect._enum_to_str(status.state)
if (hasattr(status, 'health_rollup')
and status.health_rollup is not None):
result['health_rollup'] = RedfishInspect._enum_to_str(
status.health_rollup)
return result or None
def _parse_storage_controller(self, task, storage):
"""Parse a single storage resource.
:param task: a TaskManager instance.
:param storage: Sushy Storage object.
:returns: Dictionary with storage controller info.
"""
controller = {
'id': str(storage.identity) if storage.identity else '',
'name': storage.name if hasattr(storage, 'name') else None,
}
# Collect sub-controllers from deprecated StorageControllers list
sub_controllers = []
try:
sc_list = storage.storage_controllers or []
except (sushy.exceptions.SushyError, TypeError):
sc_list = []
for sc in sc_list:
try:
sc_info = {
'member_id': getattr(sc, 'member_id', None),
'name': getattr(sc, 'name', None),
'raid_types': [
rt.value for rt in
(getattr(sc, 'raid_types', None) or [])],
'speed_gbps': getattr(sc, 'speed_gbps', None),
'controller_protocols': [
p.value for p in
(getattr(sc, 'controller_protocols', None)
or [])],
'device_protocols': [
p.value for p in
(getattr(sc, 'device_protocols', None)
or [])],
}
status = getattr(sc, 'status', None)
if status:
sc_info['status'] = self._status_to_dict(status)
sub_controllers.append(sc_info)
except (AttributeError, TypeError,
sushy.exceptions.SushyError):
LOG.debug('Failed to parse sub-controller on node '
'%(node)s', {'node': task.node.uuid})
controller['storage_controllers'] = sub_controllers
# Collect drives
drives = []
if hasattr(storage, 'drives'):
for drive in storage.drives:
try:
drive_info = {
'name': drive.name,
'size': drive.capacity_bytes,
'id': getattr(drive, 'identity', None),
'media_type': getattr(drive, 'media_type',
None),
'serial_number': getattr(drive, 'serial_number',
None),
'manufacturer': getattr(drive, 'manufacturer',
None),
'model': getattr(drive, 'model', None),
'revision': getattr(drive, 'revision', None),
}
protocol = getattr(drive, 'protocol', None)
if protocol:
drive_info['protocol'] = self._enum_to_str(
protocol)
status = getattr(drive, 'status', None)
if status:
drive_info['status'] = self._status_to_dict(
status)
drives.append(drive_info)
except (AttributeError, TypeError,
sushy.exceptions.SushyError):
LOG.debug('Failed to parse drive on node %(node)s',
{'node': task.node.uuid})
controller['drives'] = drives
return controller
def _get_pcie_devices(self, pcie_devices_collection):
"""Extract PCIe device information from Redfish collection.
:param pcie_devices_collection: Redfish PCIe devices collection
:returns: List of PCIe device dictionaries
"""
# Return empty list if collection is None
if pcie_devices_collection is None:
return []
device_list = []
# Process each PCIe device
for pcie_device in pcie_devices_collection.get_members():
# Skip devices that don't have functions
if (not hasattr(pcie_device, 'pcie_functions')
or not pcie_device.pcie_functions):
continue
# Process each function on this device
for pcie_function in pcie_device.pcie_functions.get_members():
function_info = self._extract_function_info(pcie_function)
if function_info:
device_list.append(function_info)
return device_list
def _extract_function_info(self, function):
"""Extract information from a PCIe function.
:param function: PCIe function object
:returns: Dictionary with function attributes
"""
info = {}
# Naming them same as in IPA for compatibility
# IPA has extra bus and numa_node_id which BMC doesn't have.
if function.device_class is not None:
info['class'] = str(function.device_class)
if function.device_id is not None:
info['product_id'] = function.device_id
if function.vendor_id is not None:
info['vendor_id'] = function.vendor_id
if function.subsystem_id is not None:
info['subsystem_id'] = function.subsystem_id
if function.subsystem_vendor_id is not None:
info['subsystem_vendor_id'] = function.subsystem_vendor_id
if function.revision_id is not None:
info['revision'] = function.revision_id
return info
def _collect_lldp_data(self, task, system):
"""Collect LLDP data from Redfish NetworkAdapter Ports.
This method can be overridden by vendor-specific implementations
to provide alternative LLDP data sources (e.g., Dell OEM endpoints).
Default implementation uses standard Redfish LLDP data from
Port.Ethernet.LLDPReceive via Sushy NetworkAdapter/Port resources.
:param task: A TaskManager instance
:param system: Sushy system object
:returns: Dict mapping interface names to parsed LLDP data
Format: {'interface_name': {'switch_chassis_id': '..',
'switch_port_id': '..'}}
"""
parsed_lldp = {}
try:
# Check if chassis exists
if not system.chassis:
return parsed_lldp
# Process each chassis
for chassis in system.chassis:
try:
# Get NetworkAdapters collection
network_adapters = (
chassis.network_adapters.get_members())
except sushy.exceptions.SushyError as ex:
LOG.debug('Failed to get network adapters for chassis '
'on node %(node)s: %(error)s',
{'node': task.node.uuid, 'error': ex})
continue
# Process each NetworkAdapter
for adapter in network_adapters:
try:
# Get Ports collection using Sushy
ports = adapter.ports.get_members()
except sushy.exceptions.SushyError as ex:
LOG.debug('Failed to get ports for adapter '
'on node %(node)s: %(error)s',
{'node': task.node.uuid, 'error': ex})
continue
# Process each Port
for port in ports:
try:
# Check if LLDP data exists using Sushy
if (not port.ethernet
or not port.ethernet.lldp_receive):
continue
lldp_receive = port.ethernet.lldp_receive
# Convert directly to parsed LLDP format
lldp_dict = self._convert_lldp_receive_to_dict(
lldp_receive)
if not lldp_dict:
continue
# Use port identity directly as interface name
if port.identity:
parsed_lldp[port.identity] = lldp_dict
except Exception as e:
LOG.debug('Failed to process LLDP data for port '
'%(port)s on node %(node)s: %(error)s',
{'port': port.identity,
'node': task.node.uuid, 'error': e})
continue
except Exception as e:
LOG.warning('Failed to collect standard Redfish LLDP data for '
'node %(node)s: %(error)s',
{'node': task.node.uuid, 'error': e})
return parsed_lldp
def _convert_lldp_receive_to_dict(self, lldp_receive):
"""Convert Sushy LLDPReceive object directly to parsed dict format.
:param lldp_receive: Sushy LLDPReceiveField object or dict
:returns: Dict with parsed LLDP data or None
"""
lldp_dict = {}
# Chassis ID
chassis_id = self._get_lldp_value(lldp_receive, 'chassis_id',
'ChassisId')
if chassis_id:
lldp_dict['switch_chassis_id'] = chassis_id
# Port ID
port_id = self._get_lldp_value(lldp_receive, 'port_id', 'PortId')
if port_id:
lldp_dict['switch_port_id'] = port_id
# System Name
system_name = self._get_lldp_value(lldp_receive, 'system_name',
'SystemName')
if system_name:
lldp_dict['switch_system_name'] = system_name
# System Description
system_description = self._get_lldp_value(lldp_receive,
'system_description',
'SystemDescription')
if system_description:
lldp_dict['switch_system_description'] = system_description
# Management VLAN ID
vlan_id = self._get_lldp_value(lldp_receive, 'management_vlan_id',
'ManagementVlanId')
if vlan_id:
lldp_dict['switch_vlan_id'] = vlan_id
return lldp_dict if lldp_dict else None
def _get_lldp_value(self, lldp_receive, attr_name, json_key):
"""Get value from LLDP receive, handling both dict and object.
:param lldp_receive: LLDP data (Sushy object or dict)
:param attr_name: Sushy attribute name
:param json_key: JSON property name (required)
:returns: The value or None
"""
# Being defensive to handle both Sushy object and dict
if isinstance(lldp_receive, dict):
return lldp_receive.get(json_key)
else:
return getattr(lldp_receive, attr_name, None)