Skip to content

Commit e4f0b22

Browse files
committed
Support chroot with property, context and argument
Signed-off-by: Aleksei Stepanov <penguinolog@gmail.com> (cherry picked from commit aa56772) Signed-off-by: Aleksei Stepanov <penguinolog@gmail.com>
1 parent 9e4711c commit e4f0b22

File tree

6 files changed

+275
-61
lines changed

6 files changed

+275
-61
lines changed

doc/source/SSHClient.rst

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ API: SSHClient and SSHAuth.
1010
1111
SSHClient helper.
1212

13-
.. py:method:: __init__(host, port=22, username=None, password=None, private_keys=None, auth=None, )
13+
.. py:method:: __init__(host, port=22, username=None, password=None, private_keys=None, auth=None, *, chroot_path=None)
1414
1515
:param host: remote hostname
1616
:type host: ``str``
@@ -26,6 +26,8 @@ API: SSHClient and SSHAuth.
2626
:type auth: typing.Optional[SSHAuth]
2727
:param verbose: show additional error/warning messages
2828
:type verbose: bool
29+
:param chroot_path: chroot path (use chroot if set)
30+
:type chroot_path: typing.Optional[str]
2931

3032
.. note:: auth has priority over username/password/private_keys
3133

@@ -66,6 +68,11 @@ API: SSHClient and SSHAuth.
6668
``bool``
6769
Paramiko status: ready to use|reconnect required
6870

71+
.. py:attribute:: chroot_path
72+
73+
``typing.Optional[str]``
74+
Path for chroot if set.
75+
6976
.. py:attribute:: sudo_mode
7077
7178
``bool``
@@ -102,6 +109,18 @@ API: SSHClient and SSHAuth.
102109
.. versionchanged:: 1.1.0 release lock on exit
103110
.. versionchanged:: 1.2.1 disconnect enforced on close only not in keepalive mode
104111

112+
.. py:method:: chroot(path)
113+
114+
Context manager for changing chroot rules.
115+
116+
:param path: chroot path or none for working without chroot.
117+
:type path: typing.Optional[str]
118+
:return: context manager with selected chroot state inside
119+
:rtype: typing.ContextManager
120+
121+
.. Note:: Enter and exit main context manager is produced as well.
122+
.. versionadded:: 2.12.0
123+
105124
.. py:method:: sudo(enforce=None)
106125
107126
Context manager getter for sudo operation
@@ -121,7 +140,7 @@ API: SSHClient and SSHAuth.
121140
.. Note:: Enter and exit ssh context manager is produced as well.
122141
.. versionadded:: 1.2.1
123142

124-
.. py:method:: execute_async(command, stdin=None, open_stdout=True, open_stderr=True, verbose=False, log_mask_re=None, *, get_pty=False, width=80, height=24, **kwargs)
143+
.. py:method:: execute_async(command, stdin=None, open_stdout=True, open_stderr=True, verbose=False, log_mask_re=None, *, chroot_path=None, get_pty=False, width=80, height=24, **kwargs)
125144
126145
Execute command in async mode and return channel with IO objects.
127146

@@ -137,7 +156,9 @@ API: SSHClient and SSHAuth.
137156
:type verbose: bool
138157
:param log_mask_re: regex lookup rule to mask command for logger.
139158
all MATCHED groups will be replaced by '<*masked*>'
140-
:type log_mask_re: typing.Optional[str]
159+
:type log_mask_re: ``typing.Optional[str]``
160+
:param chroot_path: chroot path override
161+
:type chroot_path: ``typing.Optional[str]``
141162
:param get_pty: Get PTY for connection
142163
:type get_pty: bool
143164
:param width: PTY width

doc/source/Subprocess.rst

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@ API: Subprocess
88
99
.. py:class:: Subprocess()
1010
11-
.. py:method:: __init__(logger, log_mask_re=None, *, logger=logging.getLogger("exec_helpers.subprocess_runner"))
11+
.. py:method:: __init__(logger, log_mask_re=None, *, logger=logging.getLogger("exec_helpers.subprocess_runner"), chroot_path=None)
1212
1313
ExecHelper global API.
1414

1515
:param log_mask_re: regex lookup rule to mask command for logger. all MATCHED groups will be replaced by '<*masked*>'
1616
:type log_mask_re: typing.Optional[str]
1717
:param logger: logger instance to use
1818
:type logger: logging.Logger
19+
:param chroot_path: chroot path (use chroot if set)
20+
:type chroot_path: typing.Optional[str]
1921

