Skip to content

Commit 2e99e86

Browse files
committed
sonic: refuse breakouts that claim other ports
A detected breakout names its children after the master's lane offsets -- Ethernet<base + n*lanes_per_child> -- which assumes every slot below the next master is unused. That holds for every port of every bundled HWSKU except one: on Accton-AS7726-32X the last 100G port, Ethernet124, has four lanes, but Ethernet125 and Ethernet126 are independent 10G SFP+ ports occupying two of its four child slots. Claiming them as children silently reconfigures two working ports. A breakout_ports entry is authoritative for a port's lanes and speed, and the PORT table is built from every entry in port_config rather than only the interfaces present in NetBox, so both ports are rewritten on any device with that HWSKU: lanes 129 and 128 become 126 and 127, speed 10000 becomes 25000, and the aliases Eth1/33 and Eth1/34 become Eth1/33/1 and Eth1/34/1, presenting them as breakout sub-ports. Add _breakout_child_collisions() and refuse before mutating anything, so the group is dropped whole rather than half-applied and the master keeps its own configuration. Two of the three detection paths needed it: - the Eth<module>/<port>/<subport> path, which computes child names from the master offset and had no check at all; - the SONiC-name 400G grouping path, likewise. The SONiC-name standard grouping path already refuses these groups. Its topology gate skips a group whose intermediate slots are ports in port_config, which is the same condition this collision test applies. A comment now records that the gate is load-bearing for correctness and not only for the native-port misdetection it was written for. The collision test is that a child name other than the master is itself a key in port_config. Swept across every master of all nine bundled HWSKUs at 1x/2x/4x/8x, it flags exactly the three real cases on that one HWSKU and nothing else, so it needs no per-HWSKU exception list. The new tests load the .ini files shipped in this repo instead of building a port_config inline. That is the point: the existing breakout tests use one- or two-entry port_configs, which cannot express an occupied child slot, so this class of bug was structurally unreachable by the suite. The 400G case keeps a synthetic port_config because no bundled HWSKU has an 8-lane master with an occupied child slot. Assisted-by: Claude:claude-opus-5 Signed-off-by: Roger Luethi <luethi@osism.tech>
1 parent c977639 commit 2e99e86

2 files changed

Lines changed: 195 additions & 21 deletions

File tree

osism/tasks/conductor/sonic/interface.py

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,20 @@ def get_connected_interfaces(device, portchannel_info=None):
641641
return _get_connected_interfaces(device, portchannel_info)
642642

643643

