mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 21:45:41 +03:00
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:
parent
765d706ec4
commit
aaabf0409d
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
292
tests/e2e/test_iou_kernel_datapath.py
Normal file
292
tests/e2e/test_iou_kernel_datapath.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user