Merge branch '3.1' into fork/markparonyan/api-issue-10-0eztih

# Conflicts:
#	gns3server/schemas/__init__.py
This commit is contained in:
grossmj committed 2026-10-10 06:39:15 +02:00
commit 6004e06499
110 files changed
+21567 -701

No files matched your search

+247 -2
View File
@@ -1,14 +1,26 @@
"""
Device config tool tests with mocked topology and Nornir layers.
Covers the VPCS node-type guard and the error contract shared by
device_config_send / device_show_run / vpcs_config_set.
Covers the VPCS node-type guard, the error contract shared by
device_config_send / device_show_run / vpcs_config_set, and the
same-device entry merging that keeps duplicate entries from
overwriting each other's commands and outputs.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from gns3server.agent.gns3_copilot.utils.device_configs import (
merge_duplicate_device_configs,
)
VPCS_MOD = "gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko"
DISPLAY_MOD = "gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir"
CONFIG_MOD = "gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir"
PROJECT_ID = "0c0fde25-6ead-4413-a283-ea8fd2324291"
def _topology_ports(node_type):
@@ -16,6 +28,26 @@ def _topology_ports(node_type):
return {"PC1": {"port": 5000, "node_type": node_type}}
def _topology_two_devices():
"""Mocked topology with two console-reachable devices."""
return {
"R1": {"port": 5000, "node_type": "iou"},
"R2": {"port": 5001, "node_type": "iou"},
}
def _aggregated_result(output_by_device):
"""Real AggregatedResult: device -> MultiResult with one successful Result."""
from nornir.core.task import AggregatedResult, MultiResult, Result
aggregated = AggregatedResult("task")
for name, output in output_by_device.items():
multi = MultiResult("task")
multi.append(Result(host=None, result=output, failed=False))
aggregated[name] = multi
return aggregated
class TestVPCSNodeTypeGuard:
def test_non_vpcs_node_is_rejected(self):
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
@@ -161,3 +193,216 @@ class TestDeviceToolErrorContract:
assert len(result) == 1
assert result[0]["status"] == "failed"
assert "Template rendering failed" in result[0]["error"]
class TestDuplicateDeviceMerging:
"""
Same-device entries in one batch must merge before execution.
Regression: the per-device command map kept only the last entry's
commands while result processing iterated every original entry, so all
rows reported the last entry's output (first reproduced with the MCP
direct-commands path, no template).
"""
def test_show_run_merges_same_device_entries(self):
from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import (
ExecuteMultipleDeviceCommands,
)
nornir = MagicMock()
nornir.run.return_value = _aggregated_result({"R1": "merged output", "R2": "r2 output"})
with (
patch(f"{DISPLAY_MOD}.get_device_ports_from_topology", return_value=_topology_two_devices()),
patch.object(ExecuteMultipleDeviceCommands, "_initialize_nornir", return_value=nornir),
):
result = ExecuteMultipleDeviceCommands()._run(
json.dumps(
{
"project_id": PROJECT_ID,
"device_configs": [
{"device_name": "R1", "commands": ["show ip route"]},
{"device_name": "R2", "commands": ["show version"]},
{"device_name": "R1", "commands": ["show ip interface brief"]},
],
}
)
)
# every command reached the execution layer, in order
assert nornir.run.call_args.kwargs["device_configs_map"] == {
"R1": ["show ip route", "show ip interface brief"],
"R2": ["show version"],
}
# one truthful row per device
assert [row["device_name"] for row in result] == ["R1", "R2"]
assert result[0]["status"] == "success"
assert result[0]["output"] == "merged output"
assert result[0]["diagnostic_commands"] == ["show ip route", "show ip interface brief"]
def test_config_send_merges_same_device_entries(self):
from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import (
ExecuteMultipleDeviceConfigCommands,
)
nornir = MagicMock()
nornir.run.return_value = _aggregated_result({"R1": "merged output"})
with (
patch(
f"{CONFIG_MOD}.get_device_ports_from_topology", return_value={"R1": {"port": 5000, "node_type": "iou"}}
),
patch.object(ExecuteMultipleDeviceConfigCommands, "_initialize_nornir", return_value=nornir),
):
result = ExecuteMultipleDeviceConfigCommands()._run(
json.dumps(
{
"project_id": PROJECT_ID,
"device_configs": [
{"device_name": "R1", "config_commands": ["int lo0"]},
{"device_name": "R1", "config_commands": ["ip address 1.1.1.1 255.255.255.255", "exit"]},
],
}
)
)
assert nornir.run.call_args.kwargs["device_configs_map"] == {
"R1": ["int lo0", "ip address 1.1.1.1 255.255.255.255", "exit"],
}
assert len(result) == 1
assert result[0]["status"] == "success"
assert result[0]["config_commands"] == ["int lo0", "ip address 1.1.1.1 255.255.255.255", "exit"]
def test_vpcs_merges_same_device_entries(self):
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
nornir = MagicMock()
host_result = MagicMock(failed=False)
host_result.result = "OK"
nornir.run.return_value = {"PC1": host_result}
with (
patch(f"{VPCS_MOD}.get_device_ports_from_topology", return_value=_topology_ports("vpcs")),
patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir),
):
result = VPCSCommands()._run(
json.dumps(
{
"project_id": PROJECT_ID,
"device_configs": [
{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]},
{"device_name": "PC1", "commands": ["save"]},
],
}
)
)
assert nornir.run.call_args.kwargs["device_configs_map"] == {"PC1": ["ip 10.0.0.1/24", "save"]}
assert len(result) == 1
assert result[0]["status"] == "success"
assert result[0]["commands"] == ["ip 10.0.0.1/24", "save"]
def test_blocked_commands_accumulate_across_duplicate_entries(self):
"""Blocked-command info must not last-win between same-device entries."""
from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import (
ExecuteMultipleDeviceCommands,
)
def fake_filter(commands):
blocked = {c: "matched forbidden pattern" for c in commands if c.startswith("debug")}
allowed = [c for c in commands if not c.startswith("debug")]
return allowed, blocked
nornir = MagicMock()
nornir.run.return_value = _aggregated_result({"R1": "ok"})
with (
patch(
f"{DISPLAY_MOD}.get_device_ports_from_topology", return_value={"R1": {"port": 5000, "node_type": "iou"}}
),
patch.object(ExecuteMultipleDeviceCommands, "_initialize_nornir", return_value=nornir),
patch(f"{DISPLAY_MOD}.filter_forbidden_commands", side_effect=fake_filter),
):
result = ExecuteMultipleDeviceCommands()._run(
json.dumps(
{
"project_id": PROJECT_ID,
"device_configs": [
{"device_name": "R1", "commands": ["show version", "debug ip routing"]},
{"device_name": "R1", "commands": ["debug ospf events", "show clock"]},
],
}
)
)
assert nornir.run.call_args.kwargs["device_configs_map"] == {"R1": ["show version", "show clock"]}
assert result[0]["status"] == "partial_success"
assert set(result[0]["blocked_commands"]) == {"debug ip routing", "debug ospf events"}
def test_entry_without_device_name_fails_the_whole_batch(self):
"""An unaddressable entry must abort with an error, not vanish."""
from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import (
ExecuteMultipleDeviceCommands,
)
result = ExecuteMultipleDeviceCommands()._run(
json.dumps(
{
"project_id": PROJECT_ID,
"device_configs": [
{"device_name": "R1", "commands": ["show version"]},
{"commands": ["show clock"]},
],
}
)
)
assert result[0]["status"] == "failed"
assert "device_configs[1]" in result[0]["error"]
class TestMergeDuplicateDeviceConfigs:
"""Unit tests for the shared normalization helper."""
def test_merges_in_first_appearance_order(self):
merged = merge_duplicate_device_configs(
[
{"device_name": "R1", "commands": ["a"]},
{"device_name": "R2", "commands": ["b"]},
{"device_name": "R1", "commands": ["c", "d"]},
],
commands_field="commands",
)
assert merged == [
{"device_name": "R1", "commands": ["a", "c", "d"]},
{"device_name": "R2", "commands": ["b"]},
]
def test_preserves_first_entry_fields_without_mutating_input(self):
original = [
{"device_name": "R1", "commands": ["a"], "extra": 1},
{"device_name": "R1", "commands": ["b"]},
]
merged = merge_duplicate_device_configs(original, commands_field="commands")
assert merged == [{"device_name": "R1", "commands": ["a", "b"], "extra": 1}]
assert original[0]["commands"] == ["a"]
def test_entry_without_device_name_is_rejected(self):
# Silently dropping it would let the batch execute partially while
# every returned row reports success.
with pytest.raises(ValueError, match=r"device_configs\[0\]"):
merge_duplicate_device_configs(
[{"commands": ["a"]}, {"device_name": "R1", "commands": ["b"]}],
commands_field="commands",
)
def test_entry_that_is_not_an_object_is_rejected(self):
with pytest.raises(ValueError, match=r"device_configs\[1\]"):
merge_duplicate_device_configs(
[{"device_name": "R1", "commands": ["a"]}, "R2"],
commands_field="commands",
)
def test_missing_commands_field_defaults_to_empty(self):
merged = merge_duplicate_device_configs(
[{"device_name": "R1"}, {"device_name": "R1", "commands": ["b"]}],
commands_field="commands",
)
assert merged == [{"device_name": "R1", "commands": ["b"]}]
+15
View File
@@ -425,6 +425,21 @@ class TestLink:
result = get_links_handler({"project_id": "p1", "fields": ["link_id"]}, ctx)
assert result["links"] == [{"link_id": "l1"}]
def test_list_can_ask_for_kernel_datapath(self, ctx):
"""
The datapath is a field the tool descriptions always advertised, so
it has to be requestable: a whitelist missing it rejected
fields=["kernel_datapath"] and made link_list unable to report which
links run on kernel interfaces.
"""
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_links_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn([{"link_id": "l1", "link_type": "ethernet", "kernel_datapath": True}])
result = get_links_handler({"project_id": "p1", "fields": ["link_id", "kernel_datapath"]}, ctx)
assert result["links"] == [{"link_id": "l1", "kernel_datapath": True}]
def test_get(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_link_handler
+172
View File
@@ -0,0 +1,172 @@
#
# 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/>.
"""
Name locators: name->UUID resolution and the UUID-typo "Did you mean" hint.
"""
from gns3server.agent.mcp import locators
from gns3server.agent.mcp.locators import add_did_you_mean, looks_like_uuid, resolve_locators
PROJECT_ID = "d1ab2349-2e53-4e41-a9ae-b5ffcd912408"
NODE_ID = "b78a2630-2c9e-4b43-9397-c855c8053d1a"
NODE_ID_TYPO = "b78a2630-3b9e-4b43-9397-c855c8053d1a" # 2c9e -> 3b9e (distance 2)
def _run_factory(projects=None, nodes=None):
"""A run() callback answering the two list handlers from canned data."""
calls = []
def run(handler, params):
calls.append((handler, dict(params)))
if handler is locators.list_projects_handler:
if isinstance(projects, Exception):
raise projects
return {"projects": projects if projects is not None else []}
if handler is locators.get_nodes_handler:
if isinstance(nodes, Exception):
raise nodes
return nodes if nodes is not None else []
raise AssertionError(f"unexpected handler {handler}")
return run, calls
def test_looks_like_uuid():
assert looks_like_uuid(PROJECT_ID) is True
assert looks_like_uuid(PROJECT_ID.upper()) is True
assert looks_like_uuid("FRRDocker-4") is False
assert looks_like_uuid("") is False
assert looks_like_uuid(None) is False
assert looks_like_uuid(1234) is False
def test_uuid_params_pass_through_without_calls():
"""UUID-shaped values never trigger resolution lookups."""
run, calls = _run_factory(projects=[{"project_id": PROJECT_ID, "name": "lab"}])
params, error = resolve_locators({"project_id": PROJECT_ID, "node_id": NODE_ID}, run)
assert error is None
assert params == {"project_id": PROJECT_ID, "node_id": NODE_ID}
assert calls == []
def test_project_name_resolved():
"""A name-shaped project_id becomes the project's UUID."""
run, calls = _run_factory(
projects=[
{"project_id": "11111111-1111-1111-1111-111111111111", "name": "other"},
{"project_id": PROJECT_ID, "name": "my-lab"},
]
)
params, error = resolve_locators({"project_id": "my-lab"}, run)
assert error is None
assert params["project_id"] == PROJECT_ID
assert len(calls) == 1 # only the project list
def test_project_name_not_found_lists_known_names():
run, _calls = _run_factory(projects=[{"project_id": PROJECT_ID, "name": "my-lab"}])
params, error = resolve_locators({"project_id": "no-such-lab"}, run)
assert params is None
assert "not found" in error["error"]
assert "my-lab" in error["error"]
def test_node_name_resolved_within_project():
run, calls = _run_factory(
projects=[{"project_id": PROJECT_ID, "name": "my-lab"}], nodes=[{"node_id": NODE_ID, "name": "FRRDocker-4"}]
)
params, error = resolve_locators({"project_id": "my-lab", "node_id": "FRRDocker-4"}, run)
assert error is None
assert params["project_id"] == PROJECT_ID
assert params["node_id"] == NODE_ID
# node lookup went to the already-resolved project
assert calls[1] == (locators.get_nodes_handler, {"project_id": PROJECT_ID})
def test_node_name_case_insensitive_unique_match():
run, _calls = _run_factory(nodes=[{"node_id": NODE_ID, "name": "FRRDocker-4"}])
params, error = resolve_locators({"project_id": PROJECT_ID, "node_id": "frrdocker-4"}, run)
assert error is None
assert params["node_id"] == NODE_ID
def test_node_name_not_found_lists_known_nodes():
run, _calls = _run_factory(nodes=[{"node_id": NODE_ID, "name": "FRRDocker-4"}])
params, error = resolve_locators({"project_id": PROJECT_ID, "node_id": "vpcs-1"}, run)
assert params is None
assert "FRRDocker-4" in error["error"]
def test_handler_failure_during_resolution_is_reported():
run, _calls = _run_factory(nodes={"error": "boom"})
params, error = resolve_locators({"project_id": PROJECT_ID, "node_id": "FRRDocker-4"}, run)
assert params is None
assert "Could not resolve node" in error["error"]
def test_did_you_mean_appended_for_transcription_slip():
"""The 2c9e -> 3b9e class of typo earns exactly one hint."""
run, _calls = _run_factory(
projects=[{"project_id": PROJECT_ID, "name": "my-lab"}], nodes=[{"node_id": NODE_ID, "name": "FRRDocker-4"}]
)
result = add_did_you_mean({"error": f"Node ID {NODE_ID_TYPO} doesn't exist"}, {"project_id": PROJECT_ID}, run)
assert f"Did you mean node 'FRRDocker-4' ({NODE_ID})" in result["error"]
# original message preserved
assert NODE_ID_TYPO in result["error"]
def test_no_hint_when_too_far_or_ambiguous():
far = "b78a2630-9999-4b43-9397-c855c8053d1a" # distance 3
run, _calls = _run_factory(nodes=[{"node_id": NODE_ID, "name": "n1"}])
result = add_did_you_mean({"error": f"Node ID {far} doesn't exist"}, {"project_id": PROJECT_ID}, run)
assert "Did you mean" not in result["error"]
two = [NODE_ID, "b78a2630-2b9e-4b43-9397-c855c8053d1a"] # both within distance 2 of the typo
run2, _calls2 = _run_factory(nodes=[{"node_id": i, "name": f"n{i}"} for i in two])
result2 = add_did_you_mean({"error": f"Node ID {NODE_ID_TYPO} doesn't exist"}, {"project_id": PROJECT_ID}, run2)
assert "Did you mean" not in result2["error"]
def test_non_error_results_untouched():
run, _calls = _run_factory(nodes=[{"node_id": NODE_ID, "name": "n1"}])
payload = [{"node_id": NODE_ID}]
assert add_did_you_mean(payload, {"project_id": PROJECT_ID}, run) is payload
assert add_did_you_mean({"filters": {}}, {"project_id": PROJECT_ID}, run) == {"filters": {}}
# errors without an id carry nothing to hint against
result = add_did_you_mean({"error": "Node not found: no id here"}, {"project_id": PROJECT_ID}, run)
assert result == {"error": "Node not found: no id here"}
def test_edit_distance_at_most():
assert locators._edit_distance_at_most("abcd", "abcd", 2)
assert locators._edit_distance_at_most("abcd", "abce", 2)
assert locators._edit_distance_at_most("2c9e", "3b9e", 2)
assert not locators._edit_distance_at_most("abcd", "dcba", 2)
assert not locators._edit_distance_at_most("abc", "abcdef", 2)
+98 -2
View File
@@ -28,9 +28,22 @@ from gns3server.version import __version__
pytestmark = pytest.mark.asyncio
async def _no_tap_probe():
"""The probes spawn uBridge — stub them out for payload assertions."""
return None
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)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_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() == {
@@ -54,10 +67,21 @@ 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,
"ubridge_tap": None,
"ubridge_iol_tap": None,
"ubridge_bridge_tap": 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)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_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() == {
@@ -81,4 +105,76 @@ 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,
"ubridge_tap": None,
"ubridge_iol_tap": None,
"ubridge_bridge_tap": 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)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_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"] == {
"netem": ["delay", "rate"],
"ebpf": True,
"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)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_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_tap"] is True
async def test_get_reports_ubridge_iol_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
"""
The IOL-port TAP capability is what makes an IOU node kernel-eligible,
so it travels with the other compute capabilities.
"""
async def fake_iol_tap_probe():
return True
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", fake_iol_tap_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_iol_tap"] is True
async def test_get_reports_ubridge_bridge_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
"""
The swappable-bridge-TAP-leg capability is what makes an IOL runner
container kernel-eligible, so it travels with the other compute
capabilities.
"""
async def fake_bridge_tap_probe():
return True
monkeypatch.setattr(
"gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", fake_bridge_tap_probe
)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_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_bridge_tap"] is True
@@ -226,6 +226,31 @@ class TestDockerNodesRoutes:
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["type"] == "nio_udp"
async def test_docker_nio_create_bridge_requires_the_field_but_takes_null(
self, app: FastAPI, compute_client: AsyncClient, vm: dict
) -> None:
"""
BridgeNIO.bridge is required-but-nullable: an omitted key is a 422
(the NIO factories read it, and the routes drop unset fields — an
omitted key used to pass validation and die as a KeyError during
binding), while an explicit null is the absorbed-link shape.
"""
url = app.url_path_for(
"compute:create_docker_node_nio",
project_id=vm["project_id"],
node_id=vm["node_id"],
adapter_number="0",
port_number="0",
)
response = await compute_client.post(url, json={"type": "nio_bridge"})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
response = await compute_client.post(url, json={"type": "nio_bridge", "bridge": None})
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["bridge"] is None
async def test_docker_nio_batch_create(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None:
"""
Exercise the project-wide batch NIO endpoint: bind two NIOs on the same
@@ -14,14 +14,17 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from unittest.mock import MagicMock, call
import os
from unittest.mock import MagicMock, call, patch
import pytest
import pytest_asyncio
from fastapi import FastAPI, status
from httpx import AsyncClient
from gns3server.compute.error import NodeError
from gns3server.compute.project import Project
from gns3server.compute.ubridge.ubridge_error import UbridgeError
from tests.utils import asyncio_patch
# The builtin Ethernet switch talks to uBridge (brctl/bridge modules) instead of
@@ -535,3 +538,784 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_204_NO_CONTENT
node._ubridge_send.assert_any_call(f"bridge stop_capture {relay}")
# ------------------------------------------------------------------ #
# kernel datapath: absorbed anchors (nio_anchor)
# ------------------------------------------------------------------ #
@staticmethod
def _anchor_params(anchor: str = "gv00010203e0p0", **extra) -> dict:
params = {"type": "nio_anchor", "anchor": anchor}
params.update(extra)
return params
@staticmethod
def _sysfs(anchor_exists: bool = True, bridge_member: bool = True):
"""An ``os.path.exists`` that answers the /sys/class/net probes of the
kernel-port wiring, delegating everything else to the real one."""
real = os.path.exists
def fake(path):
value = str(path)
if "/brif/" in value:
return bridge_member
if value.startswith("/sys/class/net/"):
return anchor_exists
return real(path)
return patch("os.path.exists", new=fake)
async def test_ethernet_switch_create_anchor_nio(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
A kernel link joins the peer's anchor straight to the switch bridge
with the port's VLAN mode applied — no relay, no per-port TAP.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True):
response = await compute_client.post(url, json=self._anchor_params(anchor))
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["type"] == "nio_anchor"
node = compute_project.get_node(ethernet_switch["node_id"])
br = node._bridge_name
# access VLAN 1 (default): the anchor replaces the port TAP as the
# bridge port, so it carries the port's membership
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
node._ubridge_send.assert_any_call(f'brctl vlan_del "{br}" "{anchor}" 1')
node._ubridge_send.assert_any_call(f'brctl vlan_add "{br}" "{anchor}" 1 pvid untagged')
node._ubridge_send.assert_any_call(f'link set "{anchor}" up')
# no relay was created for this port
assert not any("bridge create" in str(c) for c in node._ubridge_send.call_args_list)
assert node._kernel_ports[0] == anchor
assert node.get_nio(0).anchor == anchor
async def test_ethernet_switch_create_anchor_nio_defers_when_anchor_missing(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
The peer's anchor only exists while the peer runs: a link created
against a stopped peer must not fail (nor stop the switch's
uBridge) — the join is deferred to the re-push after the peer
starts.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=False):
response = await compute_client.post(url, json=self._anchor_params(anchor))
assert response.status_code == status.HTTP_201_CREATED
node = compute_project.get_node(ethernet_switch["node_id"])
assert node._kernel_ports[0] == anchor
assert node.get_nio(0).anchor == anchor
assert not any("addif" in str(c) for c in node._ubridge_send.call_args_list)
assert not any("brctl" in str(c) for c in node._ubridge_send.call_args_list)
async def test_ethernet_switch_update_anchor_nio_reconciles(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Updating a joined kernel port reconciles on the anchor: tc
impairments, markers and the carrier the suspend flag drives.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
update_url = app.url_path_for(
"compute:update_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True, bridge_member=True):
response = await compute_client.put(
update_url, json=self._anchor_params(anchor, filters={"delay": [10]}, suspend=True)
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["suspend"] is True
node._ubridge_send.assert_any_call(f'tc reset "{anchor}"')
node._ubridge_send.assert_any_call(f'tc netem set "{anchor}" delay 10')
node._ubridge_send.assert_any_call(f'link set "{anchor}" down')
# no re-join on a plain reconcile
assert not any("addif" in str(c) for c in node._ubridge_send.call_args_list)
async def test_ethernet_switch_update_anchor_nio_completes_deferred_join(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
The controller re-pushes the switch NIO once the peer starts: a join
deferred at link creation completes (the anchor exists but is not a
bridge member), and a peer restart that replaced its anchor under
the same name re-joins.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=False):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
update_url = app.url_path_for(
"compute:update_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
# the anchor exists now (peer started) but is not a bridge member
with self._sysfs(anchor_exists=True, bridge_member=False):
response = await compute_client.put(update_url, json=self._anchor_params(anchor))
assert response.status_code == status.HTTP_201_CREATED
br = node._bridge_name
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
node._ubridge_send.assert_any_call(f'brctl vlan_add "{br}" "{anchor}" 1 pvid untagged')
node._ubridge_send.assert_any_call(f'link set "{anchor}" up')
async def test_ethernet_switch_delete_anchor_nio(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Removing a kernel link resets the anchor's qdisc, takes it out of
the switch bridge and drops its carrier — the anchor itself survives
(it belongs to the peer).
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
with self._sysfs(anchor_exists=True):
response = await compute_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
br = node._bridge_name
node._ubridge_send.assert_any_call(f'tc reset "{anchor}"')
node._ubridge_send.assert_any_call(f'brctl delif "{br}" "{anchor}"')
node._ubridge_send.assert_any_call(f'link set "{anchor}" down')
assert 0 not in node._kernel_ports
assert 0 not in node._nios
async def test_ethernet_switch_capture_anchor_nio(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Capture on a kernel port runs on the absorbed anchor (AF_PACKET),
not on a relay.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
response = await compute_client.post(
app.url_path_for(
"compute:start_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
),
json={"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"},
)
assert response.status_code == status.HTTP_200_OK
assert any(
c.args and str(c.args[0]).startswith(f'capture start_kernel {anchor} "')
for c in node._ubridge_send.call_args_list
), node._ubridge_send.call_args_list
response = await compute_client.post(
app.url_path_for(
"compute:stop_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
node._ubridge_send.assert_any_call("capture stop_kernel")
async def test_ethernet_switch_batch_create_anchor_nio(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
The project-open batch endpoint accepts anchor NIOs (a reopen must
not 422 the switch's kernel links).
"""
anchor = "gv00010203e0p3"
params = {
"nios": [
{
"node_id": ethernet_switch["node_id"],
"adapter_number": 0,
"port_number": 3,
"nio": {"type": "nio_anchor", "anchor": anchor},
}
]
}
with self._sysfs(anchor_exists=False):
response = await compute_client.post(
app.url_path_for("compute:create_batch_nios", project_id=compute_project.id), json=params
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["added"] == 1
node = compute_project.get_node(ethernet_switch["node_id"])
assert node._kernel_ports[3] == anchor
# ------------------------------------------------------------------ #
# switch-to-switch cascade: one veth pair joins the two bridges
# ------------------------------------------------------------------ #
@staticmethod
def _cascade_params(anchor: str, peer: str) -> dict:
return {"type": "nio_anchor", "anchor": anchor, "peer": peer}
def _cascade_sysfs(self, anchor: str, exists: dict):
"""An ``os.path.exists`` whose answer for *one* interface flips with
the ``exists`` flag — for the create-race test, where the pair comes
into being between the pre-check and the failed create."""
real = os.path.exists
def fake(path):
value = str(path)
if value == f"/sys/class/net/{anchor}":
return exists["value"]
if "/brif/" in value:
return True
if value.startswith("/sys/class/net/"):
return True
return real(value)
return patch("os.path.exists", new=fake)
async def test_ethernet_switch_create_cascade_nio_creates_the_pair(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
A cascade port is one end of a link-owned veth pair: the switch
creates the pair (uBridge's docker create_veth — the same primitive
every Docker adapter is born from) and enslaves its own end into its
bridge with the port's VLAN mode, exactly like an absorbed anchor.
"""
anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1"
node = compute_project.get_node(ethernet_switch["node_id"])
exists = {"value": False}
async def create_the_pair(command):
if "create_veth" in str(command):
exists["value"] = True
node._ubridge_send.side_effect = create_the_pair
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._cascade_sysfs(anchor, exists):
response = await compute_client.post(url, json=self._cascade_params(anchor, peer))
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["peer"] == peer
node = compute_project.get_node(ethernet_switch["node_id"])
br = node._bridge_name
node._ubridge_send.assert_any_call(f'docker create_veth "{anchor}" "{peer}"')
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
node._ubridge_send.assert_any_call(f'brctl vlan_add "{br}" "{anchor}" 1 pvid untagged')
node._ubridge_send.assert_any_call(f'link set "{anchor}" up')
assert node._kernel_ports[0] == anchor
assert node._cascade_peers[0] == peer
async def test_ethernet_switch_create_cascade_nio_reuses_an_existing_pair(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
An existing end (this switch restarted, or a leftover from a previous
life of this link id) is reused as-is: the other end may still be a
live port of the peer switch, so the pair is never destroyed here —
a stale tc qdisc is all a reused end can carry, and the reset clears
it before the join.
"""
anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._cascade_sysfs(anchor, {"value": True}):
response = await compute_client.post(url, json=self._cascade_params(anchor, peer))
assert response.status_code == status.HTTP_201_CREATED
node = compute_project.get_node(ethernet_switch["node_id"])
br = node._bridge_name
node._ubridge_send.assert_any_call(f'tc reset "{anchor}"')
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
assert not any("create_veth" in str(c) for c in node._ubridge_send.call_args_list)
async def test_ethernet_switch_create_cascade_nio_lost_create_race(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Both cascade sides create the pair when their end is missing, so the
two uBridge processes can race: the loser's create fails with
"exists", the kernel re-check shows the pair is there, and the join
proceeds — a lost race is not an error.
"""
anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1"
node = compute_project.get_node(ethernet_switch["node_id"])
exists = {"value": False}
async def lose_the_race(command):
if "create_veth" in str(command):
# the peer switch's create won between our pre-check and now
exists["value"] = True
raise UbridgeError("202-interface already exists")
node._ubridge_send.side_effect = lose_the_race
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._cascade_sysfs(anchor, exists):
response = await compute_client.post(url, json=self._cascade_params(anchor, peer))
assert response.status_code == status.HTTP_201_CREATED
br = node._bridge_name
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
assert node._kernel_ports[0] == anchor
async def test_ethernet_switch_create_cascade_nio_hardens_own_end_l2only(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
``docker create_veth`` hardens only its FIRST end (the Docker shape:
host anchor plus a container leg that keeps its L3 life) — but a
cascade end the peer's winning create minted is that second,
unhardened end, and the kernel gives it an IPv6 link-local (a silent
L3 identity on the switch fabric, ubridge-l2-anchor-spec §E). Each
switch therefore hardens its OWN end once the pair exists, whatever
branch brought it there (fresh create, lost race, reuse).
"""
anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
# pre-existing end: the branch whose end create_veth did NOT harden
with self._cascade_sysfs(anchor, {"value": True}):
response = await compute_client.post(url, json=self._cascade_params(anchor, peer))
assert response.status_code == status.HTTP_201_CREATED
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.assert_any_call(f'link l2only "{anchor}" on')
# only its own end — the peer's end is the peer switch's to harden
assert not any(f'"{peer}"' in str(c) and "l2only" in str(c) for c in node._ubridge_send.call_args_list)
async def test_ethernet_switch_delete_cascade_nio_destroys_the_pair(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
The cascade pair dies with the link: the teardown detaches the link
state, delifs the end, and destroys the pair (deleting one veth end
takes the peer end with it — either side's teardown converges on the
same gone pair).
"""
anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._cascade_sysfs(anchor, {"value": True}):
await compute_client.post(url, json=self._cascade_params(anchor, peer))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
response = await compute_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
br = node._bridge_name
node._ubridge_send.assert_any_call(f'brctl delif "{br}" "{anchor}"')
node._ubridge_send.assert_any_call(f'docker delete_veth "{anchor}"')
assert node._kernel_ports == {}
assert node._cascade_peers == {}
# ------------------------------------------------------------------ #
# in-place VLAN reconfiguration (no port ever leaves the bridge)
# ------------------------------------------------------------------ #
async def test_ethernet_switch_update_ports_reconfigures_in_place(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Changing a wired port's VLAN edits its membership in place: delete the
old membership, add the new one — the port is never detached from the
bridge (a relay TAP here; the TAP is the cloud/relay-side port).
"""
node = compute_project.get_node(ethernet_switch["node_id"])
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
await compute_client.post(url, json=self._udp_params())
node._ubridge_send.reset_mock()
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
ports[0]["vlan"] = 10
response = await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
),
json={"ports_mapping": ports},
)
assert response.status_code == status.HTTP_200_OK
br = node._bridge_name
tap = f"{br}-0"
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
assert commands == [
f'brctl vlan_del "{br}" "{tap}" 1',
f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged',
], commands
assert not any("delif" in c or "addif" in c for c in commands)
async def test_ethernet_switch_update_ports_leaves_untouched_ports_alone(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Only ports whose mode/VLAN changed are touched: re-saving the mapping
no longer re-programs every wired port.
"""
node = compute_project.get_node(ethernet_switch["node_id"])
for port_number in (0, 1):
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number=str(port_number),
)
await compute_client.post(url, json=self._udp_params())
node._ubridge_send.reset_mock()
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
ports[1]["vlan"] = 20
response = await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
),
json={"ports_mapping": ports},
)
assert response.status_code == status.HTTP_200_OK
br = node._bridge_name
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
assert commands == [
f'brctl vlan_del "{br}" "{br}-1" 1',
f'brctl vlan_add "{br}" "{br}-1" 20 pvid untagged',
], commands
assert not any(f'"{br}-0"' in c for c in commands)
async def test_ethernet_switch_vlan_transition_table(self, compute_project: Project, ethernet_switch: dict) -> None:
"""
Every mode transition is expressed as in-place VLAN edits: the old
mode's entries the new mode does not want, then the new mode's
entries. The dot1q admits-all entry is one range operation in both
directions, and same-to-same emits nothing.
"""
node = compute_project.get_node(ethernet_switch["node_id"])
br = node._bridge_name
iface = "gvdeadbeef0p0"
cases = [
(
{"type": "access", "vlan": 10},
{"type": "dot1q", "vlan": 20},
[
f'brctl vlan_del "{br}" "{iface}" 10',
f'brctl vlan_add "{br}" "{iface}" 1 vid 4094',
f'brctl vlan_add "{br}" "{iface}" 20 pvid untagged',
],
),
(
{"type": "dot1q", "vlan": 20},
{"type": "access", "vlan": 30},
[
f'brctl vlan_del "{br}" "{iface}" 1 vid 4094',
f'brctl vlan_add "{br}" "{iface}" 30 pvid untagged',
],
),
(
{"type": "dot1q", "vlan": 20},
{"type": "dot1q", "vlan": 30},
[
f'brctl vlan_del "{br}" "{iface}" 20',
f'brctl vlan_add "{br}" "{iface}" 30 pvid untagged',
],
),
(
{"type": "qinq", "vlan": 2},
{"type": "dot1q", "vlan": 5},
[
f'brctl vlan_del "{br}" "{iface}" 2',
f'brctl vlan_add "{br}" "{iface}" 1 vid 4094',
f'brctl vlan_add "{br}" "{iface}" 5 pvid untagged',
],
),
(
{"type": "dot1q", "vlan": 5},
{"type": "qinq", "vlan": 2},
[
f'brctl vlan_del "{br}" "{iface}" 1 vid 4094',
f'brctl vlan_add "{br}" "{iface}" 2 pvid untagged',
],
),
(
{"type": "access", "vlan": 10},
{"type": "qinq", "vlan": 2},
[
f'brctl vlan_del "{br}" "{iface}" 10',
f'brctl vlan_add "{br}" "{iface}" 2 pvid untagged',
],
),
(
{"type": "qinq", "vlan": 2},
{"type": "access", "vlan": 10},
[
f'brctl vlan_del "{br}" "{iface}" 2',
f'brctl vlan_add "{br}" "{iface}" 10 pvid untagged',
],
),
(
{"type": "qinq", "vlan": 2},
{"type": "qinq", "vlan": 9},
[
f'brctl vlan_del "{br}" "{iface}" 2',
f'brctl vlan_add "{br}" "{iface}" 9 pvid untagged',
],
),
(
{"type": "access", "vlan": 7},
{"type": "access", "vlan": 7},
[],
),
]
for old_settings, new_settings, expected in cases:
node._ubridge_send.reset_mock()
await node._reconfigure_port_vlan(iface, old_settings, new_settings)
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
assert commands == expected, (old_settings, new_settings, commands)
assert not any("delif" in c or "addif" in c for c in commands)
async def test_ethernet_switch_update_ports_reconfigures_absorbed_anchor_in_place(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
The same in-place reconcile applies to an absorbed kernel anchor (a
peer node's interface): a ports_mapping update issues VLAN edits on
it and never detaches it from the bridge.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
ports[0]["vlan"] = 10
response = await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
),
json={"ports_mapping": ports},
)
assert response.status_code == status.HTTP_200_OK
br = node._bridge_name
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
assert commands == [
f'brctl vlan_del "{br}" "{anchor}" 1',
f'brctl vlan_add "{br}" "{anchor}" 10 pvid untagged',
], commands
assert not any("delif" in c or "addif" in c for c in commands)
async def test_ethernet_switch_update_ports_skips_a_deferred_anchor(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
A kernel port whose peer is stopped has no interface yet (the join is
deferred by design): a VLAN change must not send commands that would
fail ENODEV and abort the whole reconcile — the port keeps the new
settings in the mapping and they apply when the peer starts.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=False):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
assert node._kernel_ports.get(0) == anchor # deferred but registered
node._ubridge_send.reset_mock()
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
ports[0]["vlan"] = 10
with self._sysfs(anchor_exists=False):
response = await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
),
json={"ports_mapping": ports},
)
assert response.status_code == status.HTTP_200_OK
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
assert not any("vlan_" in c for c in commands), commands
async def test_ethernet_switch_update_ports_rolls_the_mapping_back_on_failure(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
A failed VLAN reconcile must not keep the new mapping: the retry would
then diff it against itself and report success with the VLANs never
applied.
"""
await compute_client.post(
app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
),
json=self._udp_params(),
)
node = compute_project.get_node(ethernet_switch["node_id"])
before = [dict(port) for port in node.ports_mapping]
node._ubridge_send.side_effect = NodeError("207-reconcile failed")
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
ports[0]["vlan"] = 10
response = await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
),
json={"ports_mapping": ports},
)
assert response.status_code == status.HTTP_409_CONFLICT
assert response.json()["message"] == "207-reconcile failed"
assert [dict(port) for port in node.ports_mapping] == before
@@ -297,6 +297,40 @@ class TestIOUNodesRoutes:
assert response.json()["type"] == "nio_udp"
assert response.json()["filters"] == {}
async def test_iou_nio_update_carries_suspend_and_bridge(
self, app: FastAPI, compute_client: AsyncClient, vm: dict
) -> None:
"""
A kernel-datapath NIO (nio_bridge) is accepted on the IOU routes, and
the update body's suspend flag reaches the NIO — the compute turns it
into an admin-down anchor, so a dropped suspend would leave a
"suspended" link forwarding traffic.
"""
url = app.url_path_for(
"compute:create_iou_node_nio",
project_id=vm["project_id"],
node_id=vm["node_id"],
adapter_number="1",
port_number="0",
)
params = {"type": "nio_bridge", "bridge": "gns3a1b2c3d4e5f"}
response = await compute_client.post(url, json=params)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["type"] == "nio_bridge"
url = app.url_path_for(
"compute:update_iou_node_nio",
project_id=vm["project_id"],
node_id=vm["node_id"],
adapter_number="1",
port_number="0",
)
params["suspend"] = True
response = await compute_client.put(url, json=params)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["suspend"] is True
async def test_iou_nio_create_ethernet(
self, app: FastAPI, compute_client: AsyncClient, vm: dict, ethernet_device: str
) -> None:
@@ -99,6 +99,40 @@ class TestComputeRoutes:
assert response.status_code == status.HTTP_200_OK
assert response.json()["compute_id"] == str(test_compute.compute_id)
async def test_compute_get_reports_every_capability(self, app: FastAPI, client: AsyncClient, controller) -> None:
"""
The capabilities payload the compute reports must survive the
response model intact. The local compute is the interesting one: it
is the controller object serialized straight through, and a second,
private Capabilities definition in the response schema silently
stripped the kernel-datapath fields (ubridge_tap, ubridge_iol_tap)
there — links were then built on a datapath clients could not see.
"""
await controller.add_compute(
compute_id="local", name="local", host="127.0.0.1", port=3080, force=True, connect=False
)
controller.get_compute("local")._capabilities.update(
{
"version": "3.1.0",
"node_types": ["docker"],
"platform": "linux",
"cpus": 4,
"memory": 1024,
"disk_size": 2048,
"ubridge_tc": {"netem": ["delay"], "ebpf": True, "ebpf_modes": ["nth"], "cbpf": True},
"ubridge_tap": True,
"ubridge_iol_tap": False,
}
)
response = await client.get(app.url_path_for("get_compute", compute_id="local"))
assert response.status_code == status.HTTP_200_OK
capabilities = response.json()["capabilities"]
assert capabilities["ubridge_tap"] is True
assert capabilities["ubridge_iol_tap"] is False
assert capabilities["ubridge_tc"]["netem"] == ["delay"]
async def test_compute_get_local(self, app: FastAPI, client: AsyncClient, controller) -> None:
await controller.add_compute(
+94 -6
View File
@@ -81,6 +81,45 @@ class TestLinkRoutes:
assert len(project.links) == 1
assert list(project.links.values())[0].filters == filters
async def test_create_links_batch(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
valid = {
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
}
same_node = {
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
]
}
unknown_node = {
"nodes": [
{"node_id": str(uuid.uuid4()), "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
response = await client.post(
app.url_path_for("create_links", project_id=project.id), json=[same_node, unknown_node, valid]
)
assert response.status_code == status.HTTP_200_OK
results = response.json()
assert [r["status_code"] for r in results] == [409, 404, 201]
assert results[0]["link"] is None
assert results[0]["error"]["message"]
assert results[1]["error"]["message"]
assert results[2]["error"] is None
assert results[2]["link"]["link_id"] in project.links
assert len(project.links) == 1
async def test_create_link_failure(
self, app: FastAPI, client: AsyncClient, compute: Compute, project: Project
) -> None:
@@ -243,9 +282,36 @@ class TestLinkRoutes:
assert mock.called
link_id = response.json()["link_id"]
assert response.json()["nodes"][0]["label"]["x"] == 42
# the runtime datapath fact survives the response model on both POST and GET
assert response.json()["kernel_datapath"] is False
response = await client.get(app.url_path_for("get_link", project_id=project.id, link_id=link_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["nodes"][0]["label"]["x"] == 42
assert response.json()["kernel_datapath"] is False
async def test_get_link_kernel_datapath(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
"""A kernel-wired link reports kernel_datapath true (was dropped by the schema)."""
node1, node2 = nodes
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
link_id = response.json()["link_id"]
link = project.get_link(link_id)
link._link_data = [{"type": "nio_bridge"}]
response = await client.get(app.url_path_for("get_link", project_id=project.id, link_id=link_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["kernel_datapath"] is True
async def test_link_etag_and_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
@@ -515,7 +581,10 @@ class TestLinkRoutes:
{"frequency_drop": [32768]},
{"delay": [32768, 0]},
{"delay": [10, -1]},
{"packet_loss": [1, 2]},
# a second packet_loss value (correlation) is valid — it is a
# kernel-datapath parameter the controller accepts; gemodel out
# of range stands in for the invalid slot instead
{"gemodel": [101, 50, 50]},
{"bpf": [1]},
{"packet_loss": ["abc"]},
],
@@ -529,7 +598,9 @@ class TestLinkRoutes:
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
# The filter vocabulary is capability-gated per link, so values are
# validated by the controller (409), not by a strict request schema
assert response.status_code == status.HTTP_409_CONFLICT
assert "message" in response.json()
assert link.filters == {}
@@ -541,10 +612,24 @@ class TestLinkRoutes:
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
json={"filters": {"unknown": [1]}},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
assert response.status_code == status.HTTP_409_CONFLICT
assert "message" in response.json()
assert link.filters == {}
async def test_update_link_packet_loss_correlation(
self, app: FastAPI, client: AsyncClient, project: Project
) -> None:
# The optional packet_loss correlation (second value) is a
# kernel-datapath parameter accepted by the controller
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
json={"filters": {"packet_loss": [1, 2]}},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["filters"] == {"packet_loss": [1, 2]}
async def test_update_link_invalid_delay_latency(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
@@ -557,9 +642,12 @@ class TestLinkRoutes:
async def test_openapi_link_filters(self, app: FastAPI) -> None:
schemas = app.openapi()["components"]["schemas"]
link_filters = schemas["LinkFilters"]
assert link_filters["additionalProperties"] is False
assert set(link_filters["properties"]) == {"frequency_drop", "packet_loss", "delay", "corrupt", "bpf"}
# The filters vocabulary is capability-gated per link and validated by
# the controller, so the Link schema carries it as a free-form object
# (no strict LinkFilters model on the request path)
assert "LinkFilters" not in schemas
filters_property = schemas["Link"]["properties"]["filters"]
assert filters_property["anyOf"][0] == {"additionalProperties": True, "type": "object"}
assert schemas["LinkFilterDefinition"]["properties"]["parameters"]["items"] == {
"$ref": "#/components/schemas/LinkFilterParameter"
}
+98 -1
View File
@@ -34,7 +34,7 @@ from pydantic import SecretStr
from gns3server.api.routes.controller.nodes import vnc_console, ws_console
from gns3server.config import Config
from gns3server.controller.compute import Compute
from gns3server.controller.controller_error import ComputeError
from gns3server.controller.controller_error import ComputeError, ControllerError
from gns3server.controller.node import Node
from gns3server.controller.project import Project
from gns3server.services import auth_service
@@ -73,6 +73,103 @@ class TestNodeRoutes:
assert response.json()["name"] == "test"
assert "name" not in response.json()["properties"]
async def test_create_node_taken_name_is_renamed(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
) -> None:
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
payload = {"name": "R1", "node_type": "vpcs", "compute_id": "example.com"}
first = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
second = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
assert first.json()["name"] == "R1"
assert second.status_code == status.HTTP_201_CREATED
assert second.json()["name"] == "R2"
async def test_create_node_strict_names(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
) -> None:
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
payload = {"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True}
first = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
second = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
assert first.status_code == status.HTTP_201_CREATED
assert second.status_code == status.HTTP_409_CONFLICT
assert "R1" in second.json()["message"]
assert len(project.nodes) == 1
async def test_create_nodes_batch(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
) -> None:
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
nodes = [{"name": "R{0}", "node_type": "vpcs", "compute_id": "example.com"} for _ in range(3)]
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
assert response.status_code == status.HTTP_200_OK
results = response.json()
assert [r["status_code"] for r in results] == [201, 201, 201]
assert [r["node"]["name"] for r in results] == ["R1", "R2", "R3"]
assert all(r["error"] is None for r in results)
assert len(project.nodes) == 3
async def test_create_nodes_batch_item_failures_keep_order(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
) -> None:
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
nodes = [
{"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True},
{"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True},
{"name": "R1", "node_type": "vpcs", "compute_id": "unknown"},
{"name": "R1", "node_type": "vpcs", "compute_id": "example.com"},
]
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
assert response.status_code == status.HTTP_200_OK
results = response.json()
assert [r["status_code"] for r in results] == [201, 409, 404, 201]
assert results[0]["node"]["name"] == "R1"
assert results[1]["node"] is None
assert "R1" in results[1]["error"]["message"]
assert results[2]["error"]["message"]
assert results[3]["node"]["name"] == "R2"
assert len(project.nodes) == 2
async def test_create_nodes_batch_failed_node_releases_name(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
) -> None:
compute.post = AsyncioMagicMock(side_effect=ControllerError("compute failure"))
nodes = [{"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True}]
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
assert response.json()[0]["status_code"] == status.HTTP_409_CONFLICT
assert len(project.nodes) == 0
response_ok = MagicMock()
response_ok.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response_ok)
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
assert response.json()[0]["status_code"] == status.HTTP_201_CREATED
assert response.json()[0]["node"]["name"] == "R1"
async def test_list_node(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None:
response = MagicMock()
File diff suppressed because it is too large. Load diff
+34 -22
View File
@@ -1541,14 +1541,17 @@ async def test_add_ubridge_connection(vm):
vm._namespace = 42
await vm._add_ubridge_connection(nio, 0)
host_ifc, guest_ifc = vm._veth_names(0, 0)
calls = [
call.send(f'docker create_veth "{host_ifc}" "{guest_ifc}"'),
call.send(f'link set "{host_ifc}" down'),
call.send(f"docker move_to_ns {guest_ifc} 42 eth0"),
call.send("bridge create bridge0"),
call.send("bridge add_nio_tap bridge0 tap-gns3-e0 off"),
call.send("docker move_to_ns tap-gns3-e0 42 eth0"),
call.send(f'bridge add_nio_ethernet bridge0 "{host_ifc}"'),
call.send("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"),
call.send('bridge start_capture bridge0 "/tmp/capture.pcap"'),
call.send("bridge start bridge0"),
call.send("bridge set_nio_tap_carrier bridge0 on"),
call.send(f'link set "{host_ifc}" up'),
]
assert "bridge0" in vm._bridges
# We need to check any_order otherwise mock is confused by asyncio
@@ -1571,11 +1574,13 @@ async def test_add_ubridge_connections_with_base_mac_address(vm):
nio = vm.manager.create_nio(nio_params)
await vm._add_ubridge_connection(nio, 1)
_, guest0 = vm._veth_names(0, 0)
_, guest1 = vm._veth_names(1, 0)
calls = [
call.send("bridge create bridge0"),
call.send("bridge create bridge1"),
call.send("docker set_mac_addr tap-gns3-e0 02:42:42:42:42:00"),
call.send("docker set_mac_addr tap-gns3-e0 02:42:42:42:42:01"),
call.send(f"docker set_mac_addr {guest0} 02:42:42:42:42:00"),
call.send(f"docker set_mac_addr {guest1} 02:42:42:42:42:01"),
]
# We need to check any_order otherwise mock is confused by asyncio
@@ -1591,12 +1596,14 @@ async def test_add_ubridge_connection_none_nio(vm):
await vm._add_ubridge_connection(nio, 0)
host_ifc, guest_ifc = vm._veth_names(0, 0)
calls = [
call.send("bridge create bridge0"),
call.send("bridge add_nio_tap bridge0 tap-gns3-e0 off"),
call.send("docker move_to_ns tap-gns3-e0 42 eth0"),
call.send(f'docker create_veth "{host_ifc}" "{guest_ifc}"'),
call.send(f'link set "{host_ifc}" down'),
call.send(f"docker move_to_ns {guest_ifc} 42 eth0"),
]
assert "bridge0" in vm._bridges
# unconnected adapter: interface exists (carrier off), no relay bridge
assert "bridge0" not in vm._bridges
# We need to check any_order ortherwise mock is confused by asyncio
vm._ubridge_hypervisor.assert_has_calls(calls, any_order=True)
@@ -1626,19 +1633,6 @@ async def test_add_ubridge_connection_invalid_adapter_number(vm):
await vm._add_ubridge_connection(nio, 12)
@pytest.mark.asyncio
async def test_add_ubridge_connection_no_free_interface(vm):
nio = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}
nio = vm.manager.create_nio(nio)
with pytest.raises(DockerError):
# We create fake ethernet interfaces for docker
interfaces = [f"tap-gns3-e{index}" for index in range(4096)]
with patch("psutil.net_if_addrs", return_value=interfaces):
await vm._add_ubridge_connection(nio, 0)
@pytest.mark.asyncio
async def test_adapter_add_nio_binding_1(vm):
@@ -1648,6 +1642,24 @@ async def test_adapter_add_nio_binding_1(vm):
assert vm._ethernet_adapters[0].get_nio(0) == nio
@pytest.mark.asyncio
async def test_adapter_add_nio_binding_refuses_an_occupied_port(vm):
"""
The compute-side backstop of the controller's duplicate-port guard: a
second NIO for a port that already carries one is refused instead of
silently overwriting the binding the first link's teardown still needs.
"""
first = vm.manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
await vm.adapter_add_nio_binding(0, first)
second = vm.manager.create_nio({"type": "nio_udp", "lport": 4244, "rport": 4345, "rhost": "127.0.0.1"})
with pytest.raises(DockerError, match="already has a link"):
await vm.adapter_add_nio_binding(0, second)
assert vm._ethernet_adapters[0].get_nio(0) is first
@pytest.mark.asyncio
async def test_adapter_add_nio_binding_sets_carrier(vm):
@@ -0,0 +1,392 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software and is distributed under the same terms as gns3-server.
# See the LICENSE file for licensing information.
#
"""
Kernel-datapath tests for IOLDockerVM: the persistent TAP anchors (one per
Ethernet bay/unit), the per-port bridge's swappable topology leg (unix ↔ UDP
relay vs unix ↔ TAP anchor) and the teardown order the fd holders dictate.
Image-free: everything is asserted against the uBridge command stream and
the anchor naming contract.
"""
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_error import DockerError
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
from gns3server.utils.kernel_anchor import kernel_anchor_name, kernel_anchor_type
from tests.utils import AsyncioMagicMock
NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f"
TAP00 = "gx00010203e0p0" # the vm fixture's node id, bay 0 unit 0
TAP01 = "gx00010203e0p1"
TAP10 = "gx00010203e1p0"
PORT_BRIDGE = "bridge0" # adapter 0 port 0 (single-port naming)
BRIDGE = "gns3abc123def45" # the per-link kernel bridge carried by the NIO
@pytest_asyncio.fixture
async def manager(port_manager):
m = Docker.instance()
m.port_manager = port_manager
return m
@pytest_asyncio.fixture(scope="function")
async def vm(compute_project, manager):
vm = IOLDockerVM(
"iol-xe-1",
NODE_ID,
compute_project,
manager,
"iol-xe/iol-xe:17-18-02",
environment="GNS3_IOL_RUNNER=1",
adapters=2,
)
vm._cid = "e90e34656842"
vm.application_id = 700
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_hypervisor.is_running.return_value = True
vm._ubridge_send = AsyncioMagicMock()
return vm
def _capable(vm, monkeypatch):
"""Make both anchor-lifecycle probes answer True."""
async def probe(timeout=15.0):
return True
monkeypatch.setattr("gns3server.compute.docker.iol_docker_vm.probe_bridge_tap_support", probe)
monkeypatch.setattr("gns3server.compute.docker.iol_docker_vm.probe_tap_support", probe)
return vm
def _commands(vm):
return [str(c.args[0]) for c in vm._ubridge_send.call_args_list]
# ---------------------------------------------------------------------------
# Anchor lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_tap_datapath_creates_one_tap_per_bay_unit(vm, monkeypatch):
"""
On a capable uBridge every Ethernet bay/unit gets its persistent TAP —
four units per adapter (the IOU model), swept of leftovers first and
born down. No set_owner: uBridge itself holds these fds.
"""
_capable(vm, monkeypatch)
await vm._prepare_tap_datapath()
assert vm._tap_datapath is True
assert set(vm._kernel_taps) == {(bay, unit) for bay in range(2) for unit in range(4)}
assert vm._kernel_taps[(0, 0)] == TAP00
commands = _commands(vm)
assert f'tap delete "{TAP00}"' in commands # the sweep precedes every create
assert commands.index(f'tap delete "{TAP00}"') < commands.index(f'tap create "{TAP00}"')
assert f'link set "{TAP00}" down' in commands
assert not any("set_owner" in c for c in commands)
@pytest.mark.asyncio
async def test_prepare_tap_datapath_falls_back_without_either_probe(vm, monkeypatch):
"""
A uBridge without the swappable TAP leg (or without the tap module at
all) keeps the node on the relay datapath — it still starts, it just
cannot carry kernel links.
"""
async def probe(timeout=15.0):
return None
monkeypatch.setattr("gns3server.compute.docker.iol_docker_vm.probe_bridge_tap_support", probe)
monkeypatch.setattr("gns3server.compute.docker.iol_docker_vm.probe_tap_support", probe)
await vm._prepare_tap_datapath()
assert vm._tap_datapath is False
assert vm._kernel_taps == {}
assert not any("tap create" in c for c in _commands(vm))
def test_tap_name_fits_ifnamsiz(vm):
"""
The anchor name has to fit IFNAMSIZ (15) even for a late bay/unit.
"""
assert len(vm._tap_name(7, 3)) <= 15
# ---------------------------------------------------------------------------
# Link attach / detach (the port bridge's swappable topology leg)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_attach_kernel_link_swaps_the_port_leg_to_the_anchor(vm):
"""
A kernel link ensures the anchor (the frozen ensure-then-add contract —
add_nio_tap on an absent name would open a transient TAP), swaps the
port bridge's topology leg to it, runs the shared mixin flow (per-link
kernel bridge, capture/markers/filters) and starts the relay — the unix
binding untouched throughout.
"""
vm._kernel_taps[(0, 0)] = TAP00
vm._bridges.add(PORT_BRIDGE) # the start flow registered the port bridge
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
await vm._attach_kernel_link(0, 0, nio)
commands = _commands(vm)
assert f'tap create "{TAP00}"' in commands # ensure-then-add
assert commands.index(f'tap create "{TAP00}"') < commands.index(f'bridge add_nio_tap {PORT_BRIDGE} "{TAP00}"')
assert f'brctl create "{BRIDGE}"' in commands
assert f'brctl addif "{BRIDGE}" "{TAP00}"' in commands
assert commands.index(f'brctl addif "{BRIDGE}" "{TAP00}"') < commands.index(f"bridge start {PORT_BRIDGE}")
assert f'link set "{TAP00}" up' in commands # the carrier pass brings the anchor up
assert not any("add_nio_udp" in c for c in commands)
@pytest.mark.asyncio
async def test_ensure_anchor_creates_only_when_missing(vm, monkeypatch):
"""
tap create is strictly create-only (IFF_TUN_EXCL — an existing device
answers EBUSY), so the ensure checks existence first: a present anchor
costs no command, a swept one is recreated (the repair half of the
ensure-then-add contract).
"""
vm._kernel_taps[(0, 0)] = TAP00
with monkeypatch.context() as missing:
missing.setattr("os.path.exists", lambda p: False)
await vm._ensure_anchor(0, 0)
assert f'tap create "{TAP00}"' in _commands(vm)
vm._ubridge_send.reset_mock()
with monkeypatch.context() as present:
present.setattr("os.path.exists", lambda p: True)
await vm._ensure_anchor(0, 0)
vm._ubridge_send.assert_not_called()
@pytest.mark.asyncio
async def test_attach_kernel_link_without_anchor_raises(vm):
"""
A kernel link on a node that never anchored (relay-only uBridge, or a
start that predates the kernel datapath) is an error naming the relay
datapath, not a silent fallback.
"""
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
with pytest.raises(DockerError, match="no TAP anchor"):
await vm._attach_kernel_link(0, 0, nio)
@pytest.mark.asyncio
async def test_add_ubridge_connection_routes_a_kernel_nio_to_the_anchor(vm):
"""
The start-flow entry (also the restart-with-attached-link path) accepts
a kernel NIO where the vendor class rejects it: the port bridge is
ensured, then the same attach flow runs.
"""
vm._kernel_taps[(0, 0)] = TAP00
vm._bridges.add(PORT_BRIDGE)
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
await vm._add_ubridge_connection(nio, 0, 0)
assert f'bridge add_nio_tap {PORT_BRIDGE} "{TAP00}"' in _commands(vm)
@pytest.mark.asyncio
async def test_add_nio_binding_relay_keeps_the_unix_udp_shape(vm):
"""
A relay link on a running node wires only the UDP half — the port bridge
and its unix NIO already exist from the start flow.
"""
vm.status = "started"
vm._bridges.add(PORT_BRIDGE)
nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
await vm.adapter_add_nio_binding(0, nio, 0)
commands = _commands(vm)
assert "bridge add_nio_udp bridge0 20000 127.0.0.1 20001" in commands
assert "bridge start bridge0" in commands
assert not any("add_nio_tap" in c for c in commands)
@pytest.mark.asyncio
async def test_update_nio_binding_kernel_reapplies_on_the_anchor(vm):
"""
A filter update on an attached kernel link reconciles on the anchor (tc)
without re-swapping the port leg or re-enslaving the TAP.
"""
vm.status = "started"
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
nio.filters = {"delay": [10]}
await vm.adapter_update_nio_binding(0, nio, 0)
commands = _commands(vm)
assert f'tc reset "{TAP00}"' in commands
assert f'tc netem set "{TAP00}" delay 10' in commands
assert not any("add_nio_tap" in c for c in commands)
assert not any("brctl addif" in c for c in commands)
@pytest.mark.asyncio
async def test_update_nio_binding_kernel_suspend_flaps_the_anchor(vm):
"""
Suspending a kernel link admin-downs the anchor: the port bridge's TAP
writes fail EIO (dropped) and nothing comes back — a genuinely dead
link, where the relay datapath used the synthetic filter instead.
"""
vm.status = "started"
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
nio.suspend = True
await vm.adapter_update_nio_binding(0, nio, 0)
assert f'link set "{TAP00}" down' in _commands(vm)
@pytest.mark.asyncio
async def test_remove_nio_binding_kernel_releases_the_port_leg_first(vm):
"""
Removing a kernel link stops the port bridge (its relay threads hold the
NIO pointers for their whole life — delete_nio_tap refuses while
running), releases the TAP NIO by name, then the shared anchor-side
teardown runs: qdisc reset, brctl delif, per-link bridge deletion.
"""
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
await vm.adapter_remove_nio_binding(0, 0)
commands = _commands(vm)
assert f"bridge stop {PORT_BRIDGE}" in commands
assert f'bridge delete_nio_tap {PORT_BRIDGE} "{TAP00}"' in commands
assert commands.index(f"bridge stop {PORT_BRIDGE}") < commands.index(
f'bridge delete_nio_tap {PORT_BRIDGE} "{TAP00}"'
)
assert f'tc reset "{TAP00}"' in commands
assert f'brctl delif "{BRIDGE}" "{TAP00}"' in commands
assert f'brctl delete "{BRIDGE}"' in commands
assert commands.index(f'bridge delete_nio_tap {PORT_BRIDGE} "{TAP00}"') < commands.index(f'tc reset "{TAP00}"')
@pytest.mark.asyncio
async def test_remove_nio_binding_relay_keeps_the_udp_teardown(vm):
"""
Removing a relay link keeps the historical teardown: stop + UDP NIO
removal, the unix NIO and its binding untouched.
"""
vm.status = "started"
vm._bridges.add(PORT_BRIDGE)
nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
vm._ethernet_adapters[0].add_nio(0, nio)
await vm.adapter_remove_nio_binding(0, 0)
commands = _commands(vm)
assert f"bridge stop {PORT_BRIDGE}" in commands
assert "bridge remove_nio_udp bridge0 20000 127.0.0.1 20001" in commands
assert not any("delete_nio_tap" in c for c in commands)
# ---------------------------------------------------------------------------
# Stop order (the port bridges hold the anchor fds)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stop_ubridge_releases_fds_before_deleting_taps(vm):
"""
The port bridges hold the anchor TAP fds (bridge add_nio_tap), so they
are deleted first — closing the fds — before the taps are unpersisted
(tap delete answers EBADFD on a held device), and the per-link kernel
bridges still need the live control channel. All before the hypervisor
itself stops.
"""
vm._kernel_taps[(0, 0)] = TAP00
vm._kernel_taps[(1, 0)] = TAP10
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
with patch("gns3server.compute.docker.docker_vm.DockerVM._stop_ubridge", new=AsyncioMagicMock()) as stop:
await vm._stop_ubridge()
stop.assert_called_once()
commands = _commands(vm)
assert f"bridge delete {PORT_BRIDGE}" in commands
assert "bridge delete bridge1" in commands # bay 1 unit 0 (port 0 keeps the historical name)
assert commands.index(f"bridge delete {PORT_BRIDGE}") < commands.index(f'tap delete "{TAP00}"')
assert commands.index("bridge delete bridge1") < commands.index(f'tap delete "{TAP10}"')
assert commands.index(f'tap delete "{TAP00}"') < commands.index(f'brctl delete "{BRIDGE}"')
assert vm._kernel_taps == {}
assert vm._tap_datapath is False
@pytest.mark.asyncio
async def test_start_ubridge_prepares_the_datapath_before_any_link(vm):
"""
The anchors are born with the node (uBridge control channel up, before
the start loop attaches links): an anchor that appears only with its
link would leave the switch fast path's deferred join waiting forever.
"""
with (
patch("gns3server.compute.base_node.BaseNode._start_ubridge", new=AsyncioMagicMock()) as start,
patch.object(IOLDockerVM, "_prepare_tap_datapath", new=AsyncioMagicMock()) as prepare,
):
await vm._start_ubridge(require_privileged_access=True)
start.assert_called_once()
prepare.assert_called_once()
# ---------------------------------------------------------------------------
# Anchor naming (the shared contract)
# ---------------------------------------------------------------------------
def test_anchor_names_follow_the_iol_docker_key():
"""
The controller names an IOL runner's anchor for switch absorption with
the same function the compute creates it under — the iol_docker key,
never the docker veth namespace.
"""
environment = "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=cfg.txt"
assert kernel_anchor_type("docker", environment) == "iol_docker"
assert kernel_anchor_type("docker", "GNS3_UNIX_SOCKET_NIO=1") == "docker"
assert kernel_anchor_name(kernel_anchor_type("docker", environment), NODE_ID, 0, 1) == TAP01
+6 -2
View File
@@ -650,8 +650,8 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage
async def test_link_operations_never_send_tap_carrier(compute_project, manager):
"""
uBridge rejects set_nio_tap_carrier on a bridge without a TAP NIO
("bridge has no TAP NIO"), and unix-socket NIO bridges never have one:
every link create/update/delete on a running node would fail. The
("bridge has no TAP NIO"), and unix-socket NIO relay bridges never have
one: every link create/update/delete on a running node would fail. The
vendor override must keep the carrier command out of all three paths.
"""
@@ -659,6 +659,10 @@ async def test_link_operations_never_send_tap_carrier(compute_project, manager):
vm._ubridge_hypervisor = MagicMock() # truthy ubridge, like a started node
vm._ubridge_send = AsyncioMagicMock()
vm.status = "started"
# A started node's port bridge exists and is registered by the start
# flow — record it, or the relay attach would run the ensure path
# (bridge create + socket wait) this fake never set up.
vm._bridges.add(vm._bridge_name(0, 0))
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
await vm.adapter_add_nio_binding(0, nio, 0)
+18 -2
View File
@@ -314,7 +314,7 @@ async def test_move_to_ns_uses_renamed_interface(compute_project, manager):
# adapter 0 should be renamed to mgmt0
move_calls = [c for c in vm._ubridge_hypervisor.method_calls if "move_to_ns" in str(c)]
assert move_calls, "move_to_ns was not sent"
assert call.send("docker move_to_ns tap-gns3-e0 42 mgmt0") in move_calls
assert call.send(f"docker move_to_ns {vm._veth_names(0, 0)[1]} 42 mgmt0") in move_calls
@pytest.mark.asyncio
@@ -326,7 +326,7 @@ async def test_move_to_ns_falls_back_to_eth(compute_project, manager):
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
await vm._add_ubridge_connection(nio, 1)
move_calls = [c for c in vm._ubridge_hypervisor.method_calls if "move_to_ns" in str(c)]
assert call.send("docker move_to_ns tap-gns3-e0 42 eth1") in move_calls
assert call.send(f"docker move_to_ns {vm._veth_names(1, 0)[1]} 42 eth1") in move_calls
# ---------------------------------------------------------------------------
@@ -902,3 +902,19 @@ async def test_create_reparse_refreshes_env_knobs(compute_project, manager):
assert vm._stop_timeout == 5
assert vm._gns3_init is True # removed entry reset to default
# ---------------------------------------------------------------------------
# Kernel-datapath (NIOBridge) rejection
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unix_socket_nio_rejects_kernel_datapath_link(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_UNIX_SOCKET_NIO=1")
vm._ubridge_hypervisor = MagicMock()
vm._namespace = 42
nio = manager.create_nio({"type": "nio_bridge", "bridge": "gns3a1b2c3d4e5f"})
with pytest.raises(DockerError, match="kernel-datapath"):
await vm._add_ubridge_connection(nio, 0)
@@ -0,0 +1,742 @@
#
# 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/>.
#
"""
Kernel-datapath wiring for Dynamips routers: the persistent TAP every
Ethernet slot/port owns (Ethernet adapters and WIC-1ENET ports only — serial,
ATM and POS adapters never anchor), the hypervisor TAP NIO (nio create_tap)
that holds the anchor open, per-link enslavement, the deferred wiring of
kernel links bound while the node was stopped, and the stop order (the
hypervisor's fds released before the TAPs are unpersisted — QEMU's order,
since here it is Dynamips that holds them).
"""
import contextlib
import os
import shutil
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from gns3server.compute.dynamips import Dynamips
from gns3server.compute.dynamips.adapters.gt96100_fe import GT96100_FE
from gns3server.compute.dynamips.adapters.pa_4t import PA_4T
from gns3server.compute.dynamips.adapters.pa_fe_tx import PA_FE_TX
from gns3server.compute.dynamips.adapters.wic_1enet import WIC_1ENET
from gns3server.compute.dynamips.dynamips_error import DynamipsError
from gns3server.compute.dynamips.nodes.router import Router
from gns3server.compute.nios.nio_bridge import NIOBridge
from gns3server.compute.ubridge.ubridge_error import UbridgeError
from tests.utils import AsyncioMagicMock
NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f"
BRIDGE = "gns3a1b2c3d4e5f"
TAP00 = "gd00010203e0p0" # the router fixture's node id, slot 0 port 0
TAP01 = "gd00010203e0p1"
TAP016 = "gd00010203e0p16" # WIC-1ENET port in WIC slot 0
class FakeDynamipsHypervisor:
"""Records the hypervisor protocol; answers status queries."""
def __init__(self, status_code=2):
self.commands = []
self._status_code = status_code
def is_running(self):
return True
async def send(self, command):
self.commands.append(command)
if command.startswith("vm get_status"):
return [str(self._status_code)]
return ["100-OK"]
@pytest_asyncio.fixture
async def manager(port_manager):
m = Dynamips.instance()
m.port_manager = port_manager
return m
@pytest_asyncio.fixture
async def router(compute_project, manager):
router = Router("R1", NODE_ID, compute_project, manager)
router._hypervisor = FakeDynamipsHypervisor()
router._ubridge_hypervisor = MagicMock()
router._ubridge_hypervisor.is_running.return_value = True
router._ubridge_send = AsyncioMagicMock()
# slot 0: a 2-port Ethernet motherboard with a WIC-1ENET in WIC slot 0
# (port 16); slot 1: a serial PA that must never anchor.
router._create_slots(2)
router._slots[0] = GT96100_FE()
router._slots[0]._wics[0] = WIC_1ENET()
router._slots[1] = PA_4T()
yield router
# compute_project is the shared, id-fixed project every compute test
# file reuses (ProjectManager caches by id); leave its dynamips module
# directory as this fixture found it (absent) — a later manager test
# creates it with os.makedirs and no exist_ok.
shutil.rmtree(router._working_directory, ignore_errors=True)
with contextlib.suppress(OSError):
os.rmdir(compute_project.module_working_path(manager.module_name.lower()))
async def _nio(router, **kwargs):
settings = {"type": "nio_bridge", "bridge": BRIDGE}
settings.update(kwargs)
return await router.manager.create_nio(router, settings)
def _hypervisor_commands(router):
return list(router._hypervisor.commands)
def _ubridge_commands(router):
return [str(c[0][0]) if c[0] else "" for c in router._ubridge_send.call_args_list]
# ---------------------------------------------------------------------------
# Anchor topology: which ports anchor at all
# ---------------------------------------------------------------------------
def test_ethernet_slot_ports_skip_serial_and_include_wics(router):
"""
Only Ethernet-carrying ports anchor: the PA's two ports plus the
WIC-1ENET port (numbered from 16 per WIC slot), never the serial PA.
"""
assert router._ethernet_slot_ports() == [(0, 0), (0, 1), (0, 16)]
def test_tap_name_fits_ifnamsiz(router):
"""
The anchor name has to fit IFNAMSIZ (15) even for WIC port numbers.
"""
assert len(router._tap_name(6, 48)) <= 15
def test_kernel_host_ifc_is_none_before_start(router):
assert router._kernel_host_ifc(0, 0) is None
# ---------------------------------------------------------------------------
# Anchor lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_tap_datapath_creates_one_tap_per_ethernet_port(router):
"""
On a capable uBridge every Ethernet slot/port gets its persistent TAP —
swept of leftovers first, ownership handed to this user (so the
unprivileged hypervisor can open it), born down.
"""
router._ubridge_send.reset_mock()
await router._prepare_tap_datapath()
assert router._tap_datapath is True
assert router._kernel_taps == {(0, 0): TAP00, (0, 1): TAP01, (0, 16): TAP016}
router._ubridge_send.assert_any_call(f'tap delete "{TAP00}"')
router._ubridge_send.assert_any_call(f'tap create "{TAP00}"')
router._ubridge_send.assert_any_call(f"tap set_owner {TAP016} {os.getuid()}")
router._ubridge_send.assert_any_call(f'link set "{TAP016}" down')
@pytest.mark.asyncio
async def test_prepare_tap_datapath_falls_back_without_the_tap_module(router):
"""
A uBridge without the tap module keeps the router on the relay datapath —
it still starts, it just cannot carry kernel links.
"""
async def refuse_probe(command):
if "prob" in command:
raise UbridgeError("Unknown command")
return ["100-OK"]
router._ubridge_send.side_effect = refuse_probe
with patch.object(router.project, "emit") as emit:
await router._prepare_tap_datapath()
assert router._tap_datapath is False
assert router._kernel_taps == {}
assert emit.called
@pytest.mark.asyncio
async def test_prepare_tap_datapath_skips_ghost_routers(compute_project, manager):
"""
Ghost IOS images share RAM with a real router and never link: no uBridge,
no anchors.
"""
ghost = Router("Ghost", NODE_ID, compute_project, manager, ghost_flag=True)
ghost._start_ubridge = AsyncioMagicMock()
await ghost._prepare_tap_datapath()
assert ghost._tap_datapath is False
assert not ghost._start_ubridge.called
@pytest.mark.asyncio
async def test_prepare_tap_datapath_keeps_existing_anchors_across_a_restart(router):
"""
Nothing is undone at node stop, so a restart must not recreate an anchor
a surviving kernel link is still enslaved to.
"""
await router._prepare_tap_datapath()
router._ubridge_send.reset_mock()
await router._prepare_tap_datapath()
assert router._kernel_taps == {(0, 0): TAP00, (0, 1): TAP01, (0, 16): TAP016}
assert not any("tap create" in c or "tap delete" in c for c in _ubridge_commands(router))
@pytest.mark.asyncio
async def test_prepare_tap_datapath_wires_nios_bound_while_stopped(router):
"""
A kernel link bound to a stopped router could only be stored (no anchors
existed); the start wires it — the hypervisor opens the anchor and binds
the port, the anchor joins the per-link bridge.
"""
router.status = "stopped"
nio = await _nio(router)
router._slots[0].add_nio(0, nio)
router._ubridge_send.reset_mock()
router._hypervisor.commands.clear()
await router._prepare_tap_datapath()
hypervisor = _hypervisor_commands(router)
assert any(c.startswith("nio create_tap tap-") and c.endswith(f"{TAP00}") for c in hypervisor)
assert any(c.startswith('vm slot_add_nio_binding "R1" 0 0 tap-') for c in hypervisor)
router._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP00}"')
assert (0, 0) in router._tap_nios
# ---------------------------------------------------------------------------
# Link attach / detach
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_attach_kernel_nio_opens_the_anchor_in_the_hypervisor_and_enslaves_it(router):
"""
A kernel link opens the port's anchor TAP in the Dynamips hypervisor
(Dynamips holds the fd — QEMU's shape), binds it to the slot/port, then
enslaves the anchor into the per-link kernel bridge.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router)
await router._attach_kernel_nio(0, 0, nio)
hypervisor = _hypervisor_commands(router)
assert any(c.startswith("nio create_tap tap-") and c.endswith(f"{TAP00}") for c in hypervisor)
assert any(c.startswith('vm slot_add_nio_binding "R1" 0 0 tap-') for c in hypervisor)
router._ubridge_send.assert_any_call(f'brctl create "{BRIDGE}"')
router._ubridge_send.assert_any_call(f'link set "{BRIDGE}" up')
router._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP00}"')
router._ubridge_send.assert_any_call(f'link set "{TAP00}" up')
@pytest.mark.asyncio
async def test_slot_add_nio_binding_refuses_an_occupied_port(router):
"""
The compute-side backstop of the duplicate-port guard: a second NIO for
a port that already carries one is refused here instead of overwriting
(the wiring's own idempotence check would silently skip the attach and
rebind the bookkeeping) — the occupying link's teardown needs its own
NIO handle, and its host state with it.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
first = await _nio(router)
await router.slot_add_nio_binding(0, 0, first)
assert router._slots[0].get_nio(0) is first
second = await _nio(router, bridge="gns3other")
with pytest.raises(DynamipsError, match="already has a link"):
await router.slot_add_nio_binding(0, 0, second)
assert router._slots[0].get_nio(0) is first
@pytest.mark.asyncio
async def test_slot_add_nio_binding_attach_failure_leaves_the_port_unbound(router):
"""
The bookkeeping happens last: a kernel attach that fails mid-way (the
anchor is another link's bridge port — uBridge EBUSY) must leave the
port without a binding, so the failed link creates no half-wired state.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
router._ubridge_send.side_effect = UbridgeError("208-Device or resource busy")
nio = await _nio(router)
with pytest.raises(UbridgeError):
await router.slot_add_nio_binding(0, 0, nio)
assert router._slots[0].get_nio(0) is None
@pytest.mark.asyncio
async def test_attach_kernel_nio_failure_undoes_the_binding_so_a_retry_works(router):
"""
A failure after the hypervisor binding (the enslave step here) must undo
the half-attach: the _tap_nios entry would otherwise short-circuit every
retry, and a link delete reads the still-unbound adapter as "nothing to
do" — a silent black hole until the router restarts.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router)
real_attach = router._kernel_attach
router._kernel_attach = AsyncioMagicMock(side_effect=UbridgeError("208-Device or resource busy"))
with pytest.raises(UbridgeError):
await router._attach_kernel_nio(0, 0, nio)
router._kernel_attach = real_attach
# the half-attach went away with the failure...
assert (0, 0) not in router._tap_nios
assert any("slot_remove_nio_binding" in c for c in _hypervisor_commands(router))
# ...so the retry wires the port for real
await router._attach_kernel_nio(0, 0, nio)
assert (0, 0) in router._tap_nios
router._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP00}"')
@pytest.mark.asyncio
async def test_attach_kernel_nio_without_anchor_on_a_running_node_raises(router):
"""
A kernel link on a serial port (or a router that never anchored) is an
error naming the relay datapath, not a silent fallback.
"""
router.status = "started"
nio = await _nio(router)
with pytest.raises(DynamipsError, match="relay datapath"):
await router._attach_kernel_nio(1, 0, nio)
@pytest.mark.asyncio
async def test_attach_kernel_nio_defers_on_a_stopped_node(router):
"""
On a stopped router there are no anchors yet: the NIO is stored and the
wiring happens at start (test above), so nothing is sent.
"""
router.status = "stopped"
nio = await _nio(router)
await router._attach_kernel_nio(1, 0, nio)
assert _hypervisor_commands(router) == []
assert not router._ubridge_send.called
@pytest.mark.asyncio
async def test_attach_kernel_nio_is_idempotent_across_a_restart(router):
"""
A stop tears nothing down, so a wired port must not re-bind on the next
start: a second nio create_tap would orphan the first NIO's fd and the
re-enslavement would collide on the bridge.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router)
await router._attach_kernel_nio(0, 0, nio)
hypervisor_first = list(_hypervisor_commands(router))
router._ubridge_send.reset_mock()
await router._attach_kernel_nio(0, 0, nio)
assert _hypervisor_commands(router) == hypervisor_first
assert not router._ubridge_send.called
@pytest.mark.asyncio
async def test_add_nio_binding_relay_keeps_the_udp_tunnel(router):
"""
A relay link is wired exactly as before: the hypervisor NIO is a UDP NIO
tunneled through loopback into the node's uBridge relay.
"""
router.status = "started"
nio = await router.manager.create_nio(
router, {"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001}
)
await router.slot_add_nio_binding(0, 0, nio)
hypervisor = _hypervisor_commands(router)
assert any(c.startswith("nio create_udp udp-") for c in hypervisor)
assert any(c.startswith('vm slot_add_nio_binding "R1" 0 0 udp-') for c in hypervisor)
ubridge = _ubridge_commands(router)
assert any(c.startswith("bridge create DYNAMIPS-") for c in ubridge)
assert any(c.startswith("bridge add_nio_udp DYNAMIPS-") for c in ubridge)
assert any(c.startswith("bridge start DYNAMIPS-") for c in ubridge)
@pytest.mark.asyncio
async def test_add_nio_binding_kernel_binds_and_enslaves(router):
"""
The route-level add path dispatches a NIOBridge to the kernel wiring and
stores it in the slot adapter like any NIO.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router)
await router.slot_add_nio_binding(0, 0, nio)
assert router._slots[0].get_nio(0) is nio
router._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP00}"')
@pytest.mark.asyncio
async def test_update_nio_binding_kernel_reapplies_on_the_anchor(router):
"""
A filter update on an attached kernel link reconciles on the anchor
(tc) without re-binding the port or re-enslaving the TAP.
"""
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router, filters={"delay": [10]})
router._slots[0].add_nio(0, nio)
await router.slot_update_nio_binding(0, 0, nio)
router._ubridge_send.assert_any_call(f'tc reset "{TAP00}"')
router._ubridge_send.assert_any_call(f'tc netem set "{TAP00}" delay 10')
assert not any("slot_add_nio_binding" in c for c in _hypervisor_commands(router))
assert not any("brctl addif" in c for c in _ubridge_commands(router))
@pytest.mark.asyncio
async def test_update_nio_binding_kernel_suspend_flaps_the_anchor(router):
"""
Suspending a kernel link admin-downs the anchor — the native carrier,
which is why the route must copy suspend onto the NIO.
"""
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router)
router._slots[0].add_nio(0, nio)
nio.suspend = True
await router.slot_update_nio_binding(0, 0, nio)
router._ubridge_send.assert_any_call(f'link set "{TAP00}" down')
@pytest.mark.asyncio
async def test_remove_nio_binding_kernel_releases_the_fd_and_detaches(router):
"""
Removing a kernel link detaches the anchor from the per-link bridge,
resets its qdisc, and unbinds the port plus deletes the hypervisor TAP
NIO — releasing Dynamips's fd while the control channels live.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router)
router._slots[0].add_nio(0, nio)
await router._attach_kernel_nio(0, 0, nio)
router._ubridge_send.reset_mock()
router._hypervisor.commands.clear()
removed = await router.slot_remove_nio_binding(0, 0)
assert removed is nio
assert router._slots[0].get_nio(0) is None
router._ubridge_send.assert_any_call(f'tc reset "{TAP00}"')
router._ubridge_send.assert_any_call(f'brctl delif "{BRIDGE}" "{TAP00}"')
router._ubridge_send.assert_any_call(f'brctl delete "{BRIDGE}"')
hypervisor = _hypervisor_commands(router)
assert any(c.startswith('vm slot_remove_nio_binding "R1" 0 0') for c in hypervisor)
assert any(c.startswith("nio delete tap-") for c in hypervisor)
assert (0, 0) not in router._tap_nios
# ---------------------------------------------------------------------------
# Stop order (the hypervisor holds the anchor fds — QEMU's order)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stop_ubridge_releases_hypervisor_fds_before_deleting_taps(router):
"""
The hypervisor's TAP NIOs are unbound and deleted first (closing every
anchor fd), then the TAPs are unpersisted, then the per-link bridges go —
all before uBridge itself stops.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router)
router._slots[0].add_nio(0, nio)
await router._attach_kernel_nio(0, 0, nio)
router._ubridge_send.reset_mock()
router._hypervisor.commands.clear()
with patch("gns3server.compute.base_node.BaseNode._stop_ubridge", new=AsyncioMagicMock()) as stop:
await router._stop_ubridge()
hypervisor = _hypervisor_commands(router)
ubridge = _ubridge_commands(router)
unbind = next(i for i, c in enumerate(hypervisor) if c.startswith('vm slot_remove_nio_binding "R1" 0 0'))
nio_delete = next(i for i, c in enumerate(hypervisor) if c.startswith("nio delete tap-"))
tap_delete = next(i for i, c in enumerate(ubridge) if c == f'tap delete "{TAP00}"')
brctl_delete = next(i for i, c in enumerate(ubridge) if c == f'brctl delete "{BRIDGE}"')
assert unbind < nio_delete
assert tap_delete < brctl_delete
assert stop.called
assert router._kernel_taps == {}
assert router._tap_nios == {}
assert router._ubridge_tc_caps is None
@pytest.mark.asyncio
async def test_remove_kernel_bridges_covers_wic_ports(router):
"""
The bridge sweep walks adapters derived from the anchor map, so a kernel
link on a WIC-1ENET port (inside a non-Ethernet motherboard) is found.
"""
router._kernel_taps[(0, 16)] = TAP016
wic_nio = await _nio(router)
router._slots[0].add_nio(16, wic_nio)
await router._remove_kernel_bridges()
router._ubridge_send.assert_any_call(f'brctl delete "{BRIDGE}"')
@pytest.mark.asyncio
async def test_slot_add_binding_hot_adds_anchors(router):
"""
An adapter inserted while the router runs (OIR) gets its anchors created
at once, so a kernel link can attach without a restart.
"""
router.status = "started"
router._tap_datapath = True
router._kernel_taps[(0, 0)] = TAP00
router._slots[1] = None # free the serial slot for the hot-insert
await router.slot_add_binding(1, PA_FE_TX())
assert router._kernel_taps[(1, 0)] == "gd00010203e1p0"
# ---------------------------------------------------------------------------
# Capture (kernel: the anchor; relay: the Dynamips NIO's own pcap)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_capture_on_a_kernel_link_uses_the_anchor(router):
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router)
router._slots[0].add_nio(0, nio)
await router.start_capture(0, 0, "/tmp/test.pcap")
router._ubridge_send.assert_any_call(f'capture start_kernel {TAP00} "/tmp/test.pcap"')
assert not any("bind_filter" in c for c in _hypervisor_commands(router))
await router.stop_capture(0, 0)
router._ubridge_send.assert_any_call("capture stop_kernel")
@pytest.mark.asyncio
async def test_capture_on_a_relay_link_uses_the_dynamips_nio(router):
nio = await router.manager.create_nio(
router, {"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001}
)
router._slots[0].add_nio(0, nio)
await router.start_capture(0, 0, "/tmp/test.pcap")
assert not any("capture start_kernel" in c for c in _ubridge_commands(router))
hypervisor = _hypervisor_commands(router)
assert any(c.startswith("nio bind_filter udp-") and c.endswith(" capture") for c in hypervisor)
assert any(c.startswith("nio setup_filter udp-") for c in hypervisor)
# ---------------------------------------------------------------------------
# Markers (the polymorphic anchor: relay bridge name on relay, TAP on kernel)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_markers_on_a_kernel_link_attach_to_the_anchor(router):
"""
The marker reconcile is datapath-agnostic: with the anchor as the
location, the attach/toggle/delete primitives translate to
marker *_kernel on the TAP.
"""
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router)
nio.markers = {
"m1": {"bpf": "vlan 100", "link_id": "lk1", "enabled": True, "data_link_type": "DLT_EN10MB"},
}
router._slots[0].add_nio(0, nio)
await router._ubridge_apply_markers(TAP00, nio)
router._ubridge_send.assert_any_call(
f'marker add_kernel m1 {TAP00} "vlan 100" link lk1 pcap "{router.project.markers_working_directory()}/'
f'{router.id}_lk1_m1.pcap"'
)
assert router._marker_filter_bridges[("m1", "lk1")] == TAP00
await router._ubridge_set_marker_filter_state("m1", False)
router._ubridge_send.assert_any_call(f"marker enable_kernel {TAP00} m1 off")
await router.delete_marker_capture("m1", "lk1", nio)
router._ubridge_send.assert_any_call(f"marker delete_kernel {TAP00} m1")
@pytest.mark.asyncio
async def test_markers_on_a_relay_link_keep_the_ubridge_shape(router):
"""
On the relay datapath the same reconcile talks to the uBridge relay
bridge behind the Dynamips UDP tunnel — unchanged behaviour.
"""
nio = await router.manager.create_nio(
router, {"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001}
)
nio.markers = {
"m1": {"bpf": "vlan 100", "link_id": "lk1", "enabled": True, "data_link_type": "DLT_EN10MB"},
}
router._slots[0].add_nio(0, nio)
tunnel_bridge = "DYNAMIPS-20000-20001"
await router._ubridge_apply_markers(tunnel_bridge, nio)
router._ubridge_send.assert_any_call(
f'bridge add_packet_filter {tunnel_bridge} m1 mark "vlan 100" link lk1 pcap '
f'"{router.project.markers_working_directory()}/{router.id}_lk1_m1.pcap"'
)
# ---------------------------------------------------------------------------
# Manager NIO construction
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_manager_create_nio_builds_the_bridge_nio(router):
"""
The Dynamips manager accepts nio_bridge settings (the kernel-datapath
NIO the controller POSTs) — without it the route 422s before the router
ever sees the link.
"""
nio = await router.manager.create_nio(
router, {"type": "nio_bridge", "bridge": BRIDGE, "filters": {"delay": [5]}, "suspend": True}
)
assert isinstance(nio, NIOBridge)
assert nio.bridge == BRIDGE
assert nio.filters == {"delay": [5]}
assert nio.suspend is True
assert _hypervisor_commands(router) == []
# ---------------------------------------------------------------------------
# Externally bridged anchors (an Ethernet switch absorbed them)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_external_bridge_nio_skips_bridge_membership(router):
"""
A NIOBridge with no bridge means an Ethernet switch owns the anchor's
bridge membership: the router still opens the anchor in the hypervisor
(the TAP needs its fd holder) but never touches any kernel bridge.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router, bridge=None)
await router.slot_add_nio_binding(0, 0, nio)
hypervisor = _hypervisor_commands(router)
assert any(c.startswith("nio create_tap tap-") for c in hypervisor)
assert any(c.startswith('vm slot_add_nio_binding "R1" 0 0 tap-') for c in hypervisor)
assert not any("brctl" in c for c in _ubridge_commands(router))
@pytest.mark.asyncio
async def test_external_bridge_nio_remove_skips_bridge_ops(router):
"""
Detaching an externally bridged anchor releases the hypervisor's fd and
resets the link state, but must not delif or delete any bridge — the
switch owns them, and deleting the switch's bridge would take the whole
switch down.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router, bridge=None)
await router.slot_add_nio_binding(0, 0, nio)
router._ubridge_send.reset_mock()
await router.slot_remove_nio_binding(0, 0)
commands = _ubridge_commands(router)
assert not any("brctl" in c for c in commands), commands
assert any(c == f'tc reset "{TAP00}"' for c in commands)
@pytest.mark.asyncio
async def test_remove_kernel_bridges_skips_external_nios(router):
"""
The bridge sweep never deletes a bridge this node does not own.
"""
router._kernel_taps[(0, 0)] = TAP00
external = await _nio(router, bridge=None)
router._slots[0].add_nio(0, external)
await router._remove_kernel_bridges()
assert not any("brctl" in c for c in _ubridge_commands(router))
@@ -0,0 +1,450 @@
#
# 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 as either version 2 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 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/>.
#
"""
Kernel-datapath wiring for IOU: the persistent TAP every Ethernet bay/unit
owns, the IOL-fabric binding (iol_bridge add_nio_tap) a kernel link adds to
it, kernel-link enslavement, the relay fallback for a uBridge without the
command, and the reverse-of-QEMU stop order (uBridge holds the anchor fds).
"""
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from gns3server.compute.iou import IOU
from gns3server.compute.iou.iou_error import IOUError
from gns3server.compute.iou.iou_vm import IOUVM
from gns3server.compute.ubridge.ubridge_error import UbridgeError
from tests.utils import AsyncioMagicMock
NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f"
BRIDGE = "gns3a1b2c3d4e5f"
TAP00 = "gi00010203e0p0" # the vm fixture's node id, bay 0 unit 0
IOL_BRIDGE = "IOL-BRIDGE-513" # application_id 1 + 512
@pytest_asyncio.fixture
async def manager(port_manager):
m = IOU.instance()
m.port_manager = port_manager
return m
@pytest_asyncio.fixture(scope="function")
async def vm(compute_project, manager):
vm = IOUVM("test", NODE_ID, compute_project, manager, application_id=1)
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_hypervisor.is_running.return_value = True
vm._ubridge_send = AsyncioMagicMock()
return vm
def _capable(vm, monkeypatch):
"""Make the iol-tap capability probe answer True."""
async def probe(timeout=15.0):
return True
monkeypatch.setattr("gns3server.compute.iou.iou_vm.probe_iol_tap_support", probe)
return vm
# ---------------------------------------------------------------------------
# Anchor lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_tap_datapath_creates_one_tap_per_ethernet_unit(vm, monkeypatch):
"""
On a capable uBridge every Ethernet bay/unit gets its persistent TAP —
four units per bay, the IOU adapter model — swept of leftovers first and
born down. Serial bays never anchor.
"""
_capable(vm, monkeypatch)
await vm._prepare_tap_datapath()
assert vm._tap_datapath is True
assert set(vm._kernel_taps) == {(bay, unit) for bay in range(2) for unit in range(4)}
assert vm._kernel_taps[(0, 0)] == TAP00
vm._ubridge_send.assert_any_call(f'tap delete "{TAP00}"')
vm._ubridge_send.assert_any_call(f'tap create "{TAP00}"')
vm._ubridge_send.assert_any_call(f'link set "{TAP00}" down')
assert not any("set_owner" in str(c) for c in vm._ubridge_send.call_args_list)
@pytest.mark.asyncio
async def test_prepare_tap_datapath_falls_back_without_the_command(vm, monkeypatch):
"""
A uBridge without iol_bridge add_nio_tap keeps the node on the relay
datapath — it still starts, it just cannot carry kernel links.
"""
async def probe(timeout=15.0):
return None
monkeypatch.setattr("gns3server.compute.iou.iou_vm.probe_iol_tap_support", probe)
with patch.object(vm.project, "emit") as emit:
await vm._prepare_tap_datapath()
assert vm._tap_datapath is False
assert vm._kernel_taps == {}
assert emit.called
def test_tap_name_fits_ifnamsiz(vm):
"""
The anchor name has to fit IFNAMSIZ (15) even for a late bay/unit.
"""
assert len(vm._tap_name(15, 3)) <= 15
def test_kernel_host_ifc_is_none_on_serial_bays(vm):
"""
Serial bays (past the Ethernet adapters) never anchor, so a kernel link
there fails with the serial-port message rather than wiring nonsense.
"""
assert vm._kernel_host_ifc(2, 0) is None
# ---------------------------------------------------------------------------
# Link attach / detach
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_attach_kernel_nio_binds_the_anchor_and_enslaves_it(vm):
"""
A kernel link binds the port's anchor TAP to the IOL bridge (uBridge
holds the fd and relays the fabric onto it), then enslaves the TAP into
the per-link kernel bridge.
"""
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
await vm._attach_kernel_nio(0, 0, nio)
vm._ubridge_send.assert_any_call(f'iol_bridge add_nio_tap {IOL_BRIDGE} 1 0 0 "{TAP00}"')
vm._ubridge_send.assert_any_call(f'brctl create "{BRIDGE}"')
vm._ubridge_send.assert_any_call(f'link set "{BRIDGE}" up')
vm._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP00}"')
vm._ubridge_send.assert_any_call(f'link set "{TAP00}" up')
@pytest.mark.asyncio
async def test_attach_kernel_nio_without_anchor_raises(vm):
"""
A kernel link on a serial bay (or a node that never anchored) is an
error naming the relay datapath, not a silent fallback.
"""
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
with pytest.raises(IOUError, match="no TAP anchor"):
await vm._attach_kernel_nio(2, 0, nio)
@pytest.mark.asyncio
async def test_add_nio_binding_refuses_an_occupied_port(vm):
"""
The compute-side backstop of the duplicate-port guard: a second NIO for
a port that already carries one is refused here instead of overwriting
— the occupying link's teardown needs its own NIO handle, and its host
state with it.
"""
vm._kernel_taps[(0, 0)] = TAP00
first = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
await vm.adapter_add_nio_binding(0, 0, first)
assert vm._ethernet_adapters[0].get_nio(0) is first
second = vm.manager.create_nio({"type": "nio_bridge", "bridge": "gns3other"})
with pytest.raises(IOUError, match="already has a link"):
await vm.adapter_add_nio_binding(0, 0, second)
assert vm._ethernet_adapters[0].get_nio(0) is first
@pytest.mark.asyncio
async def test_add_nio_binding_attach_failure_leaves_the_port_unbound(vm):
"""
The bookkeeping happens last: an attach that fails mid-way (uBridge
EBUSY on a held anchor) must leave the port without a binding, so the
failed link creates no half-wired state.
"""
vm._kernel_taps[(0, 0)] = TAP00
vm._ubridge_send.side_effect = UbridgeError("208-Device or resource busy")
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
with pytest.raises(UbridgeError):
await vm.adapter_add_nio_binding(0, 0, nio)
assert vm._ethernet_adapters[0].get_nio(0) is None
@pytest.mark.asyncio
async def test_add_nio_binding_relay_keeps_the_iol_udp_path(vm):
"""
A relay link is wired exactly as before: a UDP NIO on the IOL bridge
port, userspace filters at the port's IOL location.
"""
vm._ubridge_hypervisor.is_running.return_value = True
nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
nio.filters = {"delay": [10]}
await vm.adapter_add_nio_binding(0, 0, nio)
vm._ubridge_send.assert_any_call(f"iol_bridge add_nio_udp {IOL_BRIDGE} 1 0 0 20000 127.0.0.1 20001")
vm._ubridge_send.assert_any_call(f"iol_bridge reset_packet_filters {IOL_BRIDGE} 0 0")
vm._ubridge_send.assert_any_call(f"iol_bridge add_packet_filter {IOL_BRIDGE} 0 0 filter0 delay 10")
@pytest.mark.asyncio
async def test_update_nio_binding_kernel_reapplies_on_the_anchor(vm):
"""
A filter update on an attached kernel link reconciles on the anchor
(tc) without re-binding the IOL port or re-enslaving the TAP.
"""
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
nio.filters = {"delay": [10]}
await vm.adapter_update_nio_binding(0, 0, nio)
vm._ubridge_send.assert_any_call(f'tc reset "{TAP00}"')
vm._ubridge_send.assert_any_call(f'tc netem set "{TAP00}" delay 10')
assert not any("iol_bridge add_nio" in str(c) for c in vm._ubridge_send.call_args_list)
assert not any("brctl addif" in str(c) for c in vm._ubridge_send.call_args_list)
@pytest.mark.asyncio
async def test_update_nio_binding_kernel_suspend_flaps_the_anchor(vm):
"""
Suspending a kernel link admin-downs the anchor: the IOL relay's writes
fail EIO (dropped) and nothing comes back — a genuinely dead link.
"""
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
nio.suspend = True
await vm.adapter_update_nio_binding(0, 0, nio)
vm._ubridge_send.assert_any_call(f'link set "{TAP00}" down')
@pytest.mark.asyncio
async def test_remove_nio_binding_kernel_releases_the_fd_and_detaches(vm):
"""
Removing a kernel link detaches the anchor from the per-link bridge,
resets its qdisc, and unbinds the port from the TAP — releasing uBridge's
fd while the control channel lives, so a later tap delete cannot hit
EBADFD.
"""
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
await vm.adapter_remove_nio_binding(0, 0)
vm._ubridge_send.assert_any_call(f'tc reset "{TAP00}"')
vm._ubridge_send.assert_any_call(f'brctl delif "{BRIDGE}" "{TAP00}"')
vm._ubridge_send.assert_any_call(f'brctl delete "{BRIDGE}"')
vm._ubridge_send.assert_any_call(f"iol_bridge delete_nio_tap {IOL_BRIDGE} 0 0")
# ---------------------------------------------------------------------------
# Stop order (uBridge holds the anchor fds — the reverse of QEMU)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stop_ubridge_releases_fds_before_deleting_taps(vm):
"""
The IOL bridge must be deleted (closing every port's TAP fd) before the
taps are unpersisted, and the per-link bridges still need the control
channel — all before the hypervisor itself stops.
"""
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
with patch("gns3server.compute.base_node.BaseNode._stop_ubridge", new=AsyncioMagicMock()) as stop:
await vm._stop_ubridge()
commands = [str(c[0][0]) if c[0] else "" for c in vm._ubridge_send.call_args_list]
assert commands.index(f"iol_bridge delete {IOL_BRIDGE}") < commands.index(f'tap delete "{TAP00}"')
assert commands.index(f'tap delete "{TAP00}"') < commands.index(f'brctl delete "{BRIDGE}"')
assert commands.index(f'brctl delete "{BRIDGE}"') < len(commands)
assert stop.called
assert vm._kernel_taps == {}
assert vm._ubridge_tc_caps is None
# ---------------------------------------------------------------------------
# _networking (node start with links already attached)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_networking_wires_kernel_and_relay_ports(vm):
"""
On start, a kernel-linked port binds its anchor to the IOL bridge and
enslaves it; a relay-linked port keeps its UDP NIO. The IOL bridge is
recreated and started around both.
"""
vm._kernel_taps[(0, 0)] = TAP00
kernel_nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, kernel_nio)
relay_nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
vm._ethernet_adapters[1].add_nio(1, relay_nio)
await vm._networking()
vm._ubridge_send.assert_any_call(f"iol_bridge delete {IOL_BRIDGE}")
vm._ubridge_send.assert_any_call(f"iol_bridge create {IOL_BRIDGE} 513")
vm._ubridge_send.assert_any_call(f'iol_bridge add_nio_tap {IOL_BRIDGE} 1 0 0 "{TAP00}"')
vm._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP00}"')
vm._ubridge_send.assert_any_call(f"iol_bridge add_nio_udp {IOL_BRIDGE} 1 1 1 20000 127.0.0.1 20001")
vm._ubridge_send.assert_any_call(f"iol_bridge start {IOL_BRIDGE}")
@pytest.mark.asyncio
async def test_networking_restores_relay_capture_with_port_coordinates(vm):
"""
A capture active across a restart is restored on the port's bay/unit —
the iol_bridge command takes them positionally (the old restore sent a
3-arg command uBridge refused with 203).
"""
relay_nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
relay_nio.start_packet_capture("/tmp/capture.pcap", "DLT_EN10MB")
vm._ethernet_adapters[0].add_nio(0, relay_nio)
await vm._networking()
vm._ubridge_send.assert_any_call(f'iol_bridge start_capture {IOL_BRIDGE} 0 0 "/tmp/capture.pcap" EN10MB')
# ---------------------------------------------------------------------------
# Capture
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_capture_on_a_kernel_link_uses_the_anchor(vm):
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
await vm.start_capture(0, 0, "/tmp/test.pcap")
vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP00} "/tmp/test.pcap"')
await vm.stop_capture(0, 0)
vm._ubridge_send.assert_any_call("capture stop_kernel")
@pytest.mark.asyncio
async def test_capture_on_a_relay_link_uses_the_iol_port(vm):
nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
vm._ethernet_adapters[0].add_nio(0, nio)
await vm.start_capture(0, 0, "/tmp/test.pcap")
vm._ubridge_send.assert_any_call(f'iol_bridge start_capture {IOL_BRIDGE} 0 0 "/tmp/test.pcap" EN10MB')
await vm.stop_capture(0, 0)
vm._ubridge_send.assert_any_call(f"iol_bridge stop_capture {IOL_BRIDGE} 0 0")
# ---------------------------------------------------------------------------
# Markers (the polymorphic anchor: IOL location on relay, TAP on kernel)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_markers_on_a_kernel_link_attach_to_the_anchor(vm):
"""
The marker reconcile is datapath-agnostic: with the anchor as the
location, the attach/toggle/delete primitives translate to
marker *_kernel on the TAP.
"""
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
nio.markers = {
"m1": {"bpf": "vlan 100", "link_id": "lk1", "enabled": True, "data_link_type": "DLT_EN10MB"},
}
vm._ethernet_adapters[0].add_nio(0, nio)
await vm._ubridge_apply_markers(TAP00, nio)
vm._ubridge_send.assert_any_call(
f'marker add_kernel m1 {TAP00} "vlan 100" link lk1 pcap "{vm.project.markers_working_directory()}/'
f'{vm.id}_lk1_m1.pcap"'
)
assert vm._marker_filter_bridges[("m1", "lk1")] == TAP00
await vm._ubridge_set_marker_filter_state("m1", False)
vm._ubridge_send.assert_any_call(f"marker enable_kernel {TAP00} m1 off")
await vm.delete_marker_capture("m1", "lk1", nio)
vm._ubridge_send.assert_any_call(f"marker delete_kernel {TAP00} m1")
@pytest.mark.asyncio
async def test_markers_on_a_relay_link_keep_the_iol_shape(vm):
"""
On the relay datapath the same reconcile talks iol_bridge, with the
bay/unit positionals — unchanged behaviour.
"""
nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
nio.markers = {
"m1": {"bpf": "vlan 100", "link_id": "lk1", "enabled": True, "data_link_type": "DLT_EN10MB"},
}
vm._ethernet_adapters[0].add_nio(0, nio)
location = f"{IOL_BRIDGE} 0 0"
await vm._ubridge_apply_markers(location, nio)
vm._ubridge_send.assert_any_call(
f'iol_bridge add_packet_filter {location} m1 mark "vlan 100" link lk1 pcap '
f'"{vm.project.markers_working_directory()}/{vm.id}_lk1_m1.pcap"'
)
assert vm._marker_filter_bridges[("m1", "lk1")] == location
await vm._ubridge_set_marker_filter_state("m1", False)
vm._ubridge_send.assert_any_call(f"iol_bridge enable_packet_filter {location} m1 off")
await vm.delete_marker_capture("m1", "lk1", nio)
vm._ubridge_send.assert_any_call(f"iol_bridge delete_packet_filter {location} m1")
+52
View File
@@ -26,9 +26,11 @@ from unittest.mock import MagicMock, call
import pytest
import pytest_asyncio
from gns3server.compute.error import NodeError
from gns3server.compute.iou import IOU
from gns3server.compute.iou.iou_error import IOUError
from gns3server.compute.iou.iou_vm import IOUVM, IOUL1KeepaliveProtocol
from gns3server.compute.ubridge.ubridge_error import UbridgeError
from tests.utils import AsyncioMagicMock, asyncio_patch
@@ -151,6 +153,26 @@ async def test_start_failure_stops_l1_responder(vm):
vm._stop_l1_keepalive_responder.assert_called_once_with()
@pytest.mark.asyncio
async def test_start_failure_in_the_tap_setup_stops_ubridge(vm):
"""
A raise before the IOU launch (the tap datapath setup here) must take
the uBridge datapath down again: a node reporting a failed start must
not keep a uBridge process and its anchor TAPs alive.
"""
vm._check_requirements = AsyncioMagicMock(return_value=True)
vm._check_iou_license = AsyncioMagicMock(return_value=True)
vm._start_ubridge = AsyncioMagicMock()
vm._prepare_tap_datapath = AsyncioMagicMock(side_effect=IOUError("tap create refused"))
vm._stop_ubridge = AsyncioMagicMock()
with pytest.raises(IOUError, match="tap create refused"):
await vm.start()
assert vm._stop_ubridge.call_count == 1
@pytest.mark.asyncio
async def test_start_with_iourc(vm, tmpdir, config):
@@ -580,6 +602,36 @@ def test_get_legacy_vm_workdir():
assert IOU.get_legacy_vm_workdir(42, "bla") == "iou/device-42"
@pytest.mark.asyncio
async def test_ubridge_apply_filters_rejects_kernel_only(vm):
"""
The IOL relay path carries the base guard: a kernel-only filter reaching
a relay port is a clear NodeError, not a late uBridge failure.
"""
vm._ubridge_send = AsyncioMagicMock()
with pytest.raises(NodeError, match="kernel-datapath"):
await vm._ubridge_apply_filters("bridge0 0 0", {"rate": ["512kbit"]})
vm._ubridge_send.assert_not_called()
@pytest.mark.asyncio
async def test_ubridge_apply_filters_degrades_a_bad_bpf_line(vm):
"""
A BPF line that no longer compiles on this uBridge's libpcap is a
warning, not a hard failure — the same degradation as the base relay
path (the controller validated the syntax with tcpdump at update time).
"""
async def refuse(command):
if "add_packet_filter" in command:
raise UbridgeError("Cannot compile filter 'icmp': syntax error")
return ["OK"]
vm._ubridge_send = refuse
await vm._ubridge_apply_filters("bridge0 0 0", {"bpf": ["icmp"]})
@pytest.mark.asyncio
async def test_invalid_iou_file(vm, iourc_file):
@@ -0,0 +1,504 @@
#
# 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/>.
#
"""
Kernel-datapath wiring for QEMU: the persistent TAP every adapter owns, its
netdev, kernel-link enslavement, relay attach on the anchor, capture, and the
legacy socket-netdev fallback for a uBridge without the tap module.
"""
import asyncio
import os
import stat
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from gns3server.compute.qemu import Qemu
from gns3server.compute.qemu.qemu_error import QemuError
from gns3server.compute.qemu.qemu_vm import QemuVM
from gns3server.compute.ubridge.ubridge_error import UbridgeError
from tests.utils import AsyncioMagicMock, asyncio_patch
BRIDGE = "gns3a1b2c3d4e5f"
TAP0 = "gq00010203e0p0" # the vm fixture's node id, adapter 0
TAP1 = "gq00010203e1p0" # ... adapter 1
@pytest_asyncio.fixture
async def manager(port_manager):
m = Qemu.instance()
m.port_manager = port_manager
return m
@pytest.fixture
def fake_qemu_binary(monkeypatch, tmpdir):
monkeypatch.setenv("PATH", str(tmpdir))
bin_path = os.path.join(os.environ["PATH"], "qemu-system-x86_64")
with open(bin_path, "w+") as f:
f.write("1")
os.chmod(bin_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
return bin_path
@pytest_asyncio.fixture(scope="function")
async def vm(compute_project, manager, fake_qemu_binary):
vm = QemuVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, qemu_path=fake_qemu_binary)
vm._process_priority = "normal"
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_hypervisor.is_running.return_value = True
vm._ubridge_send = AsyncioMagicMock()
return vm
def _running(vm):
"""Make is_running() true without a real process."""
vm._process = MagicMock(returncode=None)
# ---------------------------------------------------------------------------
# TAP lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_tap_datapath_creates_adapter_taps(vm):
"""
The tap module is probed once per start; on success every adapter gets its
persistent TAP, owned by this user (so unprivileged QEMU can open it) and
born down (carrier off until a link attaches). A leftover from a previous
run is swept first.
"""
await vm._prepare_tap_datapath()
assert vm._tap_datapath is True
assert vm._kernel_taps == {(0, 0): TAP0}
vm._ubridge_send.assert_any_call('tap create "gq00010203prob"')
vm._ubridge_send.assert_any_call('tap delete "gq00010203prob"')
vm._ubridge_send.assert_any_call(f'tap delete "{TAP0}"')
vm._ubridge_send.assert_any_call(f'tap create "{TAP0}"')
vm._ubridge_send.assert_any_call(f"tap set_owner {TAP0} {os.getuid()}")
vm._ubridge_send.assert_any_call(f'link set "{TAP0}" down')
@pytest.mark.asyncio
async def test_prepare_tap_datapath_falls_back_without_the_tap_module(vm):
"""
A uBridge build without the tap module keeps the legacy socket-netdev
datapath — the node still starts, it just cannot carry kernel links.
"""
async def send(command):
if command.startswith("tap create"):
raise UbridgeError("202-Unknown command 'create'")
vm._ubridge_send = AsyncioMagicMock(side_effect=send)
with patch.object(vm.project, "emit") as emit:
await vm._prepare_tap_datapath()
assert vm._tap_datapath is False
assert vm._kernel_taps == {}
assert emit.called
@pytest.mark.asyncio
async def test_tap_name_fits_ifnamsiz(vm):
"""
The anchor name has to fit IFNAMSIZ (15) even for a late adapter.
"""
vm.adapters = 32
assert len(vm._tap_name(31)) <= 15
@pytest.mark.asyncio
async def test_network_options_use_the_tap_anchor(vm):
"""
On the TAP datapath the netdev is the adapter's anchor, not a UDP socket.
"""
vm._tap_datapath = True
vm._kernel_taps[(0, 0)] = TAP0
options = await vm._network_options()
assert f"tap,id=gns3-0,ifname={TAP0},script=no,downscript=no" in options
assert not any(option.startswith("socket,") for option in options)
assert not vm._local_udp_tunnels
@pytest.mark.asyncio
async def test_network_options_keep_the_legacy_socket_netdev(vm):
"""
Without the tap module nothing changes: the local UDP tunnel into uBridge
is still the adapter's netdev.
"""
vm._tap_datapath = False
options = await vm._network_options()
assert any(option.startswith("socket,id=gns3-0,udp=") for option in options)
assert vm._local_udp_tunnels
# ---------------------------------------------------------------------------
# Link attach / detach
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_connect_nio_kernel_link_enslaves_the_tap(vm):
"""
A kernel link enslaves the adapter's TAP into the per-link kernel bridge,
with the bridge brought up first (a bridge that is down keeps its ports
DISABLED and forwards nothing, silently).
"""
vm._tap_datapath = True
vm._kernel_taps[(0, 0)] = TAP0
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
await vm._connect_nio(0, nio)
vm._ubridge_send.assert_any_call(f'brctl create "{BRIDGE}"')
vm._ubridge_send.assert_any_call(f'link set "{BRIDGE}" up')
vm._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP0}"')
@pytest.mark.asyncio
async def test_connect_nio_relay_uses_the_tap_as_af_packet_endpoint(vm):
"""
A relay link attaches the TAP to the uBridge relay bridge via AF_PACKET
(not the TAP NIO: that one takes the fd QEMU holds).
"""
vm._tap_datapath = True
vm._kernel_taps[(0, 0)] = TAP0
nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
await vm._connect_nio(0, nio)
bridge = f"QEMU-{vm.id}-0"
vm._ubridge_send.assert_any_call(f"bridge create {bridge}")
vm._ubridge_send.assert_any_call(f'link set "{TAP0}" up')
vm._ubridge_send.assert_any_call(f'bridge add_nio_ethernet {bridge} "{TAP0}"')
vm._ubridge_send.assert_any_call(f"bridge add_nio_udp {bridge} 20000 127.0.0.1 20001")
vm._ubridge_send.assert_any_call(f"bridge start {bridge}")
@pytest.mark.asyncio
async def test_connect_nio_without_anchor_raises(vm):
"""
A kernel link on an adapter whose TAP does not exist (a VM started before
the TAP datapath) fails with an actionable message.
"""
vm._tap_datapath = True
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
with pytest.raises(QemuError, match="no TAP interface"):
await vm._connect_nio(0, nio)
@pytest.mark.asyncio
async def test_legacy_datapath_rejects_kernel_link(vm):
"""
The legacy datapath has no anchor to enslave: a kernel link there is an
error, not a silent fallback to the relay.
"""
vm._tap_datapath = False
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
with pytest.raises(QemuError, match="legacy relay datapath"):
await vm._connect_nio(0, nio)
@pytest.mark.asyncio
async def test_add_nio_binding_on_a_running_vm_attaches_and_brings_the_carrier_up(vm):
_running(vm)
vm._tap_datapath = True
vm._kernel_taps[(0, 0)] = TAP0
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
await vm.adapter_add_nio_binding(0, nio)
vm._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP0}"')
vm._ubridge_send.assert_any_call(f'link set "{TAP0}" up')
@pytest.mark.asyncio
async def test_update_nio_binding_reapplies_filters_on_the_anchor(vm):
"""
A filter update on an attached kernel link reconciles on the anchor
without re-enslaving it (no second addif).
"""
_running(vm)
vm._tap_datapath = True
vm._kernel_taps[(0, 0)] = TAP0
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
nio.filters = {"delay": [10]}
await vm.adapter_update_nio_binding(0, nio)
vm._ubridge_send.assert_any_call(f'tc reset "{TAP0}"')
vm._ubridge_send.assert_any_call(f'tc netem set "{TAP0}" delay 10')
assert not any("brctl addif" in str(c) for c in vm._ubridge_send.call_args_list)
@pytest.mark.asyncio
async def test_remove_nio_binding_detaches_the_anchor(vm):
"""
Removing a kernel link resets the anchor (no impairment survives on the
adapter) and detaches it from the bridge.
"""
_running(vm)
vm._tap_datapath = True
vm._kernel_taps[(0, 0)] = TAP0
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM.stop_capture"):
await vm.adapter_remove_nio_binding(0)
vm._ubridge_send.assert_any_call(f'tc reset "{TAP0}"')
vm._ubridge_send.assert_any_call(f'brctl delif "{BRIDGE}" "{TAP0}"')
vm._ubridge_send.assert_any_call(f'brctl delete "{BRIDGE}"')
@pytest.mark.asyncio
async def test_stop_ubridge_removes_taps_and_orphan_bridges(vm):
"""
A stopped VM has no links attached, so the TAPs and any per-link bridge
it still holds are removed while the uBridge control channel is up.
"""
vm._kernel_taps[(0, 0)] = TAP0
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
with asyncio_patch("gns3server.compute.base_node.BaseNode._stop_ubridge") as stop:
await vm._stop_ubridge()
vm._ubridge_send.assert_any_call(f'tap delete "{TAP0}"')
vm._ubridge_send.assert_any_call(f'brctl delete "{BRIDGE}"')
assert stop.called
assert vm._kernel_taps == {}
assert vm._ubridge_tc_caps is None
# ---------------------------------------------------------------------------
# Capture
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_capture_on_a_kernel_link_uses_the_anchor(vm):
vm._tap_datapath = True
vm._kernel_taps[(0, 0)] = TAP0
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
await vm.start_capture(0, "/tmp/test.pcap")
vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP0} "/tmp/test.pcap"')
await vm.stop_capture(0)
vm._ubridge_send.assert_any_call("capture stop_kernel")
@pytest.mark.asyncio
async def test_capture_on_a_relay_link_uses_the_relay_bridge(vm):
vm._tap_datapath = True
vm._kernel_taps[(0, 0)] = TAP0
nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
vm._ethernet_adapters[0].add_nio(0, nio)
await vm.start_capture(0, "/tmp/test.pcap")
vm._ubridge_send.assert_any_call(f'bridge start_capture QEMU-{vm.id}-0 "/tmp/test.pcap"')
await vm.stop_capture(0)
vm._ubridge_send.assert_any_call(f"bridge stop_capture QEMU-{vm.id}-0")
@pytest.mark.asyncio
async def test_stop_stops_the_process_before_deleting_the_taps(vm):
"""
The QEMU process is the guest side of every adapter TAP, and uBridge's
tap delete refuses a held device ("Device or resource busy") — that
best-effort delete is suppressed, so removing the taps while QEMU still
ran leaked the persistent TAPs. The process must be gone first (the same
lesson as IOU's reverse stop order: process side, then the devices).
"""
_running(vm)
order = []
async def fake_termination(process, timeout=None):
order.append("process")
async def fake_stop_ubridge():
order.append("taps")
with (
patch("gns3server.utils.asyncio.wait_for_process_termination", new=fake_termination),
patch.object(vm, "_stop_ubridge", new=fake_stop_ubridge),
asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM._export_config"),
asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM._clear_save_vm_stated"),
asyncio_patch("gns3server.compute.base_node.BaseNode.stop"),
):
await vm.stop()
assert order == ["process", "taps"]
@pytest.mark.asyncio
async def test_stop_releases_the_datapath_even_when_the_stop_body_raises(vm):
"""
The finally keeps a mid-stop raise (an export error here) from skipping
the uBridge/TAP release — the datapath would otherwise outlive a failed
stop with nothing left to reclaim it.
"""
_running(vm)
stopped = AsyncioMagicMock()
async def failing_export():
raise QemuError("qemu-img is missing")
with (
patch("gns3server.utils.asyncio.wait_for_process_termination", new=AsyncioMagicMock()),
patch.object(vm, "_export_config", new=failing_export),
patch.object(vm, "_stop_ubridge", new=stopped),
asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM._clear_save_vm_stated"),
):
with pytest.raises(QemuError, match="qemu-img is missing"):
await vm.stop()
assert stopped.call_count == 1
@pytest.mark.asyncio
async def test_start_failure_before_the_launch_tears_down_the_datapath(vm):
"""
A raise before the QEMU launch (a broken qemu_path in _build_command)
must take the uBridge/TAP datapath down again: a node reporting a failed
start must not keep a uBridge process and the anchor TAPs alive.
"""
stopped = AsyncioMagicMock()
with (
patch.object(vm, "check_available_ram"),
patch.object(vm, "_prepare_tap_datapath", new=AsyncioMagicMock()),
patch.object(vm, "_build_command", new=AsyncioMagicMock(side_effect=QemuError("qemu-img is missing"))),
patch.object(vm, "_stop_ubridge", new=stopped),
):
with pytest.raises(QemuError, match="qemu-img is missing"):
await vm.start()
assert stopped.call_count == 1
@pytest.mark.asyncio
async def test_add_nio_binding_refuses_an_occupied_adapter(vm):
"""
The compute-side backstop of the controller's duplicate-port guard: a
second NIO for an adapter that already carries one is refused instead of
silently overwriting the binding the first link's teardown still needs.
"""
vm._tap_datapath = True
vm._kernel_taps[(0, 0)] = TAP0
first = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
await vm.adapter_add_nio_binding(0, first)
second = vm.manager.create_nio({"type": "nio_bridge", "bridge": "gns3otherbridge"})
with pytest.raises(QemuError, match="already has a link"):
await vm.adapter_add_nio_binding(0, second)
assert vm._ethernet_adapters[0].get_nio(0) is first
@pytest.mark.asyncio
async def test_concurrent_stops_sweep_every_tap_exactly_once(vm):
"""
The process monitor calls stop() by itself when QEMU dies, so an API stop
and a process-death stop run concurrently in the normal case. Both used to
reach the TAP sweep, which walks the adapter map across awaits: whichever
swept first cleared the map under the other's iterator, and the survivor
died with "dictionary changed size during iteration" — logged against the
monitor's task as "Task exception was never retrieved", with the rest of
its sweep skipped.
The first stop is parked inside the sweep while the second one runs to
completion (in the fixed code it waits on the execution lock instead), then
released: every TAP must be swept exactly once and the sweep must survive.
"""
_running(vm)
vm._tap_datapath = True
vm._kernel_taps[(0, 0)] = TAP0
vm._kernel_taps[(1, 0)] = TAP1
release = asyncio.Event()
deletes = []
async def send(command):
if command.startswith("tap delete"):
deletes.append(command)
if len(deletes) == 1:
# park the first stop in the middle of its sweep
await release.wait()
vm._ubridge_send = AsyncioMagicMock(side_effect=send)
async def fake_termination(process, timeout=None):
pass
with (
patch("gns3server.utils.asyncio.wait_for_process_termination", new=fake_termination),
asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM._export_config"),
asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM._clear_save_vm_stated"),
asyncio_patch("gns3server.compute.base_node.BaseNode.stop"),
):
api_stop = asyncio.ensure_future(vm.stop())
for _ in range(50):
if deletes:
break
await asyncio.sleep(0)
assert deletes, "the first stop never reached the TAP sweep"
monitor_stop = asyncio.ensure_future(vm.stop())
for _ in range(50):
if monitor_stop.done():
break
await asyncio.sleep(0)
release.set()
await asyncio.gather(api_stop, monitor_stop)
assert sorted(deletes) == [f'tap delete "{TAP0}"', f'tap delete "{TAP1}"']
+16
View File
@@ -624,6 +624,22 @@ async def test_build_command(vm, fake_qemu_binary):
]
@pytest.mark.asyncio
async def test_build_command_console_none(vm):
"""
A headless QEMU node (console_type none) builds a command line without a
serial device: the console options for "none" used to fall through and
return None, which made node start fail with a TypeError.
"""
vm.console_type = "none"
os.environ["DISPLAY"] = "0:0"
with asyncio_patch("asyncio.create_subprocess_exec", return_value=MagicMock()):
cmd = await vm._build_command()
assert "-serial" not in cmd
assert "-netdev" in cmd
@pytest.mark.asyncio
async def test_build_command_manual_uuid(vm):
"""
+14
View File
@@ -169,6 +169,20 @@ async def test_ubridge_apply_filters(node):
node._ubridge_send.assert_any_call("bridge add_packet_filter VPCS-10 filter0 latency 10")
@pytest.mark.asyncio
async def test_ubridge_apply_filters_rejects_kernel_only(node):
"""
Netem-extension filters have no relay equivalent: applying them on a
relay bridge must fail loudly instead of pushing an unknown filter type
to uBridge.
"""
node._ubridge_send = AsyncioMagicMock()
with pytest.raises(NodeError, match="kernel-datapath"):
await node._ubridge_apply_filters("VPCS-10", {"delay": [100, 10], "rate": ["512kbit"]})
node._ubridge_send.assert_not_called()
@pytest.mark.asyncio
async def test_ubridge_apply_bpf_filters(node):
+277
View File
@@ -0,0 +1,277 @@
#
# 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,
)
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
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()
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
+12 -1
View File
@@ -381,7 +381,18 @@ 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,
"ubridge_tap": None,
"ubridge_iol_tap": None,
"ubridge_bridge_tap": None,
},
}
assert compute.asdict(topology_dump=True) == {
"compute_id": "my_compute_id",
File diff suppressed because it is too large. Load diff
+113
View File
@@ -113,6 +113,66 @@ async def test_add_node_already_connected(project, compute):
await link2.add_node(node1, 0, 4)
@pytest.mark.asyncio
async def test_add_node_already_connected_after_node_update(project, compute):
"""
A node update (any compute node.updated notification) rebuilds its port
objects; the port must still count as used afterwards, or the duplicate
link reaches the compute and fails on the already-bound NIO instead of
being refused here.
"""
project.dump = AsyncioMagicMock()
node1 = Node(project, compute, "node1", node_type="qemu")
node2 = Node(project, compute, "node2", node_type="qemu")
link = Link(project)
link.create = AsyncioMagicMock()
link.node_updated = AsyncioMagicMock()
link._project.emit_notification = MagicMock()
await link.add_node(node1, 0, 0)
await link.add_node(node2, 0, 0)
await node1.parse_node_response({"status": "started"})
link2 = Link(project)
link2.create = AsyncioMagicMock()
with pytest.raises(ControllerError, match="already used"):
await link2.add_node(node1, 0, 0)
@pytest.mark.asyncio
async def test_link_delete_after_node_update_releases_port(project, compute):
"""
Link.delete drops the port's link reference through the port object it
stored at add_node time; a node update since then rebuilt the port list,
so that stored object is stale. The delete must release the *live* port,
or the next link on it is refused with "Port is already used".
"""
project.dump = AsyncioMagicMock()
node1 = Node(project, compute, "node1", node_type="qemu")
node2 = Node(project, compute, "node2", node_type="qemu")
link = Link(project)
link.create = AsyncioMagicMock()
link.node_updated = AsyncioMagicMock()
link._project.emit_notification = MagicMock()
await link.add_node(node1, 0, 0)
await link.add_node(node2, 0, 0)
# a node update (e.g. the status change of a start) rebuilds the ports
await node1.parse_node_response({"status": "started"})
await link.delete()
link2 = Link(project)
link2.create = AsyncioMagicMock()
await link2.add_node(node1, 0, 0) # must not raise "Port is already used"
@pytest.mark.asyncio
async def test_add_node_cloud(project, compute):
@@ -229,6 +289,7 @@ async def test_json(project, compute):
"suspend": False,
"wireshark": False,
"link_type": "ethernet",
"kernel_datapath": False,
"capturing": False,
"capture_file_name": None,
"capture_file_path": None,
@@ -361,6 +422,58 @@ async def test_update_filters(project, compute):
assert link.update.called
@pytest.mark.asyncio
async def test_update_filters_unchanged_is_noop(project, compute):
"""Re-sending an identical filters dict skips the datapath reconcile."""
node1 = Node(project, compute, "node1", node_type="qemu")
node1._ports = [EthernetPort("E0", 0, 0, 4)]
node2 = Node(project, compute, "node2", node_type="qemu")
node2._ports = [EthernetPort("E0", 0, 0, 4)]
link = Link(project)
link.create = AsyncioMagicMock()
link._project.emit_notification = MagicMock()
project.dump = AsyncioMagicMock()
await link.add_node(node1, 0, 4)
await link.add_node(node2, 0, 4)
link.update = AsyncioMagicMock()
await link.update_filters({"packet_loss": [10]})
assert link.update.call_count == 1
await link.update_filters({"packet_loss": [10]})
assert link.update.call_count == 1
@pytest.mark.asyncio
async def test_update_filters_unchanged_window_drop_still_reconciles(project, compute):
"""
window_drop's outage schedule is measured from the apply, so an
unchanged re-send must re-arm it instead of being skipped as a no-op.
"""
node1 = Node(project, compute, "node1", node_type="qemu")
node1._ports = [EthernetPort("E0", 0, 0, 4)]
node2 = Node(project, compute, "node2", node_type="qemu")
node2._ports = [EthernetPort("E0", 0, 0, 4)]
link = Link(project)
link.create = AsyncioMagicMock()
link._project.emit_notification = MagicMock()
project.dump = AsyncioMagicMock()
await link.add_node(node1, 0, 4)
await link.add_node(node2, 0, 4)
# window_drop is kernel-only: this synthetic link has no real wiring,
# so declare the kernel datapath the way _link_data does at runtime
link._link_data = [{"type": "nio_bridge"}]
link.update = AsyncioMagicMock()
await link.update_filters({"window_drop": [3000, 5000, 100]})
assert link.update.call_count == 1
await link.update_filters({"window_drop": [3000, 5000, 100]})
assert link.update.call_count == 2
@pytest.mark.asyncio
async def test_available_filters(project, compute):
+33
View File
@@ -936,6 +936,22 @@ async def test_start(node, compute):
compute.post.assert_called_with(f"/projects/{node.project.id}/vpcs/nodes/{node.id}/start", timeout=240)
@pytest.mark.asyncio
async def test_start_notifies_links(node, compute):
"""
A successful start tells the node's links: the Ethernet-switch fast
path completes a deferred anchor join only then (UDPLink.node_started).
"""
compute.post = AsyncioMagicMock()
link = AsyncioMagicMock()
link.deferred = False
node.add_link(link)
await node.start()
assert link.node_started.called
@pytest.mark.asyncio
async def test_start_iou(compute, project, controller):
@@ -1175,3 +1191,20 @@ async def test_parse_node_response(node):
node.add_link(link)
await node.parse_node_response({"status": "started"})
assert link.node_updated.called
@pytest.mark.asyncio
async def test_port_link_survives_port_rebuild(node):
"""
A node update rebuilds the port objects (every compute node.updated
notification does); the link back-references must survive the rebuild,
or the "Port is already used" guard silently stops matching.
"""
port = node.get_port(0, 0)
link = MagicMock()
port.link = link
await node.parse_node_response({"status": "started"})
rebuilt = node.get_port(0, 0)
assert rebuilt is not port # the rebuild really does replace the objects
assert rebuilt.link is link
+40
View File
@@ -1118,6 +1118,46 @@ async def test_node_name(project):
assert node.name == "R3"
@pytest.mark.asyncio
async def test_node_name_strict(project):
compute = MagicMock()
compute.id = "local"
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
node = await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=True)
assert node.name == "R1"
with pytest.raises(ControllerError):
await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=True)
assert len(project.nodes) == 1
node = await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=False)
assert node.name == "R2"
node = await project.add_node(compute, "T{0}", None, node_type="vpcs", strict_names=True)
assert node.name == "T1"
node = await project.add_node(compute, "T{0}", None, node_type="vpcs", strict_names=True)
assert node.name == "T2"
@pytest.mark.asyncio
async def test_node_name_released_when_creation_fails(project):
compute = MagicMock()
compute.id = "local"
compute.post = AsyncioMagicMock(side_effect=ControllerError("failure"))
with pytest.raises(ControllerError):
await project.add_node(compute, "R1", None, node_type="vpcs")
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
node = await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=True)
assert node.name == "R1"
@pytest.mark.asyncio
async def test_duplicate_node(project):
+1 -1
View File
@@ -565,7 +565,7 @@ async def test_update_ethernet_switch_nio(project):
"markers": {},
"filters": {},
},
timeout=221,
timeout=120,
)
+95
View File
@@ -0,0 +1,95 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
# Live end-to-end tests
Tests under this directory run against a **real** server: real uBridge, real kernel interfaces (privileged), real emulator binaries and real images. They verify what unit tests cannot — that a datapath actually forwards traffic, that kernel bridges actually enslave anchors, that tc impairments actually shape packets. They are marked `e2e` and **excluded from normal test runs** (see `tests/pytest.ini`) — and from CI, which runs the unit suite only. Run them explicitly, on a real machine:
```bash
pytest -m e2e tests/e2e -s # -s shows the progress prints
pytest -m e2e tests/e2e -k dynamips # one scenario
```
Two run modes, chosen per test through `harness.live_server(kernel=...)`:
* **Target an existing server** — set `GNS3_E2E_URL` (e.g. `http://127.0.0.1:3080/v3`, plus `GNS3_E2E_USER`/`GNS3_E2E_PASSWORD`, default admin/admin). The server must already be configured for the datapath under test (kernel links need `enable_kernel_datapath = True`).
* **Isolated instance** (the default) — the harness starts its own server process on a free port with its own config directory (the controller DB and every path follow the config), so nothing on the machine is touched besides the kernel devices the scenarios themselves create. It needs `ubridge`, `dynamips` and an images directory (`GNS3_E2E_IMAGES`, default `~/GNS3/images`) available locally — the IOU scenario takes its L3 image from that directory's `IOU` subfolder, the QEMU scenario its L3 IOSv qcow2 from the `QEMU` subfolder; the Docker scenarios additionally need the Docker daemon the server talks to.
Either way each test creates its own project and deletes it again. Set `GNS3_E2E_KEEP=1` to keep the project (and a started instance) on failure and print where to look at it.
## Host requirements
Beyond a Linux kernel with the datapath features (bridges, veth/TAP devices, tc netem/clsact/cls_bpf, the eBPF stateful classifier — each probed where it matters, and the affected assertions skip on kernels or uBridge builds without them):
* **uBridge** with `cap_net_admin,cap_net_raw,cap_bpf` on `PATH` — the normal server install.
* **`tc` (iproute2)** for the filter scenarios: their qdisc and classifier assertions read it from `PATH`. Without it those assertions cannot run.
* **A Docker daemon** for the Docker scenarios (the harness's own guest driver talks to the same daemon as the server; socket override: `GNS3_E2E_DOCKER_SOCKET`).
* **A C compiler that can link statically** (`cc -static`) for the raw-frame probe of the link-local scenario; without it that probe skips.
* **Emulator images** — see the matrix below.
## Test images
Every scenario picks its image from the images directory (`GNS3_E2E_IMAGES`, default `~/GNS3/images`; a missing directory skips everything image-based). The first preferred name wins when present; otherwise the fallback pattern scans the directory. A missing image **skips** the scenario — never a false failure.
| Scenario | Where | Preferred file(s) | Fallback |
|---|---|---|---|
| Dynamips (c7200) | images dir root | — | any file name starting with `c7200` |
| IOU | `IOU/` | `x86_64_crb_linux-adventerprisek9-ms.iol`, then `l3-adventerprisek9-ms-17.15.01.bin` | any name containing `adventerprisek9` and **not** `l2` |
| QEMU (IOSv) | `QEMU/` | `vios-adventerprisek9-m.spa.159-3.m12.qcow2` | any `vios` `.qcow2` without `l2` |
| IOL runner containers | Docker daemon | — | any locally loaded `iol-xe/iol-xe:*` image (any tag) |
| Docker scenarios | Docker daemon | `alpine:3` pinned at digest `sha256:28bd5fe8b56d…` | none — cached, else pulled, else skip (below) |
Notes on the rows:
* The Dynamips image must be a bootable c7200 IOS that supports the `PA-2FE-TX` module; the idle-PC story is described below.
* The IOU scenario needs an **L3** image — the `l2` exclusion is deliberate (the scenarios exercise the classic 2×4 Ethernet port model; L2 IOU images have their own issues). The isolated instance has the IOU license check disabled, so no `iourc` is needed.
* The IOL runner scenario stages the repository's `gns3server/configs/iol-xe-base.txt` as the base config so Cisco's iol-runner boots past its setup dialog.
* The preferred names are the exact builds the suites were live-verified against (Cisco-licensed artifacts — they cannot ship with the repo, hence the fallbacks). A fallback-selected build is tolerated but is an unverified combination.
The dynamips scenarios give their routers an idle-PC: detected once per image through the controller's `auto_idlepc` (a throwaway project and a real CPU-usage measurement — the GUI's idle-PC finder path) and cached in `~/.cache/gns3-e2e/idlepc.json` keyed by the image checksum. Without one, each c7200 burns a **full CPU core** for the whole scenario — the e2e creates raw nodes, so no template supplies a value. Set `GNS3_E2E_IDLEPC` to skip the detection and use the given value (detection failing is never fatal: the routers simply run without one).
`harness.py` holds the shared pieces — REST client, server lifecycle, the IOS console driver, the Docker guest driver (a tiny Docker Engine API client over the daemon socket, plus the digest-pinned image helper), a WebSocket notification collector (a sync facade over `websockets`' sync client for asserting on live notification streams), host-side kernel inspections (bridge membership, tap admin state, tc qdiscs, the L2-anchor spec §E assertions) — so a new node type adds only its own scenario module.
Current scenarios:
* `test_dynamips_kernel_datapath.py` — two real c7200 routers: kernel link (anchors, per-link bridge, netem delay, suspend, capture, delete/re-create, stop/start) plus a serial link staying on the relay, and a negative control on an isolated relay-configured instance.
* `test_ethernet_switch_kernel_datapath.py` — a real Ethernet switch (kernel bridge) between two c7200 routers: deferred joins on boot, VLAN isolation, absorbed-anchor filters/suspend/capture, VPCS peers staying on the relay, and a relay negative control.
* `test_ethernet_switch_cascade.py` — two Ethernet switches cascaded through the link-owned veth pair (`gs<link_id>0/1`, each end enslaved into its own switch's bridge): a dot1q trunk cascade carrying two VLANs at once across two c7200 routers per switch, the symmetric access/trunk mode flip on the cascade ends, two-sided netem (one end per direction), suspend, link delete/re-create (the pair dies with the link, the replacement mints new names), zero residue.
* `test_iol_docker_kernel_datapath.py` — two real iol-xe containers (Cisco CML iol-runner images): kernel link through the port bridge's swappable TAP leg (anchors at start, netem delay, the TAP-anchor classifier spot check for bpf match-drop and the eBPF every-nth mode, suspend, capture, link delete/re-create swap, node stop/start rewiring) and a relay negative control.
* `test_iou_kernel_datapath.py` — two real IOU routers (a real IOU image from the images directory, the real IOS CLI on the server's telnet console): kernel link through the [IOL fabric ↔ TAP] port bridge (same lifecycle as the iol-xe containers) including the TAP-anchor classifier spot check and a filter surviving a node restart, a serial link staying on the relay (serial bays never anchor), and a relay negative control whose filters ride the IOU-specific `iol_bridge add_packet_filter` engine (delay and frequency_drop measured through real traffic, capture and markers on `iol_bridge start_capture` and the port's `mark` filter).
* `test_qemu_kernel_datapath.py` — two real IOSv routers (a real qcow2 image from the images directory's `QEMU` subfolder, linked clone, the real IOS CLI on the server's telnet console): the anchor TAP is QEMU's own netdev (no port bridge in between) — the full kernel lifecycle plus a relay negative control where the anchor carries as the relay's AF_PACKET endpoint. IOSv boots a real IOS: this is the slowest scenario pair (~6½ min kernel, ~5 min relay).
* `test_docker_kernel_datapath.py` — two real Alpine containers (the plain veth datapath): the per-link kernel bridge enslaves exactly the two veth host ends, real ICMP crosses, the filter matrix runs one type at a time (netem core and extensions, cls_bpf match-drop, the eBPF classifier — each measured through real traffic), plus a relay negative control whose own filters (delay, frequency_drop, bpf) really shape the wire as uBridge userspace bridge filters and whose capture and markers ride the relay engine (`bridge start_capture` / the bridge `mark` filter), and a relay→kernel reopen upgrade (the server's datapath choice is flipped across a restart).
* `test_docker_link_local_frames.py` — two real Alpine containers on a kernel link, with a static raw-Ethernet probe built at run time and docker-cp'd in: guest 1 injects frames per destination MAC while guest 2 counts what arrived. Asserts the per-port `brport/group_fwd_mask` reads 0xfffd (applied by `brctl addif`) and the bridge-level mask stays 0; the live matrix: ordinary multicast, STP/RSTP, LACP, EAPOL and LLDP/DCBX cross, MAC PAUSE does not (a documented kernel hard-drop — PFC labs need a relay link); a container stop/start re-applies the mask (the reconnect story). Needs `cc -static` on the host.
* `test_marker_kernel_datapath.py` — two real Alpine containers on a kernel-only link with traffic-insight markers attached to a veth anchor (`marker add_kernel`): `marker.match` signals collected off the dedicated marker WebSocket with their full identity (filter/tag/link_id/node_id/len and the tx/rx direction set of a ping), the main project channel asserted free of matches, BPF and direction enforcement, host pcap exactness, pause/resume silence counting against a still-firing sibling, tag replay (timeline, display filter, link narrowing, window, the 409 gate) and marker delete taking pcap plus signals with it.
The Docker scenarios run a digest-pinned `alpine:3`, so every developer tests the same bytes: a cached copy in the local Docker daemon costs no network, a cache miss pulls through the server's own image-pull route, and with neither cache nor registry the scenario skips (`GNS3_E2E_DOCKER_IMAGE` overrides the reference). Their guests are addressed the way a GNS3 user does it — the node's persistent `/etc/network/interfaces`, which the container's `init.sh` applies at boot — and driven through the Docker daemon socket the server itself uses.
## Dual-stack coverage
Every scenario runs its traffic checkpoints **dual-stack**: the guests carry a ULA IPv6 address next to their IPv4 one (a `fd00:a:b::h` mirror of the v4 `10.a.b.h`), and each checkpoint pings both families — the v6 twin rides the same impairment window as its v4 counterpart rather than a second filter application, so the impairment set is applied once and asserted twice. The serial (HDLC) legs stay v4-only: they are not Ethernet wires.
The family-sensitive spots get dedicated v6 assertions: the libpcap `bpf` drop filters are asserted to kill their own family and leave the other family alive (`icmp` vs `icmp6`); capture windows carry both families and assert the `86dd` ethertype next to `0800`; the eBPF classifier modes (family-blind by construction) are measured on both families. Marker twins use an echo-specific expression (`icmp6 and (ip6[40] == 128 or ip6[40] == 129)`) because a bare `icmp6` also matches the guests' own DAD/MLD/NS/NA control frames and would break the exact-count asserts — the per-family exact counts (v4 marker sees only v4 frames, v6 marker only v6) double as the cross-family check.
Docker guests get their v6 address from `harness.docker_bring_up_ipv6` after every start rather than from the interfaces file: on bridge networks without IPv6 the Docker daemon leaves the container interface with `disable_ipv6=1`, so busybox ifup's inet6 stanza would fail with EPERM at boot. The helper switches the interface on, silences DAD transmissions and adds the address; since it does not survive a stop/start, it is re-applied at every container start.
One platform quirk is recorded here because it looks like a datapath bug and is not one: while a netem delay is applied, the dynamips-emulated classic IOS **under-reports its own `ping ipv6` RTT** by ~40 % of the delayed round trip — a pcap on the anchor shows the v6 echo request→reply taking the same ~230 ms as v4 while the console reports ~130 ms, and with a small single-owned delay the report swallows most of the signal (reproducible; the order of the two pings does not matter; the v4 ping on the same platform reports honestly). The dynamips-family scenarios (dynamips, ethernet switch, cascade) therefore assert their **v6 delay from the capture's own timestamps** (`harness.pcap_icmp_rtt` — the wire truth, immune to any guest's stopwatch), while the absolute v6 ping values are asserted where the guests read the real clock (the Docker scenarios' busybox guests and the native IOL/IOS-XE guests).
Every kernel scenario also asserts the L2-anchor spec's server-side guarantees (`docs/design/ubridge-l2-anchor-spec.md` §E): anchors and bridges carry no L3 identity (`assert_pure_l2`), joined ports are FORWARDING (`assert_forwarding`), and an idle link with the guests silenced stays silent for 5 s (`assert_idle_silence`) — the guests are the IOS consoles' interfaces shut, the Docker guests' links down, or all four cascade routers silenced, whichever the scenario runs. These skip on a uBridge without `link l2only` (probed once by `harness.l2only_supported()`).
## Host environment notes
* **NetworkManager**: on hosts where NM manages the TAP anchors (many desktop Linux installs), NM can release a freshly (re-)enslaved anchor from its bridge ~60 ms after the join — silently, leaving the API green and the link dead until the next NIO update. The harness takes the anchors out of NM's reach where scenarios re-enslave them (`harness.unmanage_from_networkmanager` — a no-op where `nmcli` is absent), and the permanent host-side fix (an `unmanaged-devices` declaration) plus the full root-cause record live in `docs/bugs/networkmanager-tap-release.md`. A host with NM but without `nmcli` can still see the rare spontaneous release on the first runs.
* **Timing and load**: the traffic assertions of the IOS-based scenarios are three-packet averages over real emulated routers; the RTT bounds are deliberately generous (`harness.FAST_RTT_MS`) but a heavily loaded host can still push a sample over — run the suite on a machine with a few cores to spare. Boot waits (`_boot` timeouts) assume a responsive host too.
* **CI**: these tests do not run in CI (the testing workflow runs the unit suite, with the `e2e` marker deselected). They are a developer/validator tool for real machines.
## Environment variables
* `GNS3_E2E_URL`, `GNS3_E2E_USER`, `GNS3_E2E_PASSWORD` — target an existing server instead of starting an isolated instance (default user/password: admin/admin).
* `GNS3_E2E_KEEP=1` — keep the project (and an isolated instance) on failure and print where to look at it.
* `GNS3_E2E_IMAGES` — the images directory (default `~/GNS3/images`).
* `GNS3_E2E_DOCKER_IMAGE` — override the pinned Docker image entirely (the operator then owns its content).
* `GNS3_E2E_DOCKER_SOCKET` — the Docker daemon socket the harness's guest driver talks to (default `/var/run/docker.sock` — the same daemon the server uses).
* `GNS3_E2E_IDLEPC` — skip idle-PC detection for the Dynamips scenarios and use the given value.
* `GNS3_E2E_DEBUG=1` — run the isolated instance with debug logging (the uBridge command stream lands in its `server.log`).
View File
Whitespace-only changes.
+1438
View File
File diff suppressed because it is too large. Load diff
+820
View File
@@ -0,0 +1,820 @@
#
# 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/>.
"""
Live end-to-end tests for the standard Docker node's kernel datapath (pytest
marker ``e2e``): two real Alpine containers, addressed from the host the way
a GNS3 user does (the node's persistent ``/etc/network/interfaces``, applied
by the container's init.sh) and driven through the Docker daemon socket the
server itself uses. The scenarios cover:
* ``test_docker_kernel_fast_path`` — the veth datapath itself: the link
reports ``kernel_datapath``, the per-link kernel bridge enslaves exactly
the two veth host ends, real ICMP crosses kernel-only, the L2-anchor spec
§E assertions hold (no L3 identity, FORWARDING ports, idle silence), a
``delay 100`` filter lands as netem on both host ends, suspend kills the
traffic at the carrier, capture writes a real pcap, link delete/re-create
and container stop/start leave the wiring consistent, and deleting the
project leaves no veth or bridge behind.
* ``test_docker_kernel_filter_matrix`` — one filter type at a time on a live
kernel link, each asserted by its effect on real traffic (RTT, loss,
per-seq outage evidence) and its tc state, from netem core and the netem
extensions to the cls_bpf and eBPF classifiers; the kernel-only types are
offered by ``available_filters`` and everything restores cleanly.
* ``test_docker_relay_control`` — the negative control on an isolated
relay-configured instance: no per-link kernel bridge, the wire is the
uBridge relay, the kernel-only filter types are hidden — and the relay's
own filter set (delay, frequency_drop, bpf) really shapes the wire, as
uBridge userspace filters on the filter node's bridge
(``bridge add_packet_filter``); its capture (``bridge start_capture``)
and its markers (``bridge add_packet_filter … mark``) ride the same
relay engine, not the kernel datapath's AF_PACKET modules.
* ``test_docker_relay_to_kernel_reopen_upgrade`` — the server's datapath
choice is flipped (relay → kernel) across a restart and the project is
reopened: the link is rebuilt on the kernel datapath and forwards.
The image is pinned by digest (``harness.ensure_docker_image``): a cached
copy costs no network, a miss pulls through the server's own pull route,
and no cache + no registry skips the scenario.
Every traffic assertion runs dual-stack: the guests carry a ULA IPv6
address next to their IPv4 one and each checkpoint pings both families —
including the filter matrix (netem and the classifiers are family-blind;
the libpcap bpf drops are deliberately not) and the relay markers (a v6
twin marker with an echo-specific expression, since a bare ``icmp6``
would also match the guests' own DAD/MLD control frames).
"""
import os
import re
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# every standard Docker adapter has a single port
ETH = (0, 0)
R1_IP = "10.1.1.1"
R2_IP = "10.1.1.2"
# the dual-stack half: a ULA mirroring the v4 addressing (10.1.1.x -> fd00:1:1::x)
R1_IP6 = "fd00:1:1::1"
R2_IP6 = "fd00:1:1::2"
# exact ICMPv6 echo match for markers: a bare "icmp6" would also match the
# guests' own DAD/MLD/NS/NA control frames and break the exact-count asserts
_ICMP6_ECHO_BPF = "icmp6 and (ip6[40] == 128 or ip6[40] == 129)"
# busybox ping frame lengths (56 data bytes): 14 Ethernet + 20/40 IP + 8 ICMP + 56
PING_FRAME_LEN = 98
PING_FRAME_LEN6 = 118
def _resolve_container(daemon, node):
"""Fill in the node's container id from the daemon (the controller's
node payload does not carry the compute-side id), by the deterministic
name the server gives the container."""
container_id = daemon.container_id(f"GNS3.{node['name']}.{node['project_id']}")
assert container_id, f"container for {node['name']} not found on the Docker daemon"
node["container_id"] = container_id
return node
def _create_topology(server, pid, image, name):
"""Two containers with dual-stack addressed eth0s. The harness reaches
their guests through the Docker daemon socket — the same socket the
server uses."""
compute = server.compute
daemon = harness.DockerDaemon()
nodes = []
for suffix, address in (("1", R1_IP), ("2", R2_IP)):
node = compute.create_docker_node(pid, f"{name}-{suffix}", image)
harness.configure_docker_interfaces(node, address)
nodes.append(_resolve_container(daemon, node))
return nodes[0], nodes[1], daemon
def _start_addresses(compute, pid, daemon, nodes, addresses):
"""Start the containers and wait for init.sh to have applied their v4
addresses (busybox ifup runs a moment after container start); the v6
addresses are brought up by the harness after each start (see
``harness.docker_bring_up_ipv6`` — an interface-side disable_ipv6 the
Docker daemon sets makes the interfaces-file route impossible)."""
for node in nodes:
compute.call("POST", f"/projects/{pid}/nodes/{node['node_id']}/start")
for node, (address, address6) in zip(nodes, addresses, strict=False):
harness.docker_wait_address(daemon, node["container_id"], address)
harness.docker_bring_up_ipv6(daemon, node["container_id"], address6)
harness.docker_wait_address(daemon, node["container_id"], address6)
def _put_filters(compute, pid, lid, filters):
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": filters})
def _clear_filters(compute, pid, lid, *anchors):
"""Clear the link's filters and assert the teardown reached the kernel:
no netem qdisc and no clsact left on the anchors."""
_put_filters(compute, pid, lid, {})
for anchor in anchors:
assert "netem" not in harness.qdiscs(anchor), harness.qdiscs(anchor)
assert "clsact" not in harness.qdiscs(anchor), harness.qdiscs(anchor)
def _filter_types(compute, pid, lid):
return {entry["type"] for entry in compute.call("GET", f"/projects/{pid}/links/{lid}/available_filters")}
def _matches(marker_ws, marker_name):
return [e for e in marker_ws.events("marker.match") if e["filter"] == marker_name]
def _silence_guest(daemon, container_id):
"""Silence the guest for the idle-silence measurement — the docker
analogue of shutting the IOS interfaces in the dynamips scenario. The
link goes down first (a guest's own chatter is not just IPv6: Linux
re-sends its boot-time IGMP membership report seconds later), then its
IPv6 stack is switched off so the re-up does not re-fire DAD/MLD. The
IPv4 address stays configured and answers once the link is back."""
code, output = daemon.exec(
container_id,
["sh", "-c", "ip link set eth0 down && echo 1 > /proc/sys/net/ipv6/conf/eth0/disable_ipv6"],
)
assert code == 0, output
def _unsilence_guest(daemon, container_id, address6):
"""Reverse of ``_silence_guest``. disable_ipv6=1 removed the v6
addresses and busybox ifup does not re-run, so the guest's global
address must be brought up again by hand (the v4 address stayed
configured throughout)."""
code, output = daemon.exec(container_id, ["ip", "link", "set", "eth0", "up"])
assert code == 0, output
harness.docker_bring_up_ipv6(daemon, container_id, address6)
def test_docker_kernel_fast_path():
server = harness.live_server(kernel=True)
compute = server.compute
image = harness.ensure_docker_image(server)
project = compute.create_project("docker-e2e")
pid = project["project_id"]
a1 = a2 = bridge = None
try:
n1, n2, daemon = _create_topology(server, pid, image, "E2E")
n1_id, n2_id = n1["node_id"], n2["node_id"]
c1, c2 = n1["container_id"], n2["container_id"]
a1 = harness.docker_anchor_name(n1_id, *ETH)
a2 = harness.docker_anchor_name(n2_id, *ETH)
# The link is created while both containers are stopped: the kernel
# NIO is bound with no anchors in existence yet and attached by
# container start (the deferred-wiring path).
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert not harness.tap_exists(a1), "the veth pair is born at container start"
print(".. starting containers")
_start_addresses(compute, pid, daemon, (n1, n2), ((R1_IP, R1_IP6), (R2_IP, R2_IP6)))
# Kernel objects: the veth host ends exist and the per-link bridge
# enslaves exactly the two of them.
assert harness.tap_exists(a1) and harness.tap_exists(a2), (a1, a2)
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
# L2-anchor spec §E.1 (anchors and bridge carry no L3 identity;
# skipped on a uBridge without `link l2only`) and the forwarding
# guard — a DOWN bridge silently forwards nothing.
if harness.l2only_supported():
harness.assert_pure_l2(a1)
harness.assert_pure_l2(a2)
harness.assert_pure_l2(bridge)
else:
print(".. uBridge without link l2only: skipping the §E.1 assertions")
harness.assert_forwarding(a1)
harness.assert_forwarding(a2)
print(".. pinging between the containers over the kernel link")
baseline = harness.docker_wait_ping(daemon, c1, R2_IP)
assert baseline["loss"] == 0, baseline["raw"]
assert baseline["avg"] is not None and baseline["avg"] < 50, baseline
baseline6 = harness.docker_wait_ping(daemon, c1, R2_IP6)
assert baseline6["loss"] == 0, baseline6["raw"]
assert baseline6["avg"] is not None and baseline6["avg"] < 50, baseline6
# §E.2: with the guests silenced the segment is silent for 5 s —
# the host anchors do not generate the MLD/DAD flood an unhardened
# device produces (baseline: 6 frames / 2 s).
if harness.l2only_supported():
_silence_guest(daemon, c1)
_silence_guest(daemon, c2)
harness.assert_idle_silence(a1, a2, bridge)
_unsilence_guest(daemon, c1, R1_IP6)
_unsilence_guest(daemon, c2, R2_IP6)
assert harness.docker_wait_ping(daemon, c1, R2_IP, attempts=5)["loss"] == 0
assert harness.docker_wait_ping(daemon, c1, R2_IP6, attempts=5)["loss"] == 0
# delay 100: netem on both host ends (each impairs one direction),
# one-way ~100 ms => RTT grows by ~200 ms — netem is family-blind,
# both families ride the same qdisc
_put_filters(compute, pid, lid, {"delay": [100]})
assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
delayed = harness.docker_wait_ping(daemon, c1, R2_IP)
assert delayed["loss"] == 0 and delayed["avg"] >= 150, (baseline, delayed)
delayed6 = harness.docker_wait_ping(daemon, c1, R2_IP6)
assert delayed6["loss"] == 0 and delayed6["avg"] >= 150, (baseline6, delayed6)
_put_filters(compute, pid, lid, {})
assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1)
fast = harness.docker_wait_ping(daemon, c1, R2_IP)
assert fast["loss"] == 0 and fast["avg"] < 50, fast
fast6 = harness.docker_wait_ping(daemon, c1, R2_IP6)
assert fast6["loss"] == 0 and fast6["avg"] < 50, fast6
# suspend: the host end admin-downs (carrier loss into the
# container), the link is dead; resume restores
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True})
assert not harness.tap_up(a1)
dead = harness.docker_ping(daemon, c1, R2_IP, count=3, timeout=1)
assert dead["loss"] == 100, dead["raw"]
dead6 = harness.docker_ping(daemon, c1, R2_IP6, count=3, timeout=1)
assert dead6["loss"] == 100, dead6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False})
assert harness.tap_up(a1)
assert harness.docker_wait_ping(daemon, c1, R2_IP)["loss"] == 0
assert harness.docker_wait_ping(daemon, c1, R2_IP6)["loss"] == 0
# capture: AF_PACKET on the anchor writes a real pcap — both
# families inside the window, both ethertypes on the wire
capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"})
harness.docker_ping(daemon, c1, R2_IP)
harness.docker_ping(daemon, c1, R2_IP6)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop")
path = capture["capture_file_path"]
count, ethertypes = harness.pcap_records(path)
assert count >= 8, f"{path}: {count} records"
assert "0800" in ethertypes, (path, ethertypes)
assert "86dd" in ethertypes, (path, ethertypes)
# link delete detaches both ends (the veths survive — they are the
# adapters); re-creating enslaves them again and the traffic returns
compute.call("DELETE", f"/projects/{pid}/links/{lid}")
assert harness.bridge_members(bridge) is None
assert harness.tap_exists(a1) and harness.tap_exists(a2)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert harness.docker_wait_ping(daemon, c1, R2_IP, attempts=5)["loss"] == 0
assert harness.docker_wait_ping(daemon, c1, R2_IP6, attempts=5)["loss"] == 0
# container stop: the veth host ends are deleted explicitly (a veth
# outlives its container's netns) and the bridge goes with them; a
# restart rebuilds the whole wiring from the NIO
print(".. stopping and restarting container 1")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop")
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
assert harness.bridge_members(bridge) in (None, [a2]), harness.bridge_members(bridge)
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
harness.docker_wait_address(daemon, c1, R1_IP)
harness.docker_bring_up_ipv6(daemon, c1, R1_IP6)
harness.docker_wait_address(daemon, c1, R1_IP6)
assert harness.tap_exists(a1)
assert harness.wait_until(lambda: harness.bridge_members(bridge) == sorted([a1, a2]), timeout=15)
assert harness.docker_wait_ping(daemon, c1, R2_IP, attempts=5)["loss"] == 0
assert harness.docker_wait_ping(daemon, c1, R2_IP6, attempts=5)["loss"] == 0
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# deleting the project closed the containers: veths and bridges go
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), (
a1,
a2,
)
assert harness.wait_until(lambda: harness.bridge_members(bridge) is None, timeout=10), bridge
def test_docker_kernel_filter_matrix():
"""One filter type at a time on a live kernel link, each asserted by its
effect on real traffic and its tc state. Types the uBridge on this
compute cannot serve (bpf without cbpf, the eBPF modes without ebpf) are
skipped rather than failed — the capability chain is ``available_filters``'
contract, and a compute reporting nothing keeps the full list."""
server = harness.live_server(kernel=True)
compute = server.compute
caps = (compute.capabilities() or {}).get("ubridge_tc") or {}
image = harness.ensure_docker_image(server)
project = compute.create_project("docker-filter-e2e")
pid = project["project_id"]
try:
n1, n2, daemon = _create_topology(server, pid, image, "E2E")
n1_id, n2_id = n1["node_id"], n2["node_id"]
c1 = n1["container_id"]
a1 = harness.docker_anchor_name(n1_id, *ETH)
a2 = harness.docker_anchor_name(n2_id, *ETH)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
assert link["kernel_datapath"] is True, link
_start_addresses(compute, pid, daemon, (n1, n2), ((R1_IP, R1_IP6), (R2_IP, R2_IP6)))
baseline = harness.docker_wait_ping(daemon, c1, R2_IP)
assert baseline["loss"] == 0, baseline["raw"]
baseline6 = harness.docker_wait_ping(daemon, c1, R2_IP6)
assert baseline6["loss"] == 0, baseline6["raw"]
# The kernel link offers the kernel-only types the compute's uBridge
# reports it can run (all of them on this uBridge).
offered = _filter_types(compute, pid, lid)
assert {"delay", "packet_loss", "corrupt", "duplicate", "bpf"} <= offered, offered
if not {"rate", "reorder", "gemodel", "seed", "limit"} & offered:
pytest.skip(f"this compute's uBridge reports no netem extensions: {sorted(offered)}")
# -- netem core ----------------------------------------------------
print(".. delay 100")
_put_filters(compute, pid, lid, {"delay": [100]})
assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
delayed = harness.docker_ping(daemon, c1, R2_IP)
print(f".. {delayed['avg']} ms RTT")
assert delayed["loss"] == 0 and delayed["avg"] >= 150, (baseline, delayed)
delayed6 = harness.docker_ping(daemon, c1, R2_IP6)
print(f".. {delayed6['avg']} ms RTT (v6)")
assert delayed6["loss"] == 0 and delayed6["avg"] >= 150, (baseline6, delayed6)
print(".. delay 50 jitter 20 distribution normal")
_put_filters(compute, pid, lid, {"delay": [50, 20, "normal"]})
# the jitter parameter lands verbatim...; the sampled RTT mean is a
# random variable (per-direction ~N(50, 20)), so only the elevation
# is asserted, with margin — 10 packets put the sample mean's sigma
# around 9 ms
assert re.search(r"delay 50ms\s+20ms", harness.qdiscs(a1)), harness.qdiscs(a1)
jittered = harness.docker_ping(daemon, c1, R2_IP, count=10)
print(f".. {jittered['avg']} ms RTT")
assert jittered["loss"] == 0 and jittered["avg"] >= 65, jittered
jittered6 = harness.docker_ping(daemon, c1, R2_IP6, count=10)
print(f".. {jittered6['avg']} ms RTT (v6)")
assert jittered6["loss"] == 0 and jittered6["avg"] >= 65, jittered6
_clear_filters(compute, pid, lid, a1, a2)
print(".. packet_loss 30")
_put_filters(compute, pid, lid, {"packet_loss": [30]})
lossy = harness.docker_ping(daemon, c1, R2_IP, count=20)
print(f".. {lossy['loss']} % round-trip loss")
# round trip = 1 - 0.7^2 ~ 51 %; the stochastic draw must land
# somewhere between "clearly lossy" and "not a black hole"
assert 20 <= lossy["loss"] <= 80, lossy["raw"]
lossy6 = harness.docker_ping(daemon, c1, R2_IP6, count=20)
print(f".. {lossy6['loss']} % round-trip loss (v6)")
assert 20 <= lossy6["loss"] <= 80, lossy6["raw"]
_clear_filters(compute, pid, lid, a1, a2)
print(".. corrupt 30")
_put_filters(compute, pid, lid, {"corrupt": [30]})
assert "corrupt 30%" in harness.qdiscs(a1), harness.qdiscs(a1)
# a corrupted frame fails its checksum and is dropped: the same
# round-trip arithmetic as packet_loss (1 - 0.7^2)
corrupt = harness.docker_ping(daemon, c1, R2_IP, count=20)
print(f".. {corrupt['loss']} % round-trip loss")
assert 20 <= corrupt["loss"] <= 80, corrupt["raw"]
corrupt6 = harness.docker_ping(daemon, c1, R2_IP6, count=20)
print(f".. {corrupt6['loss']} % round-trip loss (v6)")
assert 20 <= corrupt6["loss"] <= 80, corrupt6["raw"]
_clear_filters(compute, pid, lid, a1, a2)
print(".. duplicate 50")
_put_filters(compute, pid, lid, {"duplicate": [50]})
assert "dup" in harness.qdiscs(a1), harness.qdiscs(a1)
# duplicated frames are absorbed by the stack: no loss, traffic flows
dups = harness.docker_ping(daemon, c1, R2_IP, count=5)
assert dups["loss"] == 0, dups["raw"]
dups6 = harness.docker_ping(daemon, c1, R2_IP6, count=5)
assert dups6["loss"] == 0, dups6["raw"]
_clear_filters(compute, pid, lid, a1, a2)
# -- netem extensions (kernel-only) --------------------------------
print(".. delay 50 + reorder 25 correl 50 gap 5")
_put_filters(compute, pid, lid, {"delay": [50], "reorder": [25, 50, 5]})
assert "reorder" in harness.qdiscs(a1), harness.qdiscs(a1)
reordered = harness.docker_ping(daemon, c1, R2_IP, count=10)
assert reordered["loss"] <= 60, reordered["raw"]
reordered6 = harness.docker_ping(daemon, c1, R2_IP6, count=10)
assert reordered6["loss"] <= 60, reordered6["raw"]
_clear_filters(compute, pid, lid, a1, a2)
print(".. delay 50 + seed 42 + limit 5000")
_put_filters(compute, pid, lid, {"delay": [50], "seed": [42], "limit": [5000]})
assert "limit 5000" in harness.qdiscs(a1), harness.qdiscs(a1)
seeded = harness.docker_ping(daemon, c1, R2_IP)
assert seeded["loss"] == 0 and seeded["avg"] >= 80, seeded
seeded6 = harness.docker_ping(daemon, c1, R2_IP6)
assert seeded6["loss"] == 0 and seeded6["avg"] >= 80, seeded6
_clear_filters(compute, pid, lid, a1, a2)
print(".. gemodel 100/0/30 (steady-state bad, 30 % loss)")
_put_filters(compute, pid, lid, {"gemodel": [100, 0, 30]})
assert "gemodel" in harness.qdiscs(a1), harness.qdiscs(a1)
gemodel = harness.docker_ping(daemon, c1, R2_IP, count=20)
print(f".. {gemodel['loss']} % round-trip loss")
assert 20 <= gemodel["loss"] <= 90, gemodel["raw"]
gemodel6 = harness.docker_ping(daemon, c1, R2_IP6, count=20)
print(f".. {gemodel6['loss']} % round-trip loss (v6)")
assert 20 <= gemodel6["loss"] <= 90, gemodel6["raw"]
_clear_filters(compute, pid, lid, a1, a2)
print(".. rate 512kbit (1400-byte pings)")
_put_filters(compute, pid, lid, {"rate": ["512kbit"]})
assert "rate" in harness.qdiscs(a1).lower(), harness.qdiscs(a1)
# 1400+28 bytes at 512 kbit ≈ 22 ms serialization per direction
rated = harness.docker_ping(daemon, c1, R2_IP, size=1400)
print(f".. {rated['avg']} ms RTT")
assert rated["loss"] == 0 and rated["avg"] >= 30, (baseline, rated)
rated6 = harness.docker_ping(daemon, c1, R2_IP6, size=1400)
print(f".. {rated6['avg']} ms RTT (v6)")
assert rated6["loss"] == 0 and rated6["avg"] >= 30, (baseline6, rated6)
_clear_filters(compute, pid, lid, a1, a2)
# -- cls_bpf match-drop --------------------------------------------
if caps.get("cbpf"):
print(".. bpf 'icmp' drops v4 ICMP only")
_put_filters(compute, pid, lid, {"bpf": ["icmp"]})
assert "clsact" in harness.qdiscs(a1), harness.qdiscs(a1)
dropped = harness.docker_ping(daemon, c1, R2_IP, count=4)
assert dropped["loss"] == 100, dropped["raw"]
# the libpcap expression is family-specific: v6 ICMP sails through
v6_alive = harness.docker_ping(daemon, c1, R2_IP6, count=4)
assert v6_alive["loss"] == 0, v6_alive["raw"]
print(".. bpf 'icmp6' drops v6 ICMP only")
_put_filters(compute, pid, lid, {"bpf": ["icmp6"]})
v4_alive = harness.docker_ping(daemon, c1, R2_IP, count=4)
assert v4_alive["loss"] == 0, v4_alive["raw"]
dropped6 = harness.docker_ping(daemon, c1, R2_IP6, count=4)
assert dropped6["loss"] == 100, dropped6["raw"]
print(".. bpf 'greater 150' discriminates by frame size")
_put_filters(compute, pid, lid, {"bpf": ["greater 150"]})
small = harness.docker_ping(daemon, c1, R2_IP, count=4)
assert small["loss"] == 0, small["raw"]
big = harness.docker_ping(daemon, c1, R2_IP, count=4, size=300)
assert big["loss"] == 100, big["raw"]
# the v6 echo frame is 118 bytes — still under the threshold;
# size=300 pushes it to 362, over it
small6 = harness.docker_ping(daemon, c1, R2_IP6, count=4)
assert small6["loss"] == 0, small6["raw"]
big6 = harness.docker_ping(daemon, c1, R2_IP6, count=4, size=300)
assert big6["loss"] == 100, big6["raw"]
_clear_filters(compute, pid, lid, a1, a2)
else:
print(".. uBridge reports no cbpf: skipping the bpf filter checks")
# -- eBPF stateful classifier --------------------------------------
modes = caps.get("ebpf_modes") or []
if "nth" in modes:
print(".. frequency_drop 3 (every 3rd packet per direction)")
_put_filters(compute, pid, lid, {"frequency_drop": [3]})
nth = harness.docker_ping(daemon, c1, R2_IP, count=20)
print(f".. {nth['loss']} % round-trip loss")
# kernel counts per direction: 1 - (2/3)^2 = 55.6 % round trip
assert 30 <= nth["loss"] <= 80, nth["raw"]
# the classifier draws per packet, not per header — v6 rides
# the same distribution
nth6 = harness.docker_ping(daemon, c1, R2_IP6, count=20)
print(f".. {nth6['loss']} % round-trip loss (v6)")
assert 30 <= nth6["loss"] <= 80, nth6["raw"]
_put_filters(compute, pid, lid, {"frequency_drop": [-1]})
all_dropped = harness.docker_ping(daemon, c1, R2_IP, count=4)
assert all_dropped["loss"] == 100, all_dropped["raw"]
all_dropped6 = harness.docker_ping(daemon, c1, R2_IP6, count=4)
assert all_dropped6["loss"] == 100, all_dropped6["raw"]
_clear_filters(compute, pid, lid, a1, a2)
if "quota" in modes:
print(".. quota 3000 bytes hard cutoff")
_put_filters(compute, pid, lid, {"quota": [3000, 100]})
# 1400-byte pings: the per-direction byte caps run out after
# ~2 packets, then nothing passes until the quota is reset
cut = harness.docker_ping(daemon, c1, R2_IP, count=5, size=1400)
assert cut["loss"] >= 40, cut["raw"]
after = harness.docker_ping(daemon, c1, R2_IP, count=4)
assert after["loss"] == 100, after["raw"]
cut6 = harness.docker_ping(daemon, c1, R2_IP6, count=5, size=1400)
assert cut6["loss"] >= 40, cut6["raw"]
after6 = harness.docker_ping(daemon, c1, R2_IP6, count=4)
assert after6["loss"] == 100, after6["raw"]
_clear_filters(compute, pid, lid, a1, a2)
assert harness.docker_ping(daemon, c1, R2_IP, count=4)["loss"] == 0
assert harness.docker_ping(daemon, c1, R2_IP6, count=4)["loss"] == 0
if "window" in modes:
print(".. window_drop [1000, 2000, 100] — one outage with passes on both sides")
_put_filters(compute, pid, lid, {"window_drop": [1000, 2000, 100]})
# 25 pings at 5/s: seqs before t=1 s and after t=3 s answer, the
# middle is gone — per-sequence evidence of a bounded outage
code, output = daemon.exec(c1, ["ping", "-c", "25", "-i", "0.2", "-W", "1", R2_IP], timeout=30)
replied = {int(m.group(1)) for m in re.finditer(r"seq=(\d+)", output)}
missing = set(range(25)) - replied
assert 0 in replied and 24 in replied, (sorted(replied), output)
assert missing and max(missing) - min(missing) >= 5, (sorted(replied), output)
# the outage window is one-shot from the moment the filter is
# applied (documented semantics), so the v6 stream needs the
# schedule re-armed — re-sending the same window is a no-op
# except that it restarts the schedule
_put_filters(compute, pid, lid, {"window_drop": [1000, 2000, 100]})
code, output = daemon.exec(c1, ["ping", "-c", "25", "-i", "0.2", "-W", "1", R2_IP6], timeout=30)
replied6 = {int(m.group(1)) for m in re.finditer(r"seq=(\d+)", output)}
missing6 = set(range(25)) - replied6
assert 0 in replied6 and 24 in replied6, (sorted(replied6), output)
assert missing6 and max(missing6) - min(missing6) >= 5, (sorted(replied6), output)
_clear_filters(compute, pid, lid, a1, a2)
assert harness.docker_ping(daemon, c1, R2_IP, count=4)["loss"] == 0
assert harness.docker_ping(daemon, c1, R2_IP6, count=4)["loss"] == 0
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), (
a1,
a2,
)
def test_docker_relay_control():
"""Negative control: the same topology on an isolated relay-configured
instance wires nothing into the kernel — no per-link bridge, no
enslavement — and still pings over the uBridge UDP relay, its filters,
capture and markers all riding the relay engine."""
server = harness.live_server(kernel=False)
compute = server.compute
image = harness.ensure_docker_image(server)
project = compute.create_project("docker-relay-e2e")
pid = project["project_id"]
a1 = None
try:
n1, n2, daemon = _create_topology(server, pid, image, "E2E")
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.docker_anchor_name(n1_id, *ETH)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
assert link["kernel_datapath"] is False, link
_start_addresses(compute, pid, daemon, (n1, n2), ((R1_IP, R1_IP6), (R2_IP, R2_IP6)))
# No kernel wiring: no per-link bridge, the veth host end is just a
# relay endpoint (up, pure L2 — the hardening is a creation-time
# property, not a datapath one)
assert harness.bridge_members(harness.link_bridge_name(link["link_id"])) is None
if harness.tap_exists(a1):
assert harness.tap_up(a1), a1
if harness.l2only_supported():
harness.assert_pure_l2(a1)
relay_ping = harness.docker_wait_ping(daemon, n1["container_id"], R2_IP)
assert relay_ping["loss"] == 0, relay_ping["raw"]
relay_ping6 = harness.docker_wait_ping(daemon, n1["container_id"], R2_IP6)
assert relay_ping6["loss"] == 0, relay_ping6["raw"]
# available_filters hides the kernel-only types on the relay
offered = _filter_types(compute, pid, link["link_id"])
assert "delay" in offered and "frequency_drop" in offered, offered
assert not (offered & {"rate", "reorder", "gemodel", "duplicate", "seed", "limit", "quota", "window_drop"}), (
offered
)
# The relay's filters are real uBridge userspace filters walked by
# the bridge's two listener threads: both directions of the wire
# cross them. delay 100 creates one delay line per direction, so the
# RTT grows by ~200 ms.
print(".. relay filters: delay 100 ms (one delay line per direction, RTT +~200 ms)")
_put_filters(compute, pid, link["link_id"], {"delay": [100]})
harness.docker_wait_ping(daemon, n1["container_id"], R2_IP)
delayed = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=5)
assert delayed["loss"] == 0, delayed["raw"]
assert delayed["avg"] >= 150, (relay_ping, delayed)
delayed6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=5)
assert delayed6["loss"] == 0, delayed6["raw"]
assert delayed6["avg"] >= 150, (relay_ping6, delayed6)
_put_filters(compute, pid, link["link_id"], {})
fast = harness.docker_wait_ping(daemon, n1["container_id"], R2_IP)
assert fast["loss"] == 0 and fast["avg"] < 50, (relay_ping, fast)
fast6 = harness.docker_wait_ping(daemon, n1["container_id"], R2_IP6)
assert fast6["loss"] == 0 and fast6["avg"] < 50, (relay_ping6, fast6)
# frequency_drop counts every packet crossing the bridge, both
# directions sharing one counter, so on an alternating ping stream
# the same direction dies on every crossing (probed against uBridge
# directly: replies drop, round trips die wholesale). -1 is the
# drop-everything encoding the suspend emulation uses.
print(".. relay filters: frequency_drop 2 (shared counter, one direction dies)")
_put_filters(compute, pid, link["link_id"], {"frequency_drop": [2]})
dropped = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=10)
print(f".. {dropped['loss']} % round-trip loss")
assert dropped["loss"] >= 50, dropped["raw"]
dropped6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=10)
print(f".. {dropped6['loss']} % round-trip loss (v6)")
assert dropped6["loss"] >= 50, dropped6["raw"]
_put_filters(compute, pid, link["link_id"], {"frequency_drop": [-1]})
blackholed = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4)
assert blackholed["loss"] == 100, blackholed["raw"]
blackholed6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=4)
assert blackholed6["loss"] == 100, blackholed6["raw"]
# bpf on the relay is uBridge's libpcap userspace match-drop — a
# different engine from the kernel datapath's cls_bpf, same
# family-specific expression semantics
print(".. relay filters: bpf 'icmp' drops v4 ICMP only")
_put_filters(compute, pid, link["link_id"], {"bpf": ["icmp"]})
icmp_dropped = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4)
assert icmp_dropped["loss"] == 100, icmp_dropped["raw"]
v6_alive = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=4)
assert v6_alive["loss"] == 0, v6_alive["raw"]
print(".. relay filters: bpf 'icmp6' drops v6 ICMP only")
_put_filters(compute, pid, link["link_id"], {"bpf": ["icmp6"]})
v4_alive = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4)
assert v4_alive["loss"] == 0, v4_alive["raw"]
icmp6_dropped = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=4)
assert icmp6_dropped["loss"] == 100, icmp6_dropped["raw"]
print(".. relay filters: bpf 'greater 150' discriminates by frame size")
_put_filters(compute, pid, link["link_id"], {"bpf": ["greater 150"]})
small = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4)
assert small["loss"] == 0, small["raw"]
big = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4, size=300)
assert big["loss"] == 100, big["raw"]
small6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=4)
assert small6["loss"] == 0, small6["raw"]
big6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=4, size=300)
assert big6["loss"] == 100, big6["raw"]
_put_filters(compute, pid, link["link_id"], {})
assert harness.docker_ping(daemon, n1["container_id"], R2_IP, count=5)["loss"] == 0
assert harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=5)["loss"] == 0
# capture on the relay: the same REST call as the kernel datapath's,
# hosted by the relay node's uBridge bridge (bridge start_capture)
# instead of AF_PACKET on an anchor
print(".. relay capture: bridge start_capture writes the ICMP exchange")
capture = compute.call(
"POST", f"/projects/{pid}/links/{link['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}
)
harness.docker_ping(daemon, n1["container_id"], R2_IP)
harness.docker_ping(daemon, n1["container_id"], R2_IP6)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/stop")
path = capture["capture_file_path"]
count, ethertypes = harness.pcap_records(path)
assert count >= 8, f"{path}: {count} records"
assert "0800" in ethertypes, (path, ethertypes)
assert "86dd" in ethertypes, (path, ethertypes)
# markers on the relay are uBridge's `mark` packet filter on the
# relay bridge (bridge add_packet_filter ... mark), signalled over
# the same dedicated marker WS — the relay engine's observability
# path (the kernel datapath's marker add_kernel is the other one)
print(".. relay markers: 5 v4 + 5 v6 ICMP echoes through mark filters on the relay bridge")
marker_ws = harness.WebSocketCollector(server, f"/projects/{pid}/notifications/markers/ws")
try:
m = compute.call(
"POST",
f"/projects/{pid}/links/{link['link_id']}/markers",
{"name": "m-icmp", "bpf": "icmp", "tag": 4242, "capture_node_id": n1_id},
)
assert m["capture_node_id"] == n1_id and m["enabled"] is True, m
m6 = compute.call(
"POST",
f"/projects/{pid}/links/{link['link_id']}/markers",
{"name": "m-icmp6", "bpf": _ICMP6_ECHO_BPF, "tag": 4243, "capture_node_id": n1_id},
)
assert m6["capture_node_id"] == n1_id and m6["enabled"] is True, m6
result = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=5)
assert result["loss"] == 0, result["raw"]
result6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=5)
assert result6["loss"] == 0, result6["raw"]
harness.wait_until(
lambda: len(_matches(marker_ws, "m-icmp")) >= 10 and len(_matches(marker_ws, "m-icmp6")) >= 10,
timeout=10,
)
# exact counts prove the two filters do not cross-fire: each
# marker sees its own family's 5 requests + 5 replies and
# nothing of the other's
icmp = _matches(marker_ws, "m-icmp")
assert len(icmp) == 10, len(icmp) # 5 requests + 5 replies
icmp6 = _matches(marker_ws, "m-icmp6")
assert len(icmp6) == 10, len(icmp6)
assert {e["dir"] for e in icmp} == {"tx", "rx"}, [e["dir"] for e in icmp]
assert {e["dir"] for e in icmp6} == {"tx", "rx"}, [e["dir"] for e in icmp6]
for event in icmp:
assert event["node_id"] == n1_id, event
assert event["link_id"] == link["link_id"], event
assert event["tag"] == 4242, event
assert event["len"] == PING_FRAME_LEN, event # busybox ping frame
for event in icmp6:
assert event["node_id"] == n1_id, event
assert event["link_id"] == link["link_id"], event
assert event["tag"] == 4243, event
assert event["len"] == PING_FRAME_LEN6, event # v6 echo is 20 bytes longer
# each marker's pcap on the host holds exactly its matched frames
markers_dir = os.path.join(compute.call("GET", f"/projects/{pid}")["path"], "project-files", "markers")
pcap = os.path.join(markers_dir, f"{n1_id}_{link['link_id']}_m-icmp.pcap")
pcap6 = os.path.join(markers_dir, f"{n1_id}_{link['link_id']}_m-icmp6.pcap")
assert os.path.exists(pcap), os.listdir(markers_dir)
assert os.path.exists(pcap6), os.listdir(markers_dir)
count, ethertypes = harness.pcap_records(pcap)
assert count == 10, count
assert set(ethertypes) == {"0800"}, ethertypes
count6, ethertypes6 = harness.pcap_records(pcap6)
assert count6 == 10, count6
assert set(ethertypes6) == {"86dd"}, ethertypes6
# deleting a marker removes its pcap with it
compute.call("DELETE", f"/projects/{pid}/links/{link['link_id']}/markers/m-icmp6")
assert harness.wait_until(lambda: not os.path.exists(pcap6), timeout=10), pcap6
compute.call("DELETE", f"/projects/{pid}/links/{link['link_id']}/markers/m-icmp")
assert harness.wait_until(lambda: not os.path.exists(pcap), timeout=10), pcap
finally:
marker_ws.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
def test_docker_relay_to_kernel_reopen_upgrade():
"""The server's datapath choice is a live decision: with the project
created on a relay instance, flipping ``enable_kernel_datapath`` across
a server restart and reopening the project rebuilds the link on the
kernel datapath (the upgrade path a deployment takes) and it forwards."""
if os.environ.get("GNS3_E2E_URL"):
pytest.skip("this scenario restarts the server — it needs an isolated instance")
server = harness.live_server(kernel=False)
compute = server.compute
image = harness.ensure_docker_image(server)
project = compute.create_project("docker-upgrade-e2e")
pid = project["project_id"]
a1 = a2 = bridge = None
try:
n1, n2, daemon = _create_topology(server, pid, image, "E2E")
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.docker_anchor_name(n1_id, *ETH)
a2 = harness.docker_anchor_name(n2_id, *ETH)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
assert link["kernel_datapath"] is False, link
bridge = harness.link_bridge_name(link["link_id"])
_start_addresses(compute, pid, daemon, (n1, n2), ((R1_IP, R1_IP6), (R2_IP, R2_IP6)))
assert harness.bridge_members(bridge) is None
assert harness.docker_wait_ping(daemon, n1["container_id"], R2_IP)["loss"] == 0
assert harness.docker_wait_ping(daemon, n1["container_id"], R2_IP6)["loss"] == 0
# Flip the datapath choice and reopen the project: every link
# re-runs _prepare, which re-evaluates eligibility live.
print(".. restarting the server with enable_kernel_datapath=True")
server.restart(kernel=True)
compute = server.compute
compute.call("POST", f"/projects/{pid}/open")
n1 = _resolve_container(daemon, compute.node(pid, n1_id))
n2 = _resolve_container(daemon, compute.node(pid, n2_id))
link = compute.call("GET", f"/projects/{pid}/links/{link['link_id']}")
assert link["kernel_datapath"] is True, link
assert not harness.tap_exists(a1), "containers are stopped after the reopen"
_start_addresses(compute, pid, daemon, (n1, n2), ((R1_IP, R1_IP6), (R2_IP, R2_IP6)))
assert harness.wait_until(lambda: harness.bridge_members(bridge) == sorted([a1, a2]), timeout=15)
assert harness.docker_wait_ping(daemon, n1["container_id"], R2_IP, attempts=5)["loss"] == 0
assert harness.docker_wait_ping(daemon, n1["container_id"], R2_IP6, attempts=5)["loss"] == 0
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), (
a1,
a2,
)
assert harness.wait_until(lambda: harness.bridge_members(bridge) is None, timeout=10), bridge
+377
View File
@@ -0,0 +1,377 @@
#
# 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/>.
"""
Live end-to-end test for link-local frame transparency on kernel-datapath
links (pytest marker ``e2e``): a per-link Linux bridge stands in for a cable,
so the IEEE 802.1D reserved range must cross it exactly like the uBridge UDP
relay always carried it. uBridge opens every enslave'd port's
``IFLA_BRPORT_GROUP_FWD_MASK`` to ``0xfffd`` (ubridge commit 0284500; the
contract lives in ``docs/design/ubridge-link-local-frame-forwarding-spec.md``),
which makes STP, LACP, 802.1X and LLDP/DCBX flow between the guests while the
kernel keeps hard-dropping MAC PAUSE.
``test_docker_link_local_frames`` — two real Alpine containers on a kernel
link; a static raw-Ethernet probe (built at run time, docker-cp'd in — the
image has no raw-frame tool) injects frames from guest 1's eth0 while guest
2's eth0 counts what actually arrived, per destination MAC. The scenario
asserts, through the server's own wiring path:
* the per-link bridge carries both anchors and each anchor's sysfs
``brport/group_fwd_mask`` reads 65533 (0xfffd) — the uBridge default
applied by ``brctl addif``, which is the only place gns3-server wires ports;
* the bridge-level ``group_fwd_mask`` stays 0 (the fix is per-port only);
* the live matrix from the guest: ordinary multicast crosses (wire sanity),
STP / LACP / EAPOL / LLDP cross, and PAUSE does not — ``br_handle_frame``'s
``case 0x01`` drops it unconditionally, so PFC stays unsupported by design.
The matrix is dual-stack at the frame level: the v4 all-hosts control row
has a v6 twin over ethertype 0x86dd (the all-nodes multicast MAC), and the
guests deliberately stay v4-addressed — a dual-stack guest's own MLD
report would target exactly that MAC and contaminate the per-MAC count
window the row is measured with;
* a container stop/start replays the NIO wiring and the mask is re-applied
(the reconnect story), with a fresh LACP round surviving it.
The negative control for the old behaviour is the uBridge suite's
``setportgroupfwd 0`` case (stock kernel semantics), not a second server run.
"""
import functools
import io
import os
import shutil
import subprocess
import tarfile
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# every standard Docker adapter has a single port
ETH = (0, 0)
R1_IP = "10.2.1.1"
R2_IP = "10.2.1.2"
# (destination MAC, ethertype) — what the guest protocol would emit. The
# v4 control frame's v6 twin is the all-nodes multicast: the same "ordinary
# multicast crosses" sanity, over the v6 ethertype.
CONTROL = ("01005e000001", "0800") # ordinary multicast: must always cross
CONTROL6 = ("333300000001", "86dd") # ordinary v6 multicast (ff02::1): must always cross
STP = ("0180c2000000", "0026") # STP BPDU
LACP = ("0180c2000002", "8809") # LACP / slow protocols
EAPOL = ("0180c2000003", "888e") # 802.1X EAPOL
LLDP = ("0180c200000e", "88cc") # LLDP / DCBX
PAUSE = ("0180c2000001", "8808") # 802.3x PAUSE — kernel hard-drop
MUST_CROSS = [CONTROL, CONTROL6, STP, LACP, EAPOL, LLDP]
MUST_NOT_CROSS = [PAUSE]
RECV_WINDOW = 12
_LLPROBE_C = r"""
/*
* llprobe — raw Ethernet send/recv with explicit destination MACs, for the
* link-local forwarding e2e. Built static by the test, docker-cp'd into the
* containers (the Alpine image has no raw-frame tool of its own).
*
* llprobe send <ifname> <dst-mac-hex> <ethertype-hex> <count>
* llprobe recv <ifname> <window-seconds> <dst-mac-hex>...
*
* recv prints one "<mac>=<count>" line per requested MAC, counting only
* inbound frames (PACKET_OUTGOING excluded — a veth host end sees its own
* transmissions too).
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/ioctl.h>
#include <net/if.h>
#include <net/ethernet.h>
#include <netpacket/packet.h>
#include <arpa/inet.h>
#include <time.h>
#define MAX_MACS 8
static int parse_mac(const char *hex, unsigned char *out)
{
char buf[13];
int v[6];
size_t j = 0;
for (size_t i = 0; hex[i] && j < 12; i++)
if (hex[i] != ':' && hex[i] != '-')
buf[j++] = hex[i];
buf[j] = '\0';
if (j != 12 || sscanf(buf, "%2x%2x%2x%2x%2x%2x", &v[0], &v[1], &v[2], &v[3], &v[4], &v[5]) != 6)
return -1;
for (int i = 0; i < 6; i++)
out[i] = (unsigned char)v[i];
return 0;
}
static int raw_socket(const char *ifname, struct ifreq *hwaddr)
{
int sck = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
if (sck < 0) {
perror("llprobe: socket");
return -1;
}
if (hwaddr) {
memset(hwaddr, 0, sizeof(*hwaddr));
strncpy(hwaddr->ifr_name, ifname, IFNAMSIZ - 1);
if (ioctl(sck, SIOCGIFHWADDR, hwaddr) < 0) {
perror("llprobe: SIOCGIFHWADDR");
close(sck);
return -1;
}
}
struct sockaddr_ll sa;
memset(&sa, 0, sizeof(sa));
sa.sll_family = AF_PACKET;
sa.sll_protocol = htons(ETH_P_ALL);
sa.sll_ifindex = if_nametoindex(ifname);
if (sa.sll_ifindex == 0) {
fprintf(stderr, "llprobe: no interface %s\n", ifname);
close(sck);
return -1;
}
if (bind(sck, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
perror("llprobe: bind");
close(sck);
return -1;
}
return sck;
}
static int do_send(const char *ifname, const char *dst_hex, const char *et_hex, int count)
{
unsigned char dst[6], frame[64];
unsigned int et = 0;
struct ifreq ifr;
int sck = raw_socket(ifname, &ifr);
if (sck < 0)
return 1;
if (parse_mac(dst_hex, dst) < 0 || sscanf(et_hex, "%x", &et) != 1) {
fprintf(stderr, "llprobe: bad mac %s or ethertype %s\n", dst_hex, et_hex);
close(sck);
return 1;
}
for (int n = 0; n < count; n++) {
memcpy(frame, dst, 6);
memcpy(frame + 6, ifr.ifr_hwaddr.sa_data, 6);
frame[12] = (et >> 8) & 0xff;
frame[13] = et & 0xff;
frame[14] = 'L';
frame[15] = 'L';
frame[16] = (unsigned char)n;
memset(frame + 17, 0, sizeof(frame) - 17);
if (send(sck, frame, sizeof(frame), 0) < 0) {
perror("llprobe: send");
close(sck);
return 1;
}
usleep(20000);
}
close(sck);
printf("sent %d frames to %s on %s\n", count, dst_hex, ifname);
return 0;
}
static int do_recv(const char *ifname, int window, char **macs, int nmacs)
{
unsigned char want[MAX_MACS][6];
long counts[MAX_MACS] = {0};
unsigned char buf[2048];
int sck = raw_socket(ifname, NULL);
if (sck < 0)
return 1;
for (int i = 0; i < nmacs; i++)
if (parse_mac(macs[i], want[i]) < 0) {
fprintf(stderr, "llprobe: bad mac %s\n", macs[i]);
close(sck);
return 1;
}
struct timeval tv = {0, 500000};
setsockopt(sck, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
long deadline = time(NULL) + window;
for (;;) {
if (time(NULL) >= deadline)
break;
struct sockaddr_ll from;
socklen_t flen = sizeof(from);
ssize_t n = recvfrom(sck, buf, sizeof(buf), 0, (struct sockaddr *)&from, &flen);
if (n < 14)
continue; /* timeout, error or runt */
if (from.sll_pkttype == PACKET_OUTGOING)
continue;
for (int i = 0; i < nmacs; i++)
if (memcmp(buf, want[i], 6) == 0)
counts[i]++;
}
close(sck);
for (int i = 0; i < nmacs; i++)
printf("%s=%ld\n", macs[i], counts[i]);
return 0;
}
int main(int argc, char **argv)
{
if (argc >= 5 && strcmp(argv[1], "send") == 0)
return do_send(argv[2], argv[3], argv[4], atoi(argv[5]));
if (argc >= 5 && strcmp(argv[1], "recv") == 0)
return do_recv(argv[2], atoi(argv[3]), argv + 4, argc - 4);
fprintf(stderr, "usage: llprobe send <if> <dst> <ethertype> <count>\n"
" llprobe recv <if> <window-s> <dst>...\n");
return 2;
}
"""
@functools.cache
def _llprobe():
"""Build the static raw-Ethernet probe (cached for the session)."""
directory = tempfile.mkdtemp(prefix="gns3-e2e-llprobe-")
source = os.path.join(directory, "llprobe.c")
binary = os.path.join(directory, "llprobe")
with open(source, "w") as f:
f.write(_LLPROBE_C)
cc = shutil.which("cc")
if cc is None:
pytest.skip("cannot build the raw-frame probe (no C compiler found)")
try:
# S603: resolved compiler path, fixed argv, no shell
subprocess.run([cc, "-static", "-O2", "-o", binary, source], check=True, capture_output=True) # noqa: S603
except subprocess.CalledProcessError as e:
pytest.skip(f"cannot build the raw-frame probe (cc -static): {e}")
return binary
def _upload_probe(daemon, *container_ids):
"""docker-cp the probe into every container's /tmp."""
with open(_llprobe(), "rb") as f:
payload = f.read()
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as tar:
info = tarfile.TarInfo("llprobe")
info.size = len(payload)
info.mode = 0o755
tar.addfile(info, io.BytesIO(payload))
archive = buffer.getvalue()
for container_id in container_ids:
daemon.cp(container_id, archive, "/tmp")
def _recv_counts(daemon, container_id, window, macs):
"""Run the receiver to completion and parse its "<mac>=<count>" lines."""
code, output = daemon.exec(container_id, ["/tmp/llprobe", "recv", "eth0", str(window), *macs], timeout=window + 30)
assert code == 0, output
return {line.split("=")[0]: int(line.split("=")[1]) for line in output.strip().splitlines() if "=" in line}
def _send_all(daemon, container_id, matrix, count=5):
for mac, ethertype in matrix:
code, output = daemon.exec(container_id, ["/tmp/llprobe", "send", "eth0", mac, ethertype, str(count)])
assert code == 0, output
def _port_mask(anchor):
"""The anchor's link-local forwarding mask, as an int — newer kernels
print the sysfs attribute in hex (``0xfffd``), older ones in decimal."""
return int(harness.sysfs(f"/sys/class/net/{anchor}/brport/group_fwd_mask"), 0)
def test_docker_link_local_frames():
server = harness.live_server(kernel=True)
compute = server.compute
image = harness.ensure_docker_image(server)
project = compute.create_project("docker-ll")
pid = project["project_id"]
try:
nodes = []
for suffix, address in (("1", R1_IP), ("2", R2_IP)):
node = compute.create_docker_node(pid, f"LL-{suffix}", image)
harness.configure_docker_interfaces(node, address)
nodes.append(node)
n1, n2 = nodes
daemon = harness.DockerDaemon()
for node in nodes:
node["container_id"] = daemon.container_id(f"GNS3.{node['name']}.{pid}")
assert node["container_id"], f"container for {node['name']} not found"
c1, c2 = n1["container_id"], n2["container_id"]
link = compute.create_link(pid, (n1["node_id"], *ETH), (n2["node_id"], *ETH))
lid = link["link_id"]
assert link["kernel_datapath"] is True, link
print(".. starting containers")
for node in nodes:
compute.call("POST", f"/projects/{pid}/nodes/{node['node_id']}/start")
for node, address in zip(nodes, (R1_IP, R2_IP), strict=True):
harness.docker_wait_address(daemon, node["container_id"], address)
a1 = harness.docker_anchor_name(n1["node_id"], *ETH)
a2 = harness.docker_anchor_name(n2["node_id"], *ETH)
bridge = harness.link_bridge_name(lid)
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
# The uBridge default as reached through the server's own wiring:
# both anchors open to 0xfffd, the bridge level untouched.
for anchor in (a1, a2):
mask = _port_mask(anchor)
assert mask == 0xFFFD, f"{anchor}: group_fwd_mask {mask:#x} != 0xfffd"
assert int(harness.sysfs(f"/sys/class/net/{bridge}/bridge/group_fwd_mask"), 0) == 0
print(".. injecting the link-local matrix from guest 1")
_upload_probe(daemon, c1, c2)
matrix = MUST_CROSS + MUST_NOT_CROSS
with ThreadPoolExecutor(max_workers=1) as pool:
receiver = pool.submit(_recv_counts, daemon, c2, RECV_WINDOW, [mac for mac, _ in matrix])
time.sleep(1.5) # let the receiver bind before the first frame
_send_all(daemon, c1, matrix)
counts = receiver.result()
print(f".. guest 2 received: {counts}")
for mac, _ in MUST_CROSS:
assert counts.get(mac, 0) > 0, f"{mac} did not cross the kernel link: {counts}"
for mac, _ in MUST_NOT_CROSS:
assert counts.get(mac, 0) == 0, f"{mac} unexpectedly crossed: {counts}"
print(".. restarting container 1: the NIO replay must re-apply the mask")
compute.call("POST", f"/projects/{pid}/nodes/{n1['node_id']}/stop")
harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15)
compute.call("POST", f"/projects/{pid}/nodes/{n1['node_id']}/start")
harness.docker_wait_address(daemon, c1, R1_IP)
assert harness.wait_until(lambda: harness.bridge_members(bridge) == sorted([a1, a2]), timeout=15)
mask = _port_mask(a1)
assert mask == 0xFFFD, f"{a1} after restart: group_fwd_mask {mask:#x} != 0xfffd"
with ThreadPoolExecutor(max_workers=1) as pool:
receiver = pool.submit(_recv_counts, daemon, c2, 8, [LACP[0]])
time.sleep(1.5)
_send_all(daemon, c1, [LACP])
counts = receiver.result()
assert counts.get(LACP[0], 0) > 0, f"LACP dead after the container restart: {counts}"
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid)
+341
View File
@@ -0,0 +1,341 @@
#
# 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/>.
"""
Live end-to-end test for the Dynamips kernel datapath (pytest marker
``e2e``): two real c7200 routers on a real IOS image, one Ethernet link on
the kernel path and one serial link on the relay, driven through the
controller REST API and the IOS consoles. Real ICMP proves the datapath:
* the link reports ``kernel_datapath`` and the per-link kernel bridge on
this host has exactly the two anchor TAPs enslaved (kernel objects, not
server bookkeeping);
* a ``delay 100`` filter shows up as netem on the anchors and the measured
RTT rises by ~2x100 ms; clearing it resets the qdiscs and the RTT;
* suspend drives the anchor administratively down and the pings stop;
* capture writes a pcap full of ICMP frames;
* a serial link between the same routers stays on the relay and still
pings (the relay regression net);
* deleting / re-creating the link, and stopping / starting a router, leave
the wiring consistent;
* deleting the project leaves no anchors or bridges behind.
``test_dynamips_relay_control`` is the negative control: the same topology
on an isolated relay-configured instance wires nothing into the kernel — no
per-link bridge, nothing enslaved — and still pings, so the kernel objects
above can only come from the kernel datapath. (The anchors themselves exist
on both: they are the adapter's port interfaces, created by node start
whenever the tap module is available, exactly like QEMU's TAPs.)
Every Ethernet checkpoint runs dual-stack (the routers carry a ULA v6
address next to their v4 one and each checkpoint pings both families); the
serial leg stays v4-only — it is HDLC on the relay, not an Ethernet wire.
"""
import re
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# c7200 slot 0 is the fixed IO slot; the Ethernet PA goes in slot 1 (two
# FE ports) and the serial PA in slot 2 (four ports).
SLOTS = {"slot1": "PA-2FE-TX", "slot2": "PA-4T+"}
ETH = (1, 0)
SERIAL = (2, 0)
# the dual-stack half of the Ethernet leg: a ULA mirroring 10.1.1.x. The
# serial leg stays v4-only (HDLC, not an Ethernet wire).
R1_IP6 = "fd00:1:1::1"
R2_IP6 = "fd00:1:1::2"
def _pick_image(server):
images = server.compute.dynamips_images()
image = next((name for name in images if name.startswith("c7200")), None)
if not image:
pytest.skip(f"no c7200 image available on this server (images: {images[:5]})")
return image
def _boot(server, project_id, node_id, timeout=360):
console = harness.ios_console(server, project_id, node_id)
console.boot_wait(timeout=timeout)
return console
def _eth_shutdown(console, shut):
console.run("conf t")
console.run("interface f1/0")
console.run("shutdown" if shut else "no shutdown")
console.run("end")
def test_dynamips_kernel_datapath():
server = harness.live_server(kernel=True)
compute = server.compute
if compute.capabilities().get("ubridge_tap") is not True:
pytest.skip("this compute's uBridge cannot create persistent TAPs (ubridge_tap != True)")
image = _pick_image(server)
idlepc = harness.dynamips_idlepc(server, image)
project = compute.create_project("dyn-e2e")
pid = project["project_id"]
t1 = t2 = None
try:
r1 = compute.create_dynamips_router(pid, "E2E-R1", image, SLOTS, idlepc=idlepc)
r2 = compute.create_dynamips_router(pid, "E2E-R2", image, SLOTS, idlepc=idlepc)
r1_id, r2_id = r1["node_id"], r2["node_id"]
t1 = harness.anchor_name(r1_id, *ETH)
t2 = harness.anchor_name(r2_id, *ETH)
# The link is created while both routers are stopped: the kernel NIO
# is bound with no anchors in existence yet and attached by node
# start (the deferred-wiring path).
link = compute.create_link(pid, (r1_id, *ETH), (r2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert not harness.tap_exists(t1), "anchors must not exist before start"
print(".. starting routers and waiting for IOS boots")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
c1 = _boot(server, pid, r1_id)
c2 = _boot(server, pid, r2_id)
# NetworkManager can release an NM-managed TAP from its bridge after
# (re-)enslavement (docs/bugs/networkmanager-tap-release.md); take the
# anchors out of its reach for the rest of the run.
harness.unmanage_from_networkmanager(t1, t2)
# Kernel objects: anchors exist and the per-link bridge has exactly
# the two of them enslaved.
assert harness.tap_exists(t1) and harness.tap_exists(t2), (t1, t2)
assert harness.bridge_members(bridge) == sorted([t1, t2]), harness.bridge_members(bridge)
# L2-anchor spec §E.1: anchors and the per-link bridge carry no L3
# identity (skipped on a uBridge without `link l2only` — the spec's
# degradation), and both ports are FORWARDING — the silent-failure
# guard against a bridge device left DOWN.
if harness.l2only_supported():
harness.assert_pure_l2(t1)
harness.assert_pure_l2(t2)
harness.assert_pure_l2(bridge)
else:
print(".. uBridge without link l2only: skipping the §E.1 assertions")
harness.assert_forwarding(t1)
harness.assert_forwarding(t2)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_ipv6=R2_IP6)
out = c1.run("show ip int brief")
assert re.search(r"FastEthernet1/0\s+10\.1\.1\.1\s+\S+\s+\S+\s+up\s+up", out), out
out = c1.run("show ipv6 interface brief")
# IOS prints v6 addresses upper-case (and on their own line): match
# the literal case-insensitively
assert R1_IP6.upper() in out.upper(), out
print(".. both routers configured, pinging over the kernel link")
baseline = harness.wait_ping(c1, "10.1.1.2")
assert baseline["success"] == 100, baseline["raw"]
baseline6 = harness.wait_ping(c1, R2_IP6)
assert baseline6["success"] == 100, baseline6["raw"]
# L2-anchor spec §E.2: shut the guest interfaces (silencing IOS's own
# CDP/keepalives — the only legitimate speakers on the segment) and
# demand the host side stays silent for 5 s: without the hardening
# the host's own MLD/DAD noise floods the link (6 frames / 2 s).
if harness.l2only_supported():
for console in (c1, c2):
_eth_shutdown(console, shut=True)
harness.assert_idle_silence(t1, t2, bridge)
for console in (c1, c2):
_eth_shutdown(console, shut=False)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
# delay 100: netem on both anchors (each impairs one direction),
# one-way ~100 ms => RTT grows by ~200 ms — netem is family-blind,
# both families ride the same qdisc
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(t1) and "netem" in harness.qdiscs(t2), (
harness.qdiscs(t1),
harness.qdiscs(t2),
)
delayed = harness.wait_ping(c1, "10.1.1.2")
assert delayed["success"] == 100, delayed["raw"]
delayed6 = harness.wait_ping(c1, R2_IP6)
assert delayed6["success"] == 100, delayed6["raw"]
print(f".. delay 100: v4 {baseline['avg']}->{delayed['avg']} ms, v6 {baseline6['avg']}->{delayed6['avg']} ms")
assert delayed["avg"] >= 150, (baseline, delayed)
# The v6 twin is asserted from the capture's own timestamps, not from
# the guest's report: the dynamips-emulated classic IOS under-reports
# its ping ipv6 RTT by ~40 % of a delayed round trip (reproducible:
# report ~125-135 ms while a pcap on the anchor shows the v6 echo
# request->reply taking the same ~230 ms as v4 — the wire is correct,
# the reporter is not; the pings' order does not matter).
capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"})
harness.ping(c1, R2_IP6)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop")
wire6 = harness.pcap_icmp_rtt(capture["capture_file_path"], "86dd")
print(f".. delay 100 (capture): v6 wire round trip {wire6:.0f} ms")
assert wire6 is not None and wire6 >= 60, wire6
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert "netem" not in harness.qdiscs(t1), harness.qdiscs(t1)
fast = harness.wait_ping(c1, "10.1.1.2")
assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast
fast6 = harness.wait_ping(c1, R2_IP6)
assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6
# suspend: anchor admin-down, traffic dead; resume restores it
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True})
assert not harness.tap_up(t1)
dead = harness.ping(c1, "10.1.1.2", repeat=3)
assert dead["success"] == 0, dead["raw"]
dead6 = harness.ping(c1, R2_IP6, repeat=3)
assert dead6["success"] == 0, dead6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False})
assert harness.tap_up(t1)
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
assert harness.wait_ping(c1, R2_IP6)["success"] == 100
# capture: AF_PACKET on the anchor writes a real pcap — both
# families inside the window, both ethertypes on the wire
capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"})
harness.ping(c1, "10.1.1.2")
harness.ping(c1, R2_IP6)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop")
path = capture["capture_file_path"]
count, ethertypes = harness.pcap_records(path)
assert count >= 8, f"{path}: {count} records"
assert "0800" in ethertypes, (path, ethertypes)
assert "86dd" in ethertypes, (path, ethertypes)
# serial link between the same routers: per-port exclusion keeps it
# on the relay, where the real traffic still crosses
serial = compute.create_link(pid, (r1_id, *SERIAL), (r2_id, *SERIAL))
assert serial["kernel_datapath"] is False, serial
harness.configure_ios(c1, "R1", serial_ip="10.2.2.1", clock=True)
harness.configure_ios(c2, "R2", serial_ip="10.2.2.2")
relay_ping = harness.wait_ping(c1, "10.2.2.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
compute.call("DELETE", f"/projects/{pid}/links/{serial['link_id']}")
# delete / re-create the kernel link: anchors survive, the bridge
# goes and comes back
compute.call("DELETE", f"/projects/{pid}/links/{lid}")
assert harness.bridge_members(bridge) is None
assert harness.tap_exists(t1) and harness.tap_exists(t2)
link = compute.create_link(pid, (r1_id, *ETH), (r2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
# stop / start keeps the whole wiring (the Dynamips hypervisor
# outlives ``vm stop``); the restarted router just reboots and is
# reconfigured
print(".. stopping and restarting R1")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/stop")
assert harness.tap_exists(t1)
assert harness.bridge_members(bridge) == sorted([t1, t2])
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
c1.close()
c1 = _boot(server, pid, r1_id)
assert harness.bridge_members(bridge) == sorted([t1, t2])
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# deleting the project closes the nodes: anchors and bridges go with them
assert harness.wait_until(lambda: not harness.tap_exists(t1) and not harness.tap_exists(t2), timeout=15), (
t1,
t2,
)
assert harness.wait_until(lambda: harness.bridge_members(bridge) is None, timeout=10), bridge
def test_dynamips_relay_control():
"""Negative control: the same topology with ``enable_kernel_datapath =
false`` (an isolated instance) wires nothing into the kernel — no
per-link bridge, no enslavement — and still pings over the relay.
Anchors DO exist here (a Dynamips router creates its port TAPs at start
whenever the uBridge tap module is available — they are the adapter's
interfaces, exactly like QEMU's); the server configuration only decides
whether links are attached to them."""
server = harness.live_server(kernel=False)
compute = server.compute
image = _pick_image(server)
idlepc = harness.dynamips_idlepc(server, image)
project = compute.create_project("dyn-e2e-relay")
pid = project["project_id"]
t1 = None
try:
r1 = compute.create_dynamips_router(pid, "E2E-R1", image, SLOTS, idlepc=idlepc)
r2 = compute.create_dynamips_router(pid, "E2E-R2", image, SLOTS, idlepc=idlepc)
r1_id, r2_id = r1["node_id"], r2["node_id"]
t1 = harness.anchor_name(r1_id, *ETH)
link = compute.create_link(pid, (r1_id, *ETH), (r2_id, *ETH))
lid = link["link_id"]
assert link["kernel_datapath"] is False, link
print(".. starting routers and waiting for IOS boots (relay)")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
c1 = _boot(server, pid, r1_id)
c2 = _boot(server, pid, r2_id)
# no per-link kernel bridge on the relay: nothing is enslaved, the
# anchor (if present) is just an idle port interface
assert harness.bridge_members(harness.link_bridge_name(lid)) is None
if harness.tap_exists(t1):
assert not harness.tap_up(t1), "an unattached relay anchor should sit down"
if harness.l2only_supported():
# the L2 hardening is a creation-time property, not a
# datapath one: relay anchors are pure L2 too
harness.assert_pure_l2(t1)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_ipv6=R2_IP6)
relay_ping = harness.wait_ping(c1, "10.1.1.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
relay_ping6 = harness.wait_ping(c1, R2_IP6)
assert relay_ping6["success"] == 100, relay_ping6["raw"]
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
assert harness.wait_until(lambda: not harness.tap_exists(t1), timeout=15), t1
+360
View File
@@ -0,0 +1,360 @@
#
# 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/>.
#
"""
Live end-to-end test for the switch-to-switch cascade kernel datapath
(pytest marker ``e2e``): two Ethernet switches (two Linux kernel bridges)
interconnected by a link-owned veth pair, with two c7200 routers hanging
off each switch. Real ICMP crossing both switches proves the datapath:
* the cascade link reports ``kernel_datapath`` and the veth pair
(``gs<link_id[:10]>0/1``, a pure function of the link id) exists at once
— switches are always-on, nothing defers — with each switch's bridge
carrying exactly its own end as a member, both ends UP;
* a dot1q trunk cascade carries two VLANs at once: hosts in VLAN 10 and
VLAN 20 ping across both switches simultaneously (tagged frames cross
the pair, which is what a trunk means here);
* flipping both cascade ports symmetrically to access VLAN 10 (an
in-place ``_reconfigure_port_vlan`` on each veth end) keeps VLAN 10
flowing untagged and kills VLAN 20; restoring the trunk revives VLAN 20.
Asymmetric flips are deliberately not tested: an access end egresses
untagged, and the far trunk end would park those frames in its native
VLAN — symmetric modes are the only sane inter-switch configuration;
* the L2-anchor spec §E.2 silence window with all four router interfaces
shut: the absorbed anchors, both cascade ends and both switch bridges
stay silent for 5 s;
* a ``delay 100`` filter lands as netem on BOTH veth ends (each direction
impaired exactly once, like any two-sided kernel link) and the measured
RTT grows by ~2 x 100 ms;
* suspend admin-downs both ends and kills both VLANs; resume restores;
* deleting the cascade link destroys the whole veth pair from either side
(the host links and their anchors survive); re-creating the link mints a
new pair under the new link id and the traffic returns without touching
the routers;
* deleting the project removes both switch bridges, both veth pairs and
the router anchors — zero host residue.
No relay control twin here: on the relay datapath a cascade is just two
per-port uBridge UDP relays, the machinery
``test_ethernet_switch_kernel_datapath.py::test_ethernet_switch_relay_control``
already drives.
Every checkpoint runs dual-stack: each router interface carries a ULA v6
address next to its v4 one and every ping is doubled (both VLANs tagged
across the trunk in both families, the mode flip and suspend killing both,
the two-sided delay measured on both).
"""
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# c7200 slot 0 is the fixed IO slot; the 2-port Ethernet PA goes in slot 1,
# so f1/0 = (1, 0) and f1/1 = (1, 1).
SLOTS = {"slot1": "PA-2FE-TX"}
ETH10 = (1, 0)
ETH20 = (1, 1)
IP10 = "10.1.10.%s"
IP20 = "10.1.20.%s"
# the dual-stack half: ULAs mirroring the v4 subnets
IP10_6 = "fd00:1:10::%s"
IP20_6 = "fd00:1:20::%s"
def _pick_image(server):
images = server.compute.dynamips_images()
image = next((name for name in images if name.startswith("c7200")), None)
if not image:
pytest.skip(f"no c7200 image available on this server (images: {images[:5]})")
return image
def _switch_bridge_name(switch_id):
# see EthernetSwitch._ensure_bridge
return "gns3" + switch_id.replace("-", "")[:6]
def _ports(cascade_type):
"""The switches' ports: two access ports (VLAN 10 / 20) and the cascade
port — dot1q trunk (native 1) or access 10 for the symmetric flip."""
return [
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 20},
{"name": "Ethernet2", "port_number": 2, "type": cascade_type, "vlan": 1 if cascade_type == "dot1q" else 10},
]
def _create_switch(compute, pid, name):
return compute.call(
"POST",
f"/projects/{pid}/nodes",
{
"compute_id": "local",
"name": name,
"node_type": "ethernet_switch",
"properties": {"ports_mapping": _ports("dot1q")},
},
)
def _set_ports(compute, pid, switch_id, cascade_type):
compute.call("PUT", f"/projects/{pid}/nodes/{switch_id}", {"properties": {"ports_mapping": _ports(cascade_type)}})
def _add_eth(console, ip, ifname, ipv6=None):
console.run("conf t")
console.run(f"interface {ifname}")
console.run(f"ip address {ip} 255.255.255.0")
if ipv6:
console.run(f"ipv6 address {ipv6}/64")
console.run("no shutdown")
console.run("end")
def _boot(server, project_id, node_id, timeout=360):
console = harness.ios_console(server, project_id, node_id)
console.boot_wait(timeout=timeout)
return console
def _eth_shutdown(console, shut):
"""Shut/unshut both router interfaces on their switch links — the only
legitimate speakers on these segments (IOS's own CDP/keepalives) — for
§E.2's silence window; the addresses stay configured and answer once the
interfaces are back."""
console.run("conf t")
console.run("interface f1/0")
console.run("shutdown" if shut else "no shutdown")
console.run("interface f1/1")
console.run("shutdown" if shut else "no shutdown")
console.run("end")
def test_ethernet_switch_cascade_kernel_datapath():
server = harness.live_server(kernel=True)
compute = server.compute
if compute.capabilities().get("ubridge_tap") is not True:
pytest.skip("this compute's uBridge cannot create persistent TAPs (ubridge_tap != True)")
image = _pick_image(server)
idlepc = harness.dynamips_idlepc(server, image)
project = compute.create_project("esw-cascade-e2e")
pid = project["project_id"]
bridges = []
try:
sw1 = _create_switch(compute, pid, "E2E-SW1")
sw2 = _create_switch(compute, pid, "E2E-SW2")
sw1_id, sw2_id = sw1["node_id"], sw2["node_id"]
br1, br2 = _switch_bridge_name(sw1_id), _switch_bridge_name(sw2_id)
bridges = [br1, br2]
r1 = compute.create_dynamips_router(pid, "E2E-R1", image, SLOTS, idlepc=idlepc)
r2 = compute.create_dynamips_router(pid, "E2E-R2", image, SLOTS, idlepc=idlepc)
r1_id, r2_id = r1["node_id"], r2["node_id"]
a1_10, a1_20 = harness.anchor_name(r1_id, *ETH10), harness.anchor_name(r1_id, *ETH20)
a2_10, a2_20 = harness.anchor_name(r2_id, *ETH10), harness.anchor_name(r2_id, *ETH20)
# Both switch bridges exist from node creation; the cascade wires at
# once (switches are always-on), the host links defer on the stopped
# routers exactly like the single-switch scenario.
assert harness.bridge_members(br1) == []
assert harness.bridge_members(br2) == []
host_links = [
compute.create_link(pid, (r1_id, *ETH10), (sw1_id, 0, 0)),
compute.create_link(pid, (r1_id, *ETH20), (sw1_id, 0, 1)),
compute.create_link(pid, (r2_id, *ETH10), (sw2_id, 0, 0)),
compute.create_link(pid, (r2_id, *ETH20), (sw2_id, 0, 1)),
]
cascade = compute.create_link(pid, (sw1_id, 0, 2), (sw2_id, 0, 2))
assert all(link["kernel_datapath"] is True for link in [*host_links, cascade]), (
[link.get("kernel_datapath") for link in host_links],
cascade.get("kernel_datapath"),
)
e0, e1 = harness.cascade_end_names(cascade["link_id"])
assert harness.tap_exists(e0) and harness.tap_exists(e1)
assert harness.tap_up(e0) and harness.tap_up(e1)
assert harness.bridge_members(br1) == [e0], harness.bridge_members(br1)
assert harness.bridge_members(br2) == [e1], harness.bridge_members(br2)
print(".. starting routers and waiting for IOS boots")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
c1 = _boot(server, pid, r1_id)
c2 = _boot(server, pid, r2_id)
# NetworkManager can release an NM-managed TAP from its bridge after
# (re-)enslavement (docs/bugs/networkmanager-tap-release.md); take the
# anchors out of its reach for the rest of the run.
harness.unmanage_from_networkmanager(a1_10, a1_20, a2_10, a2_20)
# The deferred host joins completed via the node-start re-push: each
# switch bridges its two router anchors plus its cascade end.
assert harness.wait_until(lambda: harness.bridge_members(br1) == sorted([a1_10, a1_20, e0]), timeout=15), (
harness.bridge_members(br1)
)
assert harness.wait_until(lambda: harness.bridge_members(br2) == sorted([a2_10, a2_20, e1]), timeout=15), (
harness.bridge_members(br2)
)
harness.configure_ios(c1, "R1", eth_ip=IP10 % 1, eth_if="f1/0", eth_ipv6=IP10_6 % 1)
_add_eth(c1, IP20 % 1, "f1/1", ipv6=IP20_6 % 1)
harness.configure_ios(c2, "R2", eth_ip=IP10 % 2, eth_if="f1/0", eth_ipv6=IP10_6 % 2)
_add_eth(c2, IP20 % 2, "f1/1", ipv6=IP20_6 % 2)
print(".. both routers configured, pinging across the cascade")
# A trunk cascade carries both VLANs at once — two simultaneous
# adjacencies over the same veth pair is the tagged-frames proof —
# in both families.
baseline10 = harness.wait_ping(c1, IP10 % 2)
assert baseline10["success"] == 100, baseline10["raw"]
baseline20 = harness.wait_ping(c1, IP20 % 2)
assert baseline20["success"] == 100, baseline20["raw"]
baseline10_6 = harness.wait_ping(c1, IP10_6 % 2)
assert baseline10_6["success"] == 100, baseline10_6["raw"]
baseline20_6 = harness.wait_ping(c1, IP20_6 % 2)
assert baseline20_6["success"] == 100, baseline20_6["raw"]
# L2-anchor spec §E.2: with all four router interfaces shut, every
# host-side device of this fabric stays silent for 5 s — the four
# absorbed router anchors, both cascade ends and both switch bridges
# (the bridges are created with multicast snooping off precisely so
# the bridge role can reach silence, see uBridge's brctl create).
if harness.l2only_supported():
for console in (c1, c2):
_eth_shutdown(console, shut=True)
harness.assert_idle_silence(a1_10, a1_20, a2_10, a2_20, e0, e1, br1, br2)
for console in (c1, c2):
_eth_shutdown(console, shut=False)
assert harness.wait_ping(c1, IP10 % 2, attempts=5)["success"] == 100
assert harness.wait_ping(c1, IP20 % 2, attempts=5)["success"] == 100
assert harness.wait_ping(c1, IP10_6 % 2, attempts=5)["success"] == 100
assert harness.wait_ping(c1, IP20_6 % 2, attempts=5)["success"] == 100
else:
print(".. uBridge without link l2only: skipping the §E.2 assertion")
# L2-anchor spec §E.1: the cascade ends and both bridges carry no L3
# identity, and the bridged ends are FORWARDING.
if harness.l2only_supported():
harness.assert_pure_l2(e0)
harness.assert_pure_l2(e1)
harness.assert_pure_l2(br1)
harness.assert_pure_l2(br2)
else:
print(".. uBridge without link l2only: skipping the §E.1 assertions")
harness.assert_forwarding(e0)
harness.assert_forwarding(e1)
# Symmetric mode flip on the cascade ports (in place, no member ever
# leaves its bridge): access 10 passes VLAN 10 untagged and drops
# VLAN 20; the trunk restores the dual-VLAN crossing.
_set_ports(compute, pid, sw1_id, "access")
_set_ports(compute, pid, sw2_id, "access")
assert harness.bridge_members(br1) == sorted([a1_10, a1_20, e0]), harness.bridge_members(br1)
assert harness.bridge_members(br2) == sorted([a2_10, a2_20, e1]), harness.bridge_members(br2)
assert harness.wait_ping(c1, IP10 % 2)["success"] == 100
assert harness.wait_ping(c1, IP10_6 % 2)["success"] == 100
isolated = harness.ping(c1, IP20 % 2, repeat=3)
assert isolated["success"] == 0, isolated["raw"]
isolated6 = harness.ping(c1, IP20_6 % 2, repeat=3)
assert isolated6["success"] == 0, isolated6["raw"]
_set_ports(compute, pid, sw1_id, "dot1q")
_set_ports(compute, pid, sw2_id, "dot1q")
rejoined = harness.wait_ping(c1, IP20 % 2, attempts=5)
assert rejoined["success"] == 100, rejoined["raw"]
rejoined6 = harness.wait_ping(c1, IP20_6 % 2, attempts=5)
assert rejoined6["success"] == 100, rejoined6["raw"]
# Two-sided impairment: each veth end is its own interface, so the
# filter lands on both — one netem per direction, RTT ~= 2 x delay.
compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(e0), harness.qdiscs(e0)
assert "netem" in harness.qdiscs(e1), harness.qdiscs(e1)
delayed = harness.wait_ping(c1, IP10 % 2)
assert delayed["success"] == 100, delayed["raw"]
assert 150 <= delayed["avg"] <= 350, (baseline10, delayed)
# the v6 twin reads the capture's own timestamps: the guest's
# ping ipv6 under-reports a delayed round trip on dynamips (see
# test_dynamips_kernel_datapath), so this asserts the wire itself
capture = compute.call(
"POST", f"/projects/{pid}/links/{cascade['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}
)
harness.ping(c1, IP10_6 % 2)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{cascade['link_id']}/capture/stop")
wire6 = harness.pcap_icmp_rtt(capture["capture_file_path"], "86dd")
print(f".. delay 100: v4 {baseline10['avg']}->{delayed['avg']} ms, v6 wire {wire6:.0f} ms")
assert wire6 is not None and wire6 >= 60, wire6
compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"filters": {}})
assert "netem" not in harness.qdiscs(e0) and "netem" not in harness.qdiscs(e1)
fast = harness.wait_ping(c1, IP10 % 2)
assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast
fast6 = harness.wait_ping(c1, IP10_6 % 2)
assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6
# suspend: both ends admin-down, both VLANs die; resume restores.
compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"suspend": True})
assert not harness.tap_up(e0) and not harness.tap_up(e1)
assert harness.ping(c1, IP10 % 2, repeat=3)["success"] == 0
assert harness.ping(c1, IP20 % 2, repeat=3)["success"] == 0
assert harness.ping(c1, IP10_6 % 2, repeat=3)["success"] == 0
assert harness.ping(c1, IP20_6 % 2, repeat=3)["success"] == 0
compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"suspend": False})
assert harness.tap_up(e0) and harness.tap_up(e1)
assert harness.wait_ping(c1, IP10 % 2, attempts=5)["success"] == 100
assert harness.wait_ping(c1, IP20 % 2, attempts=5)["success"] == 100
assert harness.wait_ping(c1, IP10_6 % 2, attempts=5)["success"] == 100
assert harness.wait_ping(c1, IP20_6 % 2, attempts=5)["success"] == 100
# Cascade link fault: the whole veth pair dies with the link (either
# side's teardown), the host links and their anchors survive; a
# re-create mints a new pair under the new link id and the traffic
# returns without touching the routers.
compute.call("DELETE", f"/projects/{pid}/links/{cascade['link_id']}")
assert not harness.tap_exists(e0) and not harness.tap_exists(e1)
assert harness.bridge_members(br1) == sorted([a1_10, a1_20]), harness.bridge_members(br1)
assert harness.bridge_members(br2) == sorted([a2_10, a2_20]), harness.bridge_members(br2)
assert harness.ping(c1, IP10 % 2, repeat=3)["success"] == 0
assert harness.ping(c1, IP10_6 % 2, repeat=3)["success"] == 0
cascade = compute.create_link(pid, (sw1_id, 0, 2), (sw2_id, 0, 2))
assert cascade["kernel_datapath"] is True, cascade
n0, n1 = harness.cascade_end_names(cascade["link_id"])
assert harness.wait_until(lambda: harness.bridge_members(br1) == sorted([a1_10, a1_20, n0]), timeout=10)
assert harness.wait_until(lambda: harness.bridge_members(br2) == sorted([a2_10, a2_20, n1]), timeout=10)
assert harness.wait_ping(c1, IP10 % 2, attempts=5)["success"] == 100
assert harness.wait_ping(c1, IP20 % 2, attempts=5)["success"] == 100
assert harness.wait_ping(c1, IP10_6 % 2, attempts=5)["success"] == 100
assert harness.wait_ping(c1, IP20_6 % 2, attempts=5)["success"] == 100
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# Project delete closed everything: both switch bridges, both veth pairs
# (the faulted one and its replacement) and all four router anchors gone.
for bridge in bridges:
assert harness.wait_until(lambda b=bridge: harness.bridge_members(b) is None, timeout=15), bridge
for end in (e0, e1, n0, n1):
assert not harness.tap_exists(end), end
for anchor in (a1_10, a1_20, a2_10, a2_20):
assert not harness.tap_exists(anchor), anchor
@@ -0,0 +1,423 @@
#
# 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/>.
"""
Live end-to-end test for the Ethernet switch kernel fast path (pytest
marker ``e2e``): a real switch (a Linux kernel bridge) between two real
c7200 routers, driven through the controller REST API and the IOS consoles.
Real ICMP crossing the switch proves the datapath:
* links created while the routers are stopped report ``kernel_datapath``
and defer the join; once the routers boot, the controller's re-push
makes the switch join their anchors — the switch bridge's members become
exactly the two router anchors (no relay TAPs anywhere);
* the L2-anchor spec §E.2 silence window with both routers shut: the
absorbed anchors and the switch bridge stay silent for 5 s;
* VLAN programming applies to absorbed anchors: different access VLANs
isolate the routers (ping dies), the same VLAN bridges them again;
* a ``delay 100`` filter lands as netem on the absorbed anchor (single
interface, switch-owned) and the measured RTT grows by ~100 ms — on both
link endpoint orders (peer-first and switch-first; the order decides
which side's NIO update runs last and must not decide the outcome);
* suspend admin-downs the anchor and kills the traffic; resume restores;
* capture (hosted by the switch side) writes a pcap of ICMP records;
* deleting a link detaches the anchor (which survives — the router owns
it) and re-creating re-joins it;
* a router stop/start keeps the wiring and the traffic;
* deleting the project removes the switch bridge and the anchors.
``test_ethernet_switch_relay_control`` is the negative control on an
isolated relay-configured instance: the same topology rides the per-port
uBridge relays (the switch bridge's members are its own relay TAPs, never
the routers' anchors) and still pings.
Every checkpoint runs dual-stack: the routers carry a ULA v6 address next
to their v4 one and each checkpoint pings both families (VLAN isolation
and suspend kill both; the netem delays and the capture window carry both).
"""
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# c7200 slot 0 is the fixed IO slot; the Ethernet PA goes in slot 1.
SLOTS = {"slot1": "PA-2FE-TX"}
ETH = (1, 0)
# the dual-stack half: a ULA mirroring 10.1.1.x
R1_IP6 = "fd00:1:1::1"
R2_IP6 = "fd00:1:1::2"
def _pick_image(server):
images = server.compute.dynamips_images()
image = next((name for name in images if name.startswith("c7200")), None)
if not image:
pytest.skip(f"no c7200 image available on this server (images: {images[:5]})")
return image
def _switch_bridge_name(switch_id):
# see EthernetSwitch._ensure_bridge
return "gns3" + switch_id.replace("-", "")[:6]
def _create_topology(compute, pid, image, name, idlepc=None):
switch = compute.call(
"POST",
f"/projects/{pid}/nodes",
{
"compute_id": "local",
"name": f"{name}-SW",
"node_type": "ethernet_switch",
"properties": {
"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 1},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 1},
]
},
},
)
r1 = compute.create_dynamips_router(pid, f"{name}-R1", image, SLOTS, idlepc=idlepc)
r2 = compute.create_dynamips_router(pid, f"{name}-R2", image, SLOTS, idlepc=idlepc)
return switch, r1, r2
def _boot(server, project_id, node_id, timeout=360):
console = harness.ios_console(server, project_id, node_id)
console.boot_wait(timeout=timeout)
return console
def _eth_shutdown(console, shut):
"""Shut/unshut the router's f1/0 — the only legitimate speaker on the
segment (IOS's own CDP/keepalives) — for §E.2's silence window; the
address stays configured and answers once it is back."""
console.run("conf t")
console.run("interface f1/0")
console.run("shutdown" if shut else "no shutdown")
console.run("end")
def test_ethernet_switch_kernel_fast_path():
server = harness.live_server(kernel=True)
compute = server.compute
if compute.capabilities().get("ubridge_tap") is not True:
pytest.skip("this compute's uBridge cannot create persistent TAPs (ubridge_tap != True)")
image = _pick_image(server)
idlepc = harness.dynamips_idlepc(server, image)
project = compute.create_project("esw-e2e")
pid = project["project_id"]
sw_bridge = None
try:
switch, r1, r2 = _create_topology(compute, pid, image, "E2E", idlepc)
switch_id = switch["node_id"]
r1_id, r2_id = r1["node_id"], r2["node_id"]
sw_bridge = _switch_bridge_name(switch_id)
a1 = harness.anchor_name(r1_id, *ETH)
a2 = harness.anchor_name(r2_id, *ETH)
# The switch bridge exists from the moment the switch node does; a
# link against a stopped router must not fail — the join defers.
assert harness.bridge_members(sw_bridge) == []
# link1 is created peer-first, link2 switch-first: the endpoint order
# decides which side's NIO update runs last, and only the switch-first
# order used to expose the passive end's tc-reset wipe (its empty-filter
# reconcile ran after the switch's apply). Both orders must work.
link1 = compute.create_link(pid, (r1_id, *ETH), (switch_id, 0, 0))
link2 = compute.create_link(pid, (switch_id, 0, 1), (r2_id, *ETH))
assert link1["kernel_datapath"] is True, link1
assert link2["kernel_datapath"] is True, link2
assert harness.bridge_members(sw_bridge) == []
print(".. starting routers and waiting for IOS boots")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
c1 = _boot(server, pid, r1_id)
c2 = _boot(server, pid, r2_id)
# Keep NetworkManager's hands off the anchors: on NM hosts its
# netdev state machine can release a freshly re-enslaved tap from
# the bridge ~60 ms after the join (silently — the port stays up,
# the server keeps believing it is attached), which the link
# delete/recreate below triggers nondeterministically. The first
# join at node start is unaffected; this covers everything after
# it.
harness.unmanage_from_networkmanager(a1, a2)
# Deferred joins completed by the node-start re-push: the anchors
# ARE the switch ports now.
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15), (
harness.bridge_members(sw_bridge)
)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_ipv6=R2_IP6)
print(".. both routers configured, pinging through the switch")
baseline = harness.wait_ping(c1, "10.1.1.2")
assert baseline["success"] == 100, baseline["raw"]
baseline6 = harness.wait_ping(c1, R2_IP6)
assert baseline6["success"] == 100, baseline6["raw"]
# L2-anchor spec §E.2: with both routers shut, the absorbed anchors
# and the switch bridge stay silent for 5 s (the bridge is created
# with multicast snooping off precisely so the bridge role can).
if harness.l2only_supported():
for console in (c1, c2):
_eth_shutdown(console, shut=True)
harness.assert_idle_silence(a1, a2, sw_bridge)
for console in (c1, c2):
_eth_shutdown(console, shut=False)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
else:
print(".. uBridge without link l2only: skipping the §E.2 assertion")
# L2-anchor spec §E.1: the absorbed anchors and the switch bridge
# carry no L3 identity (skipped on a uBridge without `link l2only`),
# and the joined ports are FORWARDING — the silent-failure guard.
if harness.l2only_supported():
harness.assert_pure_l2(a1)
harness.assert_pure_l2(a2)
harness.assert_pure_l2(sw_bridge)
else:
print(".. uBridge without link l2only: skipping the §E.1 assertions")
harness.assert_forwarding(a1)
harness.assert_forwarding(a2)
# VLAN programming on absorbed anchors: different access VLANs
# isolate, the same VLAN bridges again.
compute.call(
"PUT",
f"/projects/{pid}/nodes/{switch_id}",
{
"properties": {
"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 20},
]
}
},
)
assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge)
isolated = harness.ping(c1, "10.1.1.2", repeat=3)
assert isolated["success"] == 0, isolated["raw"]
isolated6 = harness.ping(c1, R2_IP6, repeat=3)
assert isolated6["success"] == 0, isolated6["raw"]
assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge)
compute.call(
"PUT",
f"/projects/{pid}/nodes/{switch_id}",
{
"properties": {
"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 10},
]
}
},
)
rejoined = harness.wait_ping(c1, "10.1.1.2")
assert rejoined["success"] == 100, rejoined["raw"]
rejoined6 = harness.wait_ping(c1, R2_IP6)
assert rejoined6["success"] == 100, rejoined6["raw"]
# Single-owned impairment: the filter lands on the absorbed anchor
# (one interface — the switch's), RTT grows by ~100 ms one-way.
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(a1), harness.qdiscs(a1)
assert "netem" not in harness.qdiscs(a2), harness.qdiscs(a2)
delayed = harness.wait_ping(c1, "10.1.1.2")
assert delayed["success"] == 100, delayed["raw"]
assert 60 <= delayed["avg"] <= 250, (baseline, delayed)
# the v6 twin reads the capture's own timestamps: the guest's
# ping ipv6 under-reports a delayed round trip on dynamips (see
# test_dynamips_kernel_datapath), and here the single-owned delay
# is small enough that the quirk swallows most of the signal
capture = compute.call(
"POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}
)
harness.ping(c1, R2_IP6)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop")
wire6 = harness.pcap_icmp_rtt(capture["capture_file_path"], "86dd")
print(f".. delay 100: v4 {baseline['avg']}->{delayed['avg']} ms, v6 wire {wire6:.0f} ms")
assert wire6 is not None and wire6 >= 60, wire6
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {}})
assert "netem" not in harness.qdiscs(a1)
fast = harness.wait_ping(c1, "10.1.1.2")
assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast
fast6 = harness.wait_ping(c1, R2_IP6)
assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6
# The same single-owned impairment on link2 — created switch-first,
# the order whose peer NIO update runs LAST. The passive end's empty
# reconcile used to tc-reset the shared anchor right after the switch
# applied the netem, wiping it (API green, data plane unimpaired);
# the qdisc and the RTT must both hold under this order too.
compute.call("PUT", f"/projects/{pid}/links/{link2['link_id']}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(a2), harness.qdiscs(a2)
assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1)
delayed2 = harness.wait_ping(c1, "10.1.1.2")
assert delayed2["success"] == 100, delayed2["raw"]
assert 60 <= delayed2["avg"] <= 250, (baseline, delayed2)
# the capture must sit on the pinging node's anchor (link1's, i.e.
# R1's): a TX copy is timestamped post-qdisc, so on link2's own
# anchor — where this window's delay impairs the request direction —
# the request copy would already carry the delay and the measured
# delta would collapse to the turnaround. R1's anchor sees the
# request before the delay and the reply after it.
capture = compute.call(
"POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}
)
harness.ping(c1, R2_IP6)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop")
wire2_6 = harness.pcap_icmp_rtt(capture["capture_file_path"], "86dd")
print(f".. delay 100 (link2): v4 {baseline['avg']}->{delayed2['avg']} ms, v6 wire {wire2_6:.0f} ms")
assert wire2_6 is not None and wire2_6 >= 60, wire2_6
compute.call("PUT", f"/projects/{pid}/links/{link2['link_id']}", {"filters": {}})
assert "netem" not in harness.qdiscs(a2)
fast2 = harness.wait_ping(c1, "10.1.1.2")
assert fast2["success"] == 100 and fast2["avg"] < harness.FAST_RTT_MS, fast2
fast2_6 = harness.wait_ping(c1, R2_IP6)
assert fast2_6["success"] == 100 and fast2_6["avg"] < harness.FAST_RTT_MS, fast2_6
# suspend: the anchor admin-downs, the link is dead; resume restores
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": True})
assert not harness.tap_up(a1)
dead = harness.ping(c1, "10.1.1.2", repeat=3)
assert dead["success"] == 0, dead["raw"]
dead6 = harness.ping(c1, R2_IP6, repeat=3)
assert dead6["success"] == 0, dead6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": False})
assert harness.tap_up(a1)
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
assert harness.wait_ping(c1, R2_IP6)["success"] == 100
# capture (the controller hosts it on the switch — always-on local
# builtin): a pcap of ICMP records written off the absorbed anchor,
# both families inside the window
capture = compute.call(
"POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}
)
harness.ping(c1, "10.1.1.2")
harness.ping(c1, R2_IP6)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop")
count, ethertypes = harness.pcap_records(capture["capture_file_path"])
assert count >= 8, capture["capture_file_path"]
assert "0800" in ethertypes, ethertypes
assert "86dd" in ethertypes, ethertypes
# link delete detaches the anchor (it survives — the router owns
# it); re-creating re-joins it
compute.call("DELETE", f"/projects/{pid}/links/{link2['link_id']}")
assert harness.bridge_members(sw_bridge) == [a1]
assert harness.tap_exists(a2)
link2 = compute.create_link(pid, (r2_id, *ETH), (switch_id, 0, 1))
assert link2["kernel_datapath"] is True, link2
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=10)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
# router stop/start keeps the wiring: a Dynamips stop only halts the
# emulated router — the hypervisor, the anchor and its bridge
# membership all survive (the same stop-is-not-close semantics as
# the per-link bridge datapath)
print(".. stopping and restarting R1")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/stop")
assert harness.tap_exists(a1)
assert harness.bridge_members(sw_bridge) == sorted([a1, a2])
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
c1.close()
c1 = _boot(server, pid, r1_id)
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# project delete closed everything: switch bridge and anchors gone
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) is None, timeout=15), sw_bridge
def test_ethernet_switch_relay_control():
"""Negative control: the same topology on an isolated relay-configured
instance rides the per-port uBridge relays — the switch bridge's
members are its own relay TAPs, never the routers' anchors — and still
pings."""
server = harness.live_server(kernel=False)
compute = server.compute
image = _pick_image(server)
idlepc = harness.dynamips_idlepc(server, image)
project = compute.create_project("esw-e2e-relay")
pid = project["project_id"]
sw_bridge = None
try:
switch, r1, r2 = _create_topology(compute, pid, image, "E2E", idlepc)
sw_bridge = _switch_bridge_name(switch["node_id"])
r1_id, r2_id = r1["node_id"], r2["node_id"]
a1 = harness.anchor_name(r1_id, *ETH)
link1 = compute.create_link(pid, (r1_id, *ETH), (switch["node_id"], 0, 0))
link2 = compute.create_link(pid, (r2_id, *ETH), (switch["node_id"], 0, 1))
assert link1["kernel_datapath"] is False, link1
assert link2["kernel_datapath"] is False, link2
print(".. starting routers and waiting for IOS boots (relay)")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
c1 = _boot(server, pid, r1_id)
c2 = _boot(server, pid, r2_id)
members = harness.bridge_members(sw_bridge)
assert members == sorted([f"{sw_bridge}-0", f"{sw_bridge}-1"]), members
assert a1 not in members
if harness.l2only_supported():
# creation-time hardening applies on the relay too: the switch's
# own port TAPs and its bridge are pure L2
for member in members:
harness.assert_pure_l2(member)
harness.assert_pure_l2(sw_bridge)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_ipv6=R2_IP6)
relay_ping = harness.wait_ping(c1, "10.1.1.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
relay_ping6 = harness.wait_ping(c1, R2_IP6)
assert relay_ping6["success"] == 100, relay_ping6["raw"]
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) is None, timeout=15), sw_bridge
@@ -0,0 +1,379 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software and is redistributed under the same terms as gns3-server.
# See the LICENSE file for licensing information.
#
"""
Live end-to-end test for the IOL runner container's kernel datapath (pytest
marker ``e2e``): two real iol-xe containers (Cisco CML's iol-runner images,
the real IOS-XE CLI on PID 1 stdio), driven through the controller REST API
and their telnet consoles. The guest leg stays the runner's unix-socket pair
— the kernel datapath buys the *link segment*: anchor TAPs, a per-link Linux
bridge, kernel filters, suspend and capture.
* two containers on one compute report ``kernel_datapath`` when the compute's
uBridge has both the tap module and the swappable bridge TAP leg
(``ubridge_bridge_tap``); the anchors (``gx`` TAPs) exist from node start,
before any link — and the per-link bridge enslaves exactly the two of them;
* real ICMP crosses the [unix ↔ TAP] port bridge relay;
* the L2-anchor spec §E.2 silence window with the guests shut — the
behavioral half of the hardening on an anchor created by uBridge's
bridge TAP module;
* a ``delay 100`` filter lands as netem on both anchors, RTT grows ~200 ms;
* the classifier spot check on a TAP anchor: ``bpf`` match-drop and the
eBPF ``frequency_drop`` every-nth mode (the Docker suite runs the full
matrix on veth host ends);
* suspend admin-downs the anchor and kills the traffic; resume restores;
* link delete releases the port bridge's TAP leg (stop → delete_nio_tap) and
the anchor survives — re-creating the link swaps the leg back in (the
c-socket binding never re-binds) and the traffic returns;
* node stop tears the anchors down (uBridge holds their fds — port bridges
first, then tap delete); a restart recreates the whole wiring from the NIO;
* deleting the project leaves no anchors and no bridges behind.
``test_iol_docker_relay_control`` is the negative control on an isolated
relay-configured instance: the same topology rides unix ↔ UDP through the
uBridge relay (no anchors ever exist) and still pings.
Every checkpoint runs dual-stack: the IOS-XE guests carry a ULA v6 address
next to their v4 one and each checkpoint pings both families — including
the TAP-anchor bpf spot check (the libpcap expression is family-specific:
each drop filter is asserted to kill its own family and leave the other
alive) and the eBPF nth classifier (family-blind, asserted on both).
"""
import re
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# one adapter = one 4-port unit; Ethernet0/0 is (adapter 0, port 0)
ETH = (0, 0)
BOOT_TIMEOUT = 360
# the dual-stack half: a ULA mirroring 10.1.1.x
R1_IP6 = "fd00:1:1::1"
R2_IP6 = "fd00:1:1::2"
def _pick_image(server):
images = server.compute.docker_images()
image = next((name for name in images if name.startswith("iol-xe/iol-xe:")), None)
if not image:
pytest.skip(f"no iol-xe/iol-xe image available on this server (images: {images[:5]})")
return image
def _boot(server, project_id, node_id, timeout=BOOT_TIMEOUT):
node = server.compute.node(project_id, node_id)
console = harness.IOSConsole(node["console_host"], node["console"])
# straight_prompt: IOL boots from its NVRAM startup config straight into
# the CLI — no "Press RETURN" sentinel on this console
console.boot_wait(timeout=timeout, straight_prompt=True)
return console
def _eth_shutdown(console, shut):
"""Shut/unshut the guest's Ethernet0/0 — the only legitimate speaker on
the link segment (the IOL instance's own chatter) — for §E.2's silence
window; the address stays configured and answers once it is back."""
console.run("conf t")
console.run("interface Ethernet0/0")
console.run("shutdown" if shut else "no shutdown")
console.run("end")
def _topology(compute, pid, image, name):
n1 = compute.create_iol_router(pid, f"{name}-1", image)
n2 = compute.create_iol_router(pid, f"{name}-2", image)
return n1, n2
def test_iol_docker_kernel_datapath():
server = harness.live_server(kernel=True)
compute = server.compute
caps = compute.capabilities()
if caps.get("ubridge_bridge_tap") is not True or caps.get("ubridge_tap") is not True:
pytest.skip(
f"this compute's uBridge cannot serve the IOL container anchor lifecycle "
f"(ubridge_bridge_tap={caps.get('ubridge_bridge_tap')}, ubridge_tap={caps.get('ubridge_tap')})"
)
image = _pick_image(server)
harness.stage_iol_base_config(server)
project = compute.create_project("iol-e2e")
pid = project["project_id"]
a1 = a2 = None
try:
n1, n2 = _topology(compute, pid, image, "E2E")
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.iol_anchor_name(n1_id, *ETH)
a2 = harness.iol_anchor_name(n2_id, *ETH)
# The link is created while both containers are stopped: the kernel
# NIO is bound with no anchors in existence yet and wired by node
# start (the deferred-wiring path).
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert not harness.tap_exists(a1), "anchors must not exist before start"
print(".. starting containers and waiting for IOS-XE boots")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start")
c1 = _boot(server, pid, n1_id)
c2 = _boot(server, pid, n2_id)
# NetworkManager can release an NM-managed TAP from its bridge after
# (re-)enslavement (docs/bugs/networkmanager-tap-release.md); take the
# anchors out of its reach for the rest of the run.
harness.unmanage_from_networkmanager(a1, a2)
# Kernel objects: anchors exist (born with the node, before the link
# was ever wired) and the per-link bridge has exactly the two of them.
assert harness.tap_exists(a1) and harness.tap_exists(a2), (a1, a2)
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
# L2-anchor spec §E.1: anchors and the per-link bridge carry no L3
# identity (skipped on a uBridge without `link l2only`), and both
# ports are FORWARDING — the silent-failure guard.
if harness.l2only_supported():
harness.assert_pure_l2(a1)
harness.assert_pure_l2(a2)
harness.assert_pure_l2(bridge)
else:
print(".. uBridge without link l2only: skipping the §E.1 assertions")
harness.assert_forwarding(a1)
harness.assert_forwarding(a2)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0", eth_ipv6=R2_IP6)
out = c1.run("show ip int brief")
assert re.search(r"Ethernet0/0\s+10\.1\.1\.1\s+\S+\s+\S+\s+up\s+up", out), out
out = c1.run("show ipv6 interface brief")
assert R1_IP6.upper() in out.upper(), out # IOS prints v6 upper-case
print(".. both routers configured, pinging through the port-bridge swap")
baseline = harness.wait_ping(c1, "10.1.1.2")
assert baseline["success"] == 100, baseline["raw"]
baseline6 = harness.wait_ping(c1, R2_IP6)
assert baseline6["success"] == 100, baseline6["raw"]
# L2-anchor spec §E.2: with the guests shut the anchors and the
# per-link bridge stay silent for 5 s. These anchors come from
# uBridge's bridge TAP module (`bridge add_nio_tap`) — the creator
# whose missing hardening the spec caught live; this is the
# behavioral half of that guarantee.
if harness.l2only_supported():
for console in (c1, c2):
_eth_shutdown(console, shut=True)
harness.assert_idle_silence(a1, a2, bridge)
for console in (c1, c2):
_eth_shutdown(console, shut=False)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
else:
print(".. uBridge without link l2only: skipping the §E.2 assertion")
# delay 100: netem on both anchors (each impairs one direction),
# one-way ~100 ms => RTT grows by ~200 ms
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
delayed = harness.wait_ping(c1, "10.1.1.2")
assert delayed["success"] == 100, delayed["raw"]
assert delayed["avg"] >= 150, (baseline, delayed)
delayed6 = harness.wait_ping(c1, R2_IP6)
assert delayed6["success"] == 100, delayed6["raw"]
assert delayed6["avg"] >= 150, (baseline6, delayed6)
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1)
fast = harness.wait_ping(c1, "10.1.1.2")
assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast
fast6 = harness.wait_ping(c1, R2_IP6)
assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6
# Classifier spot check on the container's TAP anchor (the Docker
# suite runs the full matrix on veth host ends): cls_bpf match-drop
# and the eBPF stateful classifier attach to a tun/tap anchor the
# same way.
tc_caps = (caps or {}).get("ubridge_tc") or {}
if tc_caps.get("cbpf"):
print(".. bpf 'icmp' drops v4 ICMP only on the TAP anchor")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"bpf": ["icmp"]}})
assert "clsact" in harness.qdiscs(a1) and "clsact" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
blocked = harness.ping(c1, "10.1.1.2", repeat=4)
assert blocked["success"] == 0, blocked["raw"]
v6_alive = harness.wait_ping(c1, R2_IP6)
assert v6_alive["success"] == 100, v6_alive["raw"]
print(".. bpf 'icmp6' drops v6 ICMP only on the TAP anchor")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"bpf": ["icmp6"]}})
v4_alive = harness.wait_ping(c1, "10.1.1.2")
assert v4_alive["success"] == 100, v4_alive["raw"]
blocked6 = harness.ping(c1, R2_IP6, repeat=4)
assert blocked6["success"] == 0, blocked6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert "clsact" not in harness.qdiscs(a1), harness.qdiscs(a1)
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
assert harness.wait_ping(c1, R2_IP6)["success"] == 100
else:
print(".. uBridge reports no cbpf: skipping the bpf check")
modes = tc_caps.get("ebpf_modes") or []
if "nth" in modes:
print(".. frequency_drop 3 (eBPF nth) on the TAP anchor")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"frequency_drop": [3]}})
nth = harness.ping(c1, "10.1.1.2", repeat=9)
loss = 100 - nth["success"]
print(f".. {loss} % round-trip loss")
# the kernel counts per direction: 1 - (2/3)^2 = 55.6 % round trip
assert 20 <= loss <= 90, nth["raw"]
nth6 = harness.ping(c1, R2_IP6, repeat=9)
loss6 = 100 - nth6["success"]
print(f".. {loss6} % round-trip loss (v6)")
assert 20 <= loss6 <= 90, nth6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
assert harness.wait_ping(c1, R2_IP6)["success"] == 100
else:
print(".. uBridge reports no eBPF nth: skipping the frequency_drop check")
# suspend: anchor admin-down (the port bridge's TAP writes fail EIO,
# nothing comes back); resume restores
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True})
assert not harness.tap_up(a1)
dead = harness.ping(c1, "10.1.1.2", repeat=3)
assert dead["success"] == 0, dead["raw"]
dead6 = harness.ping(c1, R2_IP6, repeat=3)
assert dead6["success"] == 0, dead6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False})
assert harness.tap_up(a1)
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
assert harness.wait_ping(c1, R2_IP6)["success"] == 100
# capture: AF_PACKET on the anchor writes a real pcap — both
# families inside the window
capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"})
harness.ping(c1, "10.1.1.2")
harness.ping(c1, R2_IP6)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop")
path = capture["capture_file_path"]
count, ethertypes = harness.pcap_records(path)
assert count >= 8, f"{path}: {count} records"
assert "0800" in ethertypes, (path, ethertypes)
assert "86dd" in ethertypes, (path, ethertypes)
# delete / re-create the kernel link: the port bridge's TAP leg is
# released (stop -> delete_nio_tap), the anchor survives (the node
# owns it), and re-creating swaps the leg back in — the unix binding
# never re-binds across the churn.
compute.call("DELETE", f"/projects/{pid}/links/{lid}")
assert harness.bridge_members(bridge) is None
assert harness.tap_exists(a1) and harness.tap_exists(a2)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
# node stop: uBridge holds the anchor fds, so the port bridges go
# first and the taps with them (a stopped container must not litter
# the host with gx devices); a restart recreates the whole wiring
# from the NIO that outlived it.
print(".. stopping and restarting container 1")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop")
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
# the per-link bridge lost this end's port; it may be gone entirely
# (both endpoints delete it, last one wins)
assert harness.bridge_members(bridge) in (None, [a2]), harness.bridge_members(bridge)
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
c1.close()
c1 = _boot(server, pid, n1_id)
assert harness.tap_exists(a1)
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# deleting the project closed the nodes: anchors and bridges go with them
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), (
a1,
a2,
)
def test_iol_docker_relay_control():
"""
Negative control: the same topology with ``enable_kernel_datapath =
false`` (an isolated instance) wires nothing into the kernel — no
per-link bridge, no enslavement — and still pings over the unix ↔ UDP
relay.
Anchors DO exist here (an IOL container creates its port TAPs at start
whenever the uBridge tap module and the swappable leg are available);
the server configuration only decides whether links are attached to
them (the same semantics as QEMU's and Dynamips' anchors).
"""
server = harness.live_server(kernel=False)
compute = server.compute
image = _pick_image(server)
harness.stage_iol_base_config(server)
project = compute.create_project("iol-relay-e2e")
pid = project["project_id"]
n1_id = n2_id = a1 = None
try:
n1, n2 = _topology(compute, pid, image, "E2E")
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.iol_anchor_name(n1_id, *ETH)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
assert link["kernel_datapath"] is False, link
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start")
c1 = _boot(server, pid, n1_id)
c2 = _boot(server, pid, n2_id)
# no kernel wiring: the anchor (if born) is nobody's bridge port
assert harness.bridge_members(harness.link_bridge_name(link["link_id"])) is None
if harness.tap_exists(a1):
assert not harness.tap_up(a1), a1
if harness.l2only_supported():
# the L2 hardening is a creation-time property, not a
# datapath one: relay anchors are pure L2 too
harness.assert_pure_l2(a1)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0", eth_ipv6=R2_IP6)
relay_ping = harness.wait_ping(c1, "10.1.1.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
relay_ping6 = harness.wait_ping(c1, R2_IP6)
assert relay_ping6["success"] == 100, relay_ping6["raw"]
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
+538
View File
@@ -0,0 +1,538 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software and is redistributed under the same terms as gns3-server.
# See the LICENSE file for licensing information.
#
"""
Live end-to-end test for the IOU node's kernel datapath (pytest marker
``e2e``): two real IOU routers (a real IOU image executed by the local
compute, the real IOS CLI behind the server's telnet console), driven
through the controller REST API. IOU has no host interface of its own: the
guest leg is the IOL fabric unix socket, and the kernel datapath buys the
*link segment* — one persistent anchor TAP per Ethernet bay/unit, a per-link
Linux bridge, kernel filters, suspend and capture.
* two IOU nodes on one compute report ``kernel_datapath`` when the compute's
uBridge can terminate an IOL port on a persistent TAP
(``ubridge_iol_tap``); the anchors (``gi`` TAPs, one per Ethernet
bay/unit) exist from node start, before any link — and the per-link
bridge enslaves exactly the two of them;
* real ICMP crosses the [IOL fabric ↔ TAP] port bridge;
* the L2-anchor spec §E.2 silence window with the guests shut;
* a ``delay 100`` filter lands as netem on both anchors, RTT grows ~200 ms;
* the classifier spot check on a TAP anchor: ``bpf`` match-drop and the
eBPF ``frequency_drop`` every-nth mode (the Docker suite runs the full
matrix on veth host ends);
* suspend admin-downs the anchor and kills the traffic; resume restores;
* a serial link between the same nodes never anchors (per-port exclusion):
it stays on the relay, ``kernel_datapath`` is False, and real traffic
still crosses it;
* link delete releases the port bridge's TAP leg and the anchor survives —
re-creating the link swaps the leg back in and the traffic returns;
* node stop tears the anchors down (uBridge holds their fds — the IOL
bridge goes first, then ``tap delete``); a restart recreates the whole
wiring from the NIO, a delay filter applied before the stop included;
* deleting the project leaves no anchors and no bridges behind.
``test_iou_relay_control`` is the negative control on an isolated
relay-configured instance: the same topology rides the fabric socket ↔ UDP
relay (no per-link bridge, nothing enslaved) and still pings — with its
filters applied by the IOU-specific engine
(``iol_bridge add_packet_filter`` on the IOL port) really shaping the wire.
Its capture and markers ride the same engine
(``iol_bridge start_capture`` / ``iol_bridge add_packet_filter … mark``).
"""
import os
import re
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# Ethernet0/0 is (bay 0, unit 0) — one adapter = four units. The adapter
# index runs over Ethernet and serial bays together, so with two Ethernet
# adapters the serial bays are 2 and 3: Serial2/0 is (2, 0).
ETH = (0, 0)
SERIAL = (2, 0)
BOOT_TIMEOUT = 360
# the dual-stack half of the Ethernet leg: a ULA mirroring 10.1.1.x. The
# serial leg stays v4-only (HDLC, not an Ethernet wire).
R1_IP6 = "fd00:1:1::1"
R2_IP6 = "fd00:1:1::2"
# exact ICMPv6 echo match for the relay marker: a bare "icmp6" would also
# match IOS's own control frames (DAD/MLD/NS/NA) and break the exact-count
# asserts
ICMP6_ECHO_BPF = "icmp6 and (ip6[40] == 128 or ip6[40] == 129)"
# L3 images only: an L2 switching image does not take an address on its
# switchports. The first is the image the kernel datapath was validated
# with, the second the matching 17.15 build.
IMAGE_PREFERENCE = ("x86_64_crb_linux-adventerprisek9-ms.iol", "l3-adventerprisek9-ms-17.15.01.bin")
def _pick_image(server):
images = server.compute.iou_images()
for name in IMAGE_PREFERENCE:
if name in images:
return name
l3 = next((name for name in images if "adventerprisek9" in name and "l2" not in name.lower()), None)
if not l3:
pytest.skip(f"no L3 IOU image available on this server (images: {images[:5]})")
return l3
def _boot(server, project_id, node_id, timeout=BOOT_TIMEOUT):
console = harness.ios_console(server, project_id, node_id)
console.boot_wait(timeout=timeout)
return console
def _matches(marker_ws, marker_name):
return [e for e in marker_ws.events("marker.match") if e["filter"] == marker_name]
def _eth_shutdown(console, shut):
"""Shut/unshut the guest's Ethernet0/0 — the only legitimate speaker on
the link segment (the IOL fabric's own chatter) — for §E.2's silence
window; the address stays configured and answers once it is back."""
console.run("conf t")
console.run("interface Ethernet0/0")
console.run("shutdown" if shut else "no shutdown")
console.run("end")
def test_iou_kernel_datapath():
server = harness.live_server(kernel=True)
compute = server.compute
caps = compute.capabilities()
if caps.get("ubridge_iol_tap") is not True or caps.get("ubridge_tap") is not True:
pytest.skip(
f"this compute's uBridge cannot serve the IOU anchor lifecycle "
f"(ubridge_iol_tap={caps.get('ubridge_iol_tap')}, ubridge_tap={caps.get('ubridge_tap')})"
)
image = _pick_image(server)
project = compute.create_project("iou-e2e")
pid = project["project_id"]
a1 = a2 = None
try:
n1 = compute.create_iou_node(pid, "E2E-R1", image)
n2 = compute.create_iou_node(pid, "E2E-R2", image)
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.iou_anchor_name(n1_id, *ETH)
a2 = harness.iou_anchor_name(n2_id, *ETH)
# The link is created while both nodes are stopped: the kernel NIO
# is bound with no anchors in existence yet and wired by node start
# (the deferred-wiring path).
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert not harness.tap_exists(a1), "anchors must not exist before start"
print(f".. starting routers on {image} and waiting for IOS boots")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start")
c1 = _boot(server, pid, n1_id)
c2 = _boot(server, pid, n2_id)
# NetworkManager can release an NM-managed TAP from its bridge after
# (re-)enslavement (docs/bugs/networkmanager-tap-release.md); take the
# anchors out of its reach for the rest of the run.
harness.unmanage_from_networkmanager(a1, a2)
# Kernel objects: the anchors are one persistent TAP per Ethernet
# bay/unit, born with the node (all eight — the IOU port model),
# and the per-link bridge has exactly the two addressed ports.
for adapter in range(2):
for port in range(4):
name = harness.iou_anchor_name(n1_id, adapter, port)
assert harness.tap_exists(name), name
assert harness.tap_exists(a2), a2
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
# L2-anchor spec §E.1: anchors and the per-link bridge carry no L3
# identity (skipped on a uBridge without `link l2only`), and both
# ports are FORWARDING — the silent-failure guard.
if harness.l2only_supported():
harness.assert_pure_l2(a1)
harness.assert_pure_l2(a2)
harness.assert_pure_l2(bridge)
else:
print(".. uBridge without link l2only: skipping the §E.1 assertions")
harness.assert_forwarding(a1)
harness.assert_forwarding(a2)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0", eth_ipv6=R2_IP6)
out = c1.run("show ip int brief")
assert re.search(r"Ethernet0/0\s+10\.1\.1\.1\s+\S+\s+\S+\s+up\s+up", out), out
out = c1.run("show ipv6 interface brief")
assert R1_IP6.upper() in out.upper(), out # IOS prints v6 upper-case
print(".. both routers configured, pinging through the [IOL fabric <-> TAP] port bridge")
baseline = harness.wait_ping(c1, "10.1.1.2")
assert baseline["success"] == 100, baseline["raw"]
baseline6 = harness.wait_ping(c1, R2_IP6)
assert baseline6["success"] == 100, baseline6["raw"]
# L2-anchor spec §E.2: with the guests shut the anchors and the
# per-link bridge stay silent for 5 s (these anchors are the `tap
# create` persistent TAPs, one per Ethernet bay/unit).
if harness.l2only_supported():
for console in (c1, c2):
_eth_shutdown(console, shut=True)
harness.assert_idle_silence(a1, a2, bridge)
for console in (c1, c2):
_eth_shutdown(console, shut=False)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
else:
print(".. uBridge without link l2only: skipping the §E.2 assertion")
# delay 100: netem on both anchors (each impairs one direction),
# one-way ~100 ms => RTT grows by ~200 ms
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
delayed = harness.wait_ping(c1, "10.1.1.2")
assert delayed["success"] == 100, delayed["raw"]
assert delayed["avg"] >= 150, (baseline, delayed)
delayed6 = harness.wait_ping(c1, R2_IP6)
assert delayed6["success"] == 100, delayed6["raw"]
assert delayed6["avg"] >= 150, (baseline6, delayed6)
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1)
fast = harness.wait_ping(c1, "10.1.1.2")
assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast
fast6 = harness.wait_ping(c1, R2_IP6)
assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6
# Classifier spot check on a TAP anchor (the Docker suite runs the
# full matrix on veth host ends): cls_bpf match-drop and the eBPF
# stateful classifier attach to a tun/tap anchor the same way.
tc_caps = (caps or {}).get("ubridge_tc") or {}
if tc_caps.get("cbpf"):
print(".. bpf 'icmp' drops v4 ICMP only on the TAP anchor")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"bpf": ["icmp"]}})
assert "clsact" in harness.qdiscs(a1) and "clsact" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
blocked = harness.ping(c1, "10.1.1.2", repeat=4)
assert blocked["success"] == 0, blocked["raw"]
v6_alive = harness.wait_ping(c1, R2_IP6)
assert v6_alive["success"] == 100, v6_alive["raw"]
print(".. bpf 'icmp6' drops v6 ICMP only on the TAP anchor")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"bpf": ["icmp6"]}})
v4_alive = harness.wait_ping(c1, "10.1.1.2")
assert v4_alive["success"] == 100, v4_alive["raw"]
blocked6 = harness.ping(c1, R2_IP6, repeat=4)
assert blocked6["success"] == 0, blocked6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert "clsact" not in harness.qdiscs(a1), harness.qdiscs(a1)
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
assert harness.wait_ping(c1, R2_IP6)["success"] == 100
else:
print(".. uBridge reports no cbpf: skipping the bpf check")
modes = tc_caps.get("ebpf_modes") or []
if "nth" in modes:
print(".. frequency_drop 3 (eBPF nth) on the TAP anchor")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"frequency_drop": [3]}})
nth = harness.ping(c1, "10.1.1.2", repeat=9)
loss = 100 - nth["success"]
print(f".. {loss} % round-trip loss")
# the kernel counts per direction: 1 - (2/3)^2 = 55.6 % round trip
assert 20 <= loss <= 90, nth["raw"]
nth6 = harness.ping(c1, R2_IP6, repeat=9)
loss6 = 100 - nth6["success"]
print(f".. {loss6} % round-trip loss (v6)")
assert 20 <= loss6 <= 90, nth6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
assert harness.wait_ping(c1, R2_IP6)["success"] == 100
else:
print(".. uBridge reports no eBPF nth: skipping the frequency_drop check")
# suspend: anchor admin-down (the port bridge's TAP writes fail EIO,
# nothing comes back); resume restores
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True})
assert not harness.tap_up(a1)
dead = harness.ping(c1, "10.1.1.2", repeat=3)
assert dead["success"] == 0, dead["raw"]
dead6 = harness.ping(c1, R2_IP6, repeat=3)
assert dead6["success"] == 0, dead6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False})
assert harness.tap_up(a1)
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
assert harness.wait_ping(c1, R2_IP6)["success"] == 100
# capture: AF_PACKET on the anchor writes a real pcap — both
# families inside the window
capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"})
harness.ping(c1, "10.1.1.2")
harness.ping(c1, R2_IP6)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop")
path = capture["capture_file_path"]
count, ethertypes = harness.pcap_records(path)
assert count >= 8, f"{path}: {count} records"
assert "0800" in ethertypes, (path, ethertypes)
assert "86dd" in ethertypes, (path, ethertypes)
# serial link between the same nodes: per-port exclusion keeps it on
# the relay, where the real traffic still crosses (IOU-specific —
# serial bays never anchor)
serial = compute.create_link(pid, (n1_id, *SERIAL), (n2_id, *SERIAL))
assert serial["kernel_datapath"] is False, serial
harness.configure_ios(c1, "R1", serial_ip="10.2.2.1", serial_if="Serial2/0", clock=True)
harness.configure_ios(c2, "R2", serial_ip="10.2.2.2", serial_if="Serial2/0")
relay_ping = harness.wait_ping(c1, "10.2.2.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
compute.call("DELETE", f"/projects/{pid}/links/{serial['link_id']}")
# delete / re-create the kernel link: the port bridge's TAP leg is
# released (stop -> delete_nio_tap), the anchor survives (the node
# owns it), and re-creating swaps the leg back in — the fabric
# socket binding never re-binds across the churn.
compute.call("DELETE", f"/projects/{pid}/links/{lid}")
assert harness.bridge_members(bridge) is None
assert harness.tap_exists(a1) and harness.tap_exists(a2)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
# node stop: uBridge holds the anchor fds, so the IOL bridge goes
# first and the taps with it (a stopped node must not litter the
# host with gi devices); a restart recreates the whole wiring from
# the NIO that outlived it — *including the NIO's filters*, which
# land on the freshly created anchor. The delay filter goes on
# before the stop so the restart has something to restore.
print(".. stopping and restarting router 1 with a delay filter applied")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}})
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop")
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
# the per-link bridge lost this end's port; it may be gone entirely
# (both endpoints delete it, last one wins)
assert harness.bridge_members(bridge) in (None, [a2]), harness.bridge_members(bridge)
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
c1.close()
c1 = _boot(server, pid, n1_id)
assert harness.tap_exists(a1)
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
assert harness.wait_until(lambda: "netem" in harness.qdiscs(a1), timeout=15), harness.qdiscs(a1)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6)
survivor = harness.wait_ping(c1, "10.1.1.2", attempts=5)
assert survivor["success"] == 100, survivor["raw"]
assert survivor["avg"] >= 150, (baseline, survivor)
survivor6 = harness.wait_ping(c1, R2_IP6, attempts=5)
assert survivor6["success"] == 100, survivor6["raw"]
assert survivor6["avg"] >= 150, (baseline6, survivor6)
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
cleared = harness.ping(c1, "10.1.1.2", repeat=3)
assert cleared["success"] == 100 and cleared["avg"] < harness.FAST_RTT_MS, (baseline, cleared)
cleared6 = harness.ping(c1, R2_IP6, repeat=3)
assert cleared6["success"] == 100 and cleared6["avg"] < harness.FAST_RTT_MS, (baseline6, cleared6)
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# deleting the project closed the nodes: anchors and bridges go with them
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), (
a1,
a2,
)
def test_iou_relay_control():
"""
Negative control: the same topology with ``enable_kernel_datapath =
false`` (an isolated instance) wires nothing into the kernel — no
per-link bridge, no enslavement — and still pings over the fabric
socket ↔ UDP relay. The relay's filters ride the IOU-specific engine:
the IOL port's userspace filter list (``iol_bridge add_packet_filter``),
walked by both the fabric → wire and wire → fabric paths.
Anchors DO exist here (an IOU node creates its port TAPs at start
whenever the uBridge can terminate an IOL port on one); the server
configuration only decides whether links are attached to them (the same
semantics as QEMU's and Dynamips' anchors).
"""
server = harness.live_server(kernel=False)
compute = server.compute
image = _pick_image(server)
project = compute.create_project("iou-relay-e2e")
pid = project["project_id"]
a1 = None
try:
n1 = compute.create_iou_node(pid, "E2E-R1", image)
n2 = compute.create_iou_node(pid, "E2E-R2", image)
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.iou_anchor_name(n1_id, *ETH)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
assert link["kernel_datapath"] is False, link
print(".. starting routers and waiting for IOS boots (relay)")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start")
c1 = _boot(server, pid, n1_id)
c2 = _boot(server, pid, n2_id)
# no kernel wiring: the anchor (if born) is nobody's bridge port
assert harness.bridge_members(harness.link_bridge_name(link["link_id"])) is None
if harness.tap_exists(a1):
assert not harness.tap_up(a1), a1
if harness.l2only_supported():
# the L2 hardening is a creation-time property, not a
# datapath one: relay anchors are pure L2 too
harness.assert_pure_l2(a1)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0", eth_ipv6=R2_IP6)
relay_ping = harness.wait_ping(c1, "10.1.1.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
relay_ping6 = harness.wait_ping(c1, R2_IP6)
assert relay_ping6["success"] == 100, relay_ping6["raw"]
# The relay link's filters really shape the wire on the IOU engine
# too. delay 100: one delay line per direction (the port's IOL and
# NIO sides are separate lines), so the RTT grows by ~200 ms.
lid = link["link_id"]
offered = {entry["type"] for entry in compute.call("GET", f"/projects/{pid}/links/{lid}/available_filters")}
assert "delay" in offered and "frequency_drop" in offered, offered
print(".. iol_bridge relay filter: delay 100 ms (per-direction delay lines, RTT +~200 ms)")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}})
delayed = harness.ping(c1, "10.1.1.2", repeat=5)
assert delayed["success"] == 100, delayed["raw"]
assert delayed["avg"] >= 150, (relay_ping, delayed)
delayed6 = harness.ping(c1, R2_IP6, repeat=5)
assert delayed6["success"] == 100, delayed6["raw"]
assert delayed6["avg"] >= 150, (relay_ping6, delayed6)
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
fast = harness.ping(c1, "10.1.1.2", repeat=3)
assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, (relay_ping, fast)
fast6 = harness.ping(c1, R2_IP6, repeat=3)
assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, (relay_ping6, fast6)
# frequency_drop shares one counter between the port's two
# directions: on an alternating ping stream one whole direction dies
# (same semantics as the plain relay's bridge filters).
print(".. iol_bridge relay filter: frequency_drop 2 (shared counter, one direction dies)")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"frequency_drop": [2]}})
dropped = harness.ping(c1, "10.1.1.2", repeat=6)
print(f".. success {dropped['success']} %")
assert dropped["success"] <= 50, dropped["raw"]
dropped6 = harness.ping(c1, R2_IP6, repeat=6)
print(f".. success {dropped6['success']} % (v6)")
assert dropped6["success"] <= 50, dropped6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
recovered = harness.wait_ping(c1, "10.1.1.2")
assert recovered["success"] == 100, recovered["raw"]
recovered6 = harness.wait_ping(c1, R2_IP6)
assert recovered6["success"] == 100, recovered6["raw"]
# capture on the relay: iol_bridge start_capture on the port's IOL
# location (the IOU engine's own capture path, distinct from the
# kernel datapath's capture start_kernel on the anchor)
print(".. iol_bridge relay capture: start_capture writes the ICMP exchange")
capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"})
harness.ping(c1, "10.1.1.2", repeat=5)
harness.ping(c1, R2_IP6, repeat=5)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop")
path = capture["capture_file_path"]
count, ethertypes = harness.pcap_records(path)
assert count >= 8, f"{path}: {count} records"
assert "0800" in ethertypes, (path, ethertypes)
assert "86dd" in ethertypes, (path, ethertypes)
# markers on the relay: iol_bridge add_packet_filter ... mark on the
# port's IOL location, signalled over the dedicated marker WS
print(".. iol_bridge relay markers: 5 v4 + 5 v6 ICMP echoes through mark filters")
marker_ws = harness.WebSocketCollector(server, f"/projects/{pid}/notifications/markers/ws")
try:
m = compute.call(
"POST",
f"/projects/{pid}/links/{lid}/markers",
{"name": "m-icmp", "bpf": "icmp", "tag": 4242, "capture_node_id": n1_id},
)
assert m["capture_node_id"] == n1_id and m["enabled"] is True, m
m6 = compute.call(
"POST",
f"/projects/{pid}/links/{lid}/markers",
{"name": "m-icmp6", "bpf": ICMP6_ECHO_BPF, "tag": 4243, "capture_node_id": n1_id},
)
assert m6["capture_node_id"] == n1_id and m6["enabled"] is True, m6
result = harness.ping(c1, "10.1.1.2", repeat=5)
assert result["success"] == 100, result["raw"]
result6 = harness.ping(c1, R2_IP6, repeat=5)
assert result6["success"] == 100, result6["raw"]
harness.wait_until(
lambda: len(_matches(marker_ws, "m-icmp")) >= 10 and len(_matches(marker_ws, "m-icmp6")) >= 10,
timeout=10,
)
# exact counts across both families: neither marker sees the
# other family's frames
icmp = _matches(marker_ws, "m-icmp")
assert len(icmp) == 10, len(icmp) # 5 requests + 5 replies
icmp6 = _matches(marker_ws, "m-icmp6")
assert len(icmp6) == 10, len(icmp6)
assert {e["dir"] for e in icmp} == {"tx", "rx"}, [e["dir"] for e in icmp]
assert {e["dir"] for e in icmp6} == {"tx", "rx"}, [e["dir"] for e in icmp6]
for event in icmp:
assert event["node_id"] == n1_id, event
assert event["link_id"] == lid, event
assert event["tag"] == 4242, event
for event in icmp6:
assert event["node_id"] == n1_id, event
assert event["link_id"] == lid, event
assert event["tag"] == 4243, event
markers_dir = os.path.join(compute.call("GET", f"/projects/{pid}")["path"], "project-files", "markers")
pcap = os.path.join(markers_dir, f"{n1_id}_{lid}_m-icmp.pcap")
pcap6 = os.path.join(markers_dir, f"{n1_id}_{lid}_m-icmp6.pcap")
assert os.path.exists(pcap), os.listdir(markers_dir)
assert os.path.exists(pcap6), os.listdir(markers_dir)
count, ethertypes = harness.pcap_records(pcap)
assert count == 10, count
assert set(ethertypes) == {"0800"}, ethertypes
count6, ethertypes6 = harness.pcap_records(pcap6)
assert count6 == 10, count6
assert set(ethertypes6) == {"86dd"}, ethertypes6
# deleting a marker removes its pcap with it
compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-icmp6")
assert harness.wait_until(lambda: not os.path.exists(pcap6), timeout=10), pcap6
compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-icmp")
assert harness.wait_until(lambda: not os.path.exists(pcap), timeout=10), pcap
finally:
marker_ws.close()
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# deleting the project closed the nodes: the port TAPs go with them
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
+397
View File
@@ -0,0 +1,397 @@
#
# 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/>.
#
"""
Live end-to-end test for traffic-insight markers on the kernel datapath
(pytest marker ``e2e``): two real Alpine containers on a kernel-only link,
markers attached to a veth anchor (``marker add_kernel``) and observed on
the dedicated marker notification WebSocket. Real ICMP proves:
* a marker match arrives as a ``marker.match`` on
``/projects/{id}/notifications/markers/ws`` with the full identity —
filter name, tag, link_id, capture node, timestamp, packet length,
travel direction — and **nothing** on the main project channel (the
whole point of the dedicated stream: matches must not head-of-line-block
topology events);
* the BPF is enforced on the anchor: a sibling marker whose expression
cannot match the traffic stays silent, a directioned marker only sees
its direction (``tx`` = the capture node sending), and the pcap on the
host holds exactly the matched frames;
* pausing a marker silences its signals while keeping its pcap; resuming
restores them — and other markers' matches keep flowing throughout, so
silence is never just "the channel died";
* tag replay serves the paused tag's aggregate timeline off that pcap
(merged frames with src/dst/proto, per-source inventory, display-filter
and link narrowing, the window endpoint) and refuses with 409 while any
marker under the tag is still capturing (skipped when sharkd is absent);
* deleting a marker removes its pcap and silences its signals; deleting
the project leaves no marker state behind.
Everything runs dual-stack: alongside the v4 markers the link carries v6
twin markers (one of them directioned) whose expression matches ICMPv6
echoes only — a bare ``icmp6`` would also match the guests' own DAD/MLD
control frames and break the exact-count asserts. The exact counts across
both families double as the cross-family check: neither family's traffic
reaches the other family's markers.
"""
import json
import os
import shutil
import urllib.error
import urllib.parse
import urllib.request
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# every standard Docker adapter has a single port
ETH = (0, 0)
R1_IP = "10.1.1.1"
R2_IP = "10.1.1.2"
# the dual-stack half: a ULA mirroring the v4 addressing (10.1.1.x -> fd00:1:1::x)
R1_IP6 = "fd00:1:1::1"
R2_IP6 = "fd00:1:1::2"
TAG = 4242
TAG6 = 4243
# busybox `ping` default frames: 14 Ethernet + 20/40 IP + 8 ICMP + 56 data.
PING_FRAME_LEN = 98
PING_FRAME_LEN6 = 118
# exact ICMPv6 echo match: a bare "icmp6" would also match the guests' own
# DAD/MLD/NS/NA control frames and break the exact-count asserts
ICMP6_ECHO_BPF = "icmp6 and (ip6[40] == 128 or ip6[40] == 129)"
def _resolve_container(daemon, node):
"""Fill in the node's container id from the daemon (the controller's
node payload does not carry the compute-side id), by the deterministic
name the server gives the container."""
container_id = daemon.container_id(f"GNS3.{node['name']}.{node['project_id']}")
assert container_id, f"container for {node['name']} not found on the Docker daemon"
node["container_id"] = container_id
return node
def _create_topology(server, pid, image):
compute = server.compute
daemon = harness.DockerDaemon()
nodes = []
for suffix, address in (("1", R1_IP), ("2", R2_IP)):
node = compute.create_docker_node(pid, f"MARKER-E2E-{suffix}", image)
harness.configure_docker_interfaces(node, address)
nodes.append(_resolve_container(daemon, node))
return nodes[0], nodes[1], daemon
def _start_topology(compute, pid, daemon, nodes):
"""Start the containers and wait for init.sh to have applied their v4
addresses (busybox ifup runs a moment after container start); the v6
addresses are brought up by the harness after each start."""
for node in nodes:
compute.call("POST", f"/projects/{pid}/nodes/{node['node_id']}/start")
for node, (address, address6) in zip(nodes, ((R1_IP, R1_IP6), (R2_IP, R2_IP6)), strict=False):
harness.docker_wait_address(daemon, node["container_id"], address)
harness.docker_bring_up_ipv6(daemon, node["container_id"], address6)
harness.docker_wait_address(daemon, node["container_id"], address6)
def _raw(compute, path):
"""GET without raising on an error status: returns ``(status, body)``
(body parsed as JSON when possible, else the raw text). The replay
endpoints are asserted on their status codes (409 gate / 200 data)."""
# S310: the base URL comes from the operator (GNS3_E2E_URL or the
# isolated instance this test just started), not untrusted input
req = urllib.request.Request(compute.url + path, method="GET") # noqa: S310
req.add_header("Authorization", f"Bearer {compute.token}")
try:
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
payload = resp.read()
return resp.status, json.loads(payload) if payload else None
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace")
def _matches(marker_ws, marker_name):
return [e for e in marker_ws.events("marker.match") if e["filter"] == marker_name]
def _close(*collectors):
for collector in collectors:
if collector is not None:
collector.close()
def test_marker_kernel_datapath():
server = harness.live_server(kernel=True)
compute = server.compute
image = harness.ensure_docker_image(server)
project = compute.create_project("marker-e2e")
pid = project["project_id"]
marker_ws = main_ws = None
try:
n1, n2, daemon = _create_topology(server, pid, image)
n1_id, n2_id = n1["node_id"], n2["node_id"]
c1 = n1["container_id"]
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
assert link["kernel_datapath"] is True, link
_start_topology(compute, pid, daemon, (n1, n2))
baseline = harness.docker_wait_ping(daemon, c1, R2_IP)
assert baseline["loss"] == 0, baseline["raw"]
baseline6 = harness.docker_wait_ping(daemon, c1, R2_IP6)
assert baseline6["loss"] == 0, baseline6["raw"]
a1 = harness.docker_anchor_name(n1_id, *ETH)
markers_dir = os.path.join(compute.call("GET", f"/projects/{pid}")["path"], "project-files", "markers")
# Both channels open before the markers exist so no match is missed.
marker_ws = harness.WebSocketCollector(server, f"/projects/{pid}/notifications/markers/ws")
main_ws = harness.WebSocketCollector(server, f"/projects/{pid}/notifications/ws")
print(".. attaching markers to the kernel link")
m = compute.call(
"POST",
f"/projects/{pid}/links/{lid}/markers",
{"name": "m-icmp", "bpf": "icmp", "tag": TAG, "capture_node_id": n1_id},
)
assert m["capture_node_id"] == n1_id and m["tag"] == TAG and m["enabled"] is True, m
# a sibling whose expression cannot match ICMP traffic — it must stay
# silent while the others fire (the BPF is the filter, not the marker)
compute.call(
"POST", f"/projects/{pid}/links/{lid}/markers", {"name": "m-tcp", "bpf": "tcp port 9999", "tag": 4343}
)
# direction is relative to the capture node: node1 sending only
m_tx = compute.call(
"POST",
f"/projects/{pid}/links/{lid}/markers",
{"name": "m-tx", "bpf": "icmp", "direction": "tx", "tag": 4344, "capture_node_id": n1_id},
)
assert m_tx["direction"] == "tx", m_tx
# the dual-stack twin: a v6 marker with an echo-specific expression
# (a bare "icmp6" would also match the guests' own NS/MLD/RA frames)
m6 = compute.call(
"POST",
f"/projects/{pid}/links/{lid}/markers",
{"name": "m-icmp6", "bpf": ICMP6_ECHO_BPF, "tag": TAG6, "capture_node_id": n1_id},
)
assert m6["capture_node_id"] == n1_id and m6["tag"] == TAG6 and m6["enabled"] is True, m6
m_tx6 = compute.call(
"POST",
f"/projects/{pid}/links/{lid}/markers",
{"name": "m-tx6", "bpf": ICMP6_ECHO_BPF, "direction": "tx", "tag": 4345, "capture_node_id": n1_id},
)
assert m_tx6["direction"] == "tx", m_tx6
print(".. sending 5 v4 + 5 v6 ICMP echoes and collecting marker.match signals")
result = harness.docker_ping(daemon, c1, R2_IP, count=5)
assert result["loss"] == 0, result["raw"]
result6 = harness.docker_ping(daemon, c1, R2_IP6, count=5)
assert result6["loss"] == 0, result6["raw"]
harness.wait_until(
lambda: len(_matches(marker_ws, "m-icmp")) >= 10 and len(_matches(marker_ws, "m-icmp6")) >= 10, timeout=10
)
# exact counts across both families: each marker sees its own
# family's 5 requests + 5 replies and nothing of the other's
icmp = _matches(marker_ws, "m-icmp")
assert len(icmp) == 10, len(icmp) # 5 requests (tx) + 5 replies (rx)
icmp6 = _matches(marker_ws, "m-icmp6")
assert len(icmp6) == 10, len(icmp6)
assert not _matches(marker_ws, "m-tcp"), "a non-matching BPF must stay silent"
assert len(_matches(marker_ws, "m-tx")) == 5, "the tx direction sees requests only"
assert len(_matches(marker_ws, "m-tx6")) == 5, "the v6 tx direction sees requests only"
for event in icmp:
assert event["node_id"] == n1_id, event
assert event["link_id"] == lid, event
assert event["tag"] == TAG, event
assert event["ts"] > 0, event
assert event["len"] == PING_FRAME_LEN, event
for event in icmp6:
assert event["node_id"] == n1_id, event
assert event["link_id"] == lid, event
assert event["tag"] == TAG6, event
assert event["ts"] > 0, event
assert event["len"] == PING_FRAME_LEN6, event
assert {e["dir"] for e in icmp} == {"tx", "rx"}, [e["dir"] for e in icmp]
assert {e["dir"] for e in icmp6} == {"tx", "rx"}, [e["dir"] for e in icmp6]
assert {e["dir"] for e in _matches(marker_ws, "m-tx")} == {"tx"}
assert {e["dir"] for e in _matches(marker_ws, "m-tx6")} == {"tx"}
# The dedicated channel carries matches; the main project stream must
# not (and must still be alive — its link.updated proves the socket).
assert main_ws.messages("marker.match") == [], main_ws.messages("marker.match")[:3]
assert main_ws.messages("link.updated"), "the main channel is not receiving at all"
# Host pcaps: exactly the matched frames, off the anchor
pcap = os.path.join(markers_dir, f"{n1_id}_{lid}_m-icmp.pcap")
pcap6 = os.path.join(markers_dir, f"{n1_id}_{lid}_m-icmp6.pcap")
assert os.path.exists(pcap), os.listdir(markers_dir)
assert os.path.exists(pcap6), os.listdir(markers_dir)
count, ethertypes = harness.pcap_records(pcap)
assert count == 10, count
assert set(ethertypes) == {"0800"}, ethertypes
count6, ethertypes6 = harness.pcap_records(pcap6)
assert count6 == 10, count6
assert set(ethertypes6) == {"86dd"}, ethertypes6
# pause: this marker's signals stop, its pcap survives, and the
# sibling marker's matches keep flowing (the channel is alive —
# silence is the pause, not a dead stream). Both families' streams
# run through the paused window: the count stays frozen for either.
print(".. pausing m-icmp: its signals stop while the siblings keep firing")
compute.call("PUT", f"/projects/{pid}/links/{lid}/markers/m-icmp", {"enabled": False})
harness.docker_ping(daemon, c1, R2_IP, count=3)
harness.docker_ping(daemon, c1, R2_IP6, count=3)
harness.wait_until(
lambda: len(_matches(marker_ws, "m-tx")) >= 8 and len(_matches(marker_ws, "m-icmp6")) >= 16, timeout=10
)
assert len(_matches(marker_ws, "m-icmp")) == 10, "a paused marker must not signal"
assert len(_matches(marker_ws, "m-icmp6")) == 16, "the 3 v6 pings must reach the v6 marker"
assert os.path.exists(pcap)
# resume: signals come back, pcap keeps appending
print(".. resuming m-icmp")
compute.call("PUT", f"/projects/{pid}/links/{lid}/markers/m-icmp", {"enabled": True})
harness.docker_ping(daemon, c1, R2_IP, count=2)
harness.docker_ping(daemon, c1, R2_IP6, count=2)
harness.wait_until(
lambda: len(_matches(marker_ws, "m-icmp")) >= 14 and len(_matches(marker_ws, "m-icmp6")) >= 20, timeout=10
)
assert len(_matches(marker_ws, "m-icmp")) == 14
assert len(_matches(marker_ws, "m-icmp6")) == 20
assert harness.pcap_records(pcap)[0] == 14
assert harness.pcap_records(pcap6)[0] == 20
if shutil.which("sharkd") is None:
print(".. sharkd is not installed: skipping the tag replay section")
else:
# Tag replay reads the pcaps at rest: pause every marker of the
# tag. m-icmp is the only one carrying TAG.
print(f".. pausing m-icmp and replaying tag {TAG}")
compute.call("PUT", f"/projects/{pid}/links/{lid}/markers/m-icmp", {"enabled": False})
status, timeline = _raw(compute, f"/projects/{pid}/markers/tags/{TAG}/replay/range")
assert status == 200, timeline
assert timeline["tag"] == TAG
assert timeline["frame_count"] == 14, timeline["frame_count"]
assert timeline["start"] and timeline["end"], timeline
assert timeline["sources"] == [
{
"node_id": n1_id,
"link_id": lid,
"marker": "m-icmp",
"data_link_type": "DLT_EN10MB",
"count": 14,
}
], timeline["sources"]
first = timeline["frames"][0]
assert first["src"] == R1_IP and first["dst"] == R2_IP, first
assert "ICMP" in (first["proto"] or ""), first
# display filter: echo requests only (5 + 2 of the 14)
query = urllib.parse.urlencode({"filter": "icmp.type == 8"})
status, filtered = _raw(compute, f"/projects/{pid}/markers/tags/{TAG}/replay/range?{query}")
assert status == 200, filtered
assert filtered["frame_count"] == 7, filtered["frame_count"]
# unknown link: an empty stream, same shape as a zero-match filter
query = urllib.parse.urlencode({"link": "00000000-0000-0000-0000-000000000000"})
status, empty = _raw(compute, f"/projects/{pid}/markers/tags/{TAG}/replay/range?{query}")
assert status == 200 and empty["frame_count"] == 0 and empty["frames"] == [], empty
# window endpoint: the whole capture inside one window
query = urllib.parse.urlencode({"ts": timeline["start"], "window_ms": 120000, "limit": 1000})
status, window = _raw(compute, f"/projects/{pid}/markers/tags/{TAG}/replay/frames?{query}")
assert status == 200 and len(window["frames"]) == 14, window
# the gate: replay refuses while the tag is still capturing
compute.call("PUT", f"/projects/{pid}/links/{lid}/markers/m-icmp", {"enabled": True})
status, gate = _raw(compute, f"/projects/{pid}/markers/tags/{TAG}/replay/range")
assert status == 409, (status, gate)
# the same replay over the v6 tag: identical mechanics, v6 decode
print(f".. pausing m-icmp6 and replaying tag {TAG6}")
compute.call("PUT", f"/projects/{pid}/links/{lid}/markers/m-icmp6", {"enabled": False})
status, timeline6 = _raw(compute, f"/projects/{pid}/markers/tags/{TAG6}/replay/range")
assert status == 200, timeline6
assert timeline6["tag"] == TAG6
assert timeline6["frame_count"] == 20, timeline6["frame_count"]
assert timeline6["start"] and timeline6["end"], timeline6
assert timeline6["sources"] == [
{
"node_id": n1_id,
"link_id": lid,
"marker": "m-icmp6",
"data_link_type": "DLT_EN10MB",
"count": 20,
}
], timeline6["sources"]
first6 = timeline6["frames"][0]
assert first6["src"] == R1_IP6 and first6["dst"] == R2_IP6, first6
assert "ICMPv6" in (first6["proto"] or ""), first6
# display filter: echo requests only (5 + 3 + 2 of the 20)
query = urllib.parse.urlencode({"filter": "icmpv6.type == 128"})
status, filtered6 = _raw(compute, f"/projects/{pid}/markers/tags/{TAG6}/replay/range?{query}")
assert status == 200, filtered6
assert filtered6["frame_count"] == 10, filtered6["frame_count"]
# unknown link: an empty stream, same shape as a zero-match filter
query = urllib.parse.urlencode({"link": "00000000-0000-0000-0000-000000000000"})
status, empty6 = _raw(compute, f"/projects/{pid}/markers/tags/{TAG6}/replay/range?{query}")
assert status == 200 and empty6["frame_count"] == 0 and empty6["frames"] == [], empty6
# window endpoint: the whole capture inside one window
query = urllib.parse.urlencode({"ts": timeline6["start"], "window_ms": 120000, "limit": 1000})
status, window6 = _raw(compute, f"/projects/{pid}/markers/tags/{TAG6}/replay/frames?{query}")
assert status == 200 and len(window6["frames"]) == 20, window6
# the gate: replay refuses while the tag is still capturing
compute.call("PUT", f"/projects/{pid}/links/{lid}/markers/m-icmp6", {"enabled": True})
status, gate6 = _raw(compute, f"/projects/{pid}/markers/tags/{TAG6}/replay/range")
assert status == 409, (status, gate6)
# delete: the pcaps go with their markers and the signals stop
print(".. deleting m-icmp and m-icmp6: pcaps and signals go with them")
compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-icmp")
assert harness.wait_until(lambda: not os.path.exists(pcap), timeout=10), pcap
compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-icmp6")
assert harness.wait_until(lambda: not os.path.exists(pcap6), timeout=10), pcap6
harness.docker_ping(daemon, c1, R2_IP, count=2)
harness.docker_ping(daemon, c1, R2_IP6, count=2)
# both still-attached directioned siblings prove the streams crossed
# and their events were delivered before the deleted markers are
# asserted frozen
harness.wait_until(
lambda: len(_matches(marker_ws, "m-tx")) >= 12 and len(_matches(marker_ws, "m-tx6")) >= 12, timeout=10
)
assert len(_matches(marker_ws, "m-icmp")) == 14, "a deleted marker must not signal"
assert len(_matches(marker_ws, "m-icmp6")) == 20, "a deleted marker must not signal"
compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-tcp")
compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-tx")
compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-tx6")
assert not os.listdir(markers_dir), os.listdir(markers_dir)
except BaseException:
harness.release(server, pid, failed=True)
_close(marker_ws, main_ws)
raise
_close(marker_ws, main_ws)
harness.release(server, pid, failed=False)
+349
View File
@@ -0,0 +1,349 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
# This program is free software and is redistributed under the same terms as gns3-server.
# See the LICENSE file for licensing information.
#
"""
Live end-to-end test for the QEMU node's kernel datapath (pytest marker
``e2e``): two real IOSv routers (a real qcow2 image run by the local qemu
binary, the real IOS CLI behind the server's telnet console), driven through
the controller REST API. QEMU is the node type whose guest leg *is* the
anchor: uBridge creates one persistent TAP per adapter before launch and
hands it to this user, and QEMU opens it by ifname as its netdev
(``-netdev tap,ifname=...,script=no,downscript=no``) — no userspace hop, no
port bridge, the anchor is the NIC's host side.
* two QEMU nodes on one compute report ``kernel_datapath`` when the
compute's uBridge can create persistent TAPs (``ubridge_tap``); the
anchors (``gq`` TAPs, one per adapter) exist from node start, before any
link — and the per-link bridge enslaves exactly the two of them;
* real ICMP crosses the [QEMU netdev ↔ TAP] anchor;
* the L2-anchor spec §E.2 silence window with the guests shut (the anchors
here are QEMU's own netdevs);
* a ``delay 100`` filter lands as netem on both anchors, RTT grows ~200 ms;
* suspend admin-downs the anchor (the e1000 loses carrier) and kills the
traffic; resume restores;
* a capture on the kernel link writes a real pcap;
* link delete releases the anchor from the bridge and it survives (the
running VM holds it open) — re-creating the link re-enslaves it and the
traffic returns;
* node stop deletes every anchor of the node together with the orphan
bridges; a restart recreates the TAPs and rewires the link from the NIO;
* deleting the project leaves no anchors and no bridges behind.
``test_qemu_relay_control`` is the negative control on an isolated
relay-configured instance: the anchors still exist (a QEMU node creates its
adapter TAPs at start whenever uBridge can), but nothing is enslaved — the
relay attaches the anchor itself as an AF_PACKET endpoint, so it stays up
and carrying while the link rides the uBridge relay. This differs from
IOU's relay, where the fabric socket carries the traffic and the anchors
sit idle: QEMU has no other leg, the TAP is the netdev either way.
Every checkpoint runs dual-stack: the IOSv guests carry a ULA v6 address
next to their v4 one and each checkpoint pings both families.
"""
import re
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# One NIC per adapter, port number always 0: adapter 0 is GigabitEthernet0/0.
ETH = (0, 0)
# IOSv boots a real IOS from an emulated disk — slower than IOU (the boot
# wait is event-driven; this is only the failure cap).
BOOT_TIMEOUT = 480
# L3 images only (an IOSv-L2 image switchports its interfaces). The first
# is the image on this host the kernel datapath was validated with.
IMAGE_PREFERENCE = ("vios-adventerprisek9-m.spa.159-3.m12.qcow2",)
GUEST_ETH_IF = "GigabitEthernet0/0"
# the dual-stack half: a ULA mirroring 10.1.1.x
R1_IP6 = "fd00:1:1::1"
R2_IP6 = "fd00:1:1::2"
def _pick_image(server):
images = server.compute.qemu_images()
for name in IMAGE_PREFERENCE:
if name in images:
return name
l3 = next(
(name for name in images if "vios" in name.lower() and "l2" not in name.lower() and name.endswith(".qcow2")),
None,
)
if not l3:
pytest.skip(f"no L3 IOSv qcow2 available on this server (images: {images[:5]})")
return l3
def _boot(server, project_id, node_id, timeout=BOOT_TIMEOUT):
console = harness.ios_console(server, project_id, node_id)
console.boot_wait(timeout=timeout)
return console
def _eth_shutdown(console, shut):
"""Shut/unshut the guest's GigabitEthernet0/0 — the only legitimate
speaker on the link segment (IOSv's own chatter) — for §E.2's silence
window; the address stays configured and answers once it is back."""
console.run("conf t")
console.run(f"interface {GUEST_ETH_IF}")
console.run("shutdown" if shut else "no shutdown")
console.run("end")
def test_qemu_kernel_datapath():
server = harness.live_server(kernel=True)
compute = server.compute
caps = compute.capabilities()
if caps.get("ubridge_tap") is not True:
pytest.skip(
f"this compute's uBridge cannot create the QEMU adapter TAPs (ubridge_tap={caps.get('ubridge_tap')})"
)
image = _pick_image(server)
project = compute.create_project("qemu-e2e")
pid = project["project_id"]
a1 = a2 = None
try:
n1 = compute.create_qemu_node(pid, "E2E-R1", image)
n2 = compute.create_qemu_node(pid, "E2E-R2", image)
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.qemu_anchor_name(n1_id, *ETH)
a2 = harness.qemu_anchor_name(n2_id, *ETH)
# The link is created while both nodes are stopped: the kernel NIO
# is bound with no anchors in existence yet and wired by node start
# (the deferred-wiring path).
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert not harness.tap_exists(a1), "anchors must not exist before start"
print(f".. starting IOSv routers on {image} (a real IOS boot — the slow leg of this scenario)")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start")
c1 = _boot(server, pid, n1_id)
c2 = _boot(server, pid, n2_id)
# NetworkManager can release an NM-managed TAP from its bridge after
# (re-)enslavement (docs/bugs/networkmanager-tap-release.md); take the
# anchors out of its reach for the rest of the run (the restart below
# re-attaches them under fresh devices).
harness.unmanage_from_networkmanager(a1, a2, harness.qemu_anchor_name(n1_id, 1))
# Kernel objects: one persistent TAP per adapter (both adapters of
# router 1, the addressed one of router 2), and the per-link bridge
# has exactly the two addressed ports.
for adapter in range(2):
name = harness.qemu_anchor_name(n1_id, adapter)
assert harness.tap_exists(name), name
assert harness.tap_exists(a2), a2
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
# L2-anchor spec §E.1: anchors and the per-link bridge carry no L3
# identity (skipped on a uBridge without `link l2only`), and both
# ports are FORWARDING — the silent-failure guard.
if harness.l2only_supported():
harness.assert_pure_l2(a1)
harness.assert_pure_l2(a2)
harness.assert_pure_l2(bridge)
else:
print(".. uBridge without link l2only: skipping the §E.1 assertions")
harness.assert_forwarding(a1)
harness.assert_forwarding(a2)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if=GUEST_ETH_IF, eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if=GUEST_ETH_IF, eth_ipv6=R2_IP6)
out = c1.run("show ip int brief")
assert re.search(r"GigabitEthernet0/0\s+10\.1\.1\.1\s+\S+\s+\S+\s+up\s+up", out), out
out = c1.run("show ipv6 interface brief")
assert R1_IP6.upper() in out.upper(), out # IOS prints v6 upper-case
print(".. both routers configured, pinging through the anchor TAPs")
baseline = harness.wait_ping(c1, "10.1.1.2")
assert baseline["success"] == 100, baseline["raw"]
baseline6 = harness.wait_ping(c1, R2_IP6)
assert baseline6["success"] == 100, baseline6["raw"]
# L2-anchor spec §E.2: with the guests shut the anchors (QEMU's own
# netdevs) and the per-link bridge stay silent for 5 s.
if harness.l2only_supported():
for console in (c1, c2):
_eth_shutdown(console, shut=True)
harness.assert_idle_silence(a1, a2, bridge)
for console in (c1, c2):
_eth_shutdown(console, shut=False)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
else:
print(".. uBridge without link l2only: skipping the §E.2 assertion")
# delay 100: netem on both anchors (each impairs one direction),
# one-way ~100 ms => RTT grows by ~200 ms
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
delayed = harness.wait_ping(c1, "10.1.1.2")
assert delayed["success"] == 100, delayed["raw"]
assert delayed["avg"] >= 150, (baseline, delayed)
delayed6 = harness.wait_ping(c1, R2_IP6)
assert delayed6["success"] == 100, delayed6["raw"]
assert delayed6["avg"] >= 150, (baseline6, delayed6)
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1)
fast = harness.wait_ping(c1, "10.1.1.2")
assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast
fast6 = harness.wait_ping(c1, R2_IP6)
assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6
# suspend: anchor admin-down — the e1000 loses carrier, the bridge
# port is disabled and nothing crosses; resume restores
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True})
assert not harness.tap_up(a1)
dead = harness.ping(c1, "10.1.1.2", repeat=3)
assert dead["success"] == 0, dead["raw"]
dead6 = harness.ping(c1, R2_IP6, repeat=3)
assert dead6["success"] == 0, dead6["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False})
assert harness.tap_up(a1)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
# capture: on the anchor, a real pcap — both families inside the window
capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"})
harness.ping(c1, "10.1.1.2")
harness.ping(c1, R2_IP6)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop")
path = capture["capture_file_path"]
count, ethertypes = harness.pcap_records(path)
assert count >= 8, f"{path}: {count} records"
assert "0800" in ethertypes, (path, ethertypes)
assert "86dd" in ethertypes, (path, ethertypes)
# delete / re-create the kernel link: the anchor leaves the bridge
# but survives (the running VM holds it open — this is QEMU's own
# netdev), and re-creating re-enslaves it without touching the VM.
compute.call("DELETE", f"/projects/{pid}/links/{lid}")
assert harness.bridge_members(bridge) is None
assert harness.tap_exists(a1) and harness.tap_exists(a2)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
# node stop: QEMU exits and uBridge deletes every anchor of the
# node together with the orphan bridges (a stopped node must not
# litter the host with gq devices); a restart recreates the TAPs
# and rewires the link from the NIO that outlived it.
print(".. stopping and restarting router 1")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop")
second = harness.qemu_anchor_name(n1_id, 1)
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(second), timeout=15), (
a1,
second,
)
# the per-link bridge lost this end's port; it may be gone entirely
# (both endpoints delete it, last one wins)
assert harness.bridge_members(bridge) in (None, [a2]), harness.bridge_members(bridge)
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
c1.close()
c1 = _boot(server, pid, n1_id)
assert harness.tap_exists(a1)
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if=GUEST_ETH_IF, eth_ipv6=R1_IP6)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# deleting the project closed the nodes: anchors and bridges go with them
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), (
a1,
a2,
)
def test_qemu_relay_control():
"""
Negative control: the same topology with ``enable_kernel_datapath =
false`` (an isolated instance) wires nothing into the kernel — no
per-link bridge, no enslavement — and still pings.
The anchors DO exist and carry: a QEMU node creates its adapter TAPs at
start whenever uBridge can (the netdev needs them), and on the relay
the anchor itself becomes the uBridge relay's AF_PACKET endpoint — up
and forwarding into the relay bridge. The server configuration only
decides whether a *link* enslaves them.
"""
server = harness.live_server(kernel=False)
compute = server.compute
caps = compute.capabilities()
if caps.get("ubridge_tap") is not True:
pytest.skip(
f"this compute's uBridge cannot create the QEMU adapter TAPs (ubridge_tap={caps.get('ubridge_tap')})"
)
image = _pick_image(server)
project = compute.create_project("qemu-relay-e2e")
pid = project["project_id"]
a1 = None
try:
n1 = compute.create_qemu_node(pid, "E2E-R1", image)
n2 = compute.create_qemu_node(pid, "E2E-R2", image)
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.qemu_anchor_name(n1_id, *ETH)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
assert link["kernel_datapath"] is False, link
print(".. starting IOSv routers and waiting for IOS boots (relay)")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start")
c1 = _boot(server, pid, n1_id)
c2 = _boot(server, pid, n2_id)
# no kernel wiring: nobody enslaved the anchors, the per-link
# bridge does not exist — but the anchor is up and carrying as the
# relay's AF_PACKET endpoint
assert harness.bridge_members(harness.link_bridge_name(link["link_id"])) is None
assert harness.tap_exists(a1), a1
assert harness.tap_up(a1), a1
if harness.l2only_supported():
# the L2 hardening is a creation-time property, not a datapath
# one: relay anchors are pure L2 too
harness.assert_pure_l2(a1)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if=GUEST_ETH_IF, eth_ipv6=R1_IP6)
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if=GUEST_ETH_IF, eth_ipv6=R2_IP6)
relay_ping = harness.wait_ping(c1, "10.1.1.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
relay_ping6 = harness.wait_ping(c1, R2_IP6)
assert relay_ping6["success"] == 100, relay_ping6["raw"]
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# deleting the project closed the nodes: the adapter TAPs go with them
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
+7
View File
@@ -1,2 +1,9 @@
[pytest]
asyncio_default_fixture_loop_scope=function
; Live end-to-end tests (tests/e2e, see its README) need privileges, real
; images and emulator binaries — they never run in a normal test run. Run
; them explicitly with: pytest -m e2e tests/e2e
markers =
e2e: live end-to-end tests (real server, kernel interfaces, emulators)
addopts = -m "not e2e"
+87
View File
@@ -0,0 +1,87 @@
#
# 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 kernel-datapath anchor naming contract (shared by the compute classes
that create anchors and the controller that names a peer's anchor for an
Ethernet-switch link), plus the cascade veth naming for switch-to-switch
links.
"""
from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name, kernel_anchor_type, kernel_cascade_names
NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f"
LINK_ID = "1a2b3c4d-5e6f-4a5b-8c9d-0e1f2a3b4c5d"
def test_every_node_type_has_its_own_prefix():
assert kernel_anchor_name("docker", NODE_ID, 0, 0) == "gv00010203e0p0"
assert kernel_anchor_name("qemu", NODE_ID, 1, 2) == "gq00010203e1p2"
assert kernel_anchor_name("iou", NODE_ID, 0, 3) == "gi00010203e0p3"
assert kernel_anchor_name("dynamips", NODE_ID, 6, 48) == "gd00010203e6p48"
assert kernel_anchor_name("iol_docker", NODE_ID, 0, 0) == "gx00010203e0p0"
def test_anchor_type_keys_iol_runner_containers_apart():
"""
IOL runner containers are Docker nodes whose anchors are persistent TAPs,
not veth host ends — their own key keeps the gv namespace free of TAPs.
The marker check must ignore a mere mention of the knob and survive a
multi-line environment.
"""
assert kernel_anchor_type("docker", "GNS3_IOL_RUNNER=1") == "iol_docker"
assert kernel_anchor_type("docker", "GNS3_IOL_STARTUP_CONFIG=cfg.txt\nGNS3_IOL_RUNNER=1") == "iol_docker"
assert kernel_anchor_type("docker", "GNS3_IOL_RUNNER=1,") == "iol_docker"
assert kernel_anchor_type("docker") == "docker"
assert kernel_anchor_type("docker", "GNS3_UNIX_SOCKET_NIO=1") == "docker"
# a mid-line mention is not the marker (mirrors _select_node_class)
assert kernel_anchor_type("docker", "NOTE=GNS3_IOL_RUNNER=1") == "docker"
assert kernel_anchor_type("qemu") == "qemu"
def test_unknown_node_types_have_no_anchor():
assert kernel_anchor_name("ethernet_switch", NODE_ID, 0, 0) is None
assert kernel_anchor_name("vpcs", NODE_ID, 0, 0) is None
def test_names_fit_ifnamsiz():
# worst case: WIC port numbers reach 48 with the longest prefix
assert len(kernel_anchor_name("dynamips", NODE_ID, 6, 48)) <= 15
def test_suffix_is_stable_across_the_node_type():
assert anchor_suffix(NODE_ID, 1, 2) == "00010203e1p2"
assert kernel_anchor_name("docker", NODE_ID, 1, 2) == "gv" + anchor_suffix(NODE_ID, 1, 2)
def test_cascade_names_are_a_pair_of_distinct_sides():
end0, end1 = kernel_cascade_names(LINK_ID)
assert end0 == "gs1a2b3c4d5e0"
assert end1 == "gs1a2b3c4d5e1"
assert end0 != end1
def test_cascade_names_fit_ifnamsiz():
for end in kernel_cascade_names(LINK_ID):
assert len(end) <= 15
def test_cascade_names_stay_out_of_the_node_anchor_namespace():
# "gs" cannot collide with any node anchor prefix (gv/gq/gi/gd/gx), so a
# cascade end is never mistaken for another node's anchor
for end in kernel_cascade_names(LINK_ID):
assert not any(end.startswith(prefix) for prefix in ("gv", "gq", "gi", "gd", "gx"))
+166 -3
View File
@@ -7,6 +7,7 @@ import pytest
from gns3server.utils.packet_filter_validation import (
FilterValidationError,
filter_inactive_filters,
split_kernel_only_features,
validate_all_filters,
validate_filter_parameters,
)
@@ -131,9 +132,11 @@ class TestPacketFilterValidation:
with pytest.raises(FilterValidationError, match="expects 1 parameter"):
validate_filter_parameters("frequency_drop", [1, 2])
# delay expects 2 parameters
with pytest.raises(FilterValidationError, match="expects 2 parameter"):
validate_filter_parameters("delay", [100])
# delay takes 1 to 3 parameters (latency, optional jitter,
# optional distribution) — latency alone is valid, a 4th is not
validate_filter_parameters("delay", [100])
with pytest.raises(FilterValidationError, match="expects 1 to 3 parameter"):
validate_filter_parameters("delay", [100, 50, "normal", "extra"])
def test_string_to_int_conversion(self):
"""Test string to integer conversion."""
@@ -258,3 +261,163 @@ class TestFilterInactiveFilters:
"""Test None filters."""
result = filter_inactive_filters(None)
assert result == {}
class TestNetemExtensionFilters:
"""P6a: the netem-extension filter types (rate, reorder, gemodel,
duplicate, seed, limit) and the kernel-only parameter extensions."""
def test_rate_valid(self):
for value in ("512kbit", "10mbit", "1gbit", "64000bps", "8000kbps", "100mbps", "1544bit"):
validate_filter_parameters("rate", [value])
def test_rate_invalid(self):
# bare number, unknown unit, float, over 100gbit
for value in ("512", "512kbits", "0.5mbit", "101gbit", "", "mbit"):
with pytest.raises(FilterValidationError, match="Rate"):
validate_filter_parameters("rate", [value])
def test_rate_rejects_non_string(self):
with pytest.raises(FilterValidationError, match="must be a string"):
validate_filter_parameters("rate", [512000])
def test_reorder_valid(self):
validate_filter_parameters("reorder", [25])
validate_filter_parameters("reorder", [25, 50])
validate_filter_parameters("reorder", [25, 50, 5])
def test_reorder_invalid_ranges(self):
with pytest.raises(FilterValidationError, match="Reorder"):
validate_filter_parameters("reorder", [101])
with pytest.raises(FilterValidationError, match="Gap"):
validate_filter_parameters("reorder", [25, 0, 0]) # gap < 1
with pytest.raises(FilterValidationError, match="Gap"):
validate_filter_parameters("reorder", [25, 0, 1001])
def test_reorder_requires_delay(self):
with pytest.raises(FilterValidationError, match="reorder requires delay"):
validate_all_filters({"reorder": [25]})
# with delay present it passes
validate_all_filters({"reorder": [25, 0, 5], "delay": [100, 10]})
def test_gemodel_valid_and_exclusive(self):
validate_filter_parameters("gemodel", [100])
validate_filter_parameters("gemodel", [100, 0])
validate_filter_parameters("gemodel", [100, 0, 30])
with pytest.raises(FilterValidationError, match="mutually exclusive"):
validate_all_filters({"gemodel": [100, 0, 30], "packet_loss": [10]})
def test_gemodel_invalid_range(self):
with pytest.raises(FilterValidationError, match="good-to-bad"):
validate_filter_parameters("gemodel", [101, 0, 30])
def test_duplicate_valid(self):
validate_filter_parameters("duplicate", [10])
validate_filter_parameters("duplicate", [10, 25])
def test_seed_and_limit(self):
validate_filter_parameters("seed", [42])
validate_filter_parameters("seed", [4294967295])
with pytest.raises(FilterValidationError, match="Seed"):
validate_filter_parameters("seed", [4294967296])
validate_filter_parameters("limit", [5000])
with pytest.raises(FilterValidationError, match="Limit"):
validate_filter_parameters("limit", [0])
with pytest.raises(FilterValidationError, match="Limit"):
validate_filter_parameters("limit", [1000001])
def test_delay_distribution(self):
for dist in ("uniform", "normal", "pareto", "paretonormal"):
validate_filter_parameters("delay", [100, 20, dist])
with pytest.raises(FilterValidationError, match="Distribution"):
validate_filter_parameters("delay", [100, 20, "poisson"])
# distribution without jitter does nothing -> rejected
with pytest.raises(FilterValidationError, match="requires a Jitter"):
validate_filter_parameters("delay", [100, 0, "normal"])
# empty distribution = not set
validate_filter_parameters("delay", [100, 20, ""])
def test_packet_loss_correlation(self):
validate_filter_parameters("packet_loss", [10])
validate_filter_parameters("packet_loss", [10, 25])
with pytest.raises(FilterValidationError, match="Correlation"):
validate_filter_parameters("packet_loss", [10, 101])
def test_kernel_only_features(self):
clean, dropped = split_kernel_only_features(
{
"delay": [100, 20, "normal"],
"packet_loss": [10, 25],
"rate": ["512kbit"],
"corrupt": [2],
}
)
assert dropped == {"rate", "delay distribution", "packet_loss correlation"}
assert clean == {"delay": [100, 20], "packet_loss": [10], "corrupt": [2]}
def test_kernel_only_features_all_relay_safe(self):
clean, dropped = split_kernel_only_features(
{"delay": [100, 20, ""], "packet_loss": [10, 0], "corrupt": [2], "bpf": ["icmp"]}
)
assert dropped == set()
# Zero correlation and an empty distribution carry no feature, so
# neither is reported as dropped. The packet_loss correlation slot
# is stripped all the same: the relay filter takes exactly one
# argument (ubridge's packet_loss_setup rejects argc != 1), while
# the delay's empty 3rd value is an unquoted empty token that
# tokenizes away — harmless kept as-is.
assert clean == {"delay": [100, 20, ""], "packet_loss": [10], "corrupt": [2], "bpf": ["icmp"]}
def test_kernel_only_features_empty(self):
assert split_kernel_only_features({}) == ({}, set())
assert split_kernel_only_features(None) == ({}, set())
def test_quota_valid_and_invalid(self):
validate_filter_parameters("quota", [1000000, 100])
validate_filter_parameters("quota", [1, 0])
with pytest.raises(FilterValidationError, match="Quota"):
validate_filter_parameters("quota", [0, 100])
with pytest.raises(FilterValidationError, match="Chance"):
validate_filter_parameters("quota", [1000, 101])
with pytest.raises(FilterValidationError, match="expects 2 parameter"):
validate_filter_parameters("quota", [1000])
def test_quota_is_kernel_only(self):
clean, dropped = split_kernel_only_features({"quota": [1000, 50], "frequency_drop": [7]})
assert dropped == {"quota"}
# frequency_drop runs on both datapaths (relay userspace filter /
# eBPF every-Nth) and stays
assert clean == {"frequency_drop": [7]}
def test_window_drop_valid_and_invalid(self):
# the three delivered forms: single outage, recurring, jittered
validate_filter_parameters("window_drop", [0, 2000, 100])
validate_filter_parameters("window_drop", [500, 800, 100, 2400])
validate_filter_parameters("window_drop", [500, 800, 100, 2400, 300])
with pytest.raises(FilterValidationError, match="expects 3 to 5"):
validate_filter_parameters("window_drop", [500, 800])
with pytest.raises(FilterValidationError, match="expects 3 to 5"):
validate_filter_parameters("window_drop", [500, 800, 100, 2400, 300, 1])
with pytest.raises(FilterValidationError, match="Outage"):
validate_filter_parameters("window_drop", [0, 0, 100])
with pytest.raises(FilterValidationError, match="Chance"):
validate_filter_parameters("window_drop", [0, 2000, 101])
with pytest.raises(FilterValidationError, match="Jitter"):
validate_filter_parameters("window_drop", [0, 2000, 100, 2400, 10**10])
def test_window_drop_is_kernel_only(self):
clean, dropped = split_kernel_only_features({"window_drop": [0, 800, 100, 2400], "delay": [10, 0]})
assert dropped == {"window_drop"}
assert clean == {"delay": [10, 0]}
def test_window_drop_period_must_cover_outage(self):
with pytest.raises(FilterValidationError, match="period must be greater than or equal"):
validate_all_filters({"window_drop": [0, 2000, 100, 1000]})
# period == outage is accepted (uBridge allows it); shorter is not
validate_all_filters({"window_drop": [0, 2000, 100, 2000]})
def test_window_drop_start_zero_is_active(self):
# start=0 means "the outage starts now" — unlike other filters a
# zero first parameter must NOT be treated as "disabled"
active = filter_inactive_filters({"window_drop": [0, 2000, 100]})
assert active == {"window_drop": [0, 2000, 100]}
+77
View File
@@ -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"})