644+
def _breakout_child_collisions(children, master, port_config):
645+
"""Children that are separate ports of this HWSKU rather than free slots.
646+
647+
Breakout children are named after the master's lane offsets, which assumes
648+
every slot below the next master is unused. That holds for every port of
649+
every bundled HWSKU but one: on Accton-AS7726-32X the last 100G port
650+
(Ethernet124, four lanes) is followed by Ethernet125 and Ethernet126, two
651+
independent 10G SFP+ ports occupying two of its four child slots. Claiming
652+
those as children rewrites their lanes, speed and alias, so a breakout that
653+
would do it has to be refused.
654+
"""
655+
return [c for c in children if c != master and c in port_config]
656+
657+
644658
def detect_breakout_ports(device):
645659
"""Detect breakout ports from NetBox device interfaces using the centralized breakout logic.
646660
@@ -767,14 +781,6 @@ def detect_breakout_ports(device):
767781
# Calculate physical port number (1/1 -> port 1, 1/2 -> port 2, etc.)
768782
physical_port_num = f"{module}/{port}"
769783

770-
# Add breakout config for master port
771-
breakout_cfgs[master_port] = {
772-
"breakout_owner": "MANUAL",
773-
"brkout_mode": brkout_mode,
774-
"port": physical_port_num,
775-
}
776-
777-
# Add all subports to breakout_ports
778784
min_subport = breakout_group[0][0]
779785

780786
# Determine the offset multiplier based on master port lane count
@@ -797,12 +803,31 @@ def detect_breakout_ports(device):
797803
f"8 lanes, using offset multiplier {offset_multiplier}"
798804
)
799805

800-
for subport, iface in breakout_group:
801-
current_offset = (
802-
subport - min_subport
803-
) * offset_multiplier
804-
sonic_port_num = base_port_num + current_offset
805-
port_name = f"Ethernet{sonic_port_num}"
806+
children = [
807+
"Ethernet"
808+
f"{base_port_num + (subport - min_subport) * offset_multiplier}"
809+
for subport, _iface in breakout_group
810+
]
811+
collisions = _breakout_child_collisions(
812+
children, master_port, port_config
813+
)
814+
if collisions:
815+
logger.error(
816+
f"Breakout of {master_port} would claim "
817+
f"{', '.join(collisions)}, which are separate "
818+
f"ports on this HWSKU; skipping the group"
819+
)
820+
continue
821+
822+
# Add breakout config for master port
823+
breakout_cfgs[master_port] = {
824+
"breakout_owner": "MANUAL",
825+
"brkout_mode": brkout_mode,
826+
"port": physical_port_num,
827+
}
828+
829+
# Add all subports to breakout_ports
830+
for port_name in children:
806831
breakout_ports[port_name] = {"master": master_port}
807832

808833
logger.debug(
@@ -861,6 +886,24 @@ def detect_breakout_ports(device):
861886
physical_port_index = (base_port_400g // 8) + 1
862887
physical_port_num = f"1/{physical_port_index}"
863888

889+
children = [
890+
f"Ethernet{port_num_400g}"
891+
for port_num_400g, _iface in (
892+
sonic_400g_breakout_group
893+
)
894+
]
895+
collisions = _breakout_child_collisions(
896+
children, master_port, port_config
897+
)
898+
if collisions:
899+
logger.error(
900+
f"400G breakout of {master_port} would "
901+
f"claim {', '.join(collisions)}, which are "
902+
f"separate ports on this HWSKU; skipping "
903+
f"the group"
904+
)
905+
continue
906+
864907
# Add breakout config for master port
865908
breakout_cfgs[master_port] = {
866909
"breakout_owner": "MANUAL",
@@ -869,11 +912,7 @@ def detect_breakout_ports(device):
869912
}
870913

871914
# Add all ports to breakout_ports
872-
for (
873-
port_num_400g,
874-
iface,
875-
) in sonic_400g_breakout_group:
876-
port_name = f"Ethernet{port_num_400g}"
915+
for port_name in children:
877916
breakout_ports[port_name] = {
878917
"master": master_port
879918
}
@@ -955,6 +994,15 @@ def detect_breakout_ports(device):
955994
physical_port_index = (base_port // 4) + 1
956995
physical_port_num = f"1/{physical_port_index}"
957996

997+
# NOTE: the topology gate above already refuses a group whose
998+
# intermediate slots are ports in port_config, which is the
999+
# same condition _breakout_child_collisions() tests. No
1000+
# separate collision check is needed on this path.
1001+
children = [
1002+
f"Ethernet{port_num}"
1003+
for port_num, _iface in sonic_breakout_group
1004+
]
1005+
9581006
# Add breakout config for master port
9591007
breakout_cfgs[master_port] = {
9601008
"breakout_owner": "MANUAL",
@@ -963,8 +1011,7 @@ def detect_breakout_ports(device):
9631011
}
9641012

9651013
# Add all ports to breakout_ports
966-
for port_num, iface in sonic_breakout_group:
967-
port_name = f"Ethernet{port_num}"
1014+
for port_name in children:
9681015
breakout_ports[port_name] = {"master": master_port}
9691016

9701017
logger.debug(

tests/unit/tasks/conductor/sonic/test_breakout_detection.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
``interface`` module.
1010
"""
1111

12+
import pathlib
1213
from types import SimpleNamespace
1314

