feat(controller): offer QEMU the kernel datapath, gated per compute

Kernel eligibility becomes "both endpoints on one compute, each able to
anchor": Docker always (adapters are born as veth pairs), QEMU when that
compute's uBridge can create the persistent TAPs its adapters anchor on. The
question is asked per compute, because the link needs both ends anchored --
so a mixed or half-upgraded deployment degrades to the relay instead of
failing an attach.

probe_tap_support() creates and deletes one throwaway persistent TAP through a
scratch uBridge, cached by binary identity like the tc report; /capabilities
exposes `ubridge_tap` (absent = unknown, which keeps links on the relay).

The compute route for a QEMU adapter NIO took UDPNIO only, so a kernel link
was rejected with 422: its body and response now accept
Union[UDPNIO, BridgeNIO] exactly like Docker's.

Stale "Docker-to-Docker" wording in the config, link and MCP descriptions now
talks about anchors, and docs/features/qemu-kernel-datapath.md documents the
datapath -- verified end-to-end on a real instance: 32/32 kernel-datapath
checks and 29/29 on the relay variant.
This commit is contained in:
YueGuobin committed 2026-10-06 00:21:08 +08:00
1 parent 7d2d8c6747
commit 187201a08c
15 files changed
+444 -40

No files matched your search

@@ -28,6 +28,12 @@ from gns3server.version import __version__
pytestmark = pytest.mark.asyncio
async def _no_tap_probe():
"""The tap probe spawns uBridge — stub it out for payload assertions."""
return None
class TestCapabilitiesRoutes:
async def test_get(self, app: FastAPI, compute_client: AsyncClient, windows_platform, monkeypatch) -> None:
@@ -35,6 +41,7 @@ class TestCapabilitiesRoutes:
return None
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)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
@@ -59,6 +66,7 @@ class TestCapabilitiesRoutes:
"memory": psutil.virtual_memory().total,
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
"ubridge_tc": None,
"ubridge_tap": None,
}
async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None:
@@ -67,6 +75,7 @@ class TestCapabilitiesRoutes:
return None
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)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
@@ -91,6 +100,7 @@ class TestCapabilitiesRoutes:
"memory": psutil.virtual_memory().total,
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
"ubridge_tc": None,
"ubridge_tap": None,
}
async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
@@ -99,6 +109,7 @@ class TestCapabilitiesRoutes:
return {"netem": "delay,rate", "ebpf": "1", "cbpf": "0", "ebpf_modes": "nth,quota,window,flow"}
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)
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"] == {
@@ -107,3 +118,17 @@ class TestCapabilitiesRoutes:
"ebpf_modes": ["nth", "quota", "window", "flow"],
"cbpf": False,
}
async def test_get_reports_ubridge_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
"""
The tap capability is what makes a QEMU node kernel-eligible, so it
travels with the other compute capabilities.
"""
async def fake_tap_probe():
return True
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", fake_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
+31 -1
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
from gns3server.compute.ubridge.tc_probe import probe_tc_capabilities, probe_tap_support
from gns3server.compute.ubridge.ubridge_error import UbridgeError
pytestmark = pytest.mark.asyncio
@@ -31,6 +31,8 @@ pytestmark = pytest.mark.asyncio
class FakeHypervisor:
"""Records spawns; answers `tc capabilities` with a canned reply."""
last = None
reply = ["netem=delay,rate;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow"]
error = None
spawned = 0
@@ -38,6 +40,7 @@ class FakeHypervisor:
def __init__(self, project, path, working_dir, transport, host, node_id):
self.commands = []
FakeHypervisor.last = self
FakeHypervisor.spawned += 1
async def start(self):
@@ -66,6 +69,8 @@ def probe_env(monkeypatch, tmp_path):
FakeHypervisor.error = 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()
FakeHypervisor.last = None
# a real file on disk: the cache is keyed on its (path, mtime, size)
binary = tmp_path / "ubridge"
binary.write_bytes(b"")
@@ -113,3 +118,28 @@ async def test_probe_empty_reply_means_unknown():
FakeHypervisor.reply = []
assert await probe_tc_capabilities() is None
async def test_probe_tap_support_creates_and_deletes_a_probe_tap():
assert await probe_tap_support() is True
assert FakeHypervisor.spawned == 1
commands = FakeHypervisor.last.commands
assert commands[0].startswith("tap create ")
assert commands[1].startswith("tap delete ")
async def test_probe_tap_support_unknown_on_an_old_build():
FakeHypervisor.error = "202-Unknown command 'create'"
assert await probe_tap_support() is None
# cached like the tc report: no respawn on every /capabilities hit
assert await probe_tap_support() is None
assert FakeHypervisor.spawned == 1
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
+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},
"capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": [], "ubridge_tc": None, "ubridge_tap": None},
}
assert compute.asdict(topology_dump=True) == {
"compute_id": "my_compute_id",
+89 -6
View File
@@ -90,18 +90,101 @@ async def test_kernel_datapath_eligible(project):
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_for_other_node_types(project):
def _tap_capable_compute(tap_support=True, compute_id="compute-1"):
compute = MagicMock()
compute.id = "compute-1"
node1 = _node(project, compute, "docker1")
node2 = _node(project, compute, "qemu1", node_type="qemu")
compute.id = compute_id
compute.capabilities = {"ubridge_tap": tap_support}
return compute
async def _link(project, node1, node2):
link = UDPLink(project)
await link.add_node(node1, 0, 0, batch=True, dump=False)
await link.add_node(node2, 0, 0, batch=True, dump=False)
return link
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_for_other_node_types(project):
"""
A node type with no anchor of its own (IOU, 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.
"""
compute = _tap_capable_compute(tap_support=None)
node1 = _node(project, compute, "docker1")
node2 = _node(project, compute, "qemu1", node_type="qemu")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
compute = _tap_capable_compute()
node1 = _node(project, compute, "iou1", node_type="iou")
node2 = _node(project, compute, "docker1")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_for_qemu_with_tap_support(project):
"""
QEMU adapters anchor on a persistent TAP, so a compute whose uBridge has
the tap module gets kernel links too — including the mixed docker-to-qemu
case (both anchors end up in the same per-link kernel bridge).
"""
compute = _tap_capable_compute()
node1 = _node(project, compute, "qemu1", node_type="qemu")
node2 = _node(project, compute, "qemu2", node_type="qemu")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is True
node2 = _node(project, compute, "docker1")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_kernel_datapath_capability_is_asked_per_compute(project):
"""
A QEMU node on a compute without the tap module keeps the link on the
relay even when its peer sits on a capable compute — the link needs both
endpoints anchored, so the capability is a two-end AND.
"""
capable = _tap_capable_compute(compute_id="compute-1")
plain = _tap_capable_compute(tap_support=None, compute_id="compute-2")
node1 = _node(project, capable, "qemu1", node_type="qemu")
node2 = _node(project, plain, "qemu2", node_type="qemu")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
# same compute, but the compute cannot anchor: still the relay
node2 = _node(project, _tap_capable_compute(tap_support=None), "qemu2", node_type="qemu")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_prepare_qemu_kernel_link_emits_bridge_nios(project):
"""
The prepared link data is the same NIOBridge spec Docker links use — no
UDP ports, no peer resolution — so nothing else in the controller has to
know which node type the endpoint is.
"""
compute = _tap_capable_compute()
node1 = _node(project, compute, "qemu1", node_type="qemu")
node2 = _node(project, compute, "qemu2", node_type="qemu")
link = await _link(project, node1, node2)
await link._prepare()
assert link.kernel_datapath is True
assert [d["type"] for d in link._link_data] == ["nio_bridge", "nio_bridge"]
assert link._link_data[0]["bridge"] == link._link_data[1]["bridge"]
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_across_computes(project):