2022
.. versionchanged:: 1.2.0 log_mask_re regex rule for masking cmd
2123
.. versionchanged:: 2.9.0 Not singleton anymore. Only lock is shared between all instances.
2224
.. versionchanged:: 2.9.3 Logger can be enforced.
25+
.. versionchanged:: 2.12.0 support chroot
2326

2427
.. py:attribute:: log_mask_re
2528
@@ -43,7 +46,24 @@ API: Subprocess
4346

4447
.. versionchanged:: 1.1.0 release lock on exit
4548

46-
.. py:method:: execute_async(command, stdin=None, open_stdout=True, open_stderr=True, verbose=False, log_mask_re=None, *, cwd=None, env=None, **kwargs)
49+
.. py:attribute:: chroot_path
50+
51+
``typing.Optional[str]``
52+
Path for chroot if set.
53+
54+
.. py:method:: chroot(path)
55+
56+
Context manager for changing chroot rules.
57+
58+
:param path: chroot path or none for working without chroot.
59+
:type path: typing.Optional[str]
60+
:return: context manager with selected chroot state inside
61+
:rtype: typing.ContextManager
62+
63+
.. Note:: Enter and exit main context manager is produced as well.
64+
.. versionadded:: 2.12.0
65+
66+
.. py:method:: execute_async(command, stdin=None, open_stdout=True, open_stderr=True, verbose=False, log_mask_re=None, *, chroot_path=None, cwd=None, env=None, **kwargs)
4767
4868
Execute command in async mode and return Popen with IO objects.
4969

@@ -60,6 +80,8 @@ API: Subprocess
6080
:param log_mask_re: regex lookup rule to mask command for logger.
6181
all MATCHED groups will be replaced by '<*masked*>'
6282
:type log_mask_re: ``typing.Optional[str]``
83+
:param chroot_path: chroot path override
84+
:type chroot_path: ``typing.Optional[str]``
6385
:param cwd: Sets the current directory before the child is executed.
6486
:type cwd: typing.Optional[typing.Union[str, bytes]]
6587
:param env: Defines the environment variables for the new process.

exec_helpers/_ssh_client_base.py

Lines changed: 68 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ def __call__( # type: ignore
139139
private_keys: typing.Optional[typing.Iterable[paramiko.RSAKey]] = None,
140140
auth: typing.Optional[ssh_auth.SSHAuth] = None,
141141
verbose: bool = True,
142+
*,
143+
chroot_path: typing.Optional[str] = None
142144
) -> "SSHClientBase":
143145
"""Main memorize method: check for cached instance and return it. API follows target __init__.
144146
@@ -156,10 +158,12 @@ def __call__( # type: ignore
156158
:type auth: typing.Optional[ssh_auth.SSHAuth]
157159
:param verbose: show additional error/warning messages
158160
:type verbose: bool
161+
:param chroot_path: chroot path (use chroot if set)
162+
:type chroot_path: typing.Optional[str]
159163
:return: SSH client instance
160164
:rtype: SSHClientBase
161165
"""
162-
if (host, port) in cls.__cache:
166+
if (host, port) in cls.__cache and not chroot_path: # chrooted connections are not memorized
163167
key = host, port
164168
if auth is None:
165169
auth = ssh_auth.SSHAuth(username=username, password=password, keys=private_keys)
@@ -187,8 +191,10 @@ def __call__( # type: ignore
187191
private_keys=private_keys,
188192
auth=auth,
189193
verbose=verbose,
194+
chroot_path=chroot_path,
190195
)
191-
cls.__cache[(ssh.hostname, ssh.port)] = ssh
196+
if not chroot_path:
197+
cls.__cache[(ssh.hostname, ssh.port)] = ssh
192198
return ssh
193199

