Files

328 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Interface Speed Duplex (Copper)
Verify that the interface operates at the expected speed/duplex by
restricting the set of PMD types auto-negotiation may advertise:
1. Single-mode advertise — host and target both advertise exactly
one PMD/duplex combination; the link comes up at that mode.
2. Multi-mode advertise — host advertises a set, target advertises
another set; the link comes up at the highest common.
3. Auto-negotiation off — both peers forced to a fixed speed/duplex,
the escape hatch for link partners that don't speak auto-negotiation.
Disabling autoneg also disables Auto-MDIX, so the ends use opposite
MDI/MDI-X pinouts (host mdix on, DUT mdi). The link bounces and can
take a few seconds to settle, so the forced steps poll for it.
The legacy "auto-negotiation: off + fixed speed + fixed duplex"
configuration was retired together with the obsoletion of eth:speed
in IEEE Std 802.3.2-2025; the standards-correct way to pin a link to
a specific mode is to advertise only that mode. See the augment in
infix-ethernet-interface.yang.
"""
import infamy
import infamy.iface as iface
import subprocess
from infamy.util import until
ADVERTISE_MODES = {
# Values from ethtool's ETHTOOL_LINK_MODE bit positions
# See: https://elixir.bootlin.com/linux/latest/source/include/uapi/linux/ethtool.h
"10half": 0x0001,
"10full": 0x0002,
"100half": 0x0004,
"100full": 0x0008,
"1000full": 0x0020,
}
# Mapping from (ethtool advertise key) → (IEEE PMD identity, duplex enum).
# Mirrors the migration table in src/confd/share/migrate/1.9/.
TARGET_MODES = {
"10half": ("ieee802-ethernet-phy-type:pmd-type-10BASE-T", "half", 10),
"10full": ("ieee802-ethernet-phy-type:pmd-type-10BASE-T", "full", 10),
"100half": ("ieee802-ethernet-phy-type:pmd-type-100BASE-TX", "half", 100),
"100full": ("ieee802-ethernet-phy-type:pmd-type-100BASE-TX", "full", 100),
"1000full": ("ieee802-ethernet-phy-type:pmd-type-1000BASE-T", "full", 1000),
}
def advertise_host_modes(interface, modes):
mask = 0
for mode in modes:
mask |= ADVERTISE_MODES[mode]
try:
subprocess.run(["ethtool", "-s", interface, "advertise", hex(mask)],
check=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to advertise modes via ethtool: {e}")
def enable_host_autoneg_full(interface):
"""Restore the host NIC to autoneg-on with Auto-MDIX.
'autoneg on' re-enables negotiation and advertises all supported modes;
each test step sets the host advertise explicitly, so no extra mask
handling is needed here. Separately clear any forced MDI/MDI-X pinout
left by force_host_fixed — best-effort, since not every NIC exposes mdix.
"""
try:
subprocess.run(["ethtool", "-s", interface, "autoneg", "on"], check=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to restore host autoneg: {e}")
subprocess.run(["ethtool", "-s", interface, "mdix", "auto"], check=False)
def force_host_fixed(interface, speed_mbps, duplex, mdix="on"):
"""Pin host to a fixed speed/duplex with auto-negotiation OFF.
Auto-MDIX is disabled in forced mode, so host and DUT take opposite
pinouts; default mdix=on (MDI-X) to pair with the DUT's mdi.
"""
try:
subprocess.run(["ethtool", "-s", interface, "autoneg", "off",
"speed", str(speed_mbps), "duplex", duplex,
"mdix", mdix],
check=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to force host fixed mode: {e}")
def get_target_speed_duplex(target, interface):
data = target.get_data(iface.get_xpath(interface)) \
["interfaces"]["interface"][interface]
eth = data.get("ethernet", {})
# Operational speed under ietf-interfaces:speed (bits/s, RFC 8343).
speed_bps = data.get("speed")
return speed_bps, eth.get("duplex")
def set_target_advertise(target, interface, mode_keys):
"""Configure the target interface to advertise the given set of PMDs.
mode_keys is a list of host-side mode names (e.g. ['10full', '100full']).
Translates to the corresponding (pmd-type, duplex) for the target.
When all entries share the same duplex, that duplex is also set on the
interface as an additional restriction.
"""
pmds = []
duplexes = set()
for key in mode_keys:
pmd, duplex, _ = TARGET_MODES[key]
if pmd not in pmds:
pmds.append(pmd)
duplexes.add(duplex)
eth = {
"auto-negotiation": {
"infix-ethernet-interface:advertised-pmd-types": pmds,
},
}
if len(duplexes) == 1:
eth["duplex"] = duplexes.pop()
target.put_config_dicts({
"ietf-interfaces": {
"interfaces": {
"interface": [{"name": interface, "ethernet": eth}]
}
}
})
def force_target_fixed(target, interface, mode_key, mdi_x=False):
"""Configure the target with auto-negotiation disabled, pinned to one PMD.
Exercises confd's enable=false escape hatch: the apply path emits
'ethtool --change <if> autoneg off speed N duplex D mdix ...' from the
single advertised-pmd-types entry, the duplex leaf, and the mdi-x leaf.
Default mdi_x=False (MDI) pairs with the host's forced MDI-X.
"""
pmd, duplex, _ = TARGET_MODES[mode_key]
# enable=false requires exactly one advertised-pmd-types entry, but
# put_config_dicts (PATCH) merges leaf-lists, so successive forced
# steps would accumulate entries. Reset the ethernet sub-config first
# (tolerate absence on the first call).
try:
clear_target_advertise(target, interface)
except Exception:
pass
target.put_config_dicts({
"ietf-interfaces": {
"interfaces": {
"interface": [{
"name": interface,
"ethernet": {
"auto-negotiation": {
"enable": False,
"infix-ethernet-interface:advertised-pmd-types": [pmd],
},
"duplex": duplex,
"infix-ethernet-interface:mdi-x": mdi_x,
},
}]
}
}
})
def clear_target_advertise(target, interface):
"""Drop the entire ethernet sub-config on the target.
Targets the parent container rather than the individual leaves because
RESTCONF DELETE on a leaf-list URI without a '=value' key is a silent
no-op (RFC 8040 §3.5.3.2: leaf-list instance URIs must carry the value
as the key — rousette/libyang materialise an empty leaf-list node that
matches no entry). Deleting the container removes all descendants in
one go, which is exactly what this test needs.
"""
target.delete_xpath(iface.get_xpath(interface, "ieee802-ethernet-interface:ethernet"))
def _settle_speed_duplex(target, ns, interface, exp_mbps, exp_duplex):
"""Poll until the link reaches exp speed/duplex AND carries traffic.
Any advertisement change (either peer) bounces the link, and while it
renegotiates the operational data transiently shows the previous
values or none at all -- a one-shot read races both ways. Poll the
expected speed/duplex and a ping together until all hold.
"""
def settled():
speed_bps, duplex = get_target_speed_duplex(target, interface)
if not speed_bps or not duplex:
return False
if int(speed_bps) // 1_000_000 != exp_mbps:
return False
if duplex.lower() != exp_duplex.lower():
return False
try:
ns.ping("10.0.0.2", timeout=3)
except Exception:
return False
return True
until(settled, attempts=30)
def verify_speed_duplex(target, ns, interface, exp_mbps, exp_duplex):
_settle_speed_duplex(target, ns, interface, exp_mbps, exp_duplex)
print(f"Verified: {interface} is operating at {exp_mbps} Mbps, {exp_duplex} duplex")
def verify_forced_speed_duplex(target, ns, interface, exp_mbps, exp_duplex):
"""Verify a forced (autoneg off) link reaches exp speed/duplex."""
_settle_speed_duplex(target, ns, interface, exp_mbps, exp_duplex)
print(f"Verified: {interface} forced to {exp_mbps} Mbps, {exp_duplex} duplex")
def enable_target_interface(target, interface):
target.put_config_dicts({
"ietf-interfaces": {
"interfaces": {
"interface": [{
"name": interface,
"enabled": True,
"ipv4": {"address": [{"ip": "10.0.0.2", "prefix-length": 24}]}
}]
}
}
})
def cleanup(target, hdata, tdata):
"""Restore both host and target to 'advertise everything supported'."""
print("Restoring interfaces to default (advertise all)")
try:
enable_host_autoneg_full(hdata)
except Exception as e:
print(f"Host restore failed: {e}")
try:
enable_target_interface(target, tdata)
clear_target_advertise(target, tdata)
except Exception as e:
print(f"Target restore failed: {e}")
with infamy.Test() as test:
with test.step("Set up topology and attach to target DUT"):
env = infamy.Env()
target = env.attach("target", "mgmt")
_, hdata = env.ltop.xlate("host", "data")
_, tdata = env.ltop.xlate("target", "data")
test.push_test_cleanup(lambda: cleanup(target, hdata, tdata))
with test.step("Enable target interface"):
enable_target_interface(target, tdata)
with infamy.IsolatedMacVlan(hdata) as ns:
ns.addip("10.0.0.1")
# Pinned-mode tests: both peers advertise exactly one mode.
with test.step("Advertise 10/full only on both peers"):
advertise_host_modes(hdata, ["10full"])
set_target_advertise(target, tdata, ["10full"])
verify_speed_duplex(target, ns, tdata, 10, "full")
with test.step("Advertise 10/half only on both peers"):
advertise_host_modes(hdata, ["10half"])
set_target_advertise(target, tdata, ["10half"])
verify_speed_duplex(target, ns, tdata, 10, "half")
with test.step("Advertise 100/full only on both peers"):
advertise_host_modes(hdata, ["100full"])
set_target_advertise(target, tdata, ["100full"])
verify_speed_duplex(target, ns, tdata, 100, "full")
with test.step("Advertise 100/half only on both peers"):
advertise_host_modes(hdata, ["100half"])
set_target_advertise(target, tdata, ["100half"])
verify_speed_duplex(target, ns, tdata, 100, "half")
# Multi-mode advertise tests: host restricted, target advertises all.
with test.step("Switch target back to advertising all supported modes"):
advertise_host_modes(hdata, ["100half"])
clear_target_advertise(target, tdata)
verify_speed_duplex(target, ns, tdata, 100, "half")
with test.step("Host advertises 10/full only"):
advertise_host_modes(hdata, ["10full"])
verify_speed_duplex(target, ns, tdata, 10, "full")
with test.step("Host advertises 10/half only"):
advertise_host_modes(hdata, ["10half"])
verify_speed_duplex(target, ns, tdata, 10, "half")
with test.step("Host advertises 100/full only"):
advertise_host_modes(hdata, ["100full"])
verify_speed_duplex(target, ns, tdata, 100, "full")
with test.step("Host advertises 100/half only"):
advertise_host_modes(hdata, ["100half"])
verify_speed_duplex(target, ns, tdata, 100, "half")
with test.step("Host advertises 10/half + 10/full + 100/half"):
advertise_host_modes(hdata, ["10half", "10full", "100half"])
verify_speed_duplex(target, ns, tdata, 100, "half")
with test.step("Host advertises every mode up to 1G"):
advertise_host_modes(hdata, ["10half", "10full", "100half",
"100full", "1000full"])
verify_speed_duplex(target, ns, tdata, 1000, "full")
# Auto-negotiation off — escape hatch for non-autoneg peers. Both
# ends forced to a fixed mode with autoneg disabled (host mdix on,
# DUT mdi). The link bounces, so verify_forced_speed_duplex polls
# until it settles.
with test.step("Both sides forced to 100/full, autoneg off"):
force_host_fixed(hdata, 100, "full")
force_target_fixed(target, tdata, "100full")
verify_forced_speed_duplex(target, ns, tdata, 100, "full")
with test.step("Both sides forced to 10/full, autoneg off"):
force_host_fixed(hdata, 10, "full")
force_target_fixed(target, tdata, "10full")
verify_forced_speed_duplex(target, ns, tdata, 10, "full")
test.succeed()