gns3-server/tests/compute/ubridge/test_tc_probe.py
YueGuobin 187201a08c
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.
2026-10-06 00:21:08 +08:00

146 lines
4.4 KiB
Python

#
# 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/>.
"""
The standalone `tc capabilities` probe behind the compute /capabilities
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.ubridge_error import UbridgeError
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
def __init__(self, project, path, working_dir, transport, host, node_id):
self.commands = []
FakeHypervisor.last = self
FakeHypervisor.spawned += 1
async def start(self):
pass
async def connect(self):
pass
async def send(self, command):
self.commands.append(command)
if self.error:
raise UbridgeError(self.error)
if command == "tc capabilities":
return FakeHypervisor.reply
return ["OK"]
async def stop(self):
pass
@pytest.fixture(autouse=True)
def probe_env(monkeypatch, tmp_path):
FakeHypervisor.spawned = 0
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"")
monkeypatch.setattr(tc_probe.shutil, "which", lambda name: str(binary))
monkeypatch.setattr(tc_probe, "Hypervisor", FakeHypervisor)
async def test_probe_returns_parsed_report():
caps = await probe_tc_capabilities()
assert caps == {
"netem": "delay,rate",
"ebpf": "1",
"cbpf": "1",
"ebpf_modes": "nth,quota,window,flow",
}
assert FakeHypervisor.spawned == 1
async def test_probe_cached_by_binary_identity():
first = await probe_tc_capabilities()
second = await probe_tc_capabilities()
assert first is second
assert FakeHypervisor.spawned == 1
async def test_probe_failure_means_unknown():
FakeHypervisor.error = "Unknown command" # old uBridge build
assert await probe_tc_capabilities() is None
# the failure is cached too: no respawn loop on every /capabilities hit
assert await probe_tc_capabilities() is None
assert FakeHypervisor.spawned == 1
async def test_probe_missing_binary_means_unknown(monkeypatch):
monkeypatch.setattr(tc_probe.shutil, "which", lambda name: None)
assert await probe_tc_capabilities() is None
assert FakeHypervisor.spawned == 0
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