feat(compute): probe uBridge's swappable bridge TAP leg

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).
This commit is contained in:
YueGuobin committed 2026-10-06 00:21:09 +08:00
1 parent d6851695cc
commit c20125c180
7 files changed
+179 -3

No files matched your search

@@ -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
+57 -1
View File
@@ -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
+1 -1
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, "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",