Skip to content

Commit 57dd66c

Browse files
adding nautobot-sync post-inspection hook
1 parent 154def1 commit 57dd66c

File tree

5 files changed

+674
-2
lines changed

5 files changed

+674
-2
lines changed

components/ironic/values.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,16 @@ conf:
8686
rabbit_ha_queues: true
8787
pxe:
8888
loader_file_paths: "snponly.efi:/usr/lib/ipxe/snponly.efi"
89+
redfish:
90+
# Redfish inspection hooks - run hooks for redfish-based inspection
91+
inspection_hooks: "$default_inspection_hooks,nautobot-sync"
8992
inspector:
9093
extra_kernel_params: ipa-collect-lldp=1
9194
# Agent inspection hooks - ports hook removed to prevent port manipulation during agent inspection
9295
# Default hooks include: ramdisk-error,validate-interfaces,ports,architecture
9396
# We override to exclude 'ports' from the default hooks
9497
default_hooks: "ramdisk-error,validate-interfaces,architecture"
95-
hooks: "$default_hooks,pci-devices,parse-lldp,local-link-connection,resource-class"
98+
hooks: "$default_hooks,pci-devices,parse-lldp,local-link-connection,resource-class,nautobot-sync"
9699
# enable sensors and metrics for redfish metrics - https://docs.openstack.org/ironic/latest/admin/drivers/redfish/metrics.html
97100
sensor_data:
98101
send_sensor_data: true
@@ -239,6 +242,9 @@ pod:
239242
sources:
240243
- secret:
241244
name: ironic-ks-etc
245+
- secret:
246+
name: ironic-nautobot-token
247+
optional: true
242248
ironic_api:
243249
ironic_api:
244250
volumeMounts:

