mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 21:45:41 +03:00
Regression coverage added with the fixes: - capture-slot semantics: second-port refusal, failed-start rollback, and a non-owner port's stop not reaching `capture stop_kernel`; - QEMU stop/start teardown on a mid-body or pre-launch failure, IOU start cleanup and filter guards, Dynamips half-attach undo plus retry; - switch VLAN reconcile skipping a deferred anchor and rolling the ports_mapping back on failure; - Docker/QEMU occupied-port refusal, probe ctime invalidation, probe survival of a failing uBridge start; - env-marker dialects (mid-line mentions, falsy values) and the agent merge rejecting entries without a device_name. Test-body imports of product modules move to the shared header.
505 lines
17 KiB
Python
505 lines
17 KiB
Python
#
|
|
# Copyright (C) 2026 GNS3 Technologies Inc.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
"""
|
|
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}"']
|