194200
@classmethod
@@ -199,7 +205,6 @@ def clear_cache(mcs: typing.Type["_MemorizedSSH"]) -> None:
199205
"""
200206
n_count = 3
201207
# PY3: cache, ssh, temporary
202-
# PY4: cache, values mapping, ssh, temporary
203208
for ssh in mcs.__cache.values():
204209
if CPYTHON and sys.getrefcount(ssh) == n_count: # pragma: no cover
205210
ssh.logger.debug("Closing as unused")
@@ -214,63 +219,66 @@ def close_connections(mcs: typing.Type["_MemorizedSSH"]) -> None:
214219
ssh.close() # type: ignore
215220

216221

217-
class SSHClientBase(api.ExecHelper, metaclass=_MemorizedSSH):
218-
"""SSH Client helper."""
222+
class _SudoContext:
223+
"""Context manager for call commands with sudo."""
219224

220-
__slots__ = ("__hostname", "__port", "__auth", "__ssh", "__sftp", "__sudo_mode", "__keepalive_mode", "__verbose")
225+
__slots__ = ("__ssh", "__sudo_status", "__enforce")
226+
227+
def __init__(self, ssh: "SSHClientBase", enforce: typing.Optional[bool] = None) -> None:
228+
"""Context manager for call commands with sudo.
229+
230+
:param ssh: connection instance
231+
:type ssh: SSHClientBase
232+
:param enforce: sudo mode for context manager
233+
:type enforce: typing.Optional[bool]
234+
"""
235+
self.__ssh = ssh # type: SSHClientBase
236+
self.__sudo_status = ssh.sudo_mode # type: bool
237+
self.__enforce = enforce # type: typing.Optional[bool]
221238

222-
class __get_sudo:
223-
"""Context manager for call commands with sudo."""
239+
def __enter__(self) -> None:
240+
self.__sudo_status = self.__ssh.sudo_mode
241+
if self.__enforce is not None:
242+
self.__ssh.sudo_mode = self.__enforce
224243

225-
__slots__ = ("__ssh", "__sudo_status", "__enforce")
244+
def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None:
245+
self.__ssh.sudo_mode = self.__sudo_status
226246

227-
def __init__(self, ssh: "SSHClientBase", enforce: typing.Optional[bool] = None) -> None:
228-
"""Context manager for call commands with sudo.
229247

230-
:param ssh: connection instance
231-
:type ssh: SSHClientBase
232-
:param enforce: sudo mode for context manager
233-
:type enforce: typing.Optional[bool]
234-
"""
235-
self.__ssh = ssh
236-
self.__sudo_status = ssh.sudo_mode
237-
self.__enforce = enforce
248+
class _KeepAliveContext:
249+
"""Context manager for keepalive management."""
238250

239-
def __enter__(self) -> None:
240-
self.__sudo_status = self.__ssh.sudo_mode
241-
if self.__enforce is not None:
242-
self.__ssh.sudo_mode = self.__enforce
251+
__slots__ = ("__ssh", "__keepalive_status", "__enforce")
243252

244-
def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None:
245-
self.__ssh.sudo_mode = self.__sudo_status
253+
def __init__(self, ssh: "SSHClientBase", enforce: bool = True) -> None:
254+
"""Context manager for keepalive management.
246255
247-
class __get_keepalive:
248-
"""Context manager for keepalive management."""
256+
:param ssh: connection instance
257+
:type ssh: SSHClientBase
258+
:param enforce: keepalive mode for context manager
259+
:type enforce: bool
260+
:param enforce: Keep connection alive after context manager exit
261+
"""
262+
self.__ssh = ssh # type: SSHClientBase
263+
self.__keepalive_status = ssh.keepalive_mode # type: bool
264+
self.__enforce = enforce # type: typing.Optional[bool]
249265

250-
__slots__ = ("__ssh", "__keepalive_status", "__enforce")
266+
def __enter__(self) -> None:
267+
self.__ssh.__enter__()
268+
self.__keepalive_status = self.__ssh.keepalive_mode
269+
if self.__enforce is not None:
270+
self.__ssh.keepalive_mode = self.__enforce
251271

252-
def __init__(self, ssh: "SSHClientBase", enforce: bool = True) -> None:
253-
"""Context manager for keepalive management.
272+
def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None:
273+
# Exit before releasing!
274+
self.__ssh.__exit__(exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) # type: ignore
275+
self.__ssh.keepalive_mode = self.__keepalive_status
254276

