exec-helpers 8.1.3__tar.gz → 8.2.1__tar.gz
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.
- exec_helpers-8.2.1/.pre-commit-config.yaml +34 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/PKG-INFO +11 -9
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/README.rst +5 -1
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/classifiers.txt +0 -2
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/__init__.py +2 -3
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_helpers.py +40 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_ssh_base.py +122 -30
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_ssh_helpers.py +97 -2
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_subprocess_helpers.py +3 -2
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_version.py +3 -3
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/api.py +37 -15
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/async_api/api.py +36 -13
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/async_api/exec_result.py +18 -6
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/async_api/subprocess.py +112 -34
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/exec_result.py +20 -20
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/proc_enums.py +1 -1
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/ssh_auth.py +42 -29
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/subprocess.py +83 -20
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/PKG-INFO +11 -9
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/SOURCES.txt +2 -3
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/requires.txt +3 -3
- exec_helpers-8.2.1/exec_helpers.egg-info/scm_file_list.json +77 -0
- exec_helpers-8.2.1/exec_helpers.egg-info/scm_version.json +8 -0
- exec_helpers-8.2.1/pyproject.toml +444 -0
- exec_helpers-8.1.3/.pre-commit-config.yaml +0 -44
- exec_helpers-8.1.3/flake8_requirements.txt +0 -14
- exec_helpers-8.1.3/mypy_requirements.txt +0 -1
- exec_helpers-8.1.3/pyproject.toml +0 -270
- exec_helpers-8.1.3/pytest_requirements.txt +0 -6
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/.editorconfig +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/.readthedocs.yaml +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/CI_REQUIREMENTS.txt +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/LICENSE +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/MANIFEST.in +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/build_requirements.txt +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/__init__.pyi +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_log_templates.py +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/async_api/__init__.py +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/async_api/__init__.pyi +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/constants.py +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/exceptions.py +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/py.typed +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/ssh.py +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/dependency_links.txt +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/not-zip-safe +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/top_level.txt +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/requirements.txt +0 -0
- {exec_helpers-8.1.3 → exec_helpers-8.2.1}/setup.cfg +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v6.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: check-yaml
|
|
6
|
+
- id: check-toml
|
|
7
|
+
- id: end-of-file-fixer
|
|
8
|
+
- id: trailing-whitespace
|
|
9
|
+
- id: mixed-line-ending
|
|
10
|
+
|
|
11
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
12
|
+
# Ruff version.
|
|
13
|
+
rev: v0.17.3
|
|
14
|
+
hooks:
|
|
15
|
+
# Run the linter.
|
|
16
|
+
- id: ruff-check
|
|
17
|
+
args: [ --fix ]
|
|
18
|
+
# Run the formatter.
|
|
19
|
+
- id: ruff-format
|
|
20
|
+
|
|
21
|
+
- repo: https://github.com/pre-commit/pygrep-hooks
|
|
22
|
+
rev: v1.10.0 # Use the ref you want to point at
|
|
23
|
+
hooks:
|
|
24
|
+
- id: python-check-blanket-noqa
|
|
25
|
+
- id: python-check-blanket-type-ignore
|
|
26
|
+
- id: rst-directive-colons
|
|
27
|
+
- id: rst-inline-touching-normal
|
|
28
|
+
|
|
29
|
+
- repo: https://github.com/tox-dev/pyproject-fmt
|
|
30
|
+
# Use the sha / tag you want to point at
|
|
31
|
+
# or use `pre-commit autoupdate` to get the latest version
|
|
32
|
+
rev: "v2.30.3"
|
|
33
|
+
hooks:
|
|
34
|
+
- id: pyproject-fmt
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: exec-helpers
|
|
3
|
-
Version: 8.1
|
|
3
|
+
Version: 8.2.1
|
|
4
4
|
Summary: Execution helpers for simplified usage of subprocess and ssh.
|
|
5
5
|
Author-email: Aleksei Stepanov <penguinolog@gmail.com>
|
|
6
6
|
Maintainer-email: Aleksei Stepanov <penguinolog@gmail.com>, Antonio Esposito <esposito.cloud@gmail.com>, Dennis Dmitriev <dis-xcom@gmail.com>
|
|
@@ -8,14 +8,12 @@ License-Expression: Apache-2.0
|
|
|
8
8
|
Project-URL: Documentation, https://exec-helpers.readthedocs.io/
|
|
9
9
|
Project-URL: Repository, https://github.com/python-useful-helpers/exec-helpers
|
|
10
10
|
Project-URL: Bug Tracker, https://github.com/python-useful-helpers/exec-helpers/issues
|
|
11
|
-
Keywords: subprocess
|
|
11
|
+
Keywords: ssh,subprocess
|
|
12
12
|
Classifier: Development Status :: 5 - Production/Stable
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
16
16
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
19
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -23,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
23
21
|
Classifier: Programming Language :: Python :: 3.15
|
|
24
22
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
25
23
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
26
|
-
Requires-Python: >=3.
|
|
24
|
+
Requires-Python: >=3.11.0
|
|
27
25
|
Description-Content-Type: text/x-rst
|
|
28
26
|
License-File: LICENSE
|
|
29
27
|
Requires-Dist: paramiko>=2.4
|
|
@@ -33,13 +31,13 @@ Provides-Extra: xml
|
|
|
33
31
|
Provides-Extra: lxml
|
|
34
32
|
Requires-Dist: lxml>=4.6.2; extra == "lxml"
|
|
35
33
|
Provides-Extra: yaml
|
|
36
|
-
Requires-Dist:
|
|
34
|
+
Requires-Dist: pyyaml>=3.12; extra == "yaml"
|
|
37
35
|
Provides-Extra: all-formats
|
|
38
36
|
Requires-Dist: lxml>=4.6.2; extra == "all-formats"
|
|
39
|
-
Requires-Dist:
|
|
37
|
+
Requires-Dist: pyyaml>=3.12; extra == "all-formats"
|
|
40
38
|
Provides-Extra: all-formats
|
|
41
39
|
Requires-Dist: lxml>=4.6.2; extra == "all-formats"
|
|
42
|
-
Requires-Dist:
|
|
40
|
+
Requires-Dist: pyyaml>=3.12; extra == "all-formats"
|
|
43
41
|
Dynamic: license-file
|
|
44
42
|
|
|
45
43
|
exec-helpers
|
|
@@ -447,7 +445,11 @@ Keyword arguments:
|
|
|
447
445
|
- cwd - working directory.
|
|
448
446
|
- env - environment variables dict.
|
|
449
447
|
|
|
450
|
-
.. note::
|
|
448
|
+
.. note:: The command is executed by the shell by default.
|
|
449
|
+
`Subprocess(shell=False)` (and `async_api.Subprocess(shell=False)`) executes it directly instead:
|
|
450
|
+
the command must be a list of arguments (a string raises `TypeError`),
|
|
451
|
+
and no shell syntax, expansion or `sh -c` inside a chroot is involved.
|
|
452
|
+
`SSHClient` can not do this: the remote side always passes the command to a shell.
|
|
451
453
|
|
|
452
454
|
async_api.Subprocess specific
|
|
453
455
|
-----------------------------
|
|
@@ -403,7 +403,11 @@ Keyword arguments:
|
|
|
403
403
|
- cwd - working directory.
|
|
404
404
|
- env - environment variables dict.
|
|
405
405
|
|
|
406
|
-
.. note::
|
|
406
|
+
.. note:: The command is executed by the shell by default.
|
|
407
|
+
`Subprocess(shell=False)` (and `async_api.Subprocess(shell=False)`) executes it directly instead:
|
|
408
|
+
the command must be a list of arguments (a string raises `TypeError`),
|
|
409
|
+
and no shell syntax, expansion or `sh -c` inside a chroot is involved.
|
|
410
|
+
`SSHClient` can not do this: the remote side always passes the command to a shell.
|
|
407
411
|
|
|
408
412
|
async_api.Subprocess specific
|
|
409
413
|
-----------------------------
|
|
@@ -3,8 +3,6 @@ Intended Audience :: Developers
|
|
|
3
3
|
Topic :: Software Development :: Libraries :: Python Modules
|
|
4
4
|
Programming Language :: Python :: 3
|
|
5
5
|
Programming Language :: Python :: 3 :: Only
|
|
6
|
-
Programming Language :: Python :: 3.9
|
|
7
|
-
Programming Language :: Python :: 3.10
|
|
8
6
|
Programming Language :: Python :: 3.11
|
|
9
7
|
Programming Language :: Python :: 3.12
|
|
10
8
|
Programming Language :: Python :: 3.13
|
|
@@ -16,14 +16,13 @@
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import contextlib
|
|
19
20
|
import importlib
|
|
20
21
|
import typing
|
|
21
22
|
import warnings
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
with contextlib.suppress(ImportError):
|
|
24
25
|
from ._version import version as __version__ # noqa: F401
|
|
25
|
-
except ImportError:
|
|
26
|
-
pass
|
|
27
26
|
|
|
28
27
|
if typing.TYPE_CHECKING:
|
|
29
28
|
from collections.abc import Sequence
|
|
@@ -9,6 +9,7 @@ import typing
|
|
|
9
9
|
|
|
10
10
|
if typing.TYPE_CHECKING:
|
|
11
11
|
from collections.abc import Iterable
|
|
12
|
+
from collections.abc import Mapping
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def string_bytes_bytearray_as_bytes(src: str | bytes | bytearray) -> bytes:
|
|
@@ -96,3 +97,42 @@ def chroot_command(command: str, chroot_path: str | None = None, chroot_exe: str
|
|
|
96
97
|
quoted_command = shlex.quote(command)
|
|
97
98
|
return f"{chroot_exe} {chroot_dst} sh -c {shlex.quote(f'eval {quoted_command}')}"
|
|
98
99
|
return command
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def chroot_argv_command(command: str, chroot_path: str | None = None, chroot_exe: str | None = None) -> str:
|
|
103
|
+
"""Prepare command for chroot execution without a shell, only the arguments are joined.
|
|
104
|
+
|
|
105
|
+
:param command: Original command, a result of `shlex.join`.
|
|
106
|
+
:type command: str
|
|
107
|
+
:param chroot_path: chroot path.
|
|
108
|
+
:type chroot_path: str | None
|
|
109
|
+
:param chroot_exe: chroot executable, may carry its own arguments.
|
|
110
|
+
:type chroot_exe: str | None
|
|
111
|
+
:return: Command to be executed with chroot rules if applicable.
|
|
112
|
+
:rtype: str
|
|
113
|
+
"""
|
|
114
|
+
if chroot_path and chroot_path != "/":
|
|
115
|
+
return shlex.join([*shlex.split(chroot_exe or "chroot"), chroot_path.strip(), *shlex.split(command)])
|
|
116
|
+
return command
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def repr_stdin(stdin: bytes | None) -> str:
|
|
120
|
+
"""Describe STDIN data for a debug string without exposing it.
|
|
121
|
+
|
|
122
|
+
:param stdin: Data for STDIN.
|
|
123
|
+
:type stdin: bytes | None
|
|
124
|
+
:return: Size of the data, as the content can hold secrets.
|
|
125
|
+
:rtype: str
|
|
126
|
+
"""
|
|
127
|
+
return "None" if stdin is None else f"<{len(stdin)} bytes>"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def repr_env(env: Mapping[str, str] | None) -> str:
|
|
131
|
+
"""Describe an environment for a debug string without exposing the values.
|
|
132
|
+
|
|
133
|
+
:param env: Process environment.
|
|
134
|
+
:type env: Mapping[str, str] | None
|
|
135
|
+
:return: Variable names only, as the values can hold secrets.
|
|
136
|
+
:rtype: str
|
|
137
|
+
"""
|
|
138
|
+
return "None" if env is None else f"<names: {sorted(env, key=repr)}>"
|
|
@@ -21,7 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
import concurrent.futures
|
|
22
22
|
import contextlib
|
|
23
23
|
import copy
|
|
24
|
-
import datetime
|
|
24
|
+
import datetime as dt
|
|
25
25
|
import getpass
|
|
26
26
|
import logging
|
|
27
27
|
import pathlib
|
|
@@ -50,8 +50,7 @@ if typing.TYPE_CHECKING:
|
|
|
50
50
|
from collections.abc import Iterable
|
|
51
51
|
from collections.abc import Sequence
|
|
52
52
|
from types import TracebackType
|
|
53
|
-
|
|
54
|
-
from typing_extensions import Self
|
|
53
|
+
from typing import Self
|
|
55
54
|
|
|
56
55
|
from exec_helpers.api import CalledProcessErrorSubClassT
|
|
57
56
|
from exec_helpers.api import CommandT
|
|
@@ -66,8 +65,8 @@ if typing.TYPE_CHECKING:
|
|
|
66
65
|
|
|
67
66
|
__all__ = ("SSHClientBase", "SshExecuteAsyncResult", "SupportPathT")
|
|
68
67
|
|
|
69
|
-
KeepAlivePeriodT =
|
|
70
|
-
SupportPathT =
|
|
68
|
+
KeepAlivePeriodT = int | bool
|
|
69
|
+
SupportPathT = str | pathlib.PurePath
|
|
71
70
|
|
|
72
71
|
|
|
73
72
|
class RetryOnExceptions(tenacity.retry_if_exception):
|
|
@@ -95,6 +94,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
|
|
|
95
94
|
__slots__ = ()
|
|
96
95
|
|
|
97
96
|
@property
|
|
97
|
+
# pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
|
|
98
98
|
def interface(self) -> paramiko.Channel:
|
|
99
99
|
"""Override original NamedTuple with proper typing.
|
|
100
100
|
|
|
@@ -104,6 +104,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
|
|
|
104
104
|
return super().interface # type: ignore[no-any-return]
|
|
105
105
|
|
|
106
106
|
@property
|
|
107
|
+
# pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
|
|
107
108
|
def stdin(self) -> paramiko.channel.ChannelStdinFile:
|
|
108
109
|
"""Override original NamedTuple with proper typing.
|
|
109
110
|
|
|
@@ -118,6 +119,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
|
|
|
118
119
|
return super().stdin # type: ignore[return-value]
|
|
119
120
|
|
|
120
121
|
@property
|
|
122
|
+
# pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
|
|
121
123
|
def stderr(self) -> paramiko.channel.ChannelStderrFile | None:
|
|
122
124
|
"""Override original NamedTuple with proper typing.
|
|
123
125
|
|
|
@@ -127,6 +129,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
|
|
|
127
129
|
return super().stderr
|
|
128
130
|
|
|
129
131
|
@property
|
|
132
|
+
# pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
|
|
130
133
|
def stdout(self) -> paramiko.channel.ChannelFile | None:
|
|
131
134
|
"""Override original NamedTuple with proper typing.
|
|
132
135
|
|
|
@@ -136,7 +139,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
|
|
|
136
139
|
return super().stdout
|
|
137
140
|
|
|
138
141
|
|
|
139
|
-
class _SSHExecuteContext(api.ExecuteContext
|
|
142
|
+
class _SSHExecuteContext(api.ExecuteContext):
|
|
140
143
|
"""SSH Execute context."""
|
|
141
144
|
|
|
142
145
|
__slots__ = (
|
|
@@ -223,8 +226,8 @@ class _SSHExecuteContext(api.ExecuteContext, contextlib.AbstractContextManager[S
|
|
|
223
226
|
"""
|
|
224
227
|
return (
|
|
225
228
|
f"<SSHClient().open_execute_context("
|
|
226
|
-
f"command={self.
|
|
227
|
-
f"stdin={self.stdin
|
|
229
|
+
f"command={self._masked_command!r}, "
|
|
230
|
+
f"stdin={_helpers.repr_stdin(self.stdin)}, "
|
|
228
231
|
f"open_stdout={self.open_stdout!r}, "
|
|
229
232
|
f"open_stderr={self.open_stderr!r}, "
|
|
230
233
|
f"get_pty={self.__get_pty!r}, "
|
|
@@ -309,7 +312,7 @@ class _SSHExecuteContext(api.ExecuteContext, contextlib.AbstractContextManager[S
|
|
|
309
312
|
stderr = None
|
|
310
313
|
_stdin: paramiko.channel.ChannelFile
|
|
311
314
|
with chan.makefile("wb") as _stdin:
|
|
312
|
-
started =
|
|
315
|
+
started = dt.datetime.now(tz=dt.UTC)
|
|
313
316
|
if self.__sudo_mode:
|
|
314
317
|
chan.exec_command(self.command) # nosec # Sanitize on caller side
|
|
315
318
|
if not stdout.channel.closed:
|
|
@@ -334,13 +337,8 @@ class _SSHExecuteContext(api.ExecuteContext, contextlib.AbstractContextManager[S
|
|
|
334
337
|
res_stdout = None
|
|
335
338
|
|
|
336
339
|
# noinspection PyArgumentList
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
stdin=_stdin,
|
|
340
|
-
stderr=stderr,
|
|
341
|
-
stdout=res_stdout,
|
|
342
|
-
started=started,
|
|
343
|
-
)
|
|
340
|
+
# The fields are typed by properties of the subclass, which type checkers do not accept as constructor arguments
|
|
341
|
+
return SshExecuteAsyncResult._make((chan, _stdin, stderr, res_stdout, started))
|
|
344
342
|
|
|
345
343
|
def __exit__(
|
|
346
344
|
self,
|
|
@@ -457,6 +455,15 @@ class SSHClientBase(api.ExecHelper):
|
|
|
457
455
|
:type keepalive: int | bool
|
|
458
456
|
:param allow_ssh_agent: Use SSH Agent if available.
|
|
459
457
|
:type allow_ssh_agent: bool
|
|
458
|
+
:param missing_host_key_policy: Missing host key policy class. See paramiko.MissingHostKeyPolicy for details.
|
|
459
|
+
:type missing_host_key_policy: type[paramiko.MissingHostKeyPolicy]
|
|
460
|
+
:param connect_timeout: Timeout in seconds for the TCP connection, the SSH banner, the key exchange and the
|
|
461
|
+
authentication of every connection step, including hosts reached through `proxy_to`.
|
|
462
|
+
An authentication timeout is reported as an authentication failure. By default: None
|
|
463
|
+
(paramiko defaults: no TCP and key exchange limit, banner 15 s, authentication 30 s). A connection attempt
|
|
464
|
+
is retried 3 times with a 3 s pause, so a hung host takes up to 3 times the timeout plus 6 s to report.
|
|
465
|
+
:type connect_timeout: float | None
|
|
466
|
+
:raises ValueError: connect_timeout is not positive.
|
|
460
467
|
|
|
461
468
|
.. note:: auth has priority over username/password/private_keys.
|
|
462
469
|
.. note::
|
|
@@ -475,12 +482,17 @@ class SSHClientBase(api.ExecHelper):
|
|
|
475
482
|
.. versionchanged:: 7.0.0 keepalive_mode is removed.
|
|
476
483
|
.. versionchanged:: 7.4.0 Return of keepalive_mode to prevent mix with a keepalive period. Default is `False`.
|
|
477
484
|
.. versionchanged:: 8.0.0 Expose SSH Agent usage override.
|
|
485
|
+
.. versionchanged:: 8.1.4 Allow specifying missing_host_key_policy.
|
|
486
|
+
.. versionchanged:: 8.2.1 Added connect_timeout.
|
|
487
|
+
.. versionchanged:: 8.2.1 Direct connection to the local host accepts the host keys of this machine without
|
|
488
|
+
the unknown host key warning; a key of another server is handled by the missing host key policy as before.
|
|
478
489
|
"""
|
|
479
490
|
|
|
480
491
|
__slots__ = (
|
|
481
492
|
"__allow_agent",
|
|
482
493
|
"__auth_mapping",
|
|
483
494
|
"__conn_chain",
|
|
495
|
+
"__connect_timeout",
|
|
484
496
|
"__hostname",
|
|
485
497
|
"__keepalive_mode",
|
|
486
498
|
"__keepalive_period",
|
|
@@ -491,6 +503,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
491
503
|
"__ssh_config",
|
|
492
504
|
"__sudo_mode",
|
|
493
505
|
"__verbose",
|
|
506
|
+
"_missing_host_key_policy",
|
|
494
507
|
)
|
|
495
508
|
|
|
496
509
|
def __hash__(self) -> int:
|
|
@@ -515,13 +528,19 @@ class SSHClientBase(api.ExecHelper):
|
|
|
515
528
|
sock: paramiko.ProxyCommand | paramiko.Channel | socket.socket | None = None,
|
|
516
529
|
keepalive: KeepAlivePeriodT = 1,
|
|
517
530
|
allow_ssh_agent: bool = True,
|
|
531
|
+
missing_host_key_policy: type[paramiko.MissingHostKeyPolicy] = paramiko.WarningPolicy,
|
|
532
|
+
connect_timeout: float | None = None,
|
|
518
533
|
) -> None:
|
|
519
|
-
"""
|
|
534
|
+
"""Initialize the SSH client helper."""
|
|
520
535
|
self.__sudo_mode = False
|
|
521
536
|
self.__keepalive_period: int = int(keepalive)
|
|
522
537
|
self.__keepalive_mode = False
|
|
523
538
|
self.__verbose: bool = verbose
|
|
524
539
|
self.__sock = sock
|
|
540
|
+
self._missing_host_key_policy = missing_host_key_policy
|
|
541
|
+
if connect_timeout is not None and connect_timeout <= 0:
|
|
542
|
+
raise ValueError(f"connect_timeout should be positive, got {connect_timeout!r}")
|
|
543
|
+
self.__connect_timeout = connect_timeout
|
|
525
544
|
|
|
526
545
|
self.__ssh: paramiko.SSHClient
|
|
527
546
|
self.__sftp: paramiko.SFTPClient | None = None
|
|
@@ -744,18 +763,35 @@ class SSHClientBase(api.ExecHelper):
|
|
|
744
763
|
"""
|
|
745
764
|
return self.__ssh
|
|
746
765
|
|
|
766
|
+
def __connect(self) -> None:
|
|
767
|
+
"""Open the connection, with retries unless a socket was supplied.
|
|
768
|
+
|
|
769
|
+
A failed attempt closes the supplied socket, so there is nothing to retry on:
|
|
770
|
+
the next attempt would only fail on the closed socket and hide the original error.
|
|
771
|
+
"""
|
|
772
|
+
with self.lock:
|
|
773
|
+
if self.__sock is None:
|
|
774
|
+
self.__connect_with_retries()
|
|
775
|
+
else:
|
|
776
|
+
self.__connect_once()
|
|
777
|
+
|
|
747
778
|
@tenacity.retry(
|
|
748
779
|
retry=RetryOnExceptions(retry_on=paramiko.SSHException, reraise=paramiko.AuthenticationException),
|
|
749
780
|
stop=tenacity.stop.stop_after_attempt(3),
|
|
750
781
|
wait=tenacity.wait.wait_fixed(3),
|
|
751
782
|
reraise=True,
|
|
752
783
|
)
|
|
753
|
-
def
|
|
754
|
-
"""
|
|
784
|
+
def __connect_with_retries(self) -> None:
|
|
785
|
+
"""Open the connection, retry on the connection errors."""
|
|
786
|
+
self.__connect_once()
|
|
787
|
+
|
|
788
|
+
def __connect_once(self) -> None:
|
|
789
|
+
"""Open the connection, one attempt."""
|
|
755
790
|
with self.lock:
|
|
756
791
|
if self.__sock is not None:
|
|
757
792
|
self.__ssh = paramiko.SSHClient()
|
|
758
|
-
self.__ssh.
|
|
793
|
+
self.__ssh.load_system_host_keys()
|
|
794
|
+
self.__ssh.set_missing_host_key_policy(self._missing_host_key_policy)
|
|
759
795
|
self.auth.connect(
|
|
760
796
|
client=self.__ssh,
|
|
761
797
|
hostname=self.hostname,
|
|
@@ -763,6 +799,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
763
799
|
log=self.__verbose,
|
|
764
800
|
sock=self.__sock,
|
|
765
801
|
allow_ssh_agent=self.allow_ssh_agent,
|
|
802
|
+
timeout=self.__connect_timeout,
|
|
766
803
|
)
|
|
767
804
|
else:
|
|
768
805
|
self.__ssh = self.__get_client()
|
|
@@ -770,6 +807,27 @@ class SSHClientBase(api.ExecHelper):
|
|
|
770
807
|
transport: paramiko.Transport = self._ssh_transport
|
|
771
808
|
transport.set_keepalive(1 if self.__keepalive_period else 0) # send keepalive packets
|
|
772
809
|
|
|
810
|
+
def __host_key_policy(self, hostname: str) -> paramiko.MissingHostKeyPolicy | type[paramiko.MissingHostKeyPolicy]:
|
|
811
|
+
"""Select the missing host key policy for a host which is connected to directly.
|
|
812
|
+
|
|
813
|
+
:param hostname: Host to connect to, without a proxy or a jump host.
|
|
814
|
+
:type hostname: str
|
|
815
|
+
:return: Configured policy; for the local host a policy, which accepts the keys of this machine first.
|
|
816
|
+
:rtype: paramiko.MissingHostKeyPolicy | type[paramiko.MissingHostKeyPolicy]
|
|
817
|
+
|
|
818
|
+
The local host is normally absent from `known_hosts`, so its own keys are the reference. A key of another
|
|
819
|
+
server (any local address can be a container, a tunnel or another server) goes to the configured policy as
|
|
820
|
+
before, so nothing which worked fails because of it. Without readable keys the configured policy decides.
|
|
821
|
+
The own key is accepted without asking the configured policy, so that policy does not see it.
|
|
822
|
+
"""
|
|
823
|
+
try:
|
|
824
|
+
keys = _ssh_helpers.local_host_keys() if _ssh_helpers.is_local_host(hostname) else ()
|
|
825
|
+
except OSError: # the answer only silences a warning, so a failure of the lookup must not fail the connection
|
|
826
|
+
keys = ()
|
|
827
|
+
if keys:
|
|
828
|
+
return _ssh_helpers.LocalHostKeyPolicy(keys, fallback=self._missing_host_key_policy)
|
|
829
|
+
return self._missing_host_key_policy
|
|
830
|
+
|
|
773
831
|
def __get_client(self) -> paramiko.SSHClient:
|
|
774
832
|
"""Connect using connection chain information.
|
|
775
833
|
|
|
@@ -779,11 +837,33 @@ class SSHClientBase(api.ExecHelper):
|
|
|
779
837
|
:raises RuntimeError: Unexpected state.
|
|
780
838
|
:raises ConnectionError: Cannot get SSH transport.
|
|
781
839
|
"""
|
|
840
|
+
opened: list[paramiko.SSHClient] = []
|
|
841
|
+
try:
|
|
842
|
+
return self.__connect_chain(opened)
|
|
843
|
+
except BaseException:
|
|
844
|
+
for client in reversed(opened): # paramiko leaves the transports of a failed connect running
|
|
845
|
+
client.close()
|
|
846
|
+
raise
|
|
847
|
+
|
|
848
|
+
def __connect_chain(self, opened: list[paramiko.SSHClient]) -> paramiko.SSHClient:
|
|
849
|
+
"""Connect the hosts of the connection chain one by one.
|
|
850
|
+
|
|
851
|
+
:param opened: Every client created here is added to the list at once, for the cleanup on failure.
|
|
852
|
+
:type opened: list[paramiko.SSHClient]
|
|
853
|
+
:return: Paramiko ssh connection object of the last host.
|
|
854
|
+
:rtype: paramiko.SSHClient
|
|
855
|
+
:raises ValueError: ProxyCommand found in a connection chain after the first host reached an.
|
|
856
|
+
:raises RuntimeError: Unexpected state.
|
|
857
|
+
:raises ConnectionError: Cannot get SSH transport.
|
|
858
|
+
"""
|
|
859
|
+
config, auth = self.__conn_chain[0]
|
|
782
860
|
|
|
783
861
|
last_ssh_client: paramiko.SSHClient = paramiko.SSHClient()
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
862
|
+
opened.append(last_ssh_client)
|
|
863
|
+
last_ssh_client.load_system_host_keys()
|
|
864
|
+
last_ssh_client.set_missing_host_key_policy(
|
|
865
|
+
self._missing_host_key_policy if config.proxycommand else self.__host_key_policy(config.hostname)
|
|
866
|
+
)
|
|
787
867
|
|
|
788
868
|
auth.connect(
|
|
789
869
|
last_ssh_client,
|
|
@@ -791,11 +871,14 @@ class SSHClientBase(api.ExecHelper):
|
|
|
791
871
|
port=config.port or 22,
|
|
792
872
|
sock=paramiko.ProxyCommand(config.proxycommand) if config.proxycommand else None,
|
|
793
873
|
allow_ssh_agent=self.allow_ssh_agent,
|
|
874
|
+
timeout=self.__connect_timeout,
|
|
794
875
|
)
|
|
795
876
|
|
|
796
877
|
for config, auth in self.__conn_chain[1:]: # start has another logic, so do it out of cycle
|
|
797
878
|
ssh = paramiko.SSHClient()
|
|
798
|
-
|
|
879
|
+
opened.append(ssh)
|
|
880
|
+
ssh.load_system_host_keys()
|
|
881
|
+
ssh.set_missing_host_key_policy(self._missing_host_key_policy)
|
|
799
882
|
|
|
800
883
|
if config.proxyjump:
|
|
801
884
|
transport = last_ssh_client.get_transport()
|
|
@@ -812,6 +895,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
812
895
|
port=config.port or 22,
|
|
813
896
|
sock=sock,
|
|
814
897
|
allow_ssh_agent=self.allow_ssh_agent,
|
|
898
|
+
timeout=self.__connect_timeout,
|
|
815
899
|
)
|
|
816
900
|
last_ssh_client = ssh
|
|
817
901
|
continue
|
|
@@ -823,10 +907,11 @@ class SSHClientBase(api.ExecHelper):
|
|
|
823
907
|
return last_ssh_client
|
|
824
908
|
|
|
825
909
|
def __connect_sftp(self) -> None:
|
|
826
|
-
"""SFTP
|
|
910
|
+
"""Open the SFTP channel, reconnecting the SSH session first if it was closed."""
|
|
827
911
|
with self.lock:
|
|
912
|
+
transport = self._ssh_transport # a failed reconnect is not "SFTP unavailable": let it propagate
|
|
828
913
|
try:
|
|
829
|
-
self.__sftp =
|
|
914
|
+
self.__sftp = transport.open_sftp_client()
|
|
830
915
|
except paramiko.SSHException:
|
|
831
916
|
self.logger.warning("SFTP enable failed! SSH only is accessible.")
|
|
832
917
|
|
|
@@ -836,6 +921,11 @@ class SSHClientBase(api.ExecHelper):
|
|
|
836
921
|
|
|
837
922
|
:rtype: paramiko.sftp_client.SFTPClient
|
|
838
923
|
:raises paramiko.SSHException: SFTP connection failed.
|
|
924
|
+
:raises ConnectionError: SSH session was closed and the reconnect failed.
|
|
925
|
+
:raises paramiko.SSHException: SSH session was closed and the reconnect failed (the original exception).
|
|
926
|
+
|
|
927
|
+
.. versionchanged:: 8.2.1 The SFTP channel is opened through the reconnecting transport getter,
|
|
928
|
+
so a closed SSH session is restored instead of failing with `AttributeError`.
|
|
839
929
|
"""
|
|
840
930
|
if self.__sftp is not None:
|
|
841
931
|
return self.__sftp
|
|
@@ -869,7 +959,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
869
959
|
"""
|
|
870
960
|
try:
|
|
871
961
|
self.__ssh.close()
|
|
872
|
-
except BaseException as e: # pragma: no cover # NOSONAR
|
|
962
|
+
except BaseException as e: # noqa: BLE001 # pragma: no cover # NOSONAR
|
|
873
963
|
with contextlib.suppress(AttributeError):
|
|
874
964
|
self.logger.debug(f"Exception in {self!s} destructor call: {e}")
|
|
875
965
|
self.__sftp = None
|
|
@@ -900,7 +990,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
900
990
|
|
|
901
991
|
@sudo_mode.setter
|
|
902
992
|
def sudo_mode(self, mode: bool) -> None:
|
|
903
|
-
"""
|
|
993
|
+
"""Change the persistent sudo mode of the connection object.
|
|
904
994
|
|
|
905
995
|
:param mode: Sudo status: enabled | disabled.
|
|
906
996
|
:type mode: bool
|
|
@@ -1053,7 +1143,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
1053
1143
|
result.read_stderr(src=async_result.stderr, log=self.logger if log_stderr else None, verbose=verbose)
|
|
1054
1144
|
|
|
1055
1145
|
def poll_pipes() -> None:
|
|
1056
|
-
"""
|
|
1146
|
+
"""Poll FIFO buffers."""
|
|
1057
1147
|
while not async_result.interface.status_event.is_set():
|
|
1058
1148
|
time.sleep(0.1)
|
|
1059
1149
|
if async_result.stdout or async_result.stderr:
|
|
@@ -1147,6 +1237,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
1147
1237
|
sudo_mode=self.sudo_mode,
|
|
1148
1238
|
auth=self.auth,
|
|
1149
1239
|
logger=self.logger,
|
|
1240
|
+
log_mask_re=self.log_mask_re,
|
|
1150
1241
|
**kwargs,
|
|
1151
1242
|
)
|
|
1152
1243
|
|
|
@@ -1556,6 +1647,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
1556
1647
|
sock=sock,
|
|
1557
1648
|
ssh_auth_map=ssh_auth_map if ssh_auth_map is not None else self.__auth_mapping,
|
|
1558
1649
|
keepalive=int(keepalive),
|
|
1650
|
+
connect_timeout=self.__connect_timeout,
|
|
1559
1651
|
)
|
|
1560
1652
|
|
|
1561
1653
|
def execute_through_host(
|
|
@@ -1779,7 +1871,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
1779
1871
|
results[remote.hostname, remote.port] = result
|
|
1780
1872
|
if result.exit_code not in prep_expected:
|
|
1781
1873
|
errors[remote.hostname, remote.port] = result
|
|
1782
|
-
except Exception as e: # noqa:
|
|
1874
|
+
except Exception as e: # noqa: BLE001 # stored and reported to the caller
|
|
1783
1875
|
raised_exceptions[remote.hostname, remote.port] = e
|
|
1784
1876
|
|
|
1785
1877
|
if raised_exceptions: # always raise
|