diff --git a/docs/features/ethernet-switch-kernel-datapath.md b/docs/features/ethernet-switch-kernel-datapath.md index 406088b2e..410f82758 100644 --- a/docs/features/ethernet-switch-kernel-datapath.md +++ b/docs/features/ethernet-switch-kernel-datapath.md @@ -148,6 +148,17 @@ plus scripted reproduction rounds over the REST API): relay TAPs likewise stay enslaved across VLAN changes, with a real ping proving isolation and recovery. +The cascade has its own in-repo e2e +(`tests/e2e/test_ethernet_switch_cascade.py`): two switches joined by the +link-owned veth pair, two c7200 routers per switch, real ICMP crossing both +bridges — the pair is born UP with exactly one end per bridge, a dot1q +trunk cascade carries two VLANs at once, the symmetric access/trunk mode +flip on the cascade ends isolates and revives a VLAN in place, a `delay` +filter lands as netem on both ends (one per direction, RTT ≈ 2×delay), +suspend admin-downs both ends, and a link delete/re-create retires the whole +pair and mints a new one under the new link id — with zero host residue +after the project goes. + The host-side checks throughout are the kernel's own view: bridge membership under `/sys/class/net//brif/`, the `master` link attribute, `bridge vlan show`, and live pings where the peer allows it. diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 35e21df9b..37fb173f7 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -62,6 +62,13 @@ Current scenarios: (kernel bridge) between two c7200 routers: deferred joins on boot, VLAN isolation, absorbed-anchor filters/suspend/capture, VPCS peers staying on the relay, and a relay negative control. +* `test_ethernet_switch_cascade.py` — two Ethernet switches cascaded through + the link-owned veth pair (`gs0/1`, each end enslaved into its own + switch's bridge): a dot1q trunk cascade carrying two VLANs at once across + two c7200 routers per switch, the symmetric access/trunk mode flip on the + cascade ends, two-sided netem (one end per direction), suspend, link + delete/re-create (the pair dies with the link, the replacement mints new + names), zero residue. * `test_iol_docker_kernel_datapath.py` — two real iol-xe containers (Cisco CML iol-runner images): kernel link through the port bridge's swappable TAP leg (anchors at start, netem delay, suspend, capture, link diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index 41488b8f3..904b2e86d 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -1104,6 +1104,14 @@ def link_bridge_name(link_id): return "gns3" + link_id.replace("-", "")[:11] +def cascade_end_names(link_id): + """The two veth ends of a switch-to-switch cascade link, as + ``(end0, end1)`` — a pure function of the link id, like the per-link + bridge name (see utils.kernel_cascade_names).""" + stem = link_id.replace("-", "")[:10] + return f"gs{stem}0", f"gs{stem}1" + + def anchor_name(node_id, adapter, port): """The dynamips anchor TAP name for a slot/port (see Router._tap_name).""" return "gd" + node_id.replace("-", "")[:8] + f"e{adapter}p{port}" diff --git a/tests/e2e/test_ethernet_switch_cascade.py b/tests/e2e/test_ethernet_switch_cascade.py new file mode 100644 index 000000000..69d3653b4 --- /dev/null +++ b/tests/e2e/test_ethernet_switch_cascade.py @@ -0,0 +1,278 @@ +# +# 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 . +# + +""" +Live end-to-end test for the switch-to-switch cascade kernel datapath +(pytest marker ``e2e``): two Ethernet switches (two Linux kernel bridges) +interconnected by a link-owned veth pair, with two c7200 routers hanging +off each switch. Real ICMP crossing both switches proves the datapath: + +* the cascade link reports ``kernel_datapath`` and the veth pair + (``gs0/1``, a pure function of the link id) exists at once + — switches are always-on, nothing defers — with each switch's bridge + carrying exactly its own end as a member, both ends UP; +* a dot1q trunk cascade carries two VLANs at once: hosts in VLAN 10 and + VLAN 20 ping across both switches simultaneously (tagged frames cross + the pair, which is what a trunk means here); +* flipping both cascade ports symmetrically to access VLAN 10 (an + in-place ``_reconfigure_port_vlan`` on each veth end) keeps VLAN 10 + flowing untagged and kills VLAN 20; restoring the trunk revives VLAN 20. + Asymmetric flips are deliberately not tested: an access end egresses + untagged, and the far trunk end would park those frames in its native + VLAN — symmetric modes are the only sane inter-switch configuration; +* a ``delay 100`` filter lands as netem on BOTH veth ends (each direction + impaired exactly once, like any two-sided kernel link) and the measured + RTT grows by ~2 x 100 ms; +* suspend admin-downs both ends and kills both VLANs; resume restores; +* deleting the cascade link destroys the whole veth pair from either side + (the host links and their anchors survive); re-creating the link mints a + new pair under the new link id and the traffic returns without touching + the routers; +* deleting the project removes both switch bridges, both veth pairs and + the router anchors — zero host residue. + +No relay control twin here: on the relay datapath a cascade is just two +per-port uBridge UDP relays, the machinery +``test_ethernet_switch_kernel_datapath.py::test_ethernet_switch_relay_control`` +already drives. +""" + +import pytest + +from tests.e2e import harness + +pytestmark = pytest.mark.e2e + +# c7200 slot 0 is the fixed IO slot; the 2-port Ethernet PA goes in slot 1, +# so f1/0 = (1, 0) and f1/1 = (1, 1). +SLOTS = {"slot1": "PA-2FE-TX"} +ETH10 = (1, 0) +ETH20 = (1, 1) + +IP10 = "10.1.10.%s" +IP20 = "10.1.20.%s" + + +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 _ports(cascade_type): + """The switches' ports: two access ports (VLAN 10 / 20) and the cascade + port — dot1q trunk (native 1) or access 10 for the symmetric flip.""" + return [ + {"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10}, + {"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 20}, + {"name": "Ethernet2", "port_number": 2, "type": cascade_type, "vlan": 1 if cascade_type == "dot1q" else 10}, + ] + + +def _create_switch(compute, pid, name): + return compute.call( + "POST", + f"/projects/{pid}/nodes", + { + "compute_id": "local", + "name": name, + "node_type": "ethernet_switch", + "properties": {"ports_mapping": _ports("dot1q")}, + }, + ) + + +def _set_ports(compute, pid, switch_id, cascade_type): + compute.call("PUT", f"/projects/{pid}/nodes/{switch_id}", {"properties": {"ports_mapping": _ports(cascade_type)}}) + + +def _add_eth(console, ip, ifname): + console.run("conf t") + console.run(f"interface {ifname}") + console.run(f"ip address {ip} 255.255.255.0") + console.run("no shutdown") + console.run("end") + + +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 test_ethernet_switch_cascade_kernel_datapath(): + 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-cascade-e2e") + pid = project["project_id"] + bridges = [] + try: + sw1 = _create_switch(compute, pid, "E2E-SW1") + sw2 = _create_switch(compute, pid, "E2E-SW2") + sw1_id, sw2_id = sw1["node_id"], sw2["node_id"] + br1, br2 = _switch_bridge_name(sw1_id), _switch_bridge_name(sw2_id) + bridges = [br1, br2] + r1 = compute.create_dynamips_router(pid, "E2E-R1", image, SLOTS, idlepc=idlepc) + r2 = compute.create_dynamips_router(pid, "E2E-R2", image, SLOTS, idlepc=idlepc) + r1_id, r2_id = r1["node_id"], r2["node_id"] + a1_10, a1_20 = harness.anchor_name(r1_id, *ETH10), harness.anchor_name(r1_id, *ETH20) + a2_10, a2_20 = harness.anchor_name(r2_id, *ETH10), harness.anchor_name(r2_id, *ETH20) + + # Both switch bridges exist from node creation; the cascade wires at + # once (switches are always-on), the host links defer on the stopped + # routers exactly like the single-switch scenario. + assert harness.bridge_members(br1) == [] + assert harness.bridge_members(br2) == [] + host_links = [ + compute.create_link(pid, (r1_id, *ETH10), (sw1_id, 0, 0)), + compute.create_link(pid, (r1_id, *ETH20), (sw1_id, 0, 1)), + compute.create_link(pid, (r2_id, *ETH10), (sw2_id, 0, 0)), + compute.create_link(pid, (r2_id, *ETH20), (sw2_id, 0, 1)), + ] + cascade = compute.create_link(pid, (sw1_id, 0, 2), (sw2_id, 0, 2)) + assert all(link["kernel_datapath"] is True for link in [*host_links, cascade]), ( + [link.get("kernel_datapath") for link in host_links], + cascade.get("kernel_datapath"), + ) + e0, e1 = harness.cascade_end_names(cascade["link_id"]) + assert harness.tap_exists(e0) and harness.tap_exists(e1) + assert harness.tap_up(e0) and harness.tap_up(e1) + assert harness.bridge_members(br1) == [e0], harness.bridge_members(br1) + assert harness.bridge_members(br2) == [e1], harness.bridge_members(br2) + + 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) + + # The deferred host joins completed via the node-start re-push: each + # switch bridges its two router anchors plus its cascade end. + assert harness.wait_until(lambda: harness.bridge_members(br1) == sorted([a1_10, a1_20, e0]), timeout=15), ( + harness.bridge_members(br1) + ) + assert harness.wait_until(lambda: harness.bridge_members(br2) == sorted([a2_10, a2_20, e1]), timeout=15), ( + harness.bridge_members(br2) + ) + + harness.configure_ios(c1, "R1", eth_ip=IP10 % 1, eth_if="f1/0") + _add_eth(c1, IP20 % 1, "f1/1") + harness.configure_ios(c2, "R2", eth_ip=IP10 % 2, eth_if="f1/0") + _add_eth(c2, IP20 % 2, "f1/1") + print(".. both routers configured, pinging across the cascade") + + # A trunk cascade carries both VLANs at once — two simultaneous + # adjacencies over the same veth pair is the tagged-frames proof. + baseline10 = harness.wait_ping(c1, IP10 % 2) + assert baseline10["success"] == 100, baseline10["raw"] + baseline20 = harness.wait_ping(c1, IP20 % 2) + assert baseline20["success"] == 100, baseline20["raw"] + + # L2-anchor spec §E.1: the cascade ends and both bridges carry no L3 + # identity, and the bridged ends are FORWARDING. + if harness.l2only_supported(): + harness.assert_pure_l2(e0) + harness.assert_pure_l2(e1) + harness.assert_pure_l2(br1) + harness.assert_pure_l2(br2) + else: + print(".. uBridge without link l2only: skipping the §E.1 assertions") + harness.assert_forwarding(e0) + harness.assert_forwarding(e1) + + # Symmetric mode flip on the cascade ports (in place, no member ever + # leaves its bridge): access 10 passes VLAN 10 untagged and drops + # VLAN 20; the trunk restores the dual-VLAN crossing. + _set_ports(compute, pid, sw1_id, "access") + _set_ports(compute, pid, sw2_id, "access") + assert harness.bridge_members(br1) == sorted([a1_10, a1_20, e0]), harness.bridge_members(br1) + assert harness.bridge_members(br2) == sorted([a2_10, a2_20, e1]), harness.bridge_members(br2) + assert harness.wait_ping(c1, IP10 % 2)["success"] == 100 + isolated = harness.ping(c1, IP20 % 2, repeat=3) + assert isolated["success"] == 0, isolated["raw"] + _set_ports(compute, pid, sw1_id, "dot1q") + _set_ports(compute, pid, sw2_id, "dot1q") + rejoined = harness.wait_ping(c1, IP20 % 2, attempts=5) + assert rejoined["success"] == 100, rejoined["raw"] + + # Two-sided impairment: each veth end is its own interface, so the + # filter lands on both — one netem per direction, RTT ~= 2 x delay. + compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"filters": {"delay": [100]}}) + assert "netem" in harness.qdiscs(e0), harness.qdiscs(e0) + assert "netem" in harness.qdiscs(e1), harness.qdiscs(e1) + delayed = harness.wait_ping(c1, IP10 % 2) + assert delayed["success"] == 100, delayed["raw"] + assert 150 <= delayed["avg"] <= 350, (baseline10, delayed) + compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"filters": {}}) + assert "netem" not in harness.qdiscs(e0) and "netem" not in harness.qdiscs(e1) + fast = harness.wait_ping(c1, IP10 % 2) + assert fast["success"] == 100 and fast["avg"] < 50, fast + + # suspend: both ends admin-down, both VLANs die; resume restores. + compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"suspend": True}) + assert not harness.tap_up(e0) and not harness.tap_up(e1) + assert harness.ping(c1, IP10 % 2, repeat=3)["success"] == 0 + assert harness.ping(c1, IP20 % 2, repeat=3)["success"] == 0 + compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"suspend": False}) + assert harness.tap_up(e0) and harness.tap_up(e1) + assert harness.wait_ping(c1, IP10 % 2, attempts=5)["success"] == 100 + assert harness.wait_ping(c1, IP20 % 2, attempts=5)["success"] == 100 + + # Cascade link fault: the whole veth pair dies with the link (either + # side's teardown), the host links and their anchors survive; a + # re-create mints a new pair under the new link id and the traffic + # returns without touching the routers. + compute.call("DELETE", f"/projects/{pid}/links/{cascade['link_id']}") + assert not harness.tap_exists(e0) and not harness.tap_exists(e1) + assert harness.bridge_members(br1) == sorted([a1_10, a1_20]), harness.bridge_members(br1) + assert harness.bridge_members(br2) == sorted([a2_10, a2_20]), harness.bridge_members(br2) + assert harness.ping(c1, IP10 % 2, repeat=3)["success"] == 0 + cascade = compute.create_link(pid, (sw1_id, 0, 2), (sw2_id, 0, 2)) + assert cascade["kernel_datapath"] is True, cascade + n0, n1 = harness.cascade_end_names(cascade["link_id"]) + assert harness.wait_until(lambda: harness.bridge_members(br1) == sorted([a1_10, a1_20, n0]), timeout=10) + assert harness.wait_until(lambda: harness.bridge_members(br2) == sorted([a2_10, a2_20, n1]), timeout=10) + assert harness.wait_ping(c1, IP10 % 2, attempts=5)["success"] == 100 + assert harness.wait_ping(c1, IP20 % 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: both switch bridges, both veth pairs + # (the faulted one and its replacement) and all four router anchors gone. + for bridge in bridges: + assert harness.wait_until(lambda b=bridge: harness.bridge_members(b) is None, timeout=15), bridge + for end in (e0, e1, n0, n1): + assert not harness.tap_exists(end), end + for anchor in (a1_10, a1_20, a2_10, a2_20): + assert not harness.tap_exists(anchor), anchor