Skip to content

Commit 7034d55

Browse files
author
Panos Kittenis
committed
Added timeout ability to fake server. Added working test for timeout argument of ssh clients.
1 parent f33a7f3 commit 7034d55

File tree

4 files changed

+77
-39
lines changed

4 files changed

+77
-39
lines changed

fake_server/fake_server.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,8 @@
2626
"""
2727

2828
import gevent
29-
from gevent import monkey
30-
monkey.patch_all()
3129
import os
30+
import socket
3231
from gevent import socket
3332
from gevent.event import Event
3433
import sys
@@ -37,14 +36,17 @@
3736
import paramiko
3837
import time
3938
from stub_sftp import StubSFTPServer
39+
from gevent import monkey
40+
monkey.patch_all()
41+
4042

4143
logger = logging.getLogger("fake_server")
4244
paramiko_logger = logging.getLogger('paramiko.transport')
4345

4446
host_key = paramiko.RSAKey(filename = os.path.sep.join([os.path.dirname(__file__), 'rsa.key']))
4547

4648
class Server (paramiko.ServerInterface):
47-
def __init__(self, cmd_req_response = {}, fail_auth = False):
49+
def __init__(self, cmd_req_response = {}, fail_auth=False):
4850
self.event = Event()
4951
self.cmd_req_response = cmd_req_response
5052
self.fail_auth = fail_auth
@@ -113,7 +115,8 @@ def make_socket(listen_ip):
113115
return
114116
return sock
115117

116-
def listen(cmd_req_response, sock, fail_auth = False):
118+
def listen(cmd_req_response, sock, fail_auth=False,
119+
timeout=None):
117120
"""Run a fake ssh server and given a cmd_to_run, send given \
118121
response to client connection. Returns (server, socket) tuple \
119122
where server is a joinable server thread and socket is listening \
@@ -129,16 +132,17 @@ def listen(cmd_req_response, sock, fail_auth = False):
129132
logger.error('*** Listen failed: %s' % (str(e),))
130133
traceback.print_exc()
131134
return
132-
handle_ssh_connection(cmd_req_response, sock, fail_auth=fail_auth)
135+
handle_ssh_connection(cmd_req_response, sock, fail_auth=fail_auth,
136+
timeout=timeout)
133137

134-
def _handle_ssh_connection(cmd_req_response, transport, fail_auth = False):
138+
def _handle_ssh_connection(cmd_req_response, transport, fail_auth=False):
135139
try:
136140
transport.load_server_moduli()
137141
except:
138142
return
139143
transport.add_server_key(host_key)
140144
transport.set_subsystem_handler('sftp', paramiko.SFTPServer, StubSFTPServer)
141-
server = Server(cmd_req_response = cmd_req_response, fail_auth = fail_auth)
145+
server = Server(cmd_req_response = cmd_req_response, fail_auth=fail_auth)
142146
try:
143147
transport.start_server(server=server)
144148
except paramiko.SSHException, e:
@@ -158,9 +162,15 @@ def _handle_ssh_connection(cmd_req_response, transport, fail_auth = False):
158162
time.sleep(.5)
159163
channel.close()
160164

161-
def handle_ssh_connection(cmd_req_response, sock, fail_auth = False):
165+
def handle_ssh_connection(cmd_req_response, sock,
166+
fail_auth=False,
167+
timeout=None):
162168
conn, addr = sock.accept()
163169
logger.info('Got connection..')
170+
if timeout:
171+
logger.debug("SSH server sleeping for %s then raising socket.timeout",
172+
timeout)
173+
gevent.Timeout(timeout).start()
164174
try:
165175
transport = paramiko.Transport(conn)
166176
_handle_ssh_connection(cmd_req_response, transport, fail_auth=fail_auth)
@@ -173,8 +183,10 @@ def handle_ssh_connection(cmd_req_response, sock, fail_auth = False):
173183
pass
174184
return
175185

176-
def start_server(cmd_req_response, sock, fail_auth=False):
177-
return gevent.spawn(listen, cmd_req_response, sock, fail_auth=fail_auth)
186+
def start_server(cmd_req_response, sock, fail_auth=False,
187+
timeout=None):
188+
return gevent.spawn(listen, cmd_req_response, sock, fail_auth=fail_auth,
189+
timeout=timeout)
178190

