feat(ubridge): probe iol_bridge's TAP-port support

Whether an IOU port can terminate on a persistent TAP (iol_bridge
add_nio_tap) is a capability of its own: the tap module and that command
land independently, so a build may have one without the other. Probed on
a scratch IOL bridge with a scratch TAP — one add/delete cycle, no
iol_bridge start needed (attaching a NIO works on a stopped bridge) —
cached by binary identity, with the scratch bridge id above the per-node
id space the fabric locks (513..1024) so a probe cannot collide with a
running node. Any failure, including an old build's "Unknown command",
means unknown: IOU stays on the relay datapath.

Reported through /capabilities as ubridge_iol_tap, next to ubridge_tap.
This commit is contained in:
YueGuobin 2026-10-01 00:59:41 +08:00
parent 9a14a91293
commit 200c946eef
No known key found for this signature in database
7 changed files with 178 additions and 4 deletions

View File

@ -25,7 +25,7 @@ from fastapi import APIRouter, Request
from gns3server import schemas
from gns3server.compute import MODULES
from gns3server.compute.ubridge.tc_probe import probe_tc_capabilities, probe_tap_support
from gns3server.compute.ubridge.tc_probe import 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__
@ -56,6 +56,10 @@ async def get_capabilities(request: Request) -> dict:
# datapath. None = unknown (probe failed or uBridge too old).
ubridge_tap = await probe_tap_support()
# IOL-port TAP termination (iol_bridge add_nio_tap): the anchor IOU's
# Ethernet ports need for the kernel datapath. None = unknown.
ubridge_iol_tap = await probe_iol_tap_support()
# record the controller hostname or IP address
if request.client:
request.app.state.controller_host = request.client.host
@ -69,4 +73,5 @@ async def get_capabilities(request: Request) -> dict:
"node_types": node_types,
"ubridge_tc": ubridge_tc,
"ubridge_tap": ubridge_tap,
"ubridge_iol_tap": ubridge_iol_tap,
}

View File

