osism 0.20250605.0__py3-none-any.whl → 0.20250621.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.
Files changed (36) hide show
  1. osism/commands/baremetal.py +71 -10
  2. osism/commands/manage.py +25 -1
  3. osism/commands/netbox.py +22 -5
  4. osism/commands/reconciler.py +6 -28
  5. osism/commands/sync.py +48 -1
  6. osism/commands/validate.py +7 -30
  7. osism/commands/wait.py +8 -31
  8. osism/services/listener.py +1 -1
  9. osism/settings.py +13 -2
  10. osism/tasks/__init__.py +8 -40
  11. osism/tasks/conductor/__init__.py +8 -1
  12. osism/tasks/conductor/ironic.py +90 -66
  13. osism/tasks/conductor/netbox.py +267 -6
  14. osism/tasks/conductor/sonic/__init__.py +26 -0
  15. osism/tasks/conductor/sonic/bgp.py +87 -0
  16. osism/tasks/conductor/sonic/cache.py +114 -0
  17. osism/tasks/conductor/sonic/config_generator.py +908 -0
  18. osism/tasks/conductor/sonic/connections.py +389 -0
  19. osism/tasks/conductor/sonic/constants.py +79 -0
  20. osism/tasks/conductor/sonic/device.py +82 -0
  21. osism/tasks/conductor/sonic/exporter.py +226 -0
  22. osism/tasks/conductor/sonic/interface.py +789 -0
  23. osism/tasks/conductor/sonic/sync.py +190 -0
  24. osism/tasks/conductor.py +2 -0
  25. osism/tasks/netbox.py +6 -4
  26. osism/tasks/reconciler.py +4 -5
  27. osism/utils/__init__.py +51 -4
  28. {osism-0.20250605.0.dist-info → osism-0.20250621.0.dist-info}/METADATA +4 -4
  29. {osism-0.20250605.0.dist-info → osism-0.20250621.0.dist-info}/RECORD +35 -25
  30. {osism-0.20250605.0.dist-info → osism-0.20250621.0.dist-info}/entry_points.txt +5 -0
  31. osism-0.20250621.0.dist-info/pbr.json +1 -0
  32. osism-0.20250605.0.dist-info/pbr.json +0 -1
  33. {osism-0.20250605.0.dist-info → osism-0.20250621.0.dist-info}/WHEEL +0 -0
  34. {osism-0.20250605.0.dist-info → osism-0.20250621.0.dist-info}/licenses/AUTHORS +0 -0
  35. {osism-0.20250605.0.dist-info → osism-0.20250621.0.dist-info}/licenses/LICENSE +0 -0
  36. {osism-0.20250605.0.dist-info → osism-0.20250621.0.dist-info}/top_level.txt +0 -0
@@ -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,34 +141,25 @@ 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
161
155
  if "extra" not in node_attributes:
162
156
  node_attributes["extra"] = {}
157
+ # NOTE: Copy instance_info into extra field. because ironic removes it on undeployment. This way it may be readded on undeploy without querying the netbox again
158
+ if "instance_info" in node_attributes and node_attributes["instance_info"]:
159
+ node_attributes["extra"].update(
160
+ {"instance_info": json.dumps(node_attributes["instance_info"])}
161
+ )
162
+ # NOTE: Write metadata used for provisioning into 'extra' field, so that it is available during node deploy without querying the netbox again
163
163
  if (
164
164
  "netplan_parameters" in device.custom_fields
165
165
  and device.custom_fields["netplan_parameters"]
@@ -191,10 +191,14 @@ def sync_ironic(get_ironic_parameters, force_update=False):
191
191
  )
192
192
  if lock.acquire(timeout=120):
193
193
  try:
194
- logger.info(f"Processing device {device.name}")
194
+ osism_utils.push_task_output(
195
+ request_id, f"Processing device {device.name}\n"
196
+ )
195
197
  node = openstack.baremetal_node_show(device.name, ignore_missing=True)
196
198
  if not node:
197
- logger.info(f"Creating baremetal node for {device.name}")
199
+ osism_utils.push_task_output(
200
+ request_id, f"Creating baremetal node for {device.name}\n"
201
+ )
198
202
  node = openstack.baremetal_node_create(device.name, node_attributes)
199
203
  else:
200
204
  # 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 +222,9 @@ def sync_ironic(get_ironic_parameters, force_update=False):
218
222
  if not node_updates["driver_info"]:
219
223
  node_updates.pop("driver_info", None)
220
224
  if node_updates or force_update:
