diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 1addb9d2f..f3adf7016 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -44,3 +44,15 @@ Current scenarios: (anchors, per-link bridge, netem delay, suspend, capture, delete/re-create, stop/start) plus a serial link staying on the relay, and a negative control on an isolated relay-configured instance. +* `test_ethernet_switch_kernel_datapath.py` — a real Ethernet switch + (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_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 + delete/re-create swap, node stop/start rewiring) and a relay negative + control. + +Set `GNS3_E2E_DEBUG=1` to run the isolated instance with debug logging (the +uBridge command stream lands in its `server.log`). diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index 239ce9bb8..aef68c624 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -126,6 +126,26 @@ class Compute: {"compute_id": "local", "name": name, "node_type": "dynamips", "properties": properties}, ) + def create_iol_router(self, project_id, name, image, adapters=2): + """An IOL runner container (iol-runner images), the appliance's + template properties.""" + return self.call( + "POST", + f"/projects/{project_id}/nodes", + { + "compute_id": "local", + "name": name, + "node_type": "docker", + "properties": { + "image": image, + "adapters": adapters, + "console_type": "telnet", + "environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=iol-xe-base.txt", + "extra_volumes": ["/config"], + }, + }, + ) + def create_link(self, project_id, a, b): return self.call( "POST", @@ -150,6 +170,9 @@ class Compute: def dynamips_images(self): return [image["filename"] for image in self.call("GET", "/computes/local/dynamips/images")] + def docker_images(self): + return [image["image"] for image in self.call("GET", "/computes/local/docker/images")] + # --------------------------------------------------------------------------- # Server lifecycle @@ -247,8 +270,11 @@ def _start_isolated(kernel): log = open(os.path.join(tmpdir, "server.log"), "w") # S603: fixed argv, this interpreter, no shell involvement + argv = [sys.executable, "-m", "gns3server", "--config", config_path] + if os.environ.get("GNS3_E2E_DEBUG"): + argv.append("-d") # debug logging — the uBridge command stream lands in server.log process = subprocess.Popen( # noqa: S603 - [sys.executable, "-m", "gns3server", "--config", config_path], + argv, cwd=REPO_ROOT, stdout=log, stderr=subprocess.STDOUT, @@ -370,10 +396,14 @@ class IOSConsole: out, self.buf = self.buf, "" return out - def boot_wait(self, timeout=360): + def boot_wait(self, timeout=360, straight_prompt=False): """Wait until the IOS prompt is ready, answering the initial config dialog if it appears. The prompt only counts once the stream around - it goes quiet — a boot banner still printing is not a prompt.""" + it goes quiet — a boot banner still printing is not a prompt. + + ``straight_prompt`` accepts a quiet prompt without the "Press RETURN" + sentinel — images that boot straight from a startup config into the + CLI (IOL from its NVRAM) never print it.""" answered = False deadline = time.time() + timeout self.buf = "" @@ -385,7 +415,8 @@ class IOSConsole: self.sock.sendall(b"no\r") answered = True last_growth = time.time() - prompt_seen = ("Press RETURN" in self.buf or answered) and PROMPT_RE.search(self.buf) + sentinel = straight_prompt or "Press RETURN" in self.buf or answered + prompt_seen = sentinel and PROMPT_RE.search(self.buf) if prompt_seen and time.time() - last_growth > 1.2: self.buf = "" return @@ -492,6 +523,27 @@ def anchor_name(node_id, adapter, port): return "gd" + node_id.replace("-", "")[:8] + f"e{adapter}p{port}" +def iol_anchor_name(node_id, adapter, port): + """The IOL runner container's anchor TAP name (see IOLDockerVM._tap_name).""" + return "gx" + node_id.replace("-", "")[:8] + f"e{adapter}p{port}" + + +def stage_iol_base_config(server): + """ + Copy the shipped iol-xe base config into an isolated instance's configs + directory (its GNS3_IOL_STARTUP_CONFIG lookup path) so nodes boot past + the setup dialog. A no-op for an external server — there the operator's + install provides it. + """ + + if not server.isolated: + return + src = os.path.join(REPO_ROOT, "gns3server", "configs", "iol-xe-base.txt") + configs_dir = os.path.join(server.tmpdir, "configs") + os.makedirs(configs_dir, exist_ok=True) + shutil.copy(src, os.path.join(configs_dir, "iol-xe-base.txt")) + + def qdiscs(dev): """The tc qdisc listing of a host interface ('' when tc is missing).""" if not TC: diff --git a/tests/e2e/test_iol_docker_kernel_datapath.py b/tests/e2e/test_iol_docker_kernel_datapath.py new file mode 100644 index 000000000..594ea2cae --- /dev/null +++ b/tests/e2e/test_iol_docker_kernel_datapath.py @@ -0,0 +1,249 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software and is redistributed under the same terms as gns3-server. +# See the LICENSE file for licensing information. +# + +""" +Live end-to-end test for the IOL runner container's kernel datapath (pytest +marker ``e2e``): two real iol-xe containers (Cisco CML's iol-runner images, +the real IOS-XE CLI on PID 1 stdio), driven through the controller REST API +and their telnet consoles. The guest leg stays the runner's unix-socket pair +— the kernel datapath buys the *link segment*: anchor TAPs, a per-link Linux +bridge, kernel filters, suspend and capture. + +* two containers on one compute report ``kernel_datapath`` when the compute's + uBridge has both the tap module and the swappable bridge TAP leg + (``ubridge_bridge_tap``); the anchors (``gx`` TAPs) exist from node start, + before any link — and the per-link bridge enslaves exactly the two of them; +* real ICMP crosses the [unix ↔ TAP] port bridge relay; +* a ``delay 100`` filter lands as netem on both anchors, RTT grows ~200 ms; +* suspend admin-downs the anchor and kills the traffic; resume restores; +* link delete releases the port bridge's TAP leg (stop → delete_nio_tap) and + the anchor survives — re-creating the link swaps the leg back in (the + c-socket binding never re-binds) and the traffic returns; +* node stop tears the anchors down (uBridge holds their fds — port bridges + first, then tap delete); a restart recreates the whole wiring from the NIO; +* deleting the project leaves no anchors and no bridges behind. + +``test_iol_docker_relay_control`` is the negative control on an isolated +relay-configured instance: the same topology rides unix ↔ UDP through the +uBridge relay (no anchors ever exist) and still pings. +""" + +import re +import time + +import pytest + +from tests.e2e import harness + +pytestmark = pytest.mark.e2e + +# one adapter = one 4-port unit; Ethernet0/0 is (adapter 0, port 0) +ETH = (0, 0) +BOOT_TIMEOUT = 360 + + +def _pick_image(server): + images = server.compute.docker_images() + image = next((name for name in images if name.startswith("iol-xe/iol-xe:")), None) + if not image: + pytest.skip(f"no iol-xe/iol-xe image available on this server (images: {images[:5]})") + return image + + +def _boot(server, project_id, node_id, timeout=BOOT_TIMEOUT): + node = server.compute.node(project_id, node_id) + console = harness.IOSConsole(node["console_host"], node["console"]) + # straight_prompt: IOL boots from its NVRAM startup config straight into + # the CLI — no "Press RETURN" sentinel on this console + console.boot_wait(timeout=timeout, straight_prompt=True) + return console + + +def _topology(compute, pid, image, name): + n1 = compute.create_iol_router(pid, f"{name}-1", image) + n2 = compute.create_iol_router(pid, f"{name}-2", image) + return n1, n2 + + +def test_iol_docker_kernel_datapath(): + server = harness.live_server(kernel=True) + compute = server.compute + caps = compute.capabilities() + if caps.get("ubridge_bridge_tap") is not True or caps.get("ubridge_tap") is not True: + pytest.skip( + f"this compute's uBridge cannot serve the IOL container anchor lifecycle " + f"(ubridge_bridge_tap={caps.get('ubridge_bridge_tap')}, ubridge_tap={caps.get('ubridge_tap')})" + ) + image = _pick_image(server) + harness.stage_iol_base_config(server) + + project = compute.create_project("iol-e2e") + pid = project["project_id"] + a1 = a2 = None + try: + n1, n2 = _topology(compute, pid, image, "E2E") + n1_id, n2_id = n1["node_id"], n2["node_id"] + a1 = harness.iol_anchor_name(n1_id, *ETH) + a2 = harness.iol_anchor_name(n2_id, *ETH) + + # The link is created while both containers are stopped: the kernel + # NIO is bound with no anchors in existence yet and wired by node + # start (the deferred-wiring path). + link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH)) + lid = link["link_id"] + bridge = harness.link_bridge_name(lid) + assert link["kernel_datapath"] is True, link + assert not harness.tap_exists(a1), "anchors must not exist before start" + + print(".. starting containers and waiting for IOS-XE boots") + compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start") + compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start") + c1 = _boot(server, pid, n1_id) + c2 = _boot(server, pid, n2_id) + + # Kernel objects: anchors exist (born with the node, before the link + # was ever wired) and the per-link bridge has exactly the two of them. + assert harness.tap_exists(a1) and harness.tap_exists(a2), (a1, a2) + assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge) + + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0") + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0") + out = c1.run("show ip int brief") + assert re.search(r"Ethernet0/0\s+10\.1\.1\.1\s+\S+\s+\S+\s+up\s+up", out), out + print(".. both routers configured, pinging through the port-bridge swap") + baseline = harness.wait_ping(c1, "10.1.1.2") + assert baseline["success"] == 100, baseline["raw"] + + # delay 100: netem on both anchors (each impairs one direction), + # one-way ~100 ms => RTT grows by ~200 ms + compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}}) + assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), ( + harness.qdiscs(a1), + harness.qdiscs(a2), + ) + delayed = harness.wait_ping(c1, "10.1.1.2") + assert delayed["success"] == 100, delayed["raw"] + assert delayed["avg"] >= 150, (baseline, delayed) + compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) + assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1) + fast = harness.wait_ping(c1, "10.1.1.2") + assert fast["success"] == 100 and fast["avg"] < 50, fast + + # suspend: anchor admin-down (the port bridge's TAP writes fail EIO, + # nothing comes back); resume restores + compute.call("PUT", f"/projects/{pid}/links/{lid}", {"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/{lid}", {"suspend": False}) + assert harness.tap_up(a1) + assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100 + + # capture: AF_PACKET on the anchor writes a real pcap + capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"}) + harness.ping(c1, "10.1.1.2") + time.sleep(1) + compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop") + path = capture["capture_file_path"] + count, ethertypes = harness.pcap_records(path) + assert count >= 4, f"{path}: {count} records" + assert "0800" in ethertypes, (path, ethertypes) + + # delete / re-create the kernel link: the port bridge's TAP leg is + # released (stop -> delete_nio_tap), the anchor survives (the node + # owns it), and re-creating swaps the leg back in — the unix binding + # never re-binds across the churn. + compute.call("DELETE", f"/projects/{pid}/links/{lid}") + assert harness.bridge_members(bridge) is None + assert harness.tap_exists(a1) and harness.tap_exists(a2) + link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH)) + lid = link["link_id"] + bridge = harness.link_bridge_name(lid) + assert link["kernel_datapath"] is True, link + assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + + # node stop: uBridge holds the anchor fds, so the port bridges go + # first and the taps with them (a stopped container must not litter + # the host with gx devices); a restart recreates the whole wiring + # from the NIO that outlived it. + print(".. stopping and restarting container 1") + compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop") + assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1 + # the per-link bridge lost this end's port; it may be gone entirely + # (both endpoints delete it, last one wins) + assert harness.bridge_members(bridge) in (None, [a2]), harness.bridge_members(bridge) + compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start") + c1.close() + c1 = _boot(server, pid, n1_id) + assert harness.tap_exists(a1) + assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge) + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0") + 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) + + # deleting the project closed the nodes: anchors and bridges go with them + assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), ( + a1, + a2, + ) + + +def test_iol_docker_relay_control(): + """ + Negative control: the same topology with ``enable_kernel_datapath = + false`` (an isolated instance) wires nothing into the kernel — no + per-link bridge, no enslavement — and still pings over the unix ↔ UDP + relay. + + Anchors DO exist here (an IOL container creates its port TAPs at start + whenever the uBridge tap module and the swappable leg are available); + the server configuration only decides whether links are attached to + them (the same semantics as QEMU's and Dynamips' anchors). + """ + + server = harness.live_server(kernel=False) + compute = server.compute + image = _pick_image(server) + harness.stage_iol_base_config(server) + + project = compute.create_project("iol-relay-e2e") + pid = project["project_id"] + n1_id = n2_id = a1 = None + try: + n1, n2 = _topology(compute, pid, image, "E2E") + n1_id, n2_id = n1["node_id"], n2["node_id"] + a1 = harness.iol_anchor_name(n1_id, *ETH) + + link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH)) + assert link["kernel_datapath"] is False, link + + compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start") + compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start") + c1 = _boot(server, pid, n1_id) + c2 = _boot(server, pid, n2_id) + + # no kernel wiring: the anchor (if born) is nobody's bridge port + assert harness.bridge_members(harness.link_bridge_name(link["link_id"])) is None + if harness.tap_exists(a1): + assert not harness.tap_up(a1), a1 + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0") + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0") + 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)