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,695 @@
1
+
2
+ """
3
+ .. module:: sshhelpers
4
+ :platform: Darwin, Linux, Unix, Windows
5
+ :synopsis: sshhelpers module.
6
+
7
+ .. moduleauthor:: Myron Walker <myron.walker@gmail.com>
8
+ """
9
+
10
+ __author__ = "Myron Walker"
11
+ __copyright__ = "Copyright 2025, Code Craft AI LLC"
12
+ __credits__ = []
13
+
14
+ from typing import Callable, Optional, Tuple
15
+
16
+ from ccai.xmodules.xlogging.enhancedloggers import getEnhancedLogger
17
+ import os
18
+ import shlex
19
+ import stat
20
+ import time
21
+
22
+ from ccai.protocols.ssh.sshclient import SshClient
23
+ from ccai.protocols.ssh.sshconnection import SshChannel
24
+ from ccai.protocols.ssh.sshsftp import SshSFTPClient, SshSFTPAttributes
25
+
26
+ from ccai.errors.exceptions import CommandError
27
+
28
+ from ccai.xmodules.xformatting import indent_lines, format_command_result
29
+
30
+ from ccai.protocols.ssh.sshconst import (
31
+ DEFAULT_SSH_TIMEOUT,
32
+ DEFAULT_SSH_RETRY_INTERVAL,
33
+ INTERACTIVE_PROMPT,
34
+ INTERACTIVE_PROMPT_BYTES,
35
+ REGEX_DIRECTORY_ENTRY
36
+ )
37
+
38
+ logger = getEnhancedLogger()
39
+
40
+
41
+ def lookup_entry_type(pdir: str, ename: str, finfo: SshSFTPAttributes) -> str:
42
+ """
43
+ Determines the entry type labelto assign to a file entry based on the st_mode of the file information.
44
+
45
+ :param pdir: The parent directory of the file, passed for logging purposes.
46
+ :param ename: The entry name of the file in the directory listing.
47
+ :param finfo: The file information as returned by lstat on the file entry.
48
+
49
+ :returns: A label indicating the type of directory entry.
50
+ """
51
+ etype = None
52
+ if stat.S_ISREG(finfo.st_mode):
53
+ etype = "file"
54
+ elif stat.S_ISDIR(finfo.st_mode):
55
+ etype = "dir"
56
+ elif stat.S_ISLNK(finfo.st_mode):
57
+ etype = "link"
58
+ elif stat.S_ISCHR(finfo.st_mode):
59
+ etype = "char"
60
+ elif stat.S_ISBLK(finfo.st_mode):
61
+ etype = "block"
62
+ else:
63
+ print ("ERROR: Unknown entry type. pdir=%s, ename=%s" % (pdir, ename))
64
+
65
+ return etype
66
+
67
+ def primitive_list_directory(ssh_client: SshClient, directory: str) -> dict:
68
+ """
69
+ Uses a primitive method to create a list of the files and folders in a directory
70
+ by running the 'ls -al' commands on the directory. This is required if the SSH
71
+ server does not support sftp.
72
+
73
+ :param ssh_client: A :class:`paramiko.SSHClient` object that has already been connected
74
+ to the remote server.
75
+ :param directory: The remote directory to get a list catalog for.
76
+
77
+ :returns: A dictionary with the information about the items in the target directory.
78
+ """
79
+ entries = None
80
+
81
+ ls_cmd = "ls -al %s" % shlex.quote(directory)
82
+
83
+ status, stdout, stderr = ssh_execute_command(ssh_client, ls_cmd)
84
+
85
+ if status == 0:
86
+ entries = primitive_parse_directory_listing(directory, stdout)
87
+ else:
88
+ # Allow permission denied errors
89
+ if stderr.find("Permission denied") < 0:
90
+ errmsg = "Error attempting to get a primitive directory listing.\n CMD=%s\n STDERR:\n%s" % (ls_cmd, stderr)
91
+ raise Exception(errmsg)
92
+
93
+ return entries
94
+
95
+ def primitive_list_tree(ssh_client: SshClient, treeroot: str, max_depth: int=1) -> dict:
96
+ """
97
+ Uses a primitive method to create an information tree about the files and folders
98
+ in a directory tree by running 'ls -al' commands on the directory tree. This is
99
+ required if the SSH server does not support sftp.
100
+
101
+ :param ssh_client: A :class:`paramiko.SSHClient` object that has already been connected
102
+ to the remote server.
103
+ :param treeroot: The remote root directory to get a directory tree catalog for.
104
+ :param max_depth: The maximum descent depth to go to when building the tree catalog.
105
+
106
+ :returns: A dictionary with the information about the root directory and its children.
107
+ """
108
+
109
+ level_items = primitive_list_directory(ssh_client, treeroot)
110
+
111
+ if max_depth > 1:
112
+ if not treeroot.endswith("/"):
113
+ treeroot = treeroot + "/"
114
+
115
+ for nxtitem in level_items.values():
116
+ nxtname = nxtitem["name"]
117
+ if nxtitem["type"] == "dir":
118
+ nxtroot = treeroot + nxtname
119
+ nxtchildren = primitive_list_tree_recurse(ssh_client, nxtroot, max_depth - 1)
120
+ nxtitem.update(nxtchildren)
121
+
122
+ tree_info = {
123
+ "name": treeroot,
124
+ "items": level_items
125
+ }
126
+
127
+ return tree_info
128
+
129
+ def primitive_list_tree_recurse(ssh_client: SshClient, targetdir: str, remaining: int) -> dict:
130
+ """
131
+ Method which is called recursively to list the items in a directory and to form a tree
132
+ of information about the files under a room directory. This method utilizes primitive
133
+ shell commands in order to get directory information and is used when the SSH server on a
134
+ device does not support FTP based access or file queries
135
+
136
+ :param ssh_client: A paramiko.SSHClient to use for running commands to discover the information
137
+ about a directory.
138
+ :param targetdir: The target directory of the local tree to scan.
139
+ :param remaining: The remaining levels to descend
140
+
141
+ :returns: A dictionary with the information about the target directory and its children.
142
+ """
143
+
144
+ level_items = primitive_list_directory(ssh_client, targetdir)
145
+
146
+ if remaining > 1:
147
+ if not targetdir.endswith("/"):
148
+ targetdir = targetdir + "/"
149
+
150
+ for nxtitem in level_items.values():
151
+ nxtname = nxtitem["name"]
152
+ if nxtitem["type"] == "dir":
153
+ nxttarget = targetdir + nxtname
154
+ nxtchildren = primitive_list_tree_recurse(ssh_client, nxttarget, remaining - 1)
155
+ nxtitem.update(nxtchildren)
156
+
157
+ children_info = {
158
+ "items": level_items
159
+ }
160
+
161
+ return children_info
162
+
163
+ def primitive_parse_directory_listing(listing_dir:str, content: str) -> dict:
164
+ """
165
+ Method which is called parse the information associated with a directory listing.
166
+
167
+ :param listing_dir: The directory path associated with the directory listing content being parsed.
168
+ :param content: The directory listing content to be parsed.
169
+
170
+ :returns: A dictionary with the directory entry items from a directory based on the directory listing content being parsed.
171
+ """
172
+ entries = {}
173
+
174
+ listing_lines = content.splitlines(False)
175
+ for nxtline in listing_lines:
176
+ nxtline = nxtline.strip()
177
+
178
+ mobj = REGEX_DIRECTORY_ENTRY.match(nxtline)
179
+ if mobj is not None:
180
+ mgroups = mobj.groups()
181
+
182
+ name = mgroups[6]
183
+ if name == "." or name == "..":
184
+ continue
185
+
186
+ perms = mgroups[0]
187
+
188
+ perms_tc = perms[0]
189
+ etype = None
190
+ link_target = None
191
+ if perms_tc == '-':
192
+ etype = "file"
193
+ elif perms_tc == 'd':
194
+ etype = "dir"
195
+ elif perms_tc == 'l':
196
+ etype = "link"
197
+ # For symlinks, the name field contains "linkname -> target"
198
+ # We need to find the last " -> " occurrence to properly separate them
199
+ arrow_pos = name.rfind(" -> ")
200
+ if arrow_pos != -1:
201
+ link_target = name[arrow_pos + 4:] # +4 to skip " -> "
202
+ name = name[:arrow_pos]
203
+ # If no arrow, keep the original name (shouldn't happen in normal ls output)
204
+ elif perms_tc == 'c':
205
+ etype = "char"
206
+ elif perms_tc == 'b':
207
+ etype = "block"
208
+ else:
209
+ raise Exception("Unknown directory listing entry type. dir=%s tc=%s" % (listing_dir, perms_tc))
210
+
211
+ dentry = {
212
+ "name": name,
213
+ "perms": perms,
214
+ "type": etype,
215
+ "owner": mgroups[2],
216
+ "group": mgroups[3],
217
+ "size": mgroups[4],
218
+ "modified": mgroups[5]
219
+ }
220
+
221
+ # Add link target for symlinks
222
+ if etype == "link" and link_target is not None:
223
+ dentry["link_target"] = link_target
224
+
225
+ entries[name] = dentry
226
+
227
+ return entries
228
+
229
+ def primitive_directory_exists(ssh_client: SshClient, remotedir: str) -> bool:
230
+ """
231
+ Checks to see if the specified directory exists.
232
+
233
+ :param ssh_client: A paramiko.SSHClient to run commands over.
234
+ :param remotedir: The path of the remote directory to check for existence.
235
+
236
+ :returns: A boolean indicating if the directory exists.
237
+ """
238
+ exists = False
239
+
240
+ existscmd = 'test -d {0} && echo exists'.format(shlex.quote(remotedir))
241
+ status, stdout, stderr = ssh_execute_command(ssh_client, existscmd)
242
+
243
+ if status == 0:
244
+ exists = stdout.strip() == 'exists'
245
+
246
+ return exists
247
+
248
+ def primitive_file_exists(ssh_client: SshClient, remotepath: str) -> bool:
249
+ """
250
+ Checks to see if the specified file exists.
251
+
252
+ :param ssh_client: A paramiko.SSHClient to run commands over.
253
+ :param remotepath: The path of the remote file to check for existence.
254
+
255
+ :returns: A boolean indicating if the file exists.
256
+ """
257
+ exists = False
258
+
259
+ existscmd = 'test -f {0} && echo exists'.format(shlex.quote(remotepath))
260
+ status, stdout, stderr = ssh_execute_command(ssh_client, existscmd)
261
+
262
+ if status == 0:
263
+ exists = stdout.strip() == 'exists'
264
+
265
+ return exists
266
+
267
+ def primitive_file_pull(ssh_client: SshClient, remotepath: str, localpath: str):
268
+ """
269
+ Pulls the contents of a file to a local path using a primitive 'cat' command.
270
+
271
+ :param ssh_client: A paramiko.SSHClient to run commands over.
272
+ :param remotepath: The path to the file on the remote machine.
273
+ :param localpath: The local path to a file to write the contents of the remote file too.
274
+ """
275
+ copycmd = "cat %s" % shlex.quote(remotepath)
276
+
277
+ status, stdout, stderr = ssh_execute_command(ssh_client, copycmd, decode=False)
278
+
279
+ if status != 0:
280
+ stderr = stderr.decode()
281
+ errmsg_lines = [
282
+ "Error pulling file using command. cmd=%s" % copycmd,
283
+ "STDERR:",
284
+ indent_lines(stderr, 1)
285
+ ]
286
+ raise Exception(os.linesep.join(errmsg_lines))
287
+
288
+ with open(localpath, 'wb') as lfile:
289
+ lfile.write(stdout)
290
+
291
+ return
292
+
293
+ def primitive_file_push(ssh_client: SshClient, localpath: str, remotepath: str, read_size: int=1024):
294
+ """
295
+ Pushes the contents of a local file to a file on a remote machine at the path specified by remotepath.
296
+
297
+ :param ssh_client: A paramiko.SSHClient to run commands over.
298
+ :param localpath: The local path to a file to read contents from.
299
+ :param remotepath: The path to file on the remote machine to write the contents of the local file too.
300
+ :param read_size: The chunk size to use for reading the local file and sending data.
301
+ """
302
+ cat_cmd = "cat > {}".format(shlex.quote(remotepath))
303
+
304
+ # Read the entire file - ssh_execute_command handles transmission efficiently
305
+ with open(localpath, "rb") as file:
306
+ content = file.read()
307
+
308
+ status, stdout, stderr = ssh_execute_command(ssh_client, command=cat_cmd, chunk_size=read_size, input=content, decode=False)
309
+ if status != 0:
310
+ errmsg = "Error pushing file={} to remote path.".format(remotepath)
311
+ fmt_errmsg = format_command_result(errmsg, cat_cmd, status, stdout.decode(), stderr.decode())
312
+ raise CommandError(fmt_errmsg)
313
+
314
+ return
315
+
316
+ def sftp_list_directory(sftp, directory, userlookup, grouplookup) -> dict:
317
+ """
318
+ Gets a listing of the directory entries of a remote directory.
319
+
320
+ :param sftp: A paramiko.SFTPClient that is connected to a remote machine.
321
+ :param directory: The remote directory to get the directory listing for.
322
+ :param userlookup: A callable function to lookup the user associated with a directory item by uid.
323
+ :param grouplookup: A callable function to lookup the group associated with a directory item by gid.
324
+
325
+ :returns: A dictionary with the information about the items in the target directory.
326
+ """
327
+ entries = {}
328
+
329
+ try:
330
+ sftp.chdir(directory)
331
+ directory_items = sftp.listdir()
332
+
333
+ for nname in directory_items:
334
+
335
+ try:
336
+ ninfo = sftp.lstat(nname)
337
+ perms = ""
338
+ dentry = {
339
+ "name": nname,
340
+ "perms": perms,
341
+ "type": lookup_entry_type(directory, nname, ninfo),
342
+ "owner": userlookup(ninfo.st_uid),
343
+ "group": grouplookup(ninfo.st_gid),
344
+ "size": ninfo.st_size,
345
+ "accessed": ninfo.st_atime,
346
+ "modified": ninfo.st_mtime
347
+ }
348
+
349
+ entries[nname] = dentry
350
+ except PermissionError:
351
+ logger.exception("Unable to perform lstat on remote directory item %r." % nname)
352
+ except PermissionError:
353
+ logger.exception("Unable to perform listdir on remote directory %r." % directory)
354
+
355
+ return entries
356
+
357
+ def sftp_list_tree(sftp: SshSFTPClient, treeroot: str, userlookup: Callable[[int, Optional[bool]], str], grouplookup: Callable[[int, Optional[bool]], str], max_depth: int=1) -> dict:
358
+ """
359
+ Gets a tree of the directory entries of a remote directory.
360
+
361
+ :param sftp: A paramiko.SFTPClient that is connected to a remote machine.
362
+ :param treeroot: The root directory to get the directory listing for.
363
+ :param userlookup: A callable function to lookup the user associated with a directory item by uid.
364
+ :param grouplookup: A callable function to lookup the group associated with a directory item by gid.
365
+ :param max_depth: The maximum depth to create the directory tree.
366
+
367
+ :returns: A dictionary with the information about the root directory and its children.
368
+ """
369
+ level_items = sftp_list_directory(sftp, treeroot, userlookup, grouplookup)
370
+
371
+ if max_depth > 1:
372
+ if not treeroot.endswith("/"):
373
+ treeroot = treeroot + "/"
374
+
375
+ for nxtitem in level_items.values():
376
+ nxtname = nxtitem["name"]
377
+ if nxtitem["type"] == "dir":
378
+ nxtroot = treeroot + nxtname
379
+ nxtchildren = sftp_list_tree_recurse(sftp, nxtroot, userlookup, grouplookup, max_depth - 1)
380
+ nxtitem.update(nxtchildren)
381
+
382
+ tree_info = {
383
+ "name": treeroot,
384
+ "items": level_items
385
+ }
386
+
387
+ return tree_info
388
+
389
+ def sftp_list_tree_recurse(sftp: SshSFTPClient, targetdir: str, userlookup: Callable[[int, Optional[bool]], str], grouplookup: Callable[[int, Optional[bool]], str], remaining: int) -> dict:
390
+ """
391
+ Gets a listing of the directory passed and then recursively calls itself to create sub directory listings up to the remaining depth.
392
+
393
+ :param sftp: A paramiko.SFTPClient that is connected to a remote machine.
394
+ :param targetdir: The target directory to get the directory tree listing for.
395
+ :param userlookup: A callable function to lookup the user associated with a directory item by uid.
396
+ :param grouplookup: A callable function to lookup the group associated with a directory item by gid.
397
+ :param remaining: The depth remaining to create the directory tree.
398
+
399
+ :returns: A dictionary with the information about the target directory and its children.
400
+ """
401
+ level_items = sftp_list_directory(sftp, targetdir, userlookup, grouplookup)
402
+
403
+ if remaining > 1:
404
+ if not targetdir.endswith("/"):
405
+ targetdir = targetdir + "/"
406
+
407
+ for nxtitem in level_items.values():
408
+ nxtname = nxtitem["name"]
409
+ if nxtitem["type"] == "dir":
410
+ nxtroot = targetdir + nxtname
411
+ nxtchildren = sftp_list_tree_recurse(sftp, nxtroot, userlookup, grouplookup, remaining - 1)
412
+ nxtitem.update(nxtchildren)
413
+
414
+ children_info = {
415
+ "items": level_items
416
+ }
417
+
418
+ return children_info
419
+
420
+ def ssh_execute_command(ssh_client: SshClient, command: str, pty_params=None,
421
+ inactivity_timeout: float=DEFAULT_SSH_TIMEOUT, inactivity_interval: float=DEFAULT_SSH_RETRY_INTERVAL,
422
+ chunk_size: int=1024, input: Optional[str]=None, decode=True) -> Tuple[int, str, str]:
423
+ """
424
+ Runs a command on a remote server using the specified ssh_client. We implement our own version of ssh_execute_command
425
+ in order to have better control over the timeouts and to make sure all the checks are sequenced properly in order
426
+ to prevent SSH lockups.
427
+
428
+ :param ssh_client: The :class:`paramiko.SSHClient` object to utilize when running the command.
429
+ :param command: The commandline to run.
430
+ :param inactivity_timeout: The timeout for the channel for the ssh transaction.
431
+ :param inactivity_interval: The retry interval to wait for the channel to have data ready.
432
+ :param chunk_size: The size of the chunks that are read during receive operations
433
+
434
+ :returns: A tuple with the command result code, the standard output and the standard error output.
435
+ """
436
+ status = None
437
+ stdout_buffer = bytearray()
438
+ stderr_buffer = bytearray()
439
+
440
+ start_time = time.time()
441
+ end_time = start_time + inactivity_timeout
442
+
443
+ # ssh_client.get_transport().open_session(timeout=inactivity_timeout)
444
+ # SshClient has open_sftp? No, we need channel.
445
+ # self.connection.open_channel("session") in SshClient?
446
+ # Actually SshClient.get_transport() returns Transport.
447
+ # Transport does NOT have open_session (SshConnection does).
448
+ # SshClient should probably expose open_channel?
449
+ # Or use ssh_client.invoke_shell() or something?
450
+ # My SshClient.invoke_shell returns channel.
451
+
452
+ # Let's assume ssh_client.connection.open_channel("session")
453
+ # or expose it in SshClient.
454
+
455
+ channel = ssh_client.connection.open_channel("session")
456
+ # Set timeout? My Channel doesn't support setting timeout on object yet?
457
+ # It supports timeout in recv().
458
+
459
+ try:
460
+ if pty_params is not None:
461
+ channel.request_pty(**pty_params)
462
+
463
+ channel.request_exec(command)
464
+
465
+ input_length = 0
466
+ if input is not None:
467
+ if isinstance(input, str):
468
+ input = input.encode()
469
+ input_length = len(input)
470
+ input_pos = 0
471
+ input_shutdown = False
472
+
473
+ while True:
474
+ # Grab exit status ready before checking to see if stdout_ready or stderr_ready are True
475
+ exit_status_ready = channel.exit_status_ready()
476
+ stdout_ready = channel.recv_ready()
477
+ stderr_ready = channel.recv_stderr_ready()
478
+
479
+ # If stdout_ready or stderr_ready are true, we read this round
480
+ if stdout_ready or stderr_ready:
481
+ if stdout_ready:
482
+ rcv_data = channel.recv(chunk_size)
483
+ stdout_buffer.extend(rcv_data)
484
+ if stderr_ready:
485
+ rcv_data = channel.recv_stderr(chunk_size)
486
+ stderr_buffer.extend(rcv_data)
487
+
488
+ # We only want to timeout if there is inactivity
489
+ start_time = time.time()
490
+ end_time = start_time + inactivity_timeout
491
+
492
+ # If there is data left to send and the send channel is ready
493
+ # for input, send a chunk from the input buffer
494
+ elif input_length > 0 and input_pos < input_length and channel.send_ready():
495
+
496
+ send_size = chunk_size
497
+ avail_length = input_length - input_pos
498
+ if avail_length < chunk_size:
499
+ send_size = avail_length
500
+
501
+ send_chunk = input[input_pos: input_pos + send_size]
502
+ # Bounded by the aspects' inactivity timeout in case the peer's channel window is exhausted.
503
+ sent_size = channel.send(send_chunk, timeout=inactivity_timeout)
504
+
505
+ input_pos = input_pos + sent_size
506
+
507
+ # If we have written everything there is to write, and writting to the
508
+ # channel has not been shutdown, then shutdown writing.
509
+ elif input is not None and not input_shutdown and input_pos >= input_length:
510
+ channel.shutdown_write()
511
+ input_shutdown = True
512
+
513
+ # If we did not read any data this round and our exit status was ready, its time to exit
514
+ elif exit_status_ready:
515
+ status = channel.recv_exit_status()
516
+ break
517
+ # We didn't have any data to read and our exit status was not ready, go to sleep for interval
518
+ else:
519
+ now_time = time.time()
520
+ if now_time > end_time:
521
+ diff_time = now_time - start_time
522
+ peername = ssh_client.get_transport().sock.getpeername()
523
+ err_msg = "Timeout while executing SSH command.\n peer=%r command=%s\n start=%r end=%r now=%r diff=%r" % (
524
+ peername, command, start_time, end_time, now_time, diff_time)
525
+ raise TimeoutError(err_msg)
526
+
527
+ # If we did not timeout, go to sleep for the interval before we make another attempt to read
528
+ time.sleep(inactivity_interval)
529
+
530
+ # End while True
531
+
532
+ finally:
533
+ # Always close the channel to prevent resource leaks
534
+ try:
535
+ channel.close()
536
+ except Exception:
537
+ pass # Ignore errors when closing the channel
538
+
539
+ stdout = stdout_buffer
540
+ if decode:
541
+ stdout = stdout_buffer.decode()
542
+ del stdout_buffer
543
+
544
+ stderr = stderr_buffer
545
+ if decode:
546
+ stderr = stderr_buffer.decode()
547
+ del stderr_buffer
548
+
549
+ return status, stdout, stderr
550
+
551
+ def _ssh_execute_command_in_channel_imp(ssh_channel: SshChannel, command: str, read_timeout: float=2, inactivity_timeout: float=DEFAULT_SSH_TIMEOUT, inactivity_interval: float=DEFAULT_SSH_RETRY_INTERVAL, chunk_size: int=1024) -> Tuple[bytearray, bytearray]:
552
+ """
553
+ Core implementation that executes a command in a channel and returns raw output buffers.
554
+
555
+ :param ssh_channel: The :class:`SshChannel` object to utilize when running the command.
556
+ :param command: The commandline to run.
557
+ :param read_timeout: Timeout for individual read operations.
558
+ :param inactivity_timeout: The timeout for the channel for the ssh transaction.
559
+ :param inactivity_interval: The retry interval to wait for the channel to have data ready.
560
+ :param chunk_size: The size of the chunks that are read during receive operations
561
+
562
+ :returns: A tuple with stdout_buffer and stderr_buffer as bytearrays.
563
+ """
564
+ stdout_buffer = bytearray()
565
+ stderr_buffer = bytearray()
566
+
567
+ start_time = time.time()
568
+ end_time = start_time + inactivity_timeout
569
+
570
+ # Assuming ssh_channel.send() handles encoding if needed, but it usually takes bytes.
571
+ # sshconnection.Channel.send takes bytes.
572
+ # command is str.
573
+ command_bytes = (command + "\n").encode('utf-8')
574
+ # Bounded by the aspects' inactivity timeout in case the peer's channel window is exhausted.
575
+ ssh_channel.send(command_bytes, timeout=inactivity_timeout)
576
+
577
+ stdout_pipe_timeout = 0
578
+ stderr_pipe_timeout = 0
579
+ while True:
580
+ if stdout_pipe_timeout < 2:
581
+ rcv_data = ssh_channel.recv(chunk_size, timeout=read_timeout)
582
+ if rcv_data:
583
+ stdout_buffer.extend(rcv_data)
584
+ if rcv_data.endswith(INTERACTIVE_PROMPT_BYTES):
585
+ stdout_pipe_timeout = 2
586
+ else:
587
+ # Timeout or empty
588
+ stdout_pipe_timeout += 1
589
+
590
+ if stderr_pipe_timeout < 2:
591
+ # Do not wait for stderr: an interactive channel has a pty, which merges stderr into stdout, so waiting
592
+ # would only delay every command by the read timeout. Stderr that arrives later is drained below.
593
+ rcv_data = ssh_channel.recv_stderr(chunk_size, timeout=0)
594
+ if rcv_data:
595
+ stderr_buffer.extend(rcv_data)
596
+ else:
597
+ stderr_pipe_timeout += 1
598
+
599
+ if stdout_pipe_timeout > 1 and stderr_pipe_timeout > 1:
600
+ break
601
+
602
+ now_time = time.time()
603
+ if now_time > end_time:
604
+ errmsg = "Timeout while running command={}".format(command)
605
+ raise TimeoutError(errmsg)
606
+
607
+ # Collect any stderr that arrived while stdout was still being read.
608
+ rcv_data = ssh_channel.recv_stderr(chunk_size, timeout=0)
609
+ while len(rcv_data) > 0:
610
+ stderr_buffer.extend(rcv_data)
611
+ rcv_data = ssh_channel.recv_stderr(chunk_size, timeout=0)
612
+
613
+ return stdout_buffer, stderr_buffer
614
+
615
+ def _process_channel_output(command: str, stdout_buffer: bytearray, stderr_buffer: bytearray) -> Tuple[str, str]:
616
+ """
617
+ Processes the stdout and stderr buffers from a channel command execution, validating that
618
+ the command was echoed and terminated with the interactive prompt, then returning trimmed output.
619
+ """
620
+ stdout = stdout_buffer.decode()
621
+ if not stdout.startswith(command):
622
+ errmsg = "The first line should have been an echo of our command."
623
+ raise Exception(errmsg)
624
+
625
+ if not stdout.endswith(INTERACTIVE_PROMPT):
626
+ errmsg = "The output should have ended with the interactive prompt."
627
+ raise Exception(errmsg)
628
+
629
+ stdout = stdout[len(command):-len(INTERACTIVE_PROMPT)].strip()
630
+ stderr = stderr_buffer.decode()
631
+
632
+ return stdout, stderr
633
+
634
+
635
+ def _get_exit_status_in_channel(ssh_channel: SshChannel, *, read_timeout: float, inactivity_timeout: float, inactivity_interval: float, chunk_size: int) -> int:
636
+ """
637
+ Executes ``echo $?`` in the provided channel and parses the resulting exit status without
638
+ re-entering :func:`ssh_execute_command_in_channel`, avoiding recursion concerns.
639
+ """
640
+ status_stdout_buffer, status_stderr_buffer = _ssh_execute_command_in_channel_imp(
641
+ ssh_channel, "echo $?", read_timeout, inactivity_timeout, inactivity_interval, chunk_size
642
+ )
643
+
644
+ status_stdout, _ = _process_channel_output("echo $?", status_stdout_buffer, status_stderr_buffer)
645
+
646
+ status = 1
647
+ if status_stdout.isdigit():
648
+ status = int(status_stdout)
649
+ else:
650
+ logger.warning("Could not parse exit status from: %s", status_stdout)
651
+
652
+ return status
653
+
654
+
655
+ def ssh_execute_command_in_channel(ssh_channel: SshChannel, command: str, read_timeout: float=2, inactivity_timeout: float=DEFAULT_SSH_TIMEOUT, inactivity_interval: float=DEFAULT_SSH_RETRY_INTERVAL, chunk_size: int=1024, status_check=False) -> Tuple[int, str, str]:
656
+ """
657
+ Runs a command on a remote server using the specified ssh_channel. We implement our own version of ssh_execute_command
658
+ in order to have better control over the timeouts and to make sure all the checks are sequenced properly in order
659
+ to prevent SSH lockups.
660
+
661
+ :param ssh_channel: The :class:`paramiko.Channel` object to utilize when running the command.
662
+ :param command: The commandline to run.
663
+ :param read_timeout: Timeout for individual read operations.
664
+ :param inactivity_timeout: The timeout for the channel for the ssh transaction.
665
+ :param inactivity_interval: The retry interval to wait for the channel to have data ready.
666
+ :param chunk_size: The size of the chunks that are read during receive operations
667
+ :param status_check: If True, skip getting exit status (used internally)
668
+
669
+ :returns: A tuple with the command result code, the standard output and the standard error output.
670
+ """
671
+ # Execute the main command
672
+ stdout_buffer, stderr_buffer = _ssh_execute_command_in_channel_imp(
673
+ ssh_channel, command, read_timeout, inactivity_timeout, inactivity_interval, chunk_size
674
+ )
675
+
676
+ status = 0 # Default to success
677
+ if not status_check:
678
+ # Get exit status by running echo $? immediately after the command
679
+ try:
680
+ status = _get_exit_status_in_channel(
681
+ ssh_channel,
682
+ read_timeout=read_timeout,
683
+ inactivity_timeout=inactivity_timeout,
684
+ inactivity_interval=inactivity_interval,
685
+ chunk_size=chunk_size
686
+ )
687
+
688
+ except Exception as exc:
689
+ # If status check fails, log warning and assume failure
690
+ logger.warning("Failed to get exit status for command '%s': %s", command, exc)
691
+ status = 1
692
+
693
+ stdout, stderr = _process_channel_output(command, stdout_buffer, stderr_buffer)
694
+
695
+ return status, stdout, stderr