mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 21:45:41 +03:00
test(e2e): live IOL runner container kernel datapath suite
Two real iol-xe containers (Cisco CML iol-runner images, real IOS-XE on the telnet console) against an isolated instance, the real uBridge with the swappable bridge TAP leg: * kernel link reports kernel_datapath; anchors exist from node start and the per-link bridge enslaves exactly the two of them; * real ICMP crosses the [unix <-> tap] port-bridge relay; delay 100 lands as netem on both anchors with the measured RTT growing >= 150 ms and returning < 50 ms when cleared; * suspend admin-downs the anchor and kills the traffic; resume restores; AF_PACKET capture writes a real pcap; * link delete/re-create swaps the port leg out and back in with the ping returning; node stop leaves no gx devices and a start recreates the whole wiring from the NIO; project delete cleans everything; * the relay control on a relay-configured instance keeps the link off the kernel and still pings (anchors may exist idle — the same semantics as QEMU's and Dynamips' anchors: the server flag decides attachment, not anchor birth). Harness: create_iol_router (the appliance's template properties), docker_images, iol_anchor_name, stage_iol_base_config (the shipped iol-xe-base.txt copied into the isolated instance's configs dir — the GNS3_IOL_STARTUP_CONFIG lookup path), boot_wait(straight_prompt=True) for images that boot from NVRAM straight into the CLI with no 'Press RETURN' sentinel, and GNS3_E2E_DEBUG=1 to run the isolated instance with debug logging (the uBridge command stream lands in server.log — how the ensure-anchor EBUSY was pinned down).
This commit is contained in:
parent
13c1ffb951
commit
72aa379e87
@ -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`).
|
||||
|
||||
@ -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:
|
||||
|
||||
249
tests/e2e/test_iol_docker_kernel_datapath.py
Normal file
249
tests/e2e/test_iol_docker_kernel_datapath.py
Normal file
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user