Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 3 (#2199). Companion issue to the config_generator.py orchestrator and BGP/VLAN/Loopback/VRF issues. Covers the port / interface / portchannel / breakout-related helpers in osism/tasks/conductor/sonic/config_generator.py.
These helpers all mutate a config dict that is passed by reference. Tests build a small initial scaffold ({"PORT": {}, "INTERFACE": {}, "PORTCHANNEL": {}, ...}) and assert against the post-call state.
Scope
Add tests/unit/tasks/conductor/sonic/test_config_generator_ports.py covering the helpers below in osism/tasks/conductor/sonic/config_generator.py.
Test targets
_add_port_configurations(config, port_config, connected_interfaces, portchannel_info, breakout_info, netbox_interfaces, vlan_info, device) — config_generator.py:314
Patch osism.tasks.conductor.sonic.config_generator.convert_sonic_interface_to_alias to return a deterministic alias, or pass a real port_config so the alias logic works end to end.
- Sorted iteration: ports
["Ethernet0", "Ethernet4", "Ethernet120"] are processed in numeric order (verify by recording convert_sonic_interface_to_alias call sequence)
- Master port present in
breakout_cfgs → skipped (the master port is not added to config["PORT"] directly)
admin_status = "up" when port in connected_interfaces or in portchannel_info["member_mapping"], else "down"
- NetBox speed (
speed_explicit=True) overrides port-config speed; conversion kbps → Mbps (netbox_speed=100000 → port_speed="100")
- NetBox speed with
speed_explicit=False only used if port_speed is empty / "0"
- Breakout port: speed taken from
netbox_interfaces if present, otherwise derived from brkout_mode ("4x10G" → 10000, "4x25G" → 25000, "4x50G" → 50000, "4x100G" → 100000, "4x200G" → 200000)
- Port index of breakout port copied from master port's index
- Default
port_data keys: admin_status, alias, index, lanes, speed, mtu="9100", adv_speeds="all", autoneg="off", link_training="off", unreliable_los="auto"
port_info["valid_speeds"] propagated as-is
- No
valid_speeds and port_speed set → valid_speeds = port_speed
- Breakout port:
valid_speeds is overridden via _get_breakout_port_valid_speeds
- After main loop: calls
_add_missing_breakout_ports and _add_tagged_vlans_to_ports (verify via patched mocks)
_get_breakout_port_valid_speeds(port_speed) — config_generator.py:467
Pure function — quick coverage:
"10000" → "10000,1000"
"25000" → "25000,10000,1000"
"50000" → "50000,25000,10000,1000"
"100000" → "100000,50000,25000,10000,1000"
"200000" → "200000,100000,50000,25000,10000,1000"
"40000" (other) → "40000,10000,1000"
None / "" → None
_calculate_breakout_port_lane(port_name, master_port, port_config) — config_generator.py:489
- Standard 4-lane: master
Ethernet0 lanes "1,2,3,4", port Ethernet2 → "3"
- 8-lane (400G): master
Ethernet0 lanes "73,74,75,76,77,78,79,80", port Ethernet2 → "75,76", Ethernet6 → "79,80"
- Range syntax: master lanes
"1-4" → parsed via start-end branch
- Single-lane master (
"5") → defaults to lanes_per_port=1, returns single lane
- Unexpected lane count (e.g. 6) → warning logged,
lanes_per_port=1
- Calculated range out of bounds → warning logged, returns
"1" (default)
master_port not in port_config → returns "1"
- Port name regex no-match → returns
"1"
_add_missing_breakout_ports(config, breakout_info, port_config, connected_interfaces, portchannel_info, netbox_interfaces) — config_generator.py:578
- Breakout port already in
config["PORT"] → skipped
- Speed taken from
netbox_interfaces when present (no kbps→Mbps conversion here, just str(netbox_speed) — verify the production code)
- Speed fallback via
brkout_mode ("4x25G" etc.) and ultimate default "25000"
admin_status follows connected_interfaces / port-channel membership
port_index defaults to "1", copied from master port if available
valid_speeds from master port's port_config[master]["valid_speeds"], then overridden by _get_breakout_port_valid_speeds(port_speed)
- Calls
convert_sonic_interface_to_alias(..., is_breakout=True, port_config=port_config) (one assertion is fine)
_add_tagged_vlans_to_ports(config, vlan_info, netbox_interfaces, device) — config_generator.py:668
- Port has multiple tagged VLANs in NetBox →
config["PORT"][port]["tagged_vlans"] is the sorted list of VLAN IDs (numeric sort, e.g. ["10", "100", "20"] → ["10", "20", "100"])
- Untagged VLAN members not added
- NetBox interface name without a SONiC mapping → skipped, warning logged
- Port present in mapping but absent from
config["PORT"] → silently skipped (only existing PORT entries are updated)
_add_interface_configurations(config, connected_interfaces, portchannel_info, interface_ips, netbox_interfaces, device) — config_generator.py:702
- Connected port not in port-channel and with IPv4 in
interface_ips → config["INTERFACE"][port] = {} and config["INTERFACE"][f"{port}|{ip}"] = {}
- Connected port without IPv4 →
config["INTERFACE"][port] = {"ipv6_use_link_local_only": "enable"}
- Port that is a port-channel member (in
portchannel_info["member_mapping"]) → skipped
- Disconnected port → skipped
- Port in
connected_interfaces but not in netbox_interfaces → netbox_interface_name None; falls into the no-IPv4 branch
_get_transfer_role_ipv4_addresses(device) — config_generator.py:747
Patch osism.tasks.conductor.sonic.config_generator.utils.nb, ...get_cached_device_interfaces.
transfer prefix "10.5.0.0/24", IP 10.5.0.10/24 on Eth1/1 → {"Eth1/1": "10.5.0.10/24"}
- Multiple IPs on the same interface → only the first transfer-matching IP is kept (
if interface.name in transfer_ips: continue)
- IPv6 IP → ignored (only
version == 4)
- Mgmt-only or virtual interfaces → skipped (filtered out of
interface_map)
- Invalid prefix string → caught, processing continues
- Invalid IP address → caught, processing continues
- IP without
assigned_object_id → skipped
- IP with
assigned_object_id not in interface_map → skipped
- Top-level exception → returns
{}, warning logged
_has_direct_ipv4_address(port_name, interface_ips, netbox_interfaces) — config_generator.py:836
- Port mapped, NetBox name in
interface_ips → True
- Port mapped, NetBox name not in
interface_ips → False
- Port not in
netbox_interfaces → False
interface_ips empty / None → False
netbox_interfaces empty / None → False
_has_transfer_role_ipv4(port_name, transfer_ips, netbox_interfaces) — config_generator.py:857
Same shape as the helper above; one happy path + the empty-input early returns is enough.
_is_untagged_vlan_member(port_name, vlan_info, netbox_interfaces) — config_generator.py:878
- Port has untagged VLAN membership →
True
- Port has only tagged VLAN membership →
False
- Port not in
netbox_interfaces → False
vlan_info empty / None → False
_add_portchannel_configuration(config, portchannel_info) — config_generator.py:2078
- One PortChannel with two members →
PORTCHANNEL, PORTCHANNEL_INTERFACE (with ipv6_use_link_local_only), and per-member PORTCHANNEL_MEMBER entries created
- Empty
portchannels dict → no entries added (no exception)
Mocking hints
- Initialize
config with the keys the helpers expect to mutate. A factory fixture is helpful:
@pytest.fixture
def config():
return {
"PORT": {}, "INTERFACE": {}, "PORTCHANNEL": {}, "PORTCHANNEL_INTERFACE": {},
"PORTCHANNEL_MEMBER": {}, "BREAKOUT_CFG": {}, "BREAKOUT_PORTS": {},
}
- For
_add_port_configurations, patching _add_missing_breakout_ports and _add_tagged_vlans_to_ports keeps each test focused on the main-loop branches.
- Build
port_config and breakout_info inline. breakout_info is {"breakout_cfgs": {...}, "breakout_ports": {...}}.
netbox_interfaces shape: {sonic_name: {"speed": int|None, "speed_explicit": bool, "tags": [...], "type": str|None, "netbox_name": str}}.
Definition of Done
Dependencies
Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 3 (#2199). Companion issue to the
config_generator.pyorchestrator and BGP/VLAN/Loopback/VRF issues. Covers the port / interface / portchannel / breakout-related helpers inosism/tasks/conductor/sonic/config_generator.py.These helpers all mutate a
configdict that is passed by reference. Tests build a small initial scaffold ({"PORT": {}, "INTERFACE": {}, "PORTCHANNEL": {}, ...}) and assert against the post-call state.Scope
Add
tests/unit/tasks/conductor/sonic/test_config_generator_ports.pycovering the helpers below inosism/tasks/conductor/sonic/config_generator.py.Test targets
_add_port_configurations(config, port_config, connected_interfaces, portchannel_info, breakout_info, netbox_interfaces, vlan_info, device)—config_generator.py:314Patch
osism.tasks.conductor.sonic.config_generator.convert_sonic_interface_to_aliasto return a deterministic alias, or pass a realport_configso the alias logic works end to end.["Ethernet0", "Ethernet4", "Ethernet120"]are processed in numeric order (verify by recordingconvert_sonic_interface_to_aliascall sequence)breakout_cfgs→ skipped (the master port is not added toconfig["PORT"]directly)admin_status="up"when port inconnected_interfacesor inportchannel_info["member_mapping"], else"down"speed_explicit=True) overrides port-config speed; conversion kbps → Mbps (netbox_speed=100000→port_speed="100")speed_explicit=Falseonly used ifport_speedis empty /"0"netbox_interfacesif present, otherwise derived frombrkout_mode("4x10G"→10000,"4x25G"→25000,"4x50G"→50000,"4x100G"→100000,"4x200G"→200000)port_datakeys:admin_status,alias,index,lanes,speed,mtu="9100",adv_speeds="all",autoneg="off",link_training="off",unreliable_los="auto"port_info["valid_speeds"]propagated as-isvalid_speedsandport_speedset →valid_speeds = port_speedvalid_speedsis overridden via_get_breakout_port_valid_speeds_add_missing_breakout_portsand_add_tagged_vlans_to_ports(verify via patched mocks)_get_breakout_port_valid_speeds(port_speed)—config_generator.py:467Pure function — quick coverage:
"10000"→"10000,1000""25000"→"25000,10000,1000""50000"→"50000,25000,10000,1000""100000"→"100000,50000,25000,10000,1000""200000"→"200000,100000,50000,25000,10000,1000""40000"(other) →"40000,10000,1000"None/""→None_calculate_breakout_port_lane(port_name, master_port, port_config)—config_generator.py:489Ethernet0lanes"1,2,3,4", portEthernet2→"3"Ethernet0lanes"73,74,75,76,77,78,79,80", portEthernet2→"75,76",Ethernet6→"79,80""1-4"→ parsed viastart-endbranch"5") → defaults tolanes_per_port=1, returns single lanelanes_per_port=1"1"(default)master_portnot inport_config→ returns"1""1"_add_missing_breakout_ports(config, breakout_info, port_config, connected_interfaces, portchannel_info, netbox_interfaces)—config_generator.py:578config["PORT"]→ skippednetbox_interfaceswhen present (no kbps→Mbps conversion here, juststr(netbox_speed)— verify the production code)brkout_mode("4x25G"etc.) and ultimate default"25000"admin_statusfollowsconnected_interfaces/ port-channel membershipport_indexdefaults to"1", copied from master port if availablevalid_speedsfrom master port'sport_config[master]["valid_speeds"], then overridden by_get_breakout_port_valid_speeds(port_speed)convert_sonic_interface_to_alias(..., is_breakout=True, port_config=port_config)(one assertion is fine)_add_tagged_vlans_to_ports(config, vlan_info, netbox_interfaces, device)—config_generator.py:668config["PORT"][port]["tagged_vlans"]is the sorted list of VLAN IDs (numeric sort, e.g.["10", "100", "20"]→["10", "20", "100"])config["PORT"]→ silently skipped (only existing PORT entries are updated)_add_interface_configurations(config, connected_interfaces, portchannel_info, interface_ips, netbox_interfaces, device)—config_generator.py:702interface_ips→config["INTERFACE"][port] = {}andconfig["INTERFACE"][f"{port}|{ip}"] = {}config["INTERFACE"][port] = {"ipv6_use_link_local_only": "enable"}portchannel_info["member_mapping"]) → skippedconnected_interfacesbut not innetbox_interfaces→netbox_interface_nameNone; falls into the no-IPv4 branch_get_transfer_role_ipv4_addresses(device)—config_generator.py:747Patch
osism.tasks.conductor.sonic.config_generator.utils.nb,...get_cached_device_interfaces.transferprefix"10.5.0.0/24", IP10.5.0.10/24onEth1/1→{"Eth1/1": "10.5.0.10/24"}if interface.name in transfer_ips: continue)version == 4)interface_map)assigned_object_id→ skippedassigned_object_idnot ininterface_map→ skipped{}, warning logged_has_direct_ipv4_address(port_name, interface_ips, netbox_interfaces)—config_generator.py:836interface_ips→Trueinterface_ips→Falsenetbox_interfaces→Falseinterface_ipsempty /None→Falsenetbox_interfacesempty /None→False_has_transfer_role_ipv4(port_name, transfer_ips, netbox_interfaces)—config_generator.py:857Same shape as the helper above; one happy path + the empty-input early returns is enough.
_is_untagged_vlan_member(port_name, vlan_info, netbox_interfaces)—config_generator.py:878TrueFalsenetbox_interfaces→Falsevlan_infoempty /None→False_add_portchannel_configuration(config, portchannel_info)—config_generator.py:2078PORTCHANNEL,PORTCHANNEL_INTERFACE(withipv6_use_link_local_only), and per-memberPORTCHANNEL_MEMBERentries createdportchannelsdict → no entries added (no exception)Mocking hints
configwith the keys the helpers expect to mutate. A factory fixture is helpful:_add_port_configurations, patching_add_missing_breakout_portsand_add_tagged_vlans_to_portskeeps each test focused on the main-loop branches.port_configandbreakout_infoinline.breakout_infois{"breakout_cfgs": {...}, "breakout_ports": {...}}.netbox_interfacesshape:{sonic_name: {"speed": int|None, "speed_explicit": bool, "tags": [...], "type": str|None, "netbox_name": str}}.Definition of Done
tests/unit/tasks/conductor/sonic/test_config_generator_ports.pycreatedpytest --cov=osism.tasks.conductor.sonic.config_generatorfor the targeted functions ≥ 90 %pipenv run pytest tests/unit/tasks/conductor/sonic/test_config_generator_ports.pypasses locallyflake8,mypy,python-blackremain greenpython-osism-unit-testspassesDependencies