osism 0.20250605.0__py3-none-any.whl → 0.20250616.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,16 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
 
3
- import ipaddress
4
3
  import json
5
4
 
6
5
  import jinja2
7
- from loguru import logger
8
6
  from pottery import Redlock
9
7
 
10
8
  from osism import utils as osism_utils
11
9
  from osism.tasks import netbox, openstack
12
- from osism.tasks.conductor.netbox import get_nb_device_query_list
10
+ from osism.tasks.conductor.netbox import (
11
+ get_device_oob_ip,
12
+ get_nb_device_query_list_ironic,
13
+ )
13
14
  from osism.tasks.conductor.utils import (
14
15
  deep_compare,
15
16
  deep_decrypt,
@@ -33,25 +34,32 @@ driver_params = {
33
34
  }
34
35
 
35
36
 
36
- def sync_ironic(get_ironic_parameters, force_update=False):
37
+ def sync_ironic(request_id, get_ironic_parameters, force_update=False):
38
+ osism_utils.push_task_output(
39
+ request_id,
40
+ "Starting NetBox device synchronisation with ironic\n",
41
+ )
37
42
  devices = set()
38
- nb_device_query_list = get_nb_device_query_list()
43
+ nb_device_query_list = get_nb_device_query_list_ironic()
39
44
  for nb_device_query in nb_device_query_list:
40
45
  devices |= set(netbox.get_devices(**nb_device_query))
41
46
 
42
- # NOTE: Find nodes in Ironic which are no longer present in netbox and remove them
47
+ # NOTE: Find nodes in Ironic which are no longer present in NetBox and remove them
43
48
  device_names = {dev.name for dev in devices}
44
49
  nodes = openstack.baremetal_node_list()
45
50
  for node in nodes:
46
- logger.info(f"Looking for {node['Name']} in netbox")
51
+ osism_utils.push_task_output(
52
+ request_id, f"Looking for {node['Name']} in NetBox\n"
53
+ )
47
54
  if node["Name"] not in device_names:
48
55
  if (
49
56
  not node["Instance UUID"]
50
57
  and node["Provisioning State"] in ["enroll", "manageable", "available"]
51
58
  and node["Power State"] in ["power off", None]
52
59
  ):
53
- logger.info(
54
- f"Cleaning up baremetal node not found in netbox: {node['Name']}"
60
+ osism_utils.push_task_output(
61
+ request_id,
62
+ f"Cleaning up baremetal node not found in NetBox: {node['Name']}\n",
55
63
  )
56
64
  for port in openstack.baremetal_port_list(
57
65
  details=False, attributes=dict(node_uuid=node["UUID"])
@@ -59,14 +67,15 @@ def sync_ironic(get_ironic_parameters, force_update=False):
59
67
  openstack.baremetal_port_delete(port.id)
60
68
  openstack.baremetal_node_delete(node["UUID"])
61
69
  else:
62
- logger.error(
70
+ osism_utils.push_task_output(
63
71
  f"Cannot remove baremetal node because it is still provisioned or running: {node}"
64
72
  )
65
73
 
66
- # NOTE: Find nodes in netbox which are not present in Ironic and add them
74
+ # NOTE: Find nodes in NetBox which are not present in Ironic and add them
67
75
  for device in devices:
68
- logger.info(f"Looking for {device.name} in ironic")
69
- logger.info(device)
76
+ osism_utils.push_task_output(
77
+ request_id, f"Looking for {device.name} in ironic\n"
78
+ )
70
79
 
71
80
  node_interfaces = list(netbox.get_interfaces_by_device(device.name))
72
81
 
@@ -75,7 +84,7 @@ def sync_ironic(get_ironic_parameters, force_update=False):
75
84
  "ironic_parameters" in device.custom_fields
76
85
  and device.custom_fields["ironic_parameters"]
77
86
  ):
78
- # NOTE: Update node attributes with overrides from netbox device
87
+ # NOTE: Update node attributes with overrides from NetBox device
79
88
  deep_merge(node_attributes, device.custom_fields["ironic_parameters"])
80
89
 
81
90
  # NOTE: Decrypt ansible vaulted secrets
@@ -132,32 +141,19 @@ def sync_ironic(get_ironic_parameters, force_update=False):
132
141
  # NOTE: Render driver address field
133
142
  address_key = driver_params[node_attributes["driver"]]["address"]
134
143
  if address_key in node_attributes["driver_info"]:
135
- if device.oob_ip and "address" in device.oob_ip:
136
- node_mgmt_address = device.oob_ip["address"]
137
- else:
138
- node_mgmt_addresses = [
139
- interface["address"]
140
- for interface in node_interfaces
141
- if interface.mgmt_only
142
- and "address" in interface
143
- and interface["address"]
144
- ]
145
- if len(node_mgmt_addresses) > 0:
146
- node_mgmt_address = node_mgmt_addresses[0]
147
- else:
148
- node_mgmt_address = None
149
- if node_mgmt_address:
144
+ oob_ip_result = get_device_oob_ip(device)
145
+ if oob_ip_result:
146
+ oob_ip, _ = (
147
+ oob_ip_result # Extract IP address, ignore prefix length
148
+ )
150
149
  node_attributes["driver_info"][address_key] = (
151
150
  jinja2.Environment(loader=jinja2.BaseLoader())
152
151
  .from_string(node_attributes["driver_info"][address_key])
153
- .render(
154
- remote_board_address=str(
155
- ipaddress.ip_interface(node_mgmt_address).ip
156
- )
157
- )
152
+ .render(remote_board_address=oob_ip)
158
153
  )
159
154
  node_attributes.update({"resource_class": device.name})
160
- # NOTE: Write metadata used for provisioning into 'extra' field, so that it is available during node deploy without querying the netbox again
155
+ # NOTE: Write metadata used for provisioning into 'extra' field, so that
156
+ # it is available during node deploy without querying the NetBox again
161
157
  if "extra" not in node_attributes:
162
158
  node_attributes["extra"] = {}
163
159
  if (
@@ -191,10 +187,14 @@ def sync_ironic(get_ironic_parameters, force_update=False):
191
187
  )
192
188
  if lock.acquire(timeout=120):
193
189
  try:
194
- logger.info(f"Processing device {device.name}")
190
+ osism_utils.push_task_output(
191
+ request_id, f"Processing device {device.name}\n"
192
+ )
195
193
  node = openstack.baremetal_node_show(device.name, ignore_missing=True)
196
194
  if not node:
197
- logger.info(f"Creating baremetal node for {device.name}")
195
+ osism_utils.push_task_output(
196
+ request_id, f"Creating baremetal node for {device.name}\n"
197
+ )
198
198
  node = openstack.baremetal_node_create(device.name, node_attributes)
199
199
  else:
200
200
  # NOTE: The listener service only reacts to changes in the baremetal node. Explicitly sync provision and power state in case updates were missed by the listener.
@@ -218,8 +218,9 @@ def sync_ironic(get_ironic_parameters, force_update=False):
218
218
  if not node_updates["driver_info"]:
219
219
  node_updates.pop("driver_info", None)
220
220
  if node_updates or force_update:
221
- logger.info(
222
- f"Updating baremetal node for {device.name} with {node_updates}"
221
+ osism_utils.push_task_output(
222
+ request_id,
223
+ f"Updating baremetal node for {device.name} with {node_updates}\n",
223
224
  )
224
225
  # NOTE: Do the actual updates with all values in node_attributes. Otherwise nested dicts like e.g. driver_info will be overwritten as a whole and contain only changed values
225
226
  node = openstack.baremetal_node_update(
@@ -240,27 +241,31 @@ def sync_ironic(get_ironic_parameters, force_update=False):
240
241
  == port["address"].upper()
241
242
  ]
242
243
  if not port:
243
- logger.info(
244
- f"Creating baremetal port with MAC address {port_attributes['address']} for {device.name}"
244
+ osism_utils.push_task_output(
245
+ request_id,
246
+ f"Creating baremetal port with MAC address {port_attributes['address']} for {device.name}\n",
245
247
  )
246
248
  openstack.baremetal_port_create(port_attributes)
247
249
  else:
248
250
  node_ports.remove(port[0])
249
251
  for node_port in node_ports:
250
- # NOTE: Delete remaining ports not found in netbox
251
- logger.info(
252
- f"Deleting baremetal port with MAC address {node_port['address']} for {device.name}"
252
+ # NOTE: Delete remaining ports not found in NetBox
253
+ osism_utils.push_task_output(
254
+ request_id,
255
+ f"Deleting baremetal port with MAC address {node_port['address']} for {device.name}\n",
253
256
  )
254
257
  openstack.baremetal_port_delete(node_port["id"])
255
258
 
256
259
  node_validation = openstack.baremetal_node_validate(node["uuid"])
257
260
  if node_validation["management"].result:
258
- logger.info(
259
- f"Validation of management interface successful for baremetal node for {device.name}"
261
+ osism_utils.push_task_output(
262
+ request_id,
263
+ f"Validation of management interface successful for baremetal node for {device.name}\n",
260
264
  )
261
265
  if node["provision_state"] == "enroll":
262
- logger.info(
263
- f"Transitioning baremetal node to manageable state for {device.name}"
266
+ osism_utils.push_task_output(
267
+ request_id,
268
+ f"Transitioning baremetal node to manageable state for {device.name}\n",
264
269
  )
265
270
  node = openstack.baremetal_node_set_provision_state(
266
271
  node["uuid"], "manage"
@@ -268,14 +273,19 @@ def sync_ironic(get_ironic_parameters, force_update=False):
268
273
  node = openstack.baremetal_node_wait_for_nodes_provision_state(
269
274
  node["uuid"], "manageable"
270
275
  )
271
- logger.info(f"Baremetal node for {device.name} is manageable")
276
+ osism_utils.push_task_output(
277
+ request_id,
278
+ f"Baremetal node for {device.name} is manageable\n",
279
+ )
272
280
  if node_validation["boot"].result:
273
- logger.info(
274
- f"Validation of boot interface successful for baremetal node for {device.name}"
281
+ osism_utils.push_task_output(
282
+ request_id,
283
+ f"Validation of boot interface successful for baremetal node for {device.name}\n",
275
284
  )
276
285
  if node["provision_state"] == "manageable":
277
- logger.info(
278
- f"Transitioning baremetal node to available state for {device.name}"
286
+ osism_utils.push_task_output(
287
+ request_id,
288
+ f"Transitioning baremetal node to available state for {device.name}\n",
279
289
  )
280
290
  node = openstack.baremetal_node_set_provision_state(
281
291
  node["uuid"], "provide"
@@ -285,17 +295,20 @@ def sync_ironic(get_ironic_parameters, force_update=False):
285
295
  node["uuid"], "available"
286
296
  )
287
297
  )
288
- logger.info(
289
- f"Baremetal node for {device.name} is available"
298
+ osism_utils.push_task_output(
299
+ request_id,
300
+ f"Baremetal node for {device.name} is available\n",
290
301
  )
291
302
  else:
292
- logger.info(
293
- f"Validation of boot interface failed for baremetal node for {device.name}\nReason: {node_validation['boot'].reason}"
303
+ osism_utils.push_task_output(
304
+ request_id,
305
+ f"Validation of boot interface failed for baremetal node for {device.name}\nReason: {node_validation['boot'].reason}\n",
294
306
  )
295
307
  if node["provision_state"] == "available":
296
308
  # NOTE: Demote node to manageable
297
- logger.info(
298
- f"Transitioning baremetal node to manageable state for {device.name}"
309
+ osism_utils.push_task_output(
310
+ request_id,
311
+ f"Transitioning baremetal node to manageable state for {device.name}\n",
299
312
  )
300
313
  node = openstack.baremetal_node_set_provision_state(
301
314
  node["uuid"], "manage"
@@ -305,19 +318,26 @@ def sync_ironic(get_ironic_parameters, force_update=False):
305
318
  node["uuid"], "manageable"
306
319
  )
307
320
  )
308
- logger.info(
309
- f"Baremetal node for {device.name} is manageable"
321
+ osism_utils.push_task_output(
322
+ request_id,
323
+ f"Baremetal node for {device.name} is manageable\n",
310
324
  )
311
325
  else:
312
- logger.info(
313
- f"Validation of management interface failed for baremetal node for {device.name}\nReason: {node_validation['management'].reason}"
326
+ osism_utils.push_task_output(
327
+ request_id,
328
+ f"Validation of management interface failed for baremetal node for {device.name}\nReason: {node_validation['management'].reason}\n",
314
329
  )
315
330
  except Exception as exc:
316
- logger.info(
317
- f"Could not fully synchronize device {device.name} with ironic: {exc}"
331
+ osism_utils.push_task_output(
332
+ request_id,
333
+ f"Could not fully synchronize device {device.name} with ironic: {exc}\n",
318
334
  )
319
335
  finally:
320
336
  lock.release()
321
337
 
322
338
  else:
323
- logger.error("Could not acquire lock for node {device.name}")
339
+ osism_utils.push_task_output(
340
+ "Could not acquire lock for node {device.name}"
341
+ )
342
+
343
+ osism_utils.finish_task_output(request_id, rc=0)
@@ -3,11 +3,11 @@
3
3
  from loguru import logger
4
4
  import yaml
5
5
 
6
- from osism import settings
6
+ from osism import settings, utils
7
7
  from osism.tasks import netbox
8
8
 
9
9
 
10
- def get_nb_device_query_list():
10
+ def get_nb_device_query_list_ironic():
11
11
  try:
12
12
  supported_nb_device_filters = [
13
13
  "site",
@@ -18,7 +18,7 @@ def get_nb_device_query_list():
18
18
  "tag",
19
19
  "state",
20
20
  ]
21
- nb_device_query_list = yaml.safe_load(settings.NETBOX_FILTER_CONDUCTOR)
21
+ nb_device_query_list = yaml.safe_load(settings.NETBOX_FILTER_CONDUCTOR_IRONIC)
22
22
  if type(nb_device_query_list) is not list:
23
23
  raise TypeError
24
24
  for nb_device_query in nb_device_query_list:
@@ -27,7 +27,7 @@ def get_nb_device_query_list():
27
27
  for key in list(nb_device_query.keys()):
28
28
  if key not in supported_nb_device_filters:
29
29
  raise ValueError
30
- # NOTE: Only "location_id" and "rack_id" are supported by netbox
30
+ # NOTE: Only "location_id" and "rack_id" are supported by NetBox
31
31
  if key in ["location", "rack"]:
32
32
  value_name = nb_device_query.pop(key, "")
33
33
  if key == "location":
@@ -40,11 +40,272 @@ def get_nb_device_query_list():
40
40
  raise ValueError(f"Invalid name {value_name} for {key}")
41
41
  except (yaml.YAMLError, TypeError):
42
42
  logger.error(
43
- f"Setting NETBOX_FILTER_CONDUCTOR needs to be an array of mappings containing supported netbox device filters: {supported_nb_device_filters}"
43
+ f"Setting NETBOX_FILTER_CONDUCTOR_IRONIC needs to be an array of mappings containing supported NetBox device filters: {supported_nb_device_filters}"
44
44
  )
45
45
  nb_device_query_list = []
46
46
  except ValueError as exc:
47
- logger.error(f"Unknown value in NETBOX_FILTER_CONDUCTOR: {exc}")
47
+ logger.error(f"Unknown value in NETBOX_FILTER_CONDUCTOR_IRONIC: {exc}")
48
48
  nb_device_query_list = []
49
49
 
50
50
  return nb_device_query_list
51
+
52
+
53
+ def get_nb_device_query_list_sonic():
54
+ try:
55
+ supported_nb_device_filters = [
56
+ "site",
57
+ "region",
58
+ "site_group",
59
+ "location",
60
+ "rack",
61
+ "tag",
62
+ "state",
63
+ ]
64
+ nb_device_query_list = yaml.safe_load(settings.NETBOX_FILTER_CONDUCTOR_SONIC)
65
+ if type(nb_device_query_list) is not list:
66
+ raise TypeError
67
+ for nb_device_query in nb_device_query_list:
68
+ if type(nb_device_query) is not dict:
69
+ raise TypeError
70
+ for key in list(nb_device_query.keys()):
71
+ if key not in supported_nb_device_filters:
72
+ raise ValueError
73
+ # NOTE: Only "location_id" and "rack_id" are supported by NetBox
74
+ if key in ["location", "rack"]:
75
+ value_name = nb_device_query.pop(key, "")
76
+ if key == "location":
77
+ value_id = netbox.get_location_id(value_name)
78
+ elif key == "rack":
79
+ value_id = netbox.get_rack_id(value_name)
80
+ if value_id:
81
+ nb_device_query.update({key + "_id": value_id})
82
+ else:
83
+ raise ValueError(f"Invalid name {value_name} for {key}")
84
+ except (yaml.YAMLError, TypeError):
85
+ logger.error(
86
+ f"Setting NETBOX_FILTER_CONDUCTOR_SONIC needs to be an array of mappings containing supported NetBox device filters: {supported_nb_device_filters}"
87
+ )
88
+ nb_device_query_list = []
89
+ except ValueError as exc:
90
+ logger.error(f"Unknown value in NETBOX_FILTER_CONDUCTOR_SONIC: {exc}")
91
+ nb_device_query_list = []
92
+
93
+ return nb_device_query_list
94
+
95
+
96
+ def get_device_oob_ip(device):
97
+ """Get out-of-band IP address for device management interface.
98
+
99
+ Args:
100
+ device: NetBox device object
101
+
102
+ Returns:
103
+ tuple: (IP address, prefix length) for management interface or None
104
+ Example: ('192.168.1.10', 24)
105
+ """
106
+ import ipaddress
107
+
108
+ try:
109
+ oob_ip_with_prefix = None
110
+
111
+ # First check if device has oob_ip field set
112
+ if hasattr(device, "oob_ip") and device.oob_ip:
113
+ oob_ip_with_prefix = device.oob_ip
114
+ else:
115
+ # Fall back to management interfaces
116
+ interfaces = utils.nb.dcim.interfaces.filter(device_id=device.id)
117
+
118
+ for interface in interfaces:
119
+ if interface.mgmt_only:
120
+ # Get IP addresses assigned to this interface
121
+ ip_addresses = utils.nb.ipam.ip_addresses.filter(
122
+ assigned_object_id=interface.id,
123
+ )
124
+
125
+ for ip_addr in ip_addresses:
126
+ if ip_addr.address:
127
+ oob_ip_with_prefix = ip_addr.address
128
+ break
129
+ if oob_ip_with_prefix:
130
+ break
131
+
132
+ if oob_ip_with_prefix:
133
+ # Parse the IP address with prefix (e.g., "192.168.1.10/24")
134
+ ip_interface = ipaddress.ip_interface(oob_ip_with_prefix)
135
+ ip_address = str(ip_interface.ip)
136
+ prefix_length = ip_interface.network.prefixlen
137
+
138
+ logger.debug(
139
+ f"Found OOB IP for device {device.name}: {ip_address}/{prefix_length}"
140
+ )
141
+
142
+ # Return tuple of (IP address, prefix length)
143
+ return (ip_address, prefix_length)
144
+
145
+ except Exception as e:
146
+ logger.warning(f"Could not get OOB IP for device {device.name}: {e}")
147
+
148
+ return None
149
+
150
+
151
+ def get_device_vlans(device):
152
+ """Get VLANs configured on device interfaces.
153
+
154
+ Args:
155
+ device: NetBox device object
156
+
157
+ Returns:
158
+ dict: Dictionary with VLAN information
159
+ {
160
+ 'vlans': {vid: {'name': name, 'description': desc}},
161
+ 'vlan_members': {vid: {'port_name': 'tagging_mode'}},
162
+ 'vlan_interfaces': {vid: {'addresses': [ip_with_prefix, ...]}}
163
+ }
164
+ """
165
+ vlans = {}
166
+ vlan_members = {}
167
+ vlan_interfaces = {}
168
+
169
+ try:
170
+ # Get all interfaces for the device and convert to list for multiple iterations
171
+ interfaces = list(utils.nb.dcim.interfaces.filter(device_id=device.id))
172
+
173
+ for interface in interfaces:
174
+ # Skip management interfaces and virtual interfaces
175
+ if interface.mgmt_only or (
176
+ hasattr(interface, "type")
177
+ and interface.type
178
+ and interface.type.value == "virtual"
179
+ ):
180
+ continue
181
+
182
+ # Process untagged VLAN
183
+ if hasattr(interface, "untagged_vlan") and interface.untagged_vlan:
184
+ vlan = interface.untagged_vlan
185
+ vid = vlan.vid
186
+
187
+ # Add VLAN info if not already present
188
+ if vid not in vlans:
189
+ vlans[vid] = {
190
+ "name": vlan.name or f"Vlan{vid}",
191
+ "description": vlan.description or "",
192
+ }
193
+
194
+ # Add interface to VLAN members as untagged
195
+ if vid not in vlan_members:
196
+ vlan_members[vid] = {}
197
+
198
+ # Use original NetBox interface name - conversion will be done in sonic.py
199
+ vlan_members[vid][interface.name] = "untagged"
200
+
201
+ # Process tagged VLANs
202
+ if hasattr(interface, "tagged_vlans") and interface.tagged_vlans:
203
+ for vlan in interface.tagged_vlans:
204
+ vid = vlan.vid
205
+
206
+ # Add VLAN info if not already present
207
+ if vid not in vlans:
208
+ vlans[vid] = {
209
+ "name": vlan.name or f"Vlan{vid}",
210
+ "description": vlan.description or "",
211
+ }
212
+
213
+ # Add interface to VLAN members as tagged
214
+ if vid not in vlan_members:
215
+ vlan_members[vid] = {}
216
+
217
+ # Use original NetBox interface name - conversion will be done in sonic.py
218
+ vlan_members[vid][interface.name] = "tagged"
219
+
220
+ # Get VLAN interfaces (SVIs) - virtual interfaces with VLAN assignments
221
+ for interface in interfaces:
222
+ # Check if interface is virtual type and has VLAN assignment
223
+ if (
224
+ hasattr(interface, "type")
225
+ and interface.type
226
+ and interface.type.value == "virtual"
227
+ and interface.name.startswith("Vlan")
228
+ ):
229
+ try:
230
+ vid = int(interface.name[4:])
231
+ # Get IP addresses for this VLAN interface
232
+ ip_addresses = utils.nb.ipam.ip_addresses.filter(
233
+ assigned_object_id=interface.id,
234
+ )
235
+
236
+ addresses = []
237
+ for ip_addr in ip_addresses:
238
+ if ip_addr.address:
239
+ addresses.append(ip_addr.address)
240
+
241
+ if addresses:
242
+ if vid not in vlan_interfaces:
243
+ vlan_interfaces[vid] = {}
244
+ # Store all IP addresses for this VLAN interface
245
+ vlan_interfaces[vid]["addresses"] = addresses
246
+ except (ValueError, IndexError):
247
+ # Skip if interface name doesn't follow Vlan<number> pattern
248
+ pass
249
+
250
+ except Exception as e:
251
+ logger.warning(f"Could not get VLANs for device {device.name}: {e}")
252
+
253
+ return {
254
+ "vlans": vlans,
255
+ "vlan_members": vlan_members,
256
+ "vlan_interfaces": vlan_interfaces,
257
+ }
258
+
259
+
260
+ def get_device_loopbacks(device):
261
+ """Get Loopback interfaces configured on device.
262
+
263
+ Args:
264
+ device: NetBox device object
265
+
266
+ Returns:
267
+ dict: Dictionary with Loopback information
268
+ {
269
+ 'loopbacks': {'Loopback0': {'addresses': [ip_with_prefix, ...]}}
270
+ }
271
+ """
272
+ loopbacks = {}
273
+
274
+ try:
275
+ # Get all interfaces for the device
276
+ interfaces = list(utils.nb.dcim.interfaces.filter(device_id=device.id))
277
+
278
+ for interface in interfaces:
279
+ # Check if interface is virtual type and is a Loopback interface
280
+ if (
281
+ hasattr(interface, "type")
282
+ and interface.type
283
+ and interface.type.value == "virtual"
284
+ and interface.name.startswith("Loopback")
285
+ ):
286
+
287
+ try:
288
+ # Get IP addresses for this Loopback interface
289
+ ip_addresses = utils.nb.ipam.ip_addresses.filter(
290
+ assigned_object_id=interface.id,
291
+ )
292
+
293
+ addresses = []
294
+ for ip_addr in ip_addresses:
295
+ if ip_addr.address:
296
+ addresses.append(ip_addr.address)
297
+
298
+ if addresses:
299
+ loopbacks[interface.name] = {"addresses": addresses}
300
+
301
+ except Exception as e:
302
+ logger.debug(
303
+ f"Error processing Loopback interface {interface.name}: {e}"
304
+ )
305
+
306
+ except Exception as e:
307
+ logger.warning(
308
+ f"Could not get Loopback interfaces for device {device.name}: {e}"
309
+ )
310
+
311
+ return {"loopbacks": loopbacks}