mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-13 22:15:35 +03:00
294 lines
13 KiB
Python
294 lines
13 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;
|
|
* 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.
|
|
"""
|
|
|
|
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"
|
|
|
|
|
|
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 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)
|
|
|
|
# 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)
|
|
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if=GUEST_ETH_IF)
|
|
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
|
|
print(".. both routers configured, pinging through the anchor TAPs")
|
|
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 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"]
|
|
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
|
|
|
|
# capture: on the anchor, 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 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
|
|
|
|
# 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)
|
|
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_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)
|
|
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if=GUEST_ETH_IF)
|
|
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 adapter TAPs go with them
|
|
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
|