From 57dd66c9d15707ee121efdcea6e9b94d46d0914b Mon Sep 17 00:00:00 2001 From: haseeb Date: Mon, 17 Nov 2025 19:52:27 +0530 Subject: [PATCH] adding nautobot-sync post-inspection hook --- components/ironic/values.yaml | 8 +- .../ironic_understack/conf.py | 13 +- .../ironic_understack/nautobot_sync.py | 316 +++++++++++++++++ .../tests/test_nautobot_sync.py | 335 ++++++++++++++++++ python/ironic-understack/pyproject.toml | 4 + 5 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 python/ironic-understack/ironic_understack/nautobot_sync.py create mode 100644 python/ironic-understack/ironic_understack/tests/test_nautobot_sync.py diff --git a/components/ironic/values.yaml b/components/ironic/values.yaml index a0935fc8b..a249a70fc 100644 --- a/components/ironic/values.yaml +++ b/components/ironic/values.yaml @@ -86,13 +86,16 @@ conf: rabbit_ha_queues: true pxe: loader_file_paths: "snponly.efi:/usr/lib/ipxe/snponly.efi" + redfish: + # Redfish inspection hooks - run hooks for redfish-based inspection + inspection_hooks: "$default_inspection_hooks,nautobot-sync" inspector: extra_kernel_params: ipa-collect-lldp=1 # Agent inspection hooks - ports hook removed to prevent port manipulation during agent inspection # Default hooks include: ramdisk-error,validate-interfaces,ports,architecture # We override to exclude 'ports' from the default hooks default_hooks: "ramdisk-error,validate-interfaces,architecture" - hooks: "$default_hooks,pci-devices,parse-lldp,local-link-connection,resource-class" + hooks: "$default_hooks,pci-devices,parse-lldp,local-link-connection,resource-class,nautobot-sync" # enable sensors and metrics for redfish metrics - https://docs.openstack.org/ironic/latest/admin/drivers/redfish/metrics.html sensor_data: send_sensor_data: true @@ -239,6 +242,9 @@ pod: sources: - secret: name: ironic-ks-etc + - secret: + name: ironic-nautobot-token + optional: true ironic_api: ironic_api: volumeMounts: diff --git a/python/ironic-understack/ironic_understack/conf.py b/python/ironic-understack/ironic_understack/conf.py index d41bbb234..9bc48e15c 100644 --- a/python/ironic-understack/ironic_understack/conf.py +++ b/python/ironic-understack/ironic_understack/conf.py @@ -10,7 +10,18 @@ def setup_conf(): "device_types_dir", help="directory storing Device Type description YAML files", default="/var/lib/understack/device-types", - ) + ), + cfg.StrOpt( + "nautobot_url", + help="Nautobot API URL", + default=None, + ), + cfg.StrOpt( + "nautobot_token", + help="Nautobot API token", + secret=True, + default=None, + ), ] cfg.CONF.register_group(grp) cfg.CONF.register_opts(opts, group=grp) diff --git a/python/ironic-understack/ironic_understack/nautobot_sync.py b/python/ironic-understack/ironic_understack/nautobot_sync.py new file mode 100644 index 000000000..64f97fce9 --- /dev/null +++ b/python/ironic-understack/ironic_understack/nautobot_sync.py @@ -0,0 +1,316 @@ +"""Ironic inspection hook to sync device information to Nautobot.""" + +import pynautobot +from ironic import objects +from ironic.drivers.modules.inspector.hooks import base +from oslo_log import log as logging + +from ironic_understack.conf import CONF + +LOG = logging.getLogger(__name__) + + +class NautobotSyncHook(base.InspectionHook): + """Hook to sync discovered device information to Nautobot.""" + + # Run after port information has been enriched with BIOS names and LLDP data + dependencies = ["update-baremetal-port", "port-bios-name"] + + def __call__(self, task, inventory, plugin_data): + """Sync device inventory to Nautobot. + + :param task: Ironic task context containing node and driver info + :param inventory: Hardware inventory dict from inspection + :param plugin_data: Shared data dict between hooks + """ + try: + nautobot_url = CONF.ironic_understack.nautobot_url + nautobot_token = CONF.ironic_understack.nautobot_token + + if not nautobot_url or not nautobot_token: + LOG.warning( + "Nautobot URL or token not configured, skipping sync for node %s", + task.node.uuid, + ) + return + + # Initialize Nautobot client + nautobot = pynautobot.api(url=nautobot_url, token=nautobot_token) + + # Extract device information from inventory + device_data = self._extract_device_data(task, inventory) + + # Sync to Nautobot + self._sync_to_nautobot(nautobot, device_data, task.node) + + LOG.info( + "Successfully synced device information to Nautobot for node %s", + task.node.uuid, + ) + + except (KeyError, ValueError, TypeError) as e: + msg = ( + f"Failed to extract device information from inventory for node " + f"{task.node.uuid}: {e}" + ) + LOG.error(msg) + # Don't fail inspection, just log the error + except Exception as e: + msg = f"Failed to sync device to Nautobot for node {task.node.uuid}: {e}" + LOG.error(msg) + # Don't fail inspection, just log the error + + def _extract_device_data(self, task, inventory): + """Extract relevant device data from inventory and baremetal ports.""" + # Use task.node properties directly - this is the source of truth + data = { + "uuid": task.node.uuid, + "name": task.node.name, + "properties": task.node.properties, + "driver_info": task.node.driver_info, + } + + # Extract interface information from baremetal ports + # These ports have been enriched by + # update-baremetal-port and port-bios-name hooks + interfaces = [] + try: + ports = objects.Port.list_by_node_id(task.context, task.node.id) + for port in ports: + interface_data = { + "mac_address": port.address, + "name": port.name, + "bios_name": port.extra.get("bios_name"), + "pxe_enabled": port.pxe_enabled, + } + + # local_link_connection info from update-baremetal-port hook + if port.local_link_connection: + interface_data["switch_id"] = port.local_link_connection.get( + "switch_id" + ) + interface_data["switch_info"] = port.local_link_connection.get( + "switch_info" + ) + interface_data["port_id"] = port.local_link_connection.get( + "port_id" + ) + + # Add physical_network (VLAN group) if available + if port.physical_network: + interface_data["physical_network"] = port.physical_network + + interfaces.append(interface_data) + + LOG.debug( + "Extracted %d interfaces for node %s", len(interfaces), task.node.uuid + ) + except Exception as e: + LOG.warning( + "Failed to extract interface data from ports for node %s: %s", + task.node.uuid, + e, + ) + + data["interfaces"] = interfaces + + return data + + def _sync_to_nautobot(self, nautobot, device_data, node): + """Sync device data to Nautobot.""" + node_uuid = device_data.get("uuid") + if not node_uuid: + LOG.warning("Node has no UUID, cannot sync to Nautobot") + return + + # Find device in Nautobot by UUID (Nautobot device ID = Ironic node UUID) + device = self._find_device(nautobot, node_uuid) + + if not device: + LOG.warning( + "Device with UUID %s not found in Nautobot. " + "Device must be pre-created in Nautobot before inspection.", + node_uuid, + ) + return + + LOG.info("Found device %s in Nautobot, syncing interfaces", node_uuid) + + # Sync interfaces to Nautobot + self._sync_interfaces(nautobot, device, device_data) + + def _find_device(self, nautobot, device_uuid): + """Find device in Nautobot by UUID. + + In Nautobot, the device ID is the same as the Ironic node UUID. + """ + try: + device = nautobot.dcim.devices.get(device_uuid) + if device: + LOG.info("Found device %s (%s) in Nautobot", device.name, device.id) + return device + except Exception: + LOG.exception( + "Error querying Nautobot for device with UUID %s", device_uuid + ) + return None + + def _sync_interfaces(self, nautobot, device, device_data): + """Sync interface information to Nautobot.""" + for interface_data in device_data.get("interfaces", []): + try: + self._sync_interface(nautobot, device, interface_data) + except Exception as e: + LOG.error( + "Failed to sync interface %s for device %s: %s", + interface_data.get("mac_address"), + device_data.get("uuid"), + e, + ) + + def _sync_interface(self, nautobot, device, interface_data): + """Sync a single interface to Nautobot.""" + mac_address = interface_data.get("mac_address") + if not mac_address: + LOG.warning("Interface missing MAC address, skipping") + return + + bios_name = interface_data.get("bios_name") + if not bios_name: + LOG.debug("Interface %s has no BIOS name, skipping", mac_address) + return + + # Find or create the interface in Nautobot + nautobot_interface = self._find_or_create_interface( + nautobot, device, interface_data + ) + + # Connect interface to switch if we have LLDP data + if interface_data.get("switch_id") and interface_data.get("port_id"): + self._connect_interface_to_switch( + nautobot, nautobot_interface, interface_data + ) + + def _find_or_create_interface(self, nautobot, device, interface_data): + """Find or create an interface in Nautobot.""" + bios_name = interface_data["bios_name"] + mac_address = interface_data["mac_address"] + + # Try to find existing interface by device and name + try: + interface = nautobot.dcim.interfaces.get( + device_id=device.id, name=bios_name + ) + if interface: + LOG.info( + "Found existing interface %s (%s) in Nautobot", + bios_name, + interface.id, + ) + # Update interface attributes + interface.update( + mac_address=mac_address, + status="Active", + type="25gbase-x-sfp28", # Default type, could be made configurable + ) + return interface + except Exception as e: + LOG.debug("Interface lookup failed: %s", e) + + # Create new interface + try: + interface = nautobot.dcim.interfaces.create( + device=device.id, + name=bios_name, + mac_address=mac_address, + status="Active", + type="25gbase-x-sfp28", + ) + LOG.info("Created interface %s (%s) in Nautobot", bios_name, interface.id) + return interface + except Exception as e: + LOG.error("Failed to create interface %s: %s", bios_name, e) + raise + + def _connect_interface_to_switch(self, nautobot, server_interface, interface_data): + """Connect server interface to switch interface via cable in Nautobot.""" + switch_chassis_id = interface_data.get("switch_id") + switch_port_id = interface_data.get("port_id") + + if not all([switch_chassis_id, switch_port_id]): + LOG.debug("Missing switch connection data for interface") + return + + # Find the switch device by chassis MAC address + switch = self._find_switch_by_mac(nautobot, switch_chassis_id) + if not switch: + LOG.warning( + "Switch with chassis MAC %s not found in Nautobot, cannot create cable", + switch_chassis_id, + ) + return + + # Find the switch interface + switch_interface = self._find_switch_interface(nautobot, switch, switch_port_id) + if not switch_interface: + LOG.warning( + "Switch %s has no interface %s, cannot create cable", + switch.name if hasattr(switch, "name") else switch.id, + switch_port_id, + ) + return + + # Create or verify cable connection + self._create_or_verify_cable(nautobot, server_interface, switch_interface) + + def _find_switch_by_mac(self, nautobot, chassis_mac): + """Find switch device by chassis MAC address.""" + try: + # Nautobot stores chassis MAC in a custom field + devices = nautobot.dcim.devices.filter(cf_chassis_mac_address=chassis_mac) + if devices: + return devices[0] + except Exception as e: + LOG.debug("Switch lookup by MAC failed: %s", e) + return None + + def _find_switch_interface(self, nautobot, switch, port_name): + """Find switch interface by port name.""" + try: + interface = nautobot.dcim.interfaces.get( + device_id=switch.id, name=port_name + ) + return interface + except Exception as e: + LOG.debug("Switch interface lookup failed: %s", e) + return None + + def _create_or_verify_cable(self, nautobot, server_interface, switch_interface): + """Create or verify cable connection between server and switch.""" + try: + # Check if cable already exists + cable = nautobot.dcim.cables.get( + termination_a_id=switch_interface.id, + termination_b_id=server_interface.id, + ) + if cable: + LOG.info("Cable %s already exists in Nautobot", cable.id) + return cable + + # Create new cable + cable = nautobot.dcim.cables.create( + termination_a_type="dcim.interface", + termination_a_id=switch_interface.id, + termination_b_type="dcim.interface", + termination_b_id=server_interface.id, + status="Connected", + ) + LOG.info("Created cable %s in Nautobot", cable.id) + return cable + except Exception as e: + LOG.error( + "Failed to create cable between %s and %s: %s", + server_interface.id, + switch_interface.id, + e, + ) diff --git a/python/ironic-understack/ironic_understack/tests/test_nautobot_sync.py b/python/ironic-understack/ironic_understack/tests/test_nautobot_sync.py new file mode 100644 index 000000000..9fe60a709 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_nautobot_sync.py @@ -0,0 +1,335 @@ +"""Tests for nautobot_sync inspection hook.""" + +import unittest +from unittest import mock + +from ironic import objects + +from ironic_understack.nautobot_sync import NautobotSyncHook + +# Register all Ironic objects +objects.register_all() + + +class TestNautobotSyncHook(unittest.TestCase): + """Test NautobotSyncHook inspection hook.""" + + def setUp(self): + """Set up test fixtures.""" + self.hook = NautobotSyncHook() + self.task = mock.Mock() + self.task.node = mock.Mock() + self.task.node.uuid = "test-node-uuid" + self.task.node.name = "test-node-name" + self.task.node.id = 123 + self.task.node.properties = {} + self.task.node.driver_info = {} + self.task.context = mock.Mock() + + @mock.patch("ironic_understack.nautobot_sync.CONF") + def test_missing_nautobot_url(self, mock_conf): + """Test hook skips sync when Nautobot URL is not configured.""" + mock_conf.ironic_understack.nautobot_url = None + mock_conf.ironic_understack.nautobot_token = "test-token" + + inventory = {} + plugin_data = {} + + # Should not raise exception, just log warning + self.hook(self.task, inventory, plugin_data) + + @mock.patch("ironic_understack.nautobot_sync.CONF") + def test_missing_nautobot_token(self, mock_conf): + """Test hook skips sync when Nautobot token is not configured.""" + mock_conf.ironic_understack.nautobot_url = "http://nautobot.example.com" + mock_conf.ironic_understack.nautobot_token = None + + inventory = {} + plugin_data = {} + + # Should not raise exception, just log warning + self.hook(self.task, inventory, plugin_data) + + @mock.patch("ironic_understack.nautobot_sync.pynautobot") + @mock.patch("ironic_understack.nautobot_sync.CONF") + @mock.patch.object(objects.Port, "list_by_node_id") + def test_device_not_found_in_nautobot( + self, mock_list_ports, mock_conf, mock_pynautobot + ): + """Test hook handles device not found in Nautobot.""" + mock_conf.ironic_understack.nautobot_url = "http://nautobot.example.com" + mock_conf.ironic_understack.nautobot_token = "test-token" + + inventory = {} + plugin_data = {} + + mock_list_ports.return_value = [] + + # Mock Nautobot API - device not found + mock_nautobot_instance = mock.Mock() + mock_pynautobot.api.return_value = mock_nautobot_instance + mock_nautobot_instance.dcim.devices.get.return_value = None + + self.hook(self.task, inventory, plugin_data) + + # Verify device lookup was attempted by UUID + mock_nautobot_instance.dcim.devices.get.assert_called_once_with( + "test-node-uuid" + ) + + @mock.patch("ironic_understack.nautobot_sync.pynautobot") + @mock.patch("ironic_understack.nautobot_sync.CONF") + @mock.patch.object(objects.Port, "list_by_node_id") + def test_device_found_in_nautobot( + self, mock_list_ports, mock_conf, mock_pynautobot + ): + """Test hook handles device already existing in Nautobot.""" + mock_conf.ironic_understack.nautobot_url = "http://nautobot.example.com" + mock_conf.ironic_understack.nautobot_token = "test-token" + + inventory = {} + plugin_data = {} + + mock_list_ports.return_value = [] + + # Mock Nautobot API - device found + mock_device = mock.Mock() + mock_device.id = "test-node-uuid" + mock_device.name = "Dell-ABC123" + mock_nautobot_instance = mock.Mock() + mock_pynautobot.api.return_value = mock_nautobot_instance + mock_nautobot_instance.dcim.devices.get.return_value = mock_device + + self.hook(self.task, inventory, plugin_data) + + # Verify device lookup was attempted + mock_nautobot_instance.dcim.devices.get.assert_called_once_with( + "test-node-uuid" + ) + + @mock.patch("ironic_understack.nautobot_sync.pynautobot") + @mock.patch("ironic_understack.nautobot_sync.CONF") + def test_nautobot_api_exception(self, mock_conf, mock_pynautobot): + """Test hook handles Nautobot API exceptions gracefully.""" + mock_conf.ironic_understack.nautobot_url = "http://nautobot.example.com" + mock_conf.ironic_understack.nautobot_token = "test-token" + + inventory = {} + plugin_data = {} + + # Mock Nautobot API to raise exception + mock_pynautobot.api.side_effect = Exception("Connection error") + + # Should not raise exception, just log error + self.hook(self.task, inventory, plugin_data) + + @mock.patch("ironic_understack.nautobot_sync.pynautobot") + @mock.patch("ironic_understack.nautobot_sync.CONF") + @mock.patch.object(objects.Port, "list_by_node_id") + def test_empty_inventory(self, mock_list_ports, mock_conf, mock_pynautobot): + """Test hook handles empty inventory gracefully.""" + mock_conf.ironic_understack.nautobot_url = "http://nautobot.example.com" + mock_conf.ironic_understack.nautobot_token = "test-token" + + inventory = {} + plugin_data = {} + + mock_list_ports.return_value = [] + + # Mock Nautobot API + mock_nautobot_instance = mock.Mock() + mock_pynautobot.api.return_value = mock_nautobot_instance + mock_nautobot_instance.dcim.devices.get.return_value = None + + # Should not raise exception + self.hook(self.task, inventory, plugin_data) + + @mock.patch.object(objects.Port, "list_by_node_id") + def test_extract_device_data_method(self, mock_list_ports): + """Test _extract_device_data method directly.""" + inventory = {} + + # Mock a port + port = mock.Mock() + port.address = "00:11:22:33:44:55" + port.name = "eth0" + port.pxe_enabled = True + port.extra = {"bios_name": "NIC.Slot.1-1"} + port.local_link_connection = {} + port.physical_network = None + mock_list_ports.return_value = [port] + + data = self.hook._extract_device_data(self.task, inventory) + + assert data["uuid"] == "test-node-uuid" + assert data["name"] == "test-node-name" + assert len(data["interfaces"]) == 1 + assert data["interfaces"][0]["name"] == "eth0" + assert data["interfaces"][0]["mac_address"] == "00:11:22:33:44:55" + assert data["interfaces"][0]["bios_name"] == "NIC.Slot.1-1" + + @mock.patch.object(objects.Port, "list_by_node_id") + def test_extract_device_data_missing_fields(self, mock_list_ports): + """Test _extract_device_data handles missing fields.""" + inventory = {} + mock_list_ports.return_value = [] + + data = self.hook._extract_device_data(self.task, inventory) + + assert data["uuid"] == "test-node-uuid" + assert data["interfaces"] == [] + + @mock.patch.object(objects.Port, "list_by_node_id") + def test_extract_enriched_port_data(self, mock_list_ports): + """Test _extract_device_data extracts enriched port information.""" + inventory = {} + + # Mock enriched port with LLDP and BIOS name data + port = mock.Mock() + port.address = "14:23:f3:f5:3c:90" + port.name = "1423f3f53c90" + port.pxe_enabled = True + port.extra = {"bios_name": "NIC.Slot.1-1"} + port.local_link_connection = { + "switch_id": "c4:7e:e0:e4:03:37", + "switch_info": "f20-3-2.iad3", + "port_id": "Ethernet1/1", + } + port.physical_network = "datacenter1-network" + + mock_list_ports.return_value = [port] + + data = self.hook._extract_device_data(self.task, inventory) + + assert len(data["interfaces"]) == 1 + iface = data["interfaces"][0] + assert iface["mac_address"] == "14:23:f3:f5:3c:90" + assert iface["name"] == "1423f3f53c90" + assert iface["bios_name"] == "NIC.Slot.1-1" + assert iface["pxe_enabled"] is True + assert iface["switch_id"] == "c4:7e:e0:e4:03:37" + assert iface["switch_info"] == "f20-3-2.iad3" + assert iface["port_id"] == "Ethernet1/1" + assert iface["physical_network"] == "datacenter1-network" + + @mock.patch.object(objects.Port, "list_by_node_id") + def test_extract_port_without_lldp_data(self, mock_list_ports): + """Test _extract_device_data handles ports without LLDP data.""" + inventory = {} + + # Mock port without LLDP data + port = mock.Mock() + port.address = "00:11:22:33:44:55" + port.name = "00112233445" + port.pxe_enabled = False + port.extra = {} + port.local_link_connection = {} + port.physical_network = None + + mock_list_ports.return_value = [port] + + data = self.hook._extract_device_data(self.task, inventory) + + assert len(data["interfaces"]) == 1 + iface = data["interfaces"][0] + assert iface["mac_address"] == "00:11:22:33:44:55" + assert iface["bios_name"] is None + assert "switch_id" not in iface or iface.get("switch_id") is None + assert "physical_network" not in iface + + @mock.patch.object(objects.Port, "list_by_node_id") + def test_extract_multiple_ports(self, mock_list_ports): + """Test _extract_device_data handles multiple ports.""" + inventory = {} + + # Mock multiple ports + port1 = mock.Mock() + port1.address = "00:11:22:33:44:55" + port1.name = "port1" + port1.pxe_enabled = True + port1.extra = {"bios_name": "NIC.Slot.1-1"} + port1.local_link_connection = {} + port1.physical_network = None + + port2 = mock.Mock() + port2.address = "00:11:22:33:44:56" + port2.name = "port2" + port2.pxe_enabled = False + port2.extra = {"bios_name": "NIC.Slot.1-2"} + port2.local_link_connection = {} + port2.physical_network = None + + mock_list_ports.return_value = [port1, port2] + + data = self.hook._extract_device_data(self.task, inventory) + + assert len(data["interfaces"]) == 2 + assert data["interfaces"][0]["mac_address"] == "00:11:22:33:44:55" + assert data["interfaces"][1]["mac_address"] == "00:11:22:33:44:56" + + @mock.patch("ironic_understack.nautobot_sync.pynautobot") + @mock.patch("ironic_understack.nautobot_sync.CONF") + @mock.patch.object(objects.Port, "list_by_node_id") + def test_sync_interface_with_lldp_data( + self, mock_list_ports, mock_conf, mock_pynautobot + ): + """Test syncing interface with LLDP data creates cable.""" + mock_conf.ironic_understack.nautobot_url = "http://nautobot.example.com" + mock_conf.ironic_understack.nautobot_token = "test-token" + + inventory = {} + plugin_data = {} + + # Mock port with LLDP data + port = mock.Mock() + port.address = "14:23:f3:f5:3c:90" + port.name = "1423f3f53c90" + port.pxe_enabled = True + port.extra = {"bios_name": "NIC.Slot.1-1"} + port.local_link_connection = { + "switch_id": "c4:7e:e0:e4:03:37", + "switch_info": "f20-3-2.iad3", + "port_id": "Ethernet1/1", + } + port.physical_network = "datacenter1-network" + mock_list_ports.return_value = [port] + + # Mock Nautobot API + mock_device = mock.Mock() + mock_device.id = "test-node-uuid" + mock_device.name = "Dell-ABC123" + + mock_interface = mock.Mock() + mock_interface.id = "interface-123" + mock_interface.update = mock.Mock() + + mock_switch = mock.Mock() + mock_switch.id = "switch-123" + mock_switch.name = "f20-3-2" + + mock_switch_interface = mock.Mock() + mock_switch_interface.id = "switch-interface-123" + + mock_nautobot_instance = mock.Mock() + mock_pynautobot.api.return_value = mock_nautobot_instance + mock_nautobot_instance.dcim.devices.get.return_value = mock_device + mock_nautobot_instance.dcim.interfaces.get.side_effect = [ + mock_interface, + mock_switch_interface, + ] + mock_nautobot_instance.dcim.devices.filter.return_value = [mock_switch] + mock_nautobot_instance.dcim.cables.get.return_value = None + mock_nautobot_instance.dcim.cables.create.return_value = mock.Mock( + id="cable-123" + ) + + self.hook(self.task, inventory, plugin_data) + + # Verify interface was found/created + assert mock_nautobot_instance.dcim.interfaces.get.called + # Verify cable was created + assert mock_nautobot_instance.dcim.cables.create.called + + +if __name__ == "__main__": + unittest.main() diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index 9e01289e4..2ec1625bc 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -14,10 +14,12 @@ dependencies = [ "ironic>=32.0,<33", "pyyaml~=6.0", "understack-flavor-matcher", + "pynautobot~=2.0", ] [project.entry-points."ironic.inspection.hooks"] resource-class = "ironic_understack.resource_class:ResourceClassHook" +nautobot-sync = "ironic_understack.nautobot_sync:NautobotSyncHook" [project.entry-points."ironic.hardware.interfaces.inspect"] redfish-understack = "ironic_understack.redfish_inspect_understack:UnderstackRedfishInspect" @@ -57,4 +59,6 @@ target-version = "py310" [tool.ruff.lint.per-file-ignores] "ironic_understack/tests/*.py" = [ "S101", # allow 'assert' for pytest + "S105", # allow hardcoded passwords in tests + "S106", # allow hardcoded passwords in function arguments in tests ]