Skip to content

Commit aa56772

Browse files
committed
Support chroot with property, context and argument
Signed-off-by: Aleksei Stepanov <penguinolog@gmail.com>
1 parent 741df10 commit aa56772

File tree

8 files changed

+290
-64
lines changed

8 files changed

+290
-64
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:: 4.1.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:: 3.1.0 Not singleton anymore. Only lock is shared between all instances.
2224
.. versionchanged:: 3.2.0 Logger can be enforced.
25+
.. versionchanged:: 4.1.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:: 4.1.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
@@ -128,6 +128,8 @@ def __call__( # type: ignore
128128
private_keys: typing.Optional[typing.Iterable[paramiko.RSAKey]] = None,
129129
auth: typing.Optional[ssh_auth.SSHAuth] = None,
130130
verbose: bool = True,
131+
*,
132+
chroot_path: typing.Optional[str] = None,
131133
) -> "SSHClientBase":
132134
"""Main memorize method: check for cached instance and return it. API follows target __init__.
133135
@@ -145,10 +147,12 @@ def __call__( # type: ignore
145147
:type auth: typing.Optional[ssh_auth.SSHAuth]
146148
:param verbose: show additional error/warning messages
147149
:type verbose: bool
150+
:param chroot_path: chroot path (use chroot if set)
151+
:type chroot_path: typing.Optional[str]
148152
:return: SSH client instance
149153
:rtype: SSHClientBase
150154
"""
151-
if (host, port) in cls.__cache:
155+
if (host, port) in cls.__cache and not chroot_path: # chrooted connections are not memorized
152156
key = host, port
153157
if auth is None:
154158
auth = ssh_auth.SSHAuth(username=username, password=password, keys=private_keys)
@@ -176,8 +180,10 @@ def __call__( # type: ignore
176180
private_keys=private_keys,
177181
auth=auth,
178182
verbose=verbose,
183+
chroot_path=chroot_path,
179184
)
180-
cls.__cache[(ssh.hostname, ssh.port)] = ssh
185+
if not chroot_path:
186+
cls.__cache[(ssh.hostname, ssh.port)] = ssh
181187
return ssh
182188

183189
@classmethod
@@ -188,7 +194,6 @@ def clear_cache(mcs: typing.Type["_MemorizedSSH"]) -> None:
188194
"""
189195
n_count = 3
190196
# PY3: cache, ssh, temporary
191-
# PY4: cache, values mapping, ssh, temporary
192197
for ssh in mcs.__cache.values():
193198
if CPYTHON and sys.getrefcount(ssh) == n_count: # pragma: no cover
194199
ssh.logger.debug("Closing as unused")
@@ -203,63 +208,66 @@ def close_connections(mcs: typing.Type["_MemorizedSSH"]) -> None:
203208
ssh.close() # type: ignore
204209

205210

206-
class SSHClientBase(api.ExecHelper, metaclass=_MemorizedSSH):
207-
"""SSH Client helper."""
211+
class _SudoContext:
212+
"""Context manager for call commands with sudo."""
208213

209-
__slots__ = ("__hostname", "__port", "__auth", "__ssh", "__sftp", "__sudo_mode", "__keepalive_mode", "__verbose")
214+
__slots__ = ("__ssh", "__sudo_status", "__enforce")
210215

211-
class __get_sudo:
212-
"""Context manager for call commands with sudo."""
216+
def __init__(self, ssh: "SSHClientBase", enforce: typing.Optional[bool] = None) -> None:
217+
"""Context manager for call commands with sudo.
213218
214-
__slots__ = ("__ssh", "__sudo_status", "__enforce")
219+
:param ssh: connection instance
220+
:type ssh: SSHClientBase
221+
:param enforce: sudo mode for context manager
222+
:type enforce: typing.Optional[bool]
223+
"""
224+
self.__ssh: "SSHClientBase" = ssh
225+
self.__sudo_status: bool = ssh.sudo_mode
226+
self.__enforce: typing.Optional[bool] = enforce
215227

216-
def __init__(self, ssh: "SSHClientBase", enforce: typing.Optional[bool] = None) -> None:
217-
"""Context manager for call commands with sudo.
228+
def __enter__(self) -> None:
229+
self.__sudo_status = self.__ssh.sudo_mode
230+
if self.__enforce is not None:
231+
self.__ssh.sudo_mode = self.__enforce
218232

