Files
Joachim Wiberg 7fa141178f cli: layered show interface, IEEE 802.3.2-2025 ethernet model
Import ieee802-ethernet-interface@2025-09-10.yang, from IEEE 802.3.2-2025,
and companion ieee802-ethernet-phy-type.yang, replacing the 2019 revision.
Operational interface speed moves to ietf-interfaces:speed, RFC 8343,
eth:speed is now obsolete.  Bump infix-interfaces.yang revision to track
the upgrade.

The CLI 'show interface' command renders interfaces as bottom-up layered
protocol rows.  Ethernet interfaces a new IEEE link-mode row (1000baseT,
10GbaseLR, ...) is added with 'duplex: full|half' in the DATA column.
Without link the ethernet row is the first row.  Tunnels and wifi follow
the same layering: gre/vxlan rows carry remote:/vni: tokens with optional
ethernet sub-row for the L2-bearing variants; wifi rows carry 'station' or
'access-point' ssid:, signal:, stations: tokens.

admin@bpi-76-8f-c2:/> show interface
⚑ INTERFACE   PROTOCOL      STATE       DATA
⇅ lo          loopback      UP
              ipv4                      127.0.0.1/8 (static)
              ipv6                      ::1/128 (static)
⇅ br0         bridge        DOWN
  │           ethernet                  32:2e:3f:76:8f:c2
  │           ipv4                      192.168.0.1/24 (static)
⇅ ├ lan0      bridge        LOWER-DOWN
⇅ ├ lan1      bridge        LOWER-DOWN
⇅ ├ lan2      bridge        LOWER-DOWN
⇅ └ lan3      bridge        LOWER-DOWN
⇅ gre0        gre           UP          remote: 198.51.100.7
              ipv4                      10.255.0.1/30 (static)
⇅ sfp1        ethernet      DOWN        ca:59:f0:77:80:5b
⇅ wan         1000baseT     UP          duplex: full
              ethernet                  32:2e:3f:76:8f:c2
              ipv4                      192.168.0.235/24 (dhcp)
              ipv6                      fe80::302e:3fff:fe76:8fc2/64 (link-layer)
⇅ wan.10      vlan          UP          vid: 10
  │           ipv4                      10.0.10.1/24 (static)
  └ wan
⇅ wan.10.20   vlan          UP          vid: 20
  │           ipv4                      10.0.10.20/28 (static)
  └ wan.10
⇅ wifi0       wifi          UP          station ssid: corp-net signal: good
              ethernet                  dc:a6:32:00:11:22
              ipv4                      192.168.7.42/24 (dhcp)

yanger populates phy-type and pmd-type from a static ETHTOOL link-mode
identity table keyed on (port, speed, duplex).  Both leaves are populated
for unambiguous media (copper, DAC, copper-T); for generic fiber where
the (port, speed, duplex) tuple can map to multiple PMDs (SR vs LR vs
ER, etc.) only the phy-type family identity is emitted — guessing the
PMD would be misleading.  cli_pretty prefers pmd-type (specific) and
falls back to phy-type (family) for display.

yanger also emits ietf-interfaces:speed and fixes a long-standing
'auto-negotation' typo (yanger had never matched ethtool's correctly-
spelled JSON field, so operational auto-negotiation always read
'unknown').

Filling the WG's gap on fixed-speed config
------------------------------------------

IEEE Std 802.3.2-2025 obsoleted the eth:speed leaf without providing a
config-true replacement; the standards-correct way to express what
older Infix configurations called 'fixed speed' is to restrict the set
of PMDs auto-negotiation may advertise.  Augment auto-negotiation with
two leaf-lists:

  auto-negotiation/advertised-pmd-types  config-true,  leaf-list of pmd-type
  ethernet/supported-pmd-types           config-false, leaf-list of pmd-type

Pinning a port to a single mode is now expressed as a single-entry
advertised-pmd-types list.  The kernel-supported set is exposed as
operational state for diagnosis — SFP/SFP+ cages report the inserted
module's capabilities, narrowing the list to a single PMD when the
optic only supports one mode (as suggested by @wkz in PR review).
When the supported list collapses to a single entry, yanger uses it
to refine the operational pmd-type leaf — more accurate than the
(port, speed, duplex) lookup for fiber.

confd C apply path (src/confd/src/ieee802-ethernet-interface.c)
translates each advertised pmd-type identity to the corresponding
ETHTOOL_LINK_MODE_*_BIT_*, ORs them into a mask, and emits
'ethtool --change <if> autoneg on advertise 0x<mask>'.  An empty
list keeps the historical default of advertising every supported
mode.  The (legacy) 'auto-negotiation/enable = false' + speed/duplex
branch is removed — it has no model representation any more.

cli_pretty's detail view gains 'advertised' and 'supported' rows
listing the PMDs in each set.  A new _pr_label_list() helper unifies
multi-row rendering across these new rows and the pre-existing
ipv4/ipv6 address rows.

The detailed interface view also gains a 'link mode' row carrying the
pmd-type / phy-type-derived label.

Migration of existing startup configurations
--------------------------------------------

Bump confd version 1.8 → 1.9 to trigger the migration on upgrade.
share/migrate/1.9/10-ethernet-advertise.sh translates old configs of
the form

  ethernet { auto-negotiation { enable false; } speed S; duplex D; }

into

  ethernet { auto-negotiation { advertised-pmd-types [PMD]; } duplex D; }

mapping (S, D) → PMD via a static lookup covering the copper-T speeds
the deprecated speed leaf historically supported (10/100/1000/2.5G/
5G/10G).  Interfaces that don't disable auto-negotiation, or that
lack a speed leaf, are passed through untouched.

Closes #530
Closes #805

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-05-27 20:53:29 +02:00

342 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 verify_speed_duplex(target, ns, interface, exp_mbps, exp_duplex):
until(lambda: _speed_duplex_present(target, interface))
speed_bps, duplex = get_target_speed_duplex(target, interface)
if speed_bps is None or duplex is None:
print(f"Could not fetch speed/duplex from target for interface {interface}")
test.fail()
act_mbps = int(speed_bps) // 1_000_000
if act_mbps != exp_mbps:
print(f"act_mbps: {act_mbps}, exp_mbps: {exp_mbps}")
test.fail()
if duplex.lower() != exp_duplex.lower():
print(f"act_duplex: {duplex}, exp_duplex: {exp_duplex}")
test.fail()
ns.must_reach("10.0.0.2")
print(f"Verified: {interface} is operating at {act_mbps} Mbps, {duplex} duplex")
def _speed_duplex_present(target, interface):
speed_bps, duplex = get_target_speed_duplex(target, interface)
return speed_bps is not None and duplex is not None
def verify_forced_speed_duplex(target, ns, interface, exp_mbps, exp_duplex):
"""Verify a forced (autoneg off) link reaches exp speed/duplex.
Turning auto-negotiation off bounces the link; it can take a few
seconds to settle, so poll the operational speed/duplex AND a ping
together until both hold instead of checking once.
"""
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)
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()