221
- logger.info(
222
- f"Updating baremetal node for {device.name} with {node_updates}"
225
+ osism_utils.push_task_output(
226
+ request_id,
227
+ f"Updating baremetal node for {device.name} with {node_updates}\n",
223
228
  )
224
229
  # 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
230
  node = openstack.baremetal_node_update(
@@ -240,27 +245,31 @@ def sync_ironic(get_ironic_parameters, force_update=False):
240
245
  == port["address"].upper()
241
246
  ]
242
247
  if not port:
243
- logger.info(
244
- f"Creating baremetal port with MAC address {port_attributes['address']} for {device.name}"
248
+ osism_utils.push_task_output(
249
+ request_id,
250
+ f"Creating baremetal port with MAC address {port_attributes['address']} for {device.name}\n",
245
251
  )
246
252
  openstack.baremetal_port_create(port_attributes)
247
253
  else:
248
254
  node_ports.remove(port[0])
249
255
  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}"
256
+ # NOTE: Delete remaining ports not found in NetBox
257
+ osism_utils.push_task_output(
258
+ request_id,
259
+ f"Deleting baremetal port with MAC address {node_port['address']} for {device.name}\n",
253
260
  )
254
261
  openstack.baremetal_port_delete(node_port["id"])
255
262
 
256
263
  node_validation = openstack.baremetal_node_validate(node["uuid"])
257
264
  if node_validation["management"].result:
258
- logger.info(
259
- f"Validation of management interface successful for baremetal node for {device.name}"
265
+ osism_utils.push_task_output(
266
+ request_id,
267
+ f"Validation of management interface successful for baremetal node for {device.name}\n",
260
268
  )
261
269
  if node["provision_state"] == "enroll":
262
- logger.info(
263
- f"Transitioning baremetal node to manageable state for {device.name}"
270
+ osism_utils.push_task_output(
271
+ request_id,
272
+ f"Transitioning baremetal node to manageable state for {device.name}\n",
264
273
  )
265
274
  node = openstack.baremetal_node_set_provision_state(
266
275
  node["uuid"], "manage"
@@ -268,14 +277,19 @@ def sync_ironic(get_ironic_parameters, force_update=False):
268
277
  node = openstack.baremetal_node_wait_for_nodes_provision_state(
269
278
  node["uuid"], "manageable"
270
279
  )
271
- logger.info(f"Baremetal node for {device.name} is manageable")
280
+ osism_utils.push_task_output(
281
+ request_id,
282
+ f"Baremetal node for {device.name} is manageable\n",
283
+ )
272
284
  if node_validation["boot"].result:
273
- logger.info(
274
- f"Validation of boot interface successful for baremetal node for {device.name}"
285
+ osism_utils.push_task_output(
286
+ request_id,
287
+ f"Validation of boot interface successful for baremetal node for {device.name}\n",
275
288
  )
276
289
  if node["provision_state"] == "manageable":
277
- logger.info(
278
- f"Transitioning baremetal node to available state for {device.name}"
290
+ osism_utils.push_task_output(
291
+ request_id,
292
+ f"Transitioning baremetal node to available state for {device.name}\n",
279
293
  )
280
294
  node = openstack.baremetal_node_set_provision_state(
281
295
  node["uuid"], "provide"
@@ -285,17 +299,20 @@ def sync_ironic(get_ironic_parameters, force_update=False):
285
299
  node["uuid"], "available"
286
300
  )
287
301
  )
288
- logger.info(
289
- f"Baremetal node for {device.name} is available"
302
+ osism_utils.push_task_output(
303
+ request_id,
304
+ f"Baremetal node for {device.name} is available\n",
290
305
  )
291
306
  else:
292
- logger.info(
293
- f"Validation of boot interface failed for baremetal node for {device.name}\nReason: {node_validation['boot'].reason}"
307
+ osism_utils.push_task_output(
308
+ request_id,
309
+ f"Validation of boot interface failed for baremetal node for {device.name}\nReason: {node_validation['boot'].reason}\n",
294
310
  )
295
311
  if node["provision_state"] == "available":
296
312
  # NOTE: Demote node to manageable
297
- logger.info(
298
- f"Transitioning baremetal node to manageable state for {device.name}"
313
+ osism_utils.push_task_output(
314
+ request_id,
315
+ f"Transitioning baremetal node to manageable state for {device.name}\n",
299
316
  )
300
317
  node = openstack.baremetal_node_set_provision_state(
301
318
  node["uuid"], "manage"
@@ -305,19 +322,26 @@ def sync_ironic(get_ironic_parameters, force_update=False):
305
322
  node["uuid"], "manageable"
306
323
  )
307
324
  )
308
- logger.info(
309
- f"Baremetal node for {device.name} is manageable"
325
+ osism_utils.push_task_output(
326
+ request_id,
327
+ f"Baremetal node for {device.name} is manageable\n",
310
328
  )
311
329
  else:
312
- logger.info(
313
- f"Validation of management interface failed for baremetal node for {device.name}\nReason: {node_validation['management'].reason}"
330
+ osism_utils.push_task_output(
331
+ request_id,
332
+ f"Validation of management interface failed for baremetal node for {device.name}\nReason: {node_validation['management'].reason}\n",
314
333
  )
315
334
  except Exception as exc:
316
- logger.info(
317
- f"Could not fully synchronize device {device.name} with ironic: {exc}"
335
+ osism_utils.push_task_output(
336
+ request_id,
337
+ f"Could not fully synchronize device {device.name} with ironic: {exc}\n",
318
338
  )
319
339
  finally:
320
340
  lock.release()
321
341
 
322
342
  else:
323
- logger.error("Could not acquire lock for node {device.name}")
343
+ osism_utils.push_task_output(
344
+ "Could not acquire lock for node {device.name}"
345
+ )
346
+
347
+ 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}
@@ -0,0 +1,26 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """SONiC configuration management package."""
4
+
5
+ from .config_generator import generate_sonic_config
6
+ from .exporter import save_config_to_netbox, export_config_to_file
7
+ from .sync import sync_sonic
8
+ from .connections import (
9
+ get_connected_interfaces,
10
+ get_connected_device_for_sonic_interface,
11
+ get_connected_device_via_interface,
12
+ find_interconnected_devices,
13
+ get_device_bgp_neighbors_via_loopback,
14
+ )
15
+
16
+ __all__ = [
17
+ "generate_sonic_config",
18
+ "save_config_to_netbox",
19
+ "export_config_to_file",
20
+ "sync_sonic",
21
+ "get_connected_interfaces",
22
+ "get_connected_device_for_sonic_interface",
23
+ "get_connected_device_via_interface",
24
+ "find_interconnected_devices",
25
+ "get_device_bgp_neighbors_via_loopback",
26
+ ]