Skip to content

Commit 379b871

Browse files
author
Pan
committed
Handle SCP and SFTP writes correctly in native client by resuming from last written byte on EAGAIN - resolves #148
Updated documentation, readme. Added docstrings for parallel client scp functions. Updated ssh2-python minimum version. Updated C code with cython 0.29. Updated travis cfg.
1 parent 5f4a413 commit 379b871

File tree

12 files changed

+891
-104
lines changed

12 files changed

+891
-104
lines changed

.travis.yml

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,33 @@ after_success:
4242
jobs:
4343
include:
4444

45+
- stage: build packages
46+
env:
47+
- WHEELS=1
48+
os: linux
49+
python: 3.6
50+
before_install: skip
51+
install:
52+
- pip install twine
53+
script:
54+
- docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" &&
55+
./ci/travis/build-manylinux.sh;
56+
after_success:
57+
- if [[ ! -z "$TRAVIS_TAG" ]]; then
58+
twine upload --skip-existing -u $PYPI_U -p $PYPI_P wheelhouse/*.whl;
59+
fi
60+
deploy:
61+
- provider: pypi
62+
user: pkittenis
63+
password:
64+
secure: ZQJ41Nguc7Y5XHvtN8lITIiW1S1jvy0p50rssMUJpa9wVZIh0HcW0K/Xv2v17fDNuOvQlVbsF0sY/BmcRfH7c7nzwt7fRXGOjXbZk5djqwusKXL6zlVN7OKjAY6j2EByOjD9UpDDkB5tDqb4lRBCX87wknii/t+7/8P0ddoBojM=
65+
on:
66+
repo: ParallelSSH/parallel-ssh
67+
tags: true
68+
distributions: sdist
69+
skip_upload_docs: true
70+
skip_cleanup: true
71+
4572
- &osx-wheels
4673
stage: build packages
4774
os: osx
@@ -50,10 +77,8 @@ jobs:
5077
- PYENV: 3.6.4
5178
before_install:
5279
- brew update
53-
- brew outdated openssl || brew upgrade openssl || echo "y"
5480
- brew link --overwrite python@2 || brew install python@2 || brew link --overwrite python@2
5581
- which python2
56-
- python2 -c "from __future__ import print_function; import ssl; from platform import python_version; print(ssl.OPENSSL_VERSION); print(python_version())"
5782
- sudo -H pip2 install twine
5883
- which twine
5984
- mkdir -p wheels
@@ -93,10 +118,10 @@ jobs:
93118
- ./ci/travis/pyenv-wheel.sh
94119

95120
- <<: *osx-wheels
96-
osx_image: xcode8.3
121+
osx_image: xcode9.2
97122

98123
- <<: *osx-wheels
99-
osx_image: xcode9.3
124+
osx_image: xcode9.4
100125

101126
- <<: *osx-wheels
102127
osx_image: xcode8
@@ -107,15 +132,15 @@ jobs:
107132
- ./ci/travis/pyenv-wheel.sh
108133

109134
- <<: *osx-wheels
110-
osx_image: xcode8.3
135+
osx_image: xcode9.2
111136
env:
112137
- PYENV: 3.7.0
113138
install: skip
114139
script:
115140
- ./ci/travis/pyenv-wheel.sh
116141

117142
- <<: *osx-wheels
118-
osx_image: xcode9.3
143+
osx_image: xcode9.4
119144
env:
120145
- PYENV: 3.7.0
121146
install: skip
@@ -142,31 +167,3 @@ jobs:
142167
on:
143168
repo: ParallelSSH/parallel-ssh
144169
tags: true
145-
146-
- stage: build packages
147-
env:
148-
- WHEELS=1
149-
- OPENSSL=openssl-1.0.2o
150-
os: linux
151-
python: 3.6
152-
before_install: skip
153-
install:
154-
- pip install twine
155-
script:
156-
- docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" &&
157-
./ci/travis/build-manylinux.sh;
158-
after_success:
159-
- if [[ ! -z "$TRAVIS_TAG" ]]; then
160-
twine upload --skip-existing -u $PYPI_U -p $PYPI_P wheelhouse/*.whl;
161-
fi
162-
deploy:
163-
- provider: pypi
164-
user: pkittenis
165-
password:
166-
secure: ZQJ41Nguc7Y5XHvtN8lITIiW1S1jvy0p50rssMUJpa9wVZIh0HcW0K/Xv2v17fDNuOvQlVbsF0sY/BmcRfH7c7nzwt7fRXGOjXbZk5djqwusKXL6zlVN7OKjAY6j2EByOjD9UpDDkB5tDqb4lRBCX87wknii/t+7/8P0ddoBojM=
167-
on:
168-
repo: ParallelSSH/parallel-ssh
169-
tags: true
170-
distributions: sdist
171-
skip_upload_docs: true
172-
skip_cleanup: true

Changelog.rst

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
Change Log
22
============
33

4+
5+
1.9.1
6+
++++++
7+
8+
Fixes
9+
-----
10+
11+
* Native client SCP and SFTP uploads would not handle partial writes from waiting on socket correctly.
12+
* Native client ``copy_file`` SFTP upload would get stuck repeating same writes until killed when copying multi-MB files from Windows clients - #148
13+
* Native client ``scp_send`` would not correctly preserve file mask of local file on the remote.
14+
* Native client tunnel, used for proxy implementation, would not handle partial writes from waiting on socket correctly.
15+
16+
417
1.9.0
518
++++++
619

@@ -9,7 +22,7 @@ Changes
922

1023
* Removed libssh2 native library dependency in favour of bundled ``ssh2-python`` libssh2 library.
1124
* Changed native client forward agent default behaviour to off due to incompatibility with certain SSH server implementations.
12-
* Added keep-alive functionality to native client - defaults to ``60`` seconds. ``ParallelSSHClient(<..>, keepalive_seconds=<interval>)`` to configure interval, Set to ``0`` to disable.
25+
* Added keep-alive functionality to native client - defaults to ``60`` seconds. ``ParallelSSHClient(<..>, keepalive_seconds=<interval>)`` to configure interval. Set to ``0`` to disable.
1326
* Added ``~/.ssh/id_ecdsa`` default identity location to native client.
1427

1528

README.rst

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Native Code Client Features
116116
Exit codes
117117
***********
118118

119-
Once either standard output is iterated on *to completion*, or ``client.join(output)`` is called, exit codes become available in host output. Iteration ends *only when remote command has completed*, though it may be interrupted and resumed at any point.
119+
Once *either* standard output is iterated on *to completion*, or ``client.join(output)`` is called, exit codes become available in host output. Iteration ends *only when remote command has completed*, though it may be interrupted and resumed at any point.
120120

121121
.. code-block:: python
122122
@@ -178,10 +178,35 @@ To log output without having to iterate over output generators, the ``consume_ou
178178
[localhost] Linux
179179
180180
181+
SCP
182+
****
183+
184+
SCP is supported - native clients only - and provides the best performance for file copying.
185+
186+
Unlike with the SFTP functionality, remote files that already exist are *not* overwritten and an exception is raised instead.
187+
188+
Note that enabling recursion with SCP requires server SFTP support for creating remote directories.
189+
190+
To copy a local file to remote hosts in parallel with SCP:
191+
192+
.. code-block:: python
193+
194+
from pssh.clients import ParallelSSHClient
195+
from gevent import joinall
196+
197+
hosts = ['myhost1', 'myhost2']
198+
client = ParallelSSHClient(hosts)
199+
cmds = client.scp_send('../test', 'test_dir/test')
200+
joinall(cmds, raise_error=True)
201+
202+
203+
See also documentation for SCP recv.
204+
205+
181206
SFTP
182-
******
207+
*****
183208

184-
SFTP is supported natively.
209+
SFTP is supported natively. Performance is much slower than SCP due to underlying library limitations and SCP should be preferred where possible. In the case of the deprecated paramiko clients, several bugs exist with SFTP performance and behaviour - avoid if at all possible.
185210

186211
To copy a local file to remote hosts in parallel:
187212

pssh/clients/miko/single.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ def _connect(self, client, host, port, sock=None, retries=1,
223223
"Error connecting to host '%s:%s' - %s - retry %s/%s",
224224
host, self.port, str(error_type), retries,
225225
self.num_retries,)
226-
except paramiko.AuthenticationException as ex:
226+
except paramiko.AuthenticationException:
227227
msg = "Authentication error while connecting to %s:%s."
228228
raise AuthenticationException(msg, host, port)
229229
# SSHException is more general so should be below other types

pssh/clients/native/parallel.py

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ def _make_ssh_client(self, host):
387387
keepalive_seconds=self.keepalive_seconds)
388388

389389
def copy_file(self, local_file, remote_file, recurse=False, copy_args=None):
390-
"""Copy local file to remote file in parallel
390+
"""Copy local file to remote file in parallel via SFTP.
391391
392392
This function returns a list of greenlets which can be
393393
`join`-ed on to wait for completion.
@@ -440,7 +440,7 @@ def copy_file(self, local_file, remote_file, recurse=False, copy_args=None):
440440
def copy_remote_file(self, remote_file, local_file, recurse=False,
441441
suffix_separator='_', copy_args=None,
442442
encoding='utf-8'):
443-
"""Copy remote file(s) in parallel as
443+
"""Copy remote file(s) in parallel via SFTP as
444444
<local_file><suffix_separator><host>
445445
446446
With a ``local_file`` value of ``myfile`` and default separator ``_``
@@ -522,15 +522,109 @@ def _scp_recv(self, host, remote_file, local_file, recurse=False):
522522
remote_file, local_file, recurse=recurse)
523523

524524
def scp_send(self, local_file, remote_file, recurse=False):
525+
"""Copy local file to remote file in parallel via SCP.
526+
527+
This function returns a list of greenlets which can be
528+
`join`-ed on to wait for completion.
529+
530+
:py:func:`gevent.joinall` function may be used to join on all greenlets
531+
and will also raise exceptions from them if called with
532+
``raise_error=True`` - default is `False`.
533+
534+
Alternatively call `.get()` on each greenlet to raise any exceptions
535+
from it.
536+
537+
.. note::
538+
Creating remote directories when either ``remote_file`` contains
539+
directory paths or ``recurse`` is enabled requires SFTP support on
540+
the server as libssh2 SCP implementation lacks directory creation
541+
support.
542+
543+
:param local_file: Local filepath to copy to remote host
544+
:type local_file: str
545+
:param remote_file: Remote filepath on remote host to copy file to
546+
:type remote_file: str
547+
:param recurse: Whether or not to descend into directories recursively.
548+
:type recurse: bool
549+
550+
:rtype: list(:py:class:`gevent.Greenlet`) of greenlets for remote copy
551+
commands.
552+
553+
:raises: :py:class:`pss.exceptions.SCPError` on errors copying file.
554+
:raises: :py:class:`OSError` on local OS errors like permission denied.
555+
"""
525556
return [self.pool.spawn(self._scp_send, host, local_file,
526557
remote_file, recurse=recurse)
527558
for host in self.hosts]
528559

529-
def scp_recv(self, remote_file, local_file, recurse=False, copy_args=None):
530-
copy_args = [{'local_file': '_'.join([local_file, host]),
560+
def scp_recv(self, remote_file, local_file, recurse=False, copy_args=None,
561+
suffix_separator='_'):
562+
"""Copy remote file(s) in parallel via SCP as
563+
<local_file><suffix_separator><host> or as per ``copy_args`` argument.
564+
565+
With a ``local_file`` value of ``myfile`` and default separator ``_``
566+
the resulting filename will be ``myfile_myhost`` for the file from host
567+
``myhost``.
568+
569+
De-duplication behaviour is configurable by providing ``copy_args``
570+
argument, see below.
571+
572+
This function, like :py:func:`ParallelSSHClient.scp_send`, returns a
573+
list of greenlets which can be `join`-ed on to wait for completion.
574+
575+
:py:func:`gevent.joinall` function may be used to join on all greenlets
576+
and will also raise exceptions if called with ``raise_error=True`` -
577+
default is `False`.
578+
579+
Alternatively call `.get` on each greenlet to raise any exceptions from
580+
it.
581+
582+
Exceptions listed here are raised when
583+
either ``gevent.joinall(<greenlets>, raise_error=True)`` is called
584+
or ``.get`` is called on each greenlet, not this function itself.
585+
586+
:param remote_file: remote filepath to copy to local host
587+
:type remote_file: str
588+
:param local_file: local filepath on local host to copy file to
589+
:type local_file: str
590+
:param recurse: whether or not to recurse
591+
:type recurse: bool
592+
:param suffix_separator: (Optional) Separator string between
593+
filename and host, defaults to ``_``. For example, for a
594+
``local_file`` value of ``myfile`` and default separator the
595+
resulting filename will be ``myfile_myhost`` for the file from
596+
host ``myhost``. ``suffix_separator`` has no meaning if
597+
``copy_args`` is provided
598+
:type suffix_separator: str
599+
:param copy_args: (Optional) format remote_file and local_file strings
600+
with per-host arguments in ``copy_args``. ``copy_args`` length *must*
601+
equal length of host list -
602+
:py:class:`pssh.exceptions.HostArgumentException` is raised otherwise
603+
:type copy_args: tuple or list
604+
605+
:rtype: list(:py:class:`gevent.Greenlet`) of greenlets for remote copy
606+
commands.
607+
608+
:raises: :py:class:`ValueError` when a directory is supplied to
609+
local_file and recurse is not set.
610+
:raises: :py:class:`pssh.exceptions.HostArgumentException` on number of
611+
per-host copy arguments not equal to number of hosts.
612+
:raises: :py:class:`pss.exceptions.SCPError` on errors copying file.
613+
:raises: :py:class:`OSError` on local OS errors like permission denied.
614+
615+
.. note ::
616+
Local directories in ``local_file`` that do not exist will be
617+
created as long as permissions allow.
618+
619+
.. note ::
620+
File names will be de-duplicated by appending the hostname to the
621+
filepath separated by ``suffix_separator`` or as per ``copy_args``
622+
argument if provided.
623+
"""
624+
copy_args = [{'local_file': suffix_separator.join([local_file, host]),
531625
'remote_file': remote_file}
532626
for i, host in enumerate(self.hosts)] \
533-
if copy_args is None else copy_args
627+
if copy_args is None else copy_args
534628
local_file = "%(local_file)s"
535629
remote_file = "%(remote_file)s"
536630
try:

pssh/clients/native/single.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
ConnectionErrorException, SessionError, SFTPError, SFTPIOError, Timeout, \
4242
SCPError
4343
from ...constants import DEFAULT_RETRIES, RETRY_DELAY
44-
from ...native._ssh2 import wait_select, _read_output
44+
from ...native._ssh2 import wait_select, eagain_write, _read_output
4545
from .common import _validate_pkey_path
4646

4747

@@ -524,9 +524,9 @@ def copy_file(self, local_file, remote_file, recurse=False,
524524
local_file, self.host, remote_file)
525525

526526
def _sftp_put(self, remote_fh, local_file):
527-
with open(local_file, 'rb') as local_fh:
527+
with open(local_file, 'rb', 2097152) as local_fh:
528528
for data in local_fh:
529-
self._eagain(remote_fh.write, data)
529+
eagain_write(remote_fh.write, data, self.session)
530530

531531
def sftp_put(self, sftp, local_file, remote_file):
532532
mode = LIBSSH2_SFTP_S_IRUSR | \
@@ -787,16 +787,16 @@ def _scp_send(self, local_file, remote_file):
787787
try:
788788
chan = self._eagain(
789789
self.session.scp_send64,
790-
remote_file, fileinfo.st_mode & 777, fileinfo.st_size,
790+
remote_file, fileinfo.st_mode & 0o777, fileinfo.st_size,
791791
fileinfo.st_mtime, fileinfo.st_atime)
792792
except Exception as ex:
793793
msg = "Error opening remote file %s for writing on host %s - %s"
794794
logger.error(msg, remote_file, self.host, ex)
795795
raise SCPError(msg, remote_file, self.host, ex)
796796
try:
797-
with open(local_file, 'rb') as local_fh:
797+
with open(local_file, 'rb', 2097152) as local_fh:
798798
for data in local_fh:
799-
chan.write(data)
799+
eagain_write(chan.write, data, self.session)
800800
except Exception as ex:
801801
msg = "Error writing to remote file %s on host %s - %s"
802802
logger.error(msg, remote_file, self.host, ex)

0 commit comments

Comments
 (0)