gns3-server/tests/e2e/test_ethernet_switch_kernel_datapath.py
YueGuobin 1c85befbab
test(e2e): take the switch anchors out of NetworkManager's reach
The esw kernel fast-path scenario deletes and re-creates a link, which
re-enslaves the peer anchor — and on NetworkManager hosts NM's netdev
state machine can release an NM-managed TAP from its bridge some 60-70 ms
after enslavement, ~50% of re-enslavements, while the tap is in NM's
not-yet-assumed state. The freed port stays administratively up and no
GNS3 process logs anything: the membership assert sees the join, then the
ping measures a silent blackhole (this was the line-277 e2e failure).

Root-caused with a server-free harness (one uBridge, one fresh tap, one
bridge, an addif/delif loop): the same tap frees 20/40 while NM-managed,
0/40 after `nmcli device set <tap> managed no`, 20/40 again when
re-managed. Docker's veth host ends are not NM-managed, which is why only
TAP anchors ever showed it — including the "anchor lost its bridge
membership after a ports_mapping update (TAP peers only)" incident from
the switch's development, whose conclusion is corrected in the docs.

Unmanage the anchors once the routers have booted, before the scenario
re-enslaves them; document the failure signature and the host-side remedy
(per-device nmcli call, or NM unmanaged-devices configuration) in the
L2-anchor spec and the switch feature doc.
2026-10-06 00:21:11 +08:00

367 lines
17 KiB
Python