219-
:param ssh: connection instance
220-
:type ssh: SSHClientBase
221-
:param enforce: sudo mode for context manager
222-
:type enforce: typing.Optional[bool]
223-
"""
224-
self.__ssh: "SSHClientBase" = ssh
225-
self.__sudo_status: bool = ssh.sudo_mode
226-
self.__enforce: typing.Optional[bool] = enforce
233+
def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None:
234+
self.__ssh.sudo_mode = self.__sudo_status
227235

228-
def __enter__(self) -> None:
229-
self.__sudo_status = self.__ssh.sudo_mode
230-
if self.__enforce is not None:
231-
self.__ssh.sudo_mode = self.__enforce
232236

233-
def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None:
234-
self.__ssh.sudo_mode = self.__sudo_status
237+
class _KeepAliveContext:
238+
"""Context manager for keepalive management."""
235239

236-
class __get_keepalive:
237-
"""Context manager for keepalive management."""
240+
__slots__ = ("__ssh", "__keepalive_status", "__enforce")
238241

239-
__slots__ = ("__ssh", "__keepalive_status", "__enforce")
242+
def __init__(self, ssh: "SSHClientBase", enforce: bool = True) -> None:
243+
"""Context manager for keepalive management.
240244
241-
def __init__(self, ssh: "SSHClientBase", enforce: bool = True) -> None:
242-
"""Context manager for keepalive management.
245+
:param ssh: connection instance
246+
:type ssh: SSHClientBase
247+
:param enforce: keepalive mode for context manager
248+
:type enforce: bool
249+
:param enforce: Keep connection alive after context manager exit
250+
"""
251+
self.__ssh: "SSHClientBase" = ssh
252+
self.__keepalive_status: bool = ssh.keepalive_mode
253+
self.__enforce: typing.Optional[bool] = enforce
243254