@ -51,6 +51,8 @@ log = logging.getLogger(__name__)
_cache = {}
# binary identity -> whether the tap module works (None = unknown)
_tap_cache = {}
# binary identity -> whether iol_bridge can bind a port to a TAP (None = unknown)
_iol_tap_cache = {}
_lock = asyncio.Lock()
@ -183,3 +185,92 @@ async def _probe_tap(path, config, timeout):
with contextlib.suppress(Exception):
await hypervisor.stop()
shutil.rmtree(working_dir, ignore_errors=True)
# Above the per-node IOL bridge ids: a real node's application id is 1..512,
# so its bridge locks /tmp/netio<uid>/513..1024. 1050 cannot collide with a
# node — only with another probe process, whose failure then answers "unknown"
# (the safe answer: IOU stays on the relay datapath).
_IOL_PROBE_BRIDGE_ID = 1050
async def probe_iol_tap_support(timeout: float = 15.0):
"""
Whether this host's uBridge can terminate an IOL port on a persistent
TAP (``iol_bridge add_nio_tap``) — the anchor IOU's Ethernet ports need
for the kernel datapath. Probed on a scratch IOL bridge with a scratch
TAP (both cleaned up), cached by binary identity like the tap probe;
any failure (missing binary, old build answering "Unknown command",
lock contention with another server process) 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 _iol_tap_cache:
return _iol_tap_cache[key]
supported = await _probe_iol_tap(path, config, timeout)
_iol_tap_cache[key] = supported
return supported
async def _probe_iol_tap(path, config, timeout):
"""
Spawn a throwaway uBridge and run one add_nio_tap/delete_nio_tap cycle on
a scratch bridge and scratch TAP — attaching works on a stopped bridge,
so no iol_bridge start is needed. The scratch resources are cleaned up
best-effort even when the probe itself fails.
"""
working_dir = tempfile.mkdtemp(prefix="gns3-iol-probe-")
hypervisor = Hypervisor(
None,
path,
working_dir,
config.settings.Server.ubridge_control_transport,
config.settings.Server.host,
str(uuid.uuid4()),
)
bridge = f"gns3iolprobe{uuid.uuid4().hex[:4]}"
tap = f"gns3ita{uuid.uuid4().hex[:6]}"
try:
async def ask():
await hypervisor.start()
await hypervisor.connect()
await hypervisor.send(f"iol_bridge create {bridge} {_IOL_PROBE_BRIDGE_ID}")
try:
await hypervisor.send(f"tap create {tap}")
await hypervisor.send(
f"iol_bridge add_nio_tap {bridge} {_IOL_PROBE_BRIDGE_ID - 1} 0 0 {tap}"
)
await hypervisor.send(f"iol_bridge delete_nio_tap {bridge} 0 0")
finally:
# iol_bridge delete releases every port NIO (the TAP fd),
# so the subsequent tap delete cannot hit EBADFD.
with contextlib.suppress(UbridgeError):
await hypervisor.send(f"iol_bridge delete {bridge}")
with contextlib.suppress(UbridgeError):
await hypervisor.send(f"tap delete {tap}")
return True
try:
return await asyncio.wait_for(ask(), timeout=timeout)
except (UbridgeError, OSError, asyncio.TimeoutError, ValueError) as e:
# Old build ("Unknown command"), missing module, or no
# CAP_NET_ADMIN: unknown.
log.debug("uBridge iol-tap probe failed: %s", e)
return None
finally:
with contextlib.suppress(Exception):
await hypervisor.stop()
shutil.rmtree(working_dir, ignore_errors=True)

View File

@ -101,6 +101,7 @@ class Compute:
"node_types": [],
"ubridge_tc": None,
"ubridge_tap": None,
"ubridge_iol_tap": None,
}
self.name = name
# Cache of interfaces on remote host

View File

@ -55,3 +55,9 @@ class Capabilities(BaseModel):
description="Whether uBridge can create the persistent TAPs a QEMU adapter anchors on "
"(needed to wire QEMU links on the kernel datapath); absent when the probe failed",
)
ubridge_iol_tap: Optional[bool] = Field(
None,
description="Whether uBridge can bind an IOL port to a persistent TAP (iol_bridge add_nio_tap, "
"the anchor IOU Ethernet ports need to wire IOU links on the kernel datapath); absent when the "
"probe failed",
)

View File

@ -29,7 +29,7 @@ pytestmark = pytest.mark.asyncio
async def _no_tap_probe():
"""The tap probe spawns uBridge — stub it out for payload assertions."""
"""The probes spawn uBridge — stub them out for payload assertions."""
return None
@ -42,6 +42,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)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
@ -67,6 +68,7 @@ class TestCapabilitiesRoutes:
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
"ubridge_tc": None,
"ubridge_tap": None,
"ubridge_iol_tap": None,
}
async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None:
@ -76,6 +78,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)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
@ -101,6 +104,7 @@ class TestCapabilitiesRoutes:
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
"ubridge_tc": None,
"ubridge_tap": None,
"ubridge_iol_tap": None,
}
async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
@ -110,6 +114,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)
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"] == {
@ -129,6 +134,22 @@ class TestCapabilitiesRoutes:
return True
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)
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
async def test_get_reports_ubridge_iol_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
"""
The IOL-port TAP capability is what makes an IOU node kernel-eligible,
so it travels with the other compute capabilities.
"""
async def fake_iol_tap_probe():
return True
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", fake_iol_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_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_iol_tap"] is True

View File

@ -22,7 +22,7 @@ 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_tc_capabilities, probe_tap_support
from gns3server.compute.ubridge.tc_probe import probe_iol_tap_support, probe_tc_capabilities, probe_tap_support
from gns3server.compute.ubridge.ubridge_error import UbridgeError
pytestmark = pytest.mark.asyncio
@ -35,6 +35,7 @@ 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)
spawned = 0
def __init__(self, project, path, working_dir, transport, host, node_id):
@ -54,6 +55,8 @@ class FakeHypervisor:
self.commands.append(command)
if self.error:
raise UbridgeError(self.error)
if self.error_prefix and command.startswith(self.error_prefix):
raise UbridgeError(f"202-Unknown command")
if command == "tc capabilities":
return FakeHypervisor.reply
return ["OK"]
@ -67,9 +70,11 @@ def probe_env(monkeypatch, tmp_path):
FakeHypervisor.spawned = 0
FakeHypervisor.error = None
FakeHypervisor.error_prefix = None
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()
FakeHypervisor.last = None
# a real file on disk: the cache is keyed on its (path, mtime, size)
binary = tmp_path / "ubridge"
@ -143,3 +148,48 @@ async def test_probe_tap_support_missing_binary(monkeypatch):
monkeypatch.setattr(tc_probe.shutil, "which", lambda name: None)
assert await probe_tap_support() is None
assert FakeHypervisor.spawned == 0
async def test_probe_iol_tap_support_runs_one_cycle_on_a_scratch_bridge():
"""
The probe walks the exact add_nio_tap/delete_nio_tap cycle a kernel link
uses, on a scratch bridge whose id sits above the per-node id space, and
cleans up both scratch resources even when the cycle itself fails.
"""
assert await probe_iol_tap_support() is True
assert FakeHypervisor.spawned == 1
commands = FakeHypervisor.last.commands
assert commands[0].startswith("iol_bridge create gns3iolprobe")
assert commands[0].endswith(" 1050")
assert commands[1].startswith("tap create gns3ita")
assert commands[2].startswith("iol_bridge add_nio_tap ")
assert commands[2].split()[-1].startswith("gns3ita")
assert commands[3].startswith("iol_bridge delete_nio_tap ")
# cleanup: bridge first (it releases the TAP fd), then the device
assert commands[4].startswith("iol_bridge delete ")
assert commands[5].startswith("tap delete ")
async def test_probe_iol_tap_support_unknown_on_an_old_build():
"""
A uBridge without iol_bridge add_nio_tap answers "Unknown command": the
probe reports unknown (IOU stays on the relay datapath), cleans up its
scratch bridge and tap, and caches the failure.
"""
FakeHypervisor.error_prefix = "iol_bridge add_nio_tap"
assert await probe_iol_tap_support() is None
commands = FakeHypervisor.last.commands
assert any(c.startswith("iol_bridge delete ") for c in commands)
assert any(c.startswith("tap delete ") for c in commands)
# cached like the other probes: no respawn on every /capabilities hit
assert await probe_iol_tap_support() is None
assert FakeHypervisor.spawned == 1
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

View File

@ -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},
"capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": [], "ubridge_tc": None, "ubridge_tap": None, "ubridge_iol_tap": None},
}
assert compute.asdict(topology_dump=True) == {
"compute_id": "my_compute_id",