mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 21:45:41 +03:00
The rebase onto upstream 3.1 (mypy + ruff I001/F401 waves) left the tree short of the newly enforced baselines in three ways: * contextlib / UbridgeError imports dropped as false duplicates during conflict resolution left NameErrors in iou_vm and qemu_vm (F821); the Dynamips router grabbed the compute-level NIOTAP instead of the hypervisor one, breaking TAP anchoring (test failure). * unused imports (KERNEL_UNSUPPORTED_FILTERS, UbridgeError, List) and unsorted blocks left behind where the stack's later commits moved the last usage elsewhere — ruff I001/F401/RUF022 now enforce these. * mypy, now baseline-free: cache dict annotations in tc_probe, the locators candidate list, the resolved-params None branch in the MCP runner, and validate_bpf_syntax keeps the upstream Dict[str, Any] return (the strict Optional[str] was wrong for the "valid" bool entry). The e2e raw-frame probe resolves its compiler via shutil.which (S607) and zips with strict= (B905); the remaining subprocess call is S603 by nature and gets a per-file ignore like other test files. ruff check/format clean, mypy at upstream parity (no new errors), full unit suite 2167 passed.
394 lines
14 KiB
Python
394 lines
14 KiB
Python
#
|
|
# 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 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.
|
|
"""
|
|
|
|
from gns3server.utils.kernel_anchor import kernel_anchor_name, kernel_anchor_type
|
|
|
|
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
|