179191
if __name__ == "__main__":
180192
logging.basicConfig()

pssh.py

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ class ProxyCommandException(Exception):
6666
pass
6767

6868

69+
class SSHException(Exception):
70+
"""Raised on SSHException error - error authenticating with SSH server"""
71+
pass
72+
73+
6974
class SSHClient(object):
7075
"""Wrapper class over paramiko.SSHClient with sane defaults
7176
Honours ~/.ssh/config and /etc/ssh/ssh_config entries for host username \
@@ -178,6 +183,10 @@ def _connect(self, retries=1):
178183
except paramiko.ProxyCommandFailure, e:
179184
logger.error("Error executing ProxyCommand - %s", e.message,)
180185
raise ProxyCommandException(e.message)
186+
# SSHException is more general so should below other types
187+
# of SSH failure
188+
except paramiko.SSHException, e:
189+
raise SSHException(e)
181190

182191
def exec_command(self, command, sudo=False, user=None, **kwargs):
183192
"""Wrapper to :mod:`paramiko.SSHClient.exec_command`
@@ -316,11 +325,11 @@ def __init__(self, hosts,
316325
:param pool_size: (Optional) Greenlet pool size. Controls on how many\
317326
hosts to execute tasks in parallel. Defaults to 10
318327
:type pool_size: int
319-
328+
320329
**Example**
321330
322331
>>> from pssh import ParallelSSHClient, AuthenticationException,\
323-
UnknownHostException, ConnectionErrorException
332+
UnknownHostException, ConnectionErrorException
324333
>>> client = ParallelSSHClient(['myhost1', 'myhost2'])
325334
>>> try:
326335
>>> ... cmds = client.exec_command('ls -ltrh /tmp/aasdfasdf', sudo = True)
@@ -335,7 +344,7 @@ def __init__(self, hosts,
335344
**Example with returned stdout and stderr buffers**
336345
337346
>>> from pssh import ParallelSSHClient, AuthenticationException,\
338-
UnknownHostException, ConnectionErrorException
347+
UnknownHostException, ConnectionErrorException
339348
>>> client = ParallelSSHClient(['myhost1', 'myhost2'])
340349
>>> try:
341350
>>> ... cmds = client.exec_command('ls -ltrh /tmp/aasdfasdf', sudo = True)
@@ -344,10 +353,10 @@ def __init__(self, hosts,
344353
>>> output = [client.get_stdout(cmd, return_buffers=True) for cmd in cmds]
345354
>>> print output
346355
[{'myhost1': {'exit_code': 2,
347-
'stdout' : <generator object <genexpr>,
356+
'stdout' : <generator object <genexpr>,
348357
'stderr' : <generator object <genexpr>,}},
349358
{'myhost2': {'exit_code': 2,
350-
'stdout' : <generator object <genexpr>,
359+
'stdout' : <generator object <genexpr>,
351360
'stderr' : <generator object <genexpr>,}},
352361
]
353362
>>> for host_stdout in output:
@@ -361,25 +370,25 @@ def __init__(self, hosts,
361370
>>> import paramiko
362371
>>> client_key = paramiko.RSAKey.from_private_key_file('user.key')
363372
>>> client = ParallelSSHClient(['myhost1', 'myhost2'], pkey=client_key)
364-
373+
365374
.. note ::
366375
367376
**Connection persistence**
368-
377+
369378
Connections to hosts will remain established for the duration of the
370379
object's life. To close them, just `del` or reuse the object reference.
371-
380+
372381
>>> client = ParallelSSHClient(['localhost'])
373382
>>> cmds = client.exec_command('ls -ltrh /tmp/aasdfasdf')
374383
>>> cmds[0].join()
375-
384+
376385
:netstat: ``tcp 0 0 127.0.0.1:53054 127.0.0.1:22 ESTABLISHED``
377-
386+
378387
Connection remains active after commands have finished executing. Any \
379388
additional commands will use the same connection.
380-
389+
381390
>>> del client
382-
391+
383392
Connection is terminated.
384393
"""
385394
self.pool = gevent.pool.Pool(size=pool_size)
@@ -408,19 +417,19 @@ def exec_command(self, *args, **kwargs):
408417
**Example**:
409418
410419
>>> cmds = client.exec_command('ls -ltrh')
411-
420+
412421
Wait for completion, no stdout:
413-
422+
414423
>>> for cmd in cmds:
415424
>>> cmd.join()
416-
425+
417426
Alternatively/in addition print stdout for each command:
418-
427+
419428
>>> print [get_stdout(cmd) for cmd in cmds]
420429
421430
Retrieving stdout implies join, meaning get_stdout will wait
422431
for completion of all commands before returning output.
423-
432+
424433
You may call get_stdout on already completed greenlets to re-get
425434
their output as many times as you want."""
426435
return [self.pool.spawn(self._exec_command, host, *args, **kwargs)
@@ -439,7 +448,7 @@ def _exec_command(self, host, *args, **kwargs):
439448

440449
def get_stdout(self, greenlet, return_buffers=False):
441450
"""Get/print stdout from greenlet and return exit code for host
442-
451+
443452
:mod:`pssh.get_stdout` will close the open SSH channel but this does
444453
**not** close the established connection to the remote host, only the
445454
authenticated SSH channel within it. This is standard practise
@@ -463,7 +472,7 @@ def get_stdout(self, greenlet, return_buffers=False):
463472
for example ``{'myhost1': {'exit_code': 0}}``
464473
:rtype: With ``return_buffers=True``: ``{'myhost1': {'exit_code': 0,
465474
'channel' : None or SSH channel of command if command is still executing,
466-
'stdout' : <iterable>,
475+
'stdout' : <iterable>,
467476
'stderr' : <iterable>,}}``
468477
"""
469478
gevent.sleep(.2)

tests/test_pssh_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,27 @@ def test_pssh_client_auth_failure(self):
111111
del client
112112
server.join()
113113

114+
def test_pssh_client_timeout(self):
115+
server = start_server({ self.fake_cmd : self.fake_resp },
116+
self.listen_socket,
117+
timeout=0.2)
118+
client = ParallelSSHClient(['127.0.0.1'], port=self.listen_port,
119+
pkey=self.user_key,
120+
timeout=0.1)
121+
cmd = client.exec_command(self.fake_cmd)[0]
122+
# Handle exception
123+
try:
124+
gevent.sleep(0.5)
125+
cmd.get()
126+
if not server.exception:
127+
raise Exception("Expected EOFError from socket timeout, got none")
128+
raise server.exception
129+
except gevent.Timeout:
130+
pass
131+
del client
132+
server.join()
133+
134+
114135
def test_pssh_client_exec_command_password(self):
115136
"""Test password authentication. Fake server accepts any password
116137
even empty string"""

tests/test_ssh_client.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@
2020

2121
"""Unittests for :mod:`pssh.SSHClient` class"""
2222

23+
import gevent
24+
import socket
25+
import time
2326
import unittest
2427
from pssh import SSHClient, ParallelSSHClient, UnknownHostException, AuthenticationException,\
25-
logger, ConnectionErrorException, UnknownHostException
28+
logger, ConnectionErrorException, UnknownHostException, SSHException
2629
from fake_server.fake_server import start_server, make_socket, logger as server_logger, \
27-
paramiko_logger
30+
paramiko_logger
2831
from fake_server.fake_agent import FakeAgent
2932
import paramiko
3033
import os
@@ -126,20 +129,13 @@ def test_ssh_client_retries(self):
126129
msg="Got unexpected number of retries %s - expected %s"
127130
% (num_tries, expected_num_tries,))
128131

129-
def test_ssh_client_conn_failure(self):
132+
def test_ssh_client_unknown_host_failure(self):
130133
"""Test connection error failure case - ConnectionErrorException"""
131134
host = ''.join([random.choice(string.ascii_letters) for n in xrange(8)])
132135
self.assertRaises(UnknownHostException,
133136
SSHClient, host, port=self.listen_port,
134137
pkey=self.user_key, num_retries=0)
135138

136-
def test_ssh_client_timeout(self):
137-
"""Test connection timeout error"""
138-
with self.assertRaises(ConnectionErrorException) as cm:
139-
SSHClient('127.0.0.1', port=self.listen_port,
140-
pkey=self.user_key, num_retries=0, timeout=1)
141-
142-
self.assertEqual(cm.exception.args[1], 'timed out')
143139

144140
if __name__ == '__main__':
145141
unittest.main()

0 commit comments

Comments
 (0)