mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-18 00:50:09 +03:00
Cross-compute links can ride kernel VXLAN tunnels instead of the uBridge
UDP relay: each endpoint creates one VXLAN device (uBridge's rtnetlink
vxlan module) enslaved into the same per-link Linux bridge the same-host
datapath uses, so filters, markers, capture and suspend keep running on
the anchor unchanged. Opt-in (Server.enable_vxlan_cross_compute, default
off) because the tunnel caps the usable MTU at the underlay minus the
outer headers; the relay stays the default and the fallback.
The controller derives what the pair must agree on: one VNI per link
(upper VNI space, per-link random salt) and deterministic device names
(gvl{link}{node}) that make crashed-run leftovers claimable
(show -> delete -> create). Same-host compute pairs, switch endpoints
and serial/unix-socket containers stay on the relay.
A VNI the kernel refuses self-heals on every path it can surface:
interactive create retries a fresh salt then degrades to the relay;
project open re-dispatches refused batch entries the same way; node
start recognises the refused device, stops the half-started node,
re-wires both ends on a fresh VNI (or degrades the link) and retries
the start. The device MTU is derived from the lower device (dev=) and
the per-link bridge inherits it - no configuration. The capability
probe (ubridge_vxlan in /capabilities) runs one scratch create/delete
on a scratch port that never equals the configured dstport.
The e2e scenario runs two computes on one machine (a network namespace
joined by a veth pair as the fabric) and needs root.
329 lines
11 KiB
Python
329 lines
11 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 os
|
|
|
|
import pytest
|
|
|
|
from gns3server.compute.ubridge import tc_probe
|
|
from gns3server.compute.ubridge.tc_probe import (
|
|
probe_bridge_tap_support,
|
|
probe_iol_tap_support,
|
|
probe_tap_support,
|
|
probe_tc_capabilities,
|
|
probe_vxlan_support,
|
|
)
|
|
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
|
from gns3server.config import Config
|
|
|
|
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
|
|
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):
|
|
|
|
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 self.error_prefix and command.startswith(self.error_prefix):
|
|
raise UbridgeError("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"]
|
|
|
|
async def stop(self):
|
|
pass
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
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()
|
|
tc_probe._vxlan_cache.clear()
|
|
FakeHypervisor.last = None
|
|
# a real file on disk: the cache is keyed on its (path, mtime, size,
|
|
# ctime)
|
|
binary = tmp_path / "ubridge"
|
|
binary.write_bytes(b"")
|
|
monkeypatch.setattr(tc_probe.shutil, "which", lambda name: str(binary))
|
|
monkeypatch.setattr(tc_probe, "Hypervisor", FakeHypervisor)
|
|
return str(binary)
|
|
|
|
|
|
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_reprobes_when_the_binary_attributes_change(probe_env):
|
|
|
|
assert await probe_tc_capabilities() is not None
|
|
# chmod touches ctime but neither mtime nor size: a setcap/chmod remedy
|
|
# after a permission-related probe failure must be able to flip the
|
|
# cached verdict without a server restart.
|
|
os.chmod(probe_env, 0o600)
|
|
assert await probe_tc_capabilities() is not None
|
|
assert FakeHypervisor.spawned == 2
|
|
|
|
|
|
async def test_probe_bridge_tap_unknown_when_ubridge_will_not_start(monkeypatch):
|
|
"""start() can raise UbridgeError (unusable binary): unknown, never a
|
|
failed /capabilities response."""
|
|
|
|
async def failing_start(self):
|
|
raise UbridgeError("uBridge executable version must be >= 2.4.0")
|
|
|
|
monkeypatch.setattr(FakeHypervisor, "start", failing_start)
|
|
assert await probe_bridge_tap_support() is None
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
class _FixedUuid:
|
|
"""A uuid4 stand-in whose .int/.hex pin the probe's random draws."""
|
|
|
|
def __init__(self, value):
|
|
self.int = value
|
|
self.hex = f"{value:032x}"
|
|
|
|
|
|
def _probe_create_port():
|
|
"""The dstport of the one `vxlan create` the probe issued."""
|
|
|
|
create = next(c for c in FakeHypervisor.last.commands if c.startswith("vxlan create "))
|
|
return int(create.rsplit("dstport=", 1)[1])
|
|
|
|
|
|
async def test_probe_vxlan_avoids_the_configured_dstport(monkeypatch, probe_env):
|
|
"""
|
|
A scratch draw landing exactly on the configured dstport must move: on
|
|
that port the scratch VNI shares a demux table with the live links'
|
|
devices, and an unlucky VNI draw would make the probe report the
|
|
capability unknown for the process's lifetime (the result is cached).
|
|
"""
|
|
|
|
# int % 10000 == 9999 -> the scratch window draws its very last value
|
|
monkeypatch.setattr(tc_probe.uuid, "uuid4", lambda: _FixedUuid(29999))
|
|
settings = Config.instance().settings.Server
|
|
monkeypatch.setattr(settings, "vxlan_dstport", 29999)
|
|
|
|
assert await probe_vxlan_support() is True
|
|
assert _probe_create_port() == 20000
|
|
|
|
|
|
async def test_probe_vxlan_scratch_port_stays_off_the_configured_one(monkeypatch, probe_env):
|
|
"""
|
|
Whatever the configured dstport, the probe's socket never lands on it
|
|
and stays inside its scratch window.
|
|
"""
|
|
|
|
settings = Config.instance().settings.Server
|
|
for configured in (14789, 20000, 25000, 29999):
|
|
monkeypatch.setattr(settings, "vxlan_dstport", configured)
|
|
tc_probe._vxlan_cache.clear()
|
|
assert await probe_vxlan_support() is True
|
|
port = _probe_create_port()
|
|
assert 20000 <= port <= 29999, port
|
|
assert port != configured, (port, configured)
|