feat: VPCS kernel datapath

A VPCS node's single adapter anchors on a persistent TAP the server
creates (tap create + set_owner via uBridge) and the vpcs process
attaches to by name: -e -d <gp{id8}e0p0> -n, the same
server-creates/guest-attaches shape as QEMU's netdev. -n (a vpcs fork
feature) keeps the guest's ip command from ifconfig'ing the anchor on
the host; the vpcs build is probed once per binary identity
(vpcs_tap capability, usage scan) and gates kernel links together
with ubridge_tap — anything else keeps the node on the relay (the
legacy local UDP tunnel on a stock binary, an AF_PACKET endpoint on
the anchor with a capable one).

VPCSVM takes KernelDatapathMixin: _connect_nio dispatches kernel
(enslave into the per-link bridge) / relay-on-anchor / legacy,
captures pick capture start_kernel vs the bridge engine, stop runs
under an execute lock and terminates the process before deleting the
anchor TAPs (a held device refuses deletion), and the duplicate-port
guard backstops the controller's. The NIO routes accept nio_bridge
and write suspend back on update.

_delete_anchor_taps verifies each tap is gone from sysfs and retries
a bounded three times: a holder can release its fd a moment after its
process was reaped (vpcs's daemon parent kills its console child and
exits first), so the unconditional delete raced EBUSY and leaked the
anchor intermittently.

The vpcs side needs IFNAMSIZ (16) instead of its own IFNAMESIZ (12)
for the -d name buffer — anchor names are 14 chars and truncated to a
nonexistent device, which TUNSETIFF then tries to create without
CAP_NET_ADMIN (fixed in the vpcs fork).

e2e (tests/e2e/test_vpcs_kernel_datapath.py): kernel and relay
scenarios green against the real binary — real ICMP both ways, §E.1/§
E.2 on the anchors, delay/suspend/capture, link delete/re-create,
node stop/restart self-heal, zero leftovers; the relay control passes
with both the fork binary and a stock one.
This commit is contained in:
YueGuobin committed 2026-10-12 17:19:57 +08:00
1 parent 4ced753e8d
commit c7ffe919dc
20 files changed
+1204 -94

No files matched your search

