gns3-server/tests/compute/qemu/test_qemu_kernel_datapath.py
YueGuobin 35cf964882
test: cover the kernel-datapath review fixes
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.
2026-10-06 19:55:31 +08:00

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}"']