Merge "Add Redfish LLDP data collection support to the Redfish inspection interface."

This commit is contained in:
Zuul 2025-12-16 00:20:45 +00:00 committed by Gerrit Code Review
commit 6fec6712f8
4 changed files with 352 additions and 1 deletions

View file

@ -386,7 +386,10 @@ Out-Of-Band inspection
The ``redfish`` hardware type can inspect the bare metal node by querying
Redfish compatible BMC. This process is quick and reliable compared to the
way the ``agent`` hardware type works i.e. booting bare metal node into
the introspection ramdisk.
the introspection ramdisk. The inspection collects various hardware
information including LLDP (Link Layer Discovery Protocol) data when
available from the BMC, such as chassis ID, port ID, system name, system
description, system capabilities, and management addresses.
.. note::

View file

@ -190,6 +190,15 @@ class RedfishInspect(base.InspectInterface):
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,
@ -373,3 +382,136 @@ class RedfishInspect(base.InspectInterface):
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)

View file

@ -858,6 +858,203 @@ class RedfishInspectTestCase(db_base.DbTestCase):
self.assertEqual(expected_pcie_devices,
inventory['inventory']['pci_devices'])
@mock.patch.object(redfish_utils, 'get_enabled_macs', autospec=True)
@mock.patch.object(redfish_utils, 'get_system', autospec=True)
def test_collect_lldp_data_with_complete_lldp(
self, mock_get_system, mock_get_enabled_macs):
"""Test LLDP collection with complete LLDP data"""
system_mock = self.init_system_mock(mock_get_system.return_value)
# Mock NetworkAdapters and Ports with LLDP data
mock_chassis = mock.Mock()
mock_chassis.identity = 'System.Embedded.1'
# Mock Port with complete LLDP data
mock_lldp = mock.Mock()
mock_lldp.chassis_id = 'c4:7e:e0:e4:55:3f'
mock_lldp.chassis_id_subtype = mock.Mock(value='MacAddr')
mock_lldp.port_id = 'Ethernet1/8'
mock_lldp.port_id_subtype = mock.Mock(value='IfName')
mock_lldp.system_name = 'switch-01.example.com'
mock_lldp.system_description = 'Cisco IOS XE'
mock_lldp.system_capabilities = [
mock.Mock(value='Bridge'),
mock.Mock(value='Router')]
mock_lldp.management_address_ipv4 = '192.168.1.1'
mock_lldp.management_address_ipv6 = None
mock_lldp.management_address_mac = None
mock_lldp.management_vlan_id = 100
mock_ethernet = mock.Mock()
mock_ethernet.lldp_receive = mock_lldp
mock_ethernet.associated_mac_addresses = ['14:23:F3:F5:3B:A0']
mock_port = mock.Mock()
mock_port.identity = 'NIC.Slot.1-1'
mock_port.ethernet = mock_ethernet
mock_adapter = mock.Mock()
mock_adapter.identity = 'NIC.Slot.1'
mock_adapter.ports.get_members.return_value = [mock_port]
mock_chassis.network_adapters.get_members.return_value = [mock_adapter]
system_mock.chassis = [mock_chassis]
# Mock ethernet interface for mapping
mock_iface = mock.Mock()
mock_iface.identity = 'NIC.Slot.1-1-1'
mock_iface.mac_address = '14:23:F3:F5:3B:A0'
system_mock.ethernet_interfaces.get_members.return_value = [mock_iface]
with task_manager.acquire(self.context, self.node.uuid,
shared=True) as task:
task.driver.inspect.inspect_hardware(task)
inventory = inspect_utils.get_inspection_data(self.node, self.context)
# Verify parsed_lldp was collected
self.assertIn('parsed_lldp', inventory['plugin_data'])
parsed_lldp = inventory['plugin_data']['parsed_lldp']
# Should have one interface with LLDP data using Port ID as name
self.assertEqual(1, len(parsed_lldp))
self.assertIn('NIC.Slot.1-1', parsed_lldp)
# Verify parsed LLDP format
lldp_data = parsed_lldp['NIC.Slot.1-1']
self.assertIsInstance(lldp_data, dict)
self.assertIn('switch_chassis_id', lldp_data)
self.assertIn('switch_port_id', lldp_data)
self.assertIn('switch_system_name', lldp_data)
self.assertIn('switch_system_description', lldp_data)
# Verify expected values
self.assertEqual('c4:7e:e0:e4:55:3f', lldp_data['switch_chassis_id'])
self.assertEqual('Ethernet1/8', lldp_data['switch_port_id'])
self.assertEqual('switch-01.example.com',
lldp_data['switch_system_name'])
self.assertEqual('Cisco IOS XE',
lldp_data['switch_system_description'])
@mock.patch.object(redfish_utils, 'get_enabled_macs', autospec=True)
@mock.patch.object(redfish_utils, 'get_system', autospec=True)
def test_collect_lldp_data_empty_lldp_receive(
self, mock_get_system, mock_get_enabled_macs):
"""Test LLDP collection with empty LLDPReceive (Dell scenario)"""
system_mock = self.init_system_mock(mock_get_system.return_value)
mock_chassis = mock.Mock()
mock_chassis.identity = 'System.Embedded.1'
# Mock Port with None lldp_receive
mock_ethernet = mock.Mock()
mock_ethernet.lldp_receive = None
mock_port = mock.Mock()
mock_port.identity = 'NIC.Slot.1-1'
mock_port.ethernet = mock_ethernet
mock_adapter = mock.Mock()
mock_adapter.ports.get_members.return_value = [mock_port]
mock_chassis.network_adapters.get_members.return_value = [mock_adapter]
system_mock.chassis = [mock_chassis]
with task_manager.acquire(self.context, self.node.uuid,
shared=True) as task:
task.driver.inspect.inspect_hardware(task)
inventory = inspect_utils.get_inspection_data(self.node, self.context)
# parsed_lldp should not be in plugin_data when empty
self.assertNotIn('parsed_lldp', inventory['plugin_data'])
@mock.patch.object(redfish_utils, 'get_enabled_macs', autospec=True)
@mock.patch.object(redfish_utils, 'get_system', autospec=True)
def test_collect_lldp_data_no_network_adapters(
self, mock_get_system, mock_get_enabled_macs):
"""Test LLDP collection when NetworkAdapters not available"""
system_mock = self.init_system_mock(mock_get_system.return_value)
mock_chassis = mock.Mock()
mock_chassis.identity = 'System.Embedded.1'
# Raise exception when accessing network_adapters
mock_chassis.network_adapters.get_members.side_effect = (
Exception('Not found'))
system_mock.chassis = [mock_chassis]
with task_manager.acquire(self.context, self.node.uuid,
shared=True) as task:
task.driver.inspect.inspect_hardware(task)
inventory = inspect_utils.get_inspection_data(self.node, self.context)
# Should handle gracefully and not include parsed_lldp
self.assertNotIn('parsed_lldp', inventory['plugin_data'])
def test_convert_lldp_receive_to_dict_complete(self):
"""Test dict conversion with complete LLDP data"""
mock_lldp = mock.Mock()
mock_lldp.chassis_id = 'c4:7e:e0:e4:55:3f'
mock_lldp.chassis_id_subtype = mock.Mock(value='MacAddr')
mock_lldp.port_id = 'Ethernet1/8'
mock_lldp.port_id_subtype = mock.Mock(value='IfName')
mock_lldp.system_name = 'switch-01'
mock_lldp.system_description = 'Cisco IOS'
mock_lldp.system_capabilities = [mock.Mock(value='Bridge')]
mock_lldp.management_address_ipv4 = '192.168.1.1'
mock_lldp.management_address_ipv6 = None
mock_lldp.management_address_mac = None
mock_lldp.management_vlan_id = 100
with task_manager.acquire(self.context, self.node.uuid,
shared=True) as task:
lldp_dict = task.driver.inspect._convert_lldp_receive_to_dict(
mock_lldp)
# Verify dict format
self.assertIsInstance(lldp_dict, dict)
self.assertIn('switch_chassis_id', lldp_dict)
self.assertIn('switch_port_id', lldp_dict)
self.assertIn('switch_system_name', lldp_dict)
self.assertIn('switch_system_description', lldp_dict)
self.assertIn('switch_vlan_id', lldp_dict)
# Verify expected values
self.assertEqual('c4:7e:e0:e4:55:3f', lldp_dict['switch_chassis_id'])
self.assertEqual('Ethernet1/8', lldp_dict['switch_port_id'])
self.assertEqual('switch-01', lldp_dict['switch_system_name'])
self.assertEqual('Cisco IOS', lldp_dict['switch_system_description'])
self.assertEqual(100, lldp_dict['switch_vlan_id'])
def test_convert_lldp_receive_to_dict_minimal(self):
"""Test dict conversion with minimal LLDP data"""
mock_lldp = mock.Mock()
mock_lldp.chassis_id = 'aa:bb:cc:dd:ee:ff'
mock_lldp.chassis_id_subtype = None
mock_lldp.port_id = 'port-1'
mock_lldp.port_id_subtype = None
mock_lldp.system_name = None
mock_lldp.system_description = None
mock_lldp.system_capabilities = None
mock_lldp.management_address_ipv4 = None
mock_lldp.management_address_ipv6 = None
mock_lldp.management_address_mac = None
mock_lldp.management_vlan_id = None
with task_manager.acquire(self.context, self.node.uuid,
shared=True) as task:
lldp_dict = task.driver.inspect._convert_lldp_receive_to_dict(
mock_lldp)
# Should have Chassis ID and Port ID only
self.assertEqual(2, len(lldp_dict))
self.assertIn('switch_chassis_id', lldp_dict)
self.assertIn('switch_port_id', lldp_dict)
self.assertEqual('aa:bb:cc:dd:ee:ff', lldp_dict['switch_chassis_id'])
self.assertEqual('port-1', lldp_dict['switch_port_id'])
class ContinueInspectionTestCase(db_base.DbTestCase):
def setUp(self):

View file

@ -0,0 +1,9 @@
---
features:
- |
Adds Redfish LLDP data collection support to the Redfish inspection interface.
This enables collecting Link Layer Discovery Protocol (LLDP) data from
Redfish-compliant hardware during the inspection process. The collected
data includes chassis ID, port ID, system name, system description,
system capabilities, and management addresses, formatted as TLVs
compatible with Ironic Python Agent inspection hooks and rules.