@@ -44,6 +44,7 @@ class TestCapabilitiesRoutes:
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
@@ -71,6 +72,7 @@ class TestCapabilitiesRoutes:
"ubridge_tap": None,
"ubridge_iol_tap": None,
"ubridge_bridge_tap": None,
"vpcs_tap": None,
}
async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None:
@@ -82,6 +84,7 @@ class TestCapabilitiesRoutes:
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
@@ -109,6 +112,7 @@ class TestCapabilitiesRoutes:
"ubridge_tap": None,
"ubridge_iol_tap": None,
"ubridge_bridge_tap": None,
"vpcs_tap": None,
}
async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
@@ -120,6 +124,7 @@ class TestCapabilitiesRoutes:
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["ubridge_tc"] == {
@@ -141,6 +146,7 @@ class TestCapabilitiesRoutes:
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", fake_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["ubridge_tap"] is True
@@ -175,6 +181,24 @@ class TestCapabilitiesRoutes:
)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["ubridge_bridge_tap"] is True
async def test_get_reports_vpcs_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
"""
The VPCS tap-mode capability is what makes a VPCS node
kernel-eligible, so it travels with the other compute capabilities.
"""
async def fake_vpcs_tap_probe():
return True
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", fake_vpcs_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["vpcs_tap"] is True
+78
View File
@@ -0,0 +1,78 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import os
import stat
import pytest
from gns3server.compute.vpcs.vpcs_probe import probe_vpcs_tap
pytestmark = pytest.mark.asyncio
def _fake_vpcs(path, usage):
path.write_text(f"#!/bin/sh\ncat <<'EOF'\n{usage}\nEOF\n")
mode = path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
os.chmod(path, mode)
async def test_probe_reports_dash_n_build(config, tmp_path):
binary = tmp_path / "vpcs"
_fake_vpcs(
binary,
" -d device device name, works only when -i is set to 1\n"
" -n do not auto-configure the host tap interface\n",
)
config.settings.VPCS.vpcs_path = str(binary)
assert await probe_vpcs_tap() is True
async def test_probe_reports_upstream_build(config, tmp_path):
binary = tmp_path / "vpcs"
_fake_vpcs(
binary,
" -d device device name, works only when -i is set to 1\n",
)
config.settings.VPCS.vpcs_path = str(binary)
assert await probe_vpcs_tap() is False
async def test_probe_reports_missing_binary(config, tmp_path):
config.settings.VPCS.vpcs_path = str(tmp_path / "does-not-exist")
assert await probe_vpcs_tap() is None
async def test_probe_caches_by_binary_identity(config, tmp_path):
"""
The same binary answers once; replacing it (a different mtime/size)
re-probes.
"""
binary = tmp_path / "vpcs"
_fake_vpcs(binary, " -n do not auto-configure\n")
config.settings.VPCS.vpcs_path = str(binary)
assert await probe_vpcs_tap() is True
# replace with a build without the flag: the binary identity changed,
# so the cached True must not answer
_fake_vpcs(binary, " -d device device name\n")
os.utime(binary, (0, 0))
assert await probe_vpcs_tap() is False
+180 -1
View File
@@ -45,7 +45,12 @@ async def vm(compute_project, manager, tmpdir, ubridge_path):
vm._start_ubridge = AsyncioMagicMock()
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_hypervisor.is_running.return_value = True
return vm
# The legacy-datapath tests run with an unknown probe answer (None):
# the tap datapath stays off without a project warning, exactly like a
# host whose probe failed. The tap-datapath tests patch the probe to
# True (or False, when they assert the fallback) on top.
with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=None)):
yield vm
@pytest.mark.asyncio
@@ -350,3 +355,177 @@ async def test_close(vm):
await vm.start()
await vm.close()
assert vm.is_running() is False
# ----------------------------------------------------------------------
# Kernel datapath (the adapter anchor is a persistent TAP the vpcs process
# attaches to with -e -d, kept away from host auto-configuration with -n)
# ----------------------------------------------------------------------
async def _start_on_tap_datapath(vm):
"""
Start the node with both probes answering (a -n-capable vpcs build, a
uBridge with the tap module), returning the recorded uBridge commands.
"""
# a startup.vpc left in the shared working directory by an earlier test
# would append its name to the command line (the script tests run in any
# order) — start from a clean one.
try:
os.remove(os.path.join(vm.working_dir, "startup.vpc"))
except FileNotFoundError:
pass
commands = []
async def record_send(command):
commands.append(command)
vm._ubridge_send = AsyncioMagicMock(side_effect=record_send)
process = MagicMock()
process.returncode = None
with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=True)):
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True):
with asyncio_patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec:
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"):
await vm.start()
return mock_exec, commands
@pytest.mark.asyncio
async def test_start_tap_datapath(vm):
"""
A -n-capable vpcs build on a tap-capable uBridge anchors the adapter on
a persistent TAP and speaks tap mode: -e -d <anchor> -n, no UDP tunnel.
"""
mock_exec, commands = await _start_on_tap_datapath(vm)
assert vm._tap_datapath is True
assert vm._kernel_taps == {(0, 0): "gp00010203e0p0"}
assert mock_exec.call_args[0] == (
vm._vpcs_path(),
"-p",
str(vm._internal_console_port),
"-m",
"1",
"-i",
"1",
"-F",
"-R",
"-e",
"-d",
"gp00010203e0p0",
"-n",
)
# the anchor was created through uBridge's tap module, owned by this user
assert 'tap create "gp00010203e0p0"' in commands
assert f"tap set_owner gp00010203e0p0 {os.getuid()}" in commands
# no local UDP tunnel on this datapath
assert vm._local_udp_tunnel is None
@pytest.mark.asyncio
async def test_start_legacy_datapath_when_binary_lacks_dash_n(vm):
"""
A vpcs build without -n cannot be kept away from the host side of the
anchor, so the node keeps the legacy UDP relay datapath.
"""
process = MagicMock()
process.returncode = None
with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=False)):
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True):
with asyncio_patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec:
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"):
await vm.start()
assert vm._tap_datapath is False
assert vm._kernel_taps == {}
command = mock_exec.call_args[0]
assert "-e" not in command
assert "-n" not in command
assert "-s" in command and "-c" in command and "-t" in command
@pytest.mark.asyncio
async def test_stop_terminates_process_before_deleting_taps(vm):
"""
The vpcs process holds the anchor TAP open: a held device refuses
deletion, so the TAP delete must follow the process termination (the
suppressed delete of the old order leaked the anchor).
"""
calls = []
process = MagicMock()
process.returncode = None
process.terminate.side_effect = lambda: calls.append("terminate")
async def record_send(command):
commands.append(command)
commands = []
vm._ubridge_send = AsyncioMagicMock(side_effect=record_send)
with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=True)):
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True):
with asyncio_patch("asyncio.create_subprocess_exec", return_value=process):
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"):
await vm.start()
with asyncio_patch("gns3server.utils.asyncio.wait_for_process_termination"):
await vm.stop()
assert calls == ["terminate"]
# every tap delete (the start-time sweep and the stop-time teardown)
# happened after the process termination
assert all(calls.index("terminate") < commands.index(c) for c in commands if c.startswith("tap delete"))
@pytest.mark.asyncio
async def test_add_nio_binding_duplicate_port_rejected(vm):
"""
A port carries at most one NIO: overwriting the one a link still owns
orphans that link's teardown.
"""
nio = VPCS.instance().create_nio(
{"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}
)
await vm.port_add_nio_binding(0, nio)
with pytest.raises(VPCSError, match="already has a link"):
await vm.port_add_nio_binding(0, nio)
@pytest.mark.asyncio
async def test_kernel_nio_on_legacy_datapath_rejected(vm):
"""
A kernel link to a node running on the legacy relay datapath (no anchor)
is refused with a clear error instead of a late uBridge failure.
"""
vm._process = MagicMock()
vm._process.returncode = None
vm._tap_datapath = False
nio = VPCS.instance().create_nio({"type": "nio_bridge", "bridge": "gns3testbridge", "filters": {}})
with pytest.raises(VPCSError, match="legacy relay datapath"):
await vm.port_add_nio_binding(0, nio)
@pytest.mark.asyncio
async def test_kernel_nio_attach_enslaves_the_anchor(vm):
"""
A kernel link enslaves the anchor into the per-link bridge and installs
the impairment filters the NIO carries — the same _kernel_attach flow
every anchor-backed node type uses.
"""
await _start_on_tap_datapath(vm)
nio = VPCS.instance().create_nio(
{"type": "nio_bridge", "bridge": "gns3testbridge", "filters": {"delay": [100]}}
)
await vm.port_add_nio_binding(0, nio)
sent = [call[0][0] for call in vm._ubridge_send.call_args_list]
assert 'brctl create "gns3testbridge"' in sent
assert 'brctl addif "gns3testbridge" "gp00010203e0p0"' in sent
assert any(c.startswith('tc netem set "gp00010203e0p0" delay 100') for c in sent)
+76 -4
View File
@@ -123,10 +123,11 @@ async def _link(project, node1, node2):
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_for_other_node_types(project):
"""
A node type with no anchor of its own (VPCS, cloud, ...) keeps the relay
however capable the compute is — and a QEMU node on a compute whose
uBridge cannot create persistent TAPs does too, because it runs on the
legacy relay datapath there.
A node type with no anchor of its own (cloud, ...) keeps the relay
however capable the compute is — a QEMU node on a compute whose uBridge
cannot create persistent TAPs does too (it runs on the legacy relay
datapath there), and so does a VPCS node whose binary cannot anchor
(no -n support, so no vpcs_tap capability).
"""
compute = _tap_capable_compute(tap_support=None)
@@ -227,6 +228,77 @@ async def test_dynamips_eligibility_follows_the_tap_capability(project):
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_for_vpcs_with_tap_support(project):
"""
A VPCS node anchors on a persistent TAP the vpcs process attaches to
(-e -d), which needs the tap module plus a vpcs build with -n — both
capabilities reported, and mixable with docker/qemu/iou/dynamips
endpoints on the same compute.
"""
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "vpcs_tap": True, "ubridge_iol_tap": True}
node1 = _node(project, compute, "pc1", node_type="vpcs")
node2 = _node(project, compute, "pc2", node_type="vpcs")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is True
for peer in (
_node(project, compute, "docker1"),
_node(project, compute, "qemu1", node_type="qemu"),
_node(project, compute, "iou1", node_type="iou"),
_node(project, compute, "r1", node_type="dynamips"),
):
link = await _link(project, node1, peer)
assert link._kernel_datapath_eligible(node1, peer) is True
@pytest.mark.asyncio
async def test_vpcs_eligible_for_the_switch_fast_path(project):
"""
A switch link absorbs the peer's anchor into the switch's own bridge,
so only the peer must be able to anchor — a capable VPCS does (the
controller names its gp anchor with the shared contract), an
incapable one stays on the switch's relay port.
"""
switch = _node(project, _tap_capable_compute(), "sw1", node_type="ethernet_switch")
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "vpcs_tap": True}
pc = _node(project, compute, "pc1", node_type="vpcs")
link = await _link(project, switch, pc)
assert link._kernel_datapath_eligible(switch, pc) is True
incapable = MagicMock()
incapable.id = "compute-1"
incapable.capabilities = {"ubridge_tap": True}
pc = _node(project, incapable, "pc1", node_type="vpcs")
link = await _link(project, switch, pc)
assert link._kernel_datapath_eligible(switch, pc) is False
@pytest.mark.asyncio
async def test_vpcs_eligibility_needs_both_capabilities(project):
"""
The tap module and the vpcs -n build land independently: a compute
reporting only one of them keeps VPCS on the relay, and so does an
unreported/failed probe (strict True on both).
"""
node2 = _node(project, _tap_capable_compute(), "docker1")
for capabilities in ({"vpcs_tap": True}, {"ubridge_tap": True, "vpcs_tap": None}, {"ubridge_tap": True}):
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = capabilities
node1 = _node(project, compute, "pc1", node_type="vpcs")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_for_dynamips_serial_ports(project):
"""
+70 -1
View File
@@ -235,6 +235,21 @@ class Compute:
},
)
def create_vpcs_node(self, project_id, name, startup_script=None):
"""A VPCS node: the real vpcs binary on this host behind the server's
TCP console (the driver sends ``ip``/``ping`` like a user would). A
kernel-datapath scenario additionally needs a build with ``-n``
(no host auto-configuration) — see GNS3_E2E_VPCS and the ``vpcs_tap``
capability."""
properties = {"console_type": "telnet"}
if startup_script is not None:
properties["startup_script"] = startup_script
return self.call(
"POST",
f"/projects/{project_id}/nodes",
{"compute_id": "local", "name": name, "node_type": "vpcs", "properties": properties},
)
def create_link(self, project_id, a, b):
return self.call(
"POST",
@@ -298,6 +313,9 @@ allocate_aux_console_ports = False
mmap_support = False
sparse_memory_support = False
ghost_ios_support = False
[VPCS]
vpcs_path = {vpcs}
"""
@@ -374,8 +392,17 @@ def _spawn_isolated(tmpdir, kernel):
os.makedirs(os.path.join(tmpdir, "projects"), exist_ok=True)
port = _free_port()
# GNS3_E2E_VPCS points at a vpcs build with -n (tap mode without host
# auto-configuration) — what the kernel-datapath scenario needs. The
# local fork build (where this feature is developed) is the default when
# present; otherwise the stock binary in PATH keeps the relay scenarios
# running (the kernel scenario skips itself on the vpcs_tap capability).
vpcs = os.environ.get("GNS3_E2E_VPCS")
if not vpcs:
fork_build = os.path.expanduser("~/myCode/GNS3/vpcs/src/vpcs")
vpcs = fork_build if os.path.isfile(fork_build) else (shutil.which("vpcs") or "vpcs")
config = ISOLATED_CONFIG.format(
port=port, ubridge=ubridge, dynamips=dynamips, images=images, tmp=tmpdir, kernel=kernel
port=port, ubridge=ubridge, dynamips=dynamips, images=images, tmp=tmpdir, kernel=kernel, vpcs=vpcs
)
config_path = os.path.join(tmpdir, "gns3_server.conf")
with open(config_path, "w") as f:
@@ -702,6 +729,13 @@ def ios_console(server, project_id, node_id):
return IOSConsole(host, node["console"])
def vpcs_console(server, project_id, node_id):
"""The console of a VPCS node: the vpcs binary's own TCP daemon behind
the server-side console — same dumb driver (its prompt ``PC1>`` matches
the shared prompt regex)."""
return ios_console(server, project_id, node_id)
def configure_ios(
console, hostname, eth_ip=None, eth_ipv6=None, serial_ip=None, clock=False, eth_if="f1/0", serial_if="s2/0"
):
@@ -769,6 +803,34 @@ def wait_ping(console, target, attempts=4, repeat=3, timeout=1):
return result
def vpcs_ping(console, target, count=5, timeout=1):
"""Run a VPCS ping; returns {success, avg, raw} (success in percent).
The reply lines are ``<host> icmp_seq=<n> ttl=<t> time=<ms> ms`` in both
families (VPCS prints the same shape for v4 and v6 targets)."""
out = console.run(f"ping {target} -c {count} -w {int(timeout * 1000)}", timeout=count * timeout + 25)
replies = re.findall(r"icmp_seq=\d+ ttl=\d+ time=([\d.]+) ms", out)
return {
"success": int(100 * len(replies) / count),
"avg": round(sum(float(rtt) for rtt in replies) / len(replies), 1) if replies else None,
"raw": out,
}
def wait_vpcs_ping(console, target, attempts=4, count=5, timeout=1):
"""The VPCS twin of wait_ping: the first exchange of a fresh adjacency
is ARP (a lost first ping is legitimate), so retry until it fully
succeeds; returns the last result either way."""
result = None
for _ in range(attempts):
result = vpcs_ping(console, target, count=count, timeout=timeout)
if result["success"] == 100:
return result
time.sleep(1.5)
return result
# ---------------------------------------------------------------------------
# Docker guest driver (Docker Engine API over the daemon socket)
# ---------------------------------------------------------------------------
@@ -1330,6 +1392,13 @@ def qemu_anchor_name(node_id, adapter, port=0):
return "gq" + node_id.replace("-", "")[:8] + f"e{adapter}p{port}"
def vpcs_anchor_name(node_id, adapter=0, port=0):
"""The VPCS node's anchor TAP name: one persistent TAP for the single
adapter, attached to by name through -e -d (see VPCSVM._tap_name and
utils.kernel_anchor)."""
return "gp" + 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
+260
View File
@@ -0,0 +1,260 @@
#
# 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 VPCS node's kernel datapath (pytest marker
``e2e``): two real VPCS nodes (the local vpcs binary, its own TCP console
driven like a user would), linked through the controller REST API. VPCS is
the node type whose guest leg attaches to a *pre-created* anchor: uBridge
creates the persistent TAP and hands it to this user, and the vpcs process
opens it by name (``-e -d``) while ``-n`` keeps its hands off the host side
of the anchor — the same server-creates/guest-attaches shape as QEMU.
* two VPCS nodes on one compute report ``kernel_datapath`` when the
compute's uBridge can create persistent TAPs (``ubridge_tap``) and the
vpcs build has ``-n`` (``vpcs_tap``); the anchor (``gp`` TAP) exists from
node start, before any link, and the per-link bridge enslaves exactly the
two of them;
* real ICMP crosses the [vpcs tun fd ↔ anchor TAP] leg;
* the L2-anchor spec §E.1: with ``-n`` the anchor carries no address — vpcs
never ran its ``ifconfig`` on it; §E.2 comes free: a VPCS sends nothing
spontaneously (no CDP, no keepalive), so the silence window needs no
guest shutdown;
* 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 capture on the kernel link writes a real pcap;
* link delete releases the anchor from the bridge and it survives (the
running vpcs holds it open) — re-creating the link re-enslaves it;
* node stop terminates vpcs first, then deletes the anchor (a held TAP
refuses deletion — the order is the point); a restart recreates the TAP
and rewires the link from the NIO;
* deleting the project leaves no anchors and no bridges behind.
``test_vpcs_relay_control`` is the negative control on an isolated
relay-configured instance: nothing is enslaved, no per-link bridge exists,
and the relay still carries the pings — with the legacy UDP tunnel on a
stock vpcs, or as an AF_PACKET endpoint on the anchor with a ``-n`` build
(the datapath a link rides is a server configuration, not a node property).
"""
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# VPCS has one adapter with one port; the kernel scenario addresses it.
ETH = (0, 0)
PC1_IP = "10.1.1.1"
PC2_IP = "10.1.1.2"
def _configure(console, ip):
console.run("set pcname E2E-PC")
out = console.run(f"ip {ip}/24")
assert ip in out, out
def test_vpcs_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 persistent TAPs (ubridge_tap={caps.get('ubridge_tap')})")
if caps.get("vpcs_tap") is not True:
pytest.skip(
f"this compute's vpcs binary cannot anchor a kernel link (vpcs_tap={caps.get('vpcs_tap')}); "
"point GNS3_E2E_VPCS (or the server's VPCS.vpcs_path) at a vpcs build with -n"
)
project = compute.create_project("vpcs-e2e")
pid = project["project_id"]
a1 = a2 = None
try:
n1 = compute.create_vpcs_node(pid, "E2E-PC1")
n2 = compute.create_vpcs_node(pid, "E2E-PC2")
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.vpcs_anchor_name(n1_id, *ETH)
a2 = harness.vpcs_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, same as every anchor-backed node).
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"
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start")
assert harness.wait_until(lambda: harness.tap_exists(a1) and harness.tap_exists(a2), timeout=15), (a1, a2)
c1 = harness.vpcs_console(server, pid, n1_id)
c2 = harness.vpcs_console(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 a fresh device).
harness.unmanage_from_networkmanager(a1, a2)
# Kernel objects: one persistent TAP per node, and the per-link
# bridge has exactly the two anchors as members.
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
# L2-anchor spec §E.1: with -n the anchor stays address-free — this
# is the assertion the -n flag exists for (an upstream vpcs would
# have ifconfig'd the anchor on the guest's `ip` command below).
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)
_configure(c1, PC1_IP)
_configure(c2, PC2_IP)
baseline = harness.wait_vpcs_ping(c1, PC2_IP)
assert baseline["success"] == 100, baseline["raw"]
reverse = harness.wait_vpcs_ping(c2, PC1_IP)
assert reverse["success"] == 100, reverse["raw"]
# L2-anchor spec §E.2: a VPCS speaks only when spoken to (no
# platform chatter), so the silence window needs no guest shutdown —
# the two anchors and the bridge must be quiet 2.5 s after the pings.
if harness.l2only_supported():
harness.assert_idle_silence(a1, a2, bridge)
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_vpcs_ping(c1, PC2_IP)
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_vpcs_ping(c1, PC2_IP)
assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast
# suspend: anchor admin-down — the tun fd writes go nowhere and
# nothing crosses; resume restores
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True})
assert not harness.tap_up(a1)
dead = harness.vpcs_ping(c1, PC2_IP, count=2, timeout=0.5)
assert dead["success"] == 0, dead["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False})
assert harness.tap_up(a1)
assert harness.wait_vpcs_ping(c1, PC2_IP, 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.vpcs_ping(c1, PC2_IP, count=3)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop")
count, ethertypes = harness.pcap_records(capture["capture_file_path"])
assert count >= 4, f"{capture['capture_file_path']}: {count} records"
assert "0800" in ethertypes, ethertypes
# delete / re-create the kernel link: the anchor leaves the bridge
# but survives (the running vpcs holds it open), and re-creating
# re-enslaves it without touching the process.
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_vpcs_ping(c1, PC2_IP, attempts=5)["success"] == 100
# node stop: vpcs is terminated first and only then the anchor is
# deleted (the process holds the TAP open — a held device refuses
# deletion and would leak); a restart recreates the TAP, rewires the
# link from the NIO that outlived it, and the reconfigured VPCS
# pings again.
print(".. stopping and restarting PC1")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop")
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
assert harness.wait_until(lambda: harness.tap_exists(a1), timeout=15), a1
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
c1.close()
c1 = harness.vpcs_console(server, pid, n1_id)
_configure(c1, PC1_IP)
assert harness.wait_vpcs_ping(c1, PC2_IP, 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_vpcs_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, with the relay
filters served by the uBridge bridge engine.
"""
server = harness.live_server(kernel=False)
compute = server.compute
project = compute.create_project("vpcs-relay-e2e")
pid = project["project_id"]
try:
n1 = compute.create_vpcs_node(pid, "E2E-PC1")
n2 = compute.create_vpcs_node(pid, "E2E-PC2")
n1_id, n2_id = n1["node_id"], n2["node_id"]
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
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 = harness.vpcs_console(server, pid, n1_id)
c2 = harness.vpcs_console(server, pid, n2_id)
_configure(c1, PC1_IP)
_configure(c2, PC2_IP)
# no kernel wiring: the per-link bridge does not exist
assert harness.bridge_members(harness.link_bridge_name(lid)) is None
relay_ping = harness.wait_vpcs_ping(c1, PC2_IP)
assert relay_ping["success"] == 100, relay_ping["raw"]
# the relay filters keep working: one uBridge bridge filters both
# directions, delay 100 => RTT >= 150 ms
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}})
delayed = harness.wait_vpcs_ping(c1, PC2_IP)
assert delayed["success"] == 100, delayed["raw"]
assert delayed["avg"] >= 150, delayed
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
+2 -1
View File
@@ -33,6 +33,7 @@ def test_every_node_type_has_its_own_prefix():
assert kernel_anchor_name("iou", NODE_ID, 0, 3) == "gi00010203e0p3"
assert kernel_anchor_name("dynamips", NODE_ID, 6, 48) == "gd00010203e6p48"
assert kernel_anchor_name("iol_docker", NODE_ID, 0, 0) == "gx00010203e0p0"
assert kernel_anchor_name("vpcs", NODE_ID, 0, 0) == "gp00010203e0p0"
def test_anchor_type_keys_iol_runner_containers_apart():
@@ -55,7 +56,7 @@ def test_anchor_type_keys_iol_runner_containers_apart():
def test_unknown_node_types_have_no_anchor():
assert kernel_anchor_name("ethernet_switch", NODE_ID, 0, 0) is None
assert kernel_anchor_name("vpcs", NODE_ID, 0, 0) is None
assert kernel_anchor_name("cloud", NODE_ID, 0, 0) is None
def test_names_fit_ifnamsiz():