diff --git a/components/images-openstack.yaml b/components/images-openstack.yaml index 2bcf45592..f0b521392 100644 --- a/components/images-openstack.yaml +++ b/components/images-openstack.yaml @@ -23,7 +23,7 @@ images: # ironic ironic_api: "ghcr.io/rackerlabs/understack/ironic:2025.1-ubuntu_jammy" - ironic_conductor: "ghcr.io/rackerlabs/understack/ironic:2025.1-ubuntu_jammy" + ironic_conductor: "ghcr.io/rackerlabs/understack/ironic:pr-1347" ironic_pxe: "ghcr.io/rackerlabs/understack/ironic:2025.1-ubuntu_jammy" ironic_pxe_init: "ghcr.io/rackerlabs/understack/ironic:2025.1-ubuntu_jammy" ironic_pxe_http: "docker.io/nginx:1.13.3" diff --git a/components/ironic/values.yaml b/components/ironic/values.yaml index 2f5a61e7a..2f3bc2d6b 100644 --- a/components/ironic/values.yaml +++ b/components/ironic/values.yaml @@ -88,7 +88,8 @@ conf: loader_file_paths: "snponly.efi:/usr/lib/ipxe/snponly.efi" inspector: extra_kernel_params: ipa-collect-lldp=1 - hooks: "$default_hooks,pci-devices,parse-lldp,local-link-connection,resource-class" + hooks: "$default_hooks,pci-devices,parse-lldp,resource-class,update-baremetal-port" + add_ports: "all" # enable sensors and metrics for redfish metrics - https://docs.openstack.org/ironic/latest/admin/drivers/redfish/metrics.html sensor_data: send_sensor_data: true diff --git a/python/ironic-understack/ironic_understack/conf.py b/python/ironic-understack/ironic_understack/conf.py index d41bbb234..335ea8661 100644 --- a/python/ironic-understack/ironic_understack/conf.py +++ b/python/ironic-understack/ironic_understack/conf.py @@ -10,6 +10,21 @@ def setup_conf(): "device_types_dir", help="directory storing Device Type description YAML files", default="/var/lib/understack/device-types", + ), + cfg.DictOpt( + "switch_name_vlan_group_mapping", + help="Dictionary of switch hostname suffix to vlan group name", + default={ + "1": "network", + "2": "network", + "3": "network", + "4": "network", + "1f": "storage", + "2f": "storage", + "3f": "storage-appliance", + "4f": "storage-appliance", + "1d": "bmc", + }, ) ] cfg.CONF.register_group(grp) diff --git a/python/ironic-understack/ironic_understack/output-inspection b/python/ironic-understack/ironic_understack/output-inspection new file mode 100644 index 000000000..3e504d9cf --- /dev/null +++ b/python/ironic-understack/ironic_understack/output-inspection @@ -0,0 +1,611 @@ +2025-10-21 10:06:12.416 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] called with task= + + +inventory = { + "interfaces": [ + { + "name": "eno3np0", + "mac_address": "d4:04:e6:4f:7a:dc", + "ipv4_address": "10.4.50.78", + "ipv6_address": "fe80::d604:e6ff:fe4f:7adc%eno3np0", + "has_carrier": True, + "lldp": [ + [1, "04c47ee0e4553f"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "44656c6c2d393347535730345f4e49432e496e74656772617465642e312d31"], + [5, "6632302d332d312e69616433"], + [127, "0001420101"], + [127, "00120f042328"], + [127, "0080c2030faa11343031305f70726f766973696f6e696e67"], + [127, "0080c2070100000000"], + [127, "00014208464c4d323732343042384d"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.0", + "driver": "bnxt_en", + }, + { + "name": "ens2f0np0", + "mac_address": "14:23:f3:f5:3c:a0", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.0", + "driver": "bnxt_en", + }, + { + "name": "eno4np1", + "mac_address": "d4:04:e6:4f:7a:dd", + "ipv4_address": None, + "ipv6_address": "fe80::d604:e6ff:fe4f:7add%eno4np1", + "has_carrier": True, + "lldp": [ + [1, "04401482813ee3"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "45746865726e6574312f31"], + [5, "6632302d332d31662e69616433"], + [127, "0001420101"], + [127, "00120f0405dc"], + [127, "0080c2070100000000"], + [127, "00014208464c4d3237323430384d4c"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.1", + "driver": "bnxt_en", + }, + { + "name": "ens2f1np1", + "mac_address": "14:23:f3:f5:3c:a1", + "ipv4_address": None, + "ipv6_address": "fe80::1623:f3ff:fef5:3ca1%ens2f1np1", + "has_carrier": True, + "lldp": [ + [1, "04c47ee0e7a037"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "45746865726e6574312f31"], + [5, "6632302d332d32662e69616433"], + [127, "0001420101"], + [127, "00120f0405dc"], + [127, "0080c2070100000000"], + [127, "00014208464c4d3237323430384e39"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.1", + "driver": "bnxt_en", + }, + { + "name": "eno8303", + "mac_address": "c4:cb:e1:bf:90:8e", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": None, + "biosdevname": None, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.0", + "driver": "tg3", + }, + { + "name": "eno8403", + "mac_address": "c4:cb:e1:bf:90:8f", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": None, + "biosdevname": None, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.1", + "driver": "tg3", + }, + ], + "cpu": { + "model_name": "AMD EPYC 9124 16-Core Processor", + "frequency": "", + "count": 32, + "architecture": "x86_64", + "flags": [ + "fpu", + "vme", + "de", + "pse", + "tsc", + "msr", + "pae", + "mce", + "cx8", + "apic", + "sep", + "mtrr", + "pge", + "mca", + "cmov", + "pat", + "pse36", + "clflush", + "mmx", + "fxsr", + "sse", + "sse2", + "ht", + "syscall", + "nx", + "mmxext", + "fxsr_opt", + "pdpe1gb", + "rdtscp", + "lm", + "constant_tsc", + "rep_good", + "amd_lbr_v2", + "nopl", + "nonstop_tsc", + "cpuid", + "extd_apicid", + "aperfmperf", + "rapl", + "pni", + "pclmulqdq", + "monitor", + "ssse3", + "fma", + "cx16", + "pcid", + "sse4_1", + "sse4_2", + "x2apic", + "movbe", + "popcnt", + "aes", + "xsave", + "avx", + "f16c", + "rdrand", + "lahf_lm", + "cmp_legacy", + "svm", + "extapic", + "cr8_legacy", + "abm", + "sse4a", + "misalignsse", + "3dnowprefetch", + "osvw", + "ibs", + "skinit", + "wdt", + "tce", + "topoext", + "perfctr_core", + "perfctr_nb", + "bpext", + "perfctr_llc", + "mwaitx", + "cpb", + "cat_l3", + "cdp_l3", + "invpcid_single", + "hw_pstate", + "ssbd", + "mba", + "perfmon_v2", + "ibrs", + "ibpb", + "stibp", + "ibrs_enhanced", + "vmmcall", + "fsgsbase", + "bmi1", + "avx2", + "smep", + "bmi2", + "erms", + "invpcid", + "cqm", + "rdt_a", + "avx512f", + "avx512dq", + "rdseed", + "adx", + "smap", + "avx512ifma", + "clflushopt", + "clwb", + "avx512cd", + "sha_ni", + "avx512bw", + "avx512vl", + "xsaveopt", + "xsavec", + "xgetbv1", + "xsaves", + "cqm_llc", + "cqm_occup_llc", + "cqm_mbm_total", + "cqm_mbm_local", + "avx512_bf16", + "clzero", + "irperf", + "xsaveerptr", + "rdpru", + "wbnoinvd", + "amd_ppin", + "cppc", + "arat", + "npt", + "lbrv", + "svm_lock", + "nrip_save", + "tsc_scale", + "vmcb_clean", + "flushbyasid", + "decodeassists", + "pausefilter", + "pfthreshold", + "avic", + "v_vmsave_vmload", + "vgif", + "x2avic", + "v_spec_ctrl", + "avx512vbmi", + "umip", + "pku", + "ospke", + "avx512_vbmi2", + "gfni", + "vaes", + "vpclmulqdq", + "avx512_vnni", + "avx512_bitalg", + "avx512_vpopcntdq", + "la57", + "rdpid", + "overflow_recov", + "succor", + "smca", + "fsrm", + "flush_l1d", + ], + "socket_count": 1, + }, + "disks": [ + { + "name": "/dev/sda", + "model": "PERC H755 Front", + "size": 479559942144, + "rotational": False, + "wwn": "0x6f4ee0806aa75c00300aa5c17e4ad41a", + "serial": "001ad44a7ec1a50a30005ca76a80e04e", + "vendor": "DELL", + "wwn_with_extension": "0x6f4ee0806aa75c00300aa5c17e4ad41a", + "wwn_vendor_extension": "0x300aa5c17e4ad41a", + "hctl": "0:3:110:0", + "by_path": "/dev/disk/by-path/pci-0000:41:00.0-scsi-0:3:110:0", + "logical_sectors": 512, + "physical_sectors": 512, + } + ], + "memory": {"total": 100793757696, "physical_mb": 98304}, + "bmc_address": "10.46.96.164", + "bmc_v6address": "::/0", + "system_vendor": { + "product_name": "PowerEdge R7615 (SKU=0AF7;ModelName=PowerEdge R7615)", + "serial_number": "93GSW04", + "manufacturer": "Dell Inc.", + "firmware": { + "vendor": "Dell Inc.", + "version": "1.6.10", + "build_date": "12/08/2023", + }, + }, + "boot": {"current_boot_mode": "uefi", "pxe_interface": "d4:04:e6:4f:7a:dc"}, + "hostname": "debian", + "bmc_mac": "a8:3c:a5:35:4a:b2", +} + + +plugin_data = { + "root_disk": { + "name": "/dev/sda", + "model": "PERC H755 Front", + "size": 479559942144, + "rotational": False, + "wwn": "0x6f4ee0806aa75c00300aa5c17e4ad41a", + "serial": "001ad44a7ec1a50a30005ca76a80e04e", + "vendor": "DELL", + "wwn_with_extension": "0x6f4ee0806aa75c00300aa5c17e4ad41a", + "wwn_vendor_extension": "0x300aa5c17e4ad41a", + "hctl": "0:3:110:0", + "by_path": "/dev/disk/by-path/pci-0000:41:00.0-scsi-0:3:110:0", + "logical_sectors": 512, + "physical_sectors": 512, + }, + "boot_interface": "d4:04:e6:4f:7a:dc", + "configuration": { + "collectors": ["default", "logs"], + "managers": [{"name": "generic_hardware_manager", "version": "1.2"}], + }, + "all_interfaces": { + "eno3np0": { + "name": "eno3np0", + "mac_address": "d4:04:e6:4f:7a:dc", + "ipv4_address": "10.4.50.78", + "ipv6_address": "fe80::d604:e6ff:fe4f:7adc", + "has_carrier": True, + "lldp": [ + [1, "04c47ee0e4553f"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "44656c6c2d393347535730345f4e49432e496e74656772617465642e312d31"], + [5, "6632302d332d312e69616433"], + [127, "0001420101"], + [127, "00120f042328"], + [127, "0080c2030faa11343031305f70726f766973696f6e696e67"], + [127, "0080c2070100000000"], + [127, "00014208464c4d323732343042384d"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.0", + "driver": "bnxt_en", + "pxe_enabled": True, + }, + "ens2f0np0": { + "name": "ens2f0np0", + "mac_address": "14:23:f3:f5:3c:a0", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.0", + "driver": "bnxt_en", + "pxe_enabled": False, + }, + "eno4np1": { + "name": "eno4np1", + "mac_address": "d4:04:e6:4f:7a:dd", + "ipv4_address": None, + "ipv6_address": "fe80::d604:e6ff:fe4f:7add", + "has_carrier": True, + "lldp": [ + [1, "04401482813ee3"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "45746865726e6574312f31"], + [5, "6632302d332d31662e69616433"], + [127, "0001420101"], + [127, "00120f0405dc"], + [127, "0080c2070100000000"], + [127, "00014208464c4d3237323430384d4c"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.1", + "driver": "bnxt_en", + "pxe_enabled": False, + }, + "ens2f1np1": { + "name": "ens2f1np1", + "mac_address": "14:23:f3:f5:3c:a1", + "ipv4_address": None, + "ipv6_address": "fe80::1623:f3ff:fef5:3ca1", + "has_carrier": True, + "lldp": [ + [1, "04c47ee0e7a037"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "45746865726e6574312f31"], + [5, "6632302d332d32662e69616433"], + [127, "0001420101"], + [127, "00120f0405dc"], + [127, "0080c2070100000000"], + [127, "00014208464c4d3237323430384e39"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.1", + "driver": "bnxt_en", + "pxe_enabled": False, + }, + "eno8303": { + "name": "eno8303", + "mac_address": "c4:cb:e1:bf:90:8e", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": None, + "biosdevname": None, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.0", + "driver": "tg3", + "pxe_enabled": False, + }, + "eno8403": { + "name": "eno8403", + "mac_address": "c4:cb:e1:bf:90:8f", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": None, + "biosdevname": None, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.1", + "driver": "tg3", + "pxe_enabled": False, + }, + }, + "valid_interfaces": { + "eno3np0": { + "name": "eno3np0", + "mac_address": "d4:04:e6:4f:7a:dc", + "ipv4_address": "10.4.50.78", + "ipv6_address": "fe80::d604:e6ff:fe4f:7adc", + "has_carrier": True, + "lldp": [ + [1, "04c47ee0e4553f"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "44656c6c2d393347535730345f4e49432e496e74656772617465642e312d31"], + [5, "6632302d332d312e69616433"], + [127, "0001420101"], + [127, "00120f042328"], + [127, "0080c2030faa11343031305f70726f766973696f6e696e67"], + [127, "0080c2070100000000"], + [127, "00014208464c4d323732343042384d"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.0", + "driver": "bnxt_en", + "pxe_enabled": True, + } + }, + "macs": ["d4:04:e6:4f:7a:dc"], +} + + + +__call__ /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:39 + +2025-10-21 10:06:12.429 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1459 for node 160 local_link_connection {'port_id': 'Ethernet1/1', 'switch_id': 'c4:7e:e0:e4:55:3f', 'switch_info': 'f20-3-1.iad3'} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.452 1 WARNING ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Failed to extract local_link_info from LLDP data for 160 +2025-10-21 10:06:12.453 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1463 for node 160 local_link_connection {} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.475 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1461 for node 160 local_link_connection {'port_id': 'Ethernet1/1', 'switch_id': '40:14:82:81:3e:e3', 'switch_info': 'f20-3-1f.iad3'} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.493 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1465 for node 160 local_link_connection {'port_id': 'Ethernet1/1', 'switch_id': 'c4:7e:e0:e7:a0:37', 'switch_info': 'f20-3-2f.iad3'} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.513 1 WARNING ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Failed to extract local_link_info from LLDP data for 160 +2025-10-21 10:06:12.513 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1454 for node 160 local_link_connection {} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.529 1 WARNING ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Failed to extract local_link_info from LLDP data for 160 +2025-10-21 10:06:12.530 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1457 for node 160 local_link_connection {} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Unexpected exception while running inspection hooks for node b6b6dcec-7d48-48c4-89ff-da04b8af40b7: AttributeError: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils Traceback (most recent call last): +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspect_utils.py", line 545, in run_inspection_hooks +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils _run_post_hooks(task, inventory, plugin_data, hooks) +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspect_utils.py", line 621, in _run_post_hooks +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils hook.obj.__call__(task, inventory, plugin_data) +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils File "/var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py", line 82, in __call__ +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils _update_node_traits(task, vlan_groups) +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils File "/var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py", line 177, in _update_node_traits +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils task.node.add_trait(TRAIT_STORAGE_SWITCH) +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils AttributeError: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils +2025-10-21 10:06:12.542 1 INFO ironic.drivers.utils [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Ramdisk logs were stored in local storage for node b6b6dcec-7d48-48c4-89ff-da04b8af40b7 +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Error when processing inspection data for node b6b6dcec-7d48-48c4-89ff-da04b8af40b7: ironic.common.exception.HardwareInspectionFailure: Failed to inspect hardware. Reason: Unexpected exception AttributeError during processing for node: b6b6dcec-7d48-48c4-89ff-da04b8af40b7. Error: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection Traceback (most recent call last): +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspect_utils.py", line 545, in run_inspection_hooks +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection _run_post_hooks(task, inventory, plugin_data, hooks) +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspect_utils.py", line 621, in _run_post_hooks +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection hook.obj.__call__(task, inventory, plugin_data) +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py", line 82, in __call__ +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection _update_node_traits(task, vlan_groups) +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py", line 177, in _update_node_traits +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection task.node.add_trait(TRAIT_STORAGE_SWITCH) +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection AttributeError: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection During handling of the above exception, another exception occurred: +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection Traceback (most recent call last): +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/inspection.py", line 125, in continue_inspection +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection result = task.driver.inspect.continue_inspection( +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspector/agent.py", line 94, in continue_inspection +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection inspect_utils.run_inspection_hooks(task, inventory, plugin_data, +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspect_utils.py", line 560, in run_inspection_hooks +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection raise exception.HardwareInspectionFailure(error=msg) +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection ironic.common.exception.HardwareInspectionFailure: Failed to inspect hardware. Reason: Unexpected exception AttributeError during processing for node: b6b6dcec-7d48-48c4-89ff-da04b8af40b7. Error: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection +2025-10-21 10:06:12.550 1 DEBUG ironic.common.states [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Exiting old state 'inspecting' in response to event 'fail' on_exit /var/lib/openstack/lib/python3.10/site-packages/ironic/common/states.py:361 +2025-10-21 10:06:12.550 1 DEBUG ironic.common.states [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Entering new state 'inspect failed' in response to event 'fail' on_enter /var/lib/openstack/lib/python3.10/site-packages/ironic/common/states.py:367 +2025-10-21 10:06:12.571 1 ERROR ironic.conductor.task_manager [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Node b6b6dcec-7d48-48c4-89ff-da04b8af40b7 moved to provision state "inspect failed" from state "inspecting"; target provision state is "manageable": ironic.common.exception.HardwareInspectionFailure: Failed to inspect hardware. Reason: Unexpected exception AttributeError during processing for node: b6b6dcec-7d48-48c4-89ff-da04b8af40b7. Error: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.585 1 DEBUG ironic.conductor.task_manager [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Successfully released exclusive lock for continue inspection on node b6b6dcec-7d48-48c4-89ff-da04b8af40b7 (lock was held 0.26 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:01.159 1 DEBUG ironic.common.hash_ring [-] Rebuilding cached hash rings ring /var/lib/openstack/lib/python3.10/site-packages/ironic/common/hash_ring.py:62 +2025-10-21 10:07:01.187 1 DEBUG ironic.common.hash_ring [-] Finished rebuilding hash rings, available drivers are :fake-hardware, :idrac, :ilo, :ilo5, :redfish ring /var/lib/openstack/lib/python3.10/site-packages/ironic/common/hash_ring.py:65 +2025-10-21 10:07:01.313 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 2fb79bdb-c925-4701-b304-b3768deeb85e (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.316 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 86eb7354-cc10-4173-8ff2-d1ac2ea6befd (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.321 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async import configuration task. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.322 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async firmware update tasks. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.325 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async RAID config failed. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.370 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking if async firmware update failed. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.371 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking if async update of firmware component failed. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.371 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async RAID config tasks. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.372 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async RAID config failed. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.373 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async firmware update tasks. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.378 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking if async firmware update failed. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.378 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async RAID config tasks. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.383 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async update of firmware component. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.384 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node a8a8548c-fc07-4d9c-a5f2-5f2c6fe7992c (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.387 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node b6b6dcec-7d48-48c4-89ff-da04b8af40b7 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.390 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 3271d507-9c1b-4440-bd39-0b1a9e779c5b (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.393 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 2f75cab3-63d7-45ad-9045-b80f44e86132 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.396 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 49f69ba1-bcce-4b43-aab5-a610a49f29bf (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.400 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 3df4d4ef-a65e-4f41-abe8-66169ea51a21 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:02.852 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node a8a8548c-fc07-4d9c-a5f2-5f2c6fe7992c (lock was held 1.47 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:02.857 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node b6b6dcec-7d48-48c4-89ff-da04b8af40b7 (lock was held 1.47 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:02.873 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node 2fb79bdb-c925-4701-b304-b3768deeb85e (lock was held 1.56 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:02.905 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node f86c82b1-dd24-41de-b32a-aeb3eb0ff020 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:02.908 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 5180e19d-c3c6-4afb-b626-08d70ec1f456 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:02.912 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node c5b249c8-e707-4acf-9c36-ad9ce574282f (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:02.939 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node 86eb7354-cc10-4173-8ff2-d1ac2ea6befd (lock was held 1.62 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:02.940 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node 3271d507-9c1b-4440-bd39-0b1a9e779c5b (lock was held 1.55 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:02.954 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 055818eb-7de7-43f5-b747-e8704ad7db45 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:02.961 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 609a8c97-32a2-4308-95ff-e4256706d28f (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:03.021 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node 2f75cab3-63d7-45ad-9045-b80f44e86132 (lock was held 1.63 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:03.038 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 5c1bfa75-d081-4fbe-9448-417eb54552b7 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:03.218 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node 49f69ba1-bcce-4b43-aab5-a610a49f29bf (lock was held 1.82 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:03.233 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 461737c4-037c-41bf-9c17-f4f33ff20dd7 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +-- +2 diff --git a/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py b/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py new file mode 100644 index 000000000..220c6f884 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py @@ -0,0 +1,57 @@ +from ironic.objects import port as ironic_port +from oslo_utils import uuidutils + +from ironic_understack.update_baremetal_port import UpdateBaremetalPortsHook + +_INTERFACE_1 = { + "name": "example1", + "mac_address": "11:11:11:11:11:11", + "ipv4_address": "1.1.1.1", + "lldp": [ + (0, ""), + (1, "04885a92ec5459"), + (2, "0545746865726e6574312f3138"), + (3, "0078"), + (5, "6632302d332d32662e69616433"), + ], +} + +_PLUGIN_DATA = {"all_interfaces": {"example1": _INTERFACE_1}} + +_INVENTORY = {"interfaces": [_INTERFACE_1]} + + +def test_with_valid_data(mocker): + node_uuid = uuidutils.generate_uuid() + mock_traits = mocker.Mock() + mock_context = mocker.Mock() + mock_node = mocker.Mock(id=1234, traits=mock_traits) + mock_task = mocker.Mock(node=mock_node, context=mock_context) + mock_port = mocker.Mock( + uuid=uuidutils.generate_uuid(), + node_id=node_uuid, + address="11:11:11:11:11:11", + local_link_connection={}, + physical_network="original_value", + ) + mocker.patch( + "ironic_understack.update_baremetal_port.objects.port.Port.get_by_address", + return_value=mock_port, + ) + + mock_traits.get_trait_names.return_value = ["CUSTOM_NETWORK_SWITCH", "bar"] + + UpdateBaremetalPortsHook().__call__(mock_task, _INVENTORY, _PLUGIN_DATA) + + assert mock_port.local_link_connection == { + "port_id": "Ethernet1/18", + "switch_id": "88:5a:92:ec:54:59", + "switch_info": "f20-3-2f.iad3", + } + assert mock_port.physical_network == "f20-3-storage" + mock_port.save.assert_called() + + mock_traits.get_trait_names.assert_called_once() + mock_traits.destroy.assert_called_once_with("CUSTOM_NETWORK_SWITCH") + mock_traits.create.assert_called_once_with(mock_context, 1234, ["CUSTOM_STORAGE_SWITCH"]) + mock_node.save.assert_called_once() diff --git a/python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py b/python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py new file mode 100644 index 000000000..73175ee61 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py @@ -0,0 +1,56 @@ +import pytest + +from ironic_understack.vlan_group_name_convention import vlan_group_name + +mapping = { + "1": "network", + "2": "network", + "3": "network", + "4": "network", + "1f": "storage", + "2f": "storage", + "3f": "storage-appliance", + "4f": "storage-appliance", + "1d": "bmc", +} + +def test_vlan_group_name_valid_switches(): + assert vlan_group_name("a1-1-1", mapping) == "a1-1-network" + assert vlan_group_name("a1-2-1", mapping) == "a1-2-network" + assert vlan_group_name("b12-1", mapping) == "b12-network" + assert vlan_group_name("a2-12-1", mapping) == "a2-12-network" + assert vlan_group_name("a2-12-2", mapping) == "a2-12-network" + assert vlan_group_name("a2-12-1f", mapping) == "a2-12-storage" + assert vlan_group_name("a2-12-2f", mapping) == "a2-12-storage" + assert vlan_group_name("a2-12-3f", mapping) == "a2-12-storage-appliance" + assert vlan_group_name("a2-12-4f", mapping) == "a2-12-storage-appliance" + assert vlan_group_name("a2-12-1d", mapping) == "a2-12-bmc" + + +def test_vlan_group_name_with_domain(): + assert vlan_group_name("a2-12-1.iad3.rackspace.net", mapping) == "a2-12-network" + assert vlan_group_name("a2-12-1f.lon3.rackspace.net", mapping) == "a2-12-storage" + + +def test_vlan_group_name_case_insensitive(): + assert vlan_group_name("A2-12-1F", mapping) == "a2-12-storage" + assert vlan_group_name("A2-12-1", mapping) == "a2-12-network" + + +def test_vlan_group_name_invalid_format(): + with pytest.raises(ValueError, match="Unknown switch name format"): + vlan_group_name("invalid", mapping) + + with pytest.raises(ValueError, match="Unknown switch name format"): + vlan_group_name("", mapping) + + +def test_vlan_group_name_unknown_suffix(): + with pytest.raises(ValueError, match="Switch suffix 99 is not present"): + vlan_group_name("a2-12-99", mapping) + + with pytest.raises(ValueError, match="Switch suffix 5f is not present"): + vlan_group_name("a2-12-5f", mapping) + + with pytest.raises(ValueError, match="Switch suffix xyz is not present"): + vlan_group_name("a2-12-xyz", mapping) diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py new file mode 100644 index 000000000..83eb8b8c0 --- /dev/null +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -0,0 +1,229 @@ +import binascii +from typing import Any + +import netaddr +import openstack +from construct import core +from ironic import objects +from ironic.common import exception +from ironic.drivers.modules.inspector import lldp_tlvs +from ironic.drivers.modules.inspector.hooks import base +from oslo_log import log as logging + +import ironic_understack.vlan_group_name_convention +from ironic_understack.conf import CONF + +LOG = logging.getLogger(__name__) + +LldpData = list[tuple[int, str]] + + +class UpdateBaremetalPortsHook(base.InspectionHook): + """Hook to update ports according to LLDP data.""" + + dependencies = ["validate-interfaces"] + + def __call__(self, task, inventory, plugin_data): + """Update Ports' local_link_info and physnet based on LLDP data. + + Process the LLDP packet fields for each NIC in the inventory. + + Updates attributes of the baremetal port: + + - local_link_info.port_id (e.g. "Ethernet1/1") + - local_link_info.switch_id (e.g. "aa:bb:cc:dd:ee:ff") + - local_link_info.switch_info (e.g. "a1-1-1.ord1") + - physical_network (e.g. "a1-1-network") + - pxe_boot flag? + + Also adds or removes node "traits" based on the inventory data. We + control the trait "CUSTOM_STORAGE_SWITCH". + + TODO: The IPA image will normally have exactly one inventory.interfaces + with an ipv4_address address and has_carrier set to True. This is our + pxe boot interface. We should clear the pxe interface flag on all other + baremetal ports. + """ + LOG.debug(f"{__class__} called with {task=!r} {inventory=!r} {plugin_data=!r}") + + lldp_raw: dict[str, LldpData] = plugin_data.get("lldp_raw") or {} + node_uuid: str = task.node.uuid + interfaces: list[dict] = inventory["interfaces"] + # The all_interfaces field in plugin_data is provided by the + # validate-interfaces hook, so it is a dependency for this hook + all_interfaces: dict[str, dict] = plugin_data["all_interfaces"] + context = task.context + vlan_groups: set[str] = set() + + for iface in interfaces: + if iface["name"] not in all_interfaces: + # This interface was not "validated" so don't bother with it + continue + + mac_address = iface["mac_address"] + port = objects.port.Port.get_by_address(context, mac_address) + if not port: + LOG.debug( + "Skipping LLDP processing for interface %s of node " + "%s: matching port not found in Ironic.", + mac_address, + node_uuid, + ) + continue + + lldp_data = lldp_raw.get(iface["name"]) or iface.get("lldp") + if not lldp_data: + LOG.warning( + "No LLDP data found for interface %s of node %s", + mac_address, + node_uuid, + ) + continue + + local_link_connection = _parse_lldp(lldp_data, node_uuid) + vlan_group = vlan_group_name(local_link_connection) + + _set_port_local_link_connection(port, node_uuid, local_link_connection) + _set_port_physical_network(port, vlan_group) + if vlan_group: + vlan_groups.add(vlan_group) + _set_node_traits(task, vlan_groups) + + +def _set_port_local_link_connection(port: Any, node_uuid: str, local_link_connection: dict): + try: + LOG.debug( + "Updating port %s for node %s local_link_connection %s", + port.uuid, + node_uuid, + local_link_connection, + ) + port.local_link_connection = local_link_connection + port.save() + except exception.IronicException as e: + LOG.warning( + "Failed to update port %(uuid)s for node %(node)s. Error: %(error)s", + {"uuid": port.id, "node": node_uuid, "error": e}, + ) + + +def _parse_lldp(lldp_data: LldpData, node_id: str) -> dict[str, str]: + """Convert Ironic's "lldp_raw" format to local_link dict.""" + try: + decoded = {} + for tlv_type, tlv_value in lldp_data: + if tlv_type not in decoded: + decoded[tlv_type] = [] + decoded[tlv_type].append(bytearray(binascii.unhexlify(tlv_value))) + + port_id = _extract_port_id(decoded) + switch_id = _extract_switch_id(decoded) + switch_info = _extract_hostname(decoded) + if port_id and switch_id and switch_info: + return { + "port_id": port_id, + "switch_id": switch_id, + "switch_info": switch_info, + } + LOG.warning("Failed to extract local_link_info from LLDP data for %s", node_id) + except (binascii.Error, core.MappingError, netaddr.AddrFormatError) as e: + LOG.warning("Failed to parse lldp_raw data for Node %s: %s", node_id, e) + return {} + + +def _extract_port_id(data: dict) -> str | None: + for value in data.get(lldp_tlvs.LLDP_TLV_PORT_ID, []): + parsed = lldp_tlvs.PortId.parse(value) + if parsed.value: # pyright: ignore reportAttributeAccessIssue + return parsed.value.value # pyright: ignore reportAttributeAccessIssue + + +def _extract_switch_id(data: dict) -> str | None: + for value in data.get(lldp_tlvs.LLDP_TLV_CHASSIS_ID, []): + parsed = lldp_tlvs.ChassisId.parse(value) + if "mac_address" in parsed.subtype: # pyright: ignore reportAttributeAccessIssue + return str(parsed.value.value) # pyright: ignore reportAttributeAccessIssue + + +def _extract_hostname(data: dict) -> str | None: + for value in data.get(lldp_tlvs.LLDP_TLV_SYS_NAME, []): + parsed = lldp_tlvs.SysName.parse(value) + if parsed.value: # pyright: ignore reportAttributeAccessIssue + return parsed.value # pyright: ignore reportAttributeAccessIssue + + +def vlan_group_name(local_link_connection) -> str | None: + switch_name = local_link_connection.get("switch_info") + if not switch_name: + return + + return ironic_understack.vlan_group_name_convention.vlan_group_name( + switch_name, + CONF.ironic_understack.switch_name_vlan_group_mapping + ) + + +def _set_port_physical_network(port, new_physical_network: str | None): + old_physical_network = port.physical_network + + if new_physical_network == old_physical_network: + LOG.debug("Port %s physical_network already set to %s", + port.id, new_physical_network) + return + + LOG.debug( + "Updating port %s physical_network from %s to %s", + port.id, + old_physical_network, + new_physical_network, + ) + port.physical_network = new_physical_network + port.save() + + +def _set_node_traits(task, vlan_groups: set[str]): + """Add or remove traits to the node. + + We manage a traits for each type of VLAN Group that can be connected to a + node. + + For example, a connection to VLAN Group whose name ends in "-storage" will + result in a trait being added to the node called "CUSTOM_STORAGE_SWITCH". + + We remove pre-existing traits if the node does not have the required + connections. + """ + all_possible_suffixes = set( + CONF.ironic_understack.switch_name_vlan_group_mapping.values() + ) + all_traits = { _trait_name(x) for x in all_possible_suffixes } + required_traits = { _trait_name(x) for x in vlan_groups } + existing_traits = set(task.node.traits.get_trait_names()).intersection(all_traits) + + traits_to_remove = existing_traits.difference(required_traits) + traits_to_add = required_traits.difference(existing_traits) + + LOG.debug( + "Checking traits for node %s: existing=%s required=%s", + task.node.uuid, existing_traits, required_traits, + ) + + for trait in traits_to_remove: + LOG.debug("Removing trait %s from node %s", trait, task.node.uuid) + try: + task.node.traits.destroy(trait) + except openstack.exceptions.NotFoundException: + pass + + if traits_to_add: + LOG.debug("Adding traits %s to node %s", traits_to_add, task.node.uuid) + task.node.traits = task.node.traits.create( + task.context, task.node.id, list(traits_to_add) + ) + + if traits_to_add or traits_to_remove: + task.node.save() + +def _trait_name(vlan_group_name: str) -> str: + suffix = vlan_group_name.upper().split("-")[-1] + return f"CUSTOM_{suffix}_SWITCH" diff --git a/python/ironic-understack/ironic_understack/vlan_group_name_convention.py b/python/ironic-understack/ironic_understack/vlan_group_name_convention.py new file mode 100644 index 000000000..2c65e05ff --- /dev/null +++ b/python/ironic-understack/ironic_understack/vlan_group_name_convention.py @@ -0,0 +1,39 @@ +def vlan_group_name(switch_name: str, mapping: dict[str, str]) -> str: + """The VLAN GROUP name is a function of the switch name. + + Top-of-rack switch hostname is required to follow the convention: + + - + + We only consider the unqualified name, ignoring everything after the first + dot. + + The switch name suffix must be one of the keys in the supplied mapping. The + corresponding value is used to name the VLAN Group (aka physical network). + + The VLAN GROUP name results from joining the cabinet name to the new suffix + with a hyphen. + + >>> vlan_group_name("a123-20-1", {"1": "network"}) + >>> "a123-20-network" + """ + switch_name = switch_name.split(".")[0].lower() + + parts = switch_name.rsplit("-", 1) + if len(parts) != 2: + raise ValueError( + f"Unknown switch name format: {switch_name} - this hook requires " + f"that switch names follow the convention -" + ) + + cabinet_name, suffix = parts + + vlan_suffix = mapping.get(suffix) + if vlan_suffix is None: + raise ValueError( + f"Switch suffix {suffix} is not present in the mapping configured " + f"in ironic_understack.switch_name_vlan_group_mapping. Recognised " + f"suffixes are: {mapping.keys()}" + ) + + return f"{cabinet_name}-{vlan_suffix}" diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index cb3c5f52d..058acd15f 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -12,12 +12,14 @@ readme = "README.md" license = "MIT" dependencies = [ "ironic>=29.0,<30", + "pytest-mock>=3.15.1", "pyyaml~=6.0", "understack-flavor-matcher", ] [project.entry-points."ironic.inspection.hooks"] resource-class = "ironic_understack.resource_class:ResourceClassHook" +update-baremetal-port = "ironic_understack.update_baremetal_port:UpdateBaremetalPortsHook" [project.entry-points."ironic.hardware.interfaces.inspect"] redfish-understack = "ironic_understack.redfish_inspect_understack:UnderstackRedfishInspect" diff --git a/python/ironic-understack/uv.lock b/python/ironic-understack/uv.lock index 6d7b97c42..a439e3e6f 100644 --- a/python/ironic-understack/uv.lock +++ b/python/ironic-understack/uv.lock @@ -426,6 +426,7 @@ version = "0.0.0" source = { editable = "." } dependencies = [ { name = "ironic" }, + { name = "pytest-mock" }, { name = "pyyaml" }, { name = "understack-flavor-matcher" }, ] @@ -440,6 +441,7 @@ test = [ [package.metadata] requires-dist = [ { name = "ironic", specifier = ">=29.0,<30" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pyyaml", specifier = "~=6.0" }, { name = "understack-flavor-matcher", directory = "../understack-flavor-matcher" }, ] @@ -1268,6 +1270,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/73/7b0b15cb8605ee967b34aa1d949737ab664f94e6b0f1534e8339d9e64ab2/pytest_github_actions_annotate_failures-0.3.0-py3-none-any.whl", hash = "sha256:41ea558ba10c332c0bfc053daeee0c85187507b2034e990f21e4f7e5fef044cf", size = 6030, upload-time = "2025-01-17T22:39:31.701Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"