From 28f0d9c009b4cb2f3c8ef05be2838e003f1254c0 Mon Sep 17 00:00:00 2001 From: Cristi Date: Fri, 4 Sep 2026 09:45:41 +0300 Subject: [PATCH] feat: Add Docker link carrier and interface status support --- gns3server/api/routes/compute/docker_nodes.py | 1 + gns3server/compute/docker/docker_vm.py | 180 ++++++++++++++++- gns3server/compute/ubridge/hypervisor.py | 8 +- tests/api/routes/compute/test_docker_nodes.py | 5 +- tests/compute/docker/test_docker_vm.py | 188 +++++++++++++++++- tests/compute/docker/test_vendor_docker_vm.py | 4 + tests/compute/ubridge/test_hypervisor.py | 31 +++ 7 files changed, 408 insertions(+), 9 deletions(-) diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index be55ec0f4..63d8bea91 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -307,6 +307,7 @@ async def update_docker_node_nio( if nio_data.filters: nio.filters = nio_data.filters nio.markers = nio_data.markers or {} + nio.suspend = nio_data.suspend await node.adapter_update_nio_binding(adapter_number, nio) return nio.asdict() diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 666844875..44a89f968 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -20,6 +20,8 @@ Docker container instance. import sys import asyncio +import contextlib +import json import shutil import psutil import shlex @@ -92,6 +94,7 @@ class DockerVM(BaseNode): "/sbin/udevadm", "/usr/bin/udevadm", ) + _INTERFACE_STATUS_RESYNC_INTERVAL = 10 def __init__( self, @@ -151,6 +154,10 @@ class DockerVM(BaseNode): self._permissions_fixed = True self._display = None self._closing = False + self._interface_monitor_writer = None + self._interface_monitor_task = None + self._interface_statuses = {} + self._interface_status_times = {} self._volumes = [] # Keep a list of created bridge @@ -863,6 +870,8 @@ class DockerVM(BaseNode): if state == "paused": await self.unpause() elif state == "running": + self.status = "started" + await self._start_interface_monitor() return else: @@ -911,6 +920,7 @@ class DockerVM(BaseNode): self._permissions_fixed = False self.status = "started" + await self._start_interface_monitor() log.debug( "Docker container '{name}' [{image}] started listen for {console_type} on {console}".format( name=self._name, image=self._image, console=self.console, console_type=self.console_type @@ -1226,7 +1236,9 @@ class DockerVM(BaseNode): Restart this Docker container. """ + await self._stop_interface_monitor() await self.manager.query("POST", f"containers/{self._cid}/restart") + await self._start_interface_monitor() log.debug("Docker container '{name}' [{image}] restarted".format(name=self._name, image=self._image)) def _cleanup_console_resources(self): @@ -1258,6 +1270,7 @@ class DockerVM(BaseNode): """ try: + await self._stop_interface_monitor() if self._console_websocket: await self._console_websocket.close() self._console_websocket = None @@ -1393,6 +1406,158 @@ class DockerVM(BaseNode): """ return f"eth{adapter_number}" + async def _start_interface_monitor(self): + """Monitor administrative state changes for this container's adapters. + + Docker does not expose guest ``ip link set`` changes as daemon events. + Run one small BusyBox loop through the Engine exec API and keep its + hijacked output stream open. The loop samples all GNS3 interfaces in + one process; notifications are emitted on changes and periodically to + resynchronize newly connected Web UI clients. + """ + + if self._interface_monitor_task and not self._interface_monitor_task.done(): + return + + await self._stop_interface_monitor() + if not self._cid or not self.adapters: + return + + interface_names = [self._get_container_ifname(adapter_number) for adapter_number in range(self.adapters)] + script = ( + "while :; do " + "for ifname do " + "flags=$(/gns3/bin/busybox cat \"/sys/class/net/$ifname/flags\" 2>/dev/null) || continue; " + "/gns3/bin/busybox printf '%s=%s\\n' \"$ifname\" \"$flags\"; " + "done; " + "/gns3/bin/busybox sleep 1; " + "done" + ) + + try: + result = await self.manager.query( + "POST", + f"containers/{self._cid}/exec", + data={ + "AttachStdout": True, + "AttachStderr": False, + "Tty": True, + "User": "root", + "Cmd": ["/gns3/bin/busybox", "sh", "-c", script, "sh", *interface_names], + }, + ) + exec_id = result["Id"] + reader, writer = await asyncio.open_unix_connection(self.manager._server_url) + body = json.dumps({"Detach": False, "Tty": True}) + request = ( + f"POST /v{self.manager._api_version}/exec/{exec_id}/start HTTP/1.1\r\n" + "Host: docker\r\n" + "Connection: Upgrade\r\n" + "Upgrade: tcp\r\n" + "Content-Type: application/json\r\n" + f"Content-Length: {len(body)}\r\n\r\n{body}" + ).encode() + writer.write(request) + await writer.drain() + headers = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5) + status_line = headers.split(b"\r\n", 1)[0] + if b" 101 " not in status_line and b" 200 " not in status_line: + raise DockerError(f"Docker interface monitor was rejected: {status_line.decode(errors='ignore')}") + except ( + DockerError, + OSError, + KeyError, + TypeError, + RuntimeError, + asyncio.IncompleteReadError, + asyncio.TimeoutError, + ) as e: + if 'writer' in locals(): + await self._close_interface_monitor_writer(writer) + log.warning("Could not monitor interfaces for Docker container '%s': %s", self.name, e) + return + + self._interface_statuses.clear() + self._interface_status_times.clear() + self._interface_monitor_writer = writer + self._interface_monitor_task = asyncio.create_task(self._read_interface_statuses(reader)) + + async def _stop_interface_monitor(self): + """Close the Docker exec stream and its reader task.""" + + task = self._interface_monitor_task + self._interface_monitor_task = None + if task: + task.cancel() + writer = self._interface_monitor_writer + self._interface_monitor_writer = None + if writer: + await self._close_interface_monitor_writer(writer) + if task: + with contextlib.suppress(asyncio.CancelledError): + await task + self._interface_statuses.clear() + self._interface_status_times.clear() + + @staticmethod + async def _close_interface_monitor_writer(writer): + with contextlib.suppress(Exception): + writer.close() + await writer.wait_closed() + + async def _read_interface_statuses(self, reader): + """Read ``ifname=flags`` records and emit changed adapter states.""" + + interfaces = { + self._get_container_ifname(adapter_number): adapter_number + for adapter_number in range(self.adapters) + } + try: + while True: + line = await reader.readline() + if not line: + break + record = line.decode(errors="replace").strip("\r\n") + ifname, separator, flags_text = record.partition("=") + adapter_number = interfaces.get(ifname) + if not separator or adapter_number is None: + continue + try: + is_up = bool(int(flags_text, 0) & 0x1) # Linux IFF_UP + except ValueError: + continue + status = "started" if is_up else "stopped" + now = asyncio.get_running_loop().time() + if ( + self._interface_statuses.get(adapter_number) == status + and now - self._interface_status_times.get(adapter_number, 0) + < self._INTERFACE_STATUS_RESYNC_INTERVAL + ): + continue + self._interface_statuses[adapter_number] = status + self._interface_status_times[adapter_number] = now + self.project.emit( + "node.interface_status", + { + "project_id": self.project.id, + "node_id": self.id, + "adapter_number": adapter_number, + "port_number": 0, + "status": status, + }, + ) + except asyncio.CancelledError: + raise + except (OSError, RuntimeError) as e: + log.debug("Docker interface monitor for '%s' stopped: %s", self.name, e) + finally: + if self._interface_monitor_task is asyncio.current_task(): + self._interface_monitor_task = None + writer = self._interface_monitor_writer + self._interface_monitor_writer = None + if writer: + await self._close_interface_monitor_writer(writer) + async def _add_ubridge_connection(self, nio, adapter_number): """ Creates a connection in uBridge. @@ -1424,7 +1589,7 @@ class DockerVM(BaseNode): await self._ubridge_send(f"bridge create {bridge_name}") self._bridges.add(bridge_name) await self._ubridge_send( - "bridge add_nio_tap bridge{adapter_number} {hostif}".format( + "bridge add_nio_tap bridge{adapter_number} {hostif} off".format( adapter_number=adapter_number, hostif=adapter.host_ifc ) ) @@ -1454,12 +1619,19 @@ class DockerVM(BaseNode): if nio: await self._connect_nio(adapter_number, nio) + await self._set_adapter_carrier(adapter_number, not nio.suspend) async def _get_namespace(self): result = await self.manager.query("GET", f"containers/{self._cid}/json") return int(result["State"]["Pid"]) + async def _set_adapter_carrier(self, adapter_number, connected): + """Replicate a Docker adapter's connection state on its TAP device.""" + + state = "on" if connected else "off" + await self._ubridge_send(f"bridge set_nio_tap_carrier bridge{adapter_number} {state}") + async def _connect_nio(self, adapter_number, nio): bridge_name = f"bridge{adapter_number}" @@ -1497,6 +1669,7 @@ class DockerVM(BaseNode): if self.status == "started" and self.ubridge: await self._connect_nio(adapter_number, nio) + await self._set_adapter_carrier(adapter_number, not nio.suspend) adapter.add_nio(0, nio) log.debug( @@ -1518,6 +1691,9 @@ class DockerVM(BaseNode): if bridge_name in self._bridges: await self._ubridge_apply_filters(bridge_name, nio.filters) await self._ubridge_apply_markers(bridge_name, nio) + if self.status == "started": + await self._set_adapter_carrier(adapter_number, not nio.suspend) + async def adapter_remove_nio_binding(self, adapter_number): """ Removes an adapter NIO binding. @@ -1540,6 +1716,8 @@ class DockerVM(BaseNode): if self.ubridge: nio = adapter.get_nio(0) bridge_name = f"bridge{adapter_number}" + if self.status == "started": + await self._set_adapter_carrier(adapter_number, False) await self._ubridge_send(f"bridge stop {bridge_name}") await self._ubridge_send( "bridge remove_nio_udp bridge{adapter} {lport} {rhost} {rport}".format( diff --git a/gns3server/compute/ubridge/hypervisor.py b/gns3server/compute/ubridge/hypervisor.py index 661da580c..faefcb872 100644 --- a/gns3server/compute/ubridge/hypervisor.py +++ b/gns3server/compute/ubridge/hypervisor.py @@ -160,10 +160,10 @@ class Hypervisor(UBridgeHypervisor): match = re.search(r"ubridge version ([0-9a-z\.]+)", output) if match: self._version = match.group(1) - # uBridge >= 1.2.0 is required for features this server now - # relies on: the AF_UNIX control channel (-U), the marker - # (mark) filter, and the brctl-backed builtin Ethernet Switch. - minimum_required_version = "1.2.0" + # uBridge >= 1.2.3 is required for the AF_UNIX control + # channel, marker filters, builtin Ethernet Switch support, + # and Docker TAP carrier control. + minimum_required_version = "1.2.3" if parse_version(self._version) < parse_version(minimum_required_version): raise UbridgeError(f"uBridge executable version must be >= {minimum_required_version}") else: diff --git a/tests/api/routes/compute/test_docker_nodes.py b/tests/api/routes/compute/test_docker_nodes.py index 278596dba..efadd604b 100644 --- a/tests/api/routes/compute/test_docker_nodes.py +++ b/tests/api/routes/compute/test_docker_nodes.py @@ -247,7 +247,8 @@ class TestDockerNodesRoutes: "lport": 4242, "rport": 4343, "rhost": "127.0.0.1", - "filters": {"packet_loss": 10} + "filters": {"packet_loss": 10}, + "suspend": False } url = app.url_path_for("compute:create_docker_node_nio", @@ -259,6 +260,7 @@ class TestDockerNodesRoutes: assert response.status_code == status.HTTP_201_CREATED assert response.json()["filters"] == {"packet_loss": 10} params["filters"] = {} + params["suspend"] = True url = app.url_path_for("compute:update_docker_node_nio", project_id=vm["project_id"], @@ -270,6 +272,7 @@ class TestDockerNodesRoutes: assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" assert response.json()["filters"] == {} + assert response.json()["suspend"] is True async def test_docker_delete_nio(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 70d72b3bf..7aa5abb12 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -31,7 +31,7 @@ from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Err from gns3server.compute.docker import Docker -from unittest.mock import patch, MagicMock, call +from unittest.mock import AsyncMock, patch, MagicMock, call @pytest_asyncio.fixture @@ -48,6 +48,10 @@ async def vm(compute_project, manager): vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", aux_type="none") vm._cid = "e90e34656842" vm.mac_address = '02:42:3d:b7:93:00' + # Interface monitoring has its own focused tests below. Keep unrelated + # lifecycle tests isolated from a real Docker exec stream. + vm._start_interface_monitor = AsyncioMagicMock() + vm._stop_interface_monitor = AsyncioMagicMock() return vm @@ -1198,6 +1202,7 @@ async def test_start(vm, manager, free_console_port, tmpdir): assert vm._start_ubridge.called assert vm._start_console.called assert vm._start_aux.called + assert vm._start_interface_monitor.called assert vm.status == "started" @@ -1289,12 +1294,137 @@ async def test_start_unpause(vm): assert vm.status == "started" +@pytest.mark.asyncio +async def test_start_already_running_starts_interface_monitor(vm): + + with patch("gns3server.compute.docker.Docker.install_busybox"): + with asyncio_patch("gns3server.compute.docker.DockerVM._get_container_state", return_value="running"): + await vm.start() + + assert vm.status == "started" + vm._start_interface_monitor.assert_called_once() + + @pytest.mark.asyncio async def test_restart(vm): with asyncio_patch("gns3server.compute.docker.Docker.query") as mock: await vm.restart() mock.assert_called_with("POST", "containers/e90e34656842/restart") + vm._stop_interface_monitor.assert_called_once() + vm._start_interface_monitor.assert_called_once() + + +@pytest.mark.asyncio +async def test_read_interface_statuses(vm): + + reader = MagicMock() + reader.readline = AsyncMock(side_effect=[ + b"eth0=0x1003\r\n", + b"eth0=0x1003\r\n", # duplicate must not emit again + b"eth0=0x1002\r\n", + b"unknown=0x1003\r\n", + b"eth0=invalid\r\n", + b"", + ]) + vm.project.emit = MagicMock() + + await DockerVM._read_interface_statuses(vm, reader) + + assert vm.project.emit.call_args_list == [ + call("node.interface_status", { + "project_id": vm.project.id, + "node_id": vm.id, + "adapter_number": 0, + "port_number": 0, + "status": "started", + }), + call("node.interface_status", { + "project_id": vm.project.id, + "node_id": vm.id, + "adapter_number": 0, + "port_number": 0, + "status": "stopped", + }), + ] + + +@pytest.mark.asyncio +async def test_read_interface_statuses_periodically_resynchronizes(vm): + + reader = MagicMock() + reader.readline = AsyncMock(side_effect=[b"eth0=0x1003\n", b"eth0=0x1003\n", b""]) + vm._INTERFACE_STATUS_RESYNC_INTERVAL = 0 + vm.project.emit = MagicMock() + + await DockerVM._read_interface_statuses(vm, reader) + + assert vm.project.emit.call_count == 2 + + +@pytest.mark.asyncio +async def test_start_interface_monitor(vm, manager): + + reader = MagicMock() + reader.readuntil = AsyncMock(return_value=b"HTTP/1.1 101 UPGRADED\r\n\r\n") + reader.readline = AsyncMock(return_value=b"") + writer = MagicMock() + writer.drain = AsyncMock() + writer.wait_closed = AsyncMock() + manager._api_version = "1.24" + + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "monitor-exec"}) as query: + with asyncio_patch("asyncio.open_unix_connection", return_value=(reader, writer)): + await DockerVM._start_interface_monitor(vm) + await asyncio.sleep(0) + + request = query.call_args + assert request.args[:2] == ("POST", "containers/e90e34656842/exec") + assert request.kwargs["data"]["Cmd"][-1] == "eth0" + assert vm._interface_monitor_writer is None + writer.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_start_interface_monitor_is_idempotent(vm): + + vm._interface_monitor_task = MagicMock() + vm._interface_monitor_task.done.return_value = False + + with asyncio_patch("gns3server.compute.docker.Docker.query") as query: + await DockerVM._start_interface_monitor(vm) + + query.assert_not_called() + vm._stop_interface_monitor.assert_not_called() + + +@pytest.mark.asyncio +async def test_start_interface_monitor_failure_does_not_fail_node(vm, manager): + + reader = MagicMock() + reader.readuntil = AsyncMock(return_value=b"HTTP/1.1 500 Internal Server Error\r\n\r\n") + writer = MagicMock() + writer.drain = AsyncMock() + writer.wait_closed = AsyncMock() + manager._api_version = "1.24" + + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "monitor-exec"}): + with asyncio_patch("asyncio.open_unix_connection", return_value=(reader, writer)): + await DockerVM._start_interface_monitor(vm) + + assert vm._interface_monitor_task is None + writer.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_start_interface_monitor_rejects_malformed_exec_response(vm): + + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=None): + with asyncio_patch("asyncio.open_unix_connection") as open_connection: + await DockerVM._start_interface_monitor(vm) + + open_connection.assert_not_called() + assert vm._interface_monitor_task is None @pytest.mark.asyncio @@ -1313,6 +1443,7 @@ async def test_stop(vm): assert mock.stop.called assert vm._ubridge_hypervisor is None assert vm._fix_permissions.called + assert vm._stop_interface_monitor.called @pytest.mark.asyncio @@ -1538,11 +1669,12 @@ async def test_add_ubridge_connection(vm): calls = [ call.send('bridge create bridge0'), - call.send("bridge add_nio_tap bridge0 tap-gns3-e0"), + call.send("bridge add_nio_tap bridge0 tap-gns3-e0 off"), call.send('docker move_to_ns tap-gns3-e0 42 eth0'), 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') ] assert 'bridge0' in vm._bridges # We need to check any_order otherwise mock is confused by asyncio @@ -1591,7 +1723,7 @@ async def test_add_ubridge_connection_none_nio(vm): calls = [ call.send('bridge create bridge0'), - call.send("bridge add_nio_tap bridge0 tap-gns3-e0"), + call.send("bridge add_nio_tap bridge0 tap-gns3-e0 off"), call.send('docker move_to_ns tap-gns3-e0 42 eth0'), ] @@ -1600,6 +1732,20 @@ async def test_add_ubridge_connection_none_nio(vm): vm._ubridge_hypervisor.assert_has_calls(calls, any_order=True) +@pytest.mark.asyncio +async def test_set_adapter_carrier(vm): + + vm._ubridge_send = AsyncioMagicMock() + + await vm._set_adapter_carrier(2, True) + await vm._set_adapter_carrier(2, False) + + vm._ubridge_send.assert_has_calls([ + call("bridge set_nio_tap_carrier bridge2 on"), + call("bridge set_nio_tap_carrier bridge2 off"), + ]) + + @pytest.mark.asyncio async def test_add_ubridge_connection_invalid_adapter_number(vm): @@ -1641,6 +1787,22 @@ 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_sets_carrier(vm): + + nio = vm.manager.create_nio({ + "type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1" + }) + vm.status = "started" + vm._ubridge_hypervisor = MagicMock() + vm._connect_nio = AsyncioMagicMock() + vm._set_adapter_carrier = AsyncioMagicMock() + + await vm.adapter_add_nio_binding(0, nio) + + vm._set_adapter_carrier.assert_called_once_with(0, True) + + @pytest.mark.asyncio async def test_adapter_udpate_nio_binding_bridge_not_started(vm): @@ -1656,6 +1818,24 @@ async def test_adapter_udpate_nio_binding_bridge_not_started(vm): assert vm._ubridge_apply_filters.called is False +@pytest.mark.asyncio +async def test_adapter_update_nio_binding_sets_suspended_carrier(vm): + + nio = vm.manager.create_nio({ + "type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1" + }) + nio.suspend = True + vm.status = "started" + vm._ubridge_hypervisor = MagicMock() + vm._bridges.add("bridge0") + vm._ubridge_apply_filters = AsyncioMagicMock() + vm._set_adapter_carrier = AsyncioMagicMock() + + await vm.adapter_update_nio_binding(0, nio) + + vm._set_adapter_carrier.assert_called_once_with(0, False) + + @pytest.mark.asyncio async def test_adapter_add_nio_binding_invalid_adapter(vm): @@ -1680,12 +1860,14 @@ async def test_adapter_remove_nio_binding(vm): "rhost": "127.0.0.1"} nio = vm.manager.create_nio(nio) await vm.adapter_add_nio_binding(0, nio) + vm.status = "started" with asyncio_patch("gns3server.compute.docker.DockerVM._ubridge_send") as delete_ubridge_mock: await vm.adapter_remove_nio_binding(0) assert vm._ethernet_adapters[0].get_nio(0) is None delete_ubridge_mock.assert_any_call('bridge stop bridge0') delete_ubridge_mock.assert_any_call('bridge remove_nio_udp bridge0 4242 127.0.0.1 4343') + delete_ubridge_mock.assert_any_call('bridge set_nio_tap_carrier bridge0 off') @pytest.mark.asyncio diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py index 9f578c797..e453a1b62 100644 --- a/tests/compute/docker/test_vendor_docker_vm.py +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -80,6 +80,10 @@ def _make_vm(compute_project, manager, environment=None, console_type="docker_ex extra_volumes=extra_volumes or [], adapters=adapters, ) vm._cid = "e90e34656842" + # Interface monitoring belongs to DockerVM and is covered by its focused + # tests. Keep vendor-console tests independent from a Docker exec stream. + vm._start_interface_monitor = AsyncioMagicMock() + vm._stop_interface_monitor = AsyncioMagicMock() return vm diff --git a/tests/compute/ubridge/test_hypervisor.py b/tests/compute/ubridge/test_hypervisor.py index 126278ab0..7cba76a2c 100644 --- a/tests/compute/ubridge/test_hypervisor.py +++ b/tests/compute/ubridge/test_hypervisor.py @@ -159,6 +159,37 @@ async def test_stop_tcp_has_no_socket_to_unlink(tmp_path, monkeypatch): await hyp.stop() +# --------------------------------------------------------------------------- +# version requirement +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_requires_ubridge_1_2_3(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch) + with patch( + "gns3server.compute.ubridge.hypervisor.subprocess_check_output", + new_callable=AsyncMock, + return_value="ubridge version 1.2.2", + ): + with pytest.raises(UbridgeError, match=r">= 1\.2\.3"): + await hyp._check_ubridge_version() + + +@pytest.mark.asyncio +async def test_accepts_ubridge_1_2_3(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch) + with patch( + "gns3server.compute.ubridge.hypervisor.subprocess_check_output", + new_callable=AsyncMock, + return_value="ubridge version 1.2.3", + ): + await hyp._check_ubridge_version() + + assert hyp.version == "1.2.3" + + # --------------------------------------------------------------------------- # start: fail-fast on an immediately-exiting uBridge # ---------------------------------------------------------------------------