python/ironic-understack/ironic_understack/conf.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,18 @@ def setup_conf():
1010
"device_types_dir",
1111
help="directory storing Device Type description YAML files",
1212
default="/var/lib/understack/device-types",
13-
)
13+
),
14+
cfg.StrOpt(
15+
"nautobot_url",
16+
help="Nautobot API URL",
17+
default=None,
18+
),
19+
cfg.StrOpt(
20+
"nautobot_token",
21+
help="Nautobot API token",
22+
secret=True,
23+
default=None,
24+
),
1425
]
1526
cfg.CONF.register_group(grp)
1627
cfg.CONF.register_opts(opts, group=grp)
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
"""Ironic inspection hook to sync device information to Nautobot."""
2+
3+
import pynautobot
4+
from ironic import objects
5+
from ironic.drivers.modules.inspector.hooks import base
6+
from oslo_log import log as logging
7+
8+
from ironic_understack.conf import CONF
9+
10+
LOG = logging.getLogger(__name__)
11+
12+
13+
class NautobotSyncHook(base.InspectionHook):
14+
"""Hook to sync discovered device information to Nautobot."""
15+
16+
# Run after port information has been enriched with BIOS names and LLDP data
17+
dependencies = ["update-baremetal-port", "port-bios-name"]
18+
19+
def __call__(self, task, inventory, plugin_data):
20+
"""Sync device inventory to Nautobot.
21+
22+
:param task: Ironic task context containing node and driver info
23+
:param inventory: Hardware inventory dict from inspection
24+
:param plugin_data: Shared data dict between hooks
25+
"""
26+
try:
27+
nautobot_url = CONF.ironic_understack.nautobot_url
28+
nautobot_token = CONF.ironic_understack.nautobot_token
29+
30+
if not nautobot_url or not nautobot_token:
31+
LOG.warning(
32+
"Nautobot URL or token not configured, skipping sync for node %s",
33+
task.node.uuid,
34+
)
35+
return
36+
37+
# Initialize Nautobot client
38+
nautobot = pynautobot.api(url=nautobot_url, token=nautobot_token)
39+
40+
# Extract device information from inventory
41+
device_data = self._extract_device_data(task, inventory)
42+
43+
# Sync to Nautobot
44+
self._sync_to_nautobot(nautobot, device_data, task.node)
45+
46+
LOG.info(
47+
"Successfully synced device information to Nautobot for node %s",
48+
task.node.uuid,
49+
)
50+
51+
except (KeyError, ValueError, TypeError) as e:
52+
msg = (
53+
f"Failed to extract device information from inventory for node "
54+
f"{task.node.uuid}: {e}"
55+
)
56+
LOG.error(msg)
57+
# Don't fail inspection, just log the error
58+
except Exception as e:
59+
msg = f"Failed to sync device to Nautobot for node {task.node.uuid}: {e}"
60+
LOG.error(msg)
61+
# Don't fail inspection, just log the error
62+
63+
def _extract_device_data(self, task, inventory):
64+
"""Extract relevant device data from inventory and baremetal ports."""
65+
# Use task.node properties directly - this is the source of truth
66+
data = {
67+
"uuid": task.node.uuid,
68+
"name": task.node.name,
69+
"properties": task.node.properties,
70+
"driver_info": task.node.driver_info,
71+
}
72+
73+
# Extract interface information from baremetal ports
74+
# These ports have been enriched by
75+
# update-baremetal-port and port-bios-name hooks
76+
interfaces = []
77+
try:
78+
ports = objects.Port.list_by_node_id(task.context, task.node.id)
79+
for port in ports:
80+
interface_data = {
81+
"mac_address": port.address,
82+
"name": port.name,
83+
"bios_name": port.extra.get("bios_name"),
84+
"pxe_enabled": port.pxe_enabled,
85+
}
86+
87+
# local_link_connection info from update-baremetal-port hook
88+
if port.local_link_connection:
89+
interface_data["switch_id"] = port.local_link_connection.get(
90+
"switch_id"
91+
)
92+
interface_data["switch_info"] = port.local_link_connection.get(
93+
"switch_info"
94+
)
95+
interface_data["port_id"] = port.local_link_connection.get(
96+
"port_id"
97+
)
98+
99+
# Add physical_network (VLAN group) if available
100+
if port.physical_network:
101+
interface_data["physical_network"] = port.physical_network
102+
103+
interfaces.append(interface_data)
104+
105+
LOG.debug(
106+
"Extracted %d interfaces for node %s", len(interfaces), task.node.uuid
107+
)
108+
except Exception as e:
109+
LOG.warning(
110+
"Failed to extract interface data from ports for node %s: %s",
111+
task.node.uuid,
112+
e,
113+
)
114+
115+
data["interfaces"] = interfaces
116+
117+
return data
118+
119+
def _sync_to_nautobot(self, nautobot, device_data, node):
120+
"""Sync device data to Nautobot."""
121+
node_uuid = device_data.get("uuid")
122+
if not node_uuid:
123+
LOG.warning("Node has no UUID, cannot sync to Nautobot")
124+
return
125+
126+
# Find device in Nautobot by UUID (Nautobot device ID = Ironic node UUID)
127+
device = self._find_device(nautobot, node_uuid)
128+
129+
if not device:
130+
LOG.warning(
131+
"Device with UUID %s not found in Nautobot. "
132+
"Device must be pre-created in Nautobot before inspection.",
133+
node_uuid,
134+
)
135+
return
136+
137+
LOG.info("Found device %s in Nautobot, syncing interfaces", node_uuid)
138+
139+
# Sync interfaces to Nautobot
140+
self._sync_interfaces(nautobot, device, device_data)
141+
142+
def _find_device(self, nautobot, device_uuid):
143+
"""Find device in Nautobot by UUID.
144+
145+
In Nautobot, the device ID is the same as the Ironic node UUID.
146+
"""
147+
try:
148+
device = nautobot.dcim.devices.get(device_uuid)
149+
if device:
150+
LOG.info("Found device %s (%s) in Nautobot", device.name, device.id)
151+
return device
152+
except Exception:
153+
LOG.exception(
154+
"Error querying Nautobot for device with UUID %s", device_uuid
155+
)
156+
return None
157+
158+
def _sync_interfaces(self, nautobot, device, device_data):
159+
"""Sync interface information to Nautobot."""
160+
for interface_data in device_data.get("interfaces", []):
161+
try:
162+
self._sync_interface(nautobot, device, interface_data)
163+
except Exception as e:
164+
LOG.error(
165+
"Failed to sync interface %s for device %s: %s",
166+
interface_data.get("mac_address"),
167+
device_data.get("uuid"),
168+
e,
169+
)
170+
171+
def _sync_interface(self, nautobot, device, interface_data):
172+
"""Sync a single interface to Nautobot."""
173+
mac_address = interface_data.get("mac_address")
174+
if not mac_address:
175+
LOG.warning("Interface missing MAC address, skipping")
176+
return
177+
178+
bios_name = interface_data.get("bios_name")
179+
if not bios_name:
180+
LOG.debug("Interface %s has no BIOS name, skipping", mac_address)
181+
return
182+
183+
# Find or create the interface in Nautobot
184+
nautobot_interface = self._find_or_create_interface(
185+
nautobot, device, interface_data
186+
)
187+
188+
# Connect interface to switch if we have LLDP data
189+
if interface_data.get("switch_id") and interface_data.get("port_id"):
190+
self._connect_interface_to_switch(
191+
nautobot, nautobot_interface, interface_data
192+
)
193+
194+
def _find_or_create_interface(self, nautobot, device, interface_data):
195+
"""Find or create an interface in Nautobot."""
196+
bios_name = interface_data["bios_name"]
197+
mac_address = interface_data["mac_address"]
198+
199+
# Try to find existing interface by device and name
200+
try:
201+
interface = nautobot.dcim.interfaces.get(
202+
device_id=device.id, name=bios_name
203+
)
204+
if interface:
205+
LOG.info(
206+
"Found existing interface %s (%s) in Nautobot",
207+
bios_name,
208+
interface.id,
209+
)
210+
# Update interface attributes
211+
interface.update(
212+
mac_address=mac_address,
213+
status="Active",
214+
type="25gbase-x-sfp28", # Default type, could be made configurable
215+
)
216+
return interface
217+
except Exception as e:
218+
LOG.debug("Interface lookup failed: %s", e)
219+
220+
# Create new interface
221+
try:
222+
interface = nautobot.dcim.interfaces.create(
223+
device=device.id,
224+
name=bios_name,
225+
mac_address=mac_address,
226+
status="Active",
227+
type="25gbase-x-sfp28",
228+
)
229+
LOG.info("Created interface %s (%s) in Nautobot", bios_name, interface.id)
230+
return interface
231+
except Exception as e:
232+
LOG.error("Failed to create interface %s: %s", bios_name, e)
233+
raise
234+
235+
def _connect_interface_to_switch(self, nautobot, server_interface, interface_data):
236+
"""Connect server interface to switch interface via cable in Nautobot."""
237+
switch_chassis_id = interface_data.get("switch_id")
238+
switch_port_id = interface_data.get("port_id")
239+
240+
if not all([switch_chassis_id, switch_port_id]):
241+
LOG.debug("Missing switch connection data for interface")
242+
return
243+
244+
# Find the switch device by chassis MAC address
245+
switch = self._find_switch_by_mac(nautobot, switch_chassis_id)
246+
if not switch:
247+
LOG.warning(
248+
"Switch with chassis MAC %s not found in Nautobot, cannot create cable",
249+
switch_chassis_id,
250+
)
251+
return
252+
253+
# Find the switch interface
254+
switch_interface = self._find_switch_interface(nautobot, switch, switch_port_id)
255+
if not switch_interface:
256+
LOG.warning(
257+
"Switch %s has no interface %s, cannot create cable",
258+
switch.name if hasattr(switch, "name") else switch.id,
259+
switch_port_id,
260+
)
261+
return
262+
263+
# Create or verify cable connection
264+
self._create_or_verify_cable(nautobot, server_interface, switch_interface)
265+
266+
def _find_switch_by_mac(self, nautobot, chassis_mac):
267+
"""Find switch device by chassis MAC address."""
268+
try:
269+
# Nautobot stores chassis MAC in a custom field
270+
devices = nautobot.dcim.devices.filter(cf_chassis_mac_address=chassis_mac)
271+
if devices:
272+
return devices[0]
273+
except Exception as e:
274+
LOG.debug("Switch lookup by MAC failed: %s", e)
275+
return None
276+
277+
def _find_switch_interface(self, nautobot, switch, port_name):
278+
"""Find switch interface by port name."""
279+
try:
280+
interface = nautobot.dcim.interfaces.get(
281+
device_id=switch.id, name=port_name
282+
)
283+
return interface
284+
except Exception as e:
285+
LOG.debug("Switch interface lookup failed: %s", e)
286+
return None
287+
288+
def _create_or_verify_cable(self, nautobot, server_interface, switch_interface):
289+
"""Create or verify cable connection between server and switch."""
290+
try:
291+
# Check if cable already exists
292+
cable = nautobot.dcim.cables.get(
293+
termination_a_id=switch_interface.id,
294+
termination_b_id=server_interface.id,
295+
)
296+
if cable:
297+
LOG.info("Cable %s already exists in Nautobot", cable.id)
298+
return cable
299+
300+
# Create new cable
301+
cable = nautobot.dcim.cables.create(
302+
termination_a_type="dcim.interface",
303+
termination_a_id=switch_interface.id,
304+
termination_b_type="dcim.interface",
305+
termination_b_id=server_interface.id,
306+
status="Connected",
307+
)
308+
LOG.info("Created cable %s in Nautobot", cable.id)
309+
return cable
310+
except Exception as e:
311+
LOG.error(
312+
"Failed to create cable between %s and %s: %s",
313+
server_interface.id,
314+
switch_interface.id,
315+
e,
316+
)

0 commit comments

Comments
 (0)