Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 3 (#2199). Companion to the interface-conversion issue: covers the two large topology-detection functions in osism/tasks/conductor/sonic/interface.py. They are split out because each is several hundred lines of NetBox-driven loop logic with multiple branches (standard breakout, 400G breakout, LAG name regex variants).
Scope
Add tests/unit/tasks/conductor/sonic/test_interface_detection.py covering detect_breakout_ports and detect_port_channels in osism/tasks/conductor/sonic/interface.py.
Test targets
detect_breakout_ports(device) — interface.py:603
Patch osism.tasks.conductor.sonic.interface.get_cached_device_interfaces and the local get_port_config. Build interfaces with types.SimpleNamespace; type values via SimpleNamespace(value="...").
Early exits / error paths
- Device without
custom_fields["sonic_parameters"]["hwsku"] → returns {"breakout_cfgs": {}, "breakout_ports": {}}, warning logged
get_port_config returns {} → returns empty result
get_port_config raises → returns empty result
get_cached_device_interfaces raises → returns empty result, warning logged
NetBox-format breakout (Eth1/49/1..4)
- Single subport in group → not a breakout, no entries
- 4-subport 25G group →
breakout_cfgs[master_port] = {brkout_mode: "4x25G", port: "1/49", breakout_owner: "MANUAL"}, four breakout_ports[Ethernet*] entries with master
- 4-subport 50G group →
brkout_mode: "4x50G"
- 4-subport 100G group →
brkout_mode: "4x100G"
- 4-subport 200G group →
brkout_mode: "4x200G"
- 4-subport 10G group →
brkout_mode: "4x10G"
- Speed not on
interface.speed but resolvable via interface.type.value → still works
- Unsupported speed (e.g.
40000, 4 subports) → group skipped (continue), no entries
- Master port has
lanes="1,2,3,4,5,6,7,8" (8 lanes) → offset multiplier 2, breakout port numbers go Ethernet0/2/4/6 not Ethernet0/1/2/3
- Same group key (
module/port) appears twice in interface list → only processed once (processed_groups)
SONiC-format 400G breakout (8-lane master)
Ethernet0,2,4,6 all at 100000 Mbps with master Ethernet0 having 8 lanes → detected as 4x100G, port = "1/1"
- Same for
Ethernet8,10,12,14 (master Ethernet8 with 8 lanes) → port = "1/2"
- 8-lane master but interfaces not at
100000 → not detected
- Master
Ethernet0 with only 4 lanes → not treated as 400G; falls through to standard pattern
SONiC-format standard breakout (Ethernet0..3)
- Four consecutive interfaces at
25000 Mbps → 4x25G
- Four consecutive interfaces at
50000 Mbps → 4x50G
- Four interfaces at
100000 Mbps (not breakout — too fast) → skipped (≤ 50G filter)
- Three interfaces only → not a group
- Speed derived from
interface.type.value → still works
detect_port_channels(device) — interface.py:950
Patch osism.tasks.conductor.sonic.interface.get_cached_device_interfaces and convert_netbox_interface_to_sonic (or inject HWSKU + port_config so the conversion works without further mocking).
Early exits
get_cached_device_interfaces raises → returns {"portchannels": {}, "member_mapping": {}}, warning logged
- No LAG interfaces and no
interface.lag → returns empty dicts
LAG name regex variants (re.match against lag_parent.name)
"PortChannel1" → PortChannel1
"Port-Channel2" → PortChannel2
"LAG3" → PortChannel3
"ae4" → PortChannel4
"bond5" → PortChannel5
"po-uplink-7" (number anywhere) → PortChannel7 (numeric fallback)
"trunk" (no number) and the LAG is in lag_interfaces at index 0 → PortChannel1
"trunk" and the LAG is not in lag_interfaces → PortChannel1 (string "1" fallback)
- Mixed case (
"portchannel99", "PORT-CHANNEL10") → matched case-insensitively
Members & ordering
- One LAG with two members →
members is sorted by numeric suffix (Ethernet120, Ethernet124 → ["Ethernet120", "Ethernet124"])
- Same SONiC name added twice → de-duplicated (membership check before append)
- Default
PortChannel config dict: admin_status="up", fast_rate="true", min_links="1", mtu="9100"
member_mapping populated (interface name → port channel name)
Mocking hints
- Build interfaces inline:
iface = SimpleNamespace(
name="Eth1/49/1",
id=1,
mgmt_only=False,
type=SimpleNamespace(value="100gbase-x-qsfp28"),
speed=100000,
)
- For LAG members, set
iface.lag = SimpleNamespace(name="PortChannel1", id=99).
port_config for detect_breakout_ports must contain the master port (Ethernet0 etc.) with the right lanes string. Build it inline.
- For SONiC-format breakout tests, the
interfaces list contains SimpleNamespace(name="Ethernet0", ...) etc.; the production code matches them via re.match(r"Ethernet(\d+)", ...).
- Cover both code paths in
processed_groups for the SONiC 400G branch (continue on success).
Definition of Done
Dependencies
Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 3 (#2199). Companion to the interface-conversion issue: covers the two large topology-detection functions in
osism/tasks/conductor/sonic/interface.py. They are split out because each is several hundred lines of NetBox-driven loop logic with multiple branches (standard breakout, 400G breakout, LAG name regex variants).Scope
Add
tests/unit/tasks/conductor/sonic/test_interface_detection.pycoveringdetect_breakout_portsanddetect_port_channelsinosism/tasks/conductor/sonic/interface.py.Test targets
detect_breakout_ports(device)—interface.py:603Patch
osism.tasks.conductor.sonic.interface.get_cached_device_interfacesand the localget_port_config. Build interfaces withtypes.SimpleNamespace; type values viaSimpleNamespace(value="...").Early exits / error paths
custom_fields["sonic_parameters"]["hwsku"]→ returns{"breakout_cfgs": {}, "breakout_ports": {}}, warning loggedget_port_configreturns{}→ returns empty resultget_port_configraises → returns empty resultget_cached_device_interfacesraises → returns empty result, warning loggedNetBox-format breakout (
Eth1/49/1..4)breakout_cfgs[master_port] = {brkout_mode: "4x25G", port: "1/49", breakout_owner: "MANUAL"}, fourbreakout_ports[Ethernet*]entries withmasterbrkout_mode: "4x50G"brkout_mode: "4x100G"brkout_mode: "4x200G"brkout_mode: "4x10G"interface.speedbut resolvable viainterface.type.value→ still works40000, 4 subports) → group skipped (continue), no entrieslanes="1,2,3,4,5,6,7,8"(8 lanes) → offset multiplier2, breakout port numbers goEthernet0/2/4/6notEthernet0/1/2/3module/port) appears twice in interface list → only processed once (processed_groups)SONiC-format 400G breakout (8-lane master)
Ethernet0,2,4,6all at100000Mbps with masterEthernet0having 8 lanes → detected as4x100G,port="1/1"Ethernet8,10,12,14(masterEthernet8with 8 lanes) →port="1/2"100000→ not detectedEthernet0with only 4 lanes → not treated as 400G; falls through to standard patternSONiC-format standard breakout (
Ethernet0..3)25000Mbps →4x25G50000Mbps →4x50G100000Mbps (not breakout — too fast) → skipped (≤ 50G filter)interface.type.value→ still worksdetect_port_channels(device)—interface.py:950Patch
osism.tasks.conductor.sonic.interface.get_cached_device_interfacesandconvert_netbox_interface_to_sonic(or inject HWSKU + port_config so the conversion works without further mocking).Early exits
get_cached_device_interfacesraises → returns{"portchannels": {}, "member_mapping": {}}, warning loggedinterface.lag→ returns empty dictsLAG name regex variants (
re.matchagainstlag_parent.name)"PortChannel1"→PortChannel1"Port-Channel2"→PortChannel2"LAG3"→PortChannel3"ae4"→PortChannel4"bond5"→PortChannel5"po-uplink-7"(number anywhere) →PortChannel7(numeric fallback)"trunk"(no number) and the LAG is inlag_interfacesat index 0 →PortChannel1"trunk"and the LAG is not inlag_interfaces→PortChannel1(string"1"fallback)"portchannel99","PORT-CHANNEL10") → matched case-insensitivelyMembers & ordering
membersis sorted by numeric suffix (Ethernet120,Ethernet124→["Ethernet120", "Ethernet124"])PortChannelconfig dict:admin_status="up",fast_rate="true",min_links="1",mtu="9100"member_mappingpopulated (interface name → port channel name)Mocking hints
iface.lag = SimpleNamespace(name="PortChannel1", id=99).port_configfordetect_breakout_portsmust contain the master port (Ethernet0etc.) with the rightlanesstring. Build it inline.interfaceslist containsSimpleNamespace(name="Ethernet0", ...)etc.; the production code matches them viare.match(r"Ethernet(\d+)", ...).processed_groupsfor the SONiC 400G branch (continueon success).Definition of Done
tests/unit/tasks/conductor/sonic/test_interface_detection.pycreatedpytest --cov=osism.tasks.conductor.sonic.interfacefor the two target functions ≥ 90 %pipenv run pytest tests/unit/tasks/conductor/sonic/test_interface_detection.pypasses locallyflake8,mypy,python-blackremain greenpython-osism-unit-testspassesDependencies
interface.pyis split into a separate sub-issue.