From 0c8621d081c904e2db8904e6d68515be38794c21 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Thu, 20 Nov 2025 12:42:35 +0000 Subject: [PATCH 1/9] Quieten down the logging from the openstack api client --- .../understack_workflows/main/enroll_server.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/python/understack-workflows/understack_workflows/main/enroll_server.py b/python/understack-workflows/understack_workflows/main/enroll_server.py index 11e7cb6d6..fa0352a04 100644 --- a/python/understack-workflows/understack_workflows/main/enroll_server.py +++ b/python/understack-workflows/understack_workflows/main/enroll_server.py @@ -1,4 +1,5 @@ import argparse +import logging import os from pprint import pformat @@ -23,6 +24,10 @@ logger = setup_logger(__name__) +# These are extremely verbose by default: +for name in ["ironicclient", "keystoneauth", "stevedore"]: + logging.getLogger(name).setLevel(logging.INFO) + def main(): """On-board new or Refresh existing baremetal node. From 44aa7a692430bddd715357bef3d1a36c8e52ef4a Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Wed, 12 Mar 2025 14:28:40 +0000 Subject: [PATCH 2/9] Don't update Nautobot during baremetal node enrol workflow We have data freshly discovered from the BMC. We were updating Nautobot, then fetching the same data back from Nautobot to populate Ironic. This code is re-written to use BMC data directly, allowing us to enrol independently from Nautobot. We now expect Nautotbot to be updated in response to events emitted by OpenStack, so there is no need for us to write directly to the Nautobot API. --- .../tests/test_bmc_chassis_info.py | 2 +- .../tests/test_nautobot_device.py | 4 +- .../tests/test_sync_interfaces.py | 91 ++++++------ .../tests/test_topology.py | 46 ++++-- .../understack_workflows/bmc_chassis_info.py | 8 +- .../understack_workflows/data_center.py | 94 ++++++++++++ .../understack_workflows/ironic_node.py | 71 +++++---- .../main/enroll_server.py | 82 +++-------- .../understack_workflows/nautobot_device.py | 21 +-- .../port_configuration.py | 1 - .../understack_workflows/sync_interfaces.py | 138 ++++++++++-------- .../understack_workflows/topology.py | 17 ++- .../workflowtemplates/enroll-server.yaml | 8 +- 13 files changed, 339 insertions(+), 244 deletions(-) create mode 100644 python/understack-workflows/understack_workflows/data_center.py diff --git a/python/understack-workflows/tests/test_bmc_chassis_info.py b/python/understack-workflows/tests/test_bmc_chassis_info.py index 3ded04b6a..83d5d52a0 100644 --- a/python/understack-workflows/tests/test_bmc_chassis_info.py +++ b/python/understack-workflows/tests/test_bmc_chassis_info.py @@ -45,7 +45,7 @@ def test_chassis_neighbors(): def test_chassis_info_R7615(): bmc = FakeBmc(read_fixtures("json_samples/bmc_chassis_info/R7615")) assert bmc_chassis_info.chassis_info(bmc) == bmc_chassis_info.ChassisInfo( - manufacturer="Dell Inc.", + manufacturer="Dell", model_number="PowerEdge R7615", serial_number="33GSW04", bios_version="1.6.10", diff --git a/python/understack-workflows/tests/test_nautobot_device.py b/python/understack-workflows/tests/test_nautobot_device.py index 37295296b..3d5a8dcb3 100644 --- a/python/understack-workflows/tests/test_nautobot_device.py +++ b/python/understack-workflows/tests/test_nautobot_device.py @@ -113,7 +113,7 @@ def __init__(self): def test_find_or_create(dell_nautobot_device): nautobot = FakeNautobot() chassis_info = ChassisInfo( - manufacturer="Dell Inc.", + manufacturer="Dell", model_number="PowerEdge R7615", serial_number="33GSW04", bios_version="1.6.10", @@ -147,6 +147,6 @@ def test_find_or_create(dell_nautobot_device): ], ) - device = nautobot_device.find_or_create(chassis_info, nautobot) + device = nautobot_device.find_or_create(chassis_info, "Dell-33GSW04", nautobot) assert device == dell_nautobot_device diff --git a/python/understack-workflows/tests/test_sync_interfaces.py b/python/understack-workflows/tests/test_sync_interfaces.py index 2070dcf5c..7f792e36a 100644 --- a/python/understack-workflows/tests/test_sync_interfaces.py +++ b/python/understack-workflows/tests/test_sync_interfaces.py @@ -1,80 +1,81 @@ +from dataclasses import dataclass from unittest.mock import Mock from understack_workflows import sync_interfaces +from understack_workflows.bmc_chassis_info import InterfaceInfo +@dataclass +class MockIronicNode(): + name: str + uuid: str -def test_sync_with_no_existing_interfaces(dell_nautobot_device): +def test_sync_with_no_existing_interfaces(): + device_name = "Dell-ABC123" + + ironic_node = MockIronicNode( + uuid="a3a2983f-d906-4663-943c-c41ab73c9b62", + name=device_name, + ) ironic_client = Mock() ironic_client.list_ports.return_value = [] - sync_interfaces.from_nautobot_to_ironic( - pxe_interface="", - nautobot_device=dell_nautobot_device, + print(f"NAME {ironic_node.name=}") + + discovered_interfaces = [ + InterfaceInfo( + name="NIC.Slot.1-1", + description="", + mac_address="14:23:f3:f5:25:f1", + remote_switch_mac_address="c4:7e:e0:e4:10:7f", + remote_switch_port_name="Ethernet1/6", + remote_switch_data_stale=False, + ), + InterfaceInfo( + name="NIC.Integrated.1-2", + description="", + mac_address="14:23:f3:f5:25:f2", + remote_switch_mac_address="c4:7e:e0:e4:32:df", + remote_switch_port_name="Ethernet1/6", + remote_switch_data_stale=False, + ), + ] + + sync_interfaces.update_ironic_baremetal_ports( + ironic_node=ironic_node, + discovered_interfaces=discovered_interfaces, + pxe_interface_name="", ironic_client=ironic_client, ) - assert ironic_client.create_port.call_count == 4 + assert ironic_client.create_port.call_count == 2 ironic_client.create_port.assert_any_call( { "address": "14:23:f3:f5:25:f1", - "uuid": "8c28941c-02cd-4aad-9e3f-93c39e08b58a", "node_uuid": "a3a2983f-d906-4663-943c-c41ab73c9b62", - "name": f"{dell_nautobot_device.name}:NIC.Slot.1-2", + "name": "Dell-ABC123:NIC.Slot.1-1", "pxe_enabled": False, "local_link_connection": { - "switch_id": "9c:54:16:f5:ad:27", + "switch_id": "c4:7e:e0:e4:10:7f", "port_id": "Ethernet1/6", "switch_info": "f20-2-1.iad3.rackspace.net", }, - "physical_network": "F20-2[1-2]", + "physical_network": "f20-2-network", } ) ironic_client.create_port.assert_any_call( { - "address": "d4:04:e6:4f:8d:b5", - "uuid": "39d98f09-3199-40e0-87dc-e5ed6dce78e5", + "address": "14:23:f3:f5:25:f2", "node_uuid": "a3a2983f-d906-4663-943c-c41ab73c9b62", - "name": f"{dell_nautobot_device.name}:NIC.Integrated.1-2", + "name": "Dell-ABC123:NIC.Integrated.1-2", "pxe_enabled": False, "local_link_connection": { - "switch_id": "9c:54:16:f5:ac:27", - "port_id": "Ethernet1/5", - "switch_info": "f20-2-2.iad3.rackspace.net", - }, - "physical_network": "F20-2[1-2]", - } - ) - - ironic_client.create_port.assert_any_call( - { - "address": "14:23:f3:f5:25:f0", - "uuid": "7ac587c4-015b-4a0e-b579-91284cbd0406", - "node_uuid": "a3a2983f-d906-4663-943c-c41ab73c9b62", - "name": f"{dell_nautobot_device.name}:NIC.Slot.1-1", - "pxe_enabled": False, - "local_link_connection": { - "switch_id": "9c:54:16:f5:ad:27", + "switch_id": "c4:7e:e0:e4:32:df", "port_id": "Ethernet1/6", "switch_info": "f20-2-2.iad3.rackspace.net", }, - "physical_network": "F20-2[1-2]", + "physical_network": "f20-2-network", } ) - ironic_client.create_port.assert_any_call( - { - "address": "d4:04:e6:4f:8d:b4", - "uuid": "ac2f1eae-188e-4fc6-9245-f9a6cf8b4ea8", - "node_uuid": "a3a2983f-d906-4663-943c-c41ab73c9b62", - "name": f"{dell_nautobot_device.name}:NIC.Integrated.1-1", - "pxe_enabled": False, - "local_link_connection": { - "switch_id": "9c:54:16:f5:ab:27", - "port_id": "Ethernet1/5", - "switch_info": "f20-2-1.iad3.rackspace.net", - }, - "physical_network": "F20-2[1-2]", - } - ) diff --git a/python/understack-workflows/tests/test_topology.py b/python/understack-workflows/tests/test_topology.py index 7f25f4f4a..87f265104 100644 --- a/python/understack-workflows/tests/test_topology.py +++ b/python/understack-workflows/tests/test_topology.py @@ -1,17 +1,43 @@ +import pytest from understack_workflows.topology import pxe_interface_name from understack_workflows.topology import switch_connections +from understack_workflows.bmc_chassis_info import InterfaceInfo +alien_int = InterfaceInfo( + name="NIC.Slot.1-1", + description="", + mac_address="11:22:33:44:55:66", + remote_switch_mac_address="AA:AA:AA:AA:AA:AA", + remote_switch_port_name="Eth1/1", +) -def test_pxe_interface_name(dell_nautobot_device): - # Since "NIC.Integrated.1-1" matches preferred criteria, it should be returned - assert pxe_interface_name(dell_nautobot_device) == "NIC.Integrated.1-1" +goodint1 = InterfaceInfo( + name="NIC.Slot.1-2", + description="", + mac_address="11:22:33:44:55:66", + remote_switch_mac_address="C4:7E:E0:E3:EC:2B", + remote_switch_port_name="Eth1/2", +) +goodint2 = InterfaceInfo( + name="iDRAC", + description="", + mac_address="11:22:33:44:55:66", + remote_switch_mac_address="C4:B3:6A:C8:33:80", + remote_switch_port_name="Eth1/3", +) -def test_switch_connections(dell_nautobot_device): - assert switch_connections(dell_nautobot_device) == { - "NIC.Integrated.1-1": "f20-2-1.iad3.rackspace.net", - "NIC.Integrated.1-2": "f20-2-2.iad3.rackspace.net", - "NIC.Slot.1-1": "f20-2-2.iad3.rackspace.net", - "NIC.Slot.1-2": "f20-2-1.iad3.rackspace.net", - "iDRAC": "f20-2-1d.iad3.rackspace.net", +def test_pxe_interface_name_unknown_switch(): + with pytest.raises(ValueError): + pxe_interface_name([alien_int]) + + +def test_pxe_interface_name(): + assert pxe_interface_name([goodint1, goodint2]) == "NIC.Slot.1-2" + + +def test_switch_connections(): + assert switch_connections([goodint1, goodint2]) == { + "NIC.Slot.1-2": "f20-1-1.iad3.rackspace.net", + "iDRAC": "f20-3-1d.iad3.rackspace.net", } diff --git a/python/understack-workflows/understack_workflows/bmc_chassis_info.py b/python/understack-workflows/understack_workflows/bmc_chassis_info.py index 73ae1eff2..001915071 100644 --- a/python/understack-workflows/understack_workflows/bmc_chassis_info.py +++ b/python/understack-workflows/understack_workflows/bmc_chassis_info.py @@ -76,7 +76,7 @@ def chassis_info(bmc: Bmc) -> ChassisInfo: interfaces = interface_data(bmc) return ChassisInfo( - manufacturer=chassis_data["Manufacturer"], + manufacturer=normalise_manufacturer(chassis_data["Manufacturer"]), model_number=chassis_data["Model"], serial_number=chassis_data["SKU"], bios_version=chassis_data["BiosVersion"], @@ -257,3 +257,9 @@ def normalise_mac(mac: str) -> str: def server_interface_name(name: str) -> str: return "iDRAC" if name.startswith("iDRAC.Embedded") else name + + +def normalise_manufacturer(name: str) -> str: + if "DELL" in name.upper(): + return "Dell" + raise ValueError(f"Server manufacturer {name} not supported") diff --git a/python/understack-workflows/understack_workflows/data_center.py b/python/understack-workflows/understack_workflows/data_center.py new file mode 100644 index 000000000..49178b023 --- /dev/null +++ b/python/understack-workflows/understack_workflows/data_center.py @@ -0,0 +1,94 @@ +import re +from dataclasses import dataclass + +VLAN_GROUP_SUFFIXES = { + "-1": "network", + "-2": "network", + "-1f": "storage", + "-2f": "storage", + "-3f": "storage-appliance", + "-4f": "storage-appliance", + "-1d": "bmc", +} + +SWITCH_NAME_BY_MAC = { + "C4:7E:E0:E3:EC:2B": "f20-1-1.iad3.rackspace.net", + "C4:7E:E0:E4:2E:2F": "f20-1-2.iad3.rackspace.net", + "C4:4D:84:48:7A:00": "f20-1-1d.iad3.rackspace.net", + "C4:7E:E0:E4:10:7F": "f20-2-1.iad3.rackspace.net", + "C4:7E:E0:E4:32:DF": "f20-2-2.iad3.rackspace.net", + "C4:4D:84:48:61:80": "f20-2-1d.iad3.rackspace.net", + "C4:7E:E0:E4:55:3F": "f20-3-1.iad3.rackspace.net", + "C4:7E:E0:E4:03:37": "f20-3-2.iad3.rackspace.net", + "C4:B3:6A:C8:33:80": "f20-3-1d.iad3.rackspace.net", + "40:14:82:81:3E:E3": "f20-3-1f.iad3.rackspace.net", + "C4:7E:E0:E7:A0:37": "f20-3-2f.iad3.rackspace.net", +} + + +@dataclass +class Switch: + """A switch managed by understack.""" + + name: str + vlan_group_name: str | None + + +def switch_for_mac(mac: str, port_name: str) -> Switch: + """Find switch by MAC Address. + + We "discover" our switch connections via LLDP, the iDRAC implementation of + which provides us with the switch MAC address instead of its hostname, + therefore we need to find the switch by MAC Address. + + The MAC address is one of the fields in the LLDP wire protocol, however some + Cisco switches implement this incorrectly and provide the MAC address of the + port, rather than the Chassis. We work around this behaviour by searching + for both MAC addresses. + """ + mac = mac.upper() + base_mac = _base_mac(mac, port_name) + + name = SWITCH_NAME_BY_MAC.get(mac) or SWITCH_NAME_BY_MAC.get(base_mac) + if not name: + raise ValueError( + f"We don't have a switch that matches the LLDP info " + f"reported by server BMC for {port_name}, neither " + f"{mac}, or the calculated base mac {base_mac}." + ) + + return Switch( + name=name, + vlan_group_name=vlan_group_name(name), + ) + + +def vlan_group_name(switch_name: str) -> str | None: + """Return a VLAN Group name based on our naming convention. + + >>> vlan_group_name("a1-1-1.abc1") + "a1-1-network" + """ + switch_name = switch_name.split(".")[0] + + for switch_name_suffix, vlan_group_suffix in VLAN_GROUP_SUFFIXES.items(): + if switch_name.endswith(switch_name_suffix): + cabinet_name = switch_name.removesuffix(switch_name_suffix) + return f"{cabinet_name}-{vlan_group_suffix}" + return None + + +def _base_mac(mac: str, port_name: str) -> str: + """Given a mac addr, return the mac addr which is less. + + >>> base_mac("11:22:33:44:55:66", "Eth1/6") + "11:22:33:44:55:60" + """ + port_number = re.split(r"\D+", port_name)[-1] + if not port_number: + raise ValueError(f"Need numeric interface, not {port_name!r}") + port_number = int(port_number) + mac_number = int(re.sub(r"[^0-9a-fA-f]+", "", mac), 16) + base = mac_number - port_number + hexadecimal = f"{base:012X}" + return ":".join(hexadecimal[i : i + 2] for i in range(0, 12, 2)) diff --git a/python/understack-workflows/understack_workflows/ironic_node.py b/python/understack-workflows/understack_workflows/ironic_node.py index 577e0cf2c..70687d4f0 100644 --- a/python/understack-workflows/understack_workflows/ironic_node.py +++ b/python/understack-workflows/understack_workflows/ironic_node.py @@ -13,47 +13,35 @@ logger = setup_logger(__name__) -@dataclass(frozen=True) -class NodeMetadata: - uuid: str - hostname: str - manufacturer: str - - @property - def driver(self): - if self.manufacturer.startswith("Dell"): - return "idrac" - else: - return "redfish" - - -def create_or_update(node_meta: NodeMetadata, bmc: Bmc): +def create_or_update(bmc: Bmc, name: str, manufacturer: str) -> IronicNodeConfiguration: """Note interfaces/ports are not synced here, that happens elsewhere.""" client = IronicClient() - logger.debug("Ensuring node with UUID %s exists in Ironic", node_meta.uuid) + driver = _driver_for(manufacturer) + try: - ironic_node = client.get_node(node_meta.uuid) + ironic_node = client.get_node(name) + logger.debug( + "Using existing baremetal node %s with name %s", ironic_node.uuid, name + ) + update_ironic_node(client, bmc, ironic_node, name, driver) + return ironic_node except ironicclient.common.apiclient.exceptions.NotFound: - logger.debug("Node: %s not found in Ironic, creating.", node_meta.uuid) - ironic_node = create_ironic_node(client, node_meta, bmc) - return ironic_node.provision_state # type: ignore + logger.debug("Baremetal Node with name %s not found in Ironic, creating.", name) + return create_ironic_node(client, bmc, name, driver) - if ironic_node.provision_state in STATES_ALLOWING_UPDATES: - update_ironic_node(client, node_meta, bmc) - else: + +def update_ironic_node(client, bmc, ironic_node, name, driver): + if ironic_node.provision_state not in STATES_ALLOWING_UPDATES: logger.info( - "Device %s in Ironic is in a %s provision_state, so no updates are allowed", - node_meta.uuid, + "Baremetal node %s is in %s provision_state, so no updates are allowed", + ironic_node.uuid, ironic_node.provision_state, ) + return - return ironic_node.provision_state - - -def update_ironic_node(client, node_meta, bmc): updates = [ - f"name={node_meta.hostname}", - f"driver={node_meta.driver}", + f"name={name}", + f"driver={driver}", f"driver_info/redfish_address={bmc.url()}", "driver_info/redfish_verify_ca=false", f"driver_info/redfish_username={bmc.username}", @@ -63,22 +51,22 @@ def update_ironic_node(client, node_meta, bmc): ] patches = args_array_to_patch("add", updates) - logger.info("Updating Ironic node %s patches=%s", node_meta.uuid, patches) + logger.info("Updating Ironic node %s patches=%s", ironic_node.uuid, patches) - response = client.update_node(node_meta.uuid, patches) - logger.info("Ironic node %s Updated: response=%s", node_meta.uuid, response) + response = client.update_node(ironic_node.uuid, patches) + logger.info("Ironic node %s Updated: response=%s", ironic_node.uuid, response) def create_ironic_node( client: IronicClient, - node_meta: NodeMetadata, bmc: Bmc, + name: str, + driver: str, ) -> IronicNodeConfiguration: return client.create_node( { - "uuid": node_meta.uuid, - "name": node_meta.hostname, - "driver": node_meta.driver, + "name": name, + "driver": driver, "driver_info": { "redfish_address": bmc.url(), "redfish_verify_ca": False, @@ -89,3 +77,10 @@ def create_ironic_node( "inspect_interface": "agent", } ) + + +def _driver_for(manufacturer: str) -> str: + if manufacturer.startswith("Dell"): + return "idrac" + else: + return "redfish" diff --git a/python/understack-workflows/understack_workflows/main/enroll_server.py b/python/understack-workflows/understack_workflows/main/enroll_server.py index fa0352a04..aaa9cff96 100644 --- a/python/understack-workflows/understack_workflows/main/enroll_server.py +++ b/python/understack-workflows/understack_workflows/main/enroll_server.py @@ -3,24 +3,19 @@ import os from pprint import pformat -import pynautobot from understack_workflows import ironic_node -from understack_workflows import nautobot_device -from understack_workflows import sync_interfaces +from understack_workflows.sync_interfaces import update_ironic_baremetal_ports from understack_workflows import topology from understack_workflows.bmc import Bmc from understack_workflows.bmc import bmc_for_ip_address from understack_workflows.bmc_bios import update_dell_bios_settings from understack_workflows.bmc_credentials import set_bmc_password from understack_workflows.bmc_hostname import bmc_set_hostname -from understack_workflows.bmc_network_config import bmc_set_permanent_ip_addr from understack_workflows.bmc_settings import update_dell_drac_settings from understack_workflows.discover import discover_chassis_info from understack_workflows.helpers import credential -from understack_workflows.helpers import parser_nautobot_args from understack_workflows.helpers import setup_logger -from understack_workflows.nautobot_device import NautobotDevice logger = setup_logger(__name__) @@ -34,19 +29,14 @@ def main(): We have been invoked because a baremetal node is available. - Pre-requisites in Nautobot: + Pre-requisites: - All connected switches must have a device with the base MAC address stored - in the asset tag field. - - The Rack and Location of the switches must be correct (they will be copied - verbatim to the newly created server Device). + All connected switches must be known to us via the base MAC address in our + data center yaml data. The server Device type must exist, with a name that matches the "model" as reported by the BMC. - The DRAC IP Prefix must exist. - This script has the following order of operations: - connect to the BMC using standard password, if that fails then use @@ -75,48 +65,27 @@ def main(): - MAC address - LLDP connections [{remote_mac, remote_interface_name}] - - Find or create this server in Nautobot by serial number. - - - set name, manufacturer, model, serial, location, rack - - - Find BMC interface - - - For each server interface - - find or create server interface by name in nautobot - - set interface mac addresses - - look up switch by mac addr (is stored in Nautobot's asset tag field) - - look up switch interface by name - - find or create cable - - - create BMC IP address assignment for BMC interface - convert our type - "dhcp" IP Address to type "host" and associate it with the interface - - Determine flavor of the server based on the information collected from BMC + - Find or create this baremetal node in Ironic - - create ports with MACs (omit BMC port) and set one to PXE - - TODO advance to available state + - create baremetal ports for each NIC except BMC. Set one of them to PXE. - set flavor - """ args = argument_parser().parse_args() bmc_ip_address = args.bmc_ip_address logger.info("%s starting for bmc_ip_address=%s", __file__, bmc_ip_address) - url = args.nautobot_url - token = args.nautobot_token or credential("nb-token", "token") - nautobot = pynautobot.api(url, token=token) - bmc = bmc_for_ip_address(bmc_ip_address) - nb_device = enroll_server(bmc, nautobot, args.old_bmc_password) + device_id = enroll_server(bmc, args.old_bmc_password) # argo workflows captures stdout as the results which we can use # to return the device UUID - print(str(nb_device.id)) + print(device_id) -def enroll_server(bmc: Bmc, nautobot, old_password: str | None) -> NautobotDevice: +def enroll_server(bmc: Bmc, old_password: str | None) -> str: set_bmc_password( ip_address=bmc.ip_address, new_password=bmc.password, @@ -126,36 +95,34 @@ def enroll_server(bmc: Bmc, nautobot, old_password: str | None) -> NautobotDevic device_info = discover_chassis_info(bmc) logger.info("Discovered %s", pformat(device_info)) - update_dell_drac_settings(bmc) + device_name = f"{device_info.manufacturer}-{device_info.serial_number}" - nb_device = nautobot_device.find_or_create(device_info, nautobot) - pxe_interface = topology.pxe_interface_name(nb_device) + update_dell_drac_settings(bmc) - bmc_set_hostname(bmc, device_info.bmc_hostname, nb_device.name) + pxe_interface = topology.pxe_interface_name(device_info.interfaces) - # Be sure to only do this after Nautobot IPAddress has been changed from - # DHCP, otherwise our IP might be handed out to someone else. - bmc_set_permanent_ip_addr(bmc, device_info.bmc_interface) + bmc_set_hostname(bmc, device_info.bmc_hostname, device_name) # Note the above may require a restart of the DRAC, which in turn may delete # any pending BIOS jobs, so do BIOS settings after the DRAC settings. update_dell_bios_settings(bmc, pxe_interface=pxe_interface) - _ironic_provision_state = ironic_node.create_or_update( - ironic_node.NodeMetadata( - uuid=nb_device.id, - hostname=nb_device.name, - manufacturer=device_info.manufacturer, - ), - bmc, + node = ironic_node.create_or_update( + bmc=bmc, + name=device_name, + manufacturer=device_info.manufacturer, ) - logger.info("%s _ironic_provision_state=%s", nb_device.id, _ironic_provision_state) + logger.info("%s _ironic_provision_state=%s", device_name, node.provision_state) - sync_interfaces.from_nautobot_to_ironic(nb_device, pxe_interface=pxe_interface) + update_ironic_baremetal_ports( + ironic_node=node, + discovered_interfaces=device_info.interfaces, + pxe_interface_name=pxe_interface, + ) logger.info("%s complete for %s", __file__, bmc.ip_address) - return nb_device + return node.uuid def argument_parser(): @@ -166,7 +133,6 @@ def argument_parser(): parser.add_argument( "--old-bmc-password", type=str, required=False, help="Old Password" ) - parser = parser_nautobot_args(parser) return parser diff --git a/python/understack-workflows/understack_workflows/nautobot_device.py b/python/understack-workflows/understack_workflows/nautobot_device.py index 377e22e14..c5866b9a9 100644 --- a/python/understack-workflows/understack_workflows/nautobot_device.py +++ b/python/understack-workflows/understack_workflows/nautobot_device.py @@ -52,7 +52,9 @@ class NautobotDevice: interfaces: list[NautobotInterface] -def find_or_create(chassis_info: ChassisInfo, nautobot) -> NautobotDevice: +def find_or_create( + chassis_info: ChassisInfo, device_name: str, nautobot +) -> NautobotDevice: """Update existing or create new device using the Nautobot API.""" # TODO: performance: our single graphql query here fetches the device from # nautobot with all existing interfaces, macs, cable and connected switches. @@ -82,7 +84,7 @@ def find_or_create(chassis_info: ChassisInfo, nautobot) -> NautobotDevice: logger.info("Device %s not in Nautobot, creating", chassis_info.serial_number) location_id, rack_id = location_from(list(switches.values())) - payload = server_device_payload(location_id, rack_id, chassis_info) + payload = server_device_payload(location_id, rack_id, chassis_info, device_name) logger.debug("Server device: %s", payload) nautobot.dcim.devices.create(**payload) # Re-run the graphql query to fetch any auto-created defaults from @@ -211,31 +213,22 @@ def base_mac(mac: str, port_name: str) -> str: def server_device_payload( - location_id: str, rack_id: str, chassis_info: ChassisInfo + location_id: str, rack_id: str, chassis_info: ChassisInfo, device_name: str ) -> dict: - manufacturer = _parse_manufacturer(chassis_info.manufacturer) - name = f"{manufacturer}-{chassis_info.serial_number}" - return { "status": {"name": DEVICE_INITIAL_STATUS}, "role": {"name": DEVICE_ROLE}, "device_type": { - "manufacturer": {"name": manufacturer}, + "manufacturer": {"name": chassis_info.manufacturer}, "model": chassis_info.model_number, }, - "name": name, + "name": device_name, "serial": chassis_info.serial_number, "rack": rack_id, "location": location_id, } -def _parse_manufacturer(name: str) -> str: - if "DELL" in name.upper(): - return "Dell" - raise ValueError(f"Server manufacturer {name} not supported") - - def nautobot_server(nautobot, serial: str) -> NautobotDevice | None: query = """ query($serial: String!){ diff --git a/python/understack-workflows/understack_workflows/port_configuration.py b/python/understack-workflows/understack_workflows/port_configuration.py index a32aa160a..8a01ca287 100644 --- a/python/understack-workflows/understack_workflows/port_configuration.py +++ b/python/understack-workflows/understack_workflows/port_configuration.py @@ -8,7 +8,6 @@ class PortConfiguration(BaseModel): address: Annotated[ str, StringConstraints(to_lower=True) ] # ironicclient's Port class lowercases this attribute - uuid: str # using a str here to due to ironicclient Port attribute node_uuid: str # using a str here due to ironicclient Port attribute name: str # port name pxe_enabled: bool diff --git a/python/understack-workflows/understack_workflows/sync_interfaces.py b/python/understack-workflows/understack_workflows/sync_interfaces.py index 80dfccfc8..4b9b024c6 100644 --- a/python/understack-workflows/understack_workflows/sync_interfaces.py +++ b/python/understack-workflows/understack_workflows/sync_interfaces.py @@ -2,107 +2,125 @@ from understack_workflows.helpers import setup_logger from understack_workflows.ironic.client import IronicClient -from understack_workflows.nautobot_device import NautobotDevice -from understack_workflows.nautobot_device import NautobotInterface from understack_workflows.port_configuration import PortConfiguration +from understack_workflows.bmc_chassis_info import InterfaceInfo +from understack_workflows import data_center logger = setup_logger(__name__) -def from_nautobot_to_ironic( - nautobot_device: NautobotDevice, pxe_interface: str, ironic_client=None +def update_ironic_baremetal_ports( + ironic_node, + discovered_interfaces: list[InterfaceInfo], + pxe_interface_name: str, + ironic_client: IronicClient | None = None, ): - """Update Ironic ports to match information found in Nautobot Interfaces.""" - logger.info("Syncing Interfaces / Ports for Device %s ...", nautobot_device.id) - - nautobot_ports = dict_by_uuid( - get_nautobot_interfaces(nautobot_device, pxe_interface) - ) - logger.debug("%s", nautobot_ports) - + """Update Ironic baremetal ports to match interfaces indicated by BMC.""" + device_uuid: str = ironic_node.uuid if ironic_client is None: ironic_client = IronicClient() + logger.info("Syncing Interfaces / Ports for Device %s ...", device_uuid) + + discovered_ports = dict_by_mac_address( + make_port_infos( + interfaces=discovered_interfaces, + pxe_interface_name=pxe_interface_name, + device_name=ironic_node.name, + device_uuid=device_uuid, + ) + ) + logger.debug("Actual ports according to BMC: %s", discovered_ports) logger.info("Fetching Ironic Ports ...") - ironic_ports = dict_by_uuid(ironic_client.list_ports(nautobot_device.id)) + ironic_ports = dict_by_mac_address(ironic_client.list_ports(device_uuid)) - for port_id, interface in ironic_ports.items(): - if port_id not in nautobot_ports: + for mac_address, ironic_port in ironic_ports.items(): + if mac_address not in discovered_ports: logger.info( - "Nautobot Interface %s no longer exists, " - "deleting corresponding Ironic Port", - interface.uuid, + "Server Interface %s no longer exists, " + "deleting corresponding Ironic Port %s", + mac_address, + ironic_port.uuid, ) - response = ironic_client.delete_port(interface.uuid) + response = ironic_client.delete_port(ironic_port.uuid) logger.debug("Deleted: %s", response) - for port_id, nb_port in nautobot_ports.items(): - if port_id in ironic_ports: - patch = get_patch(nb_port, ironic_ports[port_id]) + for mac_address, actual_port in discovered_ports.items(): + ironic_port = ironic_ports.get(mac_address) + if ironic_port: + patch = get_patch(actual_port, ironic_port) if patch: - logger.info("Updating Ironic Port %s ...", nb_port) - response = ironic_client.update_port(port_id, patch) + logger.info("Updating Ironic Port %s, setting %s", ironic_port, patch) + response = ironic_client.update_port(ironic_port.uuid, patch) logger.debug("Updated: %s", response) else: - logger.debug("No changes required for Ironic Port %s", port_id) + logger.debug("No changes required for Ironic Port %s", mac_address) else: - logger.info("Creating Ironic Port %s ...", nb_port) - response = ironic_client.create_port(nb_port.model_dump()) + logger.info("Creating Ironic Port %s ...", actual_port) + response = ironic_client.create_port(actual_port.dict()) logger.debug("Created: %s", response) -def dict_by_uuid(items: list) -> dict: - return {item.uuid: item for item in items} +def dict_by_mac_address(items: list) -> dict: + return {item.address: item for item in items} -def get_nautobot_interfaces( - nautobot_device: NautobotDevice, pxe_interface: str +def make_port_infos( + interfaces: list[InterfaceInfo], + pxe_interface_name: str, + device_uuid: str, + device_name: str, ) -> list[PortConfiguration]: - """Get Nautobot interfaces for a device. + """Convert InterfaceInfo into PortConfiguration - Returns a list of PortConfiguration - - Excludes interfaces with no MAC address + Excludes BMC interfaces and interfaces without a MAC address. + Adds local_link and physical_network for interfaces that are connected to a + "network" switch. """ return [ - port_configuration(interface, pxe_interface, nautobot_device) - for interface in nautobot_device.interfaces - if interface_is_relevant(interface) + port_configuration(interface, pxe_interface_name, device_uuid, device_name) + for interface in interfaces + if ( + interface.mac_address + and interface.name != "iDRAC" + and interface.name != "iLo" + ) ] def port_configuration( - interface: NautobotInterface, pxe_interface: str, device: NautobotDevice + interface: InterfaceInfo, + pxe_interface_name: str, + device_uuid: str, + device_name: str, ) -> PortConfiguration: - # Interface names have their UUID prepended because Ironic wants them + # Interface names have device name prepended because Ironic wants them # globally unique across all devices. - name = f"{device.name}:{interface.name}" - pxe_enabled = interface.name == pxe_interface - - if interface.neighbor_chassis_mac: - local_link_connection = { - "switch_id": interface.neighbor_chassis_mac.lower(), - "port_id": interface.neighbor_interface_name, - "switch_info": interface.neighbor_device_name, - } - else: - local_link_connection = {} + name = f"{device_name}:{interface.name}" + pxe_enabled = interface.name == pxe_interface_name + physical_network = None + local_link_connection = {} + + if interface.remote_switch_mac_address and interface.remote_switch_port_name: + switch = data_center.switch_for_mac( + interface.remote_switch_mac_address, interface.remote_switch_port_name + ) + if str(switch.vlan_group_name).endswith("-network"): + physical_network = switch.vlan_group_name + local_link_connection = { + "switch_id": interface.remote_switch_mac_address.lower(), + "port_id": interface.remote_switch_port_name, + "switch_info": switch.name, + } return PortConfiguration( - node_uuid=device.id, + node_uuid=device_uuid, address=interface.mac_address.lower(), - uuid=interface.id, name=name, pxe_enabled=pxe_enabled, local_link_connection=local_link_connection, - physical_network=interface.vlan_group_name, - ) - - -def interface_is_relevant(interface: NautobotInterface) -> bool: - return bool( - interface.mac_address and interface.name != "iDRAC" and interface.name != "iLo" + physical_network=physical_network, ) diff --git a/python/understack-workflows/understack_workflows/topology.py b/python/understack-workflows/understack_workflows/topology.py index f845b85be..7b48af789 100644 --- a/python/understack-workflows/understack_workflows/topology.py +++ b/python/understack-workflows/understack_workflows/topology.py @@ -1,7 +1,8 @@ -from understack_workflows.nautobot_device import NautobotDevice +from understack_workflows.bmc_chassis_info import InterfaceInfo +from understack_workflows.data_center import switch_for_mac -def pxe_interface_name(nautobot_device: NautobotDevice) -> str: +def pxe_interface_name(interfaces: list[InterfaceInfo]) -> str: """Answer the interface that connects to a -1 switch with following rules. Of the interfaces connected to the "-1" switch, @@ -17,7 +18,7 @@ def pxe_interface_name(nautobot_device: NautobotDevice) -> str: However the switch roles, etc., don't seem set in stone and so I don't want to rely on that data for now. """ - switches = switch_connections(nautobot_device) + switches = switch_connections(interfaces) for interface_name, switch_name in switches.items(): if get_preferred_interface(interface_name, switch_name, "Integrated"): @@ -39,9 +40,11 @@ def get_preferred_interface(interface_name, switch_name, keyword): return keyword in interface_name and switch_name.split(".")[0].endswith("-1") -def switch_connections(nautobot_device: NautobotDevice) -> dict: +def switch_connections(interfaces: list[InterfaceInfo]) -> dict[str, str]: return { - i.name: i.neighbor_device_name - for i in nautobot_device.interfaces - if i.neighbor_device_name + i.name: switch_for_mac( + i.remote_switch_mac_address, i.remote_switch_port_name + ).name + for i in interfaces + if i.remote_switch_mac_address and i.remote_switch_port_name } diff --git a/workflows/argo-events/workflowtemplates/enroll-server.yaml b/workflows/argo-events/workflowtemplates/enroll-server.yaml index 4ba8d1faa..fe952961c 100644 --- a/workflows/argo-events/workflowtemplates/enroll-server.yaml +++ b/workflows/argo-events/workflowtemplates/enroll-server.yaml @@ -3,7 +3,7 @@ apiVersion: argoproj.io/v1alpha1 metadata: name: enroll-server annotations: - workflows.argoproj.io/title: Perform server discovery and update Nautobot and Ironic + workflows.argoproj.io/title: Perform server discovery and update Ironic workflows.argoproj.io/description: | Defined in `workflows/argo-events/workflowtemplates/enroll-server.yaml` kind: WorkflowTemplate @@ -89,9 +89,6 @@ spec: - mountPath: /etc/openstack name: baremetal-manage readOnly: true - - mountPath: /etc/nb-token/ - name: nb-token - readOnly: true - mountPath: /etc/bmc_master/ name: bmc-master readOnly: true @@ -106,9 +103,6 @@ spec: - name: bmc-master secret: secretName: bmc-master - - name: nb-token - secret: - secretName: nautobot-token - name: baremetal-manage secret: secretName: baremetal-manage From d61377b883065118e84c54529cd6e9d79e3f9185 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Fri, 21 Nov 2025 17:02:46 +0000 Subject: [PATCH 3/9] Add missing switches --- .../understack-workflows/understack_workflows/data_center.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/understack-workflows/understack_workflows/data_center.py b/python/understack-workflows/understack_workflows/data_center.py index 49178b023..79261dcc9 100644 --- a/python/understack-workflows/understack_workflows/data_center.py +++ b/python/understack-workflows/understack_workflows/data_center.py @@ -1,6 +1,7 @@ import re from dataclasses import dataclass +# TODO: this should be loaded from some external source VLAN_GROUP_SUFFIXES = { "-1": "network", "-2": "network", @@ -11,12 +12,15 @@ "-1d": "bmc", } +# TODO: this should be loaded from some external source SWITCH_NAME_BY_MAC = { "C4:7E:E0:E3:EC:2B": "f20-1-1.iad3.rackspace.net", "C4:7E:E0:E4:2E:2F": "f20-1-2.iad3.rackspace.net", "C4:4D:84:48:7A:00": "f20-1-1d.iad3.rackspace.net", "C4:7E:E0:E4:10:7F": "f20-2-1.iad3.rackspace.net", "C4:7E:E0:E4:32:DF": "f20-2-2.iad3.rackspace.net", + "9C:09:8B:E2:97:AB": "f20-2-1f.iad3.rackspace.net", + "D4:7F:35:A0:AE:33": "f20-2-2f.iad3.rackspace.net", "C4:4D:84:48:61:80": "f20-2-1d.iad3.rackspace.net", "C4:7E:E0:E4:55:3F": "f20-3-1.iad3.rackspace.net", "C4:7E:E0:E4:03:37": "f20-3-2.iad3.rackspace.net", From 4f609d65807da7355ffccb403b87c40607334ac2 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Fri, 21 Nov 2025 18:57:36 +0000 Subject: [PATCH 4/9] Remove obsolete workflow --- python/understack-workflows/pyproject.toml | 1 - .../main/undersync_device.py | 193 ------------------ .../workflowtemplates/undersync-device.yaml | 52 ----- 3 files changed, 246 deletions(-) delete mode 100644 python/understack-workflows/understack_workflows/main/undersync_device.py delete mode 100644 workflows/argo-events/workflowtemplates/undersync-device.yaml diff --git a/python/understack-workflows/pyproject.toml b/python/understack-workflows/pyproject.toml index 38cdf3968..7f59fcd80 100644 --- a/python/understack-workflows/pyproject.toml +++ b/python/understack-workflows/pyproject.toml @@ -32,7 +32,6 @@ dependencies = [ sync-keystone = "understack_workflows.main.sync_keystone:main" sync-provision-state = "understack_workflows.main.sync_provision_state:main" undersync-switch = "understack_workflows.main.undersync_switch:main" -undersync-device = "understack_workflows.main.undersync_device:main" enroll-server = "understack_workflows.main.enroll_server:main" bmc-password = "understack_workflows.main.print_bmc_password:main" bmc-kube-password = "understack_workflows.main.bmc_display_password:main" diff --git a/python/understack-workflows/understack_workflows/main/undersync_device.py b/python/understack-workflows/understack_workflows/main/undersync_device.py deleted file mode 100644 index e96104a8d..000000000 --- a/python/understack-workflows/understack_workflows/main/undersync_device.py +++ /dev/null @@ -1,193 +0,0 @@ -import argparse -import os -import sys -from pprint import pformat -from uuid import UUID - -import requests - -from understack_workflows.helpers import boolean_args -from understack_workflows.helpers import credential -from understack_workflows.helpers import parser_nautobot_args -from understack_workflows.helpers import setup_logger -from understack_workflows.nautobot import Nautobot -from understack_workflows.undersync.client import Undersync - -logger = setup_logger(__name__) - - -def update_nautobot(args) -> UUID: - device_id = args.device_id - interface_mac = args.interface_mac - network_name = args.network_name - - nb_url = args.nautobot_url - nb_token = args.nautobot_token or credential("nb-token", "token") - - logger.info( - "Updating Nautobot device_id=%s interface_mac=%s network_name=%s", - device_id, - interface_mac, - network_name, - ) - - if network_name == "tenant": - vlan_group_id = update_nautobot_for_tenant( - nb_url, nb_token, interface_mac, args.network_id - ) - elif network_name == "provisioning": - vlan_group_id = update_nautobot_for_provisioning( - nb_url, nb_token, device_id, interface_mac - ) - else: - raise ValueError(f"need provisioning or tenant, not {network_name=}") - - logger.info( - "Updated Nautobot device_id=%s interface_mac=%s network_name=%s", - device_id, - interface_mac, - network_name, - ) - return vlan_group_id - - -def update_nautobot_for_provisioning( - nb_url, nb_token, device_id: UUID, interface_mac: str -): - new_status = "Provisioning-Interface" - nautobot = Nautobot(nb_url, nb_token, logger=logger) - - interface = nautobot.update_switch_interface_status( - device_id, interface_mac, new_status - ) - if not interface.device: - raise Exception("Interface has no associated device") - vlan_group_id = vlan_group_id_for(interface.device.id, nautobot) - logger.debug( - "Switch interface %s %s found in vlan_group_id=%s", - interface.device, - interface, - vlan_group_id, - ) - return vlan_group_id - - -def vlan_group_id_for(device_id, nautobot): - query = """ - query($device_id: ID!){ - device(id: $device_id) { - rel_vlan_group_to_devices {id} - } - } - """ - variables = {"device_id": device_id} - result = nautobot.session.graphql.query(query=query, variables=variables) - if not result.json or result.json.get("errors"): - raise Exception(f"Nautobot vlan_group graphql query failed: {result}") - return result.json["data"]["device"]["rel_vlan_group_to_devices"]["id"] - - -def update_nautobot_for_tenant( - nb_url, nb_token, server_interface_mac: str, ucvni_id: UUID -) -> UUID: - """Runs a Nautobot Job to update a switch interface for tenant mode. - - The nautobot job will assign vlans as required and set the interface - into the correct mode for "normal" tenant operation. - - The vlan group ID is returned. - """ - # Making this http request directly because it was not clear how to get - # the pynautobot api client to call an arbitrary endpoint: - - uri = f"{nb_url}/api/plugins/undercloud-vni/prep_switch_interface" - payload = { - "ucvni_id": str(ucvni_id), - "server_interface_mac": str(server_interface_mac), - } - headers = { - "Authorization": f"Token {nb_token}", - "Content-Type": "application/json", - "Accept": "application/json", - } - - logger.debug( - "Running Nautobot prep_switch_interface job uri=%s payload=%s", uri, payload - ) - - response = requests.request("POST", uri, headers=headers, json=payload, timeout=30) - response_data = response.json() - logger.debug( - "Nautobot prep_switch_interface result: %s response_data=%s", - response, - response_data, - ) - response.raise_for_status() - - return response_data["vlan_group_id"] - - -def call_undersync(args, vlan_group_id: UUID): - undersync_token = credential("undersync", "token") - if not undersync_token: - logger.error("Please provide auth token for Undersync.") - sys.exit(1) - undersync = Undersync(undersync_token) - - try: - return undersync.sync_devices( - str(vlan_group_id), dry_run=args.dry_run, force=args.force - ) - except Exception as error: - logger.error(error) - sys.exit(2) - - -def argument_parser(): - parser = argparse.ArgumentParser( - prog=os.path.basename(__file__), - description="Trigger undersync run for a device", - ) - parser.add_argument( - "--interface-mac", type=str, required=True, help="Interface MAC address" - ) - parser.add_argument( - "--device-id", type=UUID, required=False, help="Nautobot device UUID" - ) - parser.add_argument("--network-name", required=True) - parser.add_argument( - "--network-id", type=UUID, required=True, help="Nautobot network UUID" - ) - parser = parser_nautobot_args(parser) - parser.add_argument( - "--force", - type=boolean_args, - help="Call Undersync's force endpoint", - required=False, - ) - parser.add_argument( - "--dry-run", - type=boolean_args, - help="Call Undersync's dry-run endpoint", - required=False, - ) - - return parser - - -def main(): - """Updates Interface Status in Nautobot and triggers Undersync. - - Updates Nautobot Device Interface status field and follows with - request to Undersync service, requesting sync for all of the - uplink_switches that the device is connected to. - """ - args = argument_parser().parse_args() - - vlan_group_id = update_nautobot(args) - response = call_undersync(args, vlan_group_id) - logger.info("Undersync returned: %s", pformat(response.json())) - - -if __name__ == "__main__": - main() diff --git a/workflows/argo-events/workflowtemplates/undersync-device.yaml b/workflows/argo-events/workflowtemplates/undersync-device.yaml deleted file mode 100644 index 3e355d342..000000000 --- a/workflows/argo-events/workflowtemplates/undersync-device.yaml +++ /dev/null @@ -1,52 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -metadata: - name: undersync-device - annotations: - workflows.argoproj.io/title: Updates Interface Status in Nautobot and triggers Undersync - workflows.argoproj.io/description: | - Defined in `workflows/argo-events/workflowtemplates/undersync-device.yaml` -kind: WorkflowTemplate -spec: - entrypoint: trigger-undersync - serviceAccountName: workflow - templates: - - name: trigger-undersync - container: - image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest - command: - - undersync-device - args: - - --interface-mac - - "{{workflow.parameters.interface_mac}}" - - --device-id - - "{{workflow.parameters.device_uuid}}" - - --network-name - - "{{workflow.parameters.network_name}}" - - --network-id - - "{{workflow.parameters.network_id}}" - - --dry-run - - "{{workflow.parameters.dry_run}}" - - --force - - "{{workflow.parameters.force}}" - volumeMounts: - - mountPath: /etc/nb-token/ - name: nb-token - readOnly: true - - mountPath: /etc/undersync/ - name: undersync-token - readOnly: true - inputs: - parameters: - - name: interface_mac - - name: device_uuid - - name: network_name - - name: network_id - - name: force - - name: dry_run - volumes: - - name: nb-token - secret: - secretName: nautobot-token - - name: undersync-token - secret: - secretName: undersync-token From 74925bd577487fd8d5787dc469262abb2a9c12de Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 24 Nov 2025 11:00:36 +0000 Subject: [PATCH 5/9] Remove unused Nautobot code --- python/understack-workflows/pyproject.toml | 1 - python/understack-workflows/tests/conftest.py | 7 - .../tests/fixture_nautobot_device.py | 103 ---- .../tests/test_nautobot_device.py | 152 ------ .../understack_workflows/nautobot_device.py | 505 ------------------ 5 files changed, 768 deletions(-) delete mode 100644 python/understack-workflows/tests/fixture_nautobot_device.py delete mode 100644 python/understack-workflows/tests/test_nautobot_device.py diff --git a/python/understack-workflows/pyproject.toml b/python/understack-workflows/pyproject.toml index 7f59fcd80..3b8b05b10 100644 --- a/python/understack-workflows/pyproject.toml +++ b/python/understack-workflows/pyproject.toml @@ -81,7 +81,6 @@ extend = "../pyproject.toml" target-version = "py310" [tool.ruff.lint.per-file-ignores] -"understack_workflows/nautobot_device.py" = ["UP031"] "tests/test_nautobot_event_parser.py" = ["E501"] "tests/test_bmc_credentials.py" = ["B017"] "tests/**/*.py" = [ diff --git a/python/understack-workflows/tests/conftest.py b/python/understack-workflows/tests/conftest.py index e313fb175..c1103f7d4 100644 --- a/python/understack-workflows/tests/conftest.py +++ b/python/understack-workflows/tests/conftest.py @@ -3,16 +3,9 @@ import openstack import pytest -from fixture_nautobot_device import FIXTURE_DELL_NAUTOBOT_DEVICE from pynautobot import __version__ as pynautobot_version from understack_workflows.nautobot import Nautobot -from understack_workflows.nautobot_device import NautobotDevice - - -@pytest.fixture -def dell_nautobot_device() -> NautobotDevice: - return FIXTURE_DELL_NAUTOBOT_DEVICE @pytest.fixture diff --git a/python/understack-workflows/tests/fixture_nautobot_device.py b/python/understack-workflows/tests/fixture_nautobot_device.py deleted file mode 100644 index a89215366..000000000 --- a/python/understack-workflows/tests/fixture_nautobot_device.py +++ /dev/null @@ -1,103 +0,0 @@ -from understack_workflows.nautobot_device import NautobotDevice -from understack_workflows.nautobot_device import NautobotInterface - -FIXTURE_DELL_NAUTOBOT_DEVICE = NautobotDevice( - id="a3a2983f-d906-4663-943c-c41ab73c9b62", - name="Dell-33GSW04", - location_id="da47f07f-b66a-4f0c-b780-4be8498e6129", - location_name="IAD3", - rack_id="1ccd4b4a-7ba3-4557-b1ad-1ba87aee96a6", - rack_name="F20-2", - interfaces=[ - NautobotInterface( - id="ac2f1eae-188e-4fc6-9245-f9a6cf8b4ea8", - name="NIC.Integrated.1-1", - type="A_25GBASE_X_SFP28", - description="Integrated NIC 1 Port 1", - mac_address="D4:04:E6:4F:8D:B4", - status="Active", - ip_address=None, - neighbor_device_id="275ef491-2b27-4d1b-bd45-330bd6b7e0cf", - neighbor_device_name="f20-2-1.iad3.rackspace.net", - neighbor_interface_id="f9a5cc87-d10a-4827-99e8-48961fd1d773", - neighbor_interface_name="Ethernet1/5", - neighbor_chassis_mac="9C:54:16:F5:AB:27", - neighbor_location_name="IAD3", - neighbor_rack_name="F20-2", - vlan_group_name="F20-2[1-2]", - ucvni_group_name="spine402-1.iad3", - ), - NautobotInterface( - id="39d98f09-3199-40e0-87dc-e5ed6dce78e5", - name="NIC.Integrated.1-2", - type="A_25GBASE_X_SFP28", - description="Integrated NIC 1 Port 2", - mac_address="D4:04:E6:4F:8D:B5", - status="Active", - ip_address=None, - neighbor_device_id="05f6715a-4dbe-4fd6-af20-1e73adb285c2", - neighbor_device_name="f20-2-2.iad3.rackspace.net", - neighbor_interface_id="2148cf50-f70e-42c9-9f68-8ce98d61498c", - neighbor_interface_name="Ethernet1/5", - neighbor_chassis_mac="9C:54:16:F5:AC:27", - neighbor_location_name="IAD3", - neighbor_rack_name="F20-2", - vlan_group_name="F20-2[1-2]", - ucvni_group_name="spine402-1.iad3", - ), - NautobotInterface( - id="7ac587c4-015b-4a0e-b579-91284cbd0406", - name="NIC.Slot.1-1", - type="A_25GBASE_X_SFP28", - description="NIC in Slot 1 Port 1", - mac_address="14:23:F3:F5:25:F0", - status="Active", - ip_address=None, - neighbor_device_id="05f6715a-4dbe-4fd6-af20-1e73adb285c2", - neighbor_device_name="f20-2-2.iad3.rackspace.net", - neighbor_interface_id="f72bb830-3f3c-4aba-b7d5-9680ea4d358e", - neighbor_interface_name="Ethernet1/6", - neighbor_chassis_mac="9C:54:16:F5:AD:27", - neighbor_location_name="IAD3", - neighbor_rack_name="F20-2", - vlan_group_name="F20-2[1-2]", - ucvni_group_name="spine402-1.iad3", - ), - NautobotInterface( - id="8c28941c-02cd-4aad-9e3f-93c39e08b58a", - name="NIC.Slot.1-2", - type="A_25GBASE_X_SFP28", - description="NIC in Slot 1 Port 2", - mac_address="14:23:F3:F5:25:F1", - status="Active", - ip_address=None, - neighbor_device_id="275ef491-2b27-4d1b-bd45-330bd6b7e0cf", - neighbor_device_name="f20-2-1.iad3.rackspace.net", - neighbor_interface_id="c210be75-1038-4ba3-9923-60050e1c5362", - neighbor_interface_name="Ethernet1/6", - neighbor_chassis_mac="9C:54:16:F5:AD:27", - neighbor_location_name="IAD3", - neighbor_rack_name="F20-2", - vlan_group_name="F20-2[1-2]", - ucvni_group_name="spine402-1.iad3", - ), - NautobotInterface( - id="60d880c7-8618-414e-b4b4-fb6ac448c992", - name="iDRAC", - type="A_25GBASE_X_SFP28", - description="Dedicated iDRAC interface", - mac_address="A8:3C:A5:35:43:86", - status="Active", - ip_address="10.46.96.156", - neighbor_device_id="912d38b1-1194-444c-8e19-5f455e16082e", - neighbor_device_name="f20-2-1d.iad3.rackspace.net", - neighbor_interface_id="4d010e0f-3135-4769-8bb0-71ba905edf01", - neighbor_interface_name="GigabitEthernet1/0/3", - neighbor_chassis_mac="9C:54:16:F5:AE:27", - neighbor_location_name="IAD3", - neighbor_rack_name="F20-2", - vlan_group_name=None, - ucvni_group_name=None, - ), - ], -) diff --git a/python/understack-workflows/tests/test_nautobot_device.py b/python/understack-workflows/tests/test_nautobot_device.py deleted file mode 100644 index 3d5a8dcb3..000000000 --- a/python/understack-workflows/tests/test_nautobot_device.py +++ /dev/null @@ -1,152 +0,0 @@ -import json -import pathlib - -from understack_workflows import nautobot_device -from understack_workflows.bmc_chassis_info import ChassisInfo -from understack_workflows.bmc_chassis_info import InterfaceInfo - - -def read_json_samples(file_path): - here = pathlib.Path(__file__).parent - ref = here.joinpath(file_path) - with ref.open("r") as f: - return json.loads(f.read()) - - -class FakeNautobot: - def __init__(self): - self.graphql = FakeNautobot.Graphql() - self.dcim = FakeNautobot.Dcim() - - class ApiRecord: - def __init__(self): - self.id = "qwerty-1234-qwerty-1234" - - def update(self, *_): - pass - - class Graphql: - def query(self, graphql, variables: dict): - if "pattern" in variables: - return FakeNautobot.SwitchResponse() - if "serial" in variables: - return FakeNautobot.GraphqlResponse( - "json_samples/bmc_chassis_info/R7615/nautobot_graphql_response_server_device_33GSW04.json" - ) - raise Exception(f"implement graphql faker {graphql}") - - class Dcim: - def __init__(self): - self.devices = FakeNautobot.RestApiEndpoint() - self.interfaces = FakeNautobot.RestApiEndpoint() - self.cables = FakeNautobot.RestApiEndpoint() - - class RestApiEndpoint: - def create(self, **kw): - return FakeNautobot.ApiRecord() - - def get(self, **kw): - match kw: - case {"serial": "33GSW04"}: - return None - case {"device": "qwerty-1234-qwerty-1234", "name": "iDRAC"}: - return None - case _: - return FakeNautobot.ApiRecord() - - class GraphqlResponse: - def __init__(self, name): - self.json = read_json_samples(name) - - class SwitchResponse: - def __init__(self): - self.json = { - "data": { - "devices": [ - { - "id": "leafsw-1234-3456-1234", - "name": "f20-3-1.iad3.iad3.rackspace.net", - "mac": "C4:7E:E0:E4:32:DF", - "role": {"name": "Tenant leaf"}, - "location": { - "id": "da47f07f-b66a-4f0c-b780-4be8498e6129", - "name": "IAD3", - }, - "rack": { - "id": "3dd3c0f6-c6cd-42ff-8e34-763d0795ea16", - "name": "F20-3", - }, - }, - { - "id": "leafsw-1234-3456-1234", - "name": "f20-3-2.iad3.iad3.rackspace.net", - "mac": "C4:7E:E0:E4:10:7F", - "role": {"name": "Tenant leaf"}, - "location": { - "id": "da47f07f-b66a-4f0c-b780-4be8498e6129", - "name": "IAD3", - }, - "rack": { - "id": "3dd3c0f6-c6cd-42ff-8e34-763d0795ea16", - "name": "F20-3", - }, - }, - { - "id": "leafsw-1234-3456-1234", - "name": "f20-3-1d.iad3.iad3.rackspace.net", - "mac": "C4:4D:04:48:61:80", - "role": {"name": "Tenant leaf"}, - "location": { - "id": "da47f07f-b66a-4f0c-b780-4be8498e6129", - "name": "IAD3", - }, - "rack": { - "id": "3dd3c0f6-c6cd-42ff-8e34-763d0795ea16", - "name": "F20-3", - }, - }, - ] - } - } - - -def test_find_or_create(dell_nautobot_device): - nautobot = FakeNautobot() - chassis_info = ChassisInfo( - manufacturer="Dell", - model_number="PowerEdge R7615", - serial_number="33GSW04", - bios_version="1.6.10", - bmc_ip_address="1.2.3.4", - power_on=True, - memory_gib=96, - cpu="AMD EPYC 666 444-Core Processor", - interfaces=[ - InterfaceInfo( - name="iDRAC", - description="Dedicated iDRAC interface", - mac_address="A8:3C:A5:35:43:86", - hostname="Dell-33GSW04", - remote_switch_mac_address="C4:4D:04:48:61:83", - remote_switch_port_name="GigabitEthernet1/0/3", - ), - InterfaceInfo( - description="NIC in Slot 1 Port 1", - mac_address="14:23:F3:F5:25:F0", - name="NIC.Slot.1-1", - remote_switch_mac_address="C4:7E:E0:E4:32:DF", - remote_switch_port_name="Ethernet1/6", - ), - InterfaceInfo( - description="NIC in Slot 1 Port 2", - mac_address="14:23:F3:F5:25:F1", - name="NIC.Slot.1-2", - remote_switch_mac_address="C4:7E:E0:E4:10:7F", - remote_switch_port_name="Ethernet1/6", - ), - ], - ) - - device = nautobot_device.find_or_create(chassis_info, "Dell-33GSW04", nautobot) - - assert device == dell_nautobot_device diff --git a/python/understack-workflows/understack_workflows/nautobot_device.py b/python/understack-workflows/understack_workflows/nautobot_device.py index c5866b9a9..e69de29bb 100644 --- a/python/understack-workflows/understack_workflows/nautobot_device.py +++ b/python/understack-workflows/understack_workflows/nautobot_device.py @@ -1,505 +0,0 @@ -import re -from dataclasses import dataclass -from ipaddress import IPv4Interface -from typing import Any - -import pynautobot - -from understack_workflows.bmc_chassis_info import ChassisInfo -from understack_workflows.bmc_chassis_info import InterfaceInfo -from understack_workflows.helpers import setup_logger - -logger = setup_logger(__name__) - -DEVICE_INITIAL_STATUS = "Planned" -DEVICE_ROLE = "server" -INTERFACE_TYPE = "25gbase-x-sfp28" -BMC_INTERFACE_TYPE = "1000base-t" - - -@dataclass -class NautobotInterface: - """Represent a Nautobot Server Network Interface.""" - - id: str - name: str - type: str - description: str - mac_address: str - status: str - ip_address: str | None - neighbor_device_id: str | None - neighbor_device_name: str | None - neighbor_interface_id: str | None - neighbor_interface_name: str | None - neighbor_chassis_mac: str | None - neighbor_location_name: str | None - neighbor_rack_name: str | None - vlan_group_name: str | None = None - ucvni_group_name: str | None = None - - -@dataclass -class NautobotDevice: - """Represent a Nautobot Server.""" - - id: str - name: str - location_id: str - location_name: str - rack_id: str - rack_name: str - interfaces: list[NautobotInterface] - - -def find_or_create( - chassis_info: ChassisInfo, device_name: str, nautobot -) -> NautobotDevice: - """Update existing or create new device using the Nautobot API.""" - # TODO: performance: our single graphql query here fetches the device from - # nautobot with all existing interfaces, macs, cable and connected switches. - # We then query some of those items again, which adds unnecessary - # round-trips to Nautobot and/or DRAC. - # - # TODO: delete any extra items from nautobot (however we don't want to - # delete cables that temporarily went down). - # - # TODO: look out for devices that have moved cabinet, or devices that are - # taking over a switchport that is already occupied by some other device - - # we should at least detect this and give a decent error message. - # - # TODO: make sure we are able to detect and remedy a change of switchport - # (e.g. cable moved due to bad port on switch) - # - # TODO: could also verify compliant topology, e.g.: - # - has a connection to both switch devices in vlan group - # - has 4 NICs - # - DRAC is connected to a DRAC switch - # - in-band interfaces are connected to leaf switches - # - we already verify that all connections are inside the same cabinet - - switches = switches_for(nautobot, chassis_info) - device = nautobot_server(nautobot, serial=chassis_info.serial_number) - if not device: - logger.info("Device %s not in Nautobot, creating", chassis_info.serial_number) - - location_id, rack_id = location_from(list(switches.values())) - payload = server_device_payload(location_id, rack_id, chassis_info, device_name) - logger.debug("Server device: %s", payload) - nautobot.dcim.devices.create(**payload) - # Re-run the graphql query to fetch any auto-created defaults from - # nautobot (e.g. it automatically creates a BMC interface): - device = nautobot_server(nautobot, serial=chassis_info.serial_number) - if not device: - raise Exception("Failed to create device in Nautobot") - - find_or_create_interfaces(nautobot, chassis_info, device.id, switches) - - # Run the graphql query yet again, to include all the data we just populated - # in nautobot. Fairly inefficient for the case where we didn't change - # anything, but we need the accurate data. - device = nautobot_server(nautobot, serial=chassis_info.serial_number) - if not device: - raise Exception("Failed to create device in Nautobot") - return device - - -def location_from(switches): - locations = { - (switch["location"]["id"], switch["rack"]["id"]) for switch in switches - } - if not locations: - raise Exception(f"Can't find locations for {switches}") - if len(locations) > 1: - raise Exception(f"Connected switches in multiple racks or DCs: {locations}") - return next(iter(locations)) - - -def switches_for(nautobot, chassis_info: ChassisInfo) -> dict[str, dict]: - """Get all possible switches from the discovered LLDP neighbor information. - - We search for two possible mac addresses for each neighbor because some - cisco switches report the chassis mac address while others report the - interface mac address. - """ - switch_macs = { - interface.remote_switch_mac_address - for interface in chassis_info.interfaces - if interface.remote_switch_mac_address - } - base_switch_macs = { - base_mac( - interface.remote_switch_mac_address, str(interface.remote_switch_port_name) - ) - for interface in chassis_info.interfaces - if interface.remote_switch_mac_address - } - switches = nautobot_switches(nautobot, switch_macs.union(base_switch_macs)) - if not switches: - raise Exception( - f"There are no switch Devices in nautobot that match the LLDP info " - f"reported by server BMC - I found no Devices where " - f"chassis_mac_address is one of {switch_macs}" - ) - return switches - - -def nautobot_switches(nautobot, mac_addresses: set[str]) -> dict[str, dict]: - """Get switches by MAC address. - - Assumes switch MAC addresses are present in Nautobot in a custom field on - Device called chassis_mac_address. - - Assumes that MAC addresses in Nautobot are normalized to upcase - AA:BB:CC:DD:EE:FF form. - - returns a dict[mac_address] -> dict switch information indexed by mac - """ - pattern = "|".join(mac_addresses) - - query = """ - query($pattern: [String!]){ - devices(cf_chassis_mac_address__re: $pattern){ - id name - mac: cf_chassis_mac_address - location { id name } - rack { id name } - } - } - """ - - result = nautobot.graphql.query(query, variables={"pattern": pattern}) - if not result.json or result.json.get("errors"): - raise Exception(f"Nautobot switch graphql query failed: {result}") - switches = result.json["data"]["devices"] - - return {switch["mac"]: switch for switch in switches} - - -def nautobot_switch(all_switches: dict[str, Any], interface: InterfaceInfo): - if interface.remote_switch_data_stale: - logger.info("Warning: BMC marked LLDP data stale for %s", interface.name) - - if not interface.remote_switch_mac_address or not interface.remote_switch_port_name: - raise ValueError(f"missing LDLDP info in {interface}") - - mac_address = interface.remote_switch_mac_address - base_mac_address = base_mac(mac_address, interface.remote_switch_port_name) - switch = all_switches.get(mac_address, all_switches.get(base_mac_address)) - if not switch: - raise Exception( - f"There is no switch Device in nautobot that matches the LLDP info " - f"reported by server BMC for {interface} - I was looking for " - f"chassis_mac_address= {mac_address}, or the calculated base mac " - f"chassis_mac_address= {base_mac_address}." - ) - return switch - - -def base_mac(mac: str, port_name: str) -> str: - """Given a mac addr, return the mac addr which is less. - - >>> base_mac("11:22:33:44:55:66", "Eth1/6") - "11:22:33:44:55:60" - """ - port_number = re.split(r"\D+", port_name)[-1] - if not port_number: - raise ValueError(f"Need numeric interface, not {port_name!r}") - port_number = int(port_number) - mac_number = int(re.sub(r"[^0-9a-fA-f]+", "", mac), 16) - base = mac_number - port_number - hexadecimal = f"{base:012X}" - return ":".join(hexadecimal[i : i + 2] for i in range(0, 12, 2)) - - -def server_device_payload( - location_id: str, rack_id: str, chassis_info: ChassisInfo, device_name: str -) -> dict: - return { - "status": {"name": DEVICE_INITIAL_STATUS}, - "role": {"name": DEVICE_ROLE}, - "device_type": { - "manufacturer": {"name": chassis_info.manufacturer}, - "model": chassis_info.model_number, - }, - "name": device_name, - "serial": chassis_info.serial_number, - "rack": rack_id, - "location": location_id, - } - - -def nautobot_server(nautobot, serial: str) -> NautobotDevice | None: - query = """ - query($serial: String!){ - devices(serial: [$serial]){ - id name - location { id name } - rack { id name } - interfaces { - id name - type description mac_address - status { name } - connected_interface { - id name - device { - id name - mac: cf_chassis_mac_address - location { id name } - rack { id name } - vlan_group: rel_vlan_group_to_devices { - name - ucvni_group: rel_ucvnigroup_vlangroup { name } - } - } - } - ip_addresses { - id host - parent { prefix } - } - } - } - } - """ - - result = nautobot.graphql.query(query, variables={"serial": serial}) - if not result.json or result.json.get("errors"): - raise Exception(f"Nautobot server graphql query failed: {result}") - - devices = result.json["data"]["devices"] - - if not devices: - return None - - if len(devices) > 1: - raise Exception(f"Multiple nautobot devices found with serial {serial}") - - return parse_device(devices[0]) - - -def parse_device(data: dict) -> NautobotDevice: - return NautobotDevice( - id=data["id"], - name=data["name"], - location_id=data["location"]["id"], - location_name=data["location"]["name"], - rack_id=data["rack"]["id"], - rack_name=data["rack"]["name"], - interfaces=[parse_interface(i) for i in data["interfaces"]], - ) - - -def parse_interface(data: dict) -> NautobotInterface: - ip_address = data["ip_addresses"][0] if data["ip_addresses"] else None - connected = data["connected_interface"] - connected_device = connected and connected.get("device") - vlan_group = connected_device and connected_device.get("vlan_group") - ucvni_group = vlan_group and vlan_group.get("ucvni_group") - - return NautobotInterface( - id=data["id"], - name=data["name"], - mac_address=data["mac_address"], - status=data["status"]["name"], - type=data["type"], - description=data["description"], - ip_address=ip_address and ip_address["host"], - neighbor_interface_id=connected and connected["id"], - neighbor_interface_name=connected and connected["name"], - neighbor_device_id=connected and connected["device"]["id"], - neighbor_device_name=connected and connected["device"]["name"], - neighbor_chassis_mac=connected and connected["device"]["mac"], - neighbor_location_name=connected and connected["device"]["location"]["name"], - neighbor_rack_name=connected and connected["device"]["rack"]["name"], - vlan_group_name=vlan_group and vlan_group["name"], - ucvni_group_name=ucvni_group and ucvni_group["name"], - ) - - -def find_or_create_interfaces( - nautobot, chassis_info: ChassisInfo, device_id, switches: dict[str, dict] -): - """Update Nautobot Device Interfaces using the Nautobot API.""" - for interface in chassis_info.interfaces: - if interface.mac_address: - setup_nautobot_interface(nautobot, interface, device_id, switches) - - -def setup_nautobot_interface( - nautobot, interface: InterfaceInfo, device_id, switches: dict[str, dict] -): - nautobot_int = find_or_create_interface(nautobot, interface, device_id) - - if interface.ipv4_address: - ip = assign_ip_address( - nautobot, nautobot_int, interface.ipv4_address, interface.mac_address - ) - ip = associate_ip_address(nautobot, nautobot_int, ip.id) - - if interface.remote_switch_mac_address: - connect_interface_to_switch(nautobot, interface, nautobot_int, switches) - - -def find_or_create_interface(nautobot, interface: InterfaceInfo, device_id: str): - id = { - "device": device_id, - "name": interface.name, - } - attrs = { - "type": interface_type(interface), - "status": "Active", - "description": interface.description, - "mac_address": interface.mac_address, - } - server_nautobot_interface = nautobot.dcim.interfaces.get(**id) - if server_nautobot_interface: - logger.info( - "Found existing interface %s %s in Nautobot", - interface.name, - server_nautobot_interface.id, - ) - server_nautobot_interface.update(attrs) - else: - server_nautobot_interface = nautobot.dcim.interfaces.create(**id, **attrs) - logger.info( - "Created interface %s %s in Nautobot", - interface.name, - server_nautobot_interface.id, - ) - return server_nautobot_interface - - -def interface_type(interface: InterfaceInfo) -> str: - if interface.name in ["iDRAC", "iLO"]: - return BMC_INTERFACE_TYPE - else: - return INTERFACE_TYPE - - -def connect_interface_to_switch( - nautobot, interface, server_nautobot_interface, switches -): - connected_switch = nautobot_switch(switches, interface) - switch_port_name = interface.remote_switch_port_name - - switch_interface = nautobot.dcim.interfaces.get( - device=connected_switch["id"], - name=switch_port_name, - ) - if switch_interface is None: - raise Exception( - f"{connected_switch['name']} has no interface called {switch_port_name}" - ) - else: - logger.info( - "Interface %s connects to %s %s", - interface.name, - connected_switch["name"], - switch_port_name, - ) - - identity = { - "termination_a_id": switch_interface.id, - "termination_b_id": server_nautobot_interface.id, - } - attrs = { - "status": "Connected", - "termination_a_type": "dcim.interface", - "termination_b_type": "dcim.interface", - } - - cable = nautobot.dcim.cables.get(**identity) - if cable is None: - try: - cable = nautobot.dcim.cables.create(**identity, **attrs) - except pynautobot.core.query.RequestError as e: # type: ignore - raise Exception( - f"Failed to document discovered server in Nautobot - Server " - f"Interface {server_nautobot_interface.id} {interface.name} " - f"is detected as connected to Switch Interface " - f"{switch_interface.id} {connected_switch['name']} " - f"{switch_port_name}, but in Nautobot, when we try to create " - f"that cable {identity}, Nautobot gave error {e}" - ) from None - logger.info("Created cable %s in Nautobot", cable.id) - else: - logger.info("Cable %s already correctly exists in Nautobot", cable.id) - - -def assign_ip_address(nautobot, nautobot_interface, ipv4_address: IPv4Interface, mac): - """Find or create IP Address in Nautobot IPAM. - - If the existing IP address is a "dhcp" type then upgrade it to a "host" type. - """ - try: - ip = nautobot.ipam.ip_addresses.get(address=str(ipv4_address.ip)) - if ip and ip.type == "dhcp" and ip.custom_fields.get("pydhcp_mac") == mac: - logger.info("Making DHCP lease permanent in Nautobot %s", dict(ip)) - ip.update(type="host", cf_pydhcp_expire=None) - elif ip: - logger.info("IP Address %s found, %s in Nautobot", ipv4_address, ip.id) - else: - ip = nautobot.ipam.ip_addresses.create( - address=str(ipv4_address.ip), - status="Active", - parent={ - "type": "network", - "prefix": str(ipv4_address.network), - }, - ) - logger.info("Created Nautobot IP %s for %s", ip.id, ipv4_address) - except pynautobot.core.query.RequestError as e: # type: ignore - raise Exception(f"Failed to assign {ipv4_address=} in Nautobot: {e}") from None - return ip - - -def associate_ip_address(nautobot, nautobot_interface, ip_id): - """Associate a given IP Address with a given Interface in Nautobot IPAM. - - If the IP Address is already associated with some other Interface then an - Exception is raised. - - If the Interface is already associated to some other IP address then an - Exception is raised. - """ - existing_record = nautobot.ipam.ip_address_to_interface.get(ip_address=ip_id) - - if existing_record and existing_record.interface.id == nautobot_interface.id: - logger.info( - "IP Address %s {ip_id} already on %s", ip_id, nautobot_interface.name - ) - return - elif existing_record: - raise Exception( - f"Failed to document discovered server IP Address in Nautobot - " - f"We need to associate IP address {ip_id} with the server " - f"interface {nautobot_interface.id}, but the IP address is already " - f"associated with another interface {existing_record.interface.id} " - f"({existing_record.display}) Please resolve IP address clash and " - f"then re-try enrollment." - ) - - existing_record = nautobot.ipam.ip_address_to_interface.get( - interface=nautobot_interface.id - ) - if existing_record: - raise Exception( - f"Failed to document discovered server IP Address in Nautobot - " - f"We need to associate IP address {ip_id} with the server " - f"interface {nautobot_interface.id}, but that interface is already " - f"associated with a different IP address {existing_record.id} " - f"({existing_record.display}) Please resolve IP address clash and " - f"then re-try enrollment." - ) - - try: - nautobot.ipam.ip_address_to_interface.create( - ip_address=ip_id, interface=nautobot_interface.id, is_primary=True - ) - except pynautobot.core.query.RequestError as e: # type: ignore - raise Exception( - f"Failed to associate IPAddress {ip_id} in Nautobot: {e}" - ) from None - logger.info( - "Associated IP address %s {ip_id} with %s", ip_id, nautobot_interface.name - ) From 4a77fbf792463e8f73037d0f26dab9b9aa45e4bb Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 24 Nov 2025 13:40:43 +0000 Subject: [PATCH 6/9] Don't touch any Ironic interfaces during enrol workflow Interfaces are to be managed by a post-inspection hook --- .../understack_workflows/bmc_bios.py | 2 +- .../understack_workflows/data_center.py | 98 ------------- .../main/enroll_server.py | 35 +---- .../understack_workflows/sync_interfaces.py | 137 ------------------ 4 files changed, 5 insertions(+), 267 deletions(-) delete mode 100644 python/understack-workflows/understack_workflows/data_center.py delete mode 100644 python/understack-workflows/understack_workflows/sync_interfaces.py diff --git a/python/understack-workflows/understack_workflows/bmc_bios.py b/python/understack-workflows/understack_workflows/bmc_bios.py index ab8108a98..a0cda7588 100644 --- a/python/understack-workflows/understack_workflows/bmc_bios.py +++ b/python/understack-workflows/understack_workflows/bmc_bios.py @@ -20,7 +20,7 @@ def required_bios_settings(pxe_interface: str) -> dict: } -def update_dell_bios_settings(bmc: Bmc, pxe_interface="NIC.Slot.1-1") -> dict: +def update_dell_bios_settings(bmc: Bmc, pxe_interface="NIC.Integrated.1-1") -> dict: """Check and update BIOS settings to standard as required. Any changes take effect on next server reboot. diff --git a/python/understack-workflows/understack_workflows/data_center.py b/python/understack-workflows/understack_workflows/data_center.py deleted file mode 100644 index 79261dcc9..000000000 --- a/python/understack-workflows/understack_workflows/data_center.py +++ /dev/null @@ -1,98 +0,0 @@ -import re -from dataclasses import dataclass - -# TODO: this should be loaded from some external source -VLAN_GROUP_SUFFIXES = { - "-1": "network", - "-2": "network", - "-1f": "storage", - "-2f": "storage", - "-3f": "storage-appliance", - "-4f": "storage-appliance", - "-1d": "bmc", -} - -# TODO: this should be loaded from some external source -SWITCH_NAME_BY_MAC = { - "C4:7E:E0:E3:EC:2B": "f20-1-1.iad3.rackspace.net", - "C4:7E:E0:E4:2E:2F": "f20-1-2.iad3.rackspace.net", - "C4:4D:84:48:7A:00": "f20-1-1d.iad3.rackspace.net", - "C4:7E:E0:E4:10:7F": "f20-2-1.iad3.rackspace.net", - "C4:7E:E0:E4:32:DF": "f20-2-2.iad3.rackspace.net", - "9C:09:8B:E2:97:AB": "f20-2-1f.iad3.rackspace.net", - "D4:7F:35:A0:AE:33": "f20-2-2f.iad3.rackspace.net", - "C4:4D:84:48:61:80": "f20-2-1d.iad3.rackspace.net", - "C4:7E:E0:E4:55:3F": "f20-3-1.iad3.rackspace.net", - "C4:7E:E0:E4:03:37": "f20-3-2.iad3.rackspace.net", - "C4:B3:6A:C8:33:80": "f20-3-1d.iad3.rackspace.net", - "40:14:82:81:3E:E3": "f20-3-1f.iad3.rackspace.net", - "C4:7E:E0:E7:A0:37": "f20-3-2f.iad3.rackspace.net", -} - - -@dataclass -class Switch: - """A switch managed by understack.""" - - name: str - vlan_group_name: str | None - - -def switch_for_mac(mac: str, port_name: str) -> Switch: - """Find switch by MAC Address. - - We "discover" our switch connections via LLDP, the iDRAC implementation of - which provides us with the switch MAC address instead of its hostname, - therefore we need to find the switch by MAC Address. - - The MAC address is one of the fields in the LLDP wire protocol, however some - Cisco switches implement this incorrectly and provide the MAC address of the - port, rather than the Chassis. We work around this behaviour by searching - for both MAC addresses. - """ - mac = mac.upper() - base_mac = _base_mac(mac, port_name) - - name = SWITCH_NAME_BY_MAC.get(mac) or SWITCH_NAME_BY_MAC.get(base_mac) - if not name: - raise ValueError( - f"We don't have a switch that matches the LLDP info " - f"reported by server BMC for {port_name}, neither " - f"{mac}, or the calculated base mac {base_mac}." - ) - - return Switch( - name=name, - vlan_group_name=vlan_group_name(name), - ) - - -def vlan_group_name(switch_name: str) -> str | None: - """Return a VLAN Group name based on our naming convention. - - >>> vlan_group_name("a1-1-1.abc1") - "a1-1-network" - """ - switch_name = switch_name.split(".")[0] - - for switch_name_suffix, vlan_group_suffix in VLAN_GROUP_SUFFIXES.items(): - if switch_name.endswith(switch_name_suffix): - cabinet_name = switch_name.removesuffix(switch_name_suffix) - return f"{cabinet_name}-{vlan_group_suffix}" - return None - - -def _base_mac(mac: str, port_name: str) -> str: - """Given a mac addr, return the mac addr which is less. - - >>> base_mac("11:22:33:44:55:66", "Eth1/6") - "11:22:33:44:55:60" - """ - port_number = re.split(r"\D+", port_name)[-1] - if not port_number: - raise ValueError(f"Need numeric interface, not {port_name!r}") - port_number = int(port_number) - mac_number = int(re.sub(r"[^0-9a-fA-f]+", "", mac), 16) - base = mac_number - port_number - hexadecimal = f"{base:012X}" - return ":".join(hexadecimal[i : i + 2] for i in range(0, 12, 2)) diff --git a/python/understack-workflows/understack_workflows/main/enroll_server.py b/python/understack-workflows/understack_workflows/main/enroll_server.py index aaa9cff96..325e298c1 100644 --- a/python/understack-workflows/understack_workflows/main/enroll_server.py +++ b/python/understack-workflows/understack_workflows/main/enroll_server.py @@ -5,8 +5,6 @@ from understack_workflows import ironic_node -from understack_workflows.sync_interfaces import update_ironic_baremetal_ports -from understack_workflows import topology from understack_workflows.bmc import Bmc from understack_workflows.bmc import bmc_for_ip_address from understack_workflows.bmc_bios import update_dell_bios_settings @@ -14,7 +12,6 @@ from understack_workflows.bmc_hostname import bmc_set_hostname from understack_workflows.bmc_settings import update_dell_drac_settings from understack_workflows.discover import discover_chassis_info -from understack_workflows.helpers import credential from understack_workflows.helpers import setup_logger logger = setup_logger(__name__) @@ -27,18 +24,6 @@ def main(): """On-board new or Refresh existing baremetal node. - We have been invoked because a baremetal node is available. - - Pre-requisites: - - All connected switches must be known to us via the base MAC address in our - data center yaml data. - - The server Device type must exist, with a name that matches the "model" as - reported by the BMC. - - This script has the following order of operations: - - connect to the BMC using standard password, if that fails then use password supplied in --old-bmc-password option, or factory default @@ -46,8 +31,6 @@ def main(): - if DHCP, set permanent IP address, netmask, default gw - - if server is off, power it on and wait (otherwise LLDP doesn't work) - - TODO: create and install SSL certificate - TODO: set NTP Server IPs for DRAC @@ -56,6 +39,7 @@ def main(): - Using BMC, configure our standard BIOS settings - set PXE boot device - set timezone to UTC + - set the hostname - from BMC, discover basic hardware info: - manufacturer, model number, serial number @@ -65,11 +49,9 @@ def main(): - MAC address - LLDP connections [{remote_mac, remote_interface_name}] - - Determine flavor of the server based on the information collected from BMC - - Find or create this baremetal node in Ironic - - create baremetal ports for each NIC except BMC. Set one of them to PXE. - - set flavor + - set the name to "{manufacturer}-{servicetag}" + - set the driver as appropriate """ args = argument_parser().parse_args() @@ -99,13 +81,11 @@ def enroll_server(bmc: Bmc, old_password: str | None) -> str: update_dell_drac_settings(bmc) - pxe_interface = topology.pxe_interface_name(device_info.interfaces) - bmc_set_hostname(bmc, device_info.bmc_hostname, device_name) # Note the above may require a restart of the DRAC, which in turn may delete # any pending BIOS jobs, so do BIOS settings after the DRAC settings. - update_dell_bios_settings(bmc, pxe_interface=pxe_interface) + update_dell_bios_settings(bmc) node = ironic_node.create_or_update( bmc=bmc, @@ -113,13 +93,6 @@ def enroll_server(bmc: Bmc, old_password: str | None) -> str: manufacturer=device_info.manufacturer, ) logger.info("%s _ironic_provision_state=%s", device_name, node.provision_state) - - update_ironic_baremetal_ports( - ironic_node=node, - discovered_interfaces=device_info.interfaces, - pxe_interface_name=pxe_interface, - ) - logger.info("%s complete for %s", __file__, bmc.ip_address) return node.uuid diff --git a/python/understack-workflows/understack_workflows/sync_interfaces.py b/python/understack-workflows/understack_workflows/sync_interfaces.py deleted file mode 100644 index 4b9b024c6..000000000 --- a/python/understack-workflows/understack_workflows/sync_interfaces.py +++ /dev/null @@ -1,137 +0,0 @@ -from ironicclient.v1.port import Port - -from understack_workflows.helpers import setup_logger -from understack_workflows.ironic.client import IronicClient -from understack_workflows.port_configuration import PortConfiguration -from understack_workflows.bmc_chassis_info import InterfaceInfo -from understack_workflows import data_center - -logger = setup_logger(__name__) - - -def update_ironic_baremetal_ports( - ironic_node, - discovered_interfaces: list[InterfaceInfo], - pxe_interface_name: str, - ironic_client: IronicClient | None = None, -): - """Update Ironic baremetal ports to match interfaces indicated by BMC.""" - device_uuid: str = ironic_node.uuid - if ironic_client is None: - ironic_client = IronicClient() - logger.info("Syncing Interfaces / Ports for Device %s ...", device_uuid) - - discovered_ports = dict_by_mac_address( - make_port_infos( - interfaces=discovered_interfaces, - pxe_interface_name=pxe_interface_name, - device_name=ironic_node.name, - device_uuid=device_uuid, - ) - ) - logger.debug("Actual ports according to BMC: %s", discovered_ports) - - logger.info("Fetching Ironic Ports ...") - ironic_ports = dict_by_mac_address(ironic_client.list_ports(device_uuid)) - - for mac_address, ironic_port in ironic_ports.items(): - if mac_address not in discovered_ports: - logger.info( - "Server Interface %s no longer exists, " - "deleting corresponding Ironic Port %s", - mac_address, - ironic_port.uuid, - ) - response = ironic_client.delete_port(ironic_port.uuid) - logger.debug("Deleted: %s", response) - - for mac_address, actual_port in discovered_ports.items(): - ironic_port = ironic_ports.get(mac_address) - if ironic_port: - patch = get_patch(actual_port, ironic_port) - if patch: - logger.info("Updating Ironic Port %s, setting %s", ironic_port, patch) - response = ironic_client.update_port(ironic_port.uuid, patch) - logger.debug("Updated: %s", response) - else: - logger.debug("No changes required for Ironic Port %s", mac_address) - else: - logger.info("Creating Ironic Port %s ...", actual_port) - response = ironic_client.create_port(actual_port.dict()) - logger.debug("Created: %s", response) - - -def dict_by_mac_address(items: list) -> dict: - return {item.address: item for item in items} - - -def make_port_infos( - interfaces: list[InterfaceInfo], - pxe_interface_name: str, - device_uuid: str, - device_name: str, -) -> list[PortConfiguration]: - """Convert InterfaceInfo into PortConfiguration - - Excludes BMC interfaces and interfaces without a MAC address. - - Adds local_link and physical_network for interfaces that are connected to a - "network" switch. - """ - return [ - port_configuration(interface, pxe_interface_name, device_uuid, device_name) - for interface in interfaces - if ( - interface.mac_address - and interface.name != "iDRAC" - and interface.name != "iLo" - ) - ] - - -def port_configuration( - interface: InterfaceInfo, - pxe_interface_name: str, - device_uuid: str, - device_name: str, -) -> PortConfiguration: - # Interface names have device name prepended because Ironic wants them - # globally unique across all devices. - name = f"{device_name}:{interface.name}" - pxe_enabled = interface.name == pxe_interface_name - physical_network = None - local_link_connection = {} - - if interface.remote_switch_mac_address and interface.remote_switch_port_name: - switch = data_center.switch_for_mac( - interface.remote_switch_mac_address, interface.remote_switch_port_name - ) - if str(switch.vlan_group_name).endswith("-network"): - physical_network = switch.vlan_group_name - local_link_connection = { - "switch_id": interface.remote_switch_mac_address.lower(), - "port_id": interface.remote_switch_port_name, - "switch_info": switch.name, - } - - return PortConfiguration( - node_uuid=device_uuid, - address=interface.mac_address.lower(), - name=name, - pxe_enabled=pxe_enabled, - local_link_connection=local_link_connection, - physical_network=physical_network, - ) - - -def get_patch(nautobot_port: PortConfiguration, ironic_port: Port) -> list[dict]: - """Generate patch to change data in format expected by Ironic API. - - Compare attributes between Port objects and return a patch object - containing any changes. - """ - return [ - {"op": "replace", "path": f"/{key}", "value": required_value} - for key, required_value in dict(nautobot_port).items() - if getattr(ironic_port, key) != required_value - ] From 0f572aa683f454747fad84735a3bc1b23af0cad5 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 24 Nov 2025 14:26:33 +0000 Subject: [PATCH 7/9] Don't require LLDP neighbors to be found during server enrol --- .../understack_workflows/discover.py | 53 ------------------- .../main/enroll_server.py | 4 +- 2 files changed, 2 insertions(+), 55 deletions(-) delete mode 100644 python/understack-workflows/understack_workflows/discover.py diff --git a/python/understack-workflows/understack_workflows/discover.py b/python/understack-workflows/understack_workflows/discover.py deleted file mode 100644 index 284081c79..000000000 --- a/python/understack-workflows/understack_workflows/discover.py +++ /dev/null @@ -1,53 +0,0 @@ -import time - -from understack_workflows.bmc import Bmc -from understack_workflows.bmc_chassis_info import ChassisInfo -from understack_workflows.bmc_chassis_info import chassis_info -from understack_workflows.bmc_power import bmc_power_on -from understack_workflows.helpers import setup_logger - -logger = setup_logger(__name__) - -MIN_REQUIRED_NEIGHBOR_COUNT = 3 -LLDP_DISCOVERY_ATTEMPTS = 6 - - -def discover_chassis_info(bmc: Bmc) -> ChassisInfo: - """Query redfish, retrying until we get data that is acceptable. - - If the server is off, power it on. - - Make sure that we have at least MIN_REQUIRED_NEIGHBOR_COUNT LLDP neighbors - in the returned ChassisInfo. If that can't be achieved in a reasonable time - then raise an Exception. - """ - device_info = chassis_info(bmc) - - if not device_info.power_on: - logger.info("Server is powered off, sending power-on command to %s", bmc) - bmc_power_on(bmc) - - attempts_remaining = LLDP_DISCOVERY_ATTEMPTS - while len(device_info.neighbors) < MIN_REQUIRED_NEIGHBOR_COUNT: - lldp_table = { - i.name: f"{i.remote_switch_mac_address}/{i.remote_switch_port_name}" - for i in device_info.interfaces - } - logger.info( - "%s does not have enough LLDP neighbors, need %d or more, got %s", - bmc, - MIN_REQUIRED_NEIGHBOR_COUNT, - lldp_table, - ) - if not attempts_remaining: - raise Exception( - f"Only {len(device_info.neighbors)} LLDP neighbors appeared, " - f" but {MIN_REQUIRED_NEIGHBOR_COUNT} are required." - ) - logger.info("Retry in 30 seconds (attempts_remaining=%d)", attempts_remaining) - attempts_remaining = attempts_remaining - 1 - - time.sleep(30) - device_info = chassis_info(bmc) - - return device_info diff --git a/python/understack-workflows/understack_workflows/main/enroll_server.py b/python/understack-workflows/understack_workflows/main/enroll_server.py index 325e298c1..e746ad445 100644 --- a/python/understack-workflows/understack_workflows/main/enroll_server.py +++ b/python/understack-workflows/understack_workflows/main/enroll_server.py @@ -11,7 +11,7 @@ from understack_workflows.bmc_credentials import set_bmc_password from understack_workflows.bmc_hostname import bmc_set_hostname from understack_workflows.bmc_settings import update_dell_drac_settings -from understack_workflows.discover import discover_chassis_info +from understack_workflows.bmc_chassis_info import chassis_info from understack_workflows.helpers import setup_logger logger = setup_logger(__name__) @@ -74,7 +74,7 @@ def enroll_server(bmc: Bmc, old_password: str | None) -> str: old_password=old_password, ) - device_info = discover_chassis_info(bmc) + device_info = chassis_info(bmc) logger.info("Discovered %s", pformat(device_info)) device_name = f"{device_info.manufacturer}-{device_info.serial_number}" From 489492f74305ebc1781ae8a70d2bf4ff4cd71535 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Thu, 27 Nov 2025 12:53:27 +0000 Subject: [PATCH 8/9] Configure iDRAC to enable LLDP receive feature --- python/understack-workflows/understack_workflows/bmc_settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/understack-workflows/understack_workflows/bmc_settings.py b/python/understack-workflows/understack_workflows/bmc_settings.py index 62844b91f..7c267ec6f 100644 --- a/python/understack-workflows/understack_workflows/bmc_settings.py +++ b/python/understack-workflows/understack_workflows/bmc_settings.py @@ -11,6 +11,7 @@ "SNMP.1.SNMPProtocol": {"expect": "All", "new_value": "0"}, "SNMP.1.AgentCommunity": {"expect": "public", "new_value": "public"}, "SNMP.1.AlertPort": {"expect": 161, "new_value": 161}, + "SwitchConnectionView.1.Enable": {"expect": "Enabled", "new_value": "Enabled"}, } REDFISH_PATH = "/redfish/v1/Managers/iDRAC.Embedded.1/Attributes" From e803c5649bc164e2da8120f073d71c1b4b8c2b14 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Thu, 27 Nov 2025 13:02:51 +0000 Subject: [PATCH 9/9] Choose pxe interface based on order of preference of interfaces found --- .../main/enroll_server.py | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/python/understack-workflows/understack_workflows/main/enroll_server.py b/python/understack-workflows/understack_workflows/main/enroll_server.py index e746ad445..5053e1f92 100644 --- a/python/understack-workflows/understack_workflows/main/enroll_server.py +++ b/python/understack-workflows/understack_workflows/main/enroll_server.py @@ -11,7 +11,11 @@ from understack_workflows.bmc_credentials import set_bmc_password from understack_workflows.bmc_hostname import bmc_set_hostname from understack_workflows.bmc_settings import update_dell_drac_settings -from understack_workflows.bmc_chassis_info import chassis_info +from understack_workflows.bmc_chassis_info import ( + ChassisInfo, + InterfaceInfo, + chassis_info, +) from understack_workflows.helpers import setup_logger logger = setup_logger(__name__) @@ -76,16 +80,18 @@ def enroll_server(bmc: Bmc, old_password: str | None) -> str: device_info = chassis_info(bmc) logger.info("Discovered %s", pformat(device_info)) - device_name = f"{device_info.manufacturer}-{device_info.serial_number}" update_dell_drac_settings(bmc) bmc_set_hostname(bmc, device_info.bmc_hostname, device_name) + pxe_interface = guess_pxe_interface(device_info) + logger.info("Selected %s as PXE interface", pxe_interface) + # Note the above may require a restart of the DRAC, which in turn may delete # any pending BIOS jobs, so do BIOS settings after the DRAC settings. - update_dell_bios_settings(bmc) + update_dell_bios_settings(bmc, pxe_interface=pxe_interface) node = ironic_node.create_or_update( bmc=bmc, @@ -98,6 +104,29 @@ def enroll_server(bmc: Bmc, old_password: str | None) -> str: return node.uuid +def guess_pxe_interface(device_info: ChassisInfo) -> str: + interface = max(device_info.interfaces, key=_pxe_preference) + return interface.name + + +def _pxe_preference(interface: InterfaceInfo) -> int: + name = interface.name.upper() + if "DRAC" in name or "ILO" in name or "NIC.EMBEDDED" in name: + return 0 + + NIC_PREFERENCE = { + "NIC.Integrated.1-1-1": 100, + "NIC.Integrated.1-1": 99, + "NIC.Slot.1-1-1": 98, + "NIC.Slot.1-1": 97, + "NIC.Integrated.1-2-1": 96, + "NIC.Integrated.1-2": 95, + "NIC.Slot.1-2-1": 94, + "NIC.Slot.1-2": 93, + } + return NIC_PREFERENCE.get(interface.name, 50) + + def argument_parser(): parser = argparse.ArgumentParser( prog=os.path.basename(__file__), description="Ingest Baremetal Node"