mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-27 02:32:23 +02:00
feat: Add Docker link carrier and interface status support
This commit is contained in:
parent
d1b4de6b8f
commit
28f0d9c009
@ -307,6 +307,7 @@ async def update_docker_node_nio(
|
|||||||
if nio_data.filters:
|
if nio_data.filters:
|
||||||
nio.filters = nio_data.filters
|
nio.filters = nio_data.filters
|
||||||
nio.markers = nio_data.markers or {}
|
nio.markers = nio_data.markers or {}
|
||||||
|
nio.suspend = nio_data.suspend
|
||||||
await node.adapter_update_nio_binding(adapter_number, nio)
|
await node.adapter_update_nio_binding(adapter_number, nio)
|
||||||
return nio.asdict()
|
return nio.asdict()
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,8 @@ Docker container instance.
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import psutil
|
import psutil
|
||||||
import shlex
|
import shlex
|
||||||
@ -92,6 +94,7 @@ class DockerVM(BaseNode):
|
|||||||
"/sbin/udevadm",
|
"/sbin/udevadm",
|
||||||
"/usr/bin/udevadm",
|
"/usr/bin/udevadm",
|
||||||
)
|
)
|
||||||
|
_INTERFACE_STATUS_RESYNC_INTERVAL = 10
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -151,6 +154,10 @@ class DockerVM(BaseNode):
|
|||||||
self._permissions_fixed = True
|
self._permissions_fixed = True
|
||||||
self._display = None
|
self._display = None
|
||||||
self._closing = False
|
self._closing = False
|
||||||
|
self._interface_monitor_writer = None
|
||||||
|
self._interface_monitor_task = None
|
||||||
|
self._interface_statuses = {}
|
||||||
|
self._interface_status_times = {}
|
||||||
|
|
||||||
self._volumes = []
|
self._volumes = []
|
||||||
# Keep a list of created bridge
|
# Keep a list of created bridge
|
||||||
@ -863,6 +870,8 @@ class DockerVM(BaseNode):
|
|||||||
if state == "paused":
|
if state == "paused":
|
||||||
await self.unpause()
|
await self.unpause()
|
||||||
elif state == "running":
|
elif state == "running":
|
||||||
|
self.status = "started"
|
||||||
|
await self._start_interface_monitor()
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
|
|
||||||
@ -911,6 +920,7 @@ class DockerVM(BaseNode):
|
|||||||
|
|
||||||
self._permissions_fixed = False
|
self._permissions_fixed = False
|
||||||
self.status = "started"
|
self.status = "started"
|
||||||
|
await self._start_interface_monitor()
|
||||||
log.debug(
|
log.debug(
|
||||||
"Docker container '{name}' [{image}] started listen for {console_type} on {console}".format(
|
"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
|
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.
|
Restart this Docker container.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
await self._stop_interface_monitor()
|
||||||
await self.manager.query("POST", f"containers/{self._cid}/restart")
|
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))
|
log.debug("Docker container '{name}' [{image}] restarted".format(name=self._name, image=self._image))
|
||||||
|
|
||||||
def _cleanup_console_resources(self):
|
def _cleanup_console_resources(self):
|
||||||
@ -1258,6 +1270,7 @@ class DockerVM(BaseNode):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
await self._stop_interface_monitor()
|
||||||
if self._console_websocket:
|
if self._console_websocket:
|
||||||
await self._console_websocket.close()
|
await self._console_websocket.close()
|
||||||
self._console_websocket = None
|
self._console_websocket = None
|
||||||
@ -1393,6 +1406,158 @@ class DockerVM(BaseNode):
|
|||||||
"""
|
"""
|
||||||
return f"eth{adapter_number}"
|
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):
|
async def _add_ubridge_connection(self, nio, adapter_number):
|
||||||
"""
|
"""
|
||||||
Creates a connection in uBridge.
|
Creates a connection in uBridge.
|
||||||
@ -1424,7 +1589,7 @@ class DockerVM(BaseNode):
|
|||||||
await self._ubridge_send(f"bridge create {bridge_name}")
|
await self._ubridge_send(f"bridge create {bridge_name}")
|
||||||
self._bridges.add(bridge_name)
|
self._bridges.add(bridge_name)
|
||||||
await self._ubridge_send(
|
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
|
adapter_number=adapter_number, hostif=adapter.host_ifc
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -1454,12 +1619,19 @@ class DockerVM(BaseNode):
|
|||||||
|
|
||||||
if nio:
|
if nio:
|
||||||
await self._connect_nio(adapter_number, nio)
|
await self._connect_nio(adapter_number, nio)
|
||||||
|
await self._set_adapter_carrier(adapter_number, not nio.suspend)
|
||||||
|
|
||||||
async def _get_namespace(self):
|
async def _get_namespace(self):
|
||||||
|
|
||||||
result = await self.manager.query("GET", f"containers/{self._cid}/json")
|
result = await self.manager.query("GET", f"containers/{self._cid}/json")
|
||||||
return int(result["State"]["Pid"])
|
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):
|
async def _connect_nio(self, adapter_number, nio):
|
||||||
|
|
||||||
bridge_name = f"bridge{adapter_number}"
|
bridge_name = f"bridge{adapter_number}"
|
||||||
@ -1497,6 +1669,7 @@ class DockerVM(BaseNode):
|
|||||||
|
|
||||||
if self.status == "started" and self.ubridge:
|
if self.status == "started" and self.ubridge:
|
||||||
await self._connect_nio(adapter_number, nio)
|
await self._connect_nio(adapter_number, nio)
|
||||||
|
await self._set_adapter_carrier(adapter_number, not nio.suspend)
|
||||||
|
|
||||||
adapter.add_nio(0, nio)
|
adapter.add_nio(0, nio)
|
||||||
log.debug(
|
log.debug(
|
||||||
@ -1518,6 +1691,9 @@ class DockerVM(BaseNode):
|
|||||||
if bridge_name in self._bridges:
|
if bridge_name in self._bridges:
|
||||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||||
await self._ubridge_apply_markers(bridge_name, nio)
|
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):
|
async def adapter_remove_nio_binding(self, adapter_number):
|
||||||
"""
|
"""
|
||||||
Removes an adapter NIO binding.
|
Removes an adapter NIO binding.
|
||||||
@ -1540,6 +1716,8 @@ class DockerVM(BaseNode):
|
|||||||
if self.ubridge:
|
if self.ubridge:
|
||||||
nio = adapter.get_nio(0)
|
nio = adapter.get_nio(0)
|
||||||
bridge_name = f"bridge{adapter_number}"
|
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(f"bridge stop {bridge_name}")
|
||||||
await self._ubridge_send(
|
await self._ubridge_send(
|
||||||
"bridge remove_nio_udp bridge{adapter} {lport} {rhost} {rport}".format(
|
"bridge remove_nio_udp bridge{adapter} {lport} {rhost} {rport}".format(
|
||||||
|
|||||||
@ -160,10 +160,10 @@ class Hypervisor(UBridgeHypervisor):
|
|||||||
match = re.search(r"ubridge version ([0-9a-z\.]+)", output)
|
match = re.search(r"ubridge version ([0-9a-z\.]+)", output)
|
||||||
if match:
|
if match:
|
||||||
self._version = match.group(1)
|
self._version = match.group(1)
|
||||||
# uBridge >= 1.2.0 is required for features this server now
|
# uBridge >= 1.2.3 is required for the AF_UNIX control
|
||||||
# relies on: the AF_UNIX control channel (-U), the marker
|
# channel, marker filters, builtin Ethernet Switch support,
|
||||||
# (mark) filter, and the brctl-backed builtin Ethernet Switch.
|
# and Docker TAP carrier control.
|
||||||
minimum_required_version = "1.2.0"
|
minimum_required_version = "1.2.3"
|
||||||
if parse_version(self._version) < parse_version(minimum_required_version):
|
if parse_version(self._version) < parse_version(minimum_required_version):
|
||||||
raise UbridgeError(f"uBridge executable version must be >= {minimum_required_version}")
|
raise UbridgeError(f"uBridge executable version must be >= {minimum_required_version}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -247,7 +247,8 @@ class TestDockerNodesRoutes:
|
|||||||
"lport": 4242,
|
"lport": 4242,
|
||||||
"rport": 4343,
|
"rport": 4343,
|
||||||
"rhost": "127.0.0.1",
|
"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",
|
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.status_code == status.HTTP_201_CREATED
|
||||||
assert response.json()["filters"] == {"packet_loss": 10}
|
assert response.json()["filters"] == {"packet_loss": 10}
|
||||||
params["filters"] = {}
|
params["filters"] = {}
|
||||||
|
params["suspend"] = True
|
||||||
|
|
||||||
url = app.url_path_for("compute:update_docker_node_nio",
|
url = app.url_path_for("compute:update_docker_node_nio",
|
||||||
project_id=vm["project_id"],
|
project_id=vm["project_id"],
|
||||||
@ -270,6 +272,7 @@ class TestDockerNodesRoutes:
|
|||||||
assert response.status_code == status.HTTP_201_CREATED
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
assert response.json()["type"] == "nio_udp"
|
assert response.json()["type"] == "nio_udp"
|
||||||
assert response.json()["filters"] == {}
|
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:
|
async def test_docker_delete_nio(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None:
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@ from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Err
|
|||||||
from gns3server.compute.docker import Docker
|
from gns3server.compute.docker import Docker
|
||||||
|
|
||||||
|
|
||||||
from unittest.mock import patch, MagicMock, call
|
from unittest.mock import AsyncMock, patch, MagicMock, call
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@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 = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", aux_type="none")
|
||||||
vm._cid = "e90e34656842"
|
vm._cid = "e90e34656842"
|
||||||
vm.mac_address = '02:42:3d:b7:93:00'
|
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
|
return vm
|
||||||
|
|
||||||
|
|
||||||
@ -1198,6 +1202,7 @@ async def test_start(vm, manager, free_console_port, tmpdir):
|
|||||||
assert vm._start_ubridge.called
|
assert vm._start_ubridge.called
|
||||||
assert vm._start_console.called
|
assert vm._start_console.called
|
||||||
assert vm._start_aux.called
|
assert vm._start_aux.called
|
||||||
|
assert vm._start_interface_monitor.called
|
||||||
assert vm.status == "started"
|
assert vm.status == "started"
|
||||||
|
|
||||||
|
|
||||||
@ -1289,12 +1294,137 @@ async def test_start_unpause(vm):
|
|||||||
assert vm.status == "started"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_restart(vm):
|
async def test_restart(vm):
|
||||||
|
|
||||||
with asyncio_patch("gns3server.compute.docker.Docker.query") as mock:
|
with asyncio_patch("gns3server.compute.docker.Docker.query") as mock:
|
||||||
await vm.restart()
|
await vm.restart()
|
||||||
mock.assert_called_with("POST", "containers/e90e34656842/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
|
@pytest.mark.asyncio
|
||||||
@ -1313,6 +1443,7 @@ async def test_stop(vm):
|
|||||||
assert mock.stop.called
|
assert mock.stop.called
|
||||||
assert vm._ubridge_hypervisor is None
|
assert vm._ubridge_hypervisor is None
|
||||||
assert vm._fix_permissions.called
|
assert vm._fix_permissions.called
|
||||||
|
assert vm._stop_interface_monitor.called
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@ -1538,11 +1669,12 @@ async def test_add_ubridge_connection(vm):
|
|||||||
|
|
||||||
calls = [
|
calls = [
|
||||||
call.send('bridge create bridge0'),
|
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('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 add_nio_udp bridge0 4242 127.0.0.1 4343'),
|
||||||
call.send('bridge start_capture bridge0 "/tmp/capture.pcap"'),
|
call.send('bridge start_capture bridge0 "/tmp/capture.pcap"'),
|
||||||
call.send('bridge start bridge0'),
|
call.send('bridge start bridge0'),
|
||||||
|
call.send('bridge set_nio_tap_carrier bridge0 on')
|
||||||
]
|
]
|
||||||
assert 'bridge0' in vm._bridges
|
assert 'bridge0' in vm._bridges
|
||||||
# We need to check any_order otherwise mock is confused by asyncio
|
# 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 = [
|
calls = [
|
||||||
call.send('bridge create bridge0'),
|
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('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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_add_ubridge_connection_invalid_adapter_number(vm):
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_adapter_udpate_nio_binding_bridge_not_started(vm):
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_adapter_add_nio_binding_invalid_adapter(vm):
|
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"}
|
"rhost": "127.0.0.1"}
|
||||||
nio = vm.manager.create_nio(nio)
|
nio = vm.manager.create_nio(nio)
|
||||||
await vm.adapter_add_nio_binding(0, 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:
|
with asyncio_patch("gns3server.compute.docker.DockerVM._ubridge_send") as delete_ubridge_mock:
|
||||||
await vm.adapter_remove_nio_binding(0)
|
await vm.adapter_remove_nio_binding(0)
|
||||||
assert vm._ethernet_adapters[0].get_nio(0) is None
|
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 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 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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@ -80,6 +80,10 @@ def _make_vm(compute_project, manager, environment=None, console_type="docker_ex
|
|||||||
extra_volumes=extra_volumes or [], adapters=adapters,
|
extra_volumes=extra_volumes or [], adapters=adapters,
|
||||||
)
|
)
|
||||||
vm._cid = "e90e34656842"
|
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
|
return vm
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -159,6 +159,37 @@ async def test_stop_tcp_has_no_socket_to_unlink(tmp_path, monkeypatch):
|
|||||||
await hyp.stop()
|
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
|
# start: fail-fast on an immediately-exiting uBridge
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user