diff --git a/components/site-workflows/sensors/sensor-ironic-oslo-event.yaml b/components/site-workflows/sensors/sensor-ironic-oslo-event.yaml index b1c3ed203..237aebbd4 100644 --- a/components/site-workflows/sensors/sensor-ironic-oslo-event.yaml +++ b/components/site-workflows/sensors/sensor-ironic-oslo-event.yaml @@ -13,11 +13,11 @@ metadata: Resulting code should be very similar to: ``` - argo -n argo-events submit --from workflowtemplate/ironic-oslo-event \ + argo -n argo-events submit --from workflowtemplate/openstack-oslo-event \ -p event-json "JSON-payload" -p device_id= -p project_id= ``` - Defined in `workflows/argo-events/sensors/sensor-ironic-oslo-event.yaml` + Defined in `components/site-workflows/sensors/sensor-ironic-oslo-event.yaml` spec: dependencies: - eventName: openstack @@ -43,6 +43,7 @@ spec: type: "string" value: - "deploying" + - "inspecting" template: serviceAccountName: sensor-submit-workflow triggers: @@ -64,6 +65,10 @@ spec: src: dataKey: body.ironic_object.lessee dependencyName: ironic-dep + - dest: spec.arguments.parameters.3.value # previous_provision_state + src: + dataKey: body.ironic_object.previous_provision_state + dependencyName: ironic-dep source: # create a workflow in argo-events prefixed with ironic-prov- resource: @@ -81,6 +86,7 @@ spec: - name: event-json - name: device_id - name: project_id + - name: previous_provision_state templates: - name: main steps: @@ -93,6 +99,7 @@ spec: - name: event-json value: "{{workflow.parameters.event-json}}" - name: convert-project-id + when: "\"{{workflow.parameters.previous_provision_state}}\" == deploying" inline: script: image: python:alpine @@ -102,7 +109,7 @@ spec: project_id_without_dashes = "{{workflow.parameters.project_id}}" print(str(uuid.UUID(project_id_without_dashes))) - - name: ansible-storage-update - when: "\"{{steps.oslo-events.outputs.parameters.storage}}\" == wanted" + when: "\"{{steps.oslo-events.outputs.parameters.storage}}\" == wanted && \"{{workflow.parameters.previous_provision_state}}\" == deploying" templateRef: name: ansible-workflow-template template: ansible-run diff --git a/docs/operator-guide/openstack-ironic-nautobot-device-interfaces-sync.md b/docs/operator-guide/openstack-ironic-nautobot-device-interfaces-sync.md new file mode 100644 index 000000000..76f479363 --- /dev/null +++ b/docs/operator-guide/openstack-ironic-nautobot-device-interfaces-sync.md @@ -0,0 +1,258 @@ +# Ironic to Nautobot Device and Interface Synchronization + +This document explains how baremetal server devices and their Ethernet interfaces are automatically created and updated in Nautobot using hardware inspection data from OpenStack Ironic. + +## Overview + +The integration automatically synchronizes hardware inventory information from Ironic to Nautobot when a baremetal node completes the inspection phase. This ensures that Nautobot maintains an accurate, up-to-date inventory of physical servers and their network connectivity. + +## Architecture + +The synchronization flow is event-driven and uses the following components: + +1. **Server Enrollment Workflow** - Enrolls servers and triggers inspection +2. **OpenStack Ironic** - Performs hardware inspection and publishes Oslo events +3. **Argo Events Sensor** - Listens for specific Ironic events +4. **Argo Workflow** - Orchestrates the synchronization process +5. **Python Workflow Scripts** - Process inspection data and update Nautobot + +### Enrollment Workflow Context + +The Nautobot synchronization is triggered as part of the broader server enrollment process: + +**Workflow:** `enroll-server` (`workflows/argo-events/workflowtemplates/enroll-server.yaml`) + +**Steps:** + +1. `enroll-server` - Enrolls the server in Ironic using BMC credentials +2. `manage-server` - Transitions node to `manageable` state +3. `redfish-inspect` - **Triggers hardware inspection** (this is where inventory data is collected) +4. `openstack-set-baremetal-node-raid-config` - Configures RAID +5. `inspect-server` - Additional inspection if needed +6. `avail-server` - Makes server available for provisioning + +The `redfish-inspect` step executes: + +```bash +openstack baremetal node inspect --wait 0 +``` + +This inspection triggers the event that causes Nautobot to be updated with the discovered hardware information. + +## Event Flow + +```text +Server Enrollment → Redfish Inspection → Oslo Event Bus → Argo Events Sensor → Argo Workflow → Update Nautobot +``` + +### Step-by-Step Process + +1. **Server Enrollment** + - The `enroll-server` workflow is triggered with a BMC IP address + - Workflow defined in: `workflows/argo-events/workflowtemplates/enroll-server.yaml` + - Server is enrolled in Ironic and transitioned to `manageable` state + +2. **Hardware Inspection** + - The `redfish-inspect` step executes: `openstack baremetal node inspect --wait 0 ` + - Ironic performs Redfish-based hardware inspection on the baremetal node + - Inspection collects: CPU, memory, network interfaces, LLDP neighbor data, BMC information + - Node transitions from `inspecting` state to `manageable` state + +3. **Event Publication** + - Ironic publishes `baremetal.node.provision_set.end` event to Oslo message bus + - Event contains node UUID and provision state information + - Event includes `previous_provision_state: inspecting` in the payload + +4. **Event Detection** + - Argo Events sensor `ironic-oslo-inspecting-event` listens for events + - Filters for events where `previous_provision_state` was `inspecting` + - Parses the Oslo message JSON payload + +5. **Workflow Trigger** + - Sensor creates an Argo Workflow named `update-nautobot-*` + - Workflow uses template `openstack-oslo-event` + - Event data is passed as workflow parameter + +6. **Data Processing** + - Workflow executes `openstack-oslo-event` script + - Script fetches full inventory data from Ironic API using node UUID + - Inventory data is parsed and transformed into Nautobot format + +7. **Nautobot Update** + - Device is created or updated in Nautobot + - Network interfaces are created with MAC addresses + - Cables are created based on LLDP neighbor information + - IP addresses are assigned (for BMC interfaces) + +## Configuration Files + +### Sensor Configuration + +**File:** `components/site-workflows/sensors/sensor-ironic-oslo-event.yaml` + +The sensor configuration defines: + +- Event source: `openstack-ironic` +- Event type filter: `baremetal.node.provision_set.end` +- State filter: `previous_provision_state == "inspecting"` +- Workflow template to trigger + +### Workflow Template + +**File:** `workflows/argo-events/workflowtemplates/openstack-oslo-event.yaml` + +The workflow template: + +- Runs the `openstack-oslo-event` command +- Mounts Nautobot token and OpenStack credentials +- Passes event JSON as input file +- Uses service account with appropriate permissions + +## Data Processing + +### Inventory Data Extraction + +**Module:** `understack_workflows.ironic.inventory` + +The inventory module performs the following transformations: + +#### Interface Name Mapping + +Linux interface names from Ironic are converted to Redfish-style names: + +| Linux Name | Redfish Name | Description | +|------------|--------------|-------------| +| `eno8303` | `NIC.Embedded.1-1-1` | Embedded NIC port 1 | +| `eno8403` | `NIC.Embedded.2-1-1` | Embedded NIC port 2 | +| `eno3np0` | `NIC.Integrated.1-1` | Integrated NIC port 1 | +| `ens2f0np0` | `NIC.Slot.1-1` | Slot NIC port 1 | + +#### LLDP Data Parsing + +LLDP (Link Layer Discovery Protocol) data is extracted from inspection results: + +- **Chassis ID (Type 1)**: Remote switch MAC address +- **Port ID (Type 2)**: Remote switch port name +- **Port Description (Type 4)**: Alternative port identifier + +#### Device Information + +The following device attributes are extracted: + +- **Manufacturer**: System vendor from `system_vendor.manufacturer` (e.g., Dell) +- **Model Number**: Product name from `system_vendor.product_name` (stripped of parenthetical suffixes) +- **Serial Number**: System serial number from `system_vendor.serial_number` +- **BMC IP Address**: Out-of-band management IP from `bmc_address` +- **BMC MAC Address**: BMC interface MAC address from `bmc_mac` +- **BIOS Version**: Firmware version from `system_vendor.firmware.version` +- **Memory**: Total RAM in GiB (converted from `memory.physical_mb`) +- **CPU**: Processor model from `cpu.model_name` +- **Power State**: Assumed to be powered on during inspection +- **Hostname**: System hostname from inventory + +#### Interface Information + +For each network interface discovered during inspection, the following attributes are extracted: + +**BMC Interface (iDRAC):** + +- **Name**: "iDRAC" +- **Description**: "Dedicated iDRAC interface" +- **MAC Address**: From `bmc_mac` (normalized to uppercase) +- **Hostname**: System hostname +- **IPv4 Address**: From `bmc_address` (assumed /26 subnet) +- **IPv4 Gateway**: Not set for Ironic-sourced data +- **DHCP**: False (assumed static) +- **LLDP Data**: Not collected for BMC interface + +**Server Interfaces:** + +- **Name**: Linux interface name converted to Redfish format (e.g., `eno8303` → `NIC.Embedded.1-1-1`) +- **Description**: Network driver name + " interface" (e.g., "bnxt_en interface") +- **MAC Address**: From interface `mac_address` (normalized to uppercase) +- **Hostname**: System hostname +- **IPv4 Address**: Not collected for server interfaces (only BMC has IP) +- **LLDP Neighbor Data** (parsed from LLDP TLVs): + - **Remote Switch MAC Address**: From LLDP TLV Type 1 (Chassis ID, subtype 4 - MAC address) + - **Remote Switch Port Name**: From LLDP TLV Type 2 (Port ID) or Type 4 (Port Description) + - **Data Staleness**: Marked as fresh (not stale) for Ironic inspection data + +## Monitoring and Troubleshooting + +### Viewing Workflow Executions + +```bash +# List recent workflows +kubectl get workflows -n argo-events | grep update-nautobot + +# View workflow details +kubectl describe workflow update-nautobot- -n argo-events + +# View workflow logs +kubectl logs -n argo-events +``` + +## Testing + +### Triggering Server Enrollment + +To enroll a new server and trigger the complete flow: + +```bash +# Submit the enroll-server workflow with BMC IP address +argo -n argo-events submit \ + --from workflowtemplate/enroll-server \ + -p ip_address="10.0.0.100" +``` + +This will: + +1. Enroll the server in Ironic +2. Run hardware inspection (redfish-inspect step) +3. Automatically trigger the Nautobot update via Oslo events + +### Manual Inspection Re-trigger + +To re-inspect an already enrolled server: + +```bash +# Trigger inspection manually +openstack baremetal node inspect --wait +``` + +This will publish the Oslo event and trigger Nautobot synchronization. + +### Manual Workflow Execution + +You can manually trigger the Nautobot update workflow for testing: + +```bash +# To view inspect inventory data +openstack baremetal node inventory save [--file ] + +# Submit workflow manually +argo -n argo-events submit \ + --from workflowtemplate/openstack-oslo-event \ + -p event-json '{"event_type":"baremetal.node.provision_set.end","payload":{"ironic_object.data":{"uuid":"", "previous_provision_state":"inspecting"}}}' +``` + +### Verifying Results + +After workflow execution, verify in Nautobot: + +1. Device exists with correct serial number +2. Device is in correct location/rack +3. All network interfaces are created +4. MAC addresses are correct +5. Cables connect to correct switch ports +6. BMC interface has IP address assigned + +## Related Components + +- **Ironic Client** (`understack_workflows.ironic.client`): Wrapper for Ironic API +- **Chassis Info** (`understack_workflows.bmc_chassis_info`): Data models for hardware info +- **Nautobot Device** (`understack_workflows.nautobot_device`): Nautobot API operations + +## References + +- [PR #1361](https://github.com/rackerlabs/understack/pull/1361) - Implementation details diff --git a/mkdocs.yml b/mkdocs.yml index f06e88383..d5c9c2a6e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -156,6 +156,7 @@ nav: - 'OpenStack': - operator-guide/openstack-ironic.md - operator-guide/openstack-ironic-inspection-guide.md + - operator-guide/openstack-ironic-nautobot-device-interfaces-sync.md - operator-guide/openstack-ironic-change-boot-interface.md - operator-guide/openstack-neutron.md - operator-guide/openstack-placement.md diff --git a/python/understack-workflows/tests/json_samples/ironic-inspect-inventory-node-data.json b/python/understack-workflows/tests/json_samples/ironic-inspect-inventory-node-data.json new file mode 100644 index 000000000..8359fea6b --- /dev/null +++ b/python/understack-workflows/tests/json_samples/ironic-inspect-inventory-node-data.json @@ -0,0 +1,753 @@ +{ + "inventory": { + "interfaces": [ + { + "name": "eno8403", + "mac_address": "c4:cb:e1:bf:90:dd", + "ipv4_address": null, + "ipv6_address": null, + "has_carrier": false, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": null, + "biosdevname": null, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.1", + "driver": "tg3" + }, + { + "name": "eno3np0", + "mac_address": "d4:04:e6:4f:71:28", + "ipv4_address": "10.4.50.110", + "ipv6_address": "fe80::d604:e6ff:fe4f:7128%eno3np0", + "has_carrier": true, + "lldp": [ + [ + 1, + "04c47ee0e4553f" + ], + [ + 2, + "0545746865726e6574312f34" + ], + [ + 3, + "0078" + ], + [ + 4, + "44656c6c2d463347535730345f4e49432e496e74656772617465642e312d31" + ], + [ + 5, + "6632302d332d312e69616433" + ], + [ + 127, + "0001420101" + ], + [ + 127, + "00120f042328" + ], + [ + 127, + "0080c2030faa11343031305f70726f766973696f6e696e67" + ], + [ + 127, + "0080c2070100000000" + ], + [ + 127, + "00014208464c4d323732343042384d" + ], + [ + 0, + "" + ] + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": null, + "biosdevname": null, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.0", + "driver": "bnxt_en" + }, + { + "name": "eno4np1", + "mac_address": "d4:04:e6:4f:71:29", + "ipv4_address": null, + "ipv6_address": "fe80::d604:e6ff:fe4f:7129%eno4np1", + "has_carrier": true, + "lldp": [ + [ + 1, + "04401482813ee3" + ], + [ + 2, + "0545746865726e6574312f34" + ], + [ + 3, + "0078" + ], + [ + 4, + "45746865726e6574312f34" + ], + [ + 5, + "6632302d332d31662e69616433" + ], + [ + 127, + "0001420101" + ], + [ + 127, + "00120f0405dc" + ], + [ + 127, + "0080c2070100000000" + ], + [ + 127, + "00014208464c4d3237323430384d4c" + ], + [ + 0, + "" + ] + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": null, + "biosdevname": null, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.1", + "driver": "bnxt_en" + }, + { + "name": "eno8303", + "mac_address": "c4:cb:e1:bf:90:dc", + "ipv4_address": null, + "ipv6_address": null, + "has_carrier": false, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": null, + "biosdevname": null, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.0", + "driver": "tg3" + }, + { + "name": "ens2f0np0", + "mac_address": "14:23:f3:f4:c7:e0", + "ipv4_address": null, + "ipv6_address": null, + "has_carrier": false, + "lldp": [], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": null, + "biosdevname": null, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.0", + "driver": "bnxt_en" + }, + { + "name": "ens2f1np1", + "mac_address": "14:23:f3:f4:c7:e1", + "ipv4_address": null, + "ipv6_address": "fe80::1623:f3ff:fef4:c7e1%ens2f1np1", + "has_carrier": true, + "lldp": [ + [ + 1, + "04c47ee0e7a037" + ], + [ + 2, + "0545746865726e6574312f34" + ], + [ + 3, + "0078" + ], + [ + 4, + "45746865726e6574312f34" + ], + [ + 5, + "6632302d332d32662e69616433" + ], + [ + 127, + "0001420101" + ], + [ + 127, + "00120f0405dc" + ], + [ + 127, + "0080c2070100000000" + ], + [ + 127, + "00014208464c4d3237323430384e39" + ], + [ + 0, + "" + ] + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": null, + "biosdevname": null, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.1", + "driver": "bnxt_en" + } + ], + "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": "0x6f4ee0806aa736003095101b850794ee", + "serial": "00ee9407851b1095300036a76a80e04e", + "vendor": "DELL", + "wwn_with_extension": "0x6f4ee0806aa736003095101b850794ee", + "wwn_vendor_extension": "0x3095101b850794ee", + "hctl": "0:3:111:0", + "by_path": "/dev/disk/by-path/pci-0000:41:00.0-scsi-0:3:111:0", + "logical_sectors": 512, + "physical_sectors": 512 + } + ], + "memory": { + "total": 100793757696, + "physical_mb": 98304 + }, + "bmc_address": "10.46.96.165", + "bmc_v6address": "::/0", + "system_vendor": { + "product_name": "PowerEdge R7615 (SKU=0AF7;ModelName=PowerEdge R7615)", + "serial_number": "F3GSW04", + "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:71:28" + }, + "hostname": "debian", + "bmc_mac": "a8:3c:a5:35:41:3a" + }, + "plugin_data": { + "root_disk": { + "name": "/dev/sda", + "model": "PERC H755 Front", + "size": 479559942144, + "rotational": false, + "wwn": "0x6f4ee0806aa736003095101b850794ee", + "serial": "00ee9407851b1095300036a76a80e04e", + "vendor": "DELL", + "wwn_with_extension": "0x6f4ee0806aa736003095101b850794ee", + "wwn_vendor_extension": "0x3095101b850794ee", + "hctl": "0:3:111:0", + "by_path": "/dev/disk/by-path/pci-0000:41:00.0-scsi-0:3:111:0", + "logical_sectors": 512, + "physical_sectors": 512 + }, + "boot_interface": "d4:04:e6:4f:71:28", + "configuration": { + "collectors": [ + "default", + "logs" + ], + "managers": [ + { + "name": "generic_hardware_manager", + "version": "1.2" + } + ] + }, + "error": null, + "all_interfaces": { + "eno8403": { + "name": "eno8403", + "mac_address": "c4:cb:e1:bf:90:dd", + "ipv4_address": null, + "ipv6_address": null, + "has_carrier": false, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": null, + "biosdevname": null, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.1", + "driver": "tg3", + "pxe_enabled": false + }, + "eno3np0": { + "name": "eno3np0", + "mac_address": "d4:04:e6:4f:71:28", + "ipv4_address": "10.4.50.110", + "ipv6_address": "fe80::d604:e6ff:fe4f:7128", + "has_carrier": true, + "lldp": [ + [ + 1, + "04c47ee0e4553f" + ], + [ + 2, + "0545746865726e6574312f34" + ], + [ + 3, + "0078" + ], + [ + 4, + "44656c6c2d463347535730345f4e49432e496e74656772617465642e312d31" + ], + [ + 5, + "6632302d332d312e69616433" + ], + [ + 127, + "0001420101" + ], + [ + 127, + "00120f042328" + ], + [ + 127, + "0080c2030faa11343031305f70726f766973696f6e696e67" + ], + [ + 127, + "0080c2070100000000" + ], + [ + 127, + "00014208464c4d323732343042384d" + ], + [ + 0, + "" + ] + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": null, + "biosdevname": null, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.0", + "driver": "bnxt_en", + "pxe_enabled": true + }, + "eno4np1": { + "name": "eno4np1", + "mac_address": "d4:04:e6:4f:71:29", + "ipv4_address": null, + "ipv6_address": "fe80::d604:e6ff:fe4f:7129", + "has_carrier": true, + "lldp": [ + [ + 1, + "04401482813ee3" + ], + [ + 2, + "0545746865726e6574312f34" + ], + [ + 3, + "0078" + ], + [ + 4, + "45746865726e6574312f34" + ], + [ + 5, + "6632302d332d31662e69616433" + ], + [ + 127, + "0001420101" + ], + [ + 127, + "00120f0405dc" + ], + [ + 127, + "0080c2070100000000" + ], + [ + 127, + "00014208464c4d3237323430384d4c" + ], + [ + 0, + "" + ] + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": null, + "biosdevname": null, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.1", + "driver": "bnxt_en", + "pxe_enabled": false + }, + "eno8303": { + "name": "eno8303", + "mac_address": "c4:cb:e1:bf:90:dc", + "ipv4_address": null, + "ipv6_address": null, + "has_carrier": false, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": null, + "biosdevname": null, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.0", + "driver": "tg3", + "pxe_enabled": false + }, + "ens2f0np0": { + "name": "ens2f0np0", + "mac_address": "14:23:f3:f4:c7:e0", + "ipv4_address": null, + "ipv6_address": null, + "has_carrier": false, + "lldp": [], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": null, + "biosdevname": null, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.0", + "driver": "bnxt_en", + "pxe_enabled": false + }, + "ens2f1np1": { + "name": "ens2f1np1", + "mac_address": "14:23:f3:f4:c7:e1", + "ipv4_address": null, + "ipv6_address": "fe80::1623:f3ff:fef4:c7e1", + "has_carrier": true, + "lldp": [ + [ + 1, + "04c47ee0e7a037" + ], + [ + 2, + "0545746865726e6574312f34" + ], + [ + 3, + "0078" + ], + [ + 4, + "45746865726e6574312f34" + ], + [ + 5, + "6632302d332d32662e69616433" + ], + [ + 127, + "0001420101" + ], + [ + 127, + "00120f0405dc" + ], + [ + 127, + "0080c2070100000000" + ], + [ + 127, + "00014208464c4d3237323430384e39" + ], + [ + 0, + "" + ] + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": null, + "biosdevname": null, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.1", + "driver": "bnxt_en", + "pxe_enabled": false + } + }, + "valid_interfaces": { + "eno3np0": { + "name": "eno3np0", + "mac_address": "d4:04:e6:4f:71:28", + "ipv4_address": "10.4.50.110", + "ipv6_address": "fe80::d604:e6ff:fe4f:7128", + "has_carrier": true, + "lldp": [ + [ + 1, + "04c47ee0e4553f" + ], + [ + 2, + "0545746865726e6574312f34" + ], + [ + 3, + "0078" + ], + [ + 4, + "44656c6c2d463347535730345f4e49432e496e74656772617465642e312d31" + ], + [ + 5, + "6632302d332d312e69616433" + ], + [ + 127, + "0001420101" + ], + [ + 127, + "00120f042328" + ], + [ + 127, + "0080c2030faa11343031305f70726f766973696f6e696e67" + ], + [ + 127, + "0080c2070100000000" + ], + [ + 127, + "00014208464c4d323732343042384d" + ], + [ + 0, + "" + ] + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": null, + "biosdevname": null, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.0", + "driver": "bnxt_en", + "pxe_enabled": true + } + }, + "macs": [ + "d4:04:e6:4f:71:28" + ] + } +} diff --git a/python/understack-workflows/tests/json_samples/ironic_versioned_notifications_inspect_server_provisioned_formatted.json b/python/understack-workflows/tests/json_samples/ironic_versioned_notifications_inspect_server_provisioned_formatted.json new file mode 100644 index 000000000..eaa50fcc1 --- /dev/null +++ b/python/understack-workflows/tests/json_samples/ironic_versioned_notifications_inspect_server_provisioned_formatted.json @@ -0,0 +1,89 @@ +{ + "ironic_object.name": "NodeSetProvisionStatePayload", + "ironic_object.namespace": "ironic", + "ironic_object.version": "1.18", + "ironic_object.data": { + "instance_info": {}, + "driver_internal_info": { + "clean_steps": null, + "agent_erase_devices_iterations": 1, + "agent_erase_devices_zeroize": true, + "agent_continue_if_secure_erase_failed": false, + "agent_continue_if_ata_erase_failed": false, + "agent_enable_nvme_secure_erase": true, + "agent_enable_ata_secure_erase": true, + "disk_erasure_concurrency": 4, + "agent_erase_skip_read_only": false, + "last_power_state_change": "2025-10-24T05:43:26.201834", + "agent_version": "10.1.1.dev15", + "agent_last_heartbeat": "2025-10-23T21:12:29.830753", + "hardware_manager_version": { + "generic_hardware_manager": "1.2" + }, + "agent_cached_clean_steps_refreshed": "2025-10-23T21:12:24.391845", + "deploy_steps": null, + "agent_cached_deploy_steps_refreshed": "2025-10-01T19:50:45.294862", + "dnsmasq_tag": "afea5a63-8b54-4854-8cff-f3a3d9bdb7c1", + "lookup_bmc_addresses": [ + "10.46.96.165" + ] + }, + "event": "done", + "previous_provision_state": "inspecting", + "previous_target_provision_state": "manageable", + "clean_step": {}, + "conductor_group": "", + "console_enabled": false, + "created_at": "2025-07-16T18:00:11Z", + "deploy_step": {}, + "description": null, + "disable_power_off": false, + "driver": "idrac", + "extra": {}, + "boot_mode": "uefi", + "secure_boot": false, + "inspection_finished_at": "2025-10-24T05:49:55Z", + "inspection_started_at": null, + "instance_uuid": null, + "last_error": null, + "maintenance": false, + "maintenance_reason": null, + "fault": null, + "bios_interface": "idrac-redfish", + "boot_interface": "http-ipxe", + "console_interface": "no-console", + "deploy_interface": "direct", + "inspect_interface": "agent", + "management_interface": "idrac-redfish", + "network_interface": "neutron", + "power_interface": "idrac-redfish", + "raid_interface": "idrac-redfish", + "rescue_interface": "no-rescue", + "storage_interface": "noop", + "vendor_interface": "idrac-redfish", + "name": "Dell-F3GSW04", + "owner": "32e02632f4f04415bab5895d1e7247b7", + "lessee": null, + "power_state": "power on", + "properties": { + "vendor": "Dell Inc.", + "memory_mb": 98304, + "cpus": 32, + "cpu_arch": "x86_64", + "local_gb": "445", + "capabilities": "boot_mode:uefi" + }, + "protected": false, + "protected_reason": null, + "provision_state": "manageable", + "provision_updated_at": "2025-10-24T05:49:55Z", + "resource_class": "gp2.small", + "retired": false, + "retired_reason": null, + "target_power_state": null, + "target_provision_state": null, + "traits": [], + "updated_at": "2025-10-24T05:49:55Z", + "uuid": "2fb79bdb-c925-4701-b304-b3768deeb85e" + } +} diff --git a/python/understack-workflows/tests/test_openstack_oslo_event.py b/python/understack-workflows/tests/test_openstack_oslo_event.py index 0b6dc87d7..8b9aeb2ed 100644 --- a/python/understack-workflows/tests/test_openstack_oslo_event.py +++ b/python/understack-workflows/tests/test_openstack_oslo_event.py @@ -237,6 +237,7 @@ def test_main_success( # Mock event handler mock_handler = Mock(return_value=0) + mock_handler.__name__ = "mock_handler" with patch( "understack_workflows.main.openstack_oslo_event._event_handlers", {"baremetal.port.create.end": mock_handler}, @@ -309,6 +310,7 @@ def test_main_handler_error( # Mock event handler that raises exception mock_handler = Mock(side_effect=Exception("Handler error")) + mock_handler.__name__ = "mock_handler" with patch( "understack_workflows.main.openstack_oslo_event._event_handlers", {"baremetal.port.create.end": mock_handler}, @@ -345,6 +347,7 @@ def test_main_with_payload( # Mock event handler mock_handler = Mock(return_value=0) + mock_handler.__name__ = "mock_handler" with patch( "understack_workflows.main.openstack_oslo_event._event_handlers", {"baremetal.port.create.end": mock_handler}, @@ -383,6 +386,7 @@ def test_integration_port_create_event( # Mock the port event handler by patching the event handlers dict mock_handler = Mock(return_value=0) + mock_handler.__name__ = "mock_handler" with patch( "understack_workflows.main.openstack_oslo_event._event_handlers", {"baremetal.port.create.end": mock_handler}, @@ -421,6 +425,7 @@ def test_integration_port_delete_event( # Mock the port event handler by patching the event handlers dict mock_handler = Mock(return_value=0) + mock_handler.__name__ = "mock_handler" with patch( "understack_workflows.main.openstack_oslo_event._event_handlers", {"baremetal.port.delete.end": mock_handler}, @@ -459,6 +464,7 @@ def test_integration_keystone_project_created_event( # Mock the keystone project event handler by patching the event handlers dict mock_handler = Mock(return_value=0) + mock_handler.__name__ = "mock_handler" with patch( "understack_workflows.main.openstack_oslo_event._event_handlers", {"identity.project.created": mock_handler}, @@ -497,6 +503,7 @@ def test_integration_keystone_project_created_handler_success( # Mock the keystone project event handler by patching the event handlers dict mock_handler = Mock(return_value=0) + mock_handler.__name__ = "mock_handler" with patch( "understack_workflows.main.openstack_oslo_event._event_handlers", {"identity.project.created": mock_handler}, @@ -535,6 +542,7 @@ def test_integration_keystone_project_created_handler_failure( # Mock the keystone project event handler to raise an exception mock_handler = Mock(side_effect=Exception("Handler failed")) + mock_handler.__name__ = "mock_handler" with patch( "understack_workflows.main.openstack_oslo_event._event_handlers", {"identity.project.created": mock_handler}, @@ -580,6 +588,7 @@ def test_integration_keystone_project_created_event_validation( # Mock the handler to verify it gets called with correct data mock_handler = Mock(return_value=0) + mock_handler.__name__ = "mock_handler" with patch( "understack_workflows.main.openstack_oslo_event._event_handlers", {"identity.project.created": mock_handler}, diff --git a/python/understack-workflows/tests/test_oslo_event_ironic_node.py b/python/understack-workflows/tests/test_oslo_event_ironic_node.py index 8948f2299..31feb791a 100644 --- a/python/understack-workflows/tests/test_oslo_event_ironic_node.py +++ b/python/understack-workflows/tests/test_oslo_event_ironic_node.py @@ -11,6 +11,12 @@ from understack_workflows.oslo_event.ironic_node import instance_nqn +def _sample_fixture_data(name: str) -> dict: + """Load example data from JSON fixture file.""" + with open(f"tests/json_samples/{name}.json") as f: + return json.load(f) + + class TestIronicProvisionSetEvent: """Test cases for IronicProvisionSetEvent class.""" @@ -124,10 +130,42 @@ def valid_event_data(self): "lessee": uuid.uuid4(), "event": "provision_end", "uuid": uuid.uuid4(), + "previous_provision_state": "deploying", } }, } + @patch("understack_workflows.oslo_event.update_nautobot.handle_provision_end") + def test_handle_provision_end_previous_state_inspecting( + self, mock_update_nautobot_handler, mock_conn, mock_nautobot, valid_event_data + ): + """Returns early when previous_provision_state is 'inspecting'. + + Note: When state is 'inspecting', the update_nautobot handler is responsible + for processing the event (both handlers are registered for provision_set.end). + """ + valid_event_data["payload"]["ironic_object.data"][ + "previous_provision_state" + ] = "inspecting" + + # Test ironic_node handler returns early + result = handle_provision_end(mock_conn, mock_nautobot, valid_event_data) + + assert result == 0 + # should return early without calling storage-related methods + mock_conn.get_server_by_id.assert_not_called() + + # Demonstrate that update_nautobot handler would process this event + from understack_workflows.oslo_event import update_nautobot + + mock_update_nautobot_handler.return_value = 0 + result = update_nautobot.handle_provision_end( + mock_conn, mock_nautobot, valid_event_data + ) + mock_update_nautobot_handler.assert_called_once_with( + mock_conn, mock_nautobot, valid_event_data + ) + @patch("understack_workflows.oslo_event.ironic_node.is_project_svm_enabled") def test_handle_provision_end_project_not_svm_enabled( self, mock_is_svm_enabled, mock_conn, mock_nautobot, valid_event_data @@ -264,9 +302,25 @@ def test_handle_provision_end_storage_metadata_missing( mock_server.metadata = {"other_key": "value"} mock_conn.get_server_by_id.return_value = mock_server - # This should raise a KeyError when accessing metadata["storage"] - with pytest.raises(KeyError): - handle_provision_end(mock_conn, mock_nautobot, valid_event_data) + result = handle_provision_end(mock_conn, mock_nautobot, valid_event_data) + + assert result == 0 + ironic_data = valid_event_data["payload"]["ironic_object.data"] + instance_uuid = ironic_data["instance_uuid"] + node_uuid = ironic_data["uuid"] + + mock_conn.get_server_by_id.assert_called_once_with(instance_uuid) + + # When storage key is missing, it should be treated as "not-set" + expected_calls = [ + ("storage", "not-set"), + ("node_uuid", str(node_uuid)), + ("instance_uuid", str(instance_uuid)), + ] + actual_calls = [call.args for call in mock_save_output.call_args_list] + assert actual_calls == expected_calls + + mock_create_connector.assert_called_once() @patch("understack_workflows.oslo_event.ironic_node.is_project_svm_enabled") def test_handle_provision_end_invalid_event_data( @@ -390,3 +444,75 @@ def test_instance_nqn_with_known_uuid(self): result = instance_nqn(known_uuid) assert result == expected_nqn + + +@pytest.fixture +def ironic_inspection_data(): + """Load Ironic inspection inventory data from JSON fixture.""" + return _sample_fixture_data("ironic-inspect-inventory-node-data") + + +class TestIronicInspectionData: + """Test cases for processing Ironic inspection data.""" + + def test_chassis_info_from_inspection_data(self, ironic_inspection_data): + """Test creating ChassisInfo from ironic-inspect-inventory-node-data.json.""" + # Import the function to convert inspection data to ChassisInfo + from understack_workflows.ironic.inventory import get_device_info + + # Create ChassisInfo from inspection data + chassis_info = get_device_info(ironic_inspection_data) + + # Assert basic chassis information + assert chassis_info.manufacturer == "Dell Inc." + assert chassis_info.model_number == "PowerEdge R7615" + assert chassis_info.serial_number == "F3GSW04" + assert chassis_info.bmc_ip_address == "10.46.96.165" + assert chassis_info.bios_version == "1.6.10" + assert chassis_info.power_on is True + assert chassis_info.memory_gib == 96 + assert chassis_info.cpu == "AMD EPYC 9124 16-Core Processor" + + # Assert BMC interface + assert chassis_info.bmc_interface.name == "iDRAC" + assert chassis_info.bmc_interface.mac_address == "A8:3C:A5:35:41:3A" + assert chassis_info.bmc_interface.hostname == "debian" + assert str(chassis_info.bmc_interface.ipv4_address) == "10.46.96.165/26" + + # Assert we have the expected number of interfaces (1 BMC + 6 server interfaces) + assert len(chassis_info.interfaces) == 7 + + # Assert specific server interface details + server_interfaces = [ + iface for iface in chassis_info.interfaces if iface.name != "iDRAC" + ] + + # Check that we have interfaces with LLDP data + interfaces_with_lldp = [ + iface + for iface in server_interfaces + if iface.remote_switch_mac_address is not None + ] + assert ( + len(interfaces_with_lldp) == 3 + ) # eno3np0, eno4np1, ens2f1np1 have LLDP data + + # Verify one specific interface with LLDP data (eno3np0 -> NIC.Integrated.1-1) + eno3np0 = next( + ( + iface + for iface in chassis_info.interfaces + if iface.name == "NIC.Integrated.1-1" + ), + None, + ) + assert eno3np0 is not None + assert eno3np0.mac_address == "D4:04:E6:4F:71:28" + assert eno3np0.remote_switch_mac_address == "C4:7E:E0:E4:55:3F" + assert eno3np0.remote_switch_port_name == "Ethernet1/4" + + # Verify neighbors (unique switch MAC addresses) + assert len(chassis_info.neighbors) == 3 + assert "C4:7E:E0:E4:55:3F" in chassis_info.neighbors + assert "40:14:82:81:3E:E3" in chassis_info.neighbors + assert "C4:7E:E0:E7:A0:37" in chassis_info.neighbors diff --git a/python/understack-workflows/understack_workflows/ironic/client.py b/python/understack-workflows/understack_workflows/ironic/client.py index 227587378..ae742ddef 100644 --- a/python/understack-workflows/understack_workflows/ironic/client.py +++ b/python/understack-workflows/understack_workflows/ironic/client.py @@ -1,68 +1,73 @@ -from understack_workflows.openstack.client import get_ironic_client +from typing import cast +from ironicclient.common.apiclient import exceptions as ironic_exceptions +from ironicclient.v1.client import Client as IronicV1Client +from ironicclient.v1.node import Node -class IronicClient: - def __init__( - self, - ) -> None: - """Initialize our ironicclient wrapper.""" - self.logged_in = False +from understack_workflows.helpers import setup_logger +from understack_workflows.openstack.client import get_ironic_client - def login(self): - self.client = get_ironic_client() - self.logged_in = True +logger = setup_logger(__name__) - def create_node(self, node_data: dict): - self._ensure_logged_in() - return self.client.node.create(**node_data) +class IronicClient: + def __init__(self, cloud: str | None = None) -> None: + self.client: IronicV1Client = get_ironic_client(cloud=cloud) - def list_nodes(self): - self._ensure_logged_in() + def create_node(self, node_data: dict) -> Node: + return cast(Node, self.client.node.create(**node_data)) + def list_nodes(self): return self.client.node.list() - def get_node(self, node_ident: str, fields: list[str] | None = None): - self._ensure_logged_in() - - return self.client.node.get( - node_ident, - fields, - ) + def get_node(self, node_ident: str, fields: list[str] | None = None) -> Node: + return cast(Node, self.client.node.get(node_ident, fields)) def update_node(self, node_id, patch): - self._ensure_logged_in() - - return self.client.node.update( - node_id, - patch, - ) + return self.client.node.update(node_id, patch) def create_port(self, port_data: dict): - self._ensure_logged_in() - return self.client.port.create(**port_data) def update_port(self, port_id: str, patch: list): - self._ensure_logged_in() - - return self.client.port.update( - port_id, - patch, - ) + return self.client.port.update(port_id, patch) def delete_port(self, port_id: str): - self._ensure_logged_in() - - return self.client.port.delete( - port_id, - ) + return self.client.port.delete(port_id) def list_ports(self, node_id: str): - self._ensure_logged_in() - return self.client.port.list(node=node_id, detail=True) - def _ensure_logged_in(self): - if not self.logged_in: - self.login() + def get_node_inventory(self, node_ident: str) -> dict: + """Fetch node inventory data from Ironic API. + + Args: + node_ident: Node UUID, name, or other identifier + + Returns: + Dict containing node inventory data + + Raises: + ironic_exceptions.NotFound: If node doesn't exist + ironic_exceptions.ClientException: For other API errors + """ + try: + logger.info("Fetching inventory for node: %s", node_ident) + + # Call the inventory API endpoint + inventory = self.client.node.get_inventory(node_ident) + + logger.info("Successfully retrieved inventory for node %s", node_ident) + return inventory + + except ironic_exceptions.NotFound: + logger.error("Node not found: %s", node_ident) + raise + except ironic_exceptions.ClientException as e: + logger.error("Ironic API error for node %s: %s", node_ident, e) + raise + except Exception as e: + logger.error( + "Unexpected error fetching inventory for %s: %s", node_ident, e + ) + raise diff --git a/python/understack-workflows/understack_workflows/ironic/inventory.py b/python/understack-workflows/understack_workflows/ironic/inventory.py new file mode 100644 index 000000000..63190119b --- /dev/null +++ b/python/understack-workflows/understack_workflows/ironic/inventory.py @@ -0,0 +1,201 @@ +from ipaddress import IPv4Interface + +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__) + + +# Mapping of Linux interface names to Redfish-style names for Dell servers +# Based on Dell PowerEdge server interface naming conventions +DELL_INTERFACE_NAME_MAPPING = { + # Embedded NICs + "eno8303": "NIC.Embedded.1-1-1", + "eno8403": "NIC.Embedded.2-1-1", + # Integrated NICs + "eno3np0": "NIC.Integrated.1-1", + "eno4np1": "NIC.Integrated.1-2", + # Slot NICs + "ens2f0np0": "NIC.Slot.1-1", + "ens2f1np1": "NIC.Slot.1-2", +} + +# Special interfaces that should pass through unchanged for all manufacturers +SPECIAL_INTERFACES = {"idrac", "lo", "docker0", "virbr0"} + + +def linux_to_redfish(linux_interface_name: str, manufacturer: str) -> str: + """Convert Linux interface name to Redfish format based on manufacturer. + + Args: + linux_interface_name: The Linux kernel interface name (e.g., "eno3np0") + manufacturer: The server manufacturer (e.g., "Dell Inc.", "HP", "HPE") + + Returns: + For Dell servers: Redfish-style name (e.g., "NIC.Integrated.1-1") + For HP/HPE servers: Original Linux interface name + For unknown interfaces: Original Linux interface name + """ + # Special interfaces always pass through unchanged + if linux_interface_name in SPECIAL_INTERFACES: + return linux_interface_name + + # Only apply Dell-specific mapping for Dell servers + if "Dell" in manufacturer: + return DELL_INTERFACE_NAME_MAPPING.get( + linux_interface_name, linux_interface_name + ) + + # For HP/HPE and other manufacturers, return the Linux name as-is + return linux_interface_name + + +def parse_lldp_data(lldp_raw: list[list]) -> dict[str, str | None]: + """Parse LLDP TLV data from Ironic inspection format. + + LLDP TLVs are in format: [type, hex_encoded_value] + Common types: + - 1: Chassis ID + - 2: Port ID + - 4: Port Description + - 5: System Name (not stored in Redfish format) + """ + result: dict[str, str | None] = { + "remote_switch_mac_address": None, + "remote_switch_port_name": None, + } + + if not lldp_raw: + return result + + for tlv_type, hex_value in lldp_raw: + if not hex_value: + continue + + try: + # Convert hex string to bytes + data = bytes.fromhex(hex_value) + + if tlv_type == 1: # Chassis ID + # First byte is subtype, rest is the ID + if len(data) > 1: + subtype = data[0] + if subtype == 4: # MAC address subtype + if len(data) == 7: # 1 byte subtype + 6 bytes MAC + mac_bytes = data[1:7] + mac = ":".join(f"{b:02X}" for b in mac_bytes) + result["remote_switch_mac_address"] = mac + + elif tlv_type == 2: # Port ID + # First byte is subtype, rest is port identifier + if len(data) > 1: + port_name = data[1:].decode("utf-8", errors="ignore") + result["remote_switch_port_name"] = port_name + + elif tlv_type == 4: # Port Description + port_desc = data.decode("utf-8", errors="ignore") + if not result["remote_switch_port_name"]: + result["remote_switch_port_name"] = port_desc + + elif tlv_type == 5: # System Name + # Redfish interfaces don't store system name, so we skip this + pass + + except (ValueError, UnicodeDecodeError) as e: + logger.debug("Failed to parse LLDP TLV type %s: %s", tlv_type, e) + continue + + return result + + +def parse_interface_data( + interface_data: dict, hostname: str, manufacturer: str +) -> InterfaceInfo: + lldp_info = parse_lldp_data(interface_data.get("lldp", [])) + + # For server interfaces, ignore IP addresses (only iDRAC should have IP info) + return InterfaceInfo( + name=linux_to_redfish(interface_data["name"], manufacturer), + description=f"{interface_data.get('driver', 'Unknown')} interface", + mac_address=interface_data["mac_address"].upper(), + hostname=hostname, + ipv4_address=None, # Ignore IP addresses for server interfaces + ipv4_gateway=None, + dhcp=False, + remote_switch_mac_address=lldp_info["remote_switch_mac_address"], + remote_switch_port_name=lldp_info["remote_switch_port_name"], + remote_switch_data_stale=False, # Ironic data is typically fresh + ) + + +def chassis_info_from_ironic_data(inspection_data: dict) -> ChassisInfo: + inventory = inspection_data["inventory"] + + system_vendor = inventory["system_vendor"] + memory_info = inventory["memory"] + hostname = inventory.get("hostname") + + # Validate that bmc_address is present + if "bmc_address" not in inventory or not inventory["bmc_address"]: + raise ValueError( + f"bmc_address is required but not present in inventory for {hostname}" + ) + + try: + # TODO: For BMC, we assume management network is /26 + bmc_ipv4 = IPv4Interface(f"{inventory['bmc_address']}/26") + except ValueError: + bmc_ipv4 = None + + bmc_interface = InterfaceInfo( + name="iDRAC", + description="Dedicated iDRAC interface", + mac_address=inventory["bmc_mac"].upper(), + hostname=hostname, + ipv4_address=bmc_ipv4, + ipv4_gateway=None, + dhcp=False, + remote_switch_mac_address=None, + remote_switch_port_name=None, + remote_switch_data_stale=False, + ) + + manufacturer = system_vendor["manufacturer"] + + interfaces = [bmc_interface] + for interface_data in inventory["interfaces"]: + interfaces.append(parse_interface_data(interface_data, hostname, manufacturer)) + + return ChassisInfo( + manufacturer=manufacturer, + model_number=system_vendor["product_name"].split("(")[0].strip(), + serial_number=system_vendor["serial_number"], + bmc_ip_address=inventory["bmc_address"], + bios_version=system_vendor["firmware"]["version"], + power_on=True, # Assume powered on since inspection is running + interfaces=interfaces, + memory_gib=int(memory_info["physical_mb"] / 1024), + cpu=inventory["cpu"]["model_name"], + ) + + +def get_device_info(inspection_data: dict) -> ChassisInfo: + try: + chassis_info = chassis_info_from_ironic_data(inspection_data) + + logger.info( + "Successfully processed Ironic inspection data " + "for %s (%s): %d interfaces, %d neighbors", + chassis_info.bmc_hostname, + chassis_info.serial_number, + len(chassis_info.interfaces), + len(chassis_info.neighbors), + ) + + return chassis_info + + except Exception: + hostname = inspection_data.get("inventory", {}).get("hostname", "unknown") + logger.exception("Failed to process Ironic inspection data for %s", hostname) + raise diff --git a/python/understack-workflows/understack_workflows/ironic_node.py b/python/understack-workflows/understack_workflows/ironic_node.py index 577e0cf2c..69af069d2 100644 --- a/python/understack-workflows/understack_workflows/ironic_node.py +++ b/python/understack-workflows/understack_workflows/ironic_node.py @@ -2,11 +2,11 @@ import ironicclient.common.apiclient.exceptions from ironicclient.common.utils import args_array_to_patch +from ironicclient.v1.node import Node from understack_workflows.bmc import Bmc from understack_workflows.helpers import setup_logger from understack_workflows.ironic.client import IronicClient -from understack_workflows.node_configuration import IronicNodeConfiguration STATES_ALLOWING_UPDATES = ["enroll", "manageable"] @@ -73,7 +73,7 @@ def create_ironic_node( client: IronicClient, node_meta: NodeMetadata, bmc: Bmc, -) -> IronicNodeConfiguration: +) -> Node: return client.create_node( { "uuid": node_meta.uuid, diff --git a/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py b/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py index d182c766e..d68c03996 100644 --- a/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py +++ b/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py @@ -18,6 +18,7 @@ from understack_workflows.oslo_event import keystone_project from understack_workflows.oslo_event import neutron_network from understack_workflows.oslo_event import neutron_subnet +from understack_workflows.oslo_event import update_nautobot logger = setup_logger(__name__) @@ -62,12 +63,16 @@ class NoEventHandlerError(Exception): # Type alias for event handler functions EventHandler = Callable[[Connection, NautobotApi, dict[str, Any]], int] -# add the event_type here and the function that should be called -_event_handlers: dict[str, EventHandler] = { +# add the event_type here and the function(s) that should be called +# each event type can have a single handler or a list of handlers +_event_handlers: dict[str, EventHandler | list[EventHandler]] = { "baremetal.port.create.end": ironic_port.handle_port_create_update, "baremetal.port.update.end": ironic_port.handle_port_create_update, "baremetal.port.delete.end": ironic_port.handle_port_delete, - "baremetal.node.provision_set.end": ironic_node.handle_provision_end, + "baremetal.node.provision_set.end": [ + ironic_node.handle_provision_end, + update_nautobot.handle_provision_end, + ], "identity.project.created": keystone_project.handle_project_created, "identity.project.updated": keystone_project.handle_project_updated, "identity.project.deleted": keystone_project.handle_project_deleted, @@ -179,14 +184,20 @@ def main() -> int: logger.info("Processing event type: %s", event_type) - # look up the event handler - event_handler = _event_handlers.get(event_type) - if event_handler is None: + # look up the event handler(s) + event_handlers = _event_handlers.get(event_type) + if event_handlers is None: logger.error("No event handler for event type: %s", event_type) logger.debug("Available event handlers: %s", list(_event_handlers.keys())) sys.exit(_EXIT_NO_EVENT_HANDLER) - logger.debug("Found event handler for event type: %s", event_type) + # normalize to list for consistent processing + if not isinstance(event_handlers, list): + event_handlers = [event_handlers] + + logger.debug( + "Found %d handler(s) for event type: %s", len(event_handlers), event_type + ) # get a connection to OpenStack and to Nautobot try: @@ -195,17 +206,21 @@ def main() -> int: logger.exception("Client initialization failed") sys.exit(_EXIT_CLIENT_ERROR) - # execute the event handler - logger.info("Executing event handler for event type: %s", event_type) - try: - ret = event_handler(conn, nautobot, event) - except Exception: - logger.exception("Event handler failed") - sys.exit(_EXIT_HANDLER_ERROR) - - logger.info("Event handler completed successfully with return code: %s", ret) - - # exit if the event handler provided a return code or just with success - if isinstance(ret, int): - return ret - return _EXIT_SUCCESS + # execute all event handlers + last_ret = _EXIT_SUCCESS + for idx, event_handler in enumerate(event_handlers, 1): + handler_name = event_handler.__name__ + logger.info( + "Executing handler %d/%d: %s", idx, len(event_handlers), handler_name + ) + try: + ret = event_handler(conn, nautobot, event) + if isinstance(ret, int): + last_ret = ret + logger.info("Handler %s completed with return code: %s", handler_name, ret) + except Exception: + logger.exception("Handler %s failed", handler_name) + sys.exit(_EXIT_HANDLER_ERROR) + + logger.info("All handlers completed successfully") + return last_ret diff --git a/python/understack-workflows/understack_workflows/oslo_event/ironic_node.py b/python/understack-workflows/understack_workflows/oslo_event/ironic_node.py index 525dd7290..6d218c76a 100644 --- a/python/understack-workflows/understack_workflows/oslo_event/ironic_node.py +++ b/python/understack-workflows/understack_workflows/oslo_event/ironic_node.py @@ -48,6 +48,18 @@ def lessee_undashed(self) -> str: def handle_provision_end(conn: Connection, _: Nautobot, event_data: dict) -> int: """Operates on an Ironic Node provisioning END event.""" + payload = event_data.get("payload", {}) + payload_data = payload.get("ironic_object.data") + + if payload_data: + previous_provision_state = payload_data.get("previous_provision_state") + if previous_provision_state != "deploying": + logger.info( + "Skipping storage setup for previous_provision_state: %s", + previous_provision_state, + ) + return 0 + # Check if the project is configured with tags. event = IronicProvisionSetEvent.from_event_dict(event_data) logger.info("Checking if project %s is tagged with UNDERSTACK_SVM", event.lessee) @@ -63,7 +75,7 @@ def handle_provision_end(conn: Connection, _: Nautobot, event_data: dict) -> int save_output("storage", "not-found") return 1 - if server.metadata["storage"] == "wanted": + if server.metadata.get("storage") == "wanted": save_output("storage", "wanted") else: logger.info("Server %s did not want storage enabled.", server.id) diff --git a/python/understack-workflows/understack_workflows/oslo_event/update_nautobot.py b/python/understack-workflows/understack_workflows/oslo_event/update_nautobot.py new file mode 100644 index 000000000..40501ecad --- /dev/null +++ b/python/understack-workflows/understack_workflows/oslo_event/update_nautobot.py @@ -0,0 +1,36 @@ +from openstack.connection import Connection +from pynautobot.core.api import Api as Nautobot + +from understack_workflows import nautobot_device +from understack_workflows.helpers import setup_logger +from understack_workflows.ironic.client import IronicClient +from understack_workflows.ironic.inventory import get_device_info + +logger = setup_logger(__name__) + + +def handle_provision_end(_: Connection, nautobot: Nautobot, event_data: dict) -> int: + """Handle Ironic node provisioning END event.""" + payload = event_data.get("payload", {}) + payload_data = payload.get("ironic_object.data") + + if not payload_data: + raise ValueError("Missing 'ironic_object.data' in event payload") + + previous_provision_state = payload_data.get("previous_provision_state") + if previous_provision_state != "inspecting": + logger.info( + "Skipping Nautobot update for previous_provision_state: %s", + previous_provision_state, + ) + return 0 + + ironic_client = IronicClient() + node_inventory = ironic_client.get_node_inventory( + node_ident=str(payload_data["uuid"]) + ) + device_info = get_device_info(node_inventory) + nb_device = nautobot_device.find_or_create(device_info, nautobot) + + logger.info("Updated Nautobot device: %s", nb_device) + return 0