mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
(feat): Implement functional Layer 1 keepalive support for IOU/IOL nodes
This commit is contained in:
parent
7bc9ae4aa8
commit
dd497e4308
@ -54,6 +54,85 @@ import sys
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IOUL1KeepaliveProtocol(asyncio.DatagramProtocol):
|
||||
"""Handle IOU/IOL Layer 1 keepalives for connected interfaces."""
|
||||
|
||||
_header = struct.Struct("!HHBBBB")
|
||||
_message_type = 3
|
||||
|
||||
def __init__(self, vm):
|
||||
self._vm = vm
|
||||
self.transport = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
@staticmethod
|
||||
def encode_interface(adapter_number, port_number):
|
||||
"""Encode an IOU bay/unit for the L1 keepalive protocol."""
|
||||
|
||||
# IOU stores the zero-based unit in the high nibble and the
|
||||
# zero-based bay in the low nibble.
|
||||
return (port_number << 4) | adapter_number
|
||||
|
||||
@staticmethod
|
||||
def decode_interface(interface):
|
||||
"""Decode an L1 keepalive interface into an IOU bay/unit."""
|
||||
|
||||
return interface & 0x0F, interface >> 4
|
||||
|
||||
def datagram_received(self, data, address):
|
||||
if len(data) != self._header.size:
|
||||
log.debug('IOU "%s": ignored malformed L1 keepalive of %d bytes', self._vm.name, len(data))
|
||||
return
|
||||
|
||||
destination, source, destination_interface, source_interface, message_type, channel = self._header.unpack(data)
|
||||
if (
|
||||
destination != self._vm.l1_bridge_id
|
||||
or source != self._vm.application_id
|
||||
or message_type != self._message_type
|
||||
or not self._vm.has_nio_for_iou_interface(source_interface)
|
||||
):
|
||||
return
|
||||
|
||||
response = self._header.pack(
|
||||
source,
|
||||
destination,
|
||||
source_interface,
|
||||
destination_interface,
|
||||
message_type,
|
||||
channel,
|
||||
)
|
||||
try:
|
||||
self.transport.sendto(response, self._vm.l1_iou_socket_path)
|
||||
except OSError as e:
|
||||
# IOU creates its endpoint during startup and removes it on stop.
|
||||
# Dropping a keepalive during either transition is harmless.
|
||||
log.debug('IOU "%s": could not send an L1 keepalive response: %s', self._vm.name, e)
|
||||
|
||||
def send_keepalives(self):
|
||||
"""Tell IOU that every interface with an attached NIO has Layer 1 connectivity."""
|
||||
|
||||
for adapter_number, adapter in enumerate(self._vm.adapters):
|
||||
for port_number, nio in adapter.ports.items():
|
||||
if nio is None:
|
||||
continue
|
||||
interface = self.encode_interface(adapter_number, port_number)
|
||||
keepalive = self._header.pack(
|
||||
self._vm.application_id,
|
||||
self._vm.l1_bridge_id,
|
||||
interface,
|
||||
interface,
|
||||
self._message_type,
|
||||
0,
|
||||
)
|
||||
try:
|
||||
self.transport.sendto(keepalive, self._vm.l1_iou_socket_path)
|
||||
except OSError as e:
|
||||
# The IOU endpoint does not exist until the image has started.
|
||||
log.debug('IOU "%s": could not send an L1 keepalive: %s', self._vm.name, e)
|
||||
|
||||
|
||||
class IOUVM(BaseNode):
|
||||
module_name = "iou"
|
||||
|
||||
@ -98,6 +177,8 @@ class IOUVM(BaseNode):
|
||||
self._lib_base = self.manager.get_images_directory()
|
||||
self._loader = None
|
||||
self._license_check = True
|
||||
self._l1_keepalive_transport = None
|
||||
self._l1_keepalive_task = None
|
||||
|
||||
# IOU settings
|
||||
self._ethernet_adapters = []
|
||||
@ -110,7 +191,7 @@ class IOUVM(BaseNode):
|
||||
self._private_config = ""
|
||||
self._ram = 1024 # Megabytes
|
||||
self._application_id = application_id
|
||||
self._l1_keepalives = False # used to overcome the always-up Ethernet interfaces (not supported by all IOSes).
|
||||
self._l1_keepalives = False
|
||||
|
||||
def _nvram_changed(self, path):
|
||||
"""
|
||||
@ -637,6 +718,10 @@ class IOUVM(BaseNode):
|
||||
raise IOUError(f"Could not create symbolic link: {e}")
|
||||
|
||||
command = await self._build_command()
|
||||
# Only start the responder when the capability probe actually
|
||||
# enabled IOU's L1 protocol on the command line.
|
||||
if "-l" in command:
|
||||
await self._start_l1_keepalive_responder()
|
||||
try:
|
||||
if self._loader:
|
||||
log.info(f"Starting IOU: {command} with loader {self._loader}")
|
||||
@ -657,8 +742,10 @@ class IOUVM(BaseNode):
|
||||
callback = functools.partial(self._termination_callback, "IOU")
|
||||
gns3server.utils.asyncio.monitor_process(self._iou_process, callback)
|
||||
except FileNotFoundError as e:
|
||||
self._stop_l1_keepalive_responder()
|
||||
raise IOUError(f"Could not start IOU: {e}: 32-bit binary support is probably not installed, it is recommended to use a 64-bit image instead")
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
self._stop_l1_keepalive_responder()
|
||||
iou_stdout = self.read_iou_stdout()
|
||||
log.error(f"Could not start IOU {self._path}: {e}\n{iou_stdout}")
|
||||
raise IOUError(f"Could not start IOU {self._path}: {e}\n{iou_stdout}")
|
||||
@ -760,6 +847,7 @@ class IOUVM(BaseNode):
|
||||
"""
|
||||
|
||||
self._terminate_process_iou()
|
||||
self._stop_l1_keepalive_responder()
|
||||
if returncode != 0:
|
||||
if returncode == -11:
|
||||
message = 'IOU VM "{}" process has stopped with return code: {} (segfault). This could be an issue with the IOU image, using a different image may fix this.\n{}'.format(
|
||||
@ -792,6 +880,7 @@ class IOUVM(BaseNode):
|
||||
Stops the IOU process.
|
||||
"""
|
||||
|
||||
self._stop_l1_keepalive_responder()
|
||||
await self._stop_ubridge()
|
||||
if self._nvram_watcher:
|
||||
self._nvram_watcher.close()
|
||||
@ -894,6 +983,83 @@ class IOUVM(BaseNode):
|
||||
except OSError as e:
|
||||
raise IOUError(f"Could not create {netmap_path}: {e}")
|
||||
|
||||
@property
|
||||
def l1_bridge_id(self):
|
||||
return self.application_id + 512
|
||||
|
||||
@property
|
||||
def l1_socket_directory(self):
|
||||
# IOU hard-codes this directory independently from TMPDIR.
|
||||
return os.path.join("/tmp", f"netl1{os.geteuid()}")
|
||||
|
||||
@property
|
||||
def l1_bridge_socket_path(self):
|
||||
return os.path.join(self.l1_socket_directory, f"L1{self.l1_bridge_id}")
|
||||
|
||||
@property
|
||||
def l1_iou_socket_path(self):
|
||||
return os.path.join(self.l1_socket_directory, f"L1{self.application_id}")
|
||||
|
||||
def has_nio_for_iou_interface(self, interface):
|
||||
"""Return whether the IOU bay/unit encoded in one byte is connected."""
|
||||
|
||||
adapter_number, port_number = IOUL1KeepaliveProtocol.decode_interface(interface)
|
||||
if adapter_number >= len(self._adapters):
|
||||
return False
|
||||
adapter = self._adapters[adapter_number]
|
||||
return adapter.port_exists(port_number) and adapter.get_nio(port_number) is not None
|
||||
|
||||
async def _start_l1_keepalive_responder(self):
|
||||
"""Create the bridge-side UNIX datagram endpoint used by IOU's ``-l`` option."""
|
||||
|
||||
if self._l1_keepalive_transport is not None:
|
||||
return
|
||||
|
||||
socket_directory = self.l1_socket_directory
|
||||
try:
|
||||
os.makedirs(socket_directory, mode=0o755, exist_ok=True)
|
||||
if os.path.islink(socket_directory) or os.stat(socket_directory).st_uid != os.geteuid():
|
||||
raise IOUError(f"Unsafe IOU L1 keepalive directory '{socket_directory}'")
|
||||
if os.path.lexists(self.l1_bridge_socket_path):
|
||||
os.unlink(self.l1_bridge_socket_path)
|
||||
loop = asyncio.get_running_loop()
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
lambda: IOUL1KeepaliveProtocol(self),
|
||||
local_addr=self.l1_bridge_socket_path,
|
||||
family=socket.AF_UNIX,
|
||||
)
|
||||
self._l1_keepalive_transport = transport
|
||||
self._l1_keepalive_task = asyncio.create_task(self._send_l1_keepalives(protocol))
|
||||
log.info(
|
||||
'IOU "%s" [%s]: L1 keepalive responder listening on %s',
|
||||
self._name,
|
||||
self._id,
|
||||
self.l1_bridge_socket_path,
|
||||
)
|
||||
except (OSError, RuntimeError) as e:
|
||||
self._stop_l1_keepalive_responder()
|
||||
raise IOUError(f"Could not start IOU L1 keepalive responder: {e}")
|
||||
|
||||
async def _send_l1_keepalives(self, protocol):
|
||||
while self._l1_keepalive_transport is not None:
|
||||
protocol.send_keepalives()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def _stop_l1_keepalive_responder(self):
|
||||
"""Stop the L1 endpoint and remove its bridge-side socket."""
|
||||
|
||||
if self._l1_keepalive_task is not None:
|
||||
self._l1_keepalive_task.cancel()
|
||||
self._l1_keepalive_task = None
|
||||
if self._l1_keepalive_transport is not None:
|
||||
self._l1_keepalive_transport.close()
|
||||
self._l1_keepalive_transport = None
|
||||
try:
|
||||
if os.path.lexists(self.l1_bridge_socket_path):
|
||||
os.unlink(self.l1_bridge_socket_path)
|
||||
except OSError as e:
|
||||
log.warning('Could not remove IOU L1 keepalive socket "%s": %s', self.l1_bridge_socket_path, e)
|
||||
|
||||
async def _build_command(self):
|
||||
"""
|
||||
Command to start the IOU process.
|
||||
@ -1268,8 +1434,9 @@ class IOUVM(BaseNode):
|
||||
"""
|
||||
|
||||
env = os.environ.copy()
|
||||
if "IOURC" not in os.environ:
|
||||
env["IOURC"] = self.iourc_path
|
||||
iourc_path = self.iourc_path
|
||||
if "IOURC" not in os.environ and iourc_path:
|
||||
env["IOURC"] = iourc_path
|
||||
try:
|
||||
output = await gns3server.utils.asyncio.subprocess_check_output(
|
||||
*self._loader, self._path, "-h", cwd=self.working_dir, env=env, stderr=True
|
||||
|
||||
@ -38,7 +38,10 @@ class IOUBase(BaseModel):
|
||||
ethernet_adapters: Optional[int] = Field(None, description="How many Ethernet adapters are connected to IOU")
|
||||
ram: Optional[int] = Field(None, gt=0, description="Amount of RAM in MB")
|
||||
nvram: Optional[int] = Field(None, gt=0, description="Amount of NVRAM in KB")
|
||||
l1_keepalives: Optional[bool] = Field(None, description="Use default IOU values")
|
||||
l1_keepalives: Optional[bool] = Field(
|
||||
None,
|
||||
description="Enable Layer 1 keepalives so IOU interfaces report accurate link state",
|
||||
)
|
||||
use_default_iou_values: Optional[bool] = Field(None, description="Use default IOU values")
|
||||
startup_config_content: Optional[str] = Field(None, description="Content of IOU startup configuration file")
|
||||
private_config_content: Optional[str] = Field(None, description="Content of IOU private configuration file")
|
||||
|
||||
@ -35,7 +35,10 @@ class IOUTemplate(TemplateBase):
|
||||
use_default_iou_values: Optional[bool] = Field(False, description="Use default IOU values")
|
||||
startup_config: Optional[str] = Field("iou_l3_base_startup-config.txt", description="Startup-config of IOU")
|
||||
private_config: Optional[str] = Field("", description="Private-config of IOU")
|
||||
l1_keepalives: Optional[bool] = Field(False, description="Always keep up Ethernet interface (does not always work)")
|
||||
l1_keepalives: Optional[bool] = Field(
|
||||
False,
|
||||
description="Enable Layer 1 keepalives so IOU interfaces report accurate link state",
|
||||
)
|
||||
console_type: Optional[ConsoleType] = Field(ConsoleType.telnet, description="Console type")
|
||||
console_auto_start: Optional[bool] = Field(
|
||||
False, description="Automatically start the console when the node has started"
|
||||
|
||||
@ -21,13 +21,14 @@ import asyncio
|
||||
import os
|
||||
import stat
|
||||
import socket
|
||||
import struct
|
||||
import uuid
|
||||
import shutil
|
||||
|
||||
from tests.utils import asyncio_patch, AsyncioMagicMock
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from gns3server.compute.iou.iou_vm import IOUVM
|
||||
from unittest.mock import MagicMock, call
|
||||
from gns3server.compute.iou.iou_vm import IOUL1KeepaliveProtocol, IOUVM
|
||||
from gns3server.compute.iou.iou_error import IOUError
|
||||
from gns3server.compute.iou import IOU
|
||||
|
||||
@ -76,6 +77,7 @@ def test_vm(compute_project, manager):
|
||||
vm = IOUVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
|
||||
assert vm.name == "test"
|
||||
assert vm.id == "00010203-0405-0607-0809-0a0b0c0d0e0f"
|
||||
assert vm.l1_keepalives is False
|
||||
|
||||
|
||||
def test_vm_startup_config_content(compute_project, manager):
|
||||
@ -111,6 +113,45 @@ async def test_start(vm):
|
||||
vm._ubridge_send.assert_any_call("iol_bridge start IOL-BRIDGE-513")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_does_not_start_l1_responder_without_l_option(vm):
|
||||
|
||||
process = MagicMock(returncode=None)
|
||||
process.communicate = AsyncioMagicMock(return_value=(None, None))
|
||||
vm.l1_keepalives = True
|
||||
vm._check_requirements = AsyncioMagicMock(return_value=True)
|
||||
vm._check_iou_license = AsyncioMagicMock(return_value=True)
|
||||
vm._start_ubridge = AsyncioMagicMock(return_value=True)
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
vm._build_command = AsyncioMagicMock(return_value=[vm.path, str(vm.application_id)])
|
||||
vm._start_l1_keepalive_responder = AsyncioMagicMock()
|
||||
|
||||
with asyncio_patch("asyncio.create_subprocess_exec", return_value=process):
|
||||
await vm.start()
|
||||
|
||||
vm._start_l1_keepalive_responder.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_failure_stops_l1_responder(vm):
|
||||
|
||||
vm.l1_keepalives = True
|
||||
vm._check_requirements = AsyncioMagicMock(return_value=True)
|
||||
vm._check_iou_license = AsyncioMagicMock(return_value=True)
|
||||
vm._start_ubridge = AsyncioMagicMock(return_value=True)
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
vm._build_command = AsyncioMagicMock(return_value=[vm.path, "-l", str(vm.application_id)])
|
||||
vm._start_l1_keepalive_responder = AsyncioMagicMock()
|
||||
vm._stop_l1_keepalive_responder = MagicMock()
|
||||
|
||||
with asyncio_patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("missing image")):
|
||||
with pytest.raises(IOUError):
|
||||
await vm.start()
|
||||
|
||||
vm._start_l1_keepalive_responder.assert_called_once_with()
|
||||
vm._stop_l1_keepalive_responder.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_with_iourc(vm, tmpdir, config):
|
||||
|
||||
@ -261,10 +302,155 @@ def test_create_netmap_config(vm):
|
||||
assert "513:15/3 1:15/3" in content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"adapter_number,port_number,interface",
|
||||
[
|
||||
(0, 0, 0x00),
|
||||
(0, 1, 0x10),
|
||||
(0, 2, 0x20),
|
||||
(0, 3, 0x30),
|
||||
(1, 0, 0x01),
|
||||
(1, 3, 0x31),
|
||||
(15, 3, 0x3F),
|
||||
],
|
||||
)
|
||||
def test_l1_keepalive_interface_encoding(adapter_number, port_number, interface):
|
||||
|
||||
assert IOUL1KeepaliveProtocol.encode_interface(adapter_number, port_number) == interface
|
||||
assert IOUL1KeepaliveProtocol.decode_interface(interface) == (adapter_number, port_number)
|
||||
|
||||
|
||||
def test_l1_keepalive_response_for_connected_interface(vm):
|
||||
|
||||
vm._adapters[0].add_nio(1, MagicMock())
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
keepalive = struct.pack("!HHBBBB", 513, 1, 0x10, 0x10, 3, 0)
|
||||
protocol.datagram_received(keepalive, None)
|
||||
|
||||
transport.sendto.assert_called_once_with(
|
||||
struct.pack("!HHBBBB", 1, 513, 0x10, 0x10, 3, 0),
|
||||
vm.l1_iou_socket_path,
|
||||
)
|
||||
|
||||
|
||||
def test_l1_keepalive_sent_for_connected_interface(vm):
|
||||
|
||||
vm._adapters[0].add_nio(2, MagicMock())
|
||||
vm._adapters[0].add_nio(3, MagicMock())
|
||||
vm._adapters[1].add_nio(2, MagicMock())
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
protocol.send_keepalives()
|
||||
|
||||
assert transport.sendto.call_args_list == [
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x20, 0x20, 3, 0), vm.l1_iou_socket_path),
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x30, 0x30, 3, 0), vm.l1_iou_socket_path),
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x21, 0x21, 3, 0), vm.l1_iou_socket_path),
|
||||
]
|
||||
|
||||
|
||||
def test_l1_keepalives_preserve_mixed_iou_interface_numbers(vm):
|
||||
|
||||
vm.ethernet_adapters = 4
|
||||
vm.serial_adapters = 2
|
||||
vm._adapters[0].add_nio(0, MagicMock()) # Ethernet0/0
|
||||
vm._adapters[1].add_nio(1, MagicMock()) # Ethernet1/1
|
||||
vm._adapters[2].add_nio(2, MagicMock()) # Ethernet2/2
|
||||
vm._adapters[4].add_nio(0, MagicMock()) # Serial4/0
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
protocol.send_keepalives()
|
||||
|
||||
assert transport.sendto.call_args_list == [
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x00, 0x00, 3, 0), vm.l1_iou_socket_path),
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x11, 0x11, 3, 0), vm.l1_iou_socket_path),
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x22, 0x22, 3, 0), vm.l1_iou_socket_path),
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x04, 0x04, 3, 0), vm.l1_iou_socket_path),
|
||||
]
|
||||
|
||||
|
||||
def test_l1_keepalive_response_for_serial4_0(vm):
|
||||
|
||||
vm.ethernet_adapters = 4
|
||||
vm.serial_adapters = 2
|
||||
vm._adapters[4].add_nio(0, MagicMock())
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
protocol.datagram_received(struct.pack("!HHBBBB", 513, 1, 0x04, 0x04, 3, 0), None)
|
||||
|
||||
transport.sendto.assert_called_once_with(
|
||||
struct.pack("!HHBBBB", 1, 513, 0x04, 0x04, 3, 0),
|
||||
vm.l1_iou_socket_path,
|
||||
)
|
||||
|
||||
|
||||
def test_stop_l1_keepalive_responder_cleans_up(vm):
|
||||
|
||||
task = MagicMock()
|
||||
transport = MagicMock()
|
||||
vm._l1_keepalive_task = task
|
||||
vm._l1_keepalive_transport = transport
|
||||
|
||||
with asyncio_patch("os.path.lexists", return_value=False):
|
||||
vm._stop_l1_keepalive_responder()
|
||||
vm._stop_l1_keepalive_responder()
|
||||
|
||||
task.cancel.assert_called_once_with()
|
||||
transport.close.assert_called_once_with()
|
||||
assert vm._l1_keepalive_task is None
|
||||
assert vm._l1_keepalive_transport is None
|
||||
|
||||
|
||||
def test_l1_keepalive_ignored_for_disconnected_interface(vm):
|
||||
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
protocol.datagram_received(struct.pack("!HHBBBB", 513, 1, 0x00, 0x00, 3, 0), None)
|
||||
|
||||
transport.sendto.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"keepalive",
|
||||
[
|
||||
b"invalid",
|
||||
struct.pack("!HHBBBB", 514, 1, 0x00, 0x00, 3, 0),
|
||||
struct.pack("!HHBBBB", 513, 2, 0x00, 0x00, 3, 0),
|
||||
struct.pack("!HHBBBB", 513, 1, 0x00, 0x00, 2, 0),
|
||||
struct.pack("!HHBBBB", 513, 1, 0x04, 0x04, 3, 0),
|
||||
struct.pack("!HHBBBB", 513, 1, 0x40, 0x40, 3, 0),
|
||||
],
|
||||
)
|
||||
def test_invalid_l1_keepalive_is_ignored(vm, keepalive):
|
||||
|
||||
vm._adapters[0].add_nio(0, MagicMock())
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
protocol.datagram_received(keepalive, None)
|
||||
|
||||
transport.sendto.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_command(vm):
|
||||
|
||||
assert await vm._build_command() == [vm.path, "-n", "256", "-m", "1024", str(vm.application_id)]
|
||||
vm.l1_keepalives = True
|
||||
help_output = "-l\t\tEnable Layer 1 keepalive messages\n"
|
||||
with asyncio_patch("gns3server.utils.asyncio.subprocess_check_output", return_value=help_output):
|
||||
assert await vm._build_command() == [vm.path, "-n", "256", "-m", "1024", "-l", str(vm.application_id)]
|
||||
|
||||
|
||||
def test_get_startup_config(vm):
|
||||
@ -350,7 +536,7 @@ async def test_enable_l1_keepalives(vm):
|
||||
command = ["test"]
|
||||
with pytest.raises(IOUError):
|
||||
await vm._enable_l1_keepalives(command)
|
||||
assert command == ["test"]
|
||||
assert command == ["test"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user