gns3-server/tests/e2e/test_qemu_kernel_datapath.py
YueGuobin 6459122113
test(e2e): run every traffic checkpoint dual-stack (IPv6)
The live suite asserted the datapath over IPv4 only. Give every guest a
ULA IPv6 address next to its v4 one and twin every v4 assertion point,
sharing the impairment window (one filter application, both families
asserted) so the runtime grows by minutes, not double.

- harness: configure_ios(eth_ipv6=), ping() dispatches to `ping ipv6`,
  docker_bring_up_ipv6 (the Docker daemon leaves eth0 with
  disable_ipv6=1 on v6-less bridge networks, so the interfaces-file
  route cannot work), family-aware docker_wait_address, pcap_records
  scans every record (a leading DAD/MLD frame must not mask the
  sampled ethertype), and pcap_icmp_rtt measures the wire round trip
  from capture timestamps;
- scenarios: connectivity/delay/suspend/isolation/churn checks in both
  families, captures assert 86dd next to 0800, the libpcap bpf drops
  are asserted family-selective (icmp vs icmp6, each killing its own
  family and leaving the other alive), the eBPF classifier modes on
  both families, marker v6 twins with an echo-specific expression
  (bare icmp6 also matches DAD/MLD and would break the exact counts),
  the v6 sharkd replay, and the link-local matrix gains the 86dd
  all-nodes row; serial (HDLC) legs stay v4-only;
- the dynamips-emulated classic IOS under-reports its `ping ipv6` RTT
  by ~40 % of a delayed round trip (pcap-verified: the wire carries the
  v6 echo exchange in the same ~230 ms as v4 while the console reports
  ~130 ms), so the dynamips-family scenarios assert the v6 delay from
  the capture's own timestamps — recorded in tests/e2e/README.md.

Full suite: 17 passed in 58m50s.
2026-10-07 13:19:38 +08:00

350 lines
16 KiB
Python

