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

+71
View File
@@ -49,6 +49,8 @@ log = logging.getLogger(__name__)
# binary identity -> parsed report (None = probed and unusable)
_cache = {}
# binary identity -> whether the tap module works (None = unknown)
_tap_cache = {}
_lock = asyncio.Lock()
@@ -112,3 +114,72 @@ async def _probe(path, config, timeout):
with contextlib.suppress(Exception):
await hypervisor.stop()
shutil.rmtree(working_dir, ignore_errors=True)
async def probe_tap_support(timeout: float = 15.0):
"""
Whether this host's uBridge can create persistent TAPs (the ``tap``
module), probed the same way as the tc report — a throwaway uBridge, the
answer cached by binary identity. QEMU adapters anchor on such a TAP, so
this is what makes a QEMU node kernel-datapath eligible; an old build
answers "Unknown command", reported as None (unknown) 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 _tap_cache:
return _tap_cache[key]
supported = await _probe_tap(path, config, timeout)
_tap_cache[key] = supported
return supported
async def _probe_tap(path, config, timeout):
"""
Spawn a throwaway uBridge and try to create (then delete) one persistent
TAP. The probe device is named after a uuid so a concurrent probe on the
same host cannot collide; a delete that fails leaves it behind, which is
worth a warning but not a failure of the probe.
"""
working_dir = tempfile.mkdtemp(prefix="gns3-tap-probe-")
hypervisor = Hypervisor(
None,
path,
working_dir,
config.settings.Server.ubridge_control_transport,
config.settings.Server.host,
str(uuid.uuid4()),
)
name = f"gns3tap{uuid.uuid4().hex[:6]}"
try:
async def ask():
await hypervisor.start()
await hypervisor.connect()
await hypervisor.send(f"tap create {name}")
try:
await hypervisor.send(f"tap delete {name}")
except UbridgeError as e:
log.warning("Probe TAP %s could not be deleted: %s", name, e)
return True
try:
return await asyncio.wait_for(ask(), timeout=timeout)
except (UbridgeError, OSError, asyncio.TimeoutError, ValueError) as e:
# Missing module, old build, or no CAP_NET_ADMIN: unknown.
log.debug("uBridge tap-module probe failed: %s", e)
return None
finally:
with contextlib.suppress(Exception):
await hypervisor.stop()
shutil.rmtree(working_dir, ignore_errors=True)