codecraftai-protocol-ssh 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,780 @@
1
+ """
2
+ .. module:: sshbase
3
+ :platform: Darwin, Linux, Unix, Windows
4
+ :synopsis: Module containing the :class:`SshBase` class.
5
+
6
+ .. moduleauthor:: Myron Walker <myron.walker@gmail.com>
7
+
8
+ """
9
+
10
+ __author__ = "Myron Walker"
11
+ __copyright__ = "Copyright 2025, Code Craft AI LLC"
12
+ __credits__ = []
13
+
14
+
15
+ from typing import Dict, Optional, Sequence, Union, Tuple
16
+
17
+ from ccai.xmodules.xlogging.enhancedloggers import getEnhancedLogger
18
+ import os
19
+ import socket
20
+ import stat
21
+ import threading
22
+ import time
23
+
24
+ from datetime import datetime, timedelta
25
+ from io import StringIO
26
+
27
+ from ccai.protocols.ssh.sshclient import SshClient
28
+ from ccai.protocols.ssh.sshkeys import SshRSAKey, SshEd25519Key
29
+ from ccai.protocols.ssh.sshconnection import SshChannel
30
+
31
+ from ccai.waiting.waitmodel import TimeoutContext
32
+
33
+ from ccai.errors.exceptions import ConfigurationError, AbstractMethodError, SemanticError
34
+
35
+ from ccai.interop.aspects import ActionPattern, AspectsCmd, LoggingPattern, DEFAULT_CMD_ASPECTS
36
+ from ccai.credentials.sshcredential import SshCredential
37
+ from ccai.interop.interfaces.isystemcontext import ISystemContext
38
+ from ccai.xmodules.xformatting import indent_lines
39
+ from ccai.xmodules.xlogging.monitoredscope import MonitoredScope
40
+
41
+ from ccai.protocols.ssh.sshconst import (
42
+ SshJumpParams,
43
+ DEFAULT_SSH_TIMEOUT,
44
+ DEFAULT_SSH_RETRY_INTERVAL,
45
+ TEMPLATE_COMMAND_FAILURE,
46
+ TEMPLATE_COMMAND_SUCCESS
47
+ )
48
+ from ccai.protocols.ssh.sshhelpers import (
49
+ primitive_directory_exists,
50
+ primitive_file_exists,
51
+ primitive_file_pull,
52
+ primitive_file_push,
53
+ primitive_list_directory,
54
+ primitive_list_tree,
55
+ sftp_list_directory,
56
+ sftp_list_tree,
57
+ ssh_execute_command
58
+ )
59
+
60
+ logger = getEnhancedLogger()
61
+
62
+
63
+ class SshBase(ISystemContext):
64
+ """
65
+ The :class:`SshBase` object provides for the sharing of state and fuctional patterns
66
+ for APIs between the :class:`SshSession` object and the :class:`SshAgent`.
67
+ """
68
+ def __init__(self, host: str, primary_credential: SshCredential, users: Optional[Dict[str, SshCredential]] = None,
69
+ port: int = 22, jump: Union[str, SshJumpParams, None] = None, pty_params: Optional[dict] = None,
70
+ called_id: Optional[str]=None, look_for_keys: bool = False,
71
+ aspects: AspectsCmd = DEFAULT_CMD_ASPECTS):
72
+ """
73
+ Initialize the base SSH helper class.
74
+
75
+ :param host: Target host to connect to.
76
+ :param primary_credential: Primary credential for authentication.
77
+ :param users: Optional dictionary of additional users.
78
+ :param port: SSH port number.
79
+ :param jump: Optional jump host specification.
80
+ :param pty_params: Optional PTY parameters for interactive sessions.
81
+ :param called_id: Identifier used in logs.
82
+ :param look_for_keys: If ``True`` search for SSH keys automatically.
83
+ :param aspects: Default command aspects for operations.
84
+ """
85
+ self._host = host
86
+
87
+ self._primary_credential = primary_credential
88
+ self._username = primary_credential.username
89
+ self._password = primary_credential.password
90
+ self._keyfile = None
91
+ if hasattr(primary_credential, "keyfile"):
92
+ self._keyfile = primary_credential.keyfile
93
+ self._keyraw = None
94
+ if hasattr(primary_credential, "keyraw"):
95
+ self._keyraw = primary_credential.keyraw
96
+ self._keypasswd = None
97
+ if hasattr(primary_credential, "keypasswd"):
98
+ self._keypasswd = primary_credential.keypasswd
99
+ self._allow_agent = None
100
+ if hasattr(primary_credential, "allow_agent"):
101
+ self._allow_agent = primary_credential.allow_agent
102
+ self._primitive = None
103
+ if hasattr(primary_credential, "primitive"):
104
+ self._primitive = primary_credential.primitive
105
+
106
+ self._users = users
107
+ self._port = port
108
+ self._jump = jump
109
+ self._pty_params = pty_params
110
+ self._called_id = called_id
111
+
112
+ self._target_tag = host
113
+ if self._called_id is not None:
114
+ self._target_tag = "[{}]".format(called_id)
115
+
116
+ self._look_for_keys = look_for_keys
117
+ self._aspects = aspects
118
+
119
+ self._ipaddr = socket.gethostbyname(self._host)
120
+
121
+ self._user_lookup_table = {}
122
+ self._group_lookup_table = {}
123
+
124
+ if self._password is None and self._keyfile is None and self._keyraw is None and self._allow_agent is not True:
125
+ raise ConfigurationError("SshAgent requires either a 'password', an identity 'keyfile', 'keyraw' or allow_agent=True be specified.") from None
126
+ return
127
+
128
+ @property
129
+ def aspects(self) -> AspectsCmd:
130
+ """
131
+ The logging, iteration and other aspects that have been assigned to be used with interacting with the remote SSH service.
132
+ """
133
+ return self._aspects
134
+
135
+ @property
136
+ def host(self) -> str:
137
+ """
138
+ The target SSH host machine.
139
+ """
140
+ return self._host
141
+
142
+ @property
143
+ def ipaddr(self) -> str:
144
+ """
145
+ The IP address that was found to be associated with the specified host machine.
146
+ """
147
+ return self._ipaddr
148
+
149
+ @property
150
+ def jump(self):
151
+ """
152
+ A 'ProxyCommand' jump command that can be used for Advanced Server Access.
153
+ """
154
+ return self._jump
155
+
156
+ @property
157
+ def look_for_keys(self):
158
+ """
159
+ A value indicating if paramiko should search for keys.
160
+ """
161
+ return self._look_for_keys
162
+
163
+ @property
164
+ def port(self) -> int:
165
+ """
166
+ The port to use for SSH communications. The default is 22.
167
+ """
168
+ return self._port
169
+
170
+ @property
171
+ def primary_credential(self):
172
+ """
173
+ Returns the 'primary' user credential provided for this ssh client object.
174
+ """
175
+ return self._primary_credential
176
+
177
+ @property
178
+ def pty_params(self) -> dict:
179
+ """
180
+ A dictionary of parameters that are to be passed when getting a PTY when interacting with the remote SSH server.
181
+ """
182
+ return self._pty_params
183
+
184
+ @property
185
+ def users(self) -> Dict[str, SshCredential]:
186
+ """
187
+ A dictionary of roles and user credentials that can be used for interaction with the remote SSH server.
188
+ """
189
+ return self._users
190
+
191
+ def lookup_user_by_uid(self, uid: int, update: bool = True) -> Union[str, None]:
192
+ """
193
+ A helper method used to lookup cached user information by uid.
194
+
195
+ :param uid: The uid of a user.
196
+ :param update: A boolean indicating that the table should be updated if the uid is not found.
197
+
198
+ :returns: The username associated with ``uid`` or ``None`` if not found.
199
+ """
200
+ username = None
201
+
202
+ if uid in self._user_lookup_table:
203
+ username = self._user_lookup_table[uid]
204
+ elif update:
205
+ status, stdout, _ = self.run_cmd("id -n -u %d" % uid)
206
+ if status == 0:
207
+ username = stdout.strip()
208
+ self._user_lookup_table[uid] = username
209
+
210
+ return username
211
+
212
+ def lookup_group_by_gid(self, gid: int, update: bool = True) -> Union[str, None]:
213
+ """
214
+ A helper method used to lookup cached group information by gid.
215
+
216
+ :param gid: The gid of a group.
217
+ :param update: A boolean indicating that the table should be updated if the gid is not found.
218
+
219
+ :returns: The name of the group associated with the group id or None.
220
+ """
221
+ grpname = None
222
+
223
+ if gid in self._group_lookup_table:
224
+ grpname = self._group_lookup_table[gid]
225
+ elif update:
226
+ status, stdout, _ = self.run_cmd("id -n -g %d" % gid)
227
+ if status == 0:
228
+ grpname = stdout.strip()
229
+ self._group_lookup_table[gid] = grpname
230
+
231
+ return grpname
232
+
233
+ def reboot(self, aspects: Optional[AspectsCmd] = None):
234
+ """
235
+ Reboots the designated host by running the 'reboot' command on the host.
236
+
237
+ ..note: This method is not a typical SSH functionality, but it was added so that
238
+ automatic reconnects can be implemented as required on session objects.
239
+
240
+ """
241
+ raise AbstractMethodError("SshBase.reboot must be overloaded by derived class '%s'." % type(self).__name__) from None
242
+
243
+ def run_cmd(self, command: str, exp_status: Union[int, Sequence]=0, user: str = None, pty_params: dict = None, aspects: Optional[AspectsCmd] = None) -> Tuple[int, str, str]:
244
+ """
245
+ Runs a command on the designated host using the specified parameters.
246
+
247
+ :param command: The command to run.
248
+ :param exp_status: An integer or sequence of integers that specify the set of expected status codes from the command.
249
+ :param user: The registered name of the user role to use to lookup the credentials for running the command.
250
+ :param pty_params: The pty parameters to use to request a PTY when running the command.
251
+ :param aspects: The run aspects to use when running the command.
252
+
253
+ :returns: The status, stderr and stdout from the command that was run.
254
+ """
255
+ raise AbstractMethodError("SshBase.run_cmd must be overloaded by derived class '%s'." % type(self).__name__) from None
256
+
257
+ def verify_connectivity(self) -> bool:
258
+ """
259
+ Method that can be used to verify connectivity to the target computer.
260
+
261
+ :returns: A boolean value indicating whether connectivity with the remote machine was successful.
262
+ """
263
+ vok = False
264
+
265
+ hello_cmd = "echo Hello"
266
+ status, stdout, _ = self.run_cmd(hello_cmd)
267
+ if status == 0 and stdout.strip() == "Hello":
268
+ vok = True
269
+
270
+ return vok
271
+
272
+ def _create_client(self, session_user: Optional[str] = None) -> SshClient:
273
+ """
274
+ Create an SSHClient to use for running commands and performing FTP operations.
275
+
276
+ :param session_user: The user role and associated credentials to use when creating the SSHClient.
277
+
278
+ :returns: An SSHClient object connected to the remote machine under the default or specified user credential.
279
+ """
280
+ cl_username = self._username
281
+ cl_password = self._password
282
+ cl_keyfile = self._keyfile
283
+ cl_keyraw = self._keyraw
284
+ cl_keypasswd = self._keypasswd
285
+ cl_allow_agent = self._allow_agent
286
+
287
+ now_time = datetime.now()
288
+ begin_time = now_time
289
+ end_time = begin_time + timedelta(seconds=self._aspects.connection_timeout)
290
+
291
+ connection_interval = self._aspects.connection_interval
292
+ allowed_connection_exceptions = self._aspects.allowed_connection_exceptions
293
+
294
+ ssh_client = None
295
+ while now_time < end_time:
296
+
297
+ try:
298
+
299
+ if session_user is not None:
300
+ if session_user not in self._users:
301
+ errmsg_list = [
302
+ "No credentials found for the specified user '%s'." % session_user
303
+ ]
304
+ errmsg = os.linesep.join(errmsg_list)
305
+ raise ConfigurationError(errmsg) from None
306
+
307
+ user_creds = self._users[session_user]
308
+
309
+ cl_username = user_creds.username
310
+ cl_password = user_creds.password
311
+ cl_keyfile = user_creds.keyfile
312
+ cl_keyraw = user_creds.keyraw
313
+ cl_keypasswd = user_creds.keypasswd
314
+ cl_allow_agent = user_creds.allow_agent
315
+
316
+ # Validate that required credentials are present
317
+ if cl_username is None:
318
+ raise ConfigurationError(f"Username is required for session user '{session_user}' but was not provided in user credentials.") from None
319
+
320
+ # Ensure at least one authentication method is available
321
+ if cl_password is None and cl_keyfile is None and cl_keyraw is None and not cl_allow_agent:
322
+ raise ConfigurationError(f"Session user '{session_user}' requires either a 'password', an identity 'keyfile', 'keyraw' or allow_agent=True be specified.") from None
323
+
324
+ pkey = None
325
+ if cl_keyfile is not None:
326
+ pkey = self._load_key_file(cl_keyfile, cl_keypasswd)
327
+ elif cl_keyraw is not None:
328
+ pkey = self._load_key(cl_keyraw, cl_keypasswd)
329
+
330
+ ssh_client = SshClient()
331
+ # ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # TODO: Implement policy
332
+
333
+ if self._jump is not None:
334
+ # proxy = paramiko.ProxyCommand(self._jump)
335
+ logger.warning("Jump host (ProxyCommand) not yet supported in SshClient. Ignoring jump host.")
336
+ # ssh_client.connect(self._ipaddr, port=self._port, username=cl_username, password=cl_password,
337
+ # pkey=pkey, allow_agent=cl_allow_agent, sock = proxy, look_for_keys=self._look_for_keys)
338
+ ssh_client.connect(self._ipaddr, port=self._port, username=cl_username, password=cl_password,
339
+ pkey=pkey, allow_agent=cl_allow_agent, look_for_keys=self._look_for_keys)
340
+ else:
341
+ ssh_client.connect(self._ipaddr, port=self._port, username=cl_username, password=cl_password,
342
+ pkey=pkey, allow_agent=cl_allow_agent, look_for_keys=self._look_for_keys)
343
+
344
+ break
345
+
346
+ except BaseException as cerr:
347
+ # Clean up the failed ssh_client before handling the exception
348
+ if ssh_client is not None:
349
+ try:
350
+ ssh_client.close()
351
+ except Exception:
352
+ pass # Ignore errors when closing failed client
353
+ ssh_client = None
354
+
355
+ extype = type(cerr)
356
+
357
+ if len(allowed_connection_exceptions) > 0 and extype in allowed_connection_exceptions:
358
+ errmsg = f"SSH connection attempt failed for host={self._ipaddr} begin={begin_time} end={end_time} now={now_time}."
359
+ logger.error(errmsg)
360
+
361
+ now_time = datetime.now()
362
+ if now_time > end_time:
363
+ raise ConnectionError(errmsg) from cerr
364
+ else:
365
+ time.sleep(connection_interval)
366
+ else:
367
+ raise # If the exception we got is not an allowed type, re-raise it
368
+
369
+ # Ensure we have a valid connection before returning
370
+ if ssh_client is None:
371
+ errmsg = f"Failed to establish SSH connection to {self._ipaddr} within {self._aspects.connection_timeout} seconds."
372
+ raise ConnectionError(errmsg)
373
+
374
+ return ssh_client
375
+
376
+ def _directory(self, ssh_client: SshClient, root_dir: str) -> dict:
377
+ """
378
+ Private method that creates a directory listing for the folder.
379
+
380
+ :param ssh_client: The SSHClient to use to for geting a list tree.
381
+ :param root_dir: The directory to scan when creating the tree.
382
+
383
+ :returns: A dictionary that contains information about the items in the target directory.
384
+ """
385
+ dir_info = {}
386
+
387
+ if not self._primitive:
388
+ # transport = ssh_client.get_transport()
389
+
390
+ sftp = ssh_client.open_sftp()
391
+ try:
392
+ dir_info = sftp_list_directory(sftp, root_dir, self.lookup_user_by_uid, self.lookup_group_by_gid)
393
+ finally:
394
+ sftp.close()
395
+
396
+ else:
397
+ dir_info = primitive_list_directory(ssh_client, root_dir)
398
+
399
+ return dir_info
400
+
401
+ def _directory_exists(self, ssh_client: SshClient, remotedir: str) -> bool:
402
+ """
403
+ Private helper method used to check if the remote directory exists.
404
+ """
405
+ exists = False
406
+
407
+ if not self._primitive:
408
+ # transport = ssh_client.get_transport()
409
+
410
+ sftp = ssh_client.open_sftp()
411
+ try:
412
+ fsresult = sftp.stat(remotedir)
413
+ if stat.S_ISDIR(fsresult.st_mode):
414
+ exists = True
415
+ except (FileNotFoundError, IOError):
416
+ exists = False
417
+ finally:
418
+ sftp.close()
419
+
420
+ else:
421
+ exists = primitive_directory_exists(ssh_client, remotedir)
422
+
423
+ return exists
424
+
425
+ def _directory_tree(self, ssh_client: SshClient, root_dir: str, depth: int = 1) -> dict:
426
+ """
427
+ Private method that creates a directory tree for the folder.
428
+
429
+ :param ssh_client: The SSHClient to use to for geting a list tree.
430
+ :param root_dir: The root directory to scan when creating the tree.
431
+ :param depth: The depth to scan to
432
+
433
+ :returns: A dictionary with a tree of information about the directory tree found on the remote system.
434
+ """
435
+ dir_info = {}
436
+
437
+ if not self._primitive:
438
+ # transport = ssh_client.get_transport()
439
+
440
+ sftp = ssh_client.open_sftp()
441
+ try:
442
+ dir_info = sftp_list_tree(sftp, root_dir, self.lookup_user_by_uid, self.lookup_group_by_gid, max_depth=depth)
443
+ finally:
444
+ sftp.close()
445
+
446
+ else:
447
+ dir_info = primitive_list_tree(ssh_client, root_dir, max_depth=depth)
448
+
449
+ return dir_info
450
+
451
+ def _get_home_directory(self, aspects: Optional[AspectsCmd] = None) -> str:
452
+ """
453
+ Private method that handles the getting of the home directory for the credential account from a remote machine.
454
+
455
+ :param aspects: The run aspects to use when running the command.
456
+
457
+ :returns: The remote home directory
458
+ """
459
+ home_dir = None
460
+
461
+ home_cmd = 'echo "$HOME"'
462
+ status, stdout, stderr = self.run_cmd(home_cmd, aspects=aspects)
463
+ if status == 0:
464
+ home_dir = stdout.strip()
465
+
466
+ return home_dir
467
+
468
+ def _log_command_result(self, command: str, status: int, stdout: str, stderr: str, exp_status: Union[int, Sequence[int]], logging_pattern: LoggingPattern):
469
+ """
470
+ Private method that handles the logging of command results based on the expected status and logging pattern
471
+ specified.
472
+
473
+ :param command: The command that was run.
474
+ :param status: The status code returned from running the command.
475
+ :param stdout: The contents of the standard output from running the command.
476
+ :param stderr: The contents of the error output from running the command.
477
+ :param exp_status: The status code or possible status codes that was expected to be returned from running the command.
478
+ :param logging_pattern: The result logging pattern (LoggingPattern) to use when logging.
479
+ """
480
+ # pylint: disable=no-self-use
481
+
482
+ tgt_tag = ""
483
+ if self._target_tag is not None:
484
+ tgt_tag = self._target_tag
485
+
486
+ if isinstance(exp_status, int):
487
+ if status == exp_status:
488
+ if logging_pattern == LoggingPattern.ALL_RESULTS or logging_pattern == LoggingPattern.SUCCESS_ONLY:
489
+ logger.info(TEMPLATE_COMMAND_SUCCESS % (tgt_tag, command, stdout, stderr) )
490
+ else:
491
+ if status in exp_status:
492
+ if logging_pattern == LoggingPattern.ALL_RESULTS or logging_pattern == LoggingPattern.SUCCESS_ONLY:
493
+ logger.info(TEMPLATE_COMMAND_SUCCESS % (tgt_tag, command, stdout, stderr))
494
+ else:
495
+ if logging_pattern == LoggingPattern.ALL_RESULTS or logging_pattern == LoggingPattern.FAILURE_ONLY:
496
+ logger.error(TEMPLATE_COMMAND_FAILURE % (tgt_tag, command, stdout, stderr))
497
+
498
+ return
499
+
500
+ def _run_cmd(self, ssh_runner: Union[SshClient, SshChannel], command: str, exp_status: Union[int, Sequence]=0, user: str = None, pty_params: dict = None, aspects: Optional[AspectsCmd] = None) -> Tuple[int, str, str]:
501
+ """
502
+ Private method that handles the running commands in a generic way so the logic and run pattern code can be shared between agent and session objects.
503
+
504
+ :param ssh_runner: A :class:`paramiko.SSHClient` or :class:`paramiko.Channel` object that can be used to run a command on a remote machine.
505
+ :param command: The command that is to be run.
506
+ :param exp_status: The status code or a sequence of possible status codes that are expected to be returned from running the command.
507
+ :param user: The role name of the user account to utilize when looking up the credentials used to connect to the remote host.
508
+ :param pty_params: A dictionary of parameters that are passed to paramiko to get a pty when running commands.
509
+ :param aspects: The run aspects to use when running the command.
510
+
511
+ :returns: The status, stderr and stdout from the command that was run.
512
+ """
513
+ # Go through the overrides and if they are not passed use the agent defaults.
514
+ if pty_params is None:
515
+ pty_params = self._pty_params
516
+
517
+ if aspects is None:
518
+ aspects = self._aspects
519
+
520
+ if user is not None:
521
+ if self._users is None:
522
+ errmsg = "In order to pass a 'user' parameter, you must create the SSHAgent with a 'users' parameter with a dictionary of user credentials."
523
+ raise ConfigurationError(errmsg) from None
524
+ if user not in self._users:
525
+ errmsg = "The specified 'user=%s' was not found in the users credentials provided to SSHAgent." % user
526
+ raise ConfigurationError(errmsg) from None
527
+
528
+ completion_toctx = TimeoutContext(aspects.completion_timeout, aspects.completion_interval)
529
+
530
+ inactivity_timeout = aspects.inactivity_timeout
531
+ inactivity_interval = aspects.inactivity_interval
532
+ monitor_delay = aspects.monitor_delay
533
+ logging_pattern = aspects.logging_pattern
534
+
535
+ status, stdout, stderr = None, None, None
536
+
537
+ this_thr = threading.current_thread()
538
+ monmsg= "Thread failed to exit monitored scope. thid=%s thname=%s cmd=%s" % (this_thr.ident, this_thr.name, command)
539
+
540
+ # Run the command using the SINGULAR pattern, we run it once and then return the result
541
+ if aspects.action_pattern == ActionPattern.SINGLE_CALL:
542
+
543
+ # Setup a monitored scope for the call to the remote device in case of timeout failure
544
+ with MonitoredScope("RUNCMD-SINGULAR", monmsg, completion_toctx, notify_delay=monitor_delay) as _:
545
+
546
+ status, stdout, stderr = self._ssh_execute_command(ssh_runner, command, pty_params=pty_params,
547
+ inactivity_timeout=inactivity_timeout, inactivity_interval=inactivity_interval)
548
+
549
+ self._log_command_result(command, status, stdout, stderr, exp_status, logging_pattern)
550
+
551
+ # DO_UNTIL_SUCCESS, run the command until we get a successful expected result or a completion timeout has occured
552
+ elif aspects.action_pattern == ActionPattern.DO_UNTIL_SUCCESS:
553
+
554
+ completion_toctx.mark_begin()
555
+
556
+ while True:
557
+
558
+ # Setup a monitored scope for the call to the remote device in case of timeout failure
559
+ with MonitoredScope("RUNCMD-DO_UNTIL_SUCCESS", monmsg, completion_toctx, notify_delay=monitor_delay) as _:
560
+ status, stdout, stderr = self._ssh_execute_command(ssh_runner, command, pty_params=pty_params,
561
+ inactivity_timeout=inactivity_timeout, inactivity_interval=inactivity_interval)
562
+
563
+ self._log_command_result(command, status, stdout, stderr, exp_status, logging_pattern)
564
+
565
+ if isinstance(exp_status, int):
566
+ if status == exp_status:
567
+ break
568
+ elif status in exp_status:
569
+ break
570
+
571
+ if completion_toctx.final_attempt:
572
+ what_for="command success"
573
+ details = [
574
+ "CMD: %s" % command,
575
+ "STDOUT:",
576
+ indent_lines(stdout, 1),
577
+ "STDERR:",
578
+ indent_lines(stderr, 1),
579
+ ]
580
+ toerr = completion_toctx.create_timeout(what_for=what_for, detail=details)
581
+ raise toerr
582
+
583
+ elif not completion_toctx.should_continue():
584
+ completion_toctx.mark_final_attempt()
585
+
586
+ time.sleep(completion_toctx.interval)
587
+
588
+ # DO_WHILE_SUCCESS, run the command while it is succeeds or a completion timeout has occured
589
+ elif aspects.action_pattern == ActionPattern.DO_WHILE_SUCCESS:
590
+
591
+ completion_toctx.mark_begin()
592
+
593
+ while True:
594
+
595
+ with MonitoredScope("RUNCMD-DO_WHILE_SUCCESS", monmsg, completion_toctx, notify_delay=monitor_delay) as _:
596
+ status, stdout, stderr = self._ssh_execute_command(ssh_runner, command, pty_params=pty_params,
597
+ inactivity_timeout=inactivity_timeout, inactivity_interval=inactivity_interval)
598
+
599
+ self._log_command_result(command, status, stdout, stderr, exp_status, logging_pattern)
600
+
601
+ if isinstance(exp_status, int):
602
+ if status != exp_status:
603
+ break
604
+ elif status not in exp_status:
605
+ break
606
+
607
+ if completion_toctx.final_attempt:
608
+ what_for="command continued to succeed"
609
+ details = [
610
+ "CMD: %s" % command,
611
+ "STDOUT:",
612
+ indent_lines(stdout, 1),
613
+ "STDERR:",
614
+ indent_lines(stderr, 1),
615
+ ]
616
+ toerr = completion_toctx.create_timeout(what_for=what_for, detail=details)
617
+ raise toerr
618
+
619
+ elif not completion_toctx.should_continue():
620
+ completion_toctx.mark_final_attempt()
621
+
622
+ time.sleep(completion_toctx.interval)
623
+
624
+ elif aspects.action_pattern == ActionPattern.SINGLE_CONNECTED_CALL or aspects.action_pattern == ActionPattern.DO_UNTIL_CONNECTION_FAILURE:
625
+ errmsg = "SshAgent and SshSession currently do not support the SINGLE_CONNECTED_CALL or DO_UNTIL_CONNECTION_FAILURE action patterns."
626
+ raise SemanticError(errmsg) from None
627
+ else:
628
+ errmsg = "SshBase: Unknown ActionPattern encountered. action_pattern={}".format(aspects.action_pattern)
629
+ raise SemanticError(errmsg) from None
630
+
631
+ return status, stdout, stderr
632
+
633
+ def _file_exists(self, ssh_client: SshClient, remotepath: str) -> bool:
634
+ """
635
+ Private helper method used to check if the remote file exists.
636
+ """
637
+ exists = False
638
+
639
+ if not self._primitive:
640
+ # transport = ssh_client.get_transport()
641
+
642
+ sftp = ssh_client.open_sftp()
643
+ try:
644
+ fsresult = sftp.stat(remotepath)
645
+ if stat.S_ISREG(fsresult.st_mode):
646
+ exists = True
647
+ except (FileNotFoundError, IOError):
648
+ exists = False
649
+ finally:
650
+ sftp.close()
651
+ else:
652
+ exists = primitive_file_exists(ssh_client, remotepath)
653
+
654
+ return exists
655
+
656
+ def _file_pull(self, ssh_client: SshClient, remotepath: str, localpath: str):
657
+ """
658
+ Private helper method used to pull a remote file to a local file path.
659
+ """
660
+ if not self._primitive:
661
+ # transport = ssh_client.get_transport()
662
+
663
+ sftp = ssh_client.open_sftp()
664
+ try:
665
+ sftp.get(remotepath, localpath)
666
+ finally:
667
+ sftp.close()
668
+
669
+ else:
670
+ primitive_file_pull(ssh_client, remotepath, localpath)
671
+
672
+ return
673
+
674
+ def _file_push(self, ssh_client: SshClient, localpath: str, remotepath: str):
675
+ """
676
+ Private helper method used to push a local file to a remote file path.
677
+ """
678
+ if not self._primitive:
679
+ # transport = ssh_client.get_transport()
680
+
681
+ sftp = ssh_client.open_sftp()
682
+ try:
683
+ sftp.put(localpath, remotepath)
684
+ finally:
685
+ sftp.close()
686
+
687
+ else:
688
+ primitive_file_push(ssh_client, localpath, remotepath)
689
+
690
+ return
691
+
692
+ def _get_supported_key_types(self) -> list[Tuple[str, type]]:
693
+ """
694
+ Build a list of supported SSH key types in preference order.
695
+
696
+ :returns: A list of tuples containing key type labels and classes.
697
+ """
698
+ preferred_key_types = [
699
+ ("RSA", SshRSAKey),
700
+ ("Ed25519", SshEd25519Key)
701
+ # ("ECDSA", getattr(paramiko, "ECDSAKey", None)),
702
+ # ("DSS", getattr(paramiko, "DSSKey", None))
703
+ ]
704
+
705
+ key_types: list[Tuple[str, type]] = []
706
+ for key_name, key_type in preferred_key_types:
707
+ if key_type is not None:
708
+ key_types.append((key_name, key_type))
709
+
710
+ return key_types
711
+
712
+ def _load_key(self, keyraw: str, keypasswd: str):
713
+ """
714
+ Load a private key from raw text.
715
+
716
+ :param keyraw: Raw private key content.
717
+ :param keypasswd: Password protecting the key.
718
+
719
+ :returns: Loaded SSH key object.
720
+ """
721
+ keybuffer = StringIO(keyraw)
722
+
723
+ # Try different key types in order of common usage
724
+ key_types = self._get_supported_key_types()
725
+ if not key_types:
726
+ raise ConfigurationError("ERROR: No supported private key formats are available.")
727
+
728
+ for _, key_type in key_types:
729
+ try:
730
+ keybuffer.seek(0) # Reset buffer position for each attempt
731
+ pkey = key_type.from_private_key(keybuffer, password=keypasswd)
732
+ return pkey
733
+ except Exception:
734
+ continue
735
+
736
+ attempted_formats = ", ".join(name for name, _ in key_types)
737
+ errmsg = f"ERROR: Unable to load private key from raw content. Tried {attempted_formats} formats."
738
+ raise ConfigurationError(errmsg)
739
+
740
+ def _load_key_file(self, keyfile: str, keypasswd: str):
741
+ """
742
+ Load a private key from a file path.
743
+
744
+ :param keyfile: Path to the key file.
745
+ :param keypasswd: Password protecting the key.
746
+
747
+ :returns: Loaded SSH key object.
748
+ """
749
+ keyfile = os.path.expanduser(os.path.expandvars(keyfile))
750
+
751
+ # Try different key types in order of common usage
752
+ key_types = self._get_supported_key_types()
753
+ if not key_types:
754
+ raise ConfigurationError("ERROR: No supported private key formats are available.")
755
+
756
+ for _, key_type in key_types:
757
+ try:
758
+ pkey = key_type.from_private_key_file(keyfile, password=keypasswd)
759
+ return pkey
760
+ except Exception:
761
+ continue
762
+
763
+ attempted_formats = ", ".join(name for name, _ in key_types)
764
+ errmsg = f"ERROR: Unable to load private keyfile={keyfile}. Tried {attempted_formats} formats."
765
+ raise ConfigurationError(errmsg)
766
+
767
+ def _ssh_execute_command(self, ssh_runner, command: str, pty_params=None, inactivity_timeout: float=DEFAULT_SSH_TIMEOUT, inactivity_interval: float=DEFAULT_SSH_RETRY_INTERVAL, chunk_size: int=1024) -> Tuple[int, str, str]: # pylint: disable=no-self-use
768
+ """
769
+ Private helper method used to route the command running parameters to the correct routine based on whether the command is being run interactively
770
+ through a session or is a single run command from an SshAgent.
771
+
772
+ :param ssh_runner: A :class:`SshClient` or :class:`SshChannel` object that can be used to run a command on a remote machine.
773
+ :param command: The command that is to be run.
774
+ :param pty_params: A dictionary of parameters that are passed to paramiko to get a pty when running commands.
775
+ :param inactivity_timeout: A timeout for inactivity between the local machine and remote machine.
776
+ :param inactivity_interval: The interval to wait between attempts to interact or read from the remote machine.
777
+ :param chunk_size: The size of the buffer to use when reading results from the remote machine.
778
+ """
779
+ status, stdout, stderr = ssh_execute_command(ssh_runner, command, pty_params=pty_params, inactivity_timeout=inactivity_timeout, inactivity_interval=inactivity_interval, chunk_size=chunk_size)
780
+ return status, stdout, stderr