diff --git a/containers/ironic/patches/0001-Add-LLDP-collect-for-DRAC-Redfish-inspection.patch b/containers/ironic/patches/0001-Add-LLDP-collect-for-DRAC-Redfish-inspection.patch new file mode 100644 index 000000000..5c1dfcee7 --- /dev/null +++ b/containers/ironic/patches/0001-Add-LLDP-collect-for-DRAC-Redfish-inspection.patch @@ -0,0 +1,327 @@ +From 813fb13714889a1997db52d48712dac2ab2dfb22 Mon Sep 17 00:00:00 2001 +From: Nidhi Rai +Date: Thu, 11 Dec 2025 19:59:04 +0530 +Subject: [PATCH] Add LLDP collect for DRAC Redfish inspection + +The implementation collects LLDP data from Dell OEM SwitchConnection endpoints and falls back to standard Redfish LLDP collection when Dell specific data is unavailable. + +Change-Id: If8b1ec059dbeb5a8b29f2b83b1a3e29bfa21bc1b +Signed-off-by: Nidhi Rai +--- + ironic/drivers/modules/drac/inspect.py | 81 +++++++- + .../unit/drivers/modules/drac/test_inspect.py | 188 ++++++++++++++++++ + 2 files changed, 267 insertions(+), 2 deletions(-) + +diff --git a/ironic/drivers/modules/drac/inspect.py b/ironic/drivers/modules/drac/inspect.py +index a880eeadd..001489d3b 100644 +--- a/ironic/drivers/modules/drac/inspect.py ++++ b/ironic/drivers/modules/drac/inspect.py +@@ -14,12 +14,12 @@ + """ + DRAC inspection interface + """ +- + from ironic.common import boot_modes + from ironic.drivers.modules.drac import utils as drac_utils + from ironic.drivers.modules import inspect_utils + from ironic.drivers.modules.redfish import inspect as redfish_inspect + from ironic.drivers.modules.redfish import utils as redfish_utils ++from oslo_log import log + + + _PXE_DEV_ENABLED_INTERFACES = [('PxeDev1EnDis', 'PxeDev1Interface'), +@@ -27,7 +27,7 @@ _PXE_DEV_ENABLED_INTERFACES = [('PxeDev1EnDis', 'PxeDev1Interface'), + ('PxeDev3EnDis', 'PxeDev3Interface'), + ('PxeDev4EnDis', 'PxeDev4Interface')] + _BIOS_ENABLED_VALUE = 'Enabled' +- ++LOG = log.getLogger(__name__) + + class DracRedfishInspect(redfish_inspect.RedfishInspect): + """iDRAC Redfish interface for inspection-related actions.""" +@@ -107,3 +107,80 @@ class DracRedfishInspect(redfish_inspect.RedfishInspect): + pxe_port_macs = [mac for mac in pxe_port_macs_list] + + return pxe_port_macs ++ ++ def _collect_lldp_data(self, task, system): ++ """Collect LLDP data using Dell OEM SwitchConnection endpoints. ++ ++ Dell iDRAC provides LLDP neighbor information through OEM ++ DellSwitchConnection endpoints. We return parsed LLDP data directly. ++ ++ :param task: A TaskManager instance ++ :param system: Sushy system object ++ :returns: Dict mapping interface names to parsed LLDP data ++ """ ++ parsed_lldp = {} ++ ++ try: ++ # Get Dell switch connection data ++ switch_data = self._get_dell_switch_connections(task) ++ ++ # Convert directly to parsed LLDP format ++ for connection in switch_data: ++ fqdd = connection.get('FQDD') ++ switch_mac = connection.get('SwitchConnectionID') ++ switch_port = connection.get('SwitchPortConnectionID') ++ ++ # Skip unconnected interfaces ++ if (not fqdd or not switch_mac or not switch_port ++ or switch_mac == 'No Link' or switch_port == 'No Link'): ++ continue ++ ++ parsed_lldp[fqdd] = { ++ 'switch_chassis_id': switch_mac, ++ 'switch_port_id': switch_port ++ } ++ ++ LOG.debug("Generated parsed LLDP data for %d interfaces", ++ len(parsed_lldp)) ++ ++ except Exception as e: ++ LOG.debug("Dell OEM LLDP collection failed, falling back to " ++ "standard: %s", e) ++ # Fallback to standard Redfish LLDP collection ++ return super(DracRedfishInspect, self)._collect_lldp_data( ++ task, system) ++ ++ return parsed_lldp ++ ++ def _get_dell_switch_connections(self, task): ++ """Fetch Dell switch connection data via OEM. ++ ++ :param task: A TaskManager instance ++ :returns: List of switch connection dictionaries ++ """ ++ system = redfish_utils.get_system(task.node) ++ ++ # Access Sushy's private connection object ++ try: ++ conn = system._conn ++ base_url = conn._url ++ except AttributeError as e: ++ LOG.debug("Failed to access Sushy connection object: %s", e) ++ return [] ++ ++ # Dell OEM endpoint for switch connections ++ # This URL structure is specific to Dell iDRAC Redfish implementation ++ switch_url = (f"{base_url}/redfish/v1/Systems/{system.identity}" ++ "/NetworkPorts/Oem/Dell/DellSwitchConnections") ++ ++ LOG.debug("Fetching Dell switch connections from: %s", switch_url) ++ ++ try: ++ response = conn.get(switch_url) ++ data = response.json() ++ members = data.get('Members', []) ++ LOG.debug("Retrieved %d Dell switch connections", len(members)) ++ return members ++ except Exception as e: ++ LOG.debug("Failed to get Dell switch connections: %s", e) ++ return [] +diff --git a/ironic/tests/unit/drivers/modules/drac/test_inspect.py b/ironic/tests/unit/drivers/modules/drac/test_inspect.py +index 41dfd8b33..4283656ad 100644 +--- a/ironic/tests/unit/drivers/modules/drac/test_inspect.py ++++ b/ironic/tests/unit/drivers/modules/drac/test_inspect.py +@@ -80,6 +80,25 @@ class DracRedfishInspectionTestCase(test_utils.BaseDracTest): + ] + return system_mock + ++ def _setup_lldp_system_mock(self, mock_get_system): ++ """System mock for LLDP tests.""" ++ system_mock = self.init_system_mock(mock_get_system.return_value) ++ system_mock.identity = 'System.Embedded.1' ++ return system_mock ++ ++ def _setup_dell_connection_mock(self, system_mock, url='https://bmc.example.com/redfish/v1'): ++ """Helper to setup Dell connection mock for LLDP tests.""" ++ mock_conn = mock.MagicMock() ++ mock_conn._url = url ++ system_mock._conn = mock_conn ++ return mock_conn ++ ++ def _create_switch_connections_response(self, members): ++ """Create a Mock response for Dell switch connections.""" ++ mock_response = mock.MagicMock() ++ mock_response.json.return_value = {'Members': members} ++ return mock_response ++ + def test_get_properties(self): + expected = redfish_utils.COMMON_PROPERTIES + driver = drac_inspect.DracRedfishInspect() +@@ -158,3 +177,172 @@ class DracRedfishInspectionTestCase(test_utils.BaseDracTest): + shared=True) as task: + return_value = task.driver.inspect._get_mac_address(task) + self.assertEqual(expected_value, return_value) ++ @mock.patch.object(redfish_utils, 'get_system', autospec=True) ++ def test_collect_lldp_data_successful_dell_oem(self, mock_get_system): ++ """Test successful LLDP data collection from Dell OEM endpoints.""" ++ system_mock = self._setup_lldp_system_mock(mock_get_system) ++ mock_conn = self._setup_dell_connection_mock(system_mock) ++ ++ # Mock the HTTP response with switch connections ++ members = [ ++ { ++ 'FQDD': 'NIC.Integrated.1-1-1', ++ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:ff', ++ 'SwitchPortConnectionID': 'Ethernet1/0/1' ++ }, ++ { ++ 'FQDD': 'NIC.Integrated.1-1-2', ++ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:gg', ++ 'SwitchPortConnectionID': 'Ethernet1/8' ++ } ++ ] ++ mock_response = self._create_switch_connections_response(members) ++ mock_conn.get.return_value = mock_response ++ ++ expected_lldp = { ++ 'NIC.Integrated.1-1-1': { ++ 'switch_chassis_id': 'aa:bb:cc:dd:ee:ff', ++ 'switch_port_id': 'Ethernet1/0/1' ++ }, ++ 'NIC.Integrated.1-1-2': { ++ 'switch_chassis_id': 'aa:bb:cc:dd:ee:gg', ++ 'switch_port_id': 'Ethernet1/8' ++ } ++ } ++ ++ with task_manager.acquire(self.context, self.node.uuid, ++ shared=True) as task: ++ result = task.driver.inspect._collect_lldp_data(task, system_mock) ++ self.assertEqual(expected_lldp, result) ++ ++ @mock.patch.object(redfish_inspect.RedfishInspect, '_collect_lldp_data', ++ autospec=True) ++ @mock.patch.object(drac_inspect.DracRedfishInspect, ++ '_get_dell_switch_connections', autospec=True) ++ @mock.patch.object(redfish_utils, 'get_system', autospec=True) ++ def test_collect_lldp_data_fallback_to_standard(self, mock_get_system, ++ mock_get_connections, ++ mock_super_collect): ++ """Test fallback to standard Redfish LLDP when Dell OEM fails.""" ++ system_mock = self._setup_lldp_system_mock(mock_get_system) ++ ++ # Mock _get_dell_switch_connections to raise an exception ++ mock_get_connections.side_effect = Exception("Dell OEM failed") ++ ++ # Mock fallback response ++ mock_super_collect.return_value = { ++ 'NIC.Integrated.1-1-1': { ++ 'switch_chassis_id': 'fallback_chassis', ++ 'switch_port_id': 'fallback_port' ++ } ++ } ++ ++ with task_manager.acquire(self.context, self.node.uuid, ++ shared=True) as task: ++ result = task.driver.inspect._collect_lldp_data(task, system_mock) ++ # Should return the fallback data ++ mock_super_collect.assert_called_once_with( ++ task.driver.inspect, task, system_mock) ++ self.assertEqual(mock_super_collect.return_value, result) ++ ++ @mock.patch.object(redfish_utils, 'get_system', autospec=True) ++ def test_collect_lldp_data_filters_no_link(self, mock_get_system): ++ """Test that 'No Link' connections are filtered out.""" ++ system_mock = self._setup_lldp_system_mock(mock_get_system) ++ mock_conn = self._setup_dell_connection_mock(system_mock) ++ ++ # Mock the HTTP response with mixed valid/invalid connections ++ members = [ ++ { ++ 'FQDD': 'NIC.Integrated.1-1-1', ++ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:ff', ++ 'SwitchPortConnectionID': 'Ethernet1/8' ++ }, ++ { ++ 'FQDD': 'NIC.Integrated.1-1-2', ++ 'SwitchConnectionID': 'No Link', ++ 'SwitchPortConnectionID': 'No Link' ++ }, ++ { ++ 'FQDD': None, ++ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:gg', ++ 'SwitchPortConnectionID': 'Ethernet1/8' ++ } ++ ] ++ mock_response = self._create_switch_connections_response(members) ++ mock_conn.get.return_value = mock_response ++ ++ expected_lldp = { ++ 'NIC.Integrated.1-1-1': { ++ 'switch_chassis_id': 'aa:bb:cc:dd:ee:ff', ++ 'switch_port_id': 'Ethernet1/8' ++ } ++ } ++ ++ with task_manager.acquire(self.context, self.node.uuid, ++ shared=True) as task: ++ result = task.driver.inspect._collect_lldp_data(task, system_mock) ++ self.assertEqual(expected_lldp, result) ++ ++ @mock.patch.object(redfish_utils, 'get_system', autospec=True) ++ def test_get_dell_switch_connections_success(self, mock_get_system): ++ """Test successful retrieval of Dell switch connections.""" ++ system_mock = self._setup_lldp_system_mock(mock_get_system) ++ mock_conn = self._setup_dell_connection_mock(system_mock) ++ ++ expected_members = [ ++ {'FQDD': 'NIC.Integrated.1-1-1', ++ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:ff'}, ++ {'FQDD': 'NIC.Integrated.1-1-2', ++ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:gg'} ++ ] ++ mock_response = self._create_switch_connections_response( ++ expected_members) ++ mock_conn.get.return_value = mock_response ++ ++ with task_manager.acquire(self.context, self.node.uuid, ++ shared=True) as task: ++ result = task.driver.inspect._get_dell_switch_connections(task) ++ self.assertEqual(expected_members, result) ++ ++ @mock.patch.object(redfish_utils, 'get_system', autospec=True) ++ def test_get_dell_switch_connections_attr_error(self, mock_get_system): ++ """Test AttributeError when accessing private attributes.""" ++ system_mock = self._setup_lldp_system_mock(mock_get_system) ++ ++ # Mock missing _conn attribute ++ delattr(system_mock, '_conn') ++ ++ with task_manager.acquire(self.context, self.node.uuid, ++ shared=True) as task: ++ result = task.driver.inspect._get_dell_switch_connections(task) ++ self.assertEqual([], result) ++ ++ @mock.patch.object(redfish_utils, 'get_system', autospec=True) ++ def test_get_dell_switch_connections_conn_error(self, mock_get_system): ++ """Test handling of connection errors during HTTP request.""" ++ system_mock = self._setup_lldp_system_mock(mock_get_system) ++ mock_conn = self._setup_dell_connection_mock(system_mock) ++ ++ # Mock connection failure ++ mock_conn.get.side_effect = Exception("HTTP connection failed") ++ ++ with task_manager.acquire(self.context, self.node.uuid, ++ shared=True) as task: ++ result = task.driver.inspect._get_dell_switch_connections(task) ++ self.assertEqual([], result) ++ ++ @mock.patch.object(redfish_utils, 'get_system', autospec=True) ++ def test_get_dell_switch_connections_empty_response(self, mock_get_system): ++ """Test handling of empty response from Dell OEM endpoint.""" ++ system_mock = self._setup_lldp_system_mock(mock_get_system) ++ mock_conn = self._setup_dell_connection_mock(system_mock) ++ ++ # Mock empty response ++ mock_response = self._create_switch_connections_response([]) ++ mock_conn.get.return_value = mock_response ++ ++ with task_manager.acquire(self.context, self.node.uuid, ++ shared=True) as task: ++ result = task.driver.inspect._get_dell_switch_connections(task) ++ self.assertEqual([], result) +-- +2.50.1 (Apple Git-155) diff --git a/containers/ironic/patches/0001-feat-skip-invalid-mac-addr-interfaces-in-redfish-ins.patch b/containers/ironic/patches/0001-feat-skip-invalid-mac-addr-interfaces-in-redfish-ins.patch new file mode 100644 index 000000000..b791948ab --- /dev/null +++ b/containers/ironic/patches/0001-feat-skip-invalid-mac-addr-interfaces-in-redfish-ins.patch @@ -0,0 +1,115 @@ +From 3220a0cc6617dfa026ff3febc0bdd4a227320759 Mon Sep 17 00:00:00 2001 +From: Doug Goldstein +Date: Tue, 13 Jan 2026 11:49:05 -0600 +Subject: [PATCH] feat: skip invalid mac addr interfaces in redfish inspect add + speed + +Refactor the redfish interfaces inspection by moving it to its own +function, but preserving the behavior of not having an interfaces key in +the inspection data when it is empty. If the MAC is invalid for the +interface, skip it instead of adding it. Added the speed_mbps field to +match agent inspection. + +Change-Id: I60cdf6e22b4ae4773e4497f0e0f10d795ef2cb6c +Signed-off-by: Doug Goldstein +--- + ironic/drivers/modules/redfish/inspect.py | 35 +++++++++++++++---- + .../drivers/modules/redfish/test_inspect.py | 8 +++-- + 2 files changed, 35 insertions(+), 8 deletions(-) + +diff --git a/ironic/drivers/modules/redfish/inspect.py b/ironic/drivers/modules/redfish/inspect.py +index 6f951f237..5ee052713 100644 +--- a/ironic/drivers/modules/redfish/inspect.py ++++ b/ironic/drivers/modules/redfish/inspect.py +@@ -14,6 +14,7 @@ Redfish Inspect Interface + """ + + from oslo_log import log ++from oslo_utils import netutils + from oslo_utils import units + import sushy + +@@ -141,12 +142,7 @@ class RedfishInspect(base.InspectInterface): + + inventory['disks'] = disks + +- if system.ethernet_interfaces and system.ethernet_interfaces.summary: +- inventory['interfaces'] = [] +- for eth in system.ethernet_interfaces.get_members(): +- iface = {'mac_address': eth.mac_address, +- 'name': eth.identity} +- inventory['interfaces'].append(iface) ++ inventory['interfaces'] = self._get_interface_info(task, system) + + pcie_devices = self._get_pcie_devices(system.pcie_devices) + if pcie_devices: +@@ -310,6 +306,33 @@ class RedfishInspect(base.InspectInterface): + """ + 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 +diff --git a/ironic/tests/unit/drivers/modules/redfish/test_inspect.py b/ironic/tests/unit/drivers/modules/redfish/test_inspect.py +index fd9c9e398..b3ba67c9f 100644 +--- a/ironic/tests/unit/drivers/modules/redfish/test_inspect.py ++++ b/ironic/tests/unit/drivers/modules/redfish/test_inspect.py +@@ -108,6 +108,7 @@ class RedfishInspectTestCase(db_base.DbTestCase): + spec=sushy.resources.system.ethernet_interface.EthernetInterface) + eth_interface_mock1.identity = 'NIC.Integrated.1-1' + eth_interface_mock1.mac_address = '00:11:22:33:44:55' ++ eth_interface_mock1.speed_mbps = 25000 + eth_interface_mock1.status.state = sushy.STATE_ENABLED + eth_interface_mock1.status.health = sushy.HEALTH_OK + +@@ -115,6 +116,7 @@ class RedfishInspectTestCase(db_base.DbTestCase): + spec=sushy.resources.system.ethernet_interface.EthernetInterface) + eth_interface_mock2.identity = 'NIC.Integrated.2-1' + eth_interface_mock2.mac_address = '66:77:88:99:AA:BB' ++ eth_interface_mock2.speed_mbps = 25000 + eth_interface_mock2.status.state = sushy.STATE_DISABLED + eth_interface_mock2.status.health = sushy.HEALTH_OK + +@@ -235,9 +237,11 @@ class RedfishInspectTestCase(db_base.DbTestCase): + system_vendor['system_uuid']) + + expected_interfaces = [{'mac_address': '00:11:22:33:44:55', +- 'name': 'NIC.Integrated.1-1'}, ++ 'name': 'NIC.Integrated.1-1', ++ 'speed_mbps': 25000}, + {'mac_address': '66:77:88:99:AA:BB', +- 'name': 'NIC.Integrated.2-1'}] ++ 'name': 'NIC.Integrated.2-1', ++ 'speed_mbps': 25000}] + self.assertEqual(expected_interfaces, + inventory['inventory']['interfaces']) + +-- +2.50.1 (Apple Git-155) diff --git a/containers/ironic/patches/series b/containers/ironic/patches/series index b5b024edf..6523ba358 100644 --- a/containers/ironic/patches/series +++ b/containers/ironic/patches/series @@ -3,3 +3,5 @@ 0001-Add-SKU-field-to-Redfish-inspection.patch 0001-fix-redfish-inspect-system-product-name.patch 0001-hack-for-scheduling-purposes-ignore-ports-with-categ.patch +0001-feat-skip-invalid-mac-addr-interfaces-in-redfish-ins.patch +0001-Add-LLDP-collect-for-DRAC-Redfish-inspection.patch