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
ssh_client/options.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from .event_hooks import (
|
|
6
|
+
create_request_logger_hook,
|
|
7
|
+
create_request_structlogger_hook,
|
|
8
|
+
create_response_logger_hook,
|
|
9
|
+
create_response_structlogger_hook,
|
|
10
|
+
)
|
|
11
|
+
from .utils.retries import RetryPolicy
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from logging import Logger
|
|
16
|
+
|
|
17
|
+
from .client import ClientOption, InitSSHClient
|
|
18
|
+
from .event_hooks import MaskPattern
|
|
19
|
+
from .types import OperationDecorator, OutputCallback, StructuredLogger
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def with_request_timeout(timeout: float) -> ClientOption:
|
|
23
|
+
"""Set the command timeout.
|
|
24
|
+
|
|
25
|
+
:param timeout: Maximum command duration in seconds.
|
|
26
|
+
:return: Client option.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def option(client: InitSSHClient) -> None:
|
|
30
|
+
client.timeout = timeout
|
|
31
|
+
|
|
32
|
+
return option
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def with_connection_timeout(timeout: float) -> ClientOption:
|
|
36
|
+
"""Set the connection timeout.
|
|
37
|
+
|
|
38
|
+
:param timeout: Maximum connection duration in seconds.
|
|
39
|
+
:return: Client option.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def option(client: InitSSHClient) -> None:
|
|
43
|
+
client.connection_timeout = timeout
|
|
44
|
+
|
|
45
|
+
return option
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def with_password(username: str, password: str) -> ClientOption:
|
|
49
|
+
"""Select password authentication.
|
|
50
|
+
|
|
51
|
+
:param username: SSH login name.
|
|
52
|
+
:param password: Login password.
|
|
53
|
+
:return: Client option.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def option(client: InitSSHClient) -> None:
|
|
57
|
+
client.username = username
|
|
58
|
+
client.password = password
|
|
59
|
+
client.private_key_path = None
|
|
60
|
+
|
|
61
|
+
return option
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def with_private_key(username: str, private_key_path: str) -> ClientOption:
|
|
65
|
+
"""Select private-key authentication.
|
|
66
|
+
|
|
67
|
+
:param username: SSH login name.
|
|
68
|
+
:param private_key_path: Path to the private key file.
|
|
69
|
+
:return: Client option.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def option(client: InitSSHClient) -> None:
|
|
73
|
+
client.username = username
|
|
74
|
+
client.private_key_path = private_key_path
|
|
75
|
+
client.password = None
|
|
76
|
+
|
|
77
|
+
return option
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def with_known_hosts(path: str) -> ClientOption:
|
|
81
|
+
"""Trust server keys from an explicit OpenSSH known_hosts file.
|
|
82
|
+
|
|
83
|
+
:param path: Path to the trusted server keys.
|
|
84
|
+
:return: Client option.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def option(client: InitSSHClient) -> None:
|
|
88
|
+
client.known_hosts = path
|
|
89
|
+
|
|
90
|
+
return option
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def with_logger(logger: Logger, mask_patterns: Sequence[MaskPattern] | None = None) -> ClientOption:
|
|
94
|
+
"""Log sanitized SSH requests and command results.
|
|
95
|
+
|
|
96
|
+
:param logger: Standard logger.
|
|
97
|
+
:param mask_patterns: Literal secrets or regular expressions to mask.
|
|
98
|
+
:return: Client option.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def option(client: InitSSHClient) -> None:
|
|
102
|
+
client.add_request_hook(create_request_logger_hook(logger, mask_patterns))
|
|
103
|
+
client.add_response_hook(create_response_logger_hook(logger, mask_patterns))
|
|
104
|
+
|
|
105
|
+
return option
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def with_structlogger(logger: StructuredLogger, mask_patterns: Sequence[MaskPattern] | None = None) -> ClientOption:
|
|
109
|
+
"""Log sanitized structured SSH requests and command results.
|
|
110
|
+
|
|
111
|
+
:param logger: Structured logger.
|
|
112
|
+
:param mask_patterns: Literal secrets or regular expressions to mask.
|
|
113
|
+
:return: Client option.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def option(client: InitSSHClient) -> None:
|
|
117
|
+
client.add_request_hook(create_request_structlogger_hook(logger, mask_patterns))
|
|
118
|
+
client.add_response_hook(create_response_structlogger_hook(logger, mask_patterns))
|
|
119
|
+
|
|
120
|
+
return option
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def retry_if_connection_fails(
|
|
124
|
+
retries_attempts_number: int,
|
|
125
|
+
retries_interval_in_seconds: float,
|
|
126
|
+
*,
|
|
127
|
+
reraise_exception: bool = False,
|
|
128
|
+
) -> ClientOption:
|
|
129
|
+
"""Retry failed connections with a bounded delay.
|
|
130
|
+
|
|
131
|
+
:param retries_attempts_number: Maximum attempts.
|
|
132
|
+
:param retries_interval_in_seconds: Delay between attempts.
|
|
133
|
+
:param reraise_exception: Propagate the final connection error.
|
|
134
|
+
:return: Client option.
|
|
135
|
+
"""
|
|
136
|
+
policy = RetryPolicy(retries_attempts_number, retries_interval_in_seconds, reraise=reraise_exception)
|
|
137
|
+
|
|
138
|
+
def option(client: InitSSHClient) -> None:
|
|
139
|
+
client.connection_retry = policy
|
|
140
|
+
|
|
141
|
+
return option
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def retry_if_command_fails(
|
|
145
|
+
retries_attempts_number: int,
|
|
146
|
+
retries_interval_in_seconds: float,
|
|
147
|
+
*,
|
|
148
|
+
reraise_exception: bool = False,
|
|
149
|
+
) -> ClientOption:
|
|
150
|
+
"""Retry raised command errors and nonzero exit codes.
|
|
151
|
+
|
|
152
|
+
:param retries_attempts_number: Maximum attempts.
|
|
153
|
+
:param retries_interval_in_seconds: Delay between attempts.
|
|
154
|
+
:param reraise_exception: Propagate the final command failure.
|
|
155
|
+
:return: Client option.
|
|
156
|
+
"""
|
|
157
|
+
policy = RetryPolicy(retries_attempts_number, retries_interval_in_seconds, reraise=reraise_exception)
|
|
158
|
+
|
|
159
|
+
def option(client: InitSSHClient) -> None:
|
|
160
|
+
client.command_retry = policy
|
|
161
|
+
|
|
162
|
+
return option
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def with_connect_decorators(*decorators: OperationDecorator) -> ClientOption:
|
|
166
|
+
"""Register custom connection wrappers in application order.
|
|
167
|
+
|
|
168
|
+
:param decorators: Wrappers preserving the operation's result type.
|
|
169
|
+
:return: Client option.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def option(client: InitSSHClient) -> None:
|
|
173
|
+
for decorator in decorators:
|
|
174
|
+
client.add_connect_decorator(decorator)
|
|
175
|
+
|
|
176
|
+
return option
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def with_execute_decorators(*decorators: OperationDecorator) -> ClientOption:
|
|
180
|
+
"""Register custom command wrappers in application order.
|
|
181
|
+
|
|
182
|
+
:param decorators: Wrappers preserving the operation's result type.
|
|
183
|
+
:return: Client option.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def option(client: InitSSHClient) -> None:
|
|
187
|
+
for decorator in decorators:
|
|
188
|
+
client.add_execute_decorator(decorator)
|
|
189
|
+
|
|
190
|
+
return option
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def with_live_execute_callback(
|
|
194
|
+
stdout_callback: OutputCallback | None = None,
|
|
195
|
+
stderr_callback: OutputCallback | None = None,
|
|
196
|
+
) -> ClientOption:
|
|
197
|
+
"""Register default streaming output callbacks.
|
|
198
|
+
|
|
199
|
+
:param stdout_callback: Observer of stdout lines.
|
|
200
|
+
:param stderr_callback: Observer of stderr lines for asynchronous clients.
|
|
201
|
+
:return: Client option.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def option(client: InitSSHClient) -> None:
|
|
205
|
+
client.live_stdout_callback = stdout_callback
|
|
206
|
+
client.live_stderr_callback = stderr_callback
|
|
207
|
+
|
|
208
|
+
return option
|
ssh_client/py.typed
ADDED
|
File without changes
|
ssh_client/results.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class SSHResult:
|
|
6
|
+
"""SSH command result."""
|
|
7
|
+
|
|
8
|
+
stdout: str = ""
|
|
9
|
+
stderr: str = ""
|
|
10
|
+
exit_code: int | None = None
|
|
11
|
+
|
|
12
|
+
def __repr__(self) -> str:
|
|
13
|
+
"""Return a concise description of the command result."""
|
|
14
|
+
return f"SSHResult(stdout='{self.stdout}', stderr='{self.stderr}', exit_code={self.exit_code})"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class LiveSSHResult:
|
|
19
|
+
"""SSH command result with real-time data access."""
|
|
20
|
+
|
|
21
|
+
stdout_lines: list[str] = field(default_factory=list)
|
|
22
|
+
stderr_lines: list[str] = field(default_factory=list)
|
|
23
|
+
exit_code: int | None = None
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
"""Return a concise description of the command result."""
|
|
27
|
+
return (
|
|
28
|
+
f"LiveSSHResult(stdout_lines={len(self.stdout_lines)}, "
|
|
29
|
+
f"stderr_lines={len(self.stderr_lines)}, exit_code={self.exit_code})"
|
|
30
|
+
)
|
ssh_client/types.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import TYPE_CHECKING, Protocol, TypedDict
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .results import LiveSSHResult, SSHResult
|
|
8
|
+
|
|
9
|
+
type OutputCallback = Callable[[str], None]
|
|
10
|
+
type InterruptCallback = Callable[[str], bool]
|
|
11
|
+
type SyncOperation[T] = Callable[[], T]
|
|
12
|
+
type AsyncOperation[T] = Callable[[], Awaitable[T]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OperationDecorator(Protocol):
|
|
16
|
+
"""Wrap an operation while preserving its parameters and result."""
|
|
17
|
+
|
|
18
|
+
def __call__[T](self, operation: Callable[[], T]) -> Callable[[], T]:
|
|
19
|
+
"""Return a wrapped operation.
|
|
20
|
+
|
|
21
|
+
:param operation: Deferred connection or command operation.
|
|
22
|
+
:return: Operation with the same result type.
|
|
23
|
+
"""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RequestHook(Protocol):
|
|
28
|
+
"""Observe an SSH request."""
|
|
29
|
+
|
|
30
|
+
def __call__(self, *, host: str, port: int, username: str | None, command: str) -> None:
|
|
31
|
+
"""Handle request values."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResponseHook(Protocol):
|
|
36
|
+
"""Observe an SSH command result."""
|
|
37
|
+
|
|
38
|
+
def __call__(self, *, host: str, command: str, result: SSHResult | LiveSSHResult) -> None:
|
|
39
|
+
"""Handle completed command output."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StructuredLogger(Protocol):
|
|
44
|
+
"""Accept structured SSH log entries."""
|
|
45
|
+
|
|
46
|
+
def info(self, event: str, **values: object) -> object:
|
|
47
|
+
"""Write a structured informational event."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Closeable(Protocol):
|
|
52
|
+
"""Close a synchronous transport."""
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
"""Release the transport."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AsyncCloseable(Closeable, Protocol):
|
|
60
|
+
"""Wait for transport shutdown."""
|
|
61
|
+
|
|
62
|
+
async def wait_closed(self) -> None:
|
|
63
|
+
"""Wait until transport resources are released."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ExecuteOptions(TypedDict, total=False):
|
|
68
|
+
"""Keyword settings for a command."""
|
|
69
|
+
|
|
70
|
+
timeout: float | None
|
|
71
|
+
raise_for_status: bool
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class LiveExecuteOptions(ExecuteOptions, total=False):
|
|
75
|
+
"""Keyword settings for a streaming command."""
|
|
76
|
+
|
|
77
|
+
stdout_callback: OutputCallback | None
|
|
78
|
+
stderr_callback: OutputCallback | None
|
|
79
|
+
interrupt_stdout_callback: InterruptCallback | None
|
|
80
|
+
interrupt_stderr_callback: InterruptCallback | None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def mask_request_data(command: str, mask_patterns: Sequence[str | re.Pattern[str]]) -> str:
|
|
6
|
+
"""Mask sensitive data in SSH request command.
|
|
7
|
+
|
|
8
|
+
:param command: Command to mask
|
|
9
|
+
:param mask_patterns: Patterns to use for masking sensitive data
|
|
10
|
+
:returns: Masked command
|
|
11
|
+
"""
|
|
12
|
+
masked_command = command
|
|
13
|
+
for pattern in mask_patterns:
|
|
14
|
+
if isinstance(pattern, str):
|
|
15
|
+
masked_command = masked_command.replace(pattern, "****")
|
|
16
|
+
else:
|
|
17
|
+
masked_command = pattern.sub("****", masked_command)
|
|
18
|
+
return masked_command
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def mask_response_data(
|
|
22
|
+
stdout: str,
|
|
23
|
+
stderr: str,
|
|
24
|
+
mask_patterns: Sequence[str | re.Pattern[str]],
|
|
25
|
+
) -> tuple[str, str]:
|
|
26
|
+
"""Mask sensitive data in SSH response stdout and stderr.
|
|
27
|
+
|
|
28
|
+
:param stdout: Standard output to mask
|
|
29
|
+
:param stderr: Standard error to mask
|
|
30
|
+
:param mask_patterns: Patterns to use for masking sensitive data
|
|
31
|
+
:returns: Tuple of masked stdout and stderr
|
|
32
|
+
"""
|
|
33
|
+
masked_stdout = stdout
|
|
34
|
+
masked_stderr = stderr
|
|
35
|
+
for pattern in mask_patterns:
|
|
36
|
+
if isinstance(pattern, str):
|
|
37
|
+
masked_stdout = masked_stdout.replace(pattern, "****")
|
|
38
|
+
masked_stderr = masked_stderr.replace(pattern, "****")
|
|
39
|
+
else:
|
|
40
|
+
masked_stdout = pattern.sub("****", masked_stdout)
|
|
41
|
+
masked_stderr = pattern.sub("****", masked_stderr)
|
|
42
|
+
return masked_stdout, masked_stderr
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import asyncssh
|
|
9
|
+
import paramiko
|
|
10
|
+
|
|
11
|
+
from ssh_client.clients.exceptions import CommandNonZeroExitCodeError
|
|
12
|
+
from ssh_client.results import SSHResult
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from logging import Logger
|
|
17
|
+
|
|
18
|
+
CONNECTION_ERRORS = (OSError, paramiko.SSHException, asyncssh.Error)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class RetryPolicy:
|
|
23
|
+
"""Apply bounded retries to connection and command operations."""
|
|
24
|
+
|
|
25
|
+
attempts: int = 3
|
|
26
|
+
interval: float = 5
|
|
27
|
+
reraise: bool = False
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
"""Reject settings that cannot execute a bounded retry."""
|
|
31
|
+
if self.attempts < 1 or self.interval < 0:
|
|
32
|
+
message = "Retry attempts must be positive and interval must be nonnegative"
|
|
33
|
+
raise ValueError(message)
|
|
34
|
+
|
|
35
|
+
def connect(self, operation: Callable[[], None]) -> None:
|
|
36
|
+
"""Retry a synchronous connection operation.
|
|
37
|
+
|
|
38
|
+
:param operation: Connection attempt.
|
|
39
|
+
"""
|
|
40
|
+
for attempt in range(self.attempts):
|
|
41
|
+
try:
|
|
42
|
+
operation()
|
|
43
|
+
except CONNECTION_ERRORS:
|
|
44
|
+
if attempt + 1 == self.attempts:
|
|
45
|
+
if self.reraise:
|
|
46
|
+
raise
|
|
47
|
+
return
|
|
48
|
+
time.sleep(self.interval)
|
|
49
|
+
else:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
async def connect_async(self, operation: Callable[[], Awaitable[None]]) -> None:
|
|
53
|
+
"""Retry an asynchronous connection operation without blocking the event loop.
|
|
54
|
+
|
|
55
|
+
:param operation: Connection attempt.
|
|
56
|
+
"""
|
|
57
|
+
for attempt in range(self.attempts):
|
|
58
|
+
try:
|
|
59
|
+
await operation()
|
|
60
|
+
except CONNECTION_ERRORS:
|
|
61
|
+
if attempt + 1 == self.attempts:
|
|
62
|
+
if self.reraise:
|
|
63
|
+
raise
|
|
64
|
+
return
|
|
65
|
+
await asyncio.sleep(self.interval)
|
|
66
|
+
else:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
def execute(self, operation: Callable[[], SSHResult]) -> SSHResult:
|
|
70
|
+
"""Retry raised command errors and nonzero exit codes.
|
|
71
|
+
|
|
72
|
+
:param operation: Command attempt.
|
|
73
|
+
:return: Successful or final failed command result.
|
|
74
|
+
"""
|
|
75
|
+
result = SSHResult("", "Command execution failed after retries", -1)
|
|
76
|
+
for attempt in range(self.attempts):
|
|
77
|
+
try:
|
|
78
|
+
result = operation()
|
|
79
|
+
except (*CONNECTION_ERRORS, CommandNonZeroExitCodeError):
|
|
80
|
+
if attempt + 1 == self.attempts and self.reraise:
|
|
81
|
+
raise
|
|
82
|
+
else:
|
|
83
|
+
if result.exit_code == 0:
|
|
84
|
+
return result
|
|
85
|
+
if attempt + 1 < self.attempts:
|
|
86
|
+
time.sleep(self.interval)
|
|
87
|
+
return self._final_result(result)
|
|
88
|
+
|
|
89
|
+
async def execute_async(self, operation: Callable[[], Awaitable[SSHResult]]) -> SSHResult:
|
|
90
|
+
"""Retry asynchronous commands without blocking the event loop.
|
|
91
|
+
|
|
92
|
+
:param operation: Command attempt.
|
|
93
|
+
:return: Successful or final failed command result.
|
|
94
|
+
"""
|
|
95
|
+
result = SSHResult("", "Command execution failed after retries", -1)
|
|
96
|
+
for attempt in range(self.attempts):
|
|
97
|
+
try:
|
|
98
|
+
result = await operation()
|
|
99
|
+
except (*CONNECTION_ERRORS, CommandNonZeroExitCodeError):
|
|
100
|
+
if attempt + 1 == self.attempts and self.reraise:
|
|
101
|
+
raise
|
|
102
|
+
else:
|
|
103
|
+
if result.exit_code == 0:
|
|
104
|
+
return result
|
|
105
|
+
if attempt + 1 < self.attempts:
|
|
106
|
+
await asyncio.sleep(self.interval)
|
|
107
|
+
return self._final_result(result)
|
|
108
|
+
|
|
109
|
+
def _final_result(self, result: SSHResult) -> SSHResult:
|
|
110
|
+
if self.reraise:
|
|
111
|
+
raise CommandNonZeroExitCodeError.from_result(result)
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prepare_retry_error_context(error: Exception) -> tuple[str, str]:
|
|
116
|
+
"""Return the exception name and message.
|
|
117
|
+
|
|
118
|
+
:param error: Failed operation exception.
|
|
119
|
+
:return: Exception name and description.
|
|
120
|
+
"""
|
|
121
|
+
return type(error).__name__, str(error)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def retry_if_connection_fails(
|
|
125
|
+
retries_attempts_number: int = 3,
|
|
126
|
+
retries_interval_in_seconds: float = 5,
|
|
127
|
+
*,
|
|
128
|
+
reraise_exception: bool = False,
|
|
129
|
+
logger: Logger | None = None,
|
|
130
|
+
) -> Callable[[Callable[[], None]], Callable[[], None]]:
|
|
131
|
+
"""Create a synchronous connection retry decorator.
|
|
132
|
+
|
|
133
|
+
:param retries_attempts_number: Maximum attempts.
|
|
134
|
+
:param retries_interval_in_seconds: Delay between attempts.
|
|
135
|
+
:param reraise_exception: Propagate the final connection error.
|
|
136
|
+
:param logger: Optional logger receiving completion records.
|
|
137
|
+
:return: Connection decorator.
|
|
138
|
+
"""
|
|
139
|
+
policy = RetryPolicy(retries_attempts_number, retries_interval_in_seconds, reraise=reraise_exception)
|
|
140
|
+
|
|
141
|
+
def decorate(operation: Callable[[], None]) -> Callable[[], None]:
|
|
142
|
+
def execute() -> None:
|
|
143
|
+
policy.connect(operation)
|
|
144
|
+
if logger is not None:
|
|
145
|
+
logger.debug("SSH connection retry operation completed")
|
|
146
|
+
|
|
147
|
+
return execute
|
|
148
|
+
|
|
149
|
+
return decorate
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def retry_if_command_fails(
|
|
153
|
+
retries_attempts_number: int = 3,
|
|
154
|
+
retries_interval_in_seconds: float = 5,
|
|
155
|
+
*,
|
|
156
|
+
reraise_exception: bool = False,
|
|
157
|
+
logger: Logger | None = None,
|
|
158
|
+
) -> Callable[[Callable[[], SSHResult]], Callable[[], SSHResult]]:
|
|
159
|
+
"""Create a synchronous command retry decorator.
|
|
160
|
+
|
|
161
|
+
:param retries_attempts_number: Maximum attempts.
|
|
162
|
+
:param retries_interval_in_seconds: Delay between attempts.
|
|
163
|
+
:param reraise_exception: Propagate final command failure.
|
|
164
|
+
:param logger: Optional logger receiving completion records.
|
|
165
|
+
:return: Command decorator.
|
|
166
|
+
"""
|
|
167
|
+
policy = RetryPolicy(retries_attempts_number, retries_interval_in_seconds, reraise=reraise_exception)
|
|
168
|
+
|
|
169
|
+
def decorate(operation: Callable[[], SSHResult]) -> Callable[[], SSHResult]:
|
|
170
|
+
def execute() -> SSHResult:
|
|
171
|
+
result = policy.execute(operation)
|
|
172
|
+
if logger is not None:
|
|
173
|
+
logger.debug("SSH command retry operation completed with exit code %s", result.exit_code)
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
return execute
|
|
177
|
+
|
|
178
|
+
return decorate
|