test(e2e): live coverage for the IOU node kernel datapath

Two real IOU routers on a real L3 image (the image's own IOL fabric, the
real IOS CLI on the server's telnet console), driving the anchor TAPs the
kernel datapath builds on: anchors born with the node (one per Ethernet
bay/unit), real ICMP through the [fabric <-> TAP] port bridge, netem
delay, suspend, capture, link delete/re-create, node stop/start rewiring,
the serial link staying on the relay (serial bays never anchor) and a
relay negative control on an isolated instance. Every scenario ends with
no anchors, bridges or tmpdirs left behind.

harness: create_iou_node/iou_images/iou_anchor_name, and the IOS console
helper is now ios_console (dynamips, IOU and the IOL containers share it).
This commit is contained in:
YueGuobin 2026-10-02 19:30:26 +08:00
parent 765d706ec4
commit aaabf0409d
No known key found for this signature in database
5 changed files with 339 additions and 4 deletions

View File

@ -27,7 +27,8 @@ Two run modes, chosen per test through `harness.live_server(kernel=...)`:
process on a free port with its own config directory (the controller DB
and every path follow the config), so nothing on the machine is touched.
It needs `ubridge`, `dynamips` and an images directory
(`GNS3_E2E_IMAGES`, default `~/GNS3/images`) available locally; the
(`GNS3_E2E_IMAGES`, default `~/GNS3/images`) available locally — the IOU
scenario takes its L3 image from that directory's `IOU` subfolder; the
Docker scenarios additionally need the Docker daemon the server talks to.
Either way each test creates its own project and deletes it again. Set
@ -65,6 +66,11 @@ Current scenarios:
TAP leg (anchors at start, netem delay, suspend, capture, link
delete/re-create swap, node stop/start rewiring) and a relay negative
control.
* `test_iou_kernel_datapath.py` — two real IOU routers (a real IOU image
from the images directory, the real IOS CLI on the server's telnet
console): kernel link through the [IOL fabric ↔ TAP] port bridge (same
lifecycle as the iol-xe containers), a serial link staying on the relay
(serial bays never anchor), and a relay negative control.
* `test_docker_kernel_datapath.py` — two real Alpine containers (the plain
veth datapath): the per-link kernel bridge enslaves exactly the two veth
host ends, real ICMP crosses, the filter matrix runs one type at a time

View File

@ -157,6 +157,31 @@ class Compute:
},
)
def create_iou_node(self, project_id, name, image, ethernet_adapters=2, serial_adapters=2, ram=1024, nvram=256):
"""An IOU node: the real IOU binary running on this host, its IOS on
the server's telnet console. The kernel scenario picks the image
through ``iou_images`` (an L3 image — an L2 switching image does not
take addresses on its switchports) and addresses Ethernet0/0."""
return self.call(
"POST",
f"/projects/{project_id}/nodes",
{
"compute_id": "local",
"name": name,
"node_type": "iou",
"properties": {
"path": image,
"ethernet_adapters": ethernet_adapters,
"serial_adapters": serial_adapters,
"ram": ram,
"nvram": nvram,
# explicit ram/nvram, like the IOU appliance template
"use_default_iou_values": False,
"console_type": "telnet",
},
},
)
def create_iol_router(self, project_id, name, image, adapters=2):
"""An IOL runner container (iol-runner images), the appliance's
template properties."""
@ -204,6 +229,9 @@ class Compute:
def docker_images(self):
return [image["image"] for image in self.call("GET", "/computes/local/docker/images")]
def iou_images(self):
return [image["filename"] for image in self.call("GET", "/computes/local/iou/images")]
# ---------------------------------------------------------------------------
# Server lifecycle
@ -565,7 +593,10 @@ class IOSConsole:
self.sock.close()
def dynamips_console(server, project_id, node_id):
def ios_console(server, project_id, node_id):
"""The telnet console of a node running IOS (dynamips, IOU, IOL
container): the server-side telnet endpoint around the emulator's own
stdio — same driver for all of them."""
node = server.compute.node(project_id, node_id)
host = node["console_host"] if node["console_host"] not in ("0.0.0.0", "::") else "127.0.0.1"
return IOSConsole(host, node["console"])
@ -1056,6 +1087,12 @@ def iol_anchor_name(node_id, adapter, port):
return "gx" + node_id.replace("-", "")[:8] + f"e{adapter}p{port}"
def iou_anchor_name(node_id, adapter, port=0):
"""The IOU node's anchor TAP name: one persistent TAP per Ethernet
bay/unit (see IOUVM._tap_name and utils.kernel_anchor)."""
return "gi" + 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

View File

@ -66,7 +66,7 @@ def _pick_image(server):
def _boot(server, project_id, node_id, timeout=360):
console = harness.dynamips_console(server, project_id, node_id)
console = harness.ios_console(server, project_id, node_id)
console.boot_wait(timeout=timeout)
return console

View File

@ -89,7 +89,7 @@ def _create_topology(compute, pid, image, name, idlepc=None):
def _boot(server, project_id, node_id, timeout=360):
console = harness.dynamips_console(server, project_id, node_id)
console = harness.ios_console(server, project_id, node_id)
console.boot_wait(timeout=timeout)
return console

View File

