Skip to content

Commit 2a2d9cf

Browse files
author
Dan
committed
Added tests for run_command with stop_on_errors=False and tests that all possible exceptions are returned in output with correct arguments. Updated documentation post refactor and for new or changed functionality in preparation for 0.80 release
1 parent 0db3443 commit 2a2d9cf

File tree

6 files changed

+263
-77
lines changed

6 files changed

+263
-77
lines changed

doc/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Parallel-SSH's documentation
1919

2020
Welcome to ParallelSSH's API documentation.
2121

22-
New users should start with :mod:`pssh.pssh_client.ParallelSSHClient.run_command`.
22+
New users should start with :mod:`pssh.pssh_client.ParallelSSHClient` and in particular :mod:`pssh.pssh_client.ParallelSSHClient.run_command`.
2323

2424

2525
Indices and tables

pssh/exceptions.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This file is part of parallel-ssh.
22

3-
# Copyright (C) 2015 Panos Kittenis
3+
# Copyright (C) 2015- Panos Kittenis
44

55
# This library is free software; you can redistribute it and/or
66
# modify it under the terms of the GNU Lesser General Public
@@ -16,7 +16,8 @@
1616
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
1717

1818

19-
"""Exceptions raised by parallel-ssh classes"""
19+
"""Exceptions raised by parallel-ssh classes."""
20+
2021

2122
class UnknownHostException(Exception):
2223
"""Raised when a host is unknown (dns failure)"""

pssh/pssh_client.py

Lines changed: 120 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This file is part of parallel-ssh.
22

3-
# Copyright (C) 2015 Panos Kittenis
3+
# Copyright (C) 2015- Panos Kittenis
44

55
# This library is free software; you can redistribute it and/or
66
# modify it under the terms of the GNU Lesser General Public
@@ -16,7 +16,7 @@
1616
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
1717

1818

19-
"""Package containing ParallelSSHClient class"""
19+
"""Package containing ParallelSSHClient class."""
2020

2121

2222
from gevent import monkey
@@ -26,6 +26,7 @@
2626
import gevent.hub
2727
gevent.hub.Hub.NOT_ERROR = (Exception,)
2828
import warnings
29+
import hashlib
2930
from .constants import DEFAULT_RETRIES
3031
from .ssh_client import SSHClient
3132

