Skip to content

Commit d4141f4

Browse files
authored
sonic: refuse breakouts that claim other ports (#2560)
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. The tests locate the repo root by walking up for the setup.cfg marker rather than a fixed parent depth, matching how tests/integration/conftest.py finds it; a hard-coded depth breaks silently when a test module moves. Assisted-by: Claude:claude-opus-5 Signed-off-by: Roger Luethi <luethi@osism.tech>
1 parent c977639 commit d4141f4

3 files changed

Lines changed: 203 additions & 21 deletions

File tree

osism/tasks/conductor/sonic/interface.py

Lines changed: 63 additions & 20 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,10 @@ 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.
9581001
# Add breakout config for master port
9591002
breakout_cfgs[master_port] = {
9601003
"breakout_owner": "MANUAL",
@@ -963,7 +1006,7 @@ def detect_breakout_ports(device):
9631006
}
9641007

9651008
# Add all ports to breakout_ports
966-
for port_num, iface in sonic_breakout_group:
1009+
for port_num, _iface in sonic_breakout_group:
9671010
port_name = f"Ethernet{port_num}"
9681011
breakout_ports[port_name] = {"master": master_port}
9691012

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,23 @@
66
the module is private (``_``-prefixed) so pytest does not collect it.
77
"""
88

9+
from pathlib import Path
910
from types import SimpleNamespace
1011

1112

13+
def repo_root():
14+
"""Return the repository root, found by its ``setup.cfg`` marker.
15+
16+
Walking up beats hard-coding a parent depth, which silently breaks when a
17+
test module moves. ``tests/integration/conftest.py`` locates the root the
18+
same way, for the same reason.
19+
"""
20+
for parent in Path(__file__).resolve().parents:
21+
if (parent / "setup.cfg").exists():
22+
return parent
23+
raise RuntimeError("no repository root with setup.cfg above this file")
24+
25+
1226
def _make_sonic_device(device_id=1, name="sw1", hwsku="TEST-HWSKU"):
1327
"""Build a NetBox device stub carrying ``custom_fields.sonic_parameters.hwsku``."""
1428
return SimpleNamespace(

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

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from osism.tasks.conductor.sonic import interface as interface_module
1717
from osism.tasks.conductor.sonic.interface import detect_breakout_ports
1818

19-
from ._detection_helpers import _make_iface, _make_sonic_device
19+
from ._detection_helpers import _make_iface, _make_sonic_device, repo_root
2020

2121
# ---------------------------------------------------------------------------
2222
# Helpers
@@ -568,3 +568,128 @@ def test_detect_breakout_ports_sonic_standard_speed_resolved_from_port_type(
568568
result = detect_breakout_ports(device)
569569

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

0 commit comments

Comments
 (0)