1415
import pytest
@@ -568,3 +569,129 @@ def test_detect_breakout_ports_sonic_standard_speed_resolved_from_port_type(
568569
result = detect_breakout_ports(device)
569570

570571
assert result["breakout_cfgs"]["Ethernet0"]["brkout_mode"] == "4x25G"
572+
573+
574+
# ---------------------------------------------------------------------------
575+
# Child slots occupied by another port
576+
# ---------------------------------------------------------------------------
577+
578+
579+
@pytest.fixture
580+
def real_port_config(monkeypatch):
581+
"""Load a port_config from the .ini files actually shipped in this repo.
582+
583+
The helpers above build port_configs with one or two entries, which cannot
584+
express a child slot already occupied by another port -- the one shape that
585+
makes a breakout unsafe, and the reason this class of bug went unnoticed.
586+
These tests need the real file.
587+
"""
588+
589+
def _load(hwsku):
590+
repo_root = pathlib.Path(__file__).resolve().parents[5]
591+
monkeypatch.setattr(
592+
interface_module,
593+
"PORT_CONFIG_PATH",
594+
str(repo_root / "files" / "sonic" / "port_config"),
595+
)
596+
interface_module.clear_port_config_cache()
597+
return interface_module.get_port_config(hwsku)
598+
599+
yield _load
600+
interface_module.clear_port_config_cache()
601+
602+
603+
def test_netbox_format_breakout_refused_when_child_slot_is_another_port(
604+
patch_breakout_helpers, real_port_config
605+
):
606+
"""Eth1/32 on Accton-AS7726-32X is Ethernet124, a four-lane 100G port whose
607+
third and fourth child slots are Ethernet125 and Ethernet126 -- independent
608+
10G SFP+ ports. Breaking it out would rewrite their lanes, speed and alias,
609+
so the group is dropped whole, master BREAKOUT_CFG included.
610+
"""
611+
port_config = real_port_config("Accton-AS7726-32X")
612+
assert {"Ethernet125", "Ethernet126"} <= set(port_config)
613+
614+
device = _make_sonic_device()
615+
interfaces = _netbox_breakout_interfaces(speed=25_000_000, port=32)
616+
patch_breakout_helpers(interfaces=interfaces, port_config=port_config)
617+
618+
result = detect_breakout_ports(device)
619+
620+
assert result["breakout_cfgs"] == {}
621+
assert result["breakout_ports"] == {}
622+
623+
624+
def test_netbox_format_breakout_allowed_when_child_slots_are_free(
625+
patch_breakout_helpers, real_port_config
626+
):
627+
"""The same HWSKU's first port must still break out: Ethernet0's children
628+
are Ethernet1-3, none of which is a port in its own right.
629+
"""
630+
port_config = real_port_config("Accton-AS7726-32X")
631+
632+
device = _make_sonic_device()
633+
interfaces = _netbox_breakout_interfaces(speed=25_000_000, port=1)
634+
patch_breakout_helpers(interfaces=interfaces, port_config=port_config)
635+
636+
result = detect_breakout_ports(device)
637+
638+
assert result["breakout_cfgs"]["Ethernet0"]["brkout_mode"] == "4x25G"
639+
assert sorted(result["breakout_ports"]) == [
640+
"Ethernet0",
641+
"Ethernet1",
642+
"Ethernet2",
643+
"Ethernet3",
644+
]
645+
646+
647+
def test_sonic_format_breakout_already_refused_by_the_topology_gate(
648+
patch_breakout_helpers, real_port_config
649+
):
650+
"""The same collision reached through SONiC-format names rather than
651+
Eth1/<port>/<subport>. This path needs no collision check: the topology gate
652+
already skips a group whose intermediate slots are ports in port_config,
653+
which is the same condition. Pinned here on the real port_config because
654+
nothing else covered it, and because that gate is now load-bearing for
655+
correctness rather than only for native-port misdetection.
656+
"""
657+
port_config = real_port_config("Accton-AS7726-32X")
658+
659+
device = _make_sonic_device()
660+
interfaces = [
661+
_make_iface(f"Ethernet{n}", speed=25_000_000) for n in (124, 125, 126, 127)
662+
]
663+
patch_breakout_helpers(interfaces=interfaces, port_config=port_config)
664+
665+
result = detect_breakout_ports(device)
666+
667+
assert result["breakout_cfgs"] == {}
668+
assert result["breakout_ports"] == {}
669+
670+
671+
def test_sonic_400g_breakout_refused_when_child_slot_is_another_port(
672+
patch_breakout_helpers,
673+
):
674+
"""The 400G grouping path takes the same guard. No bundled HWSKU has an
675+
8-lane master with an occupied child slot, so the port_config here is
676+
built to that shape: an 8-lane master at Ethernet0 whose 4x100G children
677+
would be Ethernet0/2/4/6, with Ethernet4 present as its own port.
678+
"""
679+
port_config = {
680+
**_port_config_for_port(lanes="1,2,3,4,5,6,7,8", speed="400000"),
681+
**_port_config_for_port(
682+
sonic_port="Ethernet4",
683+
alias="hundredGigE99",
684+
lanes="9",
685+
index="99",
686+
speed="10000",
687+
),
688+
}
689+
690+
device = _make_sonic_device()
691+
interfaces = [_make_iface(f"Ethernet{n}", speed=100_000_000) for n in (0, 2, 4, 6)]
692+
patch_breakout_helpers(interfaces=interfaces, port_config=port_config)
693+
694+
result = detect_breakout_ports(device)
695+
696+
assert result["breakout_cfgs"] == {}
697+
assert result["breakout_ports"] == {}

0 commit comments

Comments
 (0)