244-
:param ssh: connection instance
245-
:type ssh: SSHClientBase
246-
:param enforce: keepalive mode for context manager
247-
:type enforce: bool
248-
:param enforce: Keep connection alive after context manager exit
249-
"""
250-
self.__ssh: "SSHClientBase" = ssh
251-
self.__keepalive_status: bool = ssh.keepalive_mode
252-
self.__enforce: typing.Optional[bool] = enforce
255+
def __enter__(self) -> None:
256+
self.__ssh.__enter__()
257+
self.__keepalive_status = self.__ssh.keepalive_mode
258+
if self.__enforce is not None:
259+
self.__ssh.keepalive_mode = self.__enforce
253260

254-
def __enter__(self) -> None:
255-
self.__keepalive_status = self.__ssh.keepalive_mode
256-
if self.__enforce is not None:
257-
self.__ssh.keepalive_mode = self.__enforce
258-
self.__ssh.__enter__()
261+
def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None:
262+
# Exit before releasing!
263+
self.__ssh.__exit__(exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) # type: ignore
264+
self.__ssh.keepalive_mode = self.__keepalive_status
265+
266+
267+
class SSHClientBase(api.ExecHelper, metaclass=_MemorizedSSH):
268+
"""SSH Client helper."""
259269

260-
def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None:
261-
self.__ssh.__exit__(exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) # type: ignore
262-
self.__ssh.keepalive_mode = self.__keepalive_status
270+
__slots__ = ("__hostname", "__port", "__auth", "__ssh", "__sftp", "__sudo_mode", "__keepalive_mode", "__verbose")
263271

264272
def __hash__(self) -> int:
265273
"""Hash for usage as dict keys."""
@@ -274,6 +282,8 @@ def __init__(
274282
private_keys: typing.Optional[typing.Iterable[paramiko.RSAKey]] = None,
275283
auth: typing.Optional[ssh_auth.SSHAuth] = None,
276284
verbose: bool = True,
285+
*,
286+
chroot_path: typing.Optional[str] = None,
277287
) -> None:
278288
"""Main SSH Client helper.
279289
@@ -291,11 +301,13 @@ def __init__(
291301
:type auth: typing.Optional[ssh_auth.SSHAuth]
292302
:param verbose: show additional error/warning messages
293303
:type verbose: bool
304+
:param chroot_path: chroot path (use chroot if set)
305+
:type chroot_path: typing.Optional[str]
294306
295307
.. note:: auth has priority over username/password/private_keys
296308
"""
297309
super(SSHClientBase, self).__init__(
298-
logger=logging.getLogger(self.__class__.__name__).getChild(f"{host}:{port}")
310+
logger=logging.getLogger(self.__class__.__name__).getChild(f"{host}:{port}"), chroot_path=chroot_path
299311
)
300312

301313
self.__hostname: str = host
@@ -510,7 +522,7 @@ def sudo(self, enforce: typing.Optional[bool] = None) -> "typing.ContextManager[
510522
:return: context manager with selected sudo state inside
511523
:rtype: typing.ContextManager
512524
"""
513-
return self.__get_sudo(ssh=self, enforce=enforce)
525+
return _SudoContext(ssh=self, enforce=enforce)
514526

515527
def keepalive(self, enforce: bool = True) -> "typing.ContextManager[None]":
516528
"""Call contextmanager with keepalive mode change.
@@ -523,7 +535,7 @@ def keepalive(self, enforce: bool = True) -> "typing.ContextManager[None]":
523535
.. Note:: Enter and exit ssh context manager is produced as well.
524536
.. versionadded:: 1.2.1
525537
"""
526-
return self.__get_keepalive(ssh=self, enforce=enforce)
538+
return _KeepAliveContext(ssh=self, enforce=enforce)
527539

528540
# noinspection PyMethodOverriding
529541
def execute_async( # pylint: disable=arguments-differ
@@ -535,6 +547,7 @@ def execute_async( # pylint: disable=arguments-differ
535547
verbose: bool = False,
536548
log_mask_re: typing.Optional[str] = None,
537549
*,
550+
chroot_path: typing.Optional[str] = None,
538551
get_pty: bool = False,
539552
width: int = 80,
540553
height: int = 24,
@@ -555,6 +568,8 @@ def execute_async( # pylint: disable=arguments-differ
555568
:param log_mask_re: regex lookup rule to mask command for logger.
556569
all MATCHED groups will be replaced by '<*masked*>'
557570
:type log_mask_re: typing.Optional[str]
571+
:param chroot_path: chroot path override
572+
:type chroot_path: typing.Optional[str]
558573
:param get_pty: Get PTY for connection
559574
:type get_pty: bool
560575
:param width: PTY width
@@ -580,6 +595,7 @@ def execute_async( # pylint: disable=arguments-differ
580595
.. versionchanged:: 1.2.0 get_pty moved to `**kwargs`
581596
.. versionchanged:: 2.1.0 Use typed NamedTuple as result
582597
.. versionchanged:: 3.2.0 Expose pty options as optional keyword-only arguments
598+
.. versionchanged:: 4.1.0 support chroot
583599
"""
584600
cmd_for_log: str = self._mask_command(cmd=command, log_mask_re=log_mask_re)
585601

@@ -597,7 +613,8 @@ def execute_async( # pylint: disable=arguments-differ
597613
stdout: paramiko.ChannelFile = chan.makefile("rb")
598614
stderr: typing.Optional[paramiko.ChannelFile] = chan.makefile_stderr("rb") if open_stderr else None
599615

600-
cmd = f"{command}\n"
616+
cmd = f"{self._prepare_command(cmd=command, chroot_path=chroot_path)}\n"
617+
601618
started = datetime.datetime.utcnow()
602619
if self.sudo_mode:
603620
encoded_cmd = base64.b64encode(cmd.encode("utf-8")).decode("utf-8")

0 commit comments

Comments
 (0)