osism 0.20250616.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.
@@ -0,0 +1,789 @@
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
+
241
+ Args:
242
+ interface_name: NetBox interface name (e.g., "Eth1/1" or "Eth1/1/1")
243
+ port_config: Port configuration dictionary
244
+
245
+ Returns:
246
+ str: SONiC interface name or original name if not found
247
+ """
248
+ # Create reverse mapping: expected NetBox name -> alias -> SONiC name
249
+ for sonic_port, config in port_config.items():
250
+ alias = config.get("alias", "")
251
+ if not alias:
252
+ continue
253
+
254
+ # Extract number from alias (e.g., tenGigE1 -> 1, hundredGigE49 -> 49)
255
+ alias_match = re.search(r"(\d+)$", alias)
256
+ if not alias_match:
257
+ continue
258
+
259
+ alias_num = int(alias_match.group(1))
260
+
261
+ # Generate expected NetBox interface names for this alias
262
+ expected_names = [
263
+ f"Eth1/{alias_num}", # Standard format
264
+ f"Eth1/{alias_num}/1", # Breakout format (first subport)
265
+ ]
266
+
267
+ if interface_name in expected_names:
268
+ logger.debug(
269
+ f"Alias mapping: {interface_name} -> {sonic_port} via alias {alias}"
270
+ )
271
+ return sonic_port
272
+
273
+ logger.warning(f"No alias mapping found for {interface_name}")
274
+ return interface_name
275
+
276
+
277
+ def convert_sonic_interface_to_alias(
278
+ sonic_interface_name, interface_speed=None, is_breakout=False
279
+ ):
280
+ """Convert SONiC interface name to NetBox-style alias.
281
+
282
+ Args:
283
+ sonic_interface_name: SONiC interface name (e.g., "Ethernet0", "Ethernet4")
284
+ interface_speed: Interface speed in Mbps (optional, for speed-based calculation)
285
+ is_breakout: Whether this is a breakout port (adds subport notation)
286
+
287
+ Returns:
288
+ str: NetBox-style alias (e.g., "Eth1/1", "Eth1/2" or "Eth1/1/1", "Eth1/1/2" for breakout)
289
+
290
+ Examples:
291
+ - Regular 100G ports: Ethernet0 -> Eth1/1, Ethernet4 -> Eth1/2, Ethernet8 -> Eth1/3
292
+ - Regular other speeds: Ethernet0 -> Eth1/1, Ethernet1 -> Eth1/2, Ethernet2 -> Eth1/3
293
+ - Breakout ports: Ethernet0 -> Eth1/1/1, Ethernet1 -> Eth1/1/2, Ethernet2 -> Eth1/1/3, Ethernet3 -> Eth1/1/4
294
+ """
295
+ # Extract port number from SONiC format (Ethernet0, Ethernet4, etc.)
296
+ match = re.match(r"Ethernet(\d+)", sonic_interface_name)
297
+ if not match:
298
+ # If it doesn't match expected pattern, return as-is
299
+ return sonic_interface_name
300
+
301
+ sonic_port_number = int(match.group(1))
302
+
303
+ if is_breakout:
304
+ # For breakout ports: Ethernet0 -> Eth1/1/1, Ethernet1 -> Eth1/1/2, etc.
305
+ # Calculate base port (master port) and subport number
306
+ base_port = (sonic_port_number // 4) * 4 # Get base port (0, 4, 8, 12, ...)
307
+ subport = (sonic_port_number % 4) + 1 # Get subport number (1, 2, 3, 4)
308
+
309
+ # Calculate physical port number for the base port
310
+ physical_port = (base_port // 4) + 1 # Convert to 1-based indexing
311
+
312
+ # Assume module 1 for now - could be extended for multi-module systems
313
+ module = 1
314
+
315
+ return f"Eth{module}/{physical_port}/{subport}"
316
+ else:
317
+ # For regular ports: use speed-based calculation
318
+ # Determine speed category and multiplier
319
+ if interface_speed and interface_speed in HIGH_SPEED_PORTS:
320
+ # High-speed ports use 4x multiplier (lanes)
321
+ multiplier = 4
322
+ else:
323
+ # Default for 1G, 10G, 25G ports - sequential numbering
324
+ multiplier = 1
325
+
326
+ # Calculate physical port number
327
+ physical_port = (
328
+ sonic_port_number // multiplier
329
+ ) + 1 # Convert to 1-based indexing
330
+
331
+ # Assume module 1 for now - could be extended for multi-module systems
332
+ module = 1
333
+
334
+ return f"Eth{module}/{physical_port}"
335
+
336
+
337
+ def get_port_config(hwsku):
338
+ """Get port configuration for a given HWSKU. Uses caching to avoid repeated file reads.
339
+
340
+ Args:
341
+ hwsku: Hardware SKU name (e.g., 'Accton-AS5835-54T')
342
+
343
+ Returns:
344
+ dict: Port configuration with port names as keys and their properties as values
345
+ Example: {'Ethernet0': {'lanes': '2', 'alias': 'tenGigE1', 'index': '1', 'speed': '10000', 'valid_speeds': '10000,25000'}}
346
+ """
347
+ global _port_config_cache # noqa F824
348
+
349
+ # Check if already cached
350
+ if hwsku in _port_config_cache:
351
+ logger.debug(f"Using cached port config for HWSKU {hwsku}")
352
+ # Return a deep copy to ensure isolation between devices
353
+ return copy.deepcopy(_port_config_cache[hwsku])
354
+
355
+ port_config = {}
356
+ config_path = f"{PORT_CONFIG_PATH}/{hwsku}.ini"
357
+
358
+ if not os.path.exists(config_path):
359
+ logger.error(f"Port config file not found: {config_path}")
360
+ # Cache empty config to avoid repeated file system checks
361
+ _port_config_cache[hwsku] = port_config
362
+ return port_config
363
+
364
+ try:
365
+ with open(config_path, "r") as f:
366
+ for line in f:
367
+ line = line.strip()
368
+ # Skip comments and empty lines
369
+ if not line or line.startswith("#"):
370
+ continue
371
+
372
+ parts = line.split()
373
+ if len(parts) >= 5:
374
+ port_name = parts[0]
375
+ port_config[port_name] = {
376
+ "lanes": parts[1],
377
+ "alias": parts[2],
378
+ "index": parts[3],
379
+ "speed": parts[4],
380
+ }
381
+ # Check for optional valid_speeds column (6th column)
382
+ if len(parts) >= 6:
383
+ port_config[port_name]["valid_speeds"] = parts[5]
384
+
385
+ # Cache the loaded configuration
386
+ _port_config_cache[hwsku] = port_config
387
+ logger.debug(
388
+ f"Cached port config for HWSKU {hwsku} with {len(port_config)} ports"
389
+ )
390
+
391
+ except Exception as e:
392
+ logger.error(f"Error parsing port config file {config_path}: {e}")
393
+ # Cache empty config on error to avoid repeated attempts
394
+ _port_config_cache[hwsku] = port_config
395
+
396
+ # Return a deep copy to ensure isolation between devices
397
+ return copy.deepcopy(port_config)
398
+
399
+
400
+ def clear_port_config_cache():
401
+ """Clear the port configuration cache. Should be called at the start of sync_sonic."""
402
+ global _port_config_cache
403
+ _port_config_cache = {}
404
+ logger.debug("Cleared port configuration cache")
405
+
406
+
407
+ # Deprecated: Use connections.get_connected_interfaces instead
408
+ # This function is kept for backward compatibility but delegates to the new module
409
+ def get_connected_interfaces(device, portchannel_info=None):
410
+ """Get list of interface names that are connected to other devices.
411
+
412
+ Args:
413
+ device: NetBox device object
414
+ portchannel_info: Optional port channel info dict from detect_port_channels
415
+
416
+ Returns:
417
+ tuple: (set of connected interfaces, set of connected port channels)
418
+ """
419
+ # Import here to avoid circular imports
420
+ from .connections import get_connected_interfaces as _get_connected_interfaces
421
+
422
+ return _get_connected_interfaces(device, portchannel_info)
423
+
424
+
425
+ def detect_breakout_ports(device):
426
+ """Detect breakout ports from NetBox device interfaces using the centralized breakout logic.
427
+
428
+ Args:
429
+ device: NetBox device object
430
+
431
+ Returns:
432
+ dict: Dictionary with breakout port information
433
+ {
434
+ 'breakout_cfgs': {port_name: {'brkout_mode': mode, 'port': port}},
435
+ 'breakout_ports': {port_name: {'master': master_port}}
436
+ }
437
+ """
438
+ breakout_cfgs = {}
439
+ breakout_ports = {}
440
+
441
+ try:
442
+ # Get all interfaces for the device (using cache)
443
+ interfaces = get_cached_device_interfaces(device.id)
444
+ interface_names = [iface.name for iface in interfaces]
445
+
446
+ # Get HWSKU for port config
447
+ device_hwsku = None
448
+ if (
449
+ hasattr(device, "custom_fields")
450
+ and "sonic_parameters" in device.custom_fields
451
+ and device.custom_fields["sonic_parameters"]
452
+ and "hwsku" in device.custom_fields["sonic_parameters"]
453
+ ):
454
+ device_hwsku = device.custom_fields["sonic_parameters"]["hwsku"]
455
+
456
+ if not device_hwsku:
457
+ logger.warning(f"No HWSKU found for device {device.name}")
458
+ return {"breakout_cfgs": breakout_cfgs, "breakout_ports": breakout_ports}
459
+
460
+ # Get port configuration for the HWSKU
461
+ try:
462
+ port_config = get_port_config(device_hwsku)
463
+ if not port_config:
464
+ logger.warning(f"No port config found for HWSKU {device_hwsku}")
465
+ return {
466
+ "breakout_cfgs": breakout_cfgs,
467
+ "breakout_ports": breakout_ports,
468
+ }
469
+ except Exception as e:
470
+ logger.warning(f"Could not load port config for {device_hwsku}: {e}")
471
+ return {"breakout_cfgs": breakout_cfgs, "breakout_ports": breakout_ports}
472
+
473
+ # Process interfaces that match breakout patterns
474
+ processed_groups = set()
475
+
476
+ for interface in interfaces:
477
+ interface_name = interface.name
478
+
479
+ # Check for EthX/Y/Z format (NetBox breakout notation)
480
+ breakout_match = re.match(r"Eth(\d+)/(\d+)/(\d+)", interface_name)
481
+ if breakout_match:
482
+ module = int(breakout_match.group(1))
483
+ port = int(breakout_match.group(2))
484
+ subport = int(breakout_match.group(3))
485
+
486
+ # Create group key to avoid processing the same group multiple times
487
+ group_key = f"{module}/{port}"
488
+ if group_key in processed_groups:
489
+ continue
490
+ processed_groups.add(group_key)
491
+
492
+ # Use the centralized breakout logic
493
+ sonic_name = _handle_breakout_interface(
494
+ interface_name, interface_names, port_config, device_hwsku
495
+ )
496
+
497
+ # If the breakout logic returned a valid SONiC name, we have a breakout group
498
+ if sonic_name.startswith("Ethernet") and sonic_name != interface_name:
499
+ # Find all interfaces in this breakout group
500
+ breakout_group = []
501
+ for iface in interfaces:
502
+ iface_match = re.match(r"Eth(\d+)/(\d+)/(\d+)", iface.name)
503
+ if iface_match:
504
+ iface_module = int(iface_match.group(1))
505
+ iface_port = int(iface_match.group(2))
506
+ iface_subport = int(iface_match.group(3))
507
+
508
+ if iface_module == module and iface_port == port:
509
+ breakout_group.append((iface_subport, iface))
510
+
511
+ # Check if we have a valid breakout group (more than one interface)
512
+ if len(breakout_group) > 1:
513
+ # Sort by subport number
514
+ breakout_group.sort(key=lambda x: x[0])
515
+
516
+ # Find the master port (interface with smallest subport)
517
+ min_subport_interface = breakout_group[0][1]
518
+ master_sonic_name = _handle_breakout_interface(
519
+ min_subport_interface.name,
520
+ interface_names,
521
+ port_config,
522
+ device_hwsku,
523
+ )
524
+
525
+ if master_sonic_name.startswith("Ethernet"):
526
+ # Extract base port number
527
+ base_match = re.match(r"Ethernet(\d+)", master_sonic_name)
528
+ if base_match:
529
+ base_port_num = int(base_match.group(1))
530
+ master_port = f"Ethernet{base_port_num}"
531
+
532
+ # Determine breakout mode based on number of subports and speed
533
+ num_subports = len(breakout_group)
534
+ interface_speed = getattr(
535
+ breakout_group[0][1], "speed", None
536
+ )
537
+ if (
538
+ not interface_speed
539
+ and hasattr(breakout_group[0][1], "type")
540
+ and breakout_group[0][1].type
541
+ ):
542
+ interface_speed = get_speed_from_port_type(
543
+ breakout_group[0][1].type.value
544
+ )
545
+
546
+ # Calculate breakout mode
547
+ if interface_speed == 25000 and num_subports == 4:
548
+ brkout_mode = "4x25G"
549
+ elif interface_speed == 50000 and num_subports == 4:
550
+ brkout_mode = "4x50G"
551
+ elif interface_speed == 100000 and num_subports == 4:
552
+ brkout_mode = "4x100G"
553
+ elif interface_speed == 200000 and num_subports == 4:
554
+ brkout_mode = "4x200G"
555
+ else:
556
+ logger.debug(
557
+ f"Unsupported breakout configuration: {num_subports} ports at {interface_speed} Mbps"
558
+ )
559
+ continue
560
+
561
+ # Add breakout config for master port
562
+ breakout_cfgs[master_port] = {
563
+ "brkout_mode": brkout_mode,
564
+ }
565
+
566
+ # Add all subports to breakout_ports
567
+ min_subport = breakout_group[0][0]
568
+ for subport, iface in breakout_group:
569
+ current_offset = subport - min_subport
570
+ sonic_port_num = base_port_num + current_offset
571
+ port_name = f"Ethernet{sonic_port_num}"
572
+ breakout_ports[port_name] = {"master": master_port}
573
+
574
+ logger.debug(
575
+ f"Detected breakout group: {group_key} -> {master_port} ({brkout_mode}) with {len(breakout_group)} ports"
576
+ )
577
+
578
+ # Also check for SONiC format breakout (Ethernet0, Ethernet1, Ethernet2, Ethernet3)
579
+ # Only process SONiC breakout if we have explicitly configured breakout ports in NetBox,
580
+ # not automatically assume consecutive Ethernet ports are breakouts
581
+ sonic_match = re.match(r"Ethernet(\d+)", interface_name)
582
+ if sonic_match:
583
+ port_num = int(sonic_match.group(1))
584
+ # Check if this could be part of a breakout group (consecutive Ethernet ports)
585
+ base_port = (port_num // 4) * 4
586
+ group_key = f"sonic_{base_port}"
587
+
588
+ if group_key in processed_groups:
589
+ continue
590
+ processed_groups.add(group_key)
591
+
592
+ # Find potential breakout group (4 consecutive Ethernet ports)
593
+ sonic_breakout_group = []
594
+ for i in range(4):
595
+ ethernet_name = f"Ethernet{base_port + i}"
596
+ for iface in interfaces:
597
+ if iface.name == ethernet_name:
598
+ # Check if this interface has a speed that suggests breakout
599
+ iface_speed = getattr(iface, "speed", None)
600
+ if (
601
+ not iface_speed
602
+ and hasattr(iface, "type")
603
+ and iface.type
604
+ ):
605
+ iface_speed = get_speed_from_port_type(iface.type.value)
606
+
607
+ # Only consider as breakout if speed is 50G or less AND we have 4 consecutive ports
608
+ # This prevents regular 100G ports from being treated as breakout ports
609
+ if (
610
+ iface_speed and iface_speed <= 50000
611
+ ): # 50G or less suggests breakout
612
+ sonic_breakout_group.append((base_port + i, iface))
613
+ break
614
+
615
+ # If we found 4 consecutive interfaces with true breakout speeds (≤50G)
616
+ if len(sonic_breakout_group) == 4:
617
+ master_port = f"Ethernet{base_port}"
618
+
619
+ # Determine breakout mode based on speed
620
+ interface_speed = getattr(sonic_breakout_group[0][1], "speed", None)
621
+ if (
622
+ not interface_speed
623
+ and hasattr(sonic_breakout_group[0][1], "type")
624
+ and sonic_breakout_group[0][1].type
625
+ ):
626
+ interface_speed = get_speed_from_port_type(
627
+ sonic_breakout_group[0][1].type.value
628
+ )
629
+
630
+ if interface_speed == 25000:
631
+ brkout_mode = "4x25G"
632
+ elif interface_speed == 50000:
633
+ brkout_mode = "4x50G"
634
+ else:
635
+ continue # Skip unsupported speeds
636
+
637
+ # Calculate physical port number
638
+ physical_port_num = (base_port // 4) + 1
639
+
640
+ # Add breakout config for master port
641
+ breakout_cfgs[master_port] = {
642
+ "brkout_mode": brkout_mode,
643
+ }
644
+
645
+ # Add all ports to breakout_ports
646
+ for port_num, iface in sonic_breakout_group:
647
+ port_name = f"Ethernet{port_num}"
648
+ breakout_ports[port_name] = {"master": master_port}
649
+
650
+ logger.debug(
651
+ f"Detected SONiC breakout group: Ethernet{base_port}-{base_port + 3} -> {master_port} ({brkout_mode})"
652
+ )
653
+
654
+ except Exception as e:
655
+ logger.warning(f"Could not detect breakout ports for device {device.name}: {e}")
656
+
657
+ return {"breakout_cfgs": breakout_cfgs, "breakout_ports": breakout_ports}
658
+
659
+
660
+ def detect_port_channels(device):
661
+ """Detect port channels (LAGs) from NetBox device interfaces.
662
+
663
+ Args:
664
+ device: NetBox device object
665
+
666
+ Returns:
667
+ dict: Dictionary with port channel information
668
+ {
669
+ 'portchannels': {
670
+ 'PortChannel1': {
671
+ 'members': ['Ethernet120', 'Ethernet124'],
672
+ 'admin_status': 'up',
673
+ 'fast_rate': 'true',
674
+ 'min_links': '1',
675
+ 'mtu': '9100'
676
+ }
677
+ },
678
+ 'member_mapping': {
679
+ 'Ethernet120': 'PortChannel1',
680
+ 'Ethernet124': 'PortChannel1'
681
+ }
682
+ }
683
+ """
684
+ portchannels = {}
685
+ member_mapping = {}
686
+
687
+ try:
688
+ # Get all interfaces for the device (using cache)
689
+ interfaces = get_cached_device_interfaces(device.id)
690
+
691
+ # First pass: find LAG interfaces
692
+ lag_interfaces = []
693
+ for interface in interfaces:
694
+ # Check if this is a LAG interface
695
+ if hasattr(interface, "type") and interface.type:
696
+ if interface.type.value == "lag":
697
+ lag_interfaces.append(interface)
698
+ logger.debug(f"Found LAG interface: {interface.name}")
699
+
700
+ # Second pass: map members to LAGs
701
+ for interface in interfaces:
702
+ # Check if this interface has a LAG parent
703
+ if hasattr(interface, "lag") and interface.lag:
704
+ lag_parent = interface.lag
705
+
706
+ # Convert NetBox interface name to SONiC format
707
+ interface_speed = getattr(interface, "speed", None)
708
+ if (
709
+ not interface_speed
710
+ and hasattr(interface, "type")
711
+ and interface.type
712
+ ):
713
+ interface_speed = get_speed_from_port_type(interface.type.value)
714
+
715
+ sonic_interface_name = convert_netbox_interface_to_sonic(
716
+ interface, device
717
+ )
718
+
719
+ # Extract port channel number from LAG name
720
+ # Common patterns: PortChannel1, Port-Channel1, LAG1, ae1, bond1
721
+ pc_number = None
722
+ if re.match(r"(?i)portchannel(\d+)", lag_parent.name):
723
+ match = re.match(r"(?i)portchannel(\d+)", lag_parent.name)
724
+ pc_number = match.group(1)
725
+ elif re.match(r"(?i)port-channel(\d+)", lag_parent.name):
726
+ match = re.match(r"(?i)port-channel(\d+)", lag_parent.name)
727
+ pc_number = match.group(1)
728
+ elif re.match(r"(?i)lag(\d+)", lag_parent.name):
729
+ match = re.match(r"(?i)lag(\d+)", lag_parent.name)
730
+ pc_number = match.group(1)
731
+ elif re.match(r"(?i)ae(\d+)", lag_parent.name):
732
+ match = re.match(r"(?i)ae(\d+)", lag_parent.name)
733
+ pc_number = match.group(1)
734
+ elif re.match(r"(?i)bond(\d+)", lag_parent.name):
735
+ match = re.match(r"(?i)bond(\d+)", lag_parent.name)
736
+ pc_number = match.group(1)
737
+ else:
738
+ # Try to extract any number from the name
739
+ numbers = re.findall(r"\d+", lag_parent.name)
740
+ if numbers:
741
+ pc_number = numbers[0]
742
+ else:
743
+ # Generate a number based on the LAG interface order
744
+ pc_number = (
745
+ str(lag_interfaces.index(lag_parent) + 1)
746
+ if lag_parent in lag_interfaces
747
+ else "1"
748
+ )
749
+
750
+ portchannel_name = f"PortChannel{pc_number}"
751
+
752
+ # Add member to mapping
753
+ member_mapping[sonic_interface_name] = portchannel_name
754
+
755
+ # Initialize port channel if not exists
756
+ if portchannel_name not in portchannels:
757
+ portchannels[portchannel_name] = {
758
+ "members": [],
759
+ "admin_status": "up",
760
+ "fast_rate": "true",
761
+ "min_links": "1",
762
+ "mtu": "9100",
763
+ }
764
+
765
+ # Add member to port channel
766
+ if (
767
+ sonic_interface_name
768
+ not in portchannels[portchannel_name]["members"]
769
+ ):
770
+ portchannels[portchannel_name]["members"].append(
771
+ sonic_interface_name
772
+ )
773
+
774
+ logger.debug(
775
+ f"Added interface {sonic_interface_name} to {portchannel_name}"
776
+ )
777
+
778
+ # Sort members in each port channel for consistent ordering
779
+ for pc_name in portchannels:
780
+ portchannels[pc_name]["members"].sort(
781
+ key=lambda x: (
782
+ int(re.search(r"\d+", x).group()) if re.search(r"\d+", x) else 0
783
+ )
784
+ )
785
+
786
+ except Exception as e:
787
+ logger.warning(f"Could not detect port channels for device {device.name}: {e}")
788
+
789
+ return {"portchannels": portchannels, "member_mapping": member_mapping}