@@ -37,11 +38,12 @@
3738
class ParallelSSHClient(object):
3839
"""Uses :mod:`pssh.ssh_client.SSHClient`, performs tasks over SSH on multiple hosts in \
3940
parallel.
40-
41+
4142
Connections to hosts are established in parallel when ``run_command`` is called,
4243
therefor any connection and/or authentication exceptions will happen on the
43-
call to ``run_command`` and need to be caught."""
44-
44+
call to ``run_command`` and need to be caught.
45+
"""
46+
4547
def __init__(self, hosts,
4648
user=None, password=None, port=None, pkey=None,
4749
forward_ssh_agent=True, num_retries=DEFAULT_RETRIES, timeout=120,
@@ -84,39 +86,76 @@ def __init__(self, hosts,
8486
set. Defaults to 22.
8587
:type proxy_port: int
8688
87-
**Example**
88-
89-
>>> from pssh import ParallelSSHClient, AuthenticationException, \
89+
**Example Usage**
90+
91+
>>> from pssh.pssh_client import ParallelSSHClient
92+
>>> from pssh.exceptions import AuthenticationException, \
9093
UnknownHostException, ConnectionErrorException
94+
9195
>>> client = ParallelSSHClient(['myhost1', 'myhost2'])
9296
>>> try:
9397
>>> ... output = client.run_command('ls -ltrh /tmp/aasdfasdf', sudo=True)
9498
>>> except (AuthenticationException, UnknownHostException, ConnectionErrorException):
95-
>>> ... return
99+
>>> ... pass
100+
96101
>>> # Commands have started executing at this point
97102
>>> # Exit code will probably not be available immediately
98103
>>> print output
99-
>>> {'myhost1': {'exit_code': None,
104+
105+
::
106+
107+
{'myhost1': {'exit_code': None,
100108
'stdout' : <generator>,
101109
'stderr' : <generator>,
102110
'cmd' : <greenlet>,
111+
'exception' : None,
103112
},
104113
'myhost2': {'exit_code': None,
105114
'stdout' : <generator>,
106115
'stderr' : <generator>,
107116
'cmd' : <greenlet>,
117+
'exception' : None,
108118
}}
109-
>>> # Print output as it comes in.
119+
120+
**Enabling host logger**
121+
122+
There is a host logger in parallel-ssh that can be enabled to show stdout
123+
*in parallel* from remote commands on hosts as it comes in.
124+
125+
This allows for stdout to be automatically displayed without having to
126+
print it serially per host.
127+
128+
>>> import pssh.utils
129+
>>> pssh.utils.enable_host_logger()
130+
>>> output = client.run_command('ls -ltrh')
131+
[myhost1] drwxrwxr-x 6 user group 4.0K Jan 1 HH:MM x
132+
[myhost2] drwxrwxr-x 6 user group 4.0K Jan 1 HH:MM x
133+
134+
Retrieve exit codes after commands have finished as below. This is
135+
only necessary for long running commands that do not exit immediately.
136+
137+
``exit_code`` in ``output`` will be ``None`` if command has not finished.
138+
139+
``get_exit_codes`` is not a blocking function and will not wait for commands
140+
to finish. Use ``client.pool.join()`` to block until all commands have
141+
finished.
142+
143+
``output`` parameter is modified in-place.
144+
145+
>>> client.get_exit_codes(output)
146+
>>> for host in output:
147+
>>> ... print output[host]['exit_code']
148+
0
149+
0
150+
151+
Print stdout serially per host as it becomes available.
152+
110153
>>> for host in output: for line in output[host]['stdout']: print line
111154
[myhost1] ls: cannot access /tmp/aasdfasdf: No such file or directory
112155
[myhost2] ls: cannot access /tmp/aasdfasdf: No such file or directory
113-
>>> # Retrieve exit code after commands have finished
114-
>>> # `get_exit_code` will return `None` if command has not finished
115-
>>> print client.get_exit_code(output[host])
116-
0
117-
156+
118157
**Example with specified private key**
119-
158+
120159
>>> import paramiko
121160
>>> client_key = paramiko.RSAKey.from_private_key_file('user.key')
122161
>>> client = ParallelSSHClient(['myhost1', 'myhost2'], pkey=client_key)
@@ -167,39 +206,45 @@ def run_command(self, *args, **kwargs):
167206
:type args: tuple
168207
:param sudo: (Optional) Run with sudo. Defaults to False
169208
:type sudo: bool
170-
:param kwargs: Keyword arguments for command
171-
:type kwargs: dict
172209
:param stop_on_errors: (Optional) Raise exception on errors running command. \
173-
Defaults to True.
210+
Defaults to True. With stop_on_errors set to False, exceptions are instead \
211+
added to output of `run_command`. See example usage below.
174212
:type stop_on_errors: bool
175-
:rtype: Dictionary with host as key as per :mod:`pssh.pssh_client.ParallelSSH.get_output`
213+
:param kwargs: Keyword arguments for command
214+
:type kwargs: dict
215+
:rtype: Dictionary with host as key as per \
216+
:mod:`pssh.pssh_client.ParallelSSHClient.get_output`
176217
177218
:raises: :mod:`pssh.exceptions.AuthenticationException` on authentication error
178219
:raises: :mod:`pssh.exceptions.UnknownHostException` on DNS resolution error
179220
:raises: :mod:`pssh.exceptions.ConnectionErrorException` on error connecting
180221
:raises: :mod:`pssh.exceptions.SSHException` on other undefined SSH errors
181222
182223
**Example Usage**
224+
225+
**Simple run command**
183226
184227
>>> output = client.run_command('ls -ltrh')
185-
186-
print stdout for each command:
228+
229+
*print stdout for each command*
187230
188231
>>> for host in output:
189232
>>> for line in output[host]['stdout']: print line
190233
191-
Get exit code after command has finished:
234+
*Get exit codes after command has finished*
192235
236+
>>> client.get_exit_codes(output)
193237
>>> for host in output:
194-
>>> for line in output[host]['stdout']: print line
195-
>>> exit_code = client.get_exit_code(output[host])
238+
>>> ... print output[host]['exit_code']
239+
0
240+
0
196241
197-
Wait for completion, no stdout:
242+
*Wait for completion, no stdout*
198243
199244
>>> client.pool.join()
200-
201-
Run with sudo:
202-
245+
246+
*Run with sudo*
247+
203248
>>> output = client.run_command('ls -ltrh', sudo=True)
204249
205250
Capture stdout - **WARNING** - this will store the entirety of stdout
@@ -209,17 +254,35 @@ def run_command(self, *args, **kwargs):
209254
>>> for host in output:
210255
>>> stdout = list(output[host]['stdout'])
211256
>>> print "Complete stdout for host %s is %s" % (host, stdout,)
212-
257+
213258
**Example Output**
214-
259+
215260
::
216261
217262
{'myhost1': {'exit_code': exit code if ready else None,
218263
'channel' : SSH channel of command,
219264
'stdout' : <iterable>,
220265
'stderr' : <iterable>,
221-
'cmd' : <greenlet>}}
222-
266+
'cmd' : <greenlet>},
267+
'exception' : None}
268+
269+
**Do not stop on errors, return per-host exceptions in output**
270+
271+
>>> output = client.run_command('ls -ltrh', stop_on_errors=False)
272+
>>> client.pool.join()
273+
>>> print output
274+
275+
::
276+
277+
{'myhost1': {'exit_code': None,
278+
'channel' : None,
279+
'stdout' : None,
280+
'stderr' : None,
281+
'cmd' : None,
282+
'exception' : ConnectionErrorException(
283+
"Error connecting to host '%s:%s' - %s - retry %s/%s",
284+
host, port, 'Connection refused', 3, 3)}}
285+
223286
"""
224287
stop_on_errors = kwargs.pop('stop_on_errors', True)
225288
cmds = [self.pool.spawn(self._exec_command, host, *args, **kwargs)
@@ -303,34 +366,43 @@ def get_output(self, cmd, output):
303366
except IndexError:
304367
logger.error("Got exception with no host argument - cannot update output data with %s", ex)
305368
raise ex
306-
output.setdefault(host, {})
307-
output[host].update({'exit_code' : None,
308-
'channel' : None,
309-
'stdout' : None,
310-
'stderr' : None,
311-
'cmd' : cmd,
312-
'exception' : ex,})
369+
self._update_host_output(output, host, None, None, None, None, cmd,
370+
exception=ex)
313371
raise ex
372+
self._update_host_output(output, host, self._get_exit_code(channel),
373+
channel, stdout, stderr, cmd)
374+
375+
def _update_host_output(self, output, host, exit_code, channel, stdout, stderr, cmd,
376+
exception=None):
377+
"""Update host output with given data"""
378+
if host in output:
379+
new_host = "_".join([host, hashlib.sha1().hexdigest()[:10]])
380+
logger.warning("Already have output for host %s - changing host key for %s to %s",
381+
host, host, new_host)
382+
host = new_host
314383
output.setdefault(host, {})
315-
output[host].update({'exit_code': self._get_exit_code(channel),
384+
output[host].update({'exit_code' : exit_code,
316385
'channel' : channel,
317386
'stdout' : stdout,
318387
'stderr' : stderr,
319-
'cmd' : cmd, })
388+
'cmd' : cmd,
389+
'exception' : exception,})
320390

321391
def get_exit_codes(self, output):
322-
"""Get exit code for all hosts in output if available.
392+
"""Get exit code for all hosts in output *if available*.
323393
Output parameter is modified in-place.
324394
325-
:param output: As returned by `self.get_output`
395+
:param output: As returned by :mod:`pssh.pssh_client.ParallelSSHClient.get_output`
326396
:rtype: None
327397
"""
328398
for host in output:
329399
output[host].update({'exit_code': self.get_exit_code(output[host])})
330400

331401
def get_exit_code(self, host_output):
332-
"""Get exit code from host output if available
333-
:param host_output: Per host output as returned by `self.get_output`
402+
"""Get exit code from host output *if available*.
403+
404+
:param host_output: Per host output as returned by \
405+
:mod:`pssh.pssh_client.ParallelSSHClient.get_output`
334406
:rtype: int or None if exit code not ready"""
335407
if not 'channel' in host_output:
336408
logger.error("%s does not look like host output..", host_output,)
@@ -348,7 +420,7 @@ def _get_exit_code(self, channel):
348420
def get_stdout(self, greenlet, return_buffers=False):
349421
"""Get/print stdout from greenlet and return exit code for host
350422
351-
**Deprecated** - use self.get_output() instead.
423+
**Deprecated** - use :mod:`pssh.pssh_client.ParallelSSHClient.get_output` instead.
352424
353425
:param greenlet: Greenlet object containing an \
354426
SSH channel reference, hostname, stdout and stderr buffers

pssh/ssh_client.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This file is part of parallel-ssh.
22

3-
# Copyright (C) 2015 Panos Kittenis
3+
# Copyright (C) 2015- Panos Kittenis
44

55
# This library is free software; you can redistribute it and/or
66
# modify it under the terms of the GNU Lesser General Public
@@ -16,7 +16,8 @@
1616
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
1717

1818

19-
"""Package containing SSHClient class"""
19+
"""Package containing SSHClient class."""
20+
2021

2122
import gevent
2223
from gevent import monkey
@@ -156,9 +157,9 @@ def _connect(self, client, host, port, sock=None, retries=1):
156157
gevent.sleep(5)
157158
return self._connect(client, host, port, sock=sock,
158159
retries=retries+1)
159-
raise UnknownHostException("%s - %s - retry %s/%s",
160-
str(ex.args[1]),
161-
self.host, retries, self.num_retries)
160+
raise UnknownHostException("Unknown host %s - %s - retry %s/%s",
161+
self.host, str(ex.args[1]), retries,
162+
self.num_retries)
162163
except sock_error, ex:
163164
logger.error("Error connecting to host '%s:%s' - retry %s/%s",
164165
self.host, self.port, retries, self.num_retries)
@@ -167,17 +168,17 @@ def _connect(self, client, host, port, sock=None, retries=1):
167168
return self._connect(client, host, port, sock=sock,
168169
retries=retries+1)
169170
error_type = ex.args[1] if len(ex.args) > 1 else ex.args[0]
170-
raise ConnectionErrorException("%s for host '%s:%s' - retry %s/%s",
171-
str(error_type), self.host, self.port,
172-
retries, self.num_retries,)
171+
raise ConnectionErrorException("Error connecting to host '%s:%s' - %s - retry %s/%s",
172+
self.host, self.port,
173+
str(error_type), retries, self.num_retries,)
173174
except paramiko.AuthenticationException, ex:
174175
msg = ex.message + "Host is '%s:%s'"
175176
raise AuthenticationException(msg, host, port)
176177
# SSHException is more general so should be below other types
177178
# of SSH failure
178179
except paramiko.SSHException, ex:
179180
logger.error("General SSH error - %s", ex)
180-
raise SSHException(ex.message, host)
181+
raise SSHException(ex.message, host, port)
181182

182183
def exec_command(self, command, sudo=False, user=None, **kwargs):
183184
"""Wrapper to :mod:`paramiko.SSHClient.exec_command`
@@ -244,8 +245,9 @@ def mkdir(self, sftp, directory):
244245
:type sftp: :mod:`paramiko.SFTPClient`
245246
:param directory: Remote directory to create
246247
:type directory: str
247-
248-
Catches and logs at error level remote IOErrors on creating directory."""
248+
249+
Catches and logs at error level remote IOErrors on creating directory.
250+
"""
249251
try:
250252
sftp.mkdir(directory)
251253
except IOError, error:
@@ -254,10 +256,10 @@ def mkdir(self, sftp, directory):
254256

255257
def copy_file(self, local_file, remote_file):
256258
"""Copy local file to host via SFTP/SCP
257-
259+
258260
Copy is done natively using SFTP/SCP version 2 protocol, no scp command \
259261
is used or required.
260-
262+
261263
:param local_file: Local filepath to copy to remote host
262264
:type local_file: str
263265
:param remote_file: Remote filepath on remote host to copy file to

0 commit comments

Comments
 (0)