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.
This commit is contained in:
YueGuobin committed 2026-10-06 19:55:31 +08:00
1 parent c3c025ab2a
commit 35cf964882
13 files changed
+438 -40

No files matched your search

@@ -899,7 +899,6 @@ async def test_update_nio_kernel_clears_netem(vm):
async def test_apply_netem_reset_tolerates_missing_qdisc(vm):
vm._ubridge_hypervisor = MagicMock()
from gns3server.compute.ubridge.ubridge_error import UbridgeError
async def refuse(command):
raise UbridgeError("207-Could not reset qdisc on gv0: No such file or directory")
@@ -914,7 +913,6 @@ async def test_apply_netem_reset_tolerates_missing_qdisc(vm):
async def test_apply_netem_reset_reraises_real_errors(vm):
vm._ubridge_hypervisor = MagicMock()
from gns3server.compute.ubridge.ubridge_error import UbridgeError
async def refuse(command):
raise UbridgeError("206-Could not reset qdisc on gv0: Operation not permitted")
@@ -997,6 +995,79 @@ async def test_start_capture_second_concurrent_fails(vm):
with pytest.raises(UbridgeError, match="already in progress"):
await vm.start_capture(0, "/tmp/capture.pcap")
# the failed start must not leave the port marked as capturing: a stuck
# flag refuses the retry and lets this port's stop kill the winner
assert nio.capturing is False
assert vm._kernel_capture_ifc is None
@pytest.mark.asyncio
async def test_start_capture_second_port_on_the_same_node_is_refused(vm):
"""
uBridge serves one kernel capture per process while the server models
captures per port: a second port's capture must be refused with a clear
error and its flag rolled back — otherwise its stop would issue the
process-wide, argument-less ``capture stop_kernel`` and kill the first
port's capture while its own flag still says capturing.
"""
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_send = AsyncioMagicMock()
vm.status = "started"
vm._ethernet_adapters.append(type(vm._ethernet_adapters[0])())
host_ifc1, _ = vm._veth_names(0, 0)
host_ifc2, _ = vm._veth_names(1, 0)
vm._kernel_veths[(0, 0)] = host_ifc1
vm._kernel_veths[(1, 0)] = host_ifc2
nio1 = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
nio2 = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio1)
vm._ethernet_adapters[1].add_nio(0, nio2)
await vm.start_capture(0, "/tmp/capture.pcap")
with pytest.raises(DockerError, match="one kernel capture per node"):
await vm.start_capture(1, "/tmp/capture2.pcap")
assert nio1.capturing is True
assert nio2.capturing is False
vm._ubridge_send.assert_any_call(f'capture start_kernel {host_ifc1} "/tmp/capture.pcap"')
assert not any("stop_kernel" in str(c) for c in vm._ubridge_send.call_args_list)
@pytest.mark.asyncio
async def test_stop_capture_on_a_port_that_does_not_own_the_slot_skips_stop_kernel(vm):
"""
``capture stop_kernel`` is process-wide and takes no interface: a stop on
a port whose flag says capturing while another port owns the slot must
not reach uBridge — it would stop the owner's capture.
"""
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_send = AsyncioMagicMock()
vm.status = "started"
vm._ethernet_adapters.append(type(vm._ethernet_adapters[0])())
host_ifc1, _ = vm._veth_names(0, 0)
host_ifc2, _ = vm._veth_names(1, 0)
vm._kernel_veths[(0, 0)] = host_ifc1
vm._kernel_veths[(1, 0)] = host_ifc2
nio1 = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
nio2 = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio1)
vm._ethernet_adapters[1].add_nio(0, nio2)
await vm.start_capture(0, "/tmp/capture.pcap") # port 0 owns the slot
nio2.start_packet_capture("/tmp/capture2.pcap") # a stale second flag
await vm.stop_capture(1)
assert nio2.capturing is False
assert not any("stop_kernel" in str(c) for c in vm._ubridge_send.call_args_list)
# the owner's capture is untouched and still stoppable
await vm.stop_capture(0)
vm._ubridge_send.assert_any_call("capture stop_kernel")
@pytest.mark.asyncio
async def test_connect_nio_kernel_restarts_capture(vm):
+18
View File
@@ -1642,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):
@@ -22,6 +22,7 @@ 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"
@@ -385,8 +386,6 @@ def test_anchor_names_follow_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"
@@ -309,6 +309,35 @@ async def test_slot_add_nio_binding_attach_failure_leaves_the_port_unbound(route
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):
"""
+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):
@@ -376,6 +376,73 @@ async def test_stop_stops_the_process_before_deleting_the_taps(vm):
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):
"""
+27 -1
View File
@@ -19,6 +19,8 @@ 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
@@ -91,11 +93,13 @@ def probe_env(monkeypatch, tmp_path):
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)
# 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():
@@ -134,6 +138,28 @@ async def test_probe_missing_binary_means_unknown(monkeypatch):
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 = []