mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
feat(compute): report uBridge tc capabilities and gate filters on them
Consume uBridge's ebpf_modes contract (feature/tc-window 40c36ae): a
stateful mode is usable iff ebpf=1 and its token is listed; builds
predating the field keep their shipped modes (nth/quota/flow), so the
pre-correction window semantics can never be installed silently.
- gns3server/utils/tc_capabilities.py: shared reply parser, mode
policy and filter-type mappings (compute and controller gate on the
same rules)
- gns3server/compute/ubridge/tc_probe.py: standalone probe spawning a
throwaway uBridge as the server's own user, cached by binary
identity (path/mtime/size - the version string cannot distinguish
rebuilds); failures cache as unknown
- GET /v3/compute/capabilities gains ubridge_tc {netem, ebpf,
ebpf_modes, cbpf}; the controller forwards it (Compute.capabilities)
and available_filters hides kernel-datapath filter types an endpoint
compute cannot run (both ends apply their own filters, so one
incapable compute hides the type); computes without a report keep
the full list and apply-time 409 stays the guard
- docker: _ubridge_apply_ebpf_drops gates per mode token; the cleanup
path only sends "off" for modes the build declares
- split DockerVM's kernel-datapath machinery (veth lifecycle, carrier,
marker primitives, tc filter translation, kernel NIO teardown) into
compute/docker/docker_kernel_datapath.py (DockerKernelDatapathMixin)
This commit is contained in:
18 files changed
+1280
-571
No files matched your search
@@ -29,8 +29,12 @@ pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestCapabilitiesRoutes:
|
||||
async def test_get(self, app: FastAPI, compute_client: AsyncClient, windows_platform) -> None:
|
||||
async def test_get(self, app: FastAPI, compute_client: AsyncClient, windows_platform, monkeypatch) -> None:
|
||||
|
||||
async def no_probe():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
@@ -54,10 +58,15 @@ class TestCapabilitiesRoutes:
|
||||
"cpus": psutil.cpu_count(logical=True),
|
||||
"memory": psutil.virtual_memory().total,
|
||||
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
|
||||
"ubridge_tc": None,
|
||||
}
|
||||
|
||||
async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm) -> None:
|
||||
async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None:
|
||||
|
||||
async def no_probe():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
@@ -81,4 +90,20 @@ class TestCapabilitiesRoutes:
|
||||
"cpus": psutil.cpu_count(logical=True),
|
||||
"memory": psutil.virtual_memory().total,
|
||||
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
|
||||
"ubridge_tc": None,
|
||||
}
|
||||
|
||||
async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
|
||||
|
||||
async def fake_probe():
|
||||
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)
|
||||
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"] == {
|
||||
"netem": ["delay", "rate"],
|
||||
"ebpf": True,
|
||||
"ebpf_modes": ["nth", "quota", "window", "flow"],
|
||||
"cbpf": False,
|
||||
}
|
||||
@@ -569,6 +569,66 @@ async def test_apply_window_drop_requires_ebpf_capability(vm):
|
||||
vm._ubridge_send.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_window_drop_requires_mode_token(vm):
|
||||
"""
|
||||
ebpf=1 is not enough: a build that does not declare the window mode
|
||||
token (pre-correction feature/tc-precision semantics) must get a clear
|
||||
upgrade error instead of silently installing back-to-back windows.
|
||||
"""
|
||||
|
||||
vm._ubridge_hypervisor = _caps_hypervisor(ebpf="1", modes="nth,quota")
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
|
||||
with pytest.raises(DockerError, match="ebpf_modes=nth,quota"):
|
||||
await vm._ubridge_apply_ebpf_drops(host_ifc, {"window_drop": [0, 2000, 100]})
|
||||
vm._ubridge_send.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_ebpf_drops_legacy_build_keeps_shipped_modes(vm):
|
||||
"""
|
||||
A build predating the ebpf_modes field (feature/tc-precision) keeps the
|
||||
modes that shipped with it: nth/quota still apply, window does not.
|
||||
"""
|
||||
|
||||
vm._ubridge_hypervisor = _caps_hypervisor(ebpf="1", modes=None)
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
|
||||
await vm._ubridge_apply_ebpf_drops(host_ifc, {"frequency_drop": [3]})
|
||||
vm._ubridge_send.assert_any_call(f'tc nth_drop "{host_ifc}" 3')
|
||||
vm._ubridge_send.assert_any_call(f'tc quota_drop "{host_ifc}" off')
|
||||
|
||||
vm._ubridge_send.reset_mock()
|
||||
with pytest.raises(DockerError, match="newer uBridge"):
|
||||
await vm._ubridge_apply_ebpf_drops(host_ifc, {"window_drop": [0, 2000, 100]})
|
||||
vm._ubridge_send.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mode_off_skips_undeclared_modes(vm):
|
||||
"""
|
||||
The cleanup path only sends "off" for modes this build declares — an
|
||||
undeclared mode's command would be an unknown command on that build.
|
||||
"""
|
||||
|
||||
vm._ubridge_hypervisor = _caps_hypervisor(ebpf="1", modes="nth")
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
|
||||
# A first apply with a filter forces the capability probe; the second
|
||||
# (filters removed) takes the cached caps down the off path.
|
||||
await vm._ubridge_apply_ebpf_drops(host_ifc, {"frequency_drop": [3]})
|
||||
vm._ubridge_send.reset_mock()
|
||||
await vm._ubridge_apply_ebpf_drops(host_ifc, {})
|
||||
|
||||
vm._ubridge_send.assert_any_call(f'tc nth_drop "{host_ifc}" off')
|
||||
sent = [call.args[0] for call in vm._ubridge_send.call_args_list]
|
||||
assert not any("quota_drop" in command or "window_drop" in command for command in sent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_ebpf_drops_skips_old_ubridge_without_filters(vm):
|
||||
"""
|
||||
@@ -605,11 +665,18 @@ async def test_apply_ebpf_drops_cleanup_only_probes_with_ebpf(vm):
|
||||
vm._ubridge_send.assert_not_called()
|
||||
|
||||
|
||||
def _caps_hypervisor(cbpf="1", ebpf="0"):
|
||||
"""A hypervisor mock whose direct send() answers `tc capabilities`."""
|
||||
def _caps_hypervisor(cbpf="1", ebpf="0", modes="nth,quota,window"):
|
||||
"""
|
||||
A hypervisor mock whose direct send() answers `tc capabilities`.
|
||||
modes=None emulates a build predating the ebpf_modes field.
|
||||
"""
|
||||
|
||||
reply = f"netem=delay;ebpf={ebpf};cbpf={cbpf}"
|
||||
if modes is not None:
|
||||
reply += f";ebpf_modes={modes}"
|
||||
hyp = MagicMock()
|
||||
hyp.is_running.return_value = True
|
||||
hyp.send = AsyncioMagicMock(return_value=[f"netem=delay;ebpf={ebpf};cbpf={cbpf}"])
|
||||
hyp.send = AsyncioMagicMock(return_value=[reply])
|
||||
return hyp
|
||||
|
||||
|
||||
@@ -682,7 +749,7 @@ async def test_apply_bpf_drops_compile_failure_warns(vm):
|
||||
raise UbridgeError("209-Cannot compile filter 'icmp': can't parse filter expression: syntax error")
|
||||
|
||||
vm._ubridge_send = AsyncioMagicMock(side_effect=send)
|
||||
with patch("gns3server.compute.docker.docker_vm.log.warning") as mock_log:
|
||||
with patch("gns3server.compute.docker.docker_kernel_datapath.log.warning") as mock_log:
|
||||
await vm._ubridge_apply_bpf_drops(host_ifc, {"bpf": ["icmp"]}) # must not raise
|
||||
assert mock_log.called
|
||||
|
||||
@@ -708,7 +775,7 @@ async def test_ubridge_tc_capabilities_probed_once(vm):
|
||||
|
||||
vm._ubridge_hypervisor = _caps_hypervisor()
|
||||
caps = await vm._ubridge_tc_capabilities()
|
||||
assert caps == {"netem": "delay", "ebpf": "0", "cbpf": "1"}
|
||||
assert caps == {"netem": "delay", "ebpf": "0", "cbpf": "1", "ebpf_modes": "nth,quota,window"}
|
||||
await vm._ubridge_tc_capabilities()
|
||||
assert vm._ubridge_hypervisor.send.call_count == 1
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#
|
||||
# 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
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class FakeHypervisor:
|
||||
"""Records spawns; answers `tc capabilities` with a canned reply."""
|
||||
|
||||
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.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()
|
||||
# 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
|
||||
@@ -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": []},
|
||||
"capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": [], "ubridge_tc": None},
|
||||
}
|
||||
assert compute.asdict(topology_dump=True) == {
|
||||
"compute_id": "my_compute_id",
|
||||
|
||||
@@ -548,3 +548,45 @@ async def test_prepare_relay_link_strips_kernel_only_filters(project):
|
||||
# carries no filters — the relay filters one bridge)
|
||||
assert by_node[node1.id]["filters"] == {"frequency_drop": [7], "delay": [100, 20], "packet_loss": [5]}
|
||||
assert by_node[node2.id]["filters"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_available_filters_gated_by_compute_capabilities(project):
|
||||
"""
|
||||
Kernel links only offer the filter types the endpoints' computes report
|
||||
a uBridge able to run (ebpf_modes tokens, cbpf, netem keywords).
|
||||
"""
|
||||
|
||||
link, node1, _node2 = await _kernel_link(project)
|
||||
link._link_data = [{"type": "nio_bridge"}]
|
||||
node1.compute.capabilities = {
|
||||
"ubridge_tc": {
|
||||
"netem": ["delay", "jitter", "loss", "rate"],
|
||||
"ebpf": True,
|
||||
"ebpf_modes": ["nth", "quota"],
|
||||
"cbpf": False,
|
||||
}
|
||||
}
|
||||
types = [f["type"] for f in link.available_filters()]
|
||||
assert "frequency_drop" in types # nth declared
|
||||
assert "quota" in types # quota declared
|
||||
assert "window_drop" not in types # token missing
|
||||
assert "bpf" not in types # cbpf off
|
||||
assert "rate" in types # netem keyword present
|
||||
assert "gemodel" not in types # netem keyword missing
|
||||
assert "delay" in types # core netem runs on any tc-module build
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_available_filters_unconstrained_without_report(project):
|
||||
"""
|
||||
Computes that report no uBridge tc capabilities (older servers, failed
|
||||
probe) impose no constraint: the full list stays offered and apply-time
|
||||
validation is the guard.
|
||||
"""
|
||||
|
||||
from gns3server.controller.link import FILTERS
|
||||
|
||||
link, _node1, _node2 = await _kernel_link(project)
|
||||
link._link_data = [{"type": "nio_bridge"}]
|
||||
assert [f["type"] for f in link.available_filters()] == [f["type"] for f in FILTERS]
|
||||
@@ -0,0 +1,77 @@
|
||||
#
|
||||
# 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 `tc capabilities` reply parser and the eBPF mode policy shared by the
|
||||
compute probe and the controller filter availability.
|
||||
"""
|
||||
|
||||
from gns3server.utils.tc_capabilities import (
|
||||
LEGACY_EBPF_MODES,
|
||||
parse_tc_capabilities,
|
||||
usable_ebpf_modes,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_full_reply():
|
||||
|
||||
caps = parse_tc_capabilities("netem=delay,jitter,loss;ebpf=1;cbpf=0;ebpf_modes=nth,quota,window,flow")
|
||||
assert caps == {
|
||||
"netem": "delay,jitter,loss",
|
||||
"ebpf": "1",
|
||||
"cbpf": "0",
|
||||
"ebpf_modes": "nth,quota,window,flow",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_legacy_reply_without_modes():
|
||||
"""A build predating the field (and unknown future keys) passes through."""
|
||||
|
||||
caps = parse_tc_capabilities("netem=delay;ebpf=1;cbpf=1")
|
||||
assert caps == {"netem": "delay", "ebpf": "1", "cbpf": "1"}
|
||||
|
||||
|
||||
def test_parse_empty_reply():
|
||||
|
||||
assert parse_tc_capabilities("") == {}
|
||||
assert parse_tc_capabilities(None) == {}
|
||||
|
||||
|
||||
def test_usable_modes_require_ebpf():
|
||||
"""The mode list is a build fact; ebpf is the runtime gate for all of them."""
|
||||
|
||||
assert usable_ebpf_modes({"ebpf": "0", "ebpf_modes": "nth,quota,window,flow"}) == ()
|
||||
assert usable_ebpf_modes({}) == ()
|
||||
|
||||
|
||||
def test_usable_modes_from_token_list():
|
||||
|
||||
caps = {"ebpf": "1", "ebpf_modes": "nth,quota,window,flow"}
|
||||
assert usable_ebpf_modes(caps) == ("nth", "quota", "window", "flow")
|
||||
|
||||
|
||||
def test_usable_modes_legacy_build_keeps_shipped_modes():
|
||||
"""No field: the modes that shipped before it — everything but window."""
|
||||
|
||||
assert usable_ebpf_modes({"ebpf": "1"}) == LEGACY_EBPF_MODES
|
||||
assert "window" not in LEGACY_EBPF_MODES
|
||||
|
||||
|
||||
def test_window_never_usable_without_token():
|
||||
"""The pre-correction build reported ebpf=1 with broken window semantics."""
|
||||
|
||||
assert "window" not in usable_ebpf_modes({"ebpf": "1"})
|
||||
assert "window" not in usable_ebpf_modes({"ebpf": "1", "ebpf_modes": "nth,quota"})
|
||||
Reference in new issue
Block a user