osism 0.20250616.0__py3-none-any.whl → 0.20250627.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 (30) hide show
  1. osism/api.py +49 -5
  2. osism/commands/baremetal.py +23 -3
  3. osism/commands/manage.py +276 -1
  4. osism/commands/reconciler.py +8 -1
  5. osism/commands/sync.py +27 -7
  6. osism/settings.py +1 -0
  7. osism/tasks/conductor/__init__.py +2 -2
  8. osism/tasks/conductor/ironic.py +21 -19
  9. osism/tasks/conductor/sonic/__init__.py +26 -0
  10. osism/tasks/conductor/sonic/bgp.py +87 -0
  11. osism/tasks/conductor/sonic/cache.py +114 -0
  12. osism/tasks/conductor/sonic/config_generator.py +1000 -0
  13. osism/tasks/conductor/sonic/connections.py +389 -0
  14. osism/tasks/conductor/sonic/constants.py +80 -0
  15. osism/tasks/conductor/sonic/device.py +82 -0
  16. osism/tasks/conductor/sonic/exporter.py +226 -0
  17. osism/tasks/conductor/sonic/interface.py +940 -0
  18. osism/tasks/conductor/sonic/sync.py +215 -0
  19. osism/tasks/reconciler.py +12 -2
  20. {osism-0.20250616.0.dist-info → osism-0.20250627.0.dist-info}/METADATA +3 -3
  21. {osism-0.20250616.0.dist-info → osism-0.20250627.0.dist-info}/RECORD +27 -18
  22. {osism-0.20250616.0.dist-info → osism-0.20250627.0.dist-info}/entry_points.txt +3 -0
  23. osism-0.20250627.0.dist-info/licenses/AUTHORS +1 -0
  24. osism-0.20250627.0.dist-info/pbr.json +1 -0
  25. osism/tasks/conductor/sonic.py +0 -1401
  26. osism-0.20250616.0.dist-info/licenses/AUTHORS +0 -1
  27. osism-0.20250616.0.dist-info/pbr.json +0 -1
  28. {osism-0.20250616.0.dist-info → osism-0.20250627.0.dist-info}/WHEEL +0 -0
  29. {osism-0.20250616.0.dist-info → osism-0.20250627.0.dist-info}/licenses/LICENSE +0 -0
  30. {osism-0.20250616.0.dist-info → osism-0.20250627.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1000 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Configuration generation logic for SONiC."""
4
+
5
+ import copy
6
+ import ipaddress
7
+ import json
8
+ import os
9
+ import re
10
+ from loguru import logger
11
+
12
+ from osism import utils
13
+ from osism.tasks.conductor.netbox import (
14
+ get_device_loopbacks,
15
+ get_device_oob_ip,
16
+ get_device_vlans,
17
+ )
18
+ from .bgp import calculate_local_asn_from_ipv4
19
+ from .device import get_device_platform, get_device_hostname, get_device_mac_address
20
+ from .interface import (
21
+ get_port_config,
22
+ get_speed_from_port_type,
23
+ convert_netbox_interface_to_sonic,
24
+ convert_sonic_interface_to_alias,
25
+ detect_breakout_ports,
26
+ detect_port_channels,
27
+ clear_port_config_cache,
28
+ )
29
+ from .connections import (
30
+ get_connected_interfaces,
31
+ get_connected_device_for_sonic_interface,
32
+ )
33
+ from .cache import get_cached_device_interfaces
34
+
35
+ # Global cache for NTP servers to avoid multiple queries
36
+ _ntp_servers_cache = None
37
+
38
+
39
+ def natural_sort_key(port_name):
40
+ """Extract numeric part from port name for natural sorting."""
41
+ match = re.search(r"(\d+)", port_name)
42
+ return int(match.group(1)) if match else 0
43
+
44
+
45
+ def generate_sonic_config(device, hwsku, device_as_mapping=None):
46
+ """Generate minimal SONiC config.json for a device.
47
+
48
+ Args:
49
+ device: NetBox device object
50
+ hwsku: Hardware SKU name
51
+ device_as_mapping: Dict mapping device IDs to pre-calculated AS numbers for spine/superspine groups
52
+
53
+ Returns:
54
+ dict: Minimal SONiC configuration dictionary
55
+ """
56
+ # Get port configuration for the HWSKU
57
+ port_config = get_port_config(hwsku)
58
+
59
+ # Get port channel configuration from NetBox first (needed by get_connected_interfaces)
60
+ portchannel_info = detect_port_channels(device)
61
+
62
+ # Get connected interfaces to determine admin_status
63
+ connected_interfaces, connected_portchannels = get_connected_interfaces(
64
+ device, portchannel_info
65
+ )
66
+
67
+ # Get OOB IP for management interface
68
+ oob_ip_result = get_device_oob_ip(device)
69
+
70
+ # Get VLAN configuration from NetBox
71
+ vlan_info = get_device_vlans(device)
72
+
73
+ # Get Loopback configuration from NetBox
74
+ loopback_info = get_device_loopbacks(device)
75
+
76
+ # Get breakout port configuration from NetBox
77
+ breakout_info = detect_breakout_ports(device)
78
+
79
+ # Get all interfaces from NetBox with their speeds and types
80
+ netbox_interfaces = {}
81
+ try:
82
+ interfaces = get_cached_device_interfaces(device.id)
83
+ for interface in interfaces:
84
+ # Convert NetBox interface name to SONiC format for lookup
85
+ interface_speed = getattr(interface, "speed", None)
86
+ # If speed is not set, try to get it from port type
87
+ if not interface_speed and hasattr(interface, "type") and interface.type:
88
+ interface_speed = get_speed_from_port_type(interface.type.value)
89
+ sonic_name = convert_netbox_interface_to_sonic(interface, device)
90
+ netbox_interfaces[sonic_name] = {
91
+ "speed": interface_speed,
92
+ "type": (
93
+ getattr(interface.type, "value", None)
94
+ if hasattr(interface, "type") and interface.type
95
+ else None
96
+ ),
97
+ "netbox_name": interface.name,
98
+ }
99
+ except Exception as e:
100
+ logger.warning(f"Could not get interface details from NetBox: {e}")
101
+
102
+ # Get device metadata using helper functions
103
+ platform = get_device_platform(device, hwsku)
104
+ hostname = get_device_hostname(device)
105
+ mac_address = get_device_mac_address(device)
106
+
107
+ # Try to load base configuration from /etc/sonic/config_db.json
108
+ # Always start with a fresh, empty configuration for each device
109
+ base_config_path = "/etc/sonic/config_db.json"
110
+ config = {}
111
+
112
+ try:
113
+ if os.path.exists(base_config_path):
114
+ with open(base_config_path, "r") as f:
115
+ base_config = json.load(f)
116
+ # Create a deep copy to ensure no cross-device contamination
117
+ config = copy.deepcopy(base_config)
118
+ logger.info(
119
+ f"Loaded fresh base configuration from {base_config_path} for device {device.name}"
120
+ )
121
+ else:
122
+ logger.debug(
123
+ f"Base config file {base_config_path} not found, starting with empty config for device {device.name}"
124
+ )
125
+ except Exception as e:
126
+ logger.warning(
127
+ f"Could not load base configuration from {base_config_path} for device {device.name}: {e}"
128
+ )
129
+ # Ensure we start fresh even on error
130
+ config = {}
131
+
132
+ # Update DEVICE_METADATA with NetBox information
133
+ if "localhost" not in config["DEVICE_METADATA"]:
134
+ config["DEVICE_METADATA"]["localhost"] = {}
135
+
136
+ config["DEVICE_METADATA"]["localhost"].update(
137
+ {
138
+ "hostname": hostname,
139
+ "hwsku": hwsku,
140
+ "platform": platform,
141
+ "mac": mac_address,
142
+ }
143
+ )
144
+
145
+ # Add BGP_GLOBALS configuration with router_id set to primary IP address
146
+ primary_ip = None
147
+ if device.primary_ip4:
148
+ primary_ip = str(device.primary_ip4.address).split("/")[0]
149
+ elif device.primary_ip6:
150
+ primary_ip = str(device.primary_ip6.address).split("/")[0]
151
+
152
+ if primary_ip:
153
+ if "default" not in config["BGP_GLOBALS"]:
154
+ config["BGP_GLOBALS"]["default"] = {}
155
+ config["BGP_GLOBALS"]["default"]["router_id"] = primary_ip
156
+
157
+ # Calculate and add local_asn from router_id (only for IPv4)
158
+ if device.primary_ip4:
159
+ try:
160
+ # Check if device is in a spine/superspine group with pre-calculated AS
161
+ if device_as_mapping and device.id in device_as_mapping:
162
+ local_asn = device_as_mapping[device.id]
163
+ logger.debug(
164
+ f"Using group-calculated AS {local_asn} for spine/superspine device {device.name}"
165
+ )
166
+ else:
167
+ # Use normal AS calculation for leaf switches and non-grouped devices
168
+ local_asn = calculate_local_asn_from_ipv4(primary_ip)
169
+
170
+ config["BGP_GLOBALS"]["default"]["local_asn"] = str(local_asn)
171
+ except ValueError as e:
172
+ logger.warning(
173
+ f"Could not calculate local ASN for device {device.name}: {e}"
174
+ )
175
+
176
+ # Add port configurations
177
+ _add_port_configurations(
178
+ config,
179
+ port_config,
180
+ connected_interfaces,
181
+ portchannel_info,
182
+ breakout_info,
183
+ netbox_interfaces,
184
+ vlan_info,
185
+ device,
186
+ )
187
+
188
+ # Add interface configurations
189
+ _add_interface_configurations(config, connected_interfaces, portchannel_info)
190
+
191
+ # Add BGP configurations
192
+ _add_bgp_configurations(
193
+ config,
194
+ connected_interfaces,
195
+ connected_portchannels,
196
+ portchannel_info,
197
+ device,
198
+ device_as_mapping,
199
+ )
200
+
201
+ # Add NTP server configuration (device-specific)
202
+ _add_ntp_configuration(config, device)
203
+
204
+ # Add VLAN configuration
205
+ _add_vlan_configuration(config, vlan_info, netbox_interfaces, device)
206
+
207
+ # Add Loopback configuration
208
+ _add_loopback_configuration(config, loopback_info)
209
+
210
+ # Add management interface configuration
211
+ if oob_ip_result:
212
+ oob_ip, prefix_len = oob_ip_result
213
+ config["MGMT_INTERFACE"]["eth0"] = {"admin_status": "up"}
214
+ config["MGMT_INTERFACE"][f"eth0|{oob_ip}/{prefix_len}"] = {}
215
+
216
+ # Add breakout configuration
217
+ if breakout_info["breakout_cfgs"]:
218
+ config["BREAKOUT_CFG"].update(breakout_info["breakout_cfgs"])
219
+ if breakout_info["breakout_ports"]:
220
+ config["BREAKOUT_PORTS"].update(breakout_info["breakout_ports"])
221
+
222
+ # Add port channel configuration
223
+ _add_portchannel_configuration(config, portchannel_info)
224
+
225
+ return config
226
+
227
+
228
+ def _add_port_configurations(
229
+ config,
230
+ port_config,
231
+ connected_interfaces,
232
+ portchannel_info,
233
+ breakout_info,
234
+ netbox_interfaces,
235
+ vlan_info,
236
+ device,
237
+ ):
238
+ """Add port configurations to config."""
239
+ # Sort ports naturally (Ethernet0, Ethernet4, Ethernet8, ...)
240
+ sorted_ports = sorted(port_config.keys(), key=natural_sort_key)
241
+
242
+ for port_name in sorted_ports:
243
+ port_info = port_config[port_name]
244
+
245
+ # Skip master ports that have breakout configurations
246
+ # These will be replaced by their individual breakout ports
247
+ if port_name in breakout_info["breakout_cfgs"]:
248
+ logger.debug(
249
+ f"Skipping master port {port_name} - has breakout configuration"
250
+ )
251
+ continue
252
+
253
+ # Set admin_status to "up" if port is connected or is a port channel member, otherwise "down"
254
+ admin_status = (
255
+ "up"
256
+ if (
257
+ port_name in connected_interfaces
258
+ or port_name in portchannel_info["member_mapping"]
259
+ )
260
+ else "down"
261
+ )
262
+
263
+ # Check if this port is a breakout port and adjust speed and lanes accordingly
264
+ port_speed = port_info["speed"]
265
+ port_lanes = port_info["lanes"]
266
+
267
+ # Override with NetBox data if available and hardware config has no speed
268
+ if port_name in netbox_interfaces:
269
+ netbox_speed = netbox_interfaces[port_name]["speed"]
270
+ if netbox_speed and (not port_speed or port_speed == "0"):
271
+ logger.info(
272
+ f"Using NetBox speed {netbox_speed} for port {port_name} (hardware config had: {port_speed})"
273
+ )
274
+ port_speed = str(netbox_speed)
275
+
276
+ if port_name in breakout_info["breakout_ports"]:
277
+ # Get the master port to determine original speed and lanes
278
+ master_port = breakout_info["breakout_ports"][port_name]["master"]
279
+
280
+ # Override with individual breakout port speed from NetBox if available
281
+ if port_name in netbox_interfaces and netbox_interfaces[port_name]["speed"]:
282
+ port_speed = str(netbox_interfaces[port_name]["speed"])
283
+ logger.debug(
284
+ f"Using NetBox speed {port_speed} for breakout port {port_name}"
285
+ )
286
+ elif master_port in breakout_info["breakout_cfgs"]:
287
+ # Fallback to extracting speed from breakout mode
288
+ brkout_mode = breakout_info["breakout_cfgs"][master_port]["brkout_mode"]
289
+ if "25G" in brkout_mode:
290
+ port_speed = "25000"
291
+ elif "50G" in brkout_mode:
292
+ port_speed = "50000"
293
+ elif "100G" in brkout_mode:
294
+ port_speed = "100000"
295
+ elif "200G" in brkout_mode:
296
+ port_speed = "200000"
297
+
298
+ # Calculate individual lane for this breakout port
299
+ port_lanes = _calculate_breakout_port_lane(
300
+ port_name, master_port, port_config
301
+ )
302
+
303
+ # Generate correct alias based on port name and speed
304
+ interface_speed = int(port_speed) if port_speed else None
305
+ is_breakout_port = port_name in breakout_info["breakout_ports"]
306
+ correct_alias = convert_sonic_interface_to_alias(
307
+ port_name, interface_speed, is_breakout_port, port_config
308
+ )
309
+
310
+ # Use master port index for breakout ports
311
+ port_index = port_info["index"]
312
+ if is_breakout_port:
313
+ master_port = breakout_info["breakout_ports"][port_name]["master"]
314
+ if master_port in port_config:
315
+ port_index = port_config[master_port]["index"]
316
+
317
+ port_data = {
318
+ "admin_status": admin_status,
319
+ "alias": correct_alias,
320
+ "index": port_index,
321
+ "lanes": port_lanes,
322
+ "speed": port_speed,
323
+ "mtu": "9100",
324
+ "adv_speeds": "all",
325
+ "autoneg": "off",
326
+ "link_training": "off",
327
+ "unreliable_los": "auto",
328
+ }
329
+
330
+ # Add valid_speeds if available in port_info
331
+ if "valid_speeds" in port_info:
332
+ port_data["valid_speeds"] = port_info["valid_speeds"]
333
+
334
+ # Override valid_speeds for breakout ports based on their individual speed
335
+ if port_name in breakout_info["breakout_ports"]:
336
+ # For breakout ports, set valid_speeds based on the port's speed
337
+ breakout_valid_speeds = _get_breakout_port_valid_speeds(port_speed)
338
+ if breakout_valid_speeds:
339
+ port_data["valid_speeds"] = breakout_valid_speeds
340
+
341
+ config["PORT"][port_name] = port_data
342
+
343
+ # Add all breakout ports (since master ports were skipped above)
344
+ _add_missing_breakout_ports(
345
+ config,
346
+ breakout_info,
347
+ port_config,
348
+ connected_interfaces,
349
+ portchannel_info,
350
+ netbox_interfaces,
351
+ )
352
+
353
+ # Add tagged VLANs to PORT configuration
354
+ _add_tagged_vlans_to_ports(config, vlan_info, netbox_interfaces, device)
355
+
356
+
357
+ def _get_breakout_port_valid_speeds(port_speed):
358
+ """Get valid speeds for a breakout port based on its configured speed."""
359
+ if not port_speed:
360
+ return None
361
+
362
+ speed_int = int(port_speed)
363
+
364
+ if speed_int == 25000:
365
+ return "25000,10000,1000"
366
+ elif speed_int == 50000:
367
+ return "50000,25000,10000,1000"
368
+ elif speed_int == 100000:
369
+ return "100000,50000,25000,10000,1000"
370
+ elif speed_int == 200000:
371
+ return "200000,100000,50000,25000,10000,1000"
372
+ else:
373
+ # For other speeds, include common lower speeds
374
+ return f"{port_speed},10000,1000"
375
+
376
+
377
+ def _calculate_breakout_port_lane(port_name, master_port, port_config):
378
+ """Calculate individual lane for a breakout port."""
379
+ # Get master port's lanes from port_config
380
+ if master_port in port_config:
381
+ master_lanes = port_config[master_port]["lanes"]
382
+ # Parse lane range (e.g., "1,2,3,4" or "1-4")
383
+ if "," in master_lanes:
384
+ lanes_list = [int(lane.strip()) for lane in master_lanes.split(",")]
385
+ elif "-" in master_lanes:
386
+ start, end = map(int, master_lanes.split("-"))
387
+ lanes_list = list(range(start, end + 1))
388
+ else:
389
+ # Single lane or simple number
390
+ lanes_list = [int(master_lanes)]
391
+
392
+ # Calculate which lane this breakout port should use
393
+ port_match = re.match(r"Ethernet(\d+)", port_name)
394
+ if port_match:
395
+ sonic_port_num = int(port_match.group(1))
396
+ master_port_match = re.match(r"Ethernet(\d+)", master_port)
397
+ if master_port_match:
398
+ master_port_num = int(master_port_match.group(1))
399
+ # Calculate subport index (0, 1, 2, 3 for 4x breakout)
400
+ subport_index = sonic_port_num - master_port_num
401
+ if 0 <= subport_index < len(lanes_list):
402
+ return str(lanes_list[subport_index])
403
+ else:
404
+ logger.warning(
405
+ f"Breakout port {port_name}: subport_index {subport_index} out of range for lanes_list {lanes_list}"
406
+ )
407
+ return "1" # Default fallback
408
+
409
+
410
+ def _add_missing_breakout_ports(
411
+ config,
412
+ breakout_info,
413
+ port_config,
414
+ connected_interfaces,
415
+ portchannel_info,
416
+ netbox_interfaces,
417
+ ):
418
+ """Add all breakout ports to config (master ports are skipped in main loop)."""
419
+ for port_name in breakout_info["breakout_ports"]:
420
+ if port_name not in config["PORT"]:
421
+ # Get the master port to determine configuration
422
+ master_port = breakout_info["breakout_ports"][port_name]["master"]
423
+
424
+ # Override with individual breakout port speed from NetBox if available
425
+ if port_name in netbox_interfaces and netbox_interfaces[port_name]["speed"]:
426
+ port_speed = str(netbox_interfaces[port_name]["speed"])
427
+ logger.debug(
428
+ f"Using NetBox speed {port_speed} for missing breakout port {port_name}"
429
+ )
430
+ elif master_port in breakout_info["breakout_cfgs"]:
431
+ # Fallback to extracting speed from breakout mode
432
+ brkout_mode = breakout_info["breakout_cfgs"][master_port]["brkout_mode"]
433
+ if "25G" in brkout_mode:
434
+ port_speed = "25000"
435
+ elif "50G" in brkout_mode:
436
+ port_speed = "50000"
437
+ elif "100G" in brkout_mode:
438
+ port_speed = "100000"
439
+ elif "200G" in brkout_mode:
440
+ port_speed = "200000"
441
+ else:
442
+ port_speed = "25000" # Default fallback
443
+ else:
444
+ port_speed = "25000" # Default fallback
445
+
446
+ # Set admin_status based on connection or port channel membership
447
+ admin_status = (
448
+ "up"
449
+ if (
450
+ port_name in connected_interfaces
451
+ or port_name in portchannel_info["member_mapping"]
452
+ )
453
+ else "down"
454
+ )
455
+
456
+ # Generate correct alias (breakout port always gets subport notation)
457
+ interface_speed = int(port_speed)
458
+ correct_alias = convert_sonic_interface_to_alias(
459
+ port_name, interface_speed, is_breakout=True, port_config=port_config
460
+ )
461
+
462
+ # Use master port index for breakout ports
463
+ port_index = "1" # Default fallback
464
+ if master_port in port_config:
465
+ port_index = port_config[master_port]["index"]
466
+
467
+ # Calculate individual lane for this breakout port
468
+ port_lanes = _calculate_breakout_port_lane(
469
+ port_name, master_port, port_config
470
+ )
471
+
472
+ port_data = {
473
+ "admin_status": admin_status,
474
+ "alias": correct_alias,
475
+ "index": port_index,
476
+ "lanes": port_lanes,
477
+ "speed": port_speed,
478
+ "mtu": "9100",
479
+ "adv_speeds": "all",
480
+ "autoneg": "off",
481
+ "link_training": "off",
482
+ "unreliable_los": "auto",
483
+ }
484
+
485
+ # For breakout ports, check if master port has valid_speeds
486
+ if (
487
+ master_port in port_config
488
+ and "valid_speeds" in port_config[master_port]
489
+ ):
490
+ port_data["valid_speeds"] = port_config[master_port]["valid_speeds"]
491
+
492
+ # Override valid_speeds for breakout ports based on their individual speed
493
+ breakout_valid_speeds = _get_breakout_port_valid_speeds(port_speed)
494
+ if breakout_valid_speeds:
495
+ port_data["valid_speeds"] = breakout_valid_speeds
496
+
497
+ config["PORT"][port_name] = port_data
498
+
499
+
500
+ def _add_tagged_vlans_to_ports(config, vlan_info, netbox_interfaces, device):
501
+ """Add tagged VLANs to PORT configuration."""
502
+ # Build a mapping of ports to their tagged VLANs
503
+ port_tagged_vlans = {}
504
+ for vid, members in vlan_info["vlan_members"].items():
505
+ for netbox_interface_name, tagging_mode in members.items():
506
+ # Convert NetBox interface name to SONiC format
507
+ # Try to find speed from netbox_interfaces
508
+ speed = None
509
+ for sonic_name, iface_info in netbox_interfaces.items():
510
+ if iface_info["netbox_name"] == netbox_interface_name:
511
+ speed = iface_info["speed"]
512
+ break
513
+ sonic_interface_name = convert_netbox_interface_to_sonic(
514
+ netbox_interface_name, device
515
+ )
516
+
517
+ # Only add if this is a tagged VLAN (not untagged)
518
+ if tagging_mode == "tagged":
519
+ if sonic_interface_name not in port_tagged_vlans:
520
+ port_tagged_vlans[sonic_interface_name] = []
521
+ port_tagged_vlans[sonic_interface_name].append(str(vid))
522
+
523
+ # Update PORT configuration with tagged VLANs
524
+ for port_name in config["PORT"]:
525
+ if port_name in port_tagged_vlans:
526
+ # Sort the VLAN IDs numerically for consistent ordering
527
+ tagged_vlans = sorted(port_tagged_vlans[port_name], key=int)
528
+ config["PORT"][port_name]["tagged_vlans"] = tagged_vlans
529
+
530
+
531
+ def _add_interface_configurations(config, connected_interfaces, portchannel_info):
532
+ """Add INTERFACE configuration for connected interfaces."""
533
+ for port_name in config["PORT"]:
534
+ # Check if this port is in the connected interfaces set and not a port channel member
535
+ if (
536
+ port_name in connected_interfaces
537
+ and port_name not in portchannel_info["member_mapping"]
538
+ ):
539
+ # Add interface to INTERFACE section with ipv6_use_link_local_only enabled
540
+ config["INTERFACE"][port_name] = {"ipv6_use_link_local_only": "enable"}
541
+
542
+
543
+ def _add_bgp_configurations(
544
+ config,
545
+ connected_interfaces,
546
+ connected_portchannels,
547
+ portchannel_info,
548
+ device,
549
+ device_as_mapping=None,
550
+ ):
551
+ """Add BGP configurations."""
552
+ # Add BGP_NEIGHBOR_AF configuration for connected interfaces
553
+ for port_name in config["PORT"]:
554
+ if (
555
+ port_name in connected_interfaces
556
+ and port_name not in portchannel_info["member_mapping"]
557
+ ):
558
+ ipv4_key = f"default|{port_name}|ipv4_unicast"
559
+ ipv6_key = f"default|{port_name}|ipv6_unicast"
560
+ config["BGP_NEIGHBOR_AF"][ipv4_key] = {"admin_status": "true"}
561
+ config["BGP_NEIGHBOR_AF"][ipv6_key] = {"admin_status": "true"}
562
+
563
+ # Add BGP_NEIGHBOR_AF configuration for connected port channels
564
+ for pc_name in connected_portchannels:
565
+ ipv4_key = f"default|{pc_name}|ipv4_unicast"
566
+ ipv6_key = f"default|{pc_name}|ipv6_unicast"
567
+ config["BGP_NEIGHBOR_AF"][ipv4_key] = {"admin_status": "true"}
568
+ config["BGP_NEIGHBOR_AF"][ipv6_key] = {"admin_status": "true"}
569
+
570
+ # Add BGP_NEIGHBOR configuration for connected interfaces
571
+ for port_name in config["PORT"]:
572
+ if (
573
+ port_name in connected_interfaces
574
+ and port_name not in portchannel_info["member_mapping"]
575
+ ):
576
+ neighbor_key = f"default|{port_name}"
577
+
578
+ # Determine peer_type based on connected device AS
579
+ peer_type = "external" # Default
580
+ connected_device = get_connected_device_for_sonic_interface(
581
+ device, port_name
582
+ )
583
+ if connected_device:
584
+ peer_type = _determine_peer_type(
585
+ device, connected_device, device_as_mapping
586
+ )
587
+
588
+ config["BGP_NEIGHBOR"][neighbor_key] = {
589
+ "peer_type": peer_type,
590
+ "v6only": "true",
591
+ }
592
+
593
+ # Add BGP_NEIGHBOR configuration for connected port channels
594
+ for pc_name in connected_portchannels:
595
+ neighbor_key = f"default|{pc_name}"
596
+
597
+ # Determine peer_type based on connected device AS
598
+ peer_type = "external" # Default
599
+ connected_device = get_connected_device_for_sonic_interface(device, pc_name)
600
+ if connected_device:
601
+ peer_type = _determine_peer_type(
602
+ device, connected_device, device_as_mapping
603
+ )
604
+
605
+ config["BGP_NEIGHBOR"][neighbor_key] = {
606
+ "peer_type": peer_type,
607
+ "v6only": "true",
608
+ }
609
+
610
+
611
+ def _get_connected_device_for_interface(device, interface_name):
612
+ """Get the connected device for a given interface name.
613
+
614
+ Args:
615
+ device: NetBox device object
616
+ interface_name: SONiC interface name (e.g., "Ethernet0")
617
+
618
+ Returns:
619
+ NetBox device object or None if not found
620
+ """
621
+ return get_connected_device_for_sonic_interface(device, interface_name)
622
+
623
+
624
+ def _determine_peer_type(local_device, connected_device, device_as_mapping=None):
625
+ """Determine BGP peer type (internal/external) based on AS number comparison.
626
+
627
+ Args:
628
+ local_device: Local NetBox device object
629
+ connected_device: Connected NetBox device object
630
+ device_as_mapping: Dict mapping device IDs to pre-calculated AS numbers
631
+
632
+ Returns:
633
+ str: "internal" if AS numbers match, "external" otherwise
634
+ """
635
+ try:
636
+ # Get local AS number
637
+ local_as = None
638
+ if device_as_mapping and local_device.id in device_as_mapping:
639
+ local_as = device_as_mapping[local_device.id]
640
+ elif local_device.primary_ip4:
641
+ local_as = calculate_local_asn_from_ipv4(
642
+ str(local_device.primary_ip4.address)
643
+ )
644
+
645
+ # Get connected device AS number
646
+ connected_as = None
647
+ if device_as_mapping and connected_device.id in device_as_mapping:
648
+ connected_as = device_as_mapping[connected_device.id]
649
+ else:
650
+ # If connected device is not in device_as_mapping, check if it's a spine/superspine
651
+ # and calculate AS for its group
652
+ if connected_device.role and connected_device.role.slug in [
653
+ "spine",
654
+ "superspine",
655
+ ]:
656
+ # Import here to avoid circular imports
657
+ from .bgp import calculate_minimum_as_for_group
658
+ from .connections import find_interconnected_devices
659
+
660
+ # Get all devices to find the group
661
+ all_devices = list(
662
+ utils.nb.dcim.devices.filter(role=["spine", "superspine"])
663
+ )
664
+ spine_groups = find_interconnected_devices(
665
+ all_devices, ["spine", "superspine"]
666
+ )
667
+
668
+ # Find which group the connected device belongs to
669
+ for group in spine_groups:
670
+ if any(dev.id == connected_device.id for dev in group):
671
+ connected_as = calculate_minimum_as_for_group(group)
672
+ if connected_as:
673
+ logger.debug(
674
+ f"Calculated AS {connected_as} for connected spine/superspine device {connected_device.name}"
675
+ )
676
+ break
677
+
678
+ # Fallback to calculating from IPv4 if still no AS
679
+ if not connected_as and connected_device.primary_ip4:
680
+ connected_as = calculate_local_asn_from_ipv4(
681
+ str(connected_device.primary_ip4.address)
682
+ )
683
+
684
+ # Compare AS numbers
685
+ if local_as and connected_as and local_as == connected_as:
686
+ return "internal"
687
+ else:
688
+ return "external"
689
+
690
+ except Exception as e:
691
+ logger.debug(
692
+ f"Could not determine peer type between {local_device.name} and {connected_device.name}: {e}"
693
+ )
694
+ return "external" # Default to external on error
695
+
696
+
697
+ def _get_ntp_server_for_device(device):
698
+ """Get single NTP server IP for a SONiC device based on OOB connection to metalbox.
699
+
700
+ Returns the IP address of the metalbox device interface that is connected to the
701
+ OOB switch. If VLANs are used, returns the IP of the VLAN interface where the
702
+ SONiC switch management interface (eth0) has access.
703
+
704
+ Args:
705
+ device: SONiC device object
706
+
707
+ Returns:
708
+ str: IP address of the NTP server or None if not found
709
+ """
710
+ try:
711
+ # Get the OOB IP configuration for this SONiC device
712
+ oob_ip_result = get_device_oob_ip(device)
713
+ if not oob_ip_result:
714
+ logger.debug(f"No OOB IP found for device {device.name}")
715
+ return None
716
+
717
+ oob_ip, prefix_len = oob_ip_result
718
+ logger.debug(f"Device {device.name} has OOB IP {oob_ip}/{prefix_len}")
719
+
720
+ # Find the network/subnet that contains this OOB IP
721
+ from ipaddress import IPv4Network, IPv4Address
722
+
723
+ device_network = IPv4Network(f"{oob_ip}/{prefix_len}", strict=False)
724
+
725
+ # Get all metalbox devices
726
+ metalbox_devices = utils.nb.dcim.devices.filter(role="metalbox")
727
+
728
+ for metalbox in metalbox_devices:
729
+ logger.debug(f"Checking metalbox device {metalbox.name} for NTP server")
730
+
731
+ # Get all interfaces on this metalbox
732
+ interfaces = utils.nb.dcim.interfaces.filter(device_id=metalbox.id)
733
+
734
+ for interface in interfaces:
735
+ # Skip management-only interfaces
736
+ if hasattr(interface, "mgmt_only") and interface.mgmt_only:
737
+ continue
738
+
739
+ # Check both physical interfaces and VLAN interfaces (SVIs)
740
+ # VLAN interfaces are typically named "Vlan123" for VLAN ID 123
741
+ is_vlan_interface = (
742
+ hasattr(interface, "type")
743
+ and interface.type
744
+ and interface.type.value == "virtual"
745
+ and interface.name.startswith("Vlan")
746
+ )
747
+
748
+ # Get IP addresses for this interface
749
+ ip_addresses = utils.nb.ipam.ip_addresses.filter(
750
+ assigned_object_id=interface.id,
751
+ )
752
+
753
+ for ip_addr in ip_addresses:
754
+ if ip_addr.address:
755
+ # Extract IP address without prefix
756
+ ip_only = ip_addr.address.split("/")[0]
757
+
758
+ # Check if it's IPv4 and in the same network as the SONiC device
759
+ try:
760
+ metalbox_ip = IPv4Address(ip_only)
761
+ if metalbox_ip in device_network:
762
+ interface_type = (
763
+ "VLAN interface"
764
+ if is_vlan_interface
765
+ else "interface"
766
+ )
767
+ logger.info(
768
+ f"Found NTP server {ip_only} on metalbox {metalbox.name} "
769
+ f"{interface_type} {interface.name} for SONiC device {device.name}"
770
+ )
771
+ return ip_only
772
+ except ValueError:
773
+ # Skip non-IPv4 addresses
774
+ continue
775
+
776
+ logger.warning(f"No suitable NTP server found for SONiC device {device.name}")
777
+ return None
778
+
779
+ except Exception as e:
780
+ logger.warning(f"Could not determine NTP server for device {device.name}: {e}")
781
+ return None
782
+
783
+
784
+ def _get_ntp_servers():
785
+ """Get NTP servers from manager/metalbox devices. Uses caching to avoid repeated queries."""
786
+ global _ntp_servers_cache
787
+
788
+ if _ntp_servers_cache is not None:
789
+ logger.debug("Using cached NTP servers")
790
+ return _ntp_servers_cache
791
+
792
+ ntp_servers = {}
793
+ try:
794
+ # Get devices with manager or metalbox device roles
795
+ devices_manager = utils.nb.dcim.devices.filter(role="manager")
796
+ devices_metalbox = utils.nb.dcim.devices.filter(role="metalbox")
797
+
798
+ # Combine both device lists
799
+ ntp_devices = list(devices_manager) + list(devices_metalbox)
800
+ logger.debug(f"Found {len(ntp_devices)} potential NTP devices")
801
+
802
+ for ntp_device in ntp_devices:
803
+ # Get interfaces for this device to find Loopback0
804
+ device_interfaces = utils.nb.dcim.interfaces.filter(device_id=ntp_device.id)
805
+
806
+ for interface in device_interfaces:
807
+ # Look for Loopback0 interface
808
+ if interface.name == "Loopback0":
809
+ # Get IP addresses assigned to this Loopback0 interface
810
+ ip_addresses = utils.nb.ipam.ip_addresses.filter(
811
+ assigned_object_id=interface.id,
812
+ )
813
+
814
+ for ip_addr in ip_addresses:
815
+ if ip_addr.address:
816
+ # Extract just the IPv4 address without prefix
817
+ ip_only = ip_addr.address.split("/")[0]
818
+
819
+ # Check if it's an IPv4 address (simple check)
820
+ if "." in ip_only and ":" not in ip_only:
821
+ ntp_servers[ip_only] = {
822
+ "maxpoll": "10",
823
+ "minpoll": "6",
824
+ "prefer": "false",
825
+ }
826
+ logger.info(
827
+ f"Found NTP server {ip_only} from device {ntp_device.name} with role {ntp_device.role.slug}"
828
+ )
829
+ break
830
+
831
+ # Cache the results
832
+ _ntp_servers_cache = ntp_servers
833
+ logger.debug(f"Cached {len(ntp_servers)} NTP servers")
834
+
835
+ except Exception as e:
836
+ logger.warning(f"Could not process NTP servers: {e}")
837
+ _ntp_servers_cache = {}
838
+
839
+ return _ntp_servers_cache
840
+
841
+
842
+ def _add_ntp_configuration(config, device):
843
+ """Add NTP_SERVER configuration to device config.
844
+
845
+ Each SONiC switch gets exactly one NTP server - the IP address of the
846
+ metalbox device interface connected to the OOB switch.
847
+ """
848
+ try:
849
+ # Get the specific NTP server for this device
850
+ ntp_server_ip = _get_ntp_server_for_device(device)
851
+
852
+ if ntp_server_ip:
853
+ # Add single NTP server configuration
854
+ config["NTP_SERVER"][ntp_server_ip] = {
855
+ "maxpoll": "10",
856
+ "minpoll": "6",
857
+ "prefer": "false",
858
+ }
859
+ logger.info(
860
+ f"Added NTP server {ntp_server_ip} to SONiC device {device.name}"
861
+ )
862
+ else:
863
+ logger.warning(f"No NTP server found for SONiC device {device.name}")
864
+
865
+ except Exception as e:
866
+ logger.warning(f"Could not add NTP configuration to device {device.name}: {e}")
867
+
868
+
869
+ def clear_ntp_cache():
870
+ """Clear the NTP servers cache. Should be called at the start of sync_sonic."""
871
+ global _ntp_servers_cache
872
+ _ntp_servers_cache = None
873
+ logger.debug("Cleared NTP servers cache")
874
+
875
+
876
+ def clear_all_caches():
877
+ """Clear all caches in config_generator module."""
878
+ clear_ntp_cache()
879
+ clear_port_config_cache()
880
+ logger.debug("Cleared all config_generator caches")
881
+
882
+
883
+ def _add_vlan_configuration(config, vlan_info, netbox_interfaces, device):
884
+ """Add VLAN configuration from NetBox."""
885
+ # Add VLAN configuration
886
+ for vid, vlan_data in vlan_info["vlans"].items():
887
+ vlan_name = f"Vlan{vid}"
888
+
889
+ # Get member ports for this VLAN and convert interface names
890
+ members = []
891
+ if vid in vlan_info["vlan_members"]:
892
+ for netbox_interface_name in vlan_info["vlan_members"][vid].keys():
893
+ # Convert NetBox interface name to SONiC format
894
+ # Try to find speed from netbox_interfaces
895
+ speed = None
896
+ for sonic_name, iface_info in netbox_interfaces.items():
897
+ if iface_info["netbox_name"] == netbox_interface_name:
898
+ speed = iface_info["speed"]
899
+ break
900
+ sonic_interface_name = convert_netbox_interface_to_sonic(
901
+ netbox_interface_name, device
902
+ )
903
+ members.append(sonic_interface_name)
904
+
905
+ config["VLAN"][vlan_name] = {
906
+ "admin_status": "up",
907
+ "autostate": "enable",
908
+ "members": members,
909
+ "vlanid": str(vid),
910
+ }
911
+
912
+ # Add VLAN members
913
+ for vid, members in vlan_info["vlan_members"].items():
914
+ vlan_name = f"Vlan{vid}"
915
+ for netbox_interface_name, tagging_mode in members.items():
916
+ # Convert NetBox interface name to SONiC format
917
+ # Try to find speed from netbox_interfaces
918
+ speed = None
919
+ for sonic_name, iface_info in netbox_interfaces.items():
920
+ if iface_info["netbox_name"] == netbox_interface_name:
921
+ speed = iface_info["speed"]
922
+ break
923
+ sonic_interface_name = convert_netbox_interface_to_sonic(
924
+ netbox_interface_name, device
925
+ )
926
+ # Create VLAN_MEMBER key in format "Vlan<vid>|<port_name>"
927
+ member_key = f"{vlan_name}|{sonic_interface_name}"
928
+ config["VLAN_MEMBER"][member_key] = {"tagging_mode": tagging_mode}
929
+
930
+ # Add VLAN interfaces (SVIs)
931
+ for vid, interface_data in vlan_info["vlan_interfaces"].items():
932
+ vlan_name = f"Vlan{vid}"
933
+ if "addresses" in interface_data and interface_data["addresses"]:
934
+ # Add the VLAN interface
935
+ config["VLAN_INTERFACE"][vlan_name] = {"admin_status": "up"}
936
+
937
+ # Add IP configuration for each address (IPv4 and IPv6)
938
+ for address in interface_data["addresses"]:
939
+ ip_key = f"{vlan_name}|{address}"
940
+ config["VLAN_INTERFACE"][ip_key] = {}
941
+
942
+
943
+ def _add_loopback_configuration(config, loopback_info):
944
+ """Add Loopback configuration from NetBox."""
945
+ for loopback_name, loopback_data in loopback_info["loopbacks"].items():
946
+ # Add the Loopback interface
947
+ config["LOOPBACK"][loopback_name] = {"admin_status": "up"}
948
+
949
+ # Add base Loopback interface entry
950
+ config["LOOPBACK_INTERFACE"][loopback_name] = {}
951
+
952
+ # Add IP configuration for each address (IPv4 and IPv6)
953
+ for address in loopback_data["addresses"]:
954
+ ip_key = f"{loopback_name}|{address}"
955
+ config["LOOPBACK_INTERFACE"][ip_key] = {}
956
+
957
+ # Add BGP_GLOBALS_AF_NETWORK configuration for Loopback0 devices
958
+ if loopback_name == "Loopback0":
959
+ for address in loopback_data["addresses"]:
960
+ # Determine if this is IPv4 or IPv6 and set appropriate address family
961
+ try:
962
+ ip_obj = ipaddress.ip_interface(address)
963
+ if ip_obj.version == 4:
964
+ af_key = f"default|ipv4_unicast|{address}"
965
+ elif ip_obj.version == 6:
966
+ af_key = f"default|ipv6_unicast|{address}"
967
+ else:
968
+ continue
969
+
970
+ config["BGP_GLOBALS_AF_NETWORK"][af_key] = {}
971
+ except ValueError:
972
+ logger.warning(f"Invalid IP address format: {address}")
973
+ continue
974
+
975
+
976
+ def _add_portchannel_configuration(config, portchannel_info):
977
+ """Add port channel configuration from NetBox."""
978
+ if portchannel_info["portchannels"]:
979
+ for pc_name, pc_data in portchannel_info["portchannels"].items():
980
+ # Add PORTCHANNEL configuration
981
+ config["PORTCHANNEL"][pc_name] = {
982
+ "admin_status": pc_data["admin_status"],
983
+ "fast_rate": pc_data["fast_rate"],
984
+ "min_links": pc_data["min_links"],
985
+ "mtu": pc_data["mtu"],
986
+ }
987
+
988
+ # Add PORTCHANNEL_INTERFACE configuration to enable IPv6 link-local
989
+ config["PORTCHANNEL_INTERFACE"][pc_name] = {
990
+ "ipv6_use_link_local_only": "enable"
991
+ }
992
+
993
+ # Add PORTCHANNEL_MEMBER configuration for each member
994
+ for member in pc_data["members"]:
995
+ member_key = f"{pc_name}|{member}"
996
+ config["PORTCHANNEL_MEMBER"][member_key] = {}
997
+
998
+ logger.debug(
999
+ f"Added port channel {pc_name} with {len(pc_data['members'])} members"
1000
+ )