#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Live end-to-end test for the Ethernet switch kernel fast path (pytest
marker ``e2e``): a real switch (a Linux kernel bridge) between two real
c7200 routers, driven through the controller REST API and the IOS consoles.
Real ICMP crossing the switch proves the datapath:
* links created while the routers are stopped report ``kernel_datapath``
and defer the join; once the routers boot, the controller's re-push
makes the switch join their anchors — the switch bridge's members become
exactly the two router anchors (no relay TAPs anywhere);
* the L2-anchor spec §E.2 silence window with both routers shut: the
absorbed anchors and the switch bridge stay silent for 5 s;
* VLAN programming applies to absorbed anchors: different access VLANs
isolate the routers (ping dies), the same VLAN bridges them again;
* a ``delay 100`` filter lands as netem on the absorbed anchor (single
interface, switch-owned) and the measured RTT grows by ~100 ms — on both
link endpoint orders (peer-first and switch-first; the order decides
which side's NIO update runs last and must not decide the outcome);
* suspend admin-downs the anchor and kills the traffic; resume restores;
* capture (hosted by the switch side) writes a pcap of ICMP records;
* deleting a link detaches the anchor (which survives — the router owns
it) and re-creating re-joins it;
* a router stop/start keeps the wiring and the traffic;
* deleting the project removes the switch bridge and the anchors.
``test_ethernet_switch_relay_control`` is the negative control on an
isolated relay-configured instance: the same topology rides the per-port
uBridge relays (the switch bridge's members are its own relay TAPs, never
the routers' anchors) and still pings.
"""
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# c7200 slot 0 is the fixed IO slot; the Ethernet PA goes in slot 1.
SLOTS = {"slot1": "PA-2FE-TX"}
ETH = (1, 0)
def _pick_image(server):
images = server.compute.dynamips_images()
image = next((name for name in images if name.startswith("c7200")), None)
if not image:
pytest.skip(f"no c7200 image available on this server (images: {images[:5]})")
return image
def _switch_bridge_name(switch_id):
# see EthernetSwitch._ensure_bridge
return "gns3" + switch_id.replace("-", "")[:6]
def _create_topology(compute, pid, image, name, idlepc=None):
switch = compute.call(
"POST",
f"/projects/{pid}/nodes",
{
"compute_id": "local",
"name": f"{name}-SW",
"node_type": "ethernet_switch",
"properties": {
"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 1},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 1},
]
},
},
)
r1 = compute.create_dynamips_router(pid, f"{name}-R1", image, SLOTS, idlepc=idlepc)
r2 = compute.create_dynamips_router(pid, f"{name}-R2", image, SLOTS, idlepc=idlepc)
return switch, r1, r2
def _boot(server, project_id, node_id, timeout=360):
console = harness.ios_console(server, project_id, node_id)
console.boot_wait(timeout=timeout)
return console
def _eth_shutdown(console, shut):
"""Shut/unshut the router's f1/0 — the only legitimate speaker on the
segment (IOS's own CDP/keepalives) — for §E.2's silence window; the
address stays configured and answers once it is back."""
console.run("conf t")
console.run("interface f1/0")
console.run("shutdown" if shut else "no shutdown")
console.run("end")
def test_ethernet_switch_kernel_fast_path():
server = harness.live_server(kernel=True)
compute = server.compute
if compute.capabilities().get("ubridge_tap") is not True:
pytest.skip("this compute's uBridge cannot create persistent TAPs (ubridge_tap != True)")
image = _pick_image(server)
idlepc = harness.dynamips_idlepc(server, image)
project = compute.create_project("esw-e2e")
pid = project["project_id"]
sw_bridge = None
try:
switch, r1, r2 = _create_topology(compute, pid, image, "E2E", idlepc)
switch_id = switch["node_id"]
r1_id, r2_id = r1["node_id"], r2["node_id"]
sw_bridge = _switch_bridge_name(switch_id)
a1 = harness.anchor_name(r1_id, *ETH)
a2 = harness.anchor_name(r2_id, *ETH)
# The switch bridge exists from the moment the switch node does; a
# link against a stopped router must not fail — the join defers.
assert harness.bridge_members(sw_bridge) == []
# link1 is created peer-first, link2 switch-first: the endpoint order
# decides which side's NIO update runs last, and only the switch-first
# order used to expose the passive end's tc-reset wipe (its empty-filter
# reconcile ran after the switch's apply). Both orders must work.
link1 = compute.create_link(pid, (r1_id, *ETH), (switch_id, 0, 0))
link2 = compute.create_link(pid, (switch_id, 0, 1), (r2_id, *ETH))
assert link1["kernel_datapath"] is True, link1
assert link2["kernel_datapath"] is True, link2
assert harness.bridge_members(sw_bridge) == []
print(".. starting routers and waiting for IOS boots")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
c1 = _boot(server, pid, r1_id)
c2 = _boot(server, pid, r2_id)
# Keep NetworkManager's hands off the anchors: on NM hosts its
# netdev state machine can release a freshly re-enslaved tap from
# the bridge ~60 ms after the join (silently — the port stays up,
# the server keeps believing it is attached), which the link
# delete/recreate below triggers nondeterministically. The first
# join at node start is unaffected; this covers everything after
# it.
harness.unmanage_from_networkmanager(a1, a2)
# Deferred joins completed by the node-start re-push: the anchors
# ARE the switch ports now.
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15), (
harness.bridge_members(sw_bridge)
)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1")
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2")
print(".. both routers configured, pinging through the switch")
baseline = harness.wait_ping(c1, "10.1.1.2")
assert baseline["success"] == 100, baseline["raw"]
# L2-anchor spec §E.2: with both routers shut, the absorbed anchors
# and the switch bridge stay silent for 5 s (the bridge is created
# with multicast snooping off precisely so the bridge role can).
if harness.l2only_supported():
for console in (c1, c2):
_eth_shutdown(console, shut=True)
harness.assert_idle_silence(a1, a2, sw_bridge)
for console in (c1, c2):
_eth_shutdown(console, shut=False)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
else:
print(".. uBridge without link l2only: skipping the §E.2 assertion")
# L2-anchor spec §E.1: the absorbed anchors and the switch bridge
# carry no L3 identity (skipped on a uBridge without `link l2only`),
# and the joined ports are FORWARDING — the silent-failure guard.
if harness.l2only_supported():
harness.assert_pure_l2(a1)
harness.assert_pure_l2(a2)
harness.assert_pure_l2(sw_bridge)
else:
print(".. uBridge without link l2only: skipping the §E.1 assertions")
harness.assert_forwarding(a1)
harness.assert_forwarding(a2)
# VLAN programming on absorbed anchors: different access VLANs
# isolate, the same VLAN bridges again.
compute.call(
"PUT",
f"/projects/{pid}/nodes/{switch_id}",
{
"properties": {
"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 20},
]
}
},
)
assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge)
isolated = harness.ping(c1, "10.1.1.2", repeat=3)
assert isolated["success"] == 0, isolated["raw"]
assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge)
compute.call(
"PUT",
f"/projects/{pid}/nodes/{switch_id}",
{
"properties": {
"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 10},
]
}
},
)
rejoined = harness.wait_ping(c1, "10.1.1.2")
assert rejoined["success"] == 100, rejoined["raw"]
# Single-owned impairment: the filter lands on the absorbed anchor
# (one interface — the switch's), RTT grows by ~100 ms one-way.
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(a1), harness.qdiscs(a1)
assert "netem" not in harness.qdiscs(a2), harness.qdiscs(a2)
delayed = harness.wait_ping(c1, "10.1.1.2")
assert delayed["success"] == 100, delayed["raw"]
assert 60 <= delayed["avg"] <= 250, (baseline, delayed)
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {}})
assert "netem" not in harness.qdiscs(a1)
fast = harness.wait_ping(c1, "10.1.1.2")
assert fast["success"] == 100 and fast["avg"] < 50, fast
# The same single-owned impairment on link2 — created switch-first,
# the order whose peer NIO update runs LAST. The passive end's empty
# reconcile used to tc-reset the shared anchor right after the switch
# applied the netem, wiping it (API green, data plane unimpaired);
# the qdisc and the RTT must both hold under this order too.
compute.call("PUT", f"/projects/{pid}/links/{link2['link_id']}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(a2), harness.qdiscs(a2)
assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1)
delayed2 = harness.wait_ping(c1, "10.1.1.2")
assert delayed2["success"] == 100, delayed2["raw"]
assert 60 <= delayed2["avg"] <= 250, (baseline, delayed2)
compute.call("PUT", f"/projects/{pid}/links/{link2['link_id']}", {"filters": {}})
assert "netem" not in harness.qdiscs(a2)
fast2 = harness.wait_ping(c1, "10.1.1.2")
assert fast2["success"] == 100 and fast2["avg"] < 50, fast2
# suspend: the anchor admin-downs, the link is dead; resume restores
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": True})
assert not harness.tap_up(a1)
dead = harness.ping(c1, "10.1.1.2", repeat=3)
assert dead["success"] == 0, dead["raw"]
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": False})
assert harness.tap_up(a1)
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
# capture (the controller hosts it on the switch — always-on local
# builtin): a pcap of ICMP records written off the absorbed anchor
capture = compute.call(
"POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}
)
harness.ping(c1, "10.1.1.2")
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop")
count, ethertypes = harness.pcap_records(capture["capture_file_path"])
assert count >= 4, capture["capture_file_path"]
assert "0800" in ethertypes, ethertypes
# link delete detaches the anchor (it survives — the router owns
# it); re-creating re-joins it
compute.call("DELETE", f"/projects/{pid}/links/{link2['link_id']}")
assert harness.bridge_members(sw_bridge) == [a1]
assert harness.tap_exists(a2)
link2 = compute.create_link(pid, (r2_id, *ETH), (switch_id, 0, 1))
assert link2["kernel_datapath"] is True, link2
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=10)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
# router stop/start keeps the wiring: a Dynamips stop only halts the
# emulated router — the hypervisor, the anchor and its bridge
# membership all survive (the same stop-is-not-close semantics as
# the per-link bridge datapath)
print(".. stopping and restarting R1")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/stop")
assert harness.tap_exists(a1)
assert harness.bridge_members(sw_bridge) == sorted([a1, a2])
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
c1.close()
c1 = _boot(server, pid, r1_id)
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1")
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# project delete closed everything: switch bridge and anchors gone
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) is None, timeout=15), sw_bridge
def test_ethernet_switch_relay_control():
"""Negative control: the same topology on an isolated relay-configured
instance rides the per-port uBridge relays — the switch bridge's
members are its own relay TAPs, never the routers' anchors — and still
pings."""
server = harness.live_server(kernel=False)
compute = server.compute
image = _pick_image(server)
idlepc = harness.dynamips_idlepc(server, image)
project = compute.create_project("esw-e2e-relay")
pid = project["project_id"]
sw_bridge = None
try:
switch, r1, r2 = _create_topology(compute, pid, image, "E2E", idlepc)
sw_bridge = _switch_bridge_name(switch["node_id"])
r1_id, r2_id = r1["node_id"], r2["node_id"]
a1 = harness.anchor_name(r1_id, *ETH)
link1 = compute.create_link(pid, (r1_id, *ETH), (switch["node_id"], 0, 0))
link2 = compute.create_link(pid, (r2_id, *ETH), (switch["node_id"], 0, 1))
assert link1["kernel_datapath"] is False, link1
assert link2["kernel_datapath"] is False, link2
print(".. starting routers and waiting for IOS boots (relay)")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
c1 = _boot(server, pid, r1_id)
c2 = _boot(server, pid, r2_id)
members = harness.bridge_members(sw_bridge)
assert members == sorted([f"{sw_bridge}-0", f"{sw_bridge}-1"]), members
assert a1 not in members
if harness.l2only_supported():
# creation-time hardening applies on the relay too: the switch's
# own port TAPs and its bridge are pure L2
for member in members:
harness.assert_pure_l2(member)
harness.assert_pure_l2(sw_bridge)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1")
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2")
relay_ping = harness.wait_ping(c1, "10.1.1.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) is None, timeout=15), sw_bridge