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,940 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Interface conversion and port detection functions for SONiC configuration."""
4
+
5
+ import copy
6
+ import os
7
+ import re
8
+ from loguru import logger
9
+
10
+ from .constants import PORT_TYPE_TO_SPEED_MAP, HIGH_SPEED_PORTS, PORT_CONFIG_PATH
11
+ from .cache import get_cached_device_interfaces
12
+
13
+ # Global cache for port configurations to avoid repeated file reads
14
+ _port_config_cache: dict[str, dict[str, dict[str, str]]] = {}
15
+
16
+
17
+ def get_speed_from_port_type(port_type):
18
+ """Get speed from port type when speed is not provided.
19
+
20
+ Args:
21
+ port_type: NetBox interface type value (e.g., "10gbase-x-sfpp", "100gbase-x-qsfp28")
22
+
23
+ Returns:
24
+ int: Speed in Mbps, or None if port type is not recognized
25
+ """
26
+ if not port_type:
27
+ return None
28
+
29
+ # Convert to lowercase for case-insensitive matching
30
+ port_type_lower = str(port_type).lower()
31
+
32
+ # Try to get speed from mapping
33
+ speed = PORT_TYPE_TO_SPEED_MAP.get(port_type_lower)
34
+
35
+ if speed:
36
+ logger.debug(f"Resolved port type '{port_type}' to speed {speed} Mbps")
37
+ else:
38
+ logger.warning(f"Unknown port type '{port_type}', unable to determine speed")
39
+
40
+ return speed
41
+
42
+
43
+ def convert_netbox_interface_to_sonic(device_interface, device=None):
44
+ """Convert NetBox interface name to SONiC interface name with device-specific mapping.
45
+
46
+ Args:
47
+ device_interface: NetBox interface object or interface name string
48
+ device: NetBox device object (required if device_interface is string)
49
+
50
+ Returns:
51
+ str: SONiC interface name (e.g., "Ethernet0", "Ethernet4")
52
+ """
53
+ # Extract interface name and determine device context
54
+ if isinstance(device_interface, str):
55
+ # Legacy mode: interface name as string
56
+ interface_name = device_interface
57
+ if device is None:
58
+ logger.warning(
59
+ "Device object required when interface is provided as string"
60
+ )
61
+ return interface_name
62
+ else:
63
+ # New mode: interface object
64
+ interface_name = device_interface.name
65
+ if device is None:
66
+ logger.warning(
67
+ "Device object required for device-specific interface mapping"
68
+ )
69
+ return interface_name
70
+
71
+ # Check if this is already in SONiC format (Ethernet*)
72
+ if interface_name.startswith("Ethernet"):
73
+ return interface_name
74
+
75
+ # Get HWSKU from device sonic_parameters
76
+ device_hwsku = None
77
+ if (
78
+ hasattr(device, "custom_fields")
79
+ and "sonic_parameters" in device.custom_fields
80
+ and device.custom_fields["sonic_parameters"]
81
+ and "hwsku" in device.custom_fields["sonic_parameters"]
82
+ ):
83
+ device_hwsku = device.custom_fields["sonic_parameters"]["hwsku"]
84
+
85
+ if not device_hwsku:
86
+ logger.warning(f"No HWSKU found for device {device.name}")
87
+ return interface_name
88
+
89
+ # Get all device interfaces for breakout detection (using cache)
90
+ try:
91
+ all_interfaces = get_cached_device_interfaces(device.id)
92
+ interface_names = [iface.name for iface in all_interfaces]
93
+ except Exception as e:
94
+ logger.warning(f"Could not fetch device interfaces: {e}")
95
+ return interface_name
96
+
97
+ # Get port configuration for HWSKU
98
+ try:
99
+ port_config = get_port_config(device_hwsku)
100
+ if not port_config:
101
+ logger.warning(f"No port config found for HWSKU {device_hwsku}")
102
+ return interface_name
103
+ except Exception as e:
104
+ logger.warning(f"Could not load port config for {device_hwsku}: {e}")
105
+ return interface_name
106
+
107
+ # Handle different interface naming patterns
108
+ return _map_interface_name_to_sonic(
109
+ interface_name, interface_names, port_config, device_hwsku
110
+ )
111
+
112
+
113
+ def _map_interface_name_to_sonic(
114
+ interface_name, all_interface_names, port_config, device_hwsku
115
+ ):
116
+ """Map interface name to SONiC format based on port config and breakout detection.
117
+
118
+ Args:
119
+ interface_name: The interface name to map
120
+ all_interface_names: List of all interface names on the device
121
+ port_config: Port configuration dictionary from HWSKU
122
+ device_hwsku: Hardware SKU name for logging
123
+
124
+ Returns:
125
+ str: SONiC interface name
126
+ """
127
+ # Check for EthX/Y/Z format (potential breakout)
128
+ breakout_match = re.match(r"Eth(\d+)/(\d+)/(\d+)", interface_name)
129
+ if breakout_match:
130
+ return _handle_breakout_interface(
131
+ interface_name, all_interface_names, port_config, device_hwsku
132
+ )
133
+
134
+ # Check for EthX/Y format (standard format)
135
+ standard_match = re.match(r"Eth(\d+)/(\d+)", interface_name)
136
+ if standard_match:
137
+ return _handle_standard_interface(interface_name, port_config, device_hwsku)
138
+
139
+ # For any other format, try to find by alias in port config
140
+ for sonic_port, config in port_config.items():
141
+ if config.get("alias") == interface_name:
142
+ logger.debug(f"Found {interface_name} -> {sonic_port} via alias mapping")
143
+ return sonic_port
144
+
145
+ logger.warning(
146
+ f"Could not map interface {interface_name} using HWSKU {device_hwsku}"
147
+ )
148
+ return interface_name
149
+
150
+
151
+ def _handle_breakout_interface(
152
+ interface_name, all_interface_names, port_config, device_hwsku
153
+ ):
154
+ """Handle EthX/Y/Z format interfaces with breakout detection.
155
+
156
+ Args:
157
+ interface_name: Interface name in EthX/Y/Z format
158
+ all_interface_names: List of all interface names on the device
159
+ port_config: Port configuration dictionary
160
+ device_hwsku: Hardware SKU name for logging
161
+
162
+ Returns:
163
+ str: SONiC interface name
164
+ """
165
+ match = re.match(r"Eth(\d+)/(\d+)/(\d+)", interface_name)
166
+ if not match:
167
+ return interface_name
168
+
169
+ module = int(match.group(1))
170
+ port = int(match.group(2))
171
+ subport = int(match.group(3))
172
+
173
+ # Find all interfaces with same module and port (potential breakout group)
174
+ breakout_group = []
175
+ for iface_name in all_interface_names:
176
+ breakout_match = re.match(r"Eth(\d+)/(\d+)/(\d+)", iface_name)
177
+ if breakout_match:
178
+ iface_module = int(breakout_match.group(1))
179
+ iface_port = int(breakout_match.group(2))
180
+ iface_subport = int(breakout_match.group(3))
181
+
182
+ if iface_module == module and iface_port == port:
183
+ breakout_group.append((iface_subport, iface_name))
184
+
185
+ # Check if this is a breakout port (more than one interface with same module/port)
186
+ if len(breakout_group) > 1:
187
+ # Sort by subport number
188
+ breakout_group.sort(key=lambda x: x[0])
189
+
190
+ # Find the alias for the interface with the smallest subport
191
+ min_subport_interface = breakout_group[0][1]
192
+
193
+ # Map the min subport interface to find base SONiC name
194
+ base_sonic_name = _find_sonic_name_by_alias_mapping(
195
+ min_subport_interface, port_config
196
+ )
197
+ if base_sonic_name:
198
+ # Extract base port number (e.g., "Ethernet0" -> 0)
199
+ base_match = re.match(r"Ethernet(\d+)", base_sonic_name)
200
+ if base_match:
201
+ base_port_num = int(base_match.group(1))
202
+
203
+ # Calculate offset from minimum subport
204
+ min_subport = breakout_group[0][0]
205
+ current_offset = subport - min_subport
206
+
207
+ sonic_port_num = base_port_num + current_offset
208
+ result = f"Ethernet{sonic_port_num}"
209
+
210
+ logger.debug(
211
+ f"Breakout mapping: {interface_name} -> {result} (base: {base_sonic_name}, offset: {current_offset})"
212
+ )
213
+ return result
214
+
215
+ # Not a breakout or couldn't find base mapping, try direct alias mapping
216
+ return _find_sonic_name_by_alias_mapping(interface_name, port_config)
217
+
218
+
219
+ def _handle_standard_interface(interface_name, port_config, device_hwsku):
220
+ """Handle EthX/Y format interfaces.
221
+
222
+ Args:
223
+ interface_name: Interface name in EthX/Y format
224
+ port_config: Port configuration dictionary
225
+ device_hwsku: Hardware SKU name for logging
226
+
227
+ Returns:
228
+ str: SONiC interface name
229
+ """
230
+ return _find_sonic_name_by_alias_mapping(interface_name, port_config)
231
+
232
+
233
+ def _find_sonic_name_by_alias_mapping(interface_name, port_config):
234
+ """Find SONiC interface name by mapping through alias in port config.
235
+
236
+ The mapping works as follows:
237
+ - tenGigE1 alias maps to Eth1/1/1 or Eth1/1
238
+ - tenGigE48 alias maps to Eth1/48/1 or Eth1/48
239
+ - hundredGigE49 alias maps to Eth1/49/1 or Eth1/49
240
+ - Eth1(Port1) -> Ethernet0, Eth2(Port2) -> Ethernet1, Eth3(Port) -> Ethernet2
241
+
242
+ Args:
243
+ interface_name: NetBox interface name (e.g., "Eth1/1", "Eth1/1/1", or "Eth1(Port1)")
244
+ port_config: Port configuration dictionary
245
+
246
+ Returns:
247
+ str: SONiC interface name or original name if not found
248
+ """
249
+ logger.debug(f"Finding SONiC name for interface: '{interface_name}'")
250
+ logger.debug(f"Port config contains {len(port_config)} entries")
251
+
252
+ # Handle new Eth1(Port1) format first
253
+ paren_match = re.match(r"Eth(\d+)\(Port(\d*)\)", interface_name)
254
+ if paren_match:
255
+ eth_num = int(paren_match.group(1))
256
+ # Map EthX(PortY) to EthernetX-1 (1-based to 0-based conversion)
257
+ ethernet_num = eth_num - 1
258
+ sonic_name = f"Ethernet{ethernet_num}"
259
+ logger.debug(
260
+ f"Alias mapping: {interface_name} -> {sonic_name} via Eth(Port) format (eth_num={eth_num}, ethernet_num={ethernet_num})"
261
+ )
262
+ return sonic_name
263
+
264
+ # Create reverse mapping: expected NetBox name -> alias -> SONiC name
265
+ for sonic_port, config in port_config.items():
266
+ alias = config.get("alias", "")
267
+ if not alias:
268
+ logger.debug(f"Skipping {sonic_port}: no alias")
269
+ continue
270
+
271
+ # Extract number from alias (e.g., tenGigE1 -> 1, hundredGigE49 -> 49)
272
+ alias_match = re.search(r"(\d+)$", alias)
273
+ if not alias_match:
274
+ logger.debug(
275
+ f"Skipping {sonic_port}: alias '{alias}' has no trailing number"
276
+ )
277
+ continue
278
+
279
+ alias_num = int(alias_match.group(1))
280
+
281
+ # Generate expected NetBox interface names for this alias
282
+ expected_names = [
283
+ f"Eth1/{alias_num}", # Standard format
284
+ f"Eth1/{alias_num}/1", # Breakout format (first subport)
285
+ ]
286
+
287
+ logger.debug(
288
+ f"Checking {sonic_port} (alias='{alias}', alias_num={alias_num}) against expected_names: {expected_names}"
289
+ )
290
+
291
+ if interface_name in expected_names:
292
+ logger.debug(
293
+ f"Alias mapping: {interface_name} -> {sonic_port} via alias {alias}"
294
+ )
295
+ return sonic_port
296
+
297
+ logger.warning(f"No alias mapping found for '{interface_name}'")
298
+ logger.debug(
299
+ f"Available aliases in port_config: {[(sonic_port, config.get('alias', '')) for sonic_port, config in port_config.items()]}"
300
+ )
301
+ return interface_name
302
+
303
+
304
+ def convert_sonic_interface_to_alias(
305
+ sonic_interface_name, interface_speed=None, is_breakout=False, port_config=None
306
+ ):
307
+ """Convert SONiC interface name to NetBox-style alias.
308
+
309
+ Args:
310
+ sonic_interface_name: SONiC interface name (e.g., "Ethernet0", "Ethernet4")
311
+ interface_speed: Interface speed in Mbps (optional, for speed-based calculation)
312
+ is_breakout: Whether this is a breakout port (adds subport notation)
313
+ port_config: Port configuration dictionary (optional, for alias-based calculation)
314
+
315
+ Returns:
316
+ str: NetBox-style alias (e.g., "Eth1/1", "Eth1/2" or "Eth1/1/1", "Eth1/1/2" for breakout)
317
+
318
+ Examples:
319
+ - Regular ports: Ethernet0 with alias "twentyFiveGigE1" -> Eth1/1
320
+ - Breakout ports: Ethernet2 with base port alias "twentyFiveGigE1" -> Eth1/1/3
321
+ """
322
+ logger.debug(
323
+ f"Converting SONiC interface to alias: {sonic_interface_name}, speed={interface_speed}, is_breakout={is_breakout}"
324
+ )
325
+
326
+ # Extract port number from SONiC format (Ethernet0, Ethernet4, etc.)
327
+ match = re.match(r"Ethernet(\d+)", sonic_interface_name)
328
+ if not match:
329
+ # If it doesn't match expected pattern, return as-is
330
+ logger.debug(
331
+ f"Interface {sonic_interface_name} doesn't match Ethernet pattern, returning as-is"
332
+ )
333
+ return sonic_interface_name
334
+
335
+ ethernet_num = int(match.group(1))
336
+ logger.debug(f"Extracted ethernet_num: {ethernet_num}")
337
+
338
+ # If port_config is provided, use alias-based calculation
339
+ if port_config:
340
+ return _convert_using_port_config(
341
+ sonic_interface_name, ethernet_num, is_breakout, port_config
342
+ )
343
+
344
+ # Fallback to legacy speed-based calculation
345
+ return _convert_using_speed_calculation(ethernet_num, interface_speed, is_breakout)
346
+
347
+
348
+ def _convert_using_port_config(
349
+ sonic_interface_name, ethernet_num, is_breakout, port_config
350
+ ):
351
+ """Convert using port config alias information."""
352
+ if is_breakout:
353
+ # For breakout ports, find the base port in port_config
354
+ base_port_name = _find_base_port_for_breakout(ethernet_num, port_config)
355
+ if base_port_name and base_port_name in port_config:
356
+ base_alias = port_config[base_port_name].get("alias", "")
357
+ # Extract port number from base alias
358
+ sonic_port_number = _extract_port_number_from_alias(base_alias)
359
+ if sonic_port_number is not None:
360
+ # Calculate subport number: how many ports after the base port
361
+ base_ethernet_num = int(base_port_name.replace("Ethernet", ""))
362
+ subport = (ethernet_num - base_ethernet_num) + 1
363
+
364
+ module = 1
365
+ result = f"Eth{module}/{sonic_port_number}/{subport}"
366
+ logger.debug(
367
+ f"Breakout conversion using port config: {sonic_interface_name} -> {result} "
368
+ f"(base_port={base_port_name}, base_alias={base_alias}, sonic_port_number={sonic_port_number}, subport={subport})"
369
+ )
370
+ return result
371
+
372
+ # Fallback if base port not found
373
+ logger.warning(
374
+ f"Could not find base port for breakout interface {sonic_interface_name}"
375
+ )
376
+ return _convert_using_speed_calculation(ethernet_num, None, is_breakout)
377
+ else:
378
+ # For regular ports, get alias directly
379
+ if sonic_interface_name in port_config:
380
+ alias = port_config[sonic_interface_name].get("alias", "")
381
+ sonic_port_number = _extract_port_number_from_alias(alias)
382
+ if sonic_port_number is not None:
383
+ module = 1
384
+ result = f"Eth{module}/{sonic_port_number}"
385
+ logger.debug(
386
+ f"Regular conversion using port config: {sonic_interface_name} -> {result} "
387
+ f"(alias={alias}, sonic_port_number={sonic_port_number})"
388
+ )
389
+ return result
390
+
391
+ # Fallback if not in port config
392
+ logger.warning(f"Interface {sonic_interface_name} not found in port config")
393
+ return _convert_using_speed_calculation(ethernet_num, None, is_breakout)
394
+
395
+
396
+ def _find_base_port_for_breakout(ethernet_num, port_config):
397
+ """Find the base port for a breakout interface.
398
+
399
+ The base port is the next smaller or equal port that exists in port_config.
400
+ E.g., for Ethernet2 -> check Ethernet2, Ethernet1, Ethernet0 until found.
401
+ """
402
+ for base_num in range(ethernet_num, -1, -1):
403
+ base_port_name = f"Ethernet{base_num}"
404
+ if base_port_name in port_config:
405
+ logger.debug(
406
+ f"Found base port {base_port_name} for breakout interface Ethernet{ethernet_num}"
407
+ )
408
+ return base_port_name
409
+
410
+ logger.warning(f"No base port found for breakout interface Ethernet{ethernet_num}")
411
+ return None
412
+
413
+
414
+ def _extract_port_number_from_alias(alias):
415
+ """Extract the port number from the end of an alias.
416
+
417
+ E.g., "twentyFiveGigE1" -> 1, "hundredGigE49" -> 49
418
+ """
419
+ if not alias:
420
+ return None
421
+
422
+ match = re.search(r"(\d+)$", alias)
423
+ if match:
424
+ port_number = int(match.group(1))
425
+ logger.debug(f"Extracted port number {port_number} from alias '{alias}'")
426
+ return port_number
427
+
428
+ logger.warning(f"Could not extract port number from alias '{alias}'")
429
+ return None
430
+
431
+
432
+ def _convert_using_speed_calculation(ethernet_num, interface_speed, is_breakout):
433
+ """Legacy speed-based conversion (fallback)."""
434
+ logger.debug(f"Using legacy speed-based calculation for Ethernet{ethernet_num}")
435
+
436
+ if is_breakout:
437
+ # For breakout ports: Ethernet0 -> Eth1/1/1, Ethernet1 -> Eth1/1/2, etc.
438
+ # Calculate base port (master port) and subport number
439
+ base_port = (ethernet_num // 4) * 4 # Get base port (0, 4, 8, 12, ...)
440
+ subport = (ethernet_num % 4) + 1 # Get subport number (1, 2, 3, 4)
441
+
442
+ # Calculate physical port number for the base port
443
+ physical_port = (base_port // 4) + 1 # Convert to 1-based indexing
444
+
445
+ # Assume module 1 for now - could be extended for multi-module systems
446
+ module = 1
447
+
448
+ result = f"Eth{module}/{physical_port}/{subport}"
449
+ logger.debug(
450
+ f"Breakout conversion: base_port={base_port}, subport={subport}, physical_port={physical_port}, result={result}"
451
+ )
452
+ return result
453
+ else:
454
+ # For regular ports: use speed-based calculation
455
+ # Determine speed category and multiplier
456
+ if interface_speed and interface_speed in HIGH_SPEED_PORTS:
457
+ # High-speed ports use 4x multiplier (lanes)
458
+ multiplier = 4
459
+ else:
460
+ # Default for 1G, 10G, 25G ports - sequential numbering
461
+ multiplier = 1
462
+
463
+ logger.debug(
464
+ f"Regular port calculation: interface_speed={interface_speed}, in_high_speed={interface_speed in HIGH_SPEED_PORTS if interface_speed else False}, multiplier={multiplier}"
465
+ )
466
+
467
+ # Calculate physical port number
468
+ physical_port = (ethernet_num // multiplier) + 1 # Convert to 1-based indexing
469
+
470
+ # Assume module 1 for now - could be extended for multi-module systems
471
+ module = 1
472
+
473
+ result = f"Eth{module}/{physical_port}"
474
+ logger.debug(
475
+ f"Regular conversion: ethernet_num={ethernet_num}, physical_port={physical_port}, result={result}"
476
+ )
477
+ return result
478
+
479
+
480
+ def get_port_config(hwsku):
481
+ """Get port configuration for a given HWSKU. Uses caching to avoid repeated file reads.
482
+
483
+ Args:
484
+ hwsku: Hardware SKU name (e.g., 'Accton-AS5835-54T')
485
+
486
+ Returns:
487
+ dict: Port configuration with port names as keys and their properties as values
488
+ Example: {'Ethernet0': {'lanes': '2', 'alias': 'tenGigE1', 'index': '1', 'speed': '10000', 'valid_speeds': '10000,25000'}}
489
+ """
490
+ global _port_config_cache # noqa F824
491
+
492
+ # Check if already cached
493
+ if hwsku in _port_config_cache:
494
+ logger.debug(f"Using cached port config for HWSKU {hwsku}")
495
+ # Return a deep copy to ensure isolation between devices
496
+ return copy.deepcopy(_port_config_cache[hwsku])
497
+
498
+ port_config = {}
499
+ config_path = f"{PORT_CONFIG_PATH}/{hwsku}.ini"
500
+
501
+ if not os.path.exists(config_path):
502
+ logger.error(f"Port config file not found: {config_path}")
503
+ # Cache empty config to avoid repeated file system checks
504
+ _port_config_cache[hwsku] = port_config
505
+ return port_config
506
+
507
+ try:
508
+ with open(config_path, "r") as f:
509
+ for line in f:
510
+ line = line.strip()
511
+ # Skip comments and empty lines
512
+ if not line or line.startswith("#"):
513
+ continue
514
+
515
+ parts = line.split()
516
+ if len(parts) >= 5:
517
+ port_name = parts[0]
518
+ port_config[port_name] = {
519
+ "lanes": parts[1],
520
+ "alias": parts[2],
521
+ "index": parts[3],
522
+ "speed": parts[4],
523
+ }
524
+ # Check for optional valid_speeds column (6th column)
525
+ if len(parts) >= 6:
526
+ port_config[port_name]["valid_speeds"] = parts[5]
527
+
528
+ # Cache the loaded configuration
529
+ _port_config_cache[hwsku] = port_config
530
+ logger.debug(
531
+ f"Cached port config for HWSKU {hwsku} with {len(port_config)} ports"
532
+ )
533
+
534
+ except Exception as e:
535
+ logger.error(f"Error parsing port config file {config_path}: {e}")
536
+ # Cache empty config on error to avoid repeated attempts
537
+ _port_config_cache[hwsku] = port_config
538
+
539
+ # Return a deep copy to ensure isolation between devices
540
+ return copy.deepcopy(port_config)
541
+
542
+
543
+ def clear_port_config_cache():
544
+ """Clear the port configuration cache. Should be called at the start of sync_sonic."""
545
+ global _port_config_cache
546
+ _port_config_cache = {}
547
+ logger.debug("Cleared port configuration cache")
548
+
549
+
550
+ # Deprecated: Use connections.get_connected_interfaces instead
551
+ # This function is kept for backward compatibility but delegates to the new module
552
+ def get_connected_interfaces(device, portchannel_info=None):
553
+ """Get list of interface names that are connected to other devices.
554
+
555
+ Args:
556
+ device: NetBox device object
557
+ portchannel_info: Optional port channel info dict from detect_port_channels
558
+
559
+ Returns:
560
+ tuple: (set of connected interfaces, set of connected port channels)
561
+ """
562
+ # Import here to avoid circular imports
563
+ from .connections import get_connected_interfaces as _get_connected_interfaces
564
+
565
+ return _get_connected_interfaces(device, portchannel_info)
566
+
567
+
568
+ def detect_breakout_ports(device):
569
+ """Detect breakout ports from NetBox device interfaces using the centralized breakout logic.
570
+
571
+ Args:
572
+ device: NetBox device object
573
+
574
+ Returns:
575
+ dict: Dictionary with breakout port information
576
+ {
577
+ 'breakout_cfgs': {port_name: {'brkout_mode': mode, 'port': port}},
578
+ 'breakout_ports': {port_name: {'master': master_port}}
579
+ }
580
+ """
581
+ breakout_cfgs = {}
582
+ breakout_ports = {}
583
+
584
+ try:
585
+ # Get all interfaces for the device (using cache)
586
+ interfaces = get_cached_device_interfaces(device.id)
587
+ interface_names = [iface.name for iface in interfaces]
588
+
589
+ # Get HWSKU for port config
590
+ device_hwsku = None
591
+ if (
592
+ hasattr(device, "custom_fields")
593
+ and "sonic_parameters" in device.custom_fields
594
+ and device.custom_fields["sonic_parameters"]
595
+ and "hwsku" in device.custom_fields["sonic_parameters"]
596
+ ):
597
+ device_hwsku = device.custom_fields["sonic_parameters"]["hwsku"]
598
+
599
+ if not device_hwsku:
600
+ logger.warning(f"No HWSKU found for device {device.name}")
601
+ return {"breakout_cfgs": breakout_cfgs, "breakout_ports": breakout_ports}
602
+
603
+ # Get port configuration for the HWSKU
604
+ try:
605
+ port_config = get_port_config(device_hwsku)
606
+ if not port_config:
607
+ logger.warning(f"No port config found for HWSKU {device_hwsku}")
608
+ return {
609
+ "breakout_cfgs": breakout_cfgs,
610
+ "breakout_ports": breakout_ports,
611
+ }
612
+ except Exception as e:
613
+ logger.warning(f"Could not load port config for {device_hwsku}: {e}")
614
+ return {"breakout_cfgs": breakout_cfgs, "breakout_ports": breakout_ports}
615
+
616
+ # Process interfaces that match breakout patterns
617
+ processed_groups = set()
618
+
619
+ for interface in interfaces:
620
+ interface_name = interface.name
621
+
622
+ # Check for EthX/Y/Z format (NetBox breakout notation)
623
+ breakout_match = re.match(r"Eth(\d+)/(\d+)/(\d+)", interface_name)
624
+ if breakout_match:
625
+ module = int(breakout_match.group(1))
626
+ port = int(breakout_match.group(2))
627
+ subport = int(breakout_match.group(3))
628
+
629
+ # Create group key to avoid processing the same group multiple times
630
+ group_key = f"{module}/{port}"
631
+ if group_key in processed_groups:
632
+ continue
633
+ processed_groups.add(group_key)
634
+
635
+ # Use the centralized breakout logic
636
+ sonic_name = _handle_breakout_interface(
637
+ interface_name, interface_names, port_config, device_hwsku
638
+ )
639
+
640
+ # If the breakout logic returned a valid SONiC name, we have a breakout group
641
+ if sonic_name.startswith("Ethernet") and sonic_name != interface_name:
642
+ # Find all interfaces in this breakout group
643
+ breakout_group = []
644
+ for iface in interfaces:
645
+ iface_match = re.match(r"Eth(\d+)/(\d+)/(\d+)", iface.name)
646
+ if iface_match:
647
+ iface_module = int(iface_match.group(1))
648
+ iface_port = int(iface_match.group(2))
649
+ iface_subport = int(iface_match.group(3))
650
+
651
+ if iface_module == module and iface_port == port:
652
+ breakout_group.append((iface_subport, iface))
653
+
654
+ # Check if we have a valid breakout group (more than one interface)
655
+ if len(breakout_group) > 1:
656
+ # Sort by subport number
657
+ breakout_group.sort(key=lambda x: x[0])
658
+
659
+ # Find the master port (interface with smallest subport)
660
+ min_subport_interface = breakout_group[0][1]
661
+ master_sonic_name = _handle_breakout_interface(
662
+ min_subport_interface.name,
663
+ interface_names,
664
+ port_config,
665
+ device_hwsku,
666
+ )
667
+
668
+ if master_sonic_name.startswith("Ethernet"):
669
+ # Extract base port number
670
+ base_match = re.match(r"Ethernet(\d+)", master_sonic_name)
671
+ if base_match:
672
+ base_port_num = int(base_match.group(1))
673
+ master_port = f"Ethernet{base_port_num}"
674
+
675
+ # Determine breakout mode based on number of subports and speed
676
+ num_subports = len(breakout_group)
677
+ interface_speed = getattr(
678
+ breakout_group[0][1], "speed", None
679
+ )
680
+ if (
681
+ not interface_speed
682
+ and hasattr(breakout_group[0][1], "type")
683
+ and breakout_group[0][1].type
684
+ ):
685
+ interface_speed = get_speed_from_port_type(
686
+ breakout_group[0][1].type.value
687
+ )
688
+
689
+ # Calculate breakout mode
690
+ if interface_speed == 25000 and num_subports == 4:
691
+ brkout_mode = "4x25G"
692
+ elif interface_speed == 50000 and num_subports == 4:
693
+ brkout_mode = "4x50G"
694
+ elif interface_speed == 100000 and num_subports == 4:
695
+ brkout_mode = "4x100G"
696
+ elif interface_speed == 200000 and num_subports == 4:
697
+ brkout_mode = "4x200G"
698
+ else:
699
+ logger.debug(
700
+ f"Unsupported breakout configuration: {num_subports} ports at {interface_speed} Mbps"
701
+ )
702
+ continue
703
+
704
+ # Calculate physical port number (1/1 -> port 1, 1/2 -> port 2, etc.)
705
+ physical_port_num = f"{module}/{port}"
706
+
707
+ # Add breakout config for master port
708
+ breakout_cfgs[master_port] = {
709
+ "breakout_owner": "MANUAL",
710
+ "brkout_mode": brkout_mode,
711
+ "port": physical_port_num,
712
+ }
713
+
714
+ # Add all subports to breakout_ports
715
+ min_subport = breakout_group[0][0]
716
+ for subport, iface in breakout_group:
717
+ current_offset = subport - min_subport
718
+ sonic_port_num = base_port_num + current_offset
719
+ port_name = f"Ethernet{sonic_port_num}"
720
+ breakout_ports[port_name] = {"master": master_port}
721
+
722
+ logger.debug(
723
+ f"Detected breakout group: {group_key} -> {master_port} ({brkout_mode}) with {len(breakout_group)} ports"
724
+ )
725
+
726
+ # Also check for SONiC format breakout (Ethernet0, Ethernet1, Ethernet2, Ethernet3)
727
+ # Only process SONiC breakout if we have explicitly configured breakout ports in NetBox,
728
+ # not automatically assume consecutive Ethernet ports are breakouts
729
+ sonic_match = re.match(r"Ethernet(\d+)", interface_name)
730
+ if sonic_match:
731
+ port_num = int(sonic_match.group(1))
732
+ # Check if this could be part of a breakout group (consecutive Ethernet ports)
733
+ base_port = (port_num // 4) * 4
734
+ group_key = f"sonic_{base_port}"
735
+
736
+ if group_key in processed_groups:
737
+ continue
738
+ processed_groups.add(group_key)
739
+
740
+ # Find potential breakout group (4 consecutive Ethernet ports)
741
+ sonic_breakout_group = []
742
+ for i in range(4):
743
+ ethernet_name = f"Ethernet{base_port + i}"
744
+ for iface in interfaces:
745
+ if iface.name == ethernet_name:
746
+ # Check if this interface has a speed that suggests breakout
747
+ iface_speed = getattr(iface, "speed", None)
748
+ if (
749
+ not iface_speed
750
+ and hasattr(iface, "type")
751
+ and iface.type
752
+ ):
753
+ iface_speed = get_speed_from_port_type(iface.type.value)
754
+
755
+ # Only consider as breakout if speed is 50G or less AND we have 4 consecutive ports
756
+ # This prevents regular 100G ports from being treated as breakout ports
757
+ if (
758
+ iface_speed and iface_speed <= 50000
759
+ ): # 50G or less suggests breakout
760
+ sonic_breakout_group.append((base_port + i, iface))
761
+ break
762
+
763
+ # If we found 4 consecutive interfaces with true breakout speeds (≤50G)
764
+ if len(sonic_breakout_group) == 4:
765
+ master_port = f"Ethernet{base_port}"
766
+
767
+ # Determine breakout mode based on speed
768
+ interface_speed = getattr(sonic_breakout_group[0][1], "speed", None)
769
+ if (
770
+ not interface_speed
771
+ and hasattr(sonic_breakout_group[0][1], "type")
772
+ and sonic_breakout_group[0][1].type
773
+ ):
774
+ interface_speed = get_speed_from_port_type(
775
+ sonic_breakout_group[0][1].type.value
776
+ )
777
+
778
+ if interface_speed == 25000:
779
+ brkout_mode = "4x25G"
780
+ elif interface_speed == 50000:
781
+ brkout_mode = "4x50G"
782
+ else:
783
+ continue # Skip unsupported speeds
784
+
785
+ # Calculate physical port number (Ethernet0-3 -> port 1/1, Ethernet4-7 -> port 1/2, etc.)
786
+ physical_port_index = (base_port // 4) + 1
787
+ physical_port_num = f"1/{physical_port_index}"
788
+
789
+ # Add breakout config for master port
790
+ breakout_cfgs[master_port] = {
791
+ "breakout_owner": "MANUAL",
792
+ "brkout_mode": brkout_mode,
793
+ "port": physical_port_num,
794
+ }
795
+
796
+ # Add all ports to breakout_ports
797
+ for port_num, iface in sonic_breakout_group:
798
+ port_name = f"Ethernet{port_num}"
799
+ breakout_ports[port_name] = {"master": master_port}
800
+
801
+ logger.debug(
802
+ f"Detected SONiC breakout group: Ethernet{base_port}-{base_port + 3} -> {master_port} ({brkout_mode})"
803
+ )
804
+
805
+ except Exception as e:
806
+ logger.warning(f"Could not detect breakout ports for device {device.name}: {e}")
807
+
808
+ return {"breakout_cfgs": breakout_cfgs, "breakout_ports": breakout_ports}
809
+
810
+
811
+ def detect_port_channels(device):
812
+ """Detect port channels (LAGs) from NetBox device interfaces.
813
+
814
+ Args:
815
+ device: NetBox device object
816
+
817
+ Returns:
818
+ dict: Dictionary with port channel information
819
+ {
820
+ 'portchannels': {
821
+ 'PortChannel1': {
822
+ 'members': ['Ethernet120', 'Ethernet124'],
823
+ 'admin_status': 'up',
824
+ 'fast_rate': 'true',
825
+ 'min_links': '1',
826
+ 'mtu': '9100'
827
+ }
828
+ },
829
+ 'member_mapping': {
830
+ 'Ethernet120': 'PortChannel1',
831
+ 'Ethernet124': 'PortChannel1'
832
+ }
833
+ }
834
+ """
835
+ portchannels = {}
836
+ member_mapping = {}
837
+
838
+ try:
839
+ # Get all interfaces for the device (using cache)
840
+ interfaces = get_cached_device_interfaces(device.id)
841
+
842
+ # First pass: find LAG interfaces
843
+ lag_interfaces = []
844
+ for interface in interfaces:
845
+ # Check if this is a LAG interface
846
+ if hasattr(interface, "type") and interface.type:
847
+ if interface.type.value == "lag":
848
+ lag_interfaces.append(interface)
849
+ logger.debug(f"Found LAG interface: {interface.name}")
850
+
851
+ # Second pass: map members to LAGs
852
+ for interface in interfaces:
853
+ # Check if this interface has a LAG parent
854
+ if hasattr(interface, "lag") and interface.lag:
855
+ lag_parent = interface.lag
856
+
857
+ # Convert NetBox interface name to SONiC format
858
+ interface_speed = getattr(interface, "speed", None)
859
+ if (
860
+ not interface_speed
861
+ and hasattr(interface, "type")
862
+ and interface.type
863
+ ):
864
+ interface_speed = get_speed_from_port_type(interface.type.value)
865
+
866
+ sonic_interface_name = convert_netbox_interface_to_sonic(
867
+ interface, device
868
+ )
869
+
870
+ # Extract port channel number from LAG name
871
+ # Common patterns: PortChannel1, Port-Channel1, LAG1, ae1, bond1
872
+ pc_number = None
873
+ if re.match(r"(?i)portchannel(\d+)", lag_parent.name):
874
+ match = re.match(r"(?i)portchannel(\d+)", lag_parent.name)
875
+ pc_number = match.group(1)
876
+ elif re.match(r"(?i)port-channel(\d+)", lag_parent.name):
877
+ match = re.match(r"(?i)port-channel(\d+)", lag_parent.name)
878
+ pc_number = match.group(1)
879
+ elif re.match(r"(?i)lag(\d+)", lag_parent.name):
880
+ match = re.match(r"(?i)lag(\d+)", lag_parent.name)
881
+ pc_number = match.group(1)
882
+ elif re.match(r"(?i)ae(\d+)", lag_parent.name):
883
+ match = re.match(r"(?i)ae(\d+)", lag_parent.name)
884
+ pc_number = match.group(1)
885
+ elif re.match(r"(?i)bond(\d+)", lag_parent.name):
886
+ match = re.match(r"(?i)bond(\d+)", lag_parent.name)
887
+ pc_number = match.group(1)
888
+ else:
889
+ # Try to extract any number from the name
890
+ numbers = re.findall(r"\d+", lag_parent.name)
891
+ if numbers:
892
+ pc_number = numbers[0]
893
+ else:
894
+ # Generate a number based on the LAG interface order
895
+ pc_number = (
896
+ str(lag_interfaces.index(lag_parent) + 1)
897
+ if lag_parent in lag_interfaces
898
+ else "1"
899
+ )
900
+
901
+ portchannel_name = f"PortChannel{pc_number}"
902
+
903
+ # Add member to mapping
904
+ member_mapping[sonic_interface_name] = portchannel_name
905
+
906
+ # Initialize port channel if not exists
907
+ if portchannel_name not in portchannels:
908
+ portchannels[portchannel_name] = {
909
+ "members": [],
910
+ "admin_status": "up",
911
+ "fast_rate": "true",
912
+ "min_links": "1",
913
+ "mtu": "9100",
914
+ }
915
+
916
+ # Add member to port channel
917
+ if (
918
+ sonic_interface_name
919
+ not in portchannels[portchannel_name]["members"]
920
+ ):
921
+ portchannels[portchannel_name]["members"].append(
922
+ sonic_interface_name
923
+ )
924
+
925
+ logger.debug(
926
+ f"Added interface {sonic_interface_name} to {portchannel_name}"
927
+ )
928
+
929
+ # Sort members in each port channel for consistent ordering
930
+ for pc_name in portchannels:
931
+ portchannels[pc_name]["members"].sort(
932
+ key=lambda x: (
933
+ int(re.search(r"\d+", x).group()) if re.search(r"\d+", x) else 0
934
+ )
935
+ )
936
+
937
+ except Exception as e:
938
+ logger.warning(f"Could not detect port channels for device {device.name}: {e}")
939
+
940
+ return {"portchannels": portchannels, "member_mapping": member_mapping}