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,433 @@
1
+ """
2
+ .. module:: sshsession
3
+ :platform: Darwin, Linux, Unix, Windows
4
+ :synopsis: Module containing the :class:`SshSession` 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 types import TracebackType
18
+ import socket
19
+ import time
20
+
21
+ from ccai.protocols.ssh.sshclient import SshClient
22
+
23
+ from ccai.errors.exceptions import UnExpectedSessionClosureError
24
+
25
+ from ccai.xmodules.xlogging.enhancedloggers import getEnhancedLogger
26
+
27
+ from ccai.credentials.sshcredential import SshCredential
28
+
29
+ from ccai.interop.aspects import AspectsCmd, DEFAULT_CMD_ASPECTS
30
+ from ccai.protocols.ssh.sshconst import (
31
+ SshJumpParams,
32
+ DEFAULT_SSH_TIMEOUT,
33
+ DEFAULT_SSH_RETRY_INTERVAL,
34
+ INTERACTIVE_PROMPT
35
+ )
36
+ from ccai.protocols.ssh.sshhelpers import (
37
+ ssh_execute_command,
38
+ ssh_execute_command_in_channel
39
+ )
40
+ from ccai.protocols.ssh.sshbase import SshBase
41
+
42
+ logger = getEnhancedLogger()
43
+
44
+ class SshSession(SshBase):
45
+ """
46
+ The :class:`SshSession` provides an extension of the :class:`SshBase` class interface and api(s). The :class:`SshSession` serves to
47
+ act as a session scoping object that establishes a set of SSH credentials, settings and patterns that are to be used for a series of
48
+ SSH operations. The :class:`SshSession` also holds open the SSHClient for its entire scope and ensures the SSHClient is closed and
49
+ cleaned up properly when the :class:`SshSession` goes out of scope.
50
+ """
51
+ def __init__(self, host: str, primary_credential: SshCredential, users: Optional[Dict[str, SshCredential]] = None, port:int=22, jump: Union[str, SshJumpParams, None] = None,
52
+ pty_params: Optional[dict] = None, session_user=None, interactive=False, basis_session: Optional["SshSession"]=None,
53
+ called_id: Optional[str]=None, look_for_keys: bool = False, primitive: Optional[bool] = None, aspects: AspectsCmd=DEFAULT_CMD_ASPECTS):
54
+ """
55
+ Create a new :class:`SshSession` instance.
56
+
57
+ :param host: Target host name.
58
+ :param primary_credential: Primary credential for authentication.
59
+ :param users: Additional credential mappings.
60
+ :param port: SSH port number.
61
+ :param jump: Optional jump host settings.
62
+ :param pty_params: Default PTY parameters for command execution.
63
+ :param session_user: Username to use for the session.
64
+ :param interactive: Flag indicating an interactive shell is desired.
65
+ :param basis_session: Optional session to base this one on.
66
+ :param called_id: Friendly identifier used in logs.
67
+ :param look_for_keys: Search for SSH keys in default locations.
68
+ :param primitive: Override primitive mode for this session. If None, uses credential's primitive setting.
69
+ :param aspects: Default command aspects for operations.
70
+ """
71
+ SshBase.__init__(self, host, primary_credential, users=users, port=port, jump=jump,
72
+ pty_params=pty_params, called_id=called_id, look_for_keys=look_for_keys, aspects=aspects)
73
+
74
+ self._session_user = session_user
75
+ self._interactive = interactive
76
+ self._read_timeout = 2
77
+
78
+ # Override primitive mode if specified for this session
79
+ if primitive is not None:
80
+ self._primitive = primitive
81
+
82
+ self._ssh_client = None
83
+ self._ssh_runner = None
84
+ self._basis_session = basis_session
85
+ return
86
+
87
+ def __enter__(self):
88
+ """
89
+ Method that implements the __enter__ symantics used for 'with' statements.
90
+ """
91
+ self._ssh_client = self._create_client()
92
+
93
+ if self._interactive:
94
+ inactivity_timeout = self._aspects.inactivity_timeout
95
+
96
+ channel = None
97
+ try:
98
+ channel = self._ssh_client.connection.open_channel("session")
99
+
100
+ if self._pty_params is not None:
101
+ channel.request_pty(**self._pty_params)
102
+ else:
103
+ channel.request_pty()
104
+
105
+ channel.request_shell()
106
+ channel.send("export PS1=%s\n" % INTERACTIVE_PROMPT)
107
+
108
+ leader = b""
109
+ max_leader_size = 8192 # Limit accumulated data to prevent memory issues
110
+ start_time = time.time()
111
+
112
+ prompt_received = False
113
+ try:
114
+ while True:
115
+ # Check for overall timeout using aspects inactivity_timeout
116
+ if time.time() - start_time > inactivity_timeout:
117
+ logger.error("Interactive shell initialization timeout after %s seconds", inactivity_timeout)
118
+ break
119
+
120
+ out = channel.recv(1028, timeout=self._read_timeout)
121
+ if not out:
122
+ if not channel.closed:
123
+ # Timeout means we are likely at the prompt
124
+ prompt_received = True
125
+ break
126
+
127
+ leader += out
128
+
129
+ # Prevent unlimited memory growth
130
+ if len(leader) > max_leader_size:
131
+ # Keep only the last portion of the data
132
+ leader = leader[-max_leader_size//2:]
133
+
134
+ # Check if we've received the prompt we're looking for
135
+ if INTERACTIVE_PROMPT.encode('utf-8') in leader:
136
+ prompt_received = True
137
+ break
138
+
139
+ except Exception as e:
140
+ logger.error("Exception during interactive shell initialization: %s", e)
141
+ # Clean up resources on failure
142
+ if channel:
143
+ try:
144
+ channel.close()
145
+ except Exception:
146
+ # Best-effort cleanup; a failure to close the channel must not mask the initialization error raised below.
147
+ pass
148
+ self._cleanup_ssh_client()
149
+ raise RuntimeError(f"Failed to initialize interactive shell: {e}") from e
150
+
151
+ # Verify that initialization was successful
152
+ if not prompt_received:
153
+ # Clean up resources on failure
154
+ if channel:
155
+ try:
156
+ channel.close()
157
+ except Exception:
158
+ # Best-effort cleanup; a failure to close the channel must not mask the initialization failure raised below.
159
+ pass
160
+ self._cleanup_ssh_client()
161
+ raise RuntimeError("Interactive shell initialization failed - prompt not received within timeout")
162
+
163
+ self._ssh_runner = channel
164
+
165
+ except Exception as e:
166
+ # If any exception occurs during channel setup, clean up the SSH client
167
+ if channel:
168
+ try:
169
+ channel.close()
170
+ except Exception:
171
+ # Best-effort cleanup; a failure to close the channel must not mask the original error re-raised below.
172
+ pass
173
+ self._cleanup_ssh_client()
174
+ raise
175
+ else:
176
+ self._ssh_runner = self._ssh_client
177
+
178
+ return self
179
+
180
+ def __exit__(self, ex_type: type, ex_val: Exception, ex_tb: TracebackType) -> bool:
181
+ """
182
+ Method that implements the __exit__ symantics used for 'with' statements.
183
+
184
+ :returns: Returns false indicating that exceptions are not handled.
185
+ """
186
+ self.close()
187
+ handled = False
188
+ return handled
189
+
190
+ def close(self):
191
+ """
192
+ Closes the SSH session and the associated SSH connection.
193
+ """
194
+ # Close interactive channel if it exists and is different from the client
195
+ if hasattr(self, '_ssh_runner') and self._ssh_runner is not None and self._ssh_runner != self._ssh_client:
196
+ try:
197
+ if hasattr(self._ssh_runner, 'close'):
198
+ self._ssh_runner.close()
199
+ except Exception:
200
+ # Ignore errors when closing the channel
201
+ pass
202
+ finally:
203
+ self._ssh_runner = None
204
+
205
+ # Only close the client if this session owns the client and it exists
206
+ if self._basis_session is None and hasattr(self, '_ssh_client') and self._ssh_client is not None:
207
+ try:
208
+ self._ssh_client.close()
209
+ except Exception:
210
+ # Ignore errors when closing the client (might already be closed)
211
+ pass
212
+ finally:
213
+ self._ssh_client = None
214
+
215
+ return
216
+
217
+ def directory(self, root_dir: str) -> dict:
218
+ """
219
+ Method that creates a directory listing for the folder.
220
+
221
+ :param root_dir: The directory to scan when creating the tree.
222
+
223
+ :returns: A dictionary that contains information about the items in the target directory.
224
+ """
225
+ dir_info = self._directory(self._ssh_client, root_dir)
226
+
227
+ return dir_info
228
+
229
+ def directory_exists(self, remotedir: str) -> bool:
230
+ """
231
+ Method used to pull a remote directory to check for existence.
232
+
233
+ :param remotedir: The remote directory path to check for existence.
234
+
235
+ :returns: A boolean value indicating if the remote file exists.
236
+ """
237
+ exists = self._directory_exists(self._ssh_client, remotedir)
238
+
239
+ return exists
240
+
241
+ def directory_tree(self, root_dir: str, depth: int = 1) -> dict:
242
+ """
243
+ Method that creates a directory tree for the folder.
244
+
245
+ :param root_dir: The root directory to scan when creating the tree.
246
+ :param depth: The depth to scan to
247
+
248
+ :returns: A dictionary with a tree of information about the directory tree found on the remote system.
249
+ """
250
+ dir_info = self._directory_tree(self._ssh_client, root_dir, depth=depth)
251
+
252
+ return dir_info
253
+
254
+ def file_exists(self, remotepath: str) -> bool:
255
+ """
256
+ Method used to pull a remote file to check for existence.
257
+
258
+ :param remotepath: The remote file path to check for existence.
259
+
260
+ :returns: A boolean value indicating if the remote file exists.
261
+ """
262
+ exists = self._file_exists(self._ssh_client, remotepath)
263
+
264
+ return exists
265
+
266
+ def file_pull(self, remotepath: str, localpath: str):
267
+ """
268
+ Method used to pull a remote file to a local file path.
269
+
270
+ :param remotepath: The remote file path to pull to the local file.
271
+ :param localpath: The local file path to pull the content to.
272
+ """
273
+ self._file_pull(self._ssh_client, remotepath, localpath)
274
+
275
+ return
276
+
277
+ def file_push(self, localpath: str, remotepath: str):
278
+ """
279
+ Method used to push a local file to a remote file path.
280
+
281
+ :param localpath: The local file path to push the content of to the remote file.
282
+ :param remotepath: The remote file path to push content to.
283
+ """
284
+ self._file_push(self._ssh_client, localpath, remotepath)
285
+
286
+ return
287
+
288
+ def get_home_directory(self, aspects: Optional[AspectsCmd] = None) -> str:
289
+ """
290
+ Method used to get the home directory of the credentialed user from a remote machine.
291
+ """
292
+ home_dir = self._get_home_directory(aspects=aspects)
293
+ return home_dir
294
+
295
+ def open_session(self, user: str = None, primitive: bool = False, pty_params: Optional[dict] = None, interactive=False, basis_session: Optional["SshSession"] = None,
296
+ aspects: Optional[AspectsCmd] = None, **kwargs) -> "SshSession":
297
+ """
298
+ Provies a mechanism to create a :class:`SshSession` object with derived settings. This method allows various parameters for the session
299
+ to be overridden. This allows for the performing of a series of SSH operations under a particular set of shared settings and or credentials.
300
+
301
+ :param user: open the session under a different user credential.
302
+ :param primitive: Use primitive mode for FTP operations for the session.
303
+ :param pty_params: The default pty parameters to use to request a PTY when running commands through the session.
304
+ :param interactive: Creates an interactive session which holds open an interactive shell so commands can interact in the shell.
305
+ :param basis_session: An optional SshSession instance to use as a session basis. This allows re-use of sessions.
306
+ :param aspects: The default run aspects to use for the operations performed by the session.
307
+
308
+ :returns: A new :class:`SshSession` instance with the requested parameters.
309
+ """
310
+
311
+ if aspects is None:
312
+ aspects = self._aspects
313
+
314
+ session_credential = self._primary_credential
315
+ if user is not None:
316
+ session_credential = self._users[user]
317
+
318
+ session = None
319
+ if basis_session is not None:
320
+ bs: SshSession = basis_session
321
+ session = SshSession(bs._host, session_credential, users=bs._users, port=bs._port,
322
+ jump=bs._jump, pty_params=pty_params, interactive=interactive,
323
+ basis_session=bs, look_for_keys=bs._look_for_keys, primitive=primitive, aspects=aspects)
324
+ else:
325
+ session = SshSession(self._host, session_credential, users=self._users, port=self._port,
326
+ jump=self._jump, pty_params=pty_params, interactive=interactive,
327
+ look_for_keys=self._look_for_keys, primitive=primitive, aspects=aspects)
328
+ return session
329
+
330
+ def reboot(self, aspects: Optional[AspectsCmd] = None):
331
+ """
332
+ Reboots the designated host by running the 'reboot' command on the host.
333
+
334
+ ..note: This method is not a typical SSH functionality, but it was added so that
335
+ automatic reconnects can be implemented as required on session objects.
336
+
337
+ """
338
+ reboot_cmd = 'reboot'
339
+ try:
340
+ self.run_cmd(reboot_cmd, aspects=aspects)
341
+ except (ConnectionError, socket.error, EOFError) as e:
342
+ # We might see a connection dropout exception here during reboot, which is expected
343
+ logger.debug(f"Expected connection error during reboot: {e}")
344
+ except KeyboardInterrupt:
345
+ # Allow keyboard interrupts to propagate
346
+ raise
347
+ except SystemExit:
348
+ # Allow system exits to propagate
349
+ raise
350
+ except Exception as e:
351
+ # Log unexpected exceptions but continue with session closure
352
+ logger.warning(f"Unexpected exception during reboot command: {e}")
353
+
354
+ # Since we are a session, we need to reconnect to the remote machine after the reboot.
355
+ errmsg = "Session closed due to reboot."
356
+ raise UnExpectedSessionClosureError(errmsg)
357
+
358
+ def run_cmd(self, command: str, exp_status: Union[int, Sequence]=0, user: Optional[str] = None, pty_params: dict = None, aspects: Optional[AspectsCmd] = None) -> Tuple[int, str, str]:
359
+ """
360
+ Runs a command on the designated host using the current session SSH session and client.
361
+
362
+ :param command: The command to run.
363
+ :param exp_status: An integer or sequence of integers that specify the set of expected status codes from the command.
364
+ :param pty_params: The pty parameters to use to request a PTY when running the command.
365
+ :param aspects: The run aspects to use when running the command.
366
+
367
+ :returns: The status, stderr and stdout from the command that was run.
368
+ """
369
+
370
+ status, stdout, stderr = self._run_cmd(self._ssh_runner, command, exp_status=exp_status, user=user, pty_params=pty_params, aspects=aspects)
371
+
372
+ return status, stdout, stderr
373
+
374
+ def _cleanup_ssh_client(self):
375
+ """
376
+ Helper method to clean up the SSH client when initialization fails.
377
+ """
378
+ if hasattr(self, '_ssh_client') and self._ssh_client is not None:
379
+ # Only close if this session owns the client (not a basis session)
380
+ if self._basis_session is None:
381
+ try:
382
+ self._ssh_client.close()
383
+ except Exception:
384
+ # Best-effort cleanup; this runs while an initialization failure is being handled and a failure
385
+ # to close the client must not mask that original error.
386
+ pass
387
+ self._ssh_client = None
388
+
389
+ def _create_client(self, session_user: Optional[str] = None) -> SshClient:
390
+ """
391
+ Create an SSHClient to use for running commands and performing FTP operations.
392
+
393
+ :param session_user: The user role and associated credentials to use when creating the SSHClient.
394
+
395
+ :returns: An SSHClient object connected to the remote machine under the default or specified user credential.
396
+
397
+ :raises: RuntimeError if the basis session client is invalid or no longer active.
398
+ """
399
+ ssh_client = None
400
+ if self._basis_session is not None and session_user is None:
401
+ # Only reuse basis session client if no specific session_user is requested
402
+ basis_client = self._basis_session._ssh_client
403
+ if basis_client is None:
404
+ raise RuntimeError("Basis session SSH client is None - the basis session may not have been properly initialized")
405
+
406
+ # Check if the basis session client is still connected
407
+ transport = basis_client.get_transport()
408
+ if transport is None or not transport.is_active():
409
+ raise RuntimeError("Basis session SSH client is no longer active - the connection has been closed or lost")
410
+
411
+ ssh_client = basis_client
412
+ else:
413
+ # Create new client with specified session_user or default credentials
414
+ ssh_client = super()._create_client(session_user=session_user)
415
+ return ssh_client
416
+
417
+ 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]:
418
+ """
419
+ Private helper method used to route the command running parameters to the correct routine based on whether the command is being run interactively
420
+ through a session or is a single run command from an SshAgent.
421
+
422
+ :param ssh_runner: A :class:`SshClient` or :class:`SshChannel` object that can be used to run a command on a remote machine.
423
+ :param command: The command that is to be run.
424
+ :param pty_params: A dictionary of parameters that are passed to paramiko to get a pty when running commands.
425
+ :param inactivity_timeout: A timeout for inactivity between the local machine and remote machine.
426
+ :param inactivity_interval: The interval to wait between attempts to interact or read from the remote machine.
427
+ :param chunk_size: The size of the buffer to use when reading results from the remote machine.
428
+ """
429
+ if self._interactive:
430
+ status, stdout, stderr = ssh_execute_command_in_channel(ssh_runner, command, read_timeout=self._read_timeout, inactivity_timeout=inactivity_timeout, inactivity_interval=inactivity_interval)
431
+ else:
432
+ status, stdout, stderr = ssh_execute_command(ssh_runner, command, pty_params=pty_params, inactivity_timeout=inactivity_timeout, inactivity_interval=inactivity_interval)
433
+ return status, stdout, stderr