From c20125c1801ac714fa6c258cf1815c244790c0f0 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 2 Oct 2026 13:30:24 +0800 Subject: [PATCH] feat(compute): probe uBridge's swappable bridge TAP leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge module gained delete_nio_tap (uBridge's gns3server-integration contract, section 'bridge — generic NIO relay with a swappable TAP leg'): a per-port bridge can swap its topology leg between UDP and a persistent TAP through stop -> delete -> add -> start, which is what lets an IOL runner container's port bridge carry a kernel-link anchor. The probe needs no scratch objects and no capabilities: one command against a bridge that cannot exist — 202 (unknown command) is an old build, 214 (bridge doesn't exist) is a new one. Cached by binary identity like every other probe; any failure reports unknown. GET /v3/compute/capabilities gains ubridge_bridge_tap; the controller's default capability dict carries it too (the single schema definition, per the 1e67fbf12 lesson). --- gns3server/api/routes/compute/capabilities.py | 13 ++- gns3server/compute/ubridge/tc_probe.py | 79 +++++++++++++++++++ gns3server/controller/compute.py | 1 + gns3server/schemas/controller/capabilities.py | 6 ++ tests/api/routes/compute/test_capabilities.py | 23 ++++++ tests/compute/ubridge/test_tc_probe.py | 58 +++++++++++++- tests/controller/test_compute.py | 2 +- 7 files changed, 179 insertions(+), 3 deletions(-) diff --git a/gns3server/api/routes/compute/capabilities.py b/gns3server/api/routes/compute/capabilities.py index df5e9d0e6..9e6a2d2bd 100644 --- a/gns3server/api/routes/compute/capabilities.py +++ b/gns3server/api/routes/compute/capabilities.py @@ -25,7 +25,12 @@ from fastapi import APIRouter, Request from gns3server import schemas from gns3server.compute import MODULES -from gns3server.compute.ubridge.tc_probe import probe_iol_tap_support, probe_tc_capabilities, probe_tap_support +from gns3server.compute.ubridge.tc_probe import ( + probe_bridge_tap_support, + probe_iol_tap_support, + probe_tc_capabilities, + probe_tap_support, +) from gns3server.utils.path import get_default_project_directory from gns3server.utils.tc_capabilities import usable_ebpf_modes from gns3server.version import __version__ @@ -60,6 +65,11 @@ async def get_capabilities(request: Request) -> dict: # Ethernet ports need for the kernel datapath. None = unknown. ubridge_iol_tap = await probe_iol_tap_support() + # Swappable TAP leg on the generic bridge module (bridge delete_nio_tap): + # what lets an IOL runner container's per-port bridge carry a kernel-link + # anchor. None = unknown (probe failed or uBridge too old). + ubridge_bridge_tap = await probe_bridge_tap_support() + # record the controller hostname or IP address if request.client: request.app.state.controller_host = request.client.host @@ -74,4 +84,5 @@ async def get_capabilities(request: Request) -> dict: "ubridge_tc": ubridge_tc, "ubridge_tap": ubridge_tap, "ubridge_iol_tap": ubridge_iol_tap, + "ubridge_bridge_tap": ubridge_bridge_tap, } diff --git a/gns3server/compute/ubridge/tc_probe.py b/gns3server/compute/ubridge/tc_probe.py index 23ed916fb..7e5e19d1c 100644 --- a/gns3server/compute/ubridge/tc_probe.py +++ b/gns3server/compute/ubridge/tc_probe.py @@ -53,6 +53,8 @@ _cache = {} _tap_cache = {} # binary identity -> whether iol_bridge can bind a port to a TAP (None = unknown) _iol_tap_cache = {} +# binary identity -> whether the bridge module can release a named TAP NIO (None = unknown) +_bridge_tap_cache = {} _lock = asyncio.Lock() @@ -274,3 +276,80 @@ async def _probe_iol_tap(path, config, timeout): with contextlib.suppress(Exception): await hypervisor.stop() shutil.rmtree(working_dir, ignore_errors=True) + + +async def probe_bridge_tap_support(timeout: float = 15.0): + """ + Whether this host's uBridge has ``bridge delete_nio_tap`` — the command + that makes the bridge module's TAP leg swappable (stop -> delete -> add -> + start), which is what lets an IOL runner container's per-port bridge swap + its topology leg between UDP (relay) and a persistent TAP anchor (kernel + datapath). Probed with no scratch objects and no capabilities needed: + deleting from a bridge that doesn't exist answers 214 on a build with the + command and 202 (unknown command) on one without. Cached by binary + identity like the other probes; any failure means unknown, reported as + None and cached as such. + """ + + config = Config.instance() + path = shutil.which(config.settings.Server.ubridge_path) + if not path: + return None + try: + stat = os.stat(path) + key = (path, stat.st_mtime_ns, stat.st_size) + except OSError: + return None + + async with _lock: + if key in _bridge_tap_cache: + return _bridge_tap_cache[key] + supported = await _probe_bridge_tap(path, config, timeout) + _bridge_tap_cache[key] = supported + return supported + + +async def _probe_bridge_tap(path, config, timeout): + """ + Spawn a throwaway uBridge and issue the probe command against a bridge + name that cannot exist (uuid). The reply splits the builds: "bridge ... + doesn't exist" (214) means the command ran — this is the new build; + "Unknown command" (202) is the old one. Both arrive as UbridgeError (the + wrapper raises on any non-100 reply), so the message is the discriminator. + """ + + working_dir = tempfile.mkdtemp(prefix="gns3-brtap-probe-") + hypervisor = Hypervisor( + None, + path, + working_dir, + config.settings.Server.ubridge_control_transport, + config.settings.Server.host, + str(uuid.uuid4()), + ) + try: + + async def ask(): + await hypervisor.start() + await hypervisor.connect() + try: + await hypervisor.send(f"bridge delete_nio_tap gns3brtapprobe{uuid.uuid4().hex[:4]} any") + except UbridgeError as e: + if "doesn't exist" in str(e): + return True + # "Unknown command" (and anything else): old or broken build. + log.debug("uBridge bridge-tap probe answered: %s", e) + return None + # A 100 here would mean the bridge existed — impossible on this + # uuid name; treat it as unknown rather than supported. + return None + + try: + return await asyncio.wait_for(ask(), timeout=timeout) + except (OSError, asyncio.TimeoutError, ValueError) as e: + log.debug("uBridge bridge-tap probe failed: %s", e) + return None + finally: + with contextlib.suppress(Exception): + await hypervisor.stop() + shutil.rmtree(working_dir, ignore_errors=True) diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index 8162a5e9c..863e7a8c0 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -102,6 +102,7 @@ class Compute: "ubridge_tc": None, "ubridge_tap": None, "ubridge_iol_tap": None, + "ubridge_bridge_tap": None, } self.name = name # Cache of interfaces on remote host diff --git a/gns3server/schemas/controller/capabilities.py b/gns3server/schemas/controller/capabilities.py index 106df4b0f..d8aaf591b 100644 --- a/gns3server/schemas/controller/capabilities.py +++ b/gns3server/schemas/controller/capabilities.py @@ -61,3 +61,9 @@ class Capabilities(BaseModel): "the anchor IOU Ethernet ports need to wire IOU links on the kernel datapath); absent when the " "probe failed", ) + ubridge_bridge_tap: Optional[bool] = Field( + None, + description="Whether uBridge's generic bridge module can release a named TAP NIO " + "(bridge delete_nio_tap, the swappable TAP leg an IOL runner container's per-port bridge " + "needs to wire its links on the kernel datapath); absent when the probe failed", + ) diff --git a/tests/api/routes/compute/test_capabilities.py b/tests/api/routes/compute/test_capabilities.py index b196de34c..97d7948c5 100644 --- a/tests/api/routes/compute/test_capabilities.py +++ b/tests/api/routes/compute/test_capabilities.py @@ -43,6 +43,7 @@ class TestCapabilitiesRoutes: monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_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() == { @@ -69,6 +70,7 @@ class TestCapabilitiesRoutes: "ubridge_tc": None, "ubridge_tap": None, "ubridge_iol_tap": None, + "ubridge_bridge_tap": None, } async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None: @@ -79,6 +81,7 @@ class TestCapabilitiesRoutes: monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_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() == { @@ -105,6 +108,7 @@ class TestCapabilitiesRoutes: "ubridge_tc": None, "ubridge_tap": None, "ubridge_iol_tap": None, + "ubridge_bridge_tap": None, } async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None: @@ -115,6 +119,7 @@ class TestCapabilitiesRoutes: monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", fake_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()["ubridge_tc"] == { @@ -135,6 +140,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) 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 @@ -153,3 +159,20 @@ class TestCapabilitiesRoutes: response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK assert response.json()["ubridge_iol_tap"] is True + + async def test_get_reports_ubridge_bridge_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None: + """ + The swappable-bridge-TAP-leg capability is what makes an IOL runner + container kernel-eligible, so it travels with the other compute + capabilities. + """ + + async def fake_bridge_tap_probe(): + return True + + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", fake_bridge_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) + 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 diff --git a/tests/compute/ubridge/test_tc_probe.py b/tests/compute/ubridge/test_tc_probe.py index 96735ab8e..9408c70c0 100644 --- a/tests/compute/ubridge/test_tc_probe.py +++ b/tests/compute/ubridge/test_tc_probe.py @@ -22,7 +22,12 @@ payload: spawn a throwaway uBridge, ask once, cache by binary identity. import pytest from gns3server.compute.ubridge import tc_probe -from gns3server.compute.ubridge.tc_probe import probe_iol_tap_support, probe_tc_capabilities, probe_tap_support +from gns3server.compute.ubridge.tc_probe import ( + probe_bridge_tap_support, + probe_iol_tap_support, + probe_tc_capabilities, + probe_tap_support, +) from gns3server.compute.ubridge.ubridge_error import UbridgeError pytestmark = pytest.mark.asyncio @@ -36,6 +41,10 @@ class FakeHypervisor: reply = ["netem=delay,rate;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow"] error = None error_prefix = None # fail only commands starting with this (a build missing one command) + # what `bridge delete_nio_tap` answers: a new build's 214 (the bridge + # doesn't exist), None = answer 100 (an impossible reply on the probe's + # uuid name — the probe must treat it as unknown) + bridge_tap_error = "214-bridge 'nosuch' doesn't exist" spawned = 0 def __init__(self, project, path, working_dir, transport, host, node_id): @@ -57,6 +66,10 @@ class FakeHypervisor: raise UbridgeError(self.error) if self.error_prefix and command.startswith(self.error_prefix): raise UbridgeError(f"202-Unknown command") + if command.startswith("bridge delete_nio_tap "): + if FakeHypervisor.bridge_tap_error: + raise UbridgeError(FakeHypervisor.bridge_tap_error) + return ["OK"] if command == "tc capabilities": return FakeHypervisor.reply return ["OK"] @@ -71,10 +84,12 @@ def probe_env(monkeypatch, tmp_path): FakeHypervisor.spawned = 0 FakeHypervisor.error = None FakeHypervisor.error_prefix = None + FakeHypervisor.bridge_tap_error = "214-bridge 'nosuch' doesn't exist" FakeHypervisor.reply = ["netem=delay,rate;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow"] tc_probe._cache.clear() tc_probe._tap_cache.clear() tc_probe._iol_tap_cache.clear() + tc_probe._bridge_tap_cache.clear() FakeHypervisor.last = None # a real file on disk: the cache is keyed on its (path, mtime, size) binary = tmp_path / "ubridge" @@ -193,3 +208,44 @@ async def test_probe_iol_tap_support_missing_binary(monkeypatch): monkeypatch.setattr(tc_probe.shutil, "which", lambda name: None) assert await probe_iol_tap_support() is None assert FakeHypervisor.spawned == 0 + + +async def test_probe_bridge_tap_support_asks_one_unused_bridge(): + """ + The probe needs no scratch objects and no capabilities: one command + against a bridge that cannot exist, whose 214 answer means the command + ran (new build). Nothing is created, nothing needs cleaning up. + """ + + assert await probe_bridge_tap_support() is True + assert FakeHypervisor.spawned == 1 + commands = FakeHypervisor.last.commands + assert len(commands) == 1 + assert commands[0].startswith("bridge delete_nio_tap gns3brtapprobe") + # cached like the other probes: no respawn on every /capabilities hit + assert await probe_bridge_tap_support() is True + assert FakeHypervisor.spawned == 1 + + +async def test_probe_bridge_tap_support_unknown_on_an_old_build(): + """ + A uBridge without the command answers 202 "Unknown command": unknown, so + IOL runner containers stay on the relay datapath. A 100 answer is + unknown too — the bridge "existed" on the probe's uuid name, which + cannot happen on a sane build. + """ + + FakeHypervisor.error_prefix = "bridge delete_nio_tap" + assert await probe_bridge_tap_support() is None + + FakeHypervisor.error_prefix = None + FakeHypervisor.bridge_tap_error = None + tc_probe._bridge_tap_cache.clear() + assert await probe_bridge_tap_support() is None + + +async def test_probe_bridge_tap_support_missing_binary(monkeypatch): + + monkeypatch.setattr(tc_probe.shutil, "which", lambda name: None) + assert await probe_bridge_tap_support() is None + assert FakeHypervisor.spawned == 0 diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index ee48a143e..17b96b4ad 100644 --- a/tests/controller/test_compute.py +++ b/tests/controller/test_compute.py @@ -381,7 +381,7 @@ async def test_json(compute): "disk_usage_percent": 0, "connected": True, "last_error": None, - "capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": [], "ubridge_tc": None, "ubridge_tap": None, "ubridge_iol_tap": None}, + "capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": [], "ubridge_tc": None, "ubridge_tap": None, "ubridge_iol_tap": None, "ubridge_bridge_tap": None}, } assert compute.asdict(topology_dump=True) == { "compute_id": "my_compute_id",