cloudru-qa-ssh-client 1.0.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.
- cloudru_qa_ssh_client-1.0.0.dist-info/METADATA +492 -0
- cloudru_qa_ssh_client-1.0.0.dist-info/RECORD +21 -0
- cloudru_qa_ssh_client-1.0.0.dist-info/WHEEL +4 -0
- cloudru_qa_ssh_client-1.0.0.dist-info/licenses/LICENSE +202 -0
- ssh_client/__init__.py +18 -0
- ssh_client/client.py +357 -0
- ssh_client/clients/__init__.py +0 -0
- ssh_client/clients/asyncssh_client.py +155 -0
- ssh_client/clients/exceptions.py +29 -0
- ssh_client/clients/paramiko_client.py +190 -0
- ssh_client/config.py +36 -0
- ssh_client/event_hooks.py +112 -0
- ssh_client/examples/__init__.py +0 -0
- ssh_client/examples/basic_usage.py +83 -0
- ssh_client/options.py +208 -0
- ssh_client/py.typed +0 -0
- ssh_client/results.py +30 -0
- ssh_client/types.py +80 -0
- ssh_client/utils/__init__.py +9 -0
- ssh_client/utils/masking.py +42 -0
- ssh_client/utils/retries.py +178 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING, Unpack, cast
|
|
6
|
+
|
|
7
|
+
import asyncssh
|
|
8
|
+
|
|
9
|
+
from ssh_client.client import BaseAsyncSSHClient
|
|
10
|
+
from ssh_client.config import ExecutionSettings
|
|
11
|
+
from ssh_client.results import LiveSSHResult, SSHResult
|
|
12
|
+
|
|
13
|
+
from .exceptions import CommandNonZeroExitCodeError, SSHClientNotDefinedError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from contextlib import AbstractAsyncContextManager
|
|
17
|
+
|
|
18
|
+
from ssh_client.types import AsyncOperation, ExecuteOptions, InterruptCallback, LiveExecuteOptions, OutputCallback
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class _StreamReader:
|
|
23
|
+
process: asyncssh.SSHClientProcess[str]
|
|
24
|
+
lines: list[str]
|
|
25
|
+
callbacks: tuple[OutputCallback | None, ...]
|
|
26
|
+
interrupt: InterruptCallback | None
|
|
27
|
+
|
|
28
|
+
async def read(self, stream: asyncssh.SSHReader[str]) -> None:
|
|
29
|
+
async for raw in stream:
|
|
30
|
+
if not raw:
|
|
31
|
+
continue
|
|
32
|
+
line = raw.removesuffix("\n").removesuffix("\r")
|
|
33
|
+
self.lines.append(line)
|
|
34
|
+
for callback in self.callbacks:
|
|
35
|
+
if callback is not None:
|
|
36
|
+
callback(line)
|
|
37
|
+
if self.interrupt is not None and self.interrupt(line):
|
|
38
|
+
self.process.send_signal("INT")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AsyncSSHClient(BaseAsyncSSHClient[asyncssh.SSHClientConnection]):
|
|
43
|
+
"""Run asynchronous SSH commands with trusted server keys."""
|
|
44
|
+
|
|
45
|
+
async def connect(self) -> None:
|
|
46
|
+
"""Open a connection after checking the server host key."""
|
|
47
|
+
if self._connected:
|
|
48
|
+
return
|
|
49
|
+
operation: AsyncOperation[None] = self._connect_attempt
|
|
50
|
+
operation = self._decorate(operation, self._connect_decorators)
|
|
51
|
+
if self.connection_retry is None:
|
|
52
|
+
await operation()
|
|
53
|
+
else:
|
|
54
|
+
await self.connection_retry.connect_async(operation)
|
|
55
|
+
|
|
56
|
+
async def execute(self, command: str | list[str], **options: Unpack[ExecuteOptions]) -> SSHResult:
|
|
57
|
+
"""Execute a command and wait for connection cleanup.
|
|
58
|
+
|
|
59
|
+
:param command: Shell command or preformatted shell fragments.
|
|
60
|
+
:param options: Timeout and keyword-only status-check settings.
|
|
61
|
+
:return: Captured output and exit status.
|
|
62
|
+
"""
|
|
63
|
+
settings = ExecutionSettings(**options)
|
|
64
|
+
text = " ".join(command) if isinstance(command, list) else command
|
|
65
|
+
try:
|
|
66
|
+
await self.connect()
|
|
67
|
+
self._request(text)
|
|
68
|
+
|
|
69
|
+
async def execute() -> SSHResult:
|
|
70
|
+
return await self._execute_command(text, settings)
|
|
71
|
+
|
|
72
|
+
operation: AsyncOperation[SSHResult] = execute
|
|
73
|
+
operation = self._decorate(operation, self._execute_decorators)
|
|
74
|
+
result = (
|
|
75
|
+
await operation() if self.command_retry is None else await self.command_retry.execute_async(operation)
|
|
76
|
+
)
|
|
77
|
+
self._response(text, result)
|
|
78
|
+
if settings.raise_for_status and result.exit_code != 0:
|
|
79
|
+
raise CommandNonZeroExitCodeError.from_result(result)
|
|
80
|
+
return result
|
|
81
|
+
finally:
|
|
82
|
+
await self.aclose()
|
|
83
|
+
|
|
84
|
+
async def execute_live(self, command: str | list[str], **options: Unpack[LiveExecuteOptions]) -> LiveSSHResult:
|
|
85
|
+
"""Stream stdout and stderr concurrently and release the connection.
|
|
86
|
+
|
|
87
|
+
:param command: Shell command or preformatted shell fragments.
|
|
88
|
+
:param options: Timeout, status checking, output callbacks and interrupt callbacks.
|
|
89
|
+
:return: Collected output lines and exit status.
|
|
90
|
+
"""
|
|
91
|
+
settings = ExecutionSettings(**options)
|
|
92
|
+
text = " ".join(command) if isinstance(command, list) else command
|
|
93
|
+
try:
|
|
94
|
+
await self.connect()
|
|
95
|
+
self._request(text)
|
|
96
|
+
result = await self._execute_live_command(text, settings)
|
|
97
|
+
self._response(text, result)
|
|
98
|
+
if settings.raise_for_status and result.exit_code != 0:
|
|
99
|
+
raise CommandNonZeroExitCodeError.from_result(result)
|
|
100
|
+
return result
|
|
101
|
+
finally:
|
|
102
|
+
await self.aclose()
|
|
103
|
+
|
|
104
|
+
async def _connect_attempt(self) -> None:
|
|
105
|
+
await self.aclose()
|
|
106
|
+
if not self.username:
|
|
107
|
+
message = "Username must be set by authentication options (with_password or with_private_key)"
|
|
108
|
+
raise ValueError(message)
|
|
109
|
+
client_keys: list[str] | tuple[()] = [self.private_key_path] if self.private_key_path is not None else ()
|
|
110
|
+
self._client = await asyncssh.connect(
|
|
111
|
+
self.host,
|
|
112
|
+
port=self.port,
|
|
113
|
+
username=self.username,
|
|
114
|
+
password=self.password,
|
|
115
|
+
client_keys=client_keys,
|
|
116
|
+
known_hosts=self.known_hosts if self.known_hosts is not None else (),
|
|
117
|
+
connect_timeout=self.connection_timeout if self.connection_timeout is not None else self.timeout,
|
|
118
|
+
)
|
|
119
|
+
self._connected = True
|
|
120
|
+
|
|
121
|
+
def _require_client(self) -> asyncssh.SSHClientConnection:
|
|
122
|
+
if self._client is None or not self._connected:
|
|
123
|
+
raise SSHClientNotDefinedError
|
|
124
|
+
return self._client
|
|
125
|
+
|
|
126
|
+
async def _execute_command(self, command: str, settings: ExecutionSettings) -> SSHResult:
|
|
127
|
+
async with asyncio.timeout(self.timeout if settings.timeout is None else settings.timeout):
|
|
128
|
+
result = await self._require_client().run(command, encoding="utf-8")
|
|
129
|
+
output = result.stdout.decode("utf-8") if isinstance(result.stdout, bytes) else result.stdout
|
|
130
|
+
errors = result.stderr.decode("utf-8") if isinstance(result.stderr, bytes) else result.stderr
|
|
131
|
+
return SSHResult(output or "", errors or "", result.returncode)
|
|
132
|
+
|
|
133
|
+
async def _execute_live_command(self, command: str, settings: ExecutionSettings) -> LiveSSHResult:
|
|
134
|
+
async with cast(
|
|
135
|
+
"AbstractAsyncContextManager[asyncssh.SSHClientProcess[str]]",
|
|
136
|
+
self._require_client().create_process(command, encoding="utf-8"),
|
|
137
|
+
) as process:
|
|
138
|
+
output: list[str] = []
|
|
139
|
+
errors: list[str] = []
|
|
140
|
+
stdout = _StreamReader(
|
|
141
|
+
process,
|
|
142
|
+
output,
|
|
143
|
+
(settings.stdout_callback, self.live_stdout_callback),
|
|
144
|
+
settings.interrupt_stdout_callback,
|
|
145
|
+
)
|
|
146
|
+
stderr = _StreamReader(
|
|
147
|
+
process,
|
|
148
|
+
errors,
|
|
149
|
+
(settings.stderr_callback, self.live_stderr_callback),
|
|
150
|
+
settings.interrupt_stderr_callback,
|
|
151
|
+
)
|
|
152
|
+
async with asyncio.timeout(self.timeout if settings.timeout is None else settings.timeout):
|
|
153
|
+
await asyncio.gather(stdout.read(process.stdout), stderr.read(process.stderr))
|
|
154
|
+
await process.wait()
|
|
155
|
+
return LiveSSHResult(output, errors, process.returncode)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ssh_client.results import LiveSSHResult, SSHResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SSHClientNotDefinedError(Exception):
|
|
7
|
+
"""Report an operation without an authenticated transport."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str = "SSH client is not defined") -> None:
|
|
10
|
+
"""Keep the transport failure reason.
|
|
11
|
+
|
|
12
|
+
:param message: Failure description.
|
|
13
|
+
"""
|
|
14
|
+
self.message = message
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandNonZeroExitCodeError(Exception):
|
|
19
|
+
"""Report a command which returned a failing exit status."""
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_result(cls, result: SSHResult | LiveSSHResult) -> CommandNonZeroExitCodeError:
|
|
23
|
+
"""Describe the failed command result.
|
|
24
|
+
|
|
25
|
+
:param result: Failed command output and exit status.
|
|
26
|
+
:return: Exception describing the command failure.
|
|
27
|
+
"""
|
|
28
|
+
stderr = result.stderr if isinstance(result, SSHResult) else "\n".join(result.stderr_lines)
|
|
29
|
+
return cls(f"Command failed with exit code {result.exit_code}: {stderr}")
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from codecs import getincrementaldecoder
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Unpack
|
|
7
|
+
|
|
8
|
+
import paramiko
|
|
9
|
+
|
|
10
|
+
from ssh_client.client import BaseSSHClient
|
|
11
|
+
from ssh_client.config import ExecutionSettings
|
|
12
|
+
from ssh_client.results import LiveSSHResult, SSHResult
|
|
13
|
+
|
|
14
|
+
from .exceptions import CommandNonZeroExitCodeError, SSHClientNotDefinedError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from codecs import IncrementalDecoder
|
|
18
|
+
|
|
19
|
+
from ssh_client.types import ExecuteOptions, LiveExecuteOptions, OutputCallback
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class _LiveOutput:
|
|
24
|
+
settings: ExecutionSettings
|
|
25
|
+
callbacks: tuple[OutputCallback | None, ...]
|
|
26
|
+
lines: list[str] = field(default_factory=list)
|
|
27
|
+
pending: str = ""
|
|
28
|
+
decoder: IncrementalDecoder = field(default_factory=getincrementaldecoder("utf-8"))
|
|
29
|
+
|
|
30
|
+
def receive(self, data: bytes, *, final: bool = False) -> bool:
|
|
31
|
+
self.pending += self.decoder.decode(data, final=final)
|
|
32
|
+
while "\n" in self.pending:
|
|
33
|
+
line, self.pending = self.pending.split("\n", 1)
|
|
34
|
+
if self._line(line.removesuffix("\r")):
|
|
35
|
+
return True
|
|
36
|
+
return bool(final and self.pending and self._line(self.pending))
|
|
37
|
+
|
|
38
|
+
def _line(self, line: str) -> bool:
|
|
39
|
+
self.lines.append(line)
|
|
40
|
+
for callback in self.callbacks:
|
|
41
|
+
if callback is not None:
|
|
42
|
+
callback(line)
|
|
43
|
+
interrupt = self.settings.interrupt_stdout_callback
|
|
44
|
+
return interrupt is not None and interrupt(line)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SyncSSHClient(BaseSSHClient[paramiko.SSHClient]):
|
|
48
|
+
"""Run SSH commands through Paramiko with trusted server keys."""
|
|
49
|
+
|
|
50
|
+
def connect(self) -> None:
|
|
51
|
+
"""Open a connection after checking the server host key."""
|
|
52
|
+
if self._connected:
|
|
53
|
+
return
|
|
54
|
+
operation = self._decorate(self._connect_attempt, self._connect_decorators)
|
|
55
|
+
if self.connection_retry is None:
|
|
56
|
+
operation()
|
|
57
|
+
else:
|
|
58
|
+
self.connection_retry.connect(operation)
|
|
59
|
+
|
|
60
|
+
def execute(self, command: str | list[str], **options: Unpack[ExecuteOptions]) -> SSHResult:
|
|
61
|
+
"""Execute a command and close its connection.
|
|
62
|
+
|
|
63
|
+
:param command: Shell command or preformatted shell fragments.
|
|
64
|
+
:param options: Timeout and keyword-only status-check settings.
|
|
65
|
+
:return: Captured output and exit status.
|
|
66
|
+
"""
|
|
67
|
+
settings = ExecutionSettings(**options)
|
|
68
|
+
text = " ".join(command) if isinstance(command, list) else command
|
|
69
|
+
try:
|
|
70
|
+
self.connect()
|
|
71
|
+
self._request(text)
|
|
72
|
+
|
|
73
|
+
def execute() -> SSHResult:
|
|
74
|
+
return self._execute_command(text, settings)
|
|
75
|
+
|
|
76
|
+
operation = self._decorate(execute, self._execute_decorators)
|
|
77
|
+
result = operation() if self.command_retry is None else self.command_retry.execute(operation)
|
|
78
|
+
self._response(text, result)
|
|
79
|
+
if settings.raise_for_status and result.exit_code != 0:
|
|
80
|
+
raise CommandNonZeroExitCodeError.from_result(result)
|
|
81
|
+
return result
|
|
82
|
+
finally:
|
|
83
|
+
self.close()
|
|
84
|
+
|
|
85
|
+
def execute_live(self, command: str | list[str], **options: Unpack[LiveExecuteOptions]) -> LiveSSHResult:
|
|
86
|
+
"""Stream command lines through callbacks and close the connection.
|
|
87
|
+
|
|
88
|
+
Paramiko uses a terminal so that interrupting output also stops the remote process;
|
|
89
|
+
stderr is therefore merged into stdout.
|
|
90
|
+
|
|
91
|
+
:param command: Shell command or preformatted shell fragments.
|
|
92
|
+
:param options: Timeout, status checking, stdout callbacks and interrupt callback.
|
|
93
|
+
:return: Collected output lines and exit status.
|
|
94
|
+
"""
|
|
95
|
+
settings = ExecutionSettings(**options)
|
|
96
|
+
text = " ".join(command) if isinstance(command, list) else command
|
|
97
|
+
try:
|
|
98
|
+
self.connect()
|
|
99
|
+
self._request(text)
|
|
100
|
+
result = self._execute_live_command(text, settings)
|
|
101
|
+
self._response(text, result)
|
|
102
|
+
if settings.raise_for_status and result.exit_code != 0:
|
|
103
|
+
raise CommandNonZeroExitCodeError.from_result(result)
|
|
104
|
+
return result
|
|
105
|
+
finally:
|
|
106
|
+
self.close()
|
|
107
|
+
|
|
108
|
+
def _connect_attempt(self) -> None:
|
|
109
|
+
self.close()
|
|
110
|
+
if not self.username:
|
|
111
|
+
message = "Username must be set by authentication options (with_password or with_private_key)"
|
|
112
|
+
raise ValueError(message)
|
|
113
|
+
self._client = paramiko.SSHClient()
|
|
114
|
+
if self.known_hosts is None:
|
|
115
|
+
self._client.load_system_host_keys()
|
|
116
|
+
else:
|
|
117
|
+
self._client.load_host_keys(self.known_hosts)
|
|
118
|
+
try:
|
|
119
|
+
self._client.connect(
|
|
120
|
+
hostname=self.host,
|
|
121
|
+
port=self.port,
|
|
122
|
+
username=self.username,
|
|
123
|
+
password=self.password,
|
|
124
|
+
key_filename=self.private_key_path,
|
|
125
|
+
timeout=self.connection_timeout if self.connection_timeout is not None else self.timeout,
|
|
126
|
+
allow_agent=self.password is None and self.private_key_path is None,
|
|
127
|
+
look_for_keys=self.password is None and self.private_key_path is None,
|
|
128
|
+
)
|
|
129
|
+
except BaseException:
|
|
130
|
+
self.close()
|
|
131
|
+
raise
|
|
132
|
+
self._connected = True
|
|
133
|
+
|
|
134
|
+
def _require_client(self) -> paramiko.SSHClient:
|
|
135
|
+
if self._client is None or not self._connected:
|
|
136
|
+
raise SSHClientNotDefinedError
|
|
137
|
+
return self._client
|
|
138
|
+
|
|
139
|
+
def _execute_command(self, command: str, settings: ExecutionSettings) -> SSHResult:
|
|
140
|
+
transport = self._require_client()
|
|
141
|
+
stdin, stdout, stderr = transport.exec_command(command, timeout=settings.timeout or self.timeout)
|
|
142
|
+
stdin.close()
|
|
143
|
+
try:
|
|
144
|
+
return self._collect_output(stdout.channel, settings)
|
|
145
|
+
finally:
|
|
146
|
+
stdout.close()
|
|
147
|
+
stderr.close()
|
|
148
|
+
|
|
149
|
+
def _collect_output(self, channel: paramiko.Channel, settings: ExecutionSettings) -> SSHResult:
|
|
150
|
+
output = bytearray()
|
|
151
|
+
errors = bytearray()
|
|
152
|
+
deadline = time.monotonic() + (self.timeout if settings.timeout is None else settings.timeout)
|
|
153
|
+
while True:
|
|
154
|
+
if channel.recv_ready():
|
|
155
|
+
output.extend(channel.recv(65536))
|
|
156
|
+
if channel.recv_stderr_ready():
|
|
157
|
+
errors.extend(channel.recv_stderr(65536))
|
|
158
|
+
if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
|
|
159
|
+
return SSHResult(output.decode("utf-8"), errors.decode("utf-8"), channel.recv_exit_status())
|
|
160
|
+
self._wait_for_output(deadline)
|
|
161
|
+
|
|
162
|
+
def _execute_live_command(self, command: str, settings: ExecutionSettings) -> LiveSSHResult:
|
|
163
|
+
stdin, stdout, stderr = self._require_client().exec_command(
|
|
164
|
+
command,
|
|
165
|
+
timeout=settings.timeout or self.timeout,
|
|
166
|
+
get_pty=True,
|
|
167
|
+
)
|
|
168
|
+
stdin.close()
|
|
169
|
+
output = _LiveOutput(settings, (settings.stdout_callback, self.live_stdout_callback))
|
|
170
|
+
deadline = time.monotonic() + (self.timeout if settings.timeout is None else settings.timeout)
|
|
171
|
+
try:
|
|
172
|
+
while True:
|
|
173
|
+
if stdout.channel.recv_ready() and output.receive(stdout.channel.recv(65536)):
|
|
174
|
+
stdout.channel.send(b"\x03")
|
|
175
|
+
stdout.channel.close()
|
|
176
|
+
return LiveSSHResult(output.lines, [], -1)
|
|
177
|
+
if stdout.channel.exit_status_ready() and not stdout.channel.recv_ready():
|
|
178
|
+
output.receive(b"", final=True)
|
|
179
|
+
return LiveSSHResult(output.lines, [], stdout.channel.recv_exit_status())
|
|
180
|
+
self._wait_for_output(deadline)
|
|
181
|
+
finally:
|
|
182
|
+
stdout.close()
|
|
183
|
+
stderr.close()
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _wait_for_output(deadline: float) -> None:
|
|
187
|
+
if time.monotonic() >= deadline:
|
|
188
|
+
message = "SSH command timed out"
|
|
189
|
+
raise TimeoutError(message)
|
|
190
|
+
time.sleep(0.01)
|
ssh_client/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .types import InterruptCallback, OutputCallback
|
|
8
|
+
from .utils.retries import RetryPolicy
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class SSHClientConfig:
|
|
13
|
+
"""Store connection and command settings."""
|
|
14
|
+
|
|
15
|
+
username: str | None = None
|
|
16
|
+
password: str | None = None
|
|
17
|
+
private_key_path: str | None = None
|
|
18
|
+
known_hosts: str | None = None
|
|
19
|
+
timeout: float = 30
|
|
20
|
+
connection_timeout: float | None = None
|
|
21
|
+
connection_retry: RetryPolicy | None = None
|
|
22
|
+
command_retry: RetryPolicy | None = None
|
|
23
|
+
live_stdout_callback: OutputCallback | None = None
|
|
24
|
+
live_stderr_callback: OutputCallback | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ExecutionSettings:
|
|
29
|
+
"""Group command settings passed to execution helpers."""
|
|
30
|
+
|
|
31
|
+
timeout: float | None = None
|
|
32
|
+
raise_for_status: bool = False
|
|
33
|
+
stdout_callback: OutputCallback | None = None
|
|
34
|
+
stderr_callback: OutputCallback | None = None
|
|
35
|
+
interrupt_stdout_callback: InterruptCallback | None = None
|
|
36
|
+
interrupt_stderr_callback: InterruptCallback | None = None
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from .results import LiveSSHResult
|
|
7
|
+
from .utils.masking import mask_request_data, mask_response_data
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from logging import Logger
|
|
12
|
+
|
|
13
|
+
from .results import SSHResult
|
|
14
|
+
from .types import RequestHook, ResponseHook, StructuredLogger
|
|
15
|
+
|
|
16
|
+
type MaskPattern = str | re.Pattern[str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _outputs(result: SSHResult | LiveSSHResult, patterns: Sequence[MaskPattern]) -> tuple[str, str]:
|
|
20
|
+
if isinstance(result, LiveSSHResult):
|
|
21
|
+
return mask_response_data("\n".join(result.stdout_lines), "\n".join(result.stderr_lines), patterns)
|
|
22
|
+
return mask_response_data(result.stdout, result.stderr, patterns)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_request_logger_hook(logger: Logger, mask_patterns: Sequence[MaskPattern] | None = None) -> RequestHook:
|
|
26
|
+
"""Create a sanitized request log callback.
|
|
27
|
+
|
|
28
|
+
:param logger: Standard logger.
|
|
29
|
+
:param mask_patterns: Literal secrets or regular expressions.
|
|
30
|
+
:return: Request observer.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def log_request(*, host: str, port: int, username: str | None, command: str) -> None:
|
|
34
|
+
logger.info(
|
|
35
|
+
"SSH request: host=%s, port=%s, username=%s, command=%s",
|
|
36
|
+
host,
|
|
37
|
+
port,
|
|
38
|
+
username,
|
|
39
|
+
mask_request_data(command, mask_patterns or ()),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return log_request
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_response_logger_hook(logger: Logger, mask_patterns: Sequence[MaskPattern] | None = None) -> ResponseHook:
|
|
46
|
+
"""Create a sanitized command result log callback.
|
|
47
|
+
|
|
48
|
+
:param logger: Standard logger.
|
|
49
|
+
:param mask_patterns: Literal secrets or regular expressions.
|
|
50
|
+
:return: Response observer.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def log_response(*, host: str, command: str, result: SSHResult | LiveSSHResult) -> None:
|
|
54
|
+
output, errors = _outputs(result, mask_patterns or ())
|
|
55
|
+
logger.info(
|
|
56
|
+
"SSH response: host=%s, exit_code=%s, stdout=%s, stderr=%s, command=%s",
|
|
57
|
+
host,
|
|
58
|
+
result.exit_code,
|
|
59
|
+
output,
|
|
60
|
+
errors,
|
|
61
|
+
mask_request_data(command, mask_patterns or ()),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return log_response
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_request_structlogger_hook(
|
|
68
|
+
logger: StructuredLogger,
|
|
69
|
+
mask_patterns: Sequence[MaskPattern] | None = None,
|
|
70
|
+
) -> RequestHook:
|
|
71
|
+
"""Create a sanitized structured request observer.
|
|
72
|
+
|
|
73
|
+
:param logger: Structured logger.
|
|
74
|
+
:param mask_patterns: Literal secrets or regular expressions.
|
|
75
|
+
:return: Request observer.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def log_request(*, host: str, port: int, username: str | None, command: str) -> None:
|
|
79
|
+
logger.info(
|
|
80
|
+
"ssh request sent",
|
|
81
|
+
host=host,
|
|
82
|
+
port=port,
|
|
83
|
+
username=username,
|
|
84
|
+
command=mask_request_data(command, mask_patterns or ()),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return log_request
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def create_response_structlogger_hook(
|
|
91
|
+
logger: StructuredLogger,
|
|
92
|
+
mask_patterns: Sequence[MaskPattern] | None = None,
|
|
93
|
+
) -> ResponseHook:
|
|
94
|
+
"""Create a sanitized structured command result observer.
|
|
95
|
+
|
|
96
|
+
:param logger: Structured logger.
|
|
97
|
+
:param mask_patterns: Literal secrets or regular expressions.
|
|
98
|
+
:return: Response observer.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def log_response(*, host: str, command: str, result: SSHResult | LiveSSHResult) -> None:
|
|
102
|
+
output, errors = _outputs(result, mask_patterns or ())
|
|
103
|
+
logger.info(
|
|
104
|
+
"ssh response received",
|
|
105
|
+
host=host,
|
|
106
|
+
exit_code=result.exit_code,
|
|
107
|
+
stdout=output,
|
|
108
|
+
stderr=errors,
|
|
109
|
+
command=mask_request_data(command, mask_patterns or ()),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return log_response
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Command examples with host key verification and connection cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from ssh_client import AsyncSSHClient, SyncSSHClient, options
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ssh_client.types import StructuredLogger
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sync_example() -> None:
|
|
20
|
+
"""Run a command with an SSH agent key and the default known_hosts."""
|
|
21
|
+
with SyncSSHClient("localhost", 22, options.with_private_key("qa-user", "/path/to/private/key")) as client:
|
|
22
|
+
result = client.execute("uname -a", timeout=10, raise_for_status=True)
|
|
23
|
+
logger.info("Remote system: %s", result.stdout)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def async_example() -> None:
|
|
27
|
+
"""Run an asynchronous command with an explicit list of trusted servers."""
|
|
28
|
+
async with AsyncSSHClient(
|
|
29
|
+
"localhost",
|
|
30
|
+
22,
|
|
31
|
+
options.with_private_key("qa-user", "/path/to/private/key"),
|
|
32
|
+
options.with_known_hosts("/path/to/known_hosts"),
|
|
33
|
+
options.with_logger(logger),
|
|
34
|
+
) as client:
|
|
35
|
+
result = await client.execute("uname -a", timeout=10, raise_for_status=True)
|
|
36
|
+
logger.info("Remote system: %s", result.stdout)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def advanced_sync_example() -> None:
|
|
40
|
+
"""Pass command lines to a handler and limit reconnects."""
|
|
41
|
+
with SyncSSHClient(
|
|
42
|
+
"localhost",
|
|
43
|
+
22,
|
|
44
|
+
options.with_private_key("qa-user", "/path/to/private/key"),
|
|
45
|
+
options.with_live_execute_callback(lambda line: logger.info("Output: %s", line)),
|
|
46
|
+
options.retry_if_connection_fails(3, 1, reraise_exception=True),
|
|
47
|
+
) as client:
|
|
48
|
+
result = client.execute_live("printf 'first\\nsecond\\n'", timeout=10, raise_for_status=True)
|
|
49
|
+
logger.info("Command exit status: %s", result.exit_code)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def advanced_async_example() -> None:
|
|
53
|
+
"""Override settings for one command using a separate client copy."""
|
|
54
|
+
async with AsyncSSHClient(
|
|
55
|
+
"localhost",
|
|
56
|
+
22,
|
|
57
|
+
options.with_private_key("qa-user", "/path/to/private/key"),
|
|
58
|
+
) as client:
|
|
59
|
+
async with client.with_options(options.with_request_timeout(10)) as temporary:
|
|
60
|
+
result = await temporary.execute_live("printf 'one\\ntwo\\n'", raise_for_status=True)
|
|
61
|
+
logger.info("Output: %s", result.stdout_lines)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def structlog_example() -> None:
|
|
65
|
+
"""Log a command through structlog with the specified text masked."""
|
|
66
|
+
structured = cast("StructuredLogger", structlog.get_logger())
|
|
67
|
+
with SyncSSHClient(
|
|
68
|
+
"localhost",
|
|
69
|
+
22,
|
|
70
|
+
options.with_private_key("qa-user", "/path/to/private/key"),
|
|
71
|
+
options.with_structlogger(structured, mask_patterns=["sensitive-value"]),
|
|
72
|
+
) as client:
|
|
73
|
+
result = client.execute("printf 'sensitive-value'", raise_for_status=True)
|
|
74
|
+
logger.info("Command exit status: %s", result.exit_code)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
logging.basicConfig(level=logging.INFO)
|
|
79
|
+
sync_example()
|
|
80
|
+
asyncio.run(async_example())
|
|
81
|
+
advanced_sync_example()
|
|
82
|
+
asyncio.run(advanced_async_example())
|
|
83
|
+
structlog_example()
|