4444
4545from ..exceptions import PyatsDeviceNotFound , PyatsNotInstalled
4646
47- _LOGGER = logging .getLogger (__name__ )
48-
4947if 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+
5663class 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