255-
:param ssh: connection instance
256-
:type ssh: SSHClientBase
257-
:param enforce: keepalive mode for context manager
258-
:type enforce: bool
259-
:param enforce: Keep connection alive after context manager exit
260-
"""
261-
self.__ssh = ssh
262-
self.__keepalive_status = ssh.keepalive_mode
263-
self.__enforce = enforce
264277

265-
def __enter__(self) -> None:
266-
self.__keepalive_status = self.__ssh.keepalive_mode
267-
if self.__enforce is not None:
268-
self.__ssh.keepalive_mode = self.__enforce
269-
self.__ssh.__enter__()
278+
class SSHClientBase(api.ExecHelper, metaclass=_MemorizedSSH):
279+
"""SSH Client helper."""
270280

271-
def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None:
272-
self.__ssh.__exit__(exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) # type: ignore
273-
self.__ssh.keepalive_mode = self.__keepalive_status
281+
__slots__ = ("__hostname", "__port", "__auth", "__ssh", "__sftp", "__sudo_mode", "__keepalive_mode", "__verbose")
274282

275283
def __hash__(self) -> int:
276284
"""Hash for usage as dict keys."""
@@ -285,6 +293,8 @@ def __init__(
285293
private_keys: typing.Optional[typing.Iterable[paramiko.RSAKey]] = None,
286294
auth: typing.Optional[ssh_auth.SSHAuth] = None,
287295
verbose: bool = True,
296+
*,
297+
chroot_path: typing.Optional[str] = None
288298
) -> None:
289299
"""Main SSH Client helper.
290300
@@ -302,11 +312,14 @@ def __init__(
302312
:type auth: typing.Optional[ssh_auth.SSHAuth]
303313
:param verbose: show additional error/warning messages
304314
:type verbose: bool
315+
:param chroot_path: chroot path (use chroot if set)
316+
:type chroot_path: typing.Optional[str]
305317
306318
.. note:: auth has priority over username/password/private_keys
307319
"""
308320
super(SSHClientBase, self).__init__(
309-
logger=logging.getLogger(self.__class__.__name__).getChild("{host}:{port}".format(host=host, port=port))
321+
logger=logging.getLogger(self.__class__.__name__).getChild("{host}:{port}".format(host=host, port=port)),
322+
chroot_path=chroot_path
310323
)
311324

312325
self.__hostname = host
@@ -525,7 +538,7 @@ def sudo(self, enforce: typing.Optional[bool] = None) -> "typing.ContextManager[
525538
:return: context manager with selected sudo state inside
526539
:rtype: typing.ContextManager
527540
"""
528-
return self.__get_sudo(ssh=self, enforce=enforce)
541+
return _SudoContext(ssh=self, enforce=enforce)
529542

530543
def keepalive(self, enforce: bool = True) -> "typing.ContextManager[None]":
531544
"""Call contextmanager with keepalive mode change.
@@ -538,7 +551,7 @@ def keepalive(self, enforce: bool = True) -> "typing.ContextManager[None]":
538551
.. Note:: Enter and exit ssh context manager is produced as well.
539552
.. versionadded:: 1.2.1
540553
"""
541-
return self.__get_keepalive(ssh=self, enforce=enforce)
554+
return _KeepAliveContext(ssh=self, enforce=enforce)
542555

543556
# noinspection PyMethodOverriding
544557
def execute_async( # pylint: disable=arguments-differ
@@ -550,6 +563,7 @@ def execute_async( # pylint: disable=arguments-differ
550563
verbose: bool = False,
551564
log_mask_re: typing.Optional[str] = None,
552565
*,
566+
chroot_path: typing.Optional[str] = None,
553567
get_pty: bool = False,
554568
width: int = 80,
555569
height: int = 24,
@@ -570,6 +584,8 @@ def execute_async( # pylint: disable=arguments-differ
570584
:param log_mask_re: regex lookup rule to mask command for logger.
571585
all MATCHED groups will be replaced by '<*masked*>'
572586
:type log_mask_re: typing.Optional[str]
587+
:param chroot_path: chroot path override
588+
:type chroot_path: typing.Optional[str]
573589
:param get_pty: Get PTY for connection
574590
:type get_pty: bool
575591
:param width: PTY width
@@ -595,6 +611,7 @@ def execute_async( # pylint: disable=arguments-differ
595611
.. versionchanged:: 1.2.0 get_pty moved to `**kwargs`
596612
.. versionchanged:: 2.1.0 Use typed NamedTuple as result
597613
.. versionchanged:: 2.9.3 Expose pty options as optional keyword-only arguments
614+
.. versionchanged:: 2.12.0 support chroot
598615
"""
599616
cmd_for_log = self._mask_command(cmd=command, log_mask_re=log_mask_re)
600617

@@ -612,7 +629,7 @@ def execute_async( # pylint: disable=arguments-differ
612629
stdout = chan.makefile("rb") # type: paramiko.ChannelFile
613630
stderr = chan.makefile_stderr("rb") if open_stderr else None
614631

615-
cmd = "{command}\n".format(command=command)
632+
cmd = "{cmd}\n".format(cmd=self._prepare_command(cmd=command, chroot_path=chroot_path))
616633
started = datetime.datetime.utcnow()
617634
if self.sudo_mode:
618635
encoded_cmd = base64.b64encode(cmd.encode("utf-8")).decode("utf-8")

0 commit comments

Comments
 (0)