@ -0,0 +1,292 @@
#
# 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 IOU node's kernel datapath (pytest marker
``e2e``): two real IOU routers (a real IOU image executed by the local
compute, the real IOS CLI behind the server's telnet console), driven
through the controller REST API. IOU has no host interface of its own: the
guest leg is the IOL fabric unix socket, and the kernel datapath buys the
*link segment* — one persistent anchor TAP per Ethernet bay/unit, a per-link
Linux bridge, kernel filters, suspend and capture.
* two IOU nodes on one compute report ``kernel_datapath`` when the compute's
uBridge can terminate an IOL port on a persistent TAP
(``ubridge_iol_tap``); the anchors (``gi`` TAPs, one per Ethernet
bay/unit) exist from node start, before any link — and the per-link
bridge enslaves exactly the two of them;
* real ICMP crosses the [IOL fabric ↔ TAP] port bridge;
* 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;
* a serial link between the same nodes never anchors (per-port exclusion):
it stays on the relay, ``kernel_datapath`` is False, and real traffic
still crosses it;
* link delete releases the port bridge's TAP leg and the anchor survives —
re-creating the link swaps the leg back in and the traffic returns;
* node stop tears the anchors down (uBridge holds their fds — the IOL
bridge goes first, then ``tap delete``); a restart recreates the whole
wiring from the NIO;
* deleting the project leaves no anchors and no bridges behind.
``test_iou_relay_control`` is the negative control on an isolated
relay-configured instance: the same topology rides the fabric socket ↔ UDP
relay (no per-link bridge, nothing enslaved) and still pings.
"""
import re
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# Ethernet0/0 is (bay 0, unit 0) — one adapter = four units. The adapter
# index runs over Ethernet and serial bays together, so with two Ethernet
# adapters the serial bays are 2 and 3: Serial2/0 is (2, 0).
ETH = (0, 0)
SERIAL = (2, 0)
BOOT_TIMEOUT = 360
# L3 images only: an L2 switching image does not take an address on its
# switchports. The first is the image the kernel datapath was validated
# with, the second the matching 17.15 build.
IMAGE_PREFERENCE = ("x86_64_crb_linux-adventerprisek9-ms.iol", "l3-adventerprisek9-ms-17.15.01.bin")
def _pick_image(server):
images = server.compute.iou_images()
for name in IMAGE_PREFERENCE:
if name in images:
return name
l3 = next((name for name in images if "adventerprisek9" in name and "l2" not in name.lower()), None)
if not l3:
pytest.skip(f"no L3 IOU image available on this server (images: {images[:5]})")
return l3
def _boot(server, project_id, node_id, timeout=BOOT_TIMEOUT):
console = harness.ios_console(server, project_id, node_id)
console.boot_wait(timeout=timeout)
return console
def test_iou_kernel_datapath():
server = harness.live_server(kernel=True)
compute = server.compute
caps = compute.capabilities()
if caps.get("ubridge_iol_tap") is not True or caps.get("ubridge_tap") is not True:
pytest.skip(
f"this compute's uBridge cannot serve the IOU anchor lifecycle "
f"(ubridge_iol_tap={caps.get('ubridge_iol_tap')}, ubridge_tap={caps.get('ubridge_tap')})"
)
image = _pick_image(server)
project = compute.create_project("iou-e2e")
pid = project["project_id"]
a1 = a2 = None
try:
n1 = compute.create_iou_node(pid, "E2E-R1", image)
n2 = compute.create_iou_node(pid, "E2E-R2", image)
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.iou_anchor_name(n1_id, *ETH)
a2 = harness.iou_anchor_name(n2_id, *ETH)
# The link is created while both nodes 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(f".. starting routers on {image} and waiting for IOS 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: the anchors are one persistent TAP per Ethernet
# bay/unit, born with the node (all eight — the IOU port model),
# and the per-link bridge has exactly the two addressed ports.
for adapter in range(2):
for port in range(4):
name = harness.iou_anchor_name(n1_id, adapter, port)
assert harness.tap_exists(name), name
assert harness.tap_exists(a2), a2
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
# L2-anchor spec §E.1: anchors and the per-link bridge carry no L3
# identity (skipped on a uBridge without `link l2only`), and both
# 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(bridge)
else:
print(".. uBridge without link l2only: skipping the §E.1 assertions")
harness.assert_forwarding(a1)
harness.assert_forwarding(a2)
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 [IOL fabric <-> TAP] port bridge")
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)
# serial link between the same nodes: per-port exclusion keeps it on
# the relay, where the real traffic still crosses (IOU-specific —
# serial bays never anchor)
serial = compute.create_link(pid, (n1_id, *SERIAL), (n2_id, *SERIAL))
assert serial["kernel_datapath"] is False, serial
harness.configure_ios(c1, "R1", serial_ip="10.2.2.1", serial_if="Serial2/0", clock=True)
harness.configure_ios(c2, "R2", serial_ip="10.2.2.2", serial_if="Serial2/0")
relay_ping = harness.wait_ping(c1, "10.2.2.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
compute.call("DELETE", f"/projects/{pid}/links/{serial['link_id']}")
# 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 fabric
# socket 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 IOL bridge goes
# first and the taps with it (a stopped node must not litter the
# host with gi devices); a restart recreates the whole wiring from
# the NIO that outlived it.
print(".. stopping and restarting router 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_iou_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 fabric
socket ↔ UDP relay.
Anchors DO exist here (an IOU node creates its port TAPs at start
whenever the uBridge can terminate an IOL port on one); 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)
project = compute.create_project("iou-relay-e2e")
pid = project["project_id"]
a1 = None
try:
n1 = compute.create_iou_node(pid, "E2E-R1", image)
n2 = compute.create_iou_node(pid, "E2E-R2", image)
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.iou_anchor_name(n1_id, *ETH)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
assert link["kernel_datapath"] is False, link
print(".. starting routers and waiting for IOS boots (relay)")
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
if harness.l2only_supported():
# the L2 hardening is a creation-time property, not a
# datapath one: relay anchors are pure L2 too
harness.assert_pure_l2(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)
# deleting the project closed the nodes: the port TAPs go with them
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1