#
# 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 QEMU node's kernel datapath (pytest marker
``e2e``): two real IOSv routers (a real qcow2 image run by the local qemu
binary, the real IOS CLI behind the server's telnet console), driven through
the controller REST API. QEMU is the node type whose guest leg *is* the
anchor: uBridge creates one persistent TAP per adapter before launch and
hands it to this user, and QEMU opens it by ifname as its netdev
(``-netdev tap,ifname=...,script=no,downscript=no``) — no userspace hop, no
port bridge, the anchor is the NIC's host side.
* two QEMU nodes on one compute report ``kernel_datapath`` when the
compute's uBridge can create persistent TAPs (``ubridge_tap``); the
anchors (``gq`` TAPs, one per adapter) exist from node start, before any
link — and the per-link bridge enslaves exactly the two of them;
* real ICMP crosses the [QEMU netdev ↔ TAP] anchor;
* the L2-anchor spec §E.2 silence window with the guests shut (the anchors
here are QEMU's own netdevs);
* a ``delay 100`` filter lands as netem on both anchors, RTT grows ~200 ms;
* suspend admin-downs the anchor (the e1000 loses carrier) and kills the
traffic; resume restores;
* a capture on the kernel link writes a real pcap;
* link delete releases the anchor from the bridge and it survives (the
running VM holds it open) — re-creating the link re-enslaves it and the
traffic returns;
* node stop deletes every anchor of the node together with the orphan
bridges; a restart recreates the TAPs and rewires the link from the NIO;
* deleting the project leaves no anchors and no bridges behind.
``test_qemu_relay_control`` is the negative control on an isolated
relay-configured instance: the anchors still exist (a QEMU node creates its
adapter TAPs at start whenever uBridge can), but nothing is enslaved — the
relay attaches the anchor itself as an AF_PACKET endpoint, so it stays up
and carrying while the link rides the uBridge relay. This differs from
IOU's relay, where the fabric socket carries the traffic and the anchors
sit idle: QEMU has no other leg, the TAP is the netdev either way.
Every checkpoint runs dual-stack: the IOSv guests carry a ULA v6 address
next to their v4 one and each checkpoint pings both families.
"""
import re
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# One NIC per adapter, port number always 0: adapter 0 is GigabitEthernet0/0.
ETH = (0, 0)
# IOSv boots a real IOS from an emulated disk — slower than IOU (the boot
# wait is event-driven; this is only the failure cap).
BOOT_TIMEOUT = 480
# L3 images only (an IOSv-L2 image switchports its interfaces). The first
# is the image on this host the kernel datapath was validated with.
IMAGE_PREFERENCE = ("vios-adventerprisek9-m.spa.159-3.m12.qcow2",)
GUEST_ETH_IF = "GigabitEthernet0/0"
# the dual-stack half: a ULA mirroring 10.1.1.x
R1_IP6 = "fd00:1:1::1"
R2_IP6 = "fd00:1:1::2"
def _pick_image(server):
images = server.compute.qemu_images()
for name in IMAGE_PREFERENCE:
if name in images:
return name
l3 = next(
(name for name in images if "vios" in name.lower() and "l2" not in name.lower() and name.endswith(".qcow2")),
None,
)
if not l3:
pytest.skip(f"no L3 IOSv qcow2 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 _eth_shutdown(console, shut):
"""Shut/unshut the guest's GigabitEthernet0/0 — the only legitimate
speaker on the link segment (IOSv's own chatter) — for §E.2's silence
window; the address stays configured and answers once it is back."""
console.run("conf t")
console.run(f"interface {GUEST_ETH_IF}")
console.run("shutdown" if shut else "no shutdown")
console.run("end")
def test_qemu_kernel_datapath():
server = harness.live_server(kernel=True)
compute = server.compute
caps = compute.capabilities()
if caps.get("ubridge_tap") is not True:
pytest.skip(
f"this compute's uBridge cannot create the QEMU adapter TAPs (ubridge_tap={caps.get('ubridge_tap')})"
)
image = _pick_image(server)
project = compute.create_project("qemu-e2e")
pid = project["project_id"]
a1 = a2 = None
try:
n1 = compute.create_qemu_node(pid, "E2E-R1", image)
n2 = compute.create_qemu_node(pid, "E2E-R2", image)
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.qemu_anchor_name(n1_id, *ETH)
a2 = harness.qemu_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 IOSv routers on {image} (a real IOS boot — the slow leg of this scenario)")
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)
# NetworkManager can release an NM-managed TAP from its bridge after
# (re-)enslavement (docs/bugs/networkmanager-tap-release.md); take the
# anchors out of its reach for the rest of the run (the restart below
# re-attaches them under fresh devices).
harness.unmanage_from_networkmanager(a1, a2, harness.qemu_anchor_name(n1_id, 1))
# Kernel objects: one persistent TAP per adapter (both adapters of
# router 1, the addressed one of router 2), and the per-link bridge
# has exactly the two addressed ports.
for adapter in range(2):
name = harness.qemu_anchor_name(n1_id, adapter)
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=GUEST_ETH_IF, eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if=GUEST_ETH_IF, eth_ipv6=R2_IP6)
out = c1.run("show ip int brief")
assert re.search(r"GigabitEthernet0/0\s+10\.1\.1\.1\s+\S+\s+\S+\s+up\s+up", out), out
out = c1.run("show ipv6 interface brief")
assert R1_IP6.upper() in out.upper(), out # IOS prints v6 upper-case
print(".. both routers configured, pinging through the anchor TAPs")
baseline = harness.wait_ping(c1, "10.1.1.2")
assert baseline["success"] == 100, baseline["raw"]
baseline6 = harness.wait_ping(c1, R2_IP6)
assert baseline6["success"] == 100, baseline6["raw"]
# L2-anchor spec §E.2: with the guests shut the anchors (QEMU's own
# netdevs) and the per-link bridge stay silent for 5 s.
if harness.l2only_supported():
for console in (c1, c2):
_eth_shutdown(console, shut=True)
harness.assert_idle_silence(a1, a2, bridge)
for console in (c1, c2):
_eth_shutdown(console, shut=False)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
else:
print(".. uBridge without link l2only: skipping the §E.2 assertion")
# 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)
delayed6 = harness.wait_ping(c1, R2_IP6)
assert delayed6["success"] == 100, delayed6["raw"]
assert delayed6["avg"] >= 150, (baseline6, delayed6)
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"] < harness.FAST_RTT_MS, fast
fast6 = harness.wait_ping(c1, R2_IP6)
assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6
# suspend: anchor admin-down — the e1000 loses carrier, the bridge
# port is disabled and nothing crosses; 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"]
dead6 = harness.ping(c1, R2_IP6, repeat=3)
assert dead6["success"] == 0, dead6["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", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
# capture: on the anchor, a real pcap — both families inside the window
capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"})
harness.ping(c1, "10.1.1.2")
harness.ping(c1, R2_IP6)
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 >= 8, f"{path}: {count} records"
assert "0800" in ethertypes, (path, ethertypes)
assert "86dd" in ethertypes, (path, ethertypes)
# delete / re-create the kernel link: the anchor leaves the bridge
# but survives (the running VM holds it open — this is QEMU's own
# netdev), and re-creating re-enslaves it without touching the VM.
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
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
# node stop: QEMU exits and uBridge deletes every anchor of the
# node together with the orphan bridges (a stopped node must not
# litter the host with gq devices); a restart recreates the TAPs
# and rewires the link from the NIO that outlived it.
print(".. stopping and restarting router 1")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop")
second = harness.qemu_anchor_name(n1_id, 1)
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(second), timeout=15), (
a1,
second,
)
# 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=GUEST_ETH_IF, eth_ipv6=R1_IP6)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, 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_qemu_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.
The anchors DO exist and carry: a QEMU node creates its adapter TAPs at
start whenever uBridge can (the netdev needs them), and on the relay
the anchor itself becomes the uBridge relay's AF_PACKET endpoint — up
and forwarding into the relay bridge. The server configuration only
decides whether a *link* enslaves them.
"""
server = harness.live_server(kernel=False)
compute = server.compute
caps = compute.capabilities()
if caps.get("ubridge_tap") is not True:
pytest.skip(
f"this compute's uBridge cannot create the QEMU adapter TAPs (ubridge_tap={caps.get('ubridge_tap')})"
)
image = _pick_image(server)
project = compute.create_project("qemu-relay-e2e")
pid = project["project_id"]
a1 = None
try:
n1 = compute.create_qemu_node(pid, "E2E-R1", image)
n2 = compute.create_qemu_node(pid, "E2E-R2", image)
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.qemu_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 IOSv 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: nobody enslaved the anchors, the per-link
# bridge does not exist — but the anchor is up and carrying as the
# relay's AF_PACKET endpoint
assert harness.bridge_members(harness.link_bridge_name(link["link_id"])) is None
assert harness.tap_exists(a1), a1
assert 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=GUEST_ETH_IF, eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if=GUEST_ETH_IF, eth_ipv6=R2_IP6)
relay_ping = harness.wait_ping(c1, "10.1.1.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
relay_ping6 = harness.wait_ping(c1, R2_IP6)
assert relay_ping6["success"] == 100, relay_ping6["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 adapter TAPs go with them
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1