Skip to content

Commit 4f2e7bb

Browse files
authored
CMLDEV-702 fix pyats console switch (#184)
* CMLDEV-702 - fix pyats console switching - default pyats terminal server ssh options disable agents and identities * Improve disconnect detection and reconnect
1 parent 6c0b0ef commit 4f2e7bb

File tree

1 file changed

+40
-12
lines changed

1 file changed

+40
-12
lines changed

virl2_client/models/cl_pyats.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,22 @@
4444

4545
from ..exceptions import PyatsDeviceNotFound, PyatsNotInstalled
4646

47-
_LOGGER = logging.getLogger(__name__)
48-
4947
if TYPE_CHECKING:
5048
from genie.libs.conf.device import Device
5149
from genie.libs.conf.testbed import Testbed
5250

5351
from .lab import Lab
5452

5553

54+
_LOGGER = logging.getLogger(__name__)
55+
56+
# Do not use any identity keys and agents with the terminal server
57+
# by default - the keys would be attempted before password, and may
58+
# exhaust the number of allowed attempts at the server
59+
# to use ssh keys, set the specific key path or set empty ssh_options
60+
DEFAULT_SSH_OPTIONS = "-o IdentitiesOnly=yes -o IdentityAgent=none"
61+
62+
5663
class ClPyats:
5764
def __init__(self, lab: Lab, hostname: str | None = None) -> None:
5865
"""
@@ -128,7 +135,7 @@ def sync_testbed(self, username: str, password: str) -> None:
128135
self._testbed = self._load_pyats_testbed(testbed_yaml)
129136
self.set_termserv_credentials(username, password)
130137

131-
def switch_pyats_serial_console(self, node_label: str, console_number: int) -> None:
138+
def switch_serial_console(self, node_label: str, console_number: int | str) -> None:
132139
"""
133140
Switch to different serial console that is used to execute PyAts commands
134141
should be executed after sync_testbed
@@ -142,23 +149,35 @@ def switch_pyats_serial_console(self, node_label: str, console_number: int) -> N
142149
except KeyError:
143150
raise PyatsDeviceNotFound(node_label)
144151

145-
connect_cmd = pyats_device.connections["a"]["command"]
146-
pyats_device.connections["a"]["command"] = connect_cmd[:-1] + console_number
152+
command = pyats_device.connections["a"]["command"]
153+
pyats_device.connections["a"]["command"] = command[:-1] + str(console_number)
147154

148155
def set_termserv_credentials(
149156
self,
150157
username: str | None = None,
151158
password: str | None = None,
152159
key_path: Path | str | None = None,
160+
ssh_options: str = DEFAULT_SSH_OPTIONS,
153161
) -> None:
162+
"""
163+
Configure how to connect to the SSH terminal server after the testbed
164+
was synced with the server; the username must be known before making
165+
any connections. Then either set the password, or path to an identity
166+
file if SSH authentication with public keys is set up on the server.
167+
By default, this function disables authentication agents and identity
168+
files that would be loaded from the environment and running user ssh
169+
configuration, so that the passed password or key is attempted first.
170+
Pass empty string or custom SSH options to override this behavior.
171+
"""
154172
terminal = self._testbed.devices.terminal_server
155173
if username is not None:
156174
terminal.credentials.default.username = username
157175
if password is not None:
158176
terminal.credentials.default.password = password
159-
if key_path is not None:
160-
ssh_options = f"-o IdentitiesOnly=yes -o IdentityFile={key_path}"
161177
terminal.connections.cli.ssh_options = ssh_options
178+
if key_path is not None:
179+
ssh_options += f" -o IdentityFile={key_path}"
180+
terminal.connections.cli.ssh_options = ssh_options
162181

163182
def _prepare_params(
164183
self,
@@ -181,8 +200,20 @@ def _prepare_params(
181200
params["init_config_commands"] = init_config_commands
182201
return params
183202

203+
def _is_connected(self, pyats_device: "Device") -> bool:
204+
"""Helper method to see if the device appears connected"""
205+
if pyats_device not in self._connections or not pyats_device.is_connected():
206+
return False
207+
try:
208+
spawn = pyats_device.connectionmgr.connections.cli.spawn
209+
return bool(spawn.fd)
210+
except (TypeError, AttributeError):
211+
return False
212+
184213
def _reconnect(self, pyats_device: "Device", params: dict) -> None:
185214
"""Helper method to reconnect a PyATS device with proper cleanup."""
215+
if self._is_connected(pyats_device):
216+
return
186217
self._destroy_device(pyats_device, raise_exc=False)
187218
try:
188219
pyats_device.connect(
@@ -233,10 +264,8 @@ def _execute_command(
233264
init_exec_commands, init_config_commands, **pyats_params
234265
)
235266

236-
if pyats_device not in self._connections or not pyats_device.is_connected():
237-
self._reconnect(pyats_device, params)
238-
239267
try:
268+
self._reconnect(pyats_device, params)
240269
if configure_mode:
241270
return pyats_device.configure(command, log_stdout=False, **params)
242271
return pyats_device.execute(command, log_stdout=False, **params)
@@ -249,7 +278,6 @@ def _execute_command(
249278
_LOGGER.info(
250279
f"PyATS command failed on node {node_label}, retrying after reconnection. Reason: {retry_reason}"
251280
)
252-
self._reconnect(pyats_device, params)
253281
return self._execute_command(
254282
node_label,
255283
command,
@@ -353,7 +381,7 @@ def _destroy_device(self, pyats_device: "Device", raise_exc=True) -> None:
353381
self._connections.discard(pyats_device)
354382

355383

356-
def _analyze_execute_failure(exc: Exception) -> tuple[bool, str]:
384+
def _analyze_execute_failure(exc: Exception) -> tuple[bool, str | None]:
357385
should_raise = True
358386
retry_reason = None
359387

0 commit comments

Comments
 (0)