diff --git a/python/understack-workflows/pyproject.toml b/python/understack-workflows/pyproject.toml index 38cdf3968..3b8b05b10 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" @@ -82,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_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 deleted file mode 100644 index 37295296b..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 Inc.", - 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, 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_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/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/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" 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/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 11e7cb6d6..5053e1f92 100644 --- a/python/understack-workflows/understack_workflows/main/enroll_server.py +++ b/python/understack-workflows/understack_workflows/main/enroll_server.py @@ -1,49 +1,33 @@ import argparse +import logging 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 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.bmc_chassis_info import ( + ChassisInfo, + InterfaceInfo, + chassis_info, +) from understack_workflows.helpers import setup_logger -from understack_workflows.nautobot_device import NautobotDevice 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. - We have been invoked because a baremetal node is available. - - Pre-requisites in Nautobot: - - 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). - - 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 password supplied in --old-bmc-password option, or factory default @@ -51,8 +35,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 @@ -61,6 +43,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 @@ -70,87 +53,78 @@ 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 - - set flavor - + - set the name to "{manufacturer}-{servicetag}" + - set the driver as appropriate """ 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, 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}" update_dell_drac_settings(bmc) - nb_device = nautobot_device.find_or_create(device_info, nautobot) - pxe_interface = topology.pxe_interface_name(nb_device) - - bmc_set_hostname(bmc, device_info.bmc_hostname, nb_device.name) + bmc_set_hostname(bmc, device_info.bmc_hostname, device_name) - # 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) + 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, 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) + logger.info("%s complete for %s", __file__, bmc.ip_address) - sync_interfaces.from_nautobot_to_ironic(nb_device, pxe_interface=pxe_interface) + return node.uuid - logger.info("%s complete for %s", __file__, bmc.ip_address) - return nb_device +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(): @@ -161,7 +135,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/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/python/understack-workflows/understack_workflows/nautobot_device.py b/python/understack-workflows/understack_workflows/nautobot_device.py index 377e22e14..e69de29bb 100644 --- a/python/understack-workflows/understack_workflows/nautobot_device.py +++ b/python/understack-workflows/understack_workflows/nautobot_device.py @@ -1,512 +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, 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) - 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 -) -> 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}, - "model": chassis_info.model_number, - }, - "name": 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!){ - 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 - ) 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 deleted file mode 100644 index 80dfccfc8..000000000 --- a/python/understack-workflows/understack_workflows/sync_interfaces.py +++ /dev/null @@ -1,119 +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.nautobot_device import NautobotDevice -from understack_workflows.nautobot_device import NautobotInterface -from understack_workflows.port_configuration import PortConfiguration - -logger = setup_logger(__name__) - - -def from_nautobot_to_ironic( - nautobot_device: NautobotDevice, pxe_interface: str, ironic_client=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) - - if ironic_client is None: - ironic_client = IronicClient() - - logger.info("Fetching Ironic Ports ...") - ironic_ports = dict_by_uuid(ironic_client.list_ports(nautobot_device.id)) - - for port_id, interface in ironic_ports.items(): - if port_id not in nautobot_ports: - logger.info( - "Nautobot Interface %s no longer exists, " - "deleting corresponding Ironic Port", - interface.uuid, - ) - response = ironic_client.delete_port(interface.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]) - if patch: - logger.info("Updating Ironic Port %s ...", nb_port) - response = ironic_client.update_port(port_id, patch) - logger.debug("Updated: %s", response) - else: - logger.debug("No changes required for Ironic Port %s", port_id) - else: - logger.info("Creating Ironic Port %s ...", nb_port) - response = ironic_client.create_port(nb_port.model_dump()) - logger.debug("Created: %s", response) - - -def dict_by_uuid(items: list) -> dict: - return {item.uuid: item for item in items} - - -def get_nautobot_interfaces( - nautobot_device: NautobotDevice, pxe_interface: str -) -> list[PortConfiguration]: - """Get Nautobot interfaces for a device. - - Returns a list of PortConfiguration - - Excludes interfaces with no MAC address - - """ - return [ - port_configuration(interface, pxe_interface, nautobot_device) - for interface in nautobot_device.interfaces - if interface_is_relevant(interface) - ] - - -def port_configuration( - interface: NautobotInterface, pxe_interface: str, device: NautobotDevice -) -> PortConfiguration: - # Interface names have their UUID 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 = {} - - return PortConfiguration( - node_uuid=device.id, - 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" - ) - - -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 - ] 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 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