diff --git a/README.rst b/README.rst index 1bac128..b59a008 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,10 @@ HPE LeftHand/StoreVirtual REST Client =================== This is a Client library that can talk to the HPE LeftHand/StoreVirtual Storage array. -The HPE LeftHand storage array has a REST web service interface. +The HPE LeftHand storage array has a REST web service interface as well as runs SSH. This client library implements a simple interface to talk with that REST -interface using the python Requests http library. +interface using the python Requests http library and communicates via SSH using +Pytohn's paramiko library. This is the new location for the rebranded HP LeftHand/StoreVirtual REST Client and will be where all future releases are made. It was previously located on PyPi at: @@ -94,4 +95,10 @@ Running Simulators Manually run flask server (when config.ini unit=true):: +* WSAPI:: + $ python test/HPELeftHandMockServer_flask.py -port 5001 -user -password -debug + +* SSH:: + + $ python test/HPELeftHandMockServer_ssh.py [port] diff --git a/docs/changelog.rst b/docs/changelog.rst index 9edce1d..0376d3e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -95,3 +95,4 @@ Changes in Version 2.0.1 * Changes the exception isinstance check to look for basestring/str instead of bytes in order to properly store the error description. * Adds a new API for modifying snapshots. +* Adds SSH support diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 5427bd9..0e485cd 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -41,6 +41,9 @@ Doing so is easy: # cl = client.HPELeftHandClient("https://10.10.10.10:8081/lhos", # secure='/etc/ssl/certs/ca-certificates.crt') + # Set the SSH authentication options for the SSH based calls. + cl.setSSHOptions(ip_address, username, password) + try: cl.login(username, password) print "Login worked!" diff --git a/hpelefthandclient/client.py b/hpelefthandclient/client.py index 6897371..65d3330 100644 --- a/hpelefthandclient/client.py +++ b/hpelefthandclient/client.py @@ -22,7 +22,8 @@ HPELeftHand REST Client :Author: Kurt Martin :Description: This is the LeftHand/StoreVirtual Client that talks to the -LeftHand OS REST Service. +LeftHand OS REST Service. This version also supports running actions on the +LeftHand that use SSH. This client requires and works with version 11.5 of the LeftHand firmware @@ -35,7 +36,7 @@ except ImportError: # Fall back to Python 2's urllib2 from urllib2 import quote -from hpelefthandclient import exceptions, http +from hpelefthandclient import exceptions, http, ssh class HPELeftHandClient(object): @@ -47,9 +48,47 @@ class HPELeftHandClient(object): self.api_url = api_url self.http = http.HTTPJSONRESTClient(self.api_url, secure=secure) self.api_version = None + self.ssh = None self.debug_rest(debug) + def setSSHOptions(self, ip, login, password, port=16022, + conn_timeout=None, privatekey=None, + **kwargs): + """Set SSH Options for ssh calls. + + This is used to set the SSH credentials for calls + that use SSH instead of REST HTTP. + + :param ip: The IP address of the LeftHand array + :type ip: str + :param login: Username to log into SSH + :type login: str + :param password: Password to log into SSH + :type password: str + :param port: Port the SSH service is running on. The default port + is 16022 + :type port: int + :param conn_timeout: The connection timeout in seconds. Default is no + connection timeout. + :type conn_timeout: int + :param privatekey: File location of SSH private key. Default does not + use a private key. + :type privatekey: int + + """ + self.ssh = ssh.HPELeftHandSSHClient(ip, login, password, port, + conn_timeout, privatekey, + **kwargs) + + def _run(self, cmd): + if self.ssh is None: + raise exceptions.SSHException('SSH is not initialized. Initialize' + ' it by calling "setSSHOptions".') + else: + self.ssh.open() + return self.ssh.run(cmd) + def debug_rest(self, flag): """ This is useful for debugging requests to LeftHand @@ -59,6 +98,8 @@ class HPELeftHandClient(object): """ self.http.set_debug_flag(flag) + if self.ssh: + self.ssh.set_debug_flag(flag) def login(self, username, password): """ @@ -98,6 +139,8 @@ class HPELeftHandClient(object): """ self.http.unauthenticate() + if self.ssh: + self.ssh.close() def getApiVersion(self): """ diff --git a/hpelefthandclient/exceptions.py b/hpelefthandclient/exceptions.py index 7a25528..de87853 100644 --- a/hpelefthandclient/exceptions.py +++ b/hpelefthandclient/exceptions.py @@ -21,9 +21,13 @@ Exceptions for the client :Author: Walter A. Boring IV :Description: This contains the HTTP exceptions that can come back from -the REST calls +the REST/SSH calls """ +import logging + +LOG = logging.getLogger(__name__) + # Python 3+ override try: basestring @@ -413,3 +417,39 @@ def from_response(response, body): """ cls = _code_map.get(response.status, ClientException) return cls(body) + + +class SSHException(Exception): + """This is the basis for the SSH Exceptions.""" + + code = 500 + message = "An unknown exception occurred." + + def __init__(self, message=None, **kwargs): + self.kwargs = kwargs + + if 'code' not in self.kwargs: + try: + self.kwargs['code'] = self.code + except AttributeError: + pass + + if not message: + try: + message = self.message % kwargs + + except Exception: + # kwargs doesn't match a variable in the message + # log the issue and the kwargs + LOG.exception('Exception in string format operation') + for name, value in list(kwargs.items()): + LOG.error("%s: %s" % (name, value)) + # at least get the core message out if something happened + message = self.message + + self.msg = message + super(SSHException, self).__init__(message) + + +class SSHInjectionThreat(SSHException): + message = "SSH command injection detected: %(command)s" diff --git a/hpelefthandclient/ssh.py b/hpelefthandclient/ssh.py new file mode 100644 index 0000000..211df01 --- /dev/null +++ b/hpelefthandclient/ssh.py @@ -0,0 +1,294 @@ +# (c) Copyright 2015 Hewlett Packard Enterprise Development LP +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +""" +HPE LeftHand SSH Client + +.. module: ssh + +:Author: Walter A. Boring IV +:Description: This is the SSH Client that is used to make calls to + the LeftHand where an existing REST API doesn't exist. + +""" + +import logging +import os +import paramiko +from random import randint +import re + +from eventlet import greenthread +from hpelefthandclient import exceptions + +# Python 3+ override +try: + basestring + python3 = False +except NameError: + basestring = str + python3 = True + + +class HPELeftHandSSHClient(object): + """This class is used to execute SSH commands on a LeftHand.""" + + log_debug = False + _logger = logging.getLogger(__name__) + _logger.setLevel(logging.INFO) + + def __init__(self, ip, login, password, + port=16022, conn_timeout=None, privatekey=None, + **kwargs): + self.san_ip = ip + self.san_ssh_port = port + self.ssh_conn_timeout = conn_timeout + self.san_login = login + self.san_password = password + self.san_private_key = privatekey + + self._create_ssh(**kwargs) + + def _create_ssh(self, **kwargs): + try: + ssh = paramiko.SSHClient() + + known_hosts_file = kwargs.get('known_hosts_file', None) + if known_hosts_file is None: + ssh.load_system_host_keys() + else: + # Make sure we can open the file for appending first. + # This is needed to create the file when we run CI tests with + # no existing key file. + open(known_hosts_file, 'a').close() + ssh.load_host_keys(known_hosts_file) + + missing_key_policy = kwargs.get('missing_key_policy', None) + if missing_key_policy is None: + missing_key_policy = paramiko.AutoAddPolicy() + elif isinstance(missing_key_policy, basestring): + # To make it configurable, allow string to be mapped to object. + if missing_key_policy == paramiko.AutoAddPolicy().__class__.\ + __name__: + missing_key_policy = paramiko.AutoAddPolicy() + elif missing_key_policy == paramiko.RejectPolicy().__class__.\ + __name__: + missing_key_policy = paramiko.RejectPolicy() + elif missing_key_policy == paramiko.WarningPolicy().__class__.\ + __name__: + missing_key_policy = paramiko.WarningPolicy() + else: + raise exceptions.SSHException( + "Invalid missing_key_policy: %s" % missing_key_policy + ) + + ssh.set_missing_host_key_policy(missing_key_policy) + + self.ssh = ssh + except Exception as e: + msg = "Error connecting via ssh: %s" % e + self._logger.error(msg) + raise paramiko.SSHException(msg) + + def _connect(self, ssh): + if self.san_password: + ssh.connect(self.san_ip, + port=self.san_ssh_port, + username=self.san_login, + password=self.san_password, + timeout=self.ssh_conn_timeout) + elif self.san_privatekey: + pkfile = os.path.expanduser(self.san_privatekey) + privatekey = paramiko.RSAKey.from_private_key_file(pkfile) + ssh.connect(self.san_ip, + port=self.san_ssh_port, + username=self.san_login, + pkey=privatekey, + timeout=self.ssh_conn_timeout) + else: + msg = "Specify a password or private_key" + raise exceptions.SSHException(msg) + + def open(self): + """Opens a new SSH connection if the transport layer is missing. + + This can be called if an active SSH connection is open already. + + """ + # Create a new SSH connection if the transport layer is missing. + if self.ssh: + transport_active = False + if self.ssh.get_transport(): + transport_active = self.ssh.get_transport().is_active() + if not transport_active: + try: + self._connect(self.ssh) + except Exception as e: + msg = "Error connecting via ssh: %s" % e + self._logger.error(msg) + raise paramiko.SSHException(msg) + + def close(self): + if self.ssh: + self.ssh.close() + + def set_debug_flag(self, flag): + """ + This turns on ssh debugging output to console + + :param flag: Set to True to enable debugging output + :type flag: bool + + """ + if not HPELeftHandSSHClient.log_debug and flag: + ch = logging.StreamHandler() + self._logger.setLevel(logging.DEBUG) + self._logger.addHandler(ch) + HPELeftHandSSHClient.log_debug = True + + def run(self, cmd): + """Runs a CLI command over SSH, without doing any result parsing.""" + self._logger.debug("SSH CMD = %s " % cmd) + + (stdout, stderr) = self._run_ssh(cmd, False) + # we have to strip out the input and exit lines + if python3: + tmp = stdout.decode().split("\r\n") + else: + tmp = stdout.split("\r\n") + + out = tmp[8:len(tmp) - 3] + self._logger.debug("OUT = %s" % out) + return out + + def _ssh_execute(self, cmd, check_exit_code=True): + """Executes the command via SSH.""" + self._logger.debug('Running cmd (SSH): %s', cmd) + + channel = self.ssh.invoke_shell() + stdin_stream = channel.makefile('wb') + stdout_stream = channel.makefile('rb') + stderr_stream = channel.makefile('rb') + + stdin_stream.write('''%s +exit +''' % cmd) + + # stdin.write('process_input would go here') + # stdin.flush() + + # NOTE(justinsb): This seems suspicious... + # ...other SSH clients have buffering issues with this approach + stdout = stdout_stream.read() + stderr = stderr_stream.read() + stdin_stream.close() + stdout_stream.close() + stderr_stream.close() + + exit_status = channel.recv_exit_status() + + # exit_status == -1 if no exit code was returned + if exit_status != -1: + self._logger.debug('Result was %s' % exit_status) + if check_exit_code and exit_status != 0: + msg = "command %s failed" % cmd + self._logger.error(msg) + raise exceptions.ProcessExecutionError(exit_code=exit_status, + stdout=stdout, + stderr=stderr, + cmd=cmd) + channel.close() + return (stdout, stderr) + + def _run_ssh(self, cmd_list, check_exit=True, attempts=2): + self.check_ssh_injection(cmd_list) + command = ' '. join(cmd_list) + + try: + total_attempts = attempts + while attempts > 0: + attempts -= 1 + try: + return self._ssh_execute(command, + check_exit_code=check_exit) + except Exception as e: + self._logger.error(e) + if attempts > 0: + greenthread.sleep(randint(20, 500) / 100.0) + if not self.ssh.get_transport().is_alive(): + self._create_ssh() + + msg = ("SSH Command failed after '%(total_attempts)r' " + "attempts : '%(command)s'" % + {'total_attempts': total_attempts, 'command': command}) + self._logger.error(msg) + raise exceptions.SSHException(message=msg) + except Exception: + self._logger.error("Error running ssh command: %s" % command) + raise + + def check_ssh_injection(self, cmd_list): + ssh_injection_pattern = ['`', '$', '|', '||', ';', '&', '&&', + '>', '>>', '<'] + + # Check whether injection attacks exist + for arg in cmd_list: + arg = arg.strip() + + # Check for matching quotes on the ends + is_quoted = re.match('^(?P[\'"])(?P.*)(?P=quote)$', + arg) + if is_quoted: + # Check for unescaped quotes within the quoted argument + quoted = is_quoted.group('quoted') + if quoted: + if (re.match('[\'"]', quoted) or + re.search('[^\\\\][\'"]', quoted)): + raise exceptions.SSHInjectionThreat( + command=str(cmd_list)) + else: + # We only allow spaces within quoted arguments, and that + # is the only special character allowed within quotes + if len(arg.split()) > 1: + raise exceptions.SSHInjectionThreat(command=str(cmd_list)) + + # Second, check whether danger character in command. So the shell + # special operator must be a single argument. + for c in ssh_injection_pattern: + if arg == c: + continue + + result = arg.find(c) + if not result == -1: + if result == 0 or not arg[result - 1] == '\\': + raise exceptions.SSHInjectionThreat(command=cmd_list) + + def was_command_successful(self, output): + """ + Given the entire output of an SSH command, this will check to see if + the result returned is 0, aka it was successful. If result is not 0, + the command failed and we return False. + + :param output: The output string of an SSH command + :type output: str + + :returns: True if the command was successful + """ + output_string = ''.join(output) + match = re.match('.*result\s+0', output_string) + if not match: + raise exceptions.SSHException( + "The command did not execute successfully.") + + return True diff --git a/requirements-py3.txt b/requirements-py3.txt index f229360..6a2af55 100644 --- a/requirements-py3.txt +++ b/requirements-py3.txt @@ -1 +1,3 @@ +eventlet +paramiko>=1.13.0 requests diff --git a/requirements.txt b/requirements.txt index f229360..6a2af55 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ +eventlet +paramiko>=1.13.0 requests diff --git a/setup.py b/setup.py index 6a894d8..59cd26d 100644 --- a/setup.py +++ b/setup.py @@ -15,9 +15,9 @@ setup( author_email="kurt.f.martin@hpe.com", maintainer="Kurt Martin", keywords=["hpe", "lefthand", "storevirtual", "rest"], - requires=['requests'], - install_requires=['requests'], - tests_require=["nose", "nose-testconfig", "flask", "Werkzeug", "flake8"], + requires=['paramiko', 'eventlet', 'requests'], + install_requires=['paramiko', 'eventlet', 'requests'], + tests_require=["mock", "nose", "nose-testconfig", "flask", "Werkzeug", "flake8"], license="Apache License, Version 2.0", packages=find_packages(), provides=['hplefthandclient'], diff --git a/test-requirements-py3.txt b/test-requirements-py3.txt index 12f7bc9..4efe216 100644 --- a/test-requirements-py3.txt +++ b/test-requirements-py3.txt @@ -2,7 +2,7 @@ nose nose-testconfig flask Werkzeug - +mock flake8 # Used by tox coverage sphinx diff --git a/test-requirements.txt b/test-requirements.txt index 12f7bc9..4efe216 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -2,7 +2,7 @@ nose nose-testconfig flask Werkzeug - +mock flake8 # Used by tox coverage sphinx diff --git a/test/HPELeftHandMockServer_ssh.py b/test/HPELeftHandMockServer_ssh.py new file mode 100755 index 0000000..a0732a1 --- /dev/null +++ b/test/HPELeftHandMockServer_ssh.py @@ -0,0 +1,183 @@ +# (c) Copyright 2015 Hewlett Packard Enterprise Development LP +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +""" Test SSH server.""" + +import argparse +import logging +import os +import shlex +import socket +import sys +import threading + +import paramiko + + +paramiko.util.log_to_file('paramiko_server.log') + + +class CliParseException(Exception): + pass + + +class CliArgumentParser(argparse.ArgumentParser): + + def error(self, message): + usage = super(CliArgumentParser, self).format_help() + full_message = "%s\r\n%s" % (message, usage) + raise CliParseException(full_message) + + def parse_args(self, *args): + return super(CliArgumentParser, self).parse_args(args[1:]) + + +class Cli(object): + + def __init__(self): + self.log_name = 'paramiko.LeftHandCLI' + self.logger = paramiko.util.get_logger(self.log_name) + + self.fpgs = {} + self.vfss = {} + + def do_cli_other(self, *args): + msg = 'FAIL! Mock SSH CLI does not know how to "%s".' % ' '.join(args) + self.logger.log(logging.ERROR, msg) + return msg + + def do_cli_exit(self, *args): + self.logger.log(logging.INFO, "quiting... g'bye") + return '' + + def do_cli_quit(self, *args): + self.logger.log(logging.INFO, "quiting... g'bye") + return '' + + def process_command(self, cmd): + self.logger.log(logging.INFO, cmd) + if cmd is None: + print("returnNone") + return '' + args = shlex.split(cmd) + if args: + method = getattr(self, 'do_cli_' + args[0], self.do_cli_other) + try: + return method(*args) + except Exception as cmd_exception: + return str(cmd_exception) + else: + return '' + + +class ParamikoServer(paramiko.ServerInterface): + + def __init__(self): + self.event = threading.Event() + + def check_channel_request(self, kind, chanid): + return paramiko.OPEN_SUCCEEDED + + def check_auth_none(self, username): + return paramiko.AUTH_SUCCESSFUL + + def check_auth_password(self, username, password): + return paramiko.AUTH_SUCCESSFUL + + def check_auth_publickey(self, username, key): + return paramiko.AUTH_SUCCESSFUL + + def get_allowed_auths(self, username): + return 'password,publickey,none' + + def check_channel_shell_request(self, c): + self.event.set() + return True + + def check_channel_pty_request(self, c, term, width, height, pixelwidth, + pixelheight, modes): + return True + + +if __name__ == "__main__": + + if len(sys.argv) > 1: + port = int(sys.argv[1]) + else: + port = 2200 + + key_file = os.path.expanduser('~/.ssh/id_rsa') + host_key = paramiko.RSAKey(filename=key_file) + + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(('', int(port))) + s.listen(60) + print("Listening for SSH client connections...") + connection, address = s.accept() + transport = None + channel = None + try: + transport = paramiko.Transport(connection) + transport.load_server_moduli() + transport.add_server_key(host_key) + server = ParamikoServer() + transport.start_server(server=server) + + cliProcessor = Cli() + + while True: + channel = transport.accept(60) + if channel is None: + print("Failed to get SSH channel.") + sys.exit(1) + + print("Connected") + server.event.wait(10) + + if not server.event.isSet(): + print("No shell set") + sys.exit(1) + + fio = channel.makefile('rU') + commands = [] + command = None + while not (command == 'exit' or command == 'quit'): + command = fio.readline().strip('\r\n') + commands.append(command) + + to_send = '\r\n'.join(commands) + channel.send(to_send) + + output = [''] + prompt = "FAKE-LeftHand-CLI cli% " + for cmd in commands: + output.append('%s%s' % (prompt, cmd)) + result = cliProcessor.process_command(cmd) + if result is not None: + output.append(result) + output_to_send = '\r\n'.join(output) + channel.send(output_to_send) + channel.close() + print("Disconnected") + + finally: + if channel: + channel.close() + if transport: + try: + transport.close() + print("transport closed") + except Exception as e: + print("transport close exception %s" % e) + pass diff --git a/test/test_HPELeftHandClient_MockSSH.py b/test/test_HPELeftHandClient_MockSSH.py new file mode 100644 index 0000000..c7a0df5 --- /dev/null +++ b/test/test_HPELeftHandClient_MockSSH.py @@ -0,0 +1,212 @@ +# (c) Copyright 2015 Hewlett Packard Enterprise Development LP +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import paramiko +import unittest + +import test_HPELeftHandClient_base +from hpelefthandclient import exceptions +from hpelefthandclient import ssh + +# Python 3+ override +try: + basestring +except NameError: + basestring = str + +user = "u" +password = "p" +ip = "10.10.22.241" +api_url = "http://10.10.22.241:8008/api/v1" + + +class HPELeftHandClientMockSSHTestCase(test_HPELeftHandClient_base + .HPELeftHandClientBaseTestCase): + + def mock_paramiko(self, known_hosts_file, missing_key_policy): + """Verify that these params get into paramiko.""" + + mock_lhk = mock.Mock() + mock_lshk = mock.Mock() + mock_smhkp = mock.Mock() + mock_smhkp.side_effect = Exception("Let's end this here") + + with mock.patch('paramiko.client.SSHClient.load_system_host_keys', + mock_lshk, create=True): + with mock.patch('paramiko.client.SSHClient.load_host_keys', + mock_lhk, create=True): + with mock.patch('paramiko.client.SSHClient.' + 'set_missing_host_key_policy', + mock_smhkp, create=True): + try: + self.cl.setSSHOptions( + ip, user, password, + known_hosts_file=known_hosts_file, + missing_key_policy=missing_key_policy) + except paramiko.SSHException as e: + if 'Invalid missing_key_policy' in str(e): + raise e + except Exception: + pass + + if known_hosts_file is None: + mock_lhk.assert_not_called() + mock_lshk.assert_called_with() + else: + mock_lhk.assert_called_with(known_hosts_file) + mock_lshk.assert_not_called() + + actual = mock_smhkp.call_args[0][0].__class__.__name__ + if missing_key_policy is None: + # If missing, it should be called with our + # default which is an AutoAddPolicy + expected = paramiko.AutoAddPolicy().__class__.__name__ + elif isinstance(missing_key_policy, basestring): + expected = missing_key_policy + else: + expected = missing_key_policy.__class__.__name__ + self.assertEqual(actual, expected) + + def do_mock_create_ssh(self, known_hosts_file, missing_key_policy): + """Verify that params are getting forwarded to _create_ssh().""" + + mock_ssh = mock.Mock() + path = 'hpelefthandclient.ssh.HPELeftHandSSHClient._create_ssh' + with mock.patch(path, mock_ssh, create=True): + + self.cl.setSSHOptions(ip, user, password, + known_hosts_file=known_hosts_file, + missing_key_policy=missing_key_policy) + + mock_ssh.assert_called_with(missing_key_policy=missing_key_policy, + known_hosts_file=known_hosts_file) + + # Create a mocked ssh object for the client so that it can be + # "closed" during a logout. + self.cl.ssh = mock.MagicMock() + + @mock.patch('hpelefthandclient.ssh.HPELeftHandSSHClient') + def do_mock_ssh(self, known_hosts_file, missing_key_policy, + mock_ssh_client): + """Verify that params are getting forwarded to HPELeftHandSSHClient.""" + + self.cl.setSSHOptions(ip, user, password, + known_hosts_file=known_hosts_file, + missing_key_policy=missing_key_policy) + + mock_ssh_client.assert_called_with( + ip, user, password, 16022, None, None, + missing_key_policy=missing_key_policy, + known_hosts_file=known_hosts_file) + + def base(self, known_hosts_file, missing_key_policy): + self.printHeader("%s : known_hosts_file=%s missing_key_policy=%s" % + (unittest.TestCase.id(self), + known_hosts_file, missing_key_policy)) + self.do_mock_ssh(known_hosts_file, missing_key_policy) + self.do_mock_create_ssh(known_hosts_file, missing_key_policy) + self.mock_paramiko(known_hosts_file, missing_key_policy) + self.printFooter(unittest.TestCase.id(self)) + + def test_auto_add_policy(self): + known_hosts_file = "test_bogus_known_hosts_file" + missing_key_policy = "AutoAddPolicy" + self.base(known_hosts_file, missing_key_policy) + + def test_warning_policy(self): + known_hosts_file = "test_bogus_known_hosts_file" + missing_key_policy = "WarningPolicy" + self.base(known_hosts_file, missing_key_policy) + + def test_reject_policy(self): + known_hosts_file = "test_bogus_known_hosts_file" + missing_key_policy = "RejectPolicy" + self.base(known_hosts_file, missing_key_policy) + + def test_known_hosts_file_is_none(self): + known_hosts_file = None + missing_key_policy = paramiko.RejectPolicy() + self.base(known_hosts_file, missing_key_policy) + + def test_both_settings_are_none(self): + known_hosts_file = None + missing_key_policy = None + self.base(known_hosts_file, missing_key_policy) + + def test_bogus_missing_key_policy(self): + known_hosts_file = None + missing_key_policy = "bogus" + self.assertRaises(paramiko.SSHException, + self.base, + known_hosts_file, + missing_key_policy) + + def test_create_ssh_except(self): + """Make sure that SSH exceptions are not quietly eaten.""" + + self.cl.setSSHOptions(ip, + user, + password, + known_hosts_file=None, + missing_key_policy=paramiko.AutoAddPolicy) + + self.cl.ssh.ssh = mock.Mock() + self.cl.ssh.ssh.invoke_shell.side_effect = Exception('boom') + + cmd = ['fake'] + self.assertRaises(exceptions.SSHException, self.cl.ssh._run_ssh, cmd) + + self.cl.ssh.ssh.assert_has_calls( + [ + mock.call.get_transport(), + mock.call.get_transport().is_alive(), + mock.call.invoke_shell(), + mock.call.get_transport(), + mock.call.get_transport().is_alive(), + ] + ) + + def test_was_command_successful_true(self): + cmd_out = ['', + 'HP StoreVirtual LeftHand OS Command Line Interface', + '(C) Copyright 2007-2013', + '', + 'RESPONSE', + ' result 0'] + ssh_client = ssh.HPELeftHandSSHClient('foo', 'bar', 'biz') + out = ssh_client.was_command_successful(cmd_out) + self.assertTrue(out) + + def test_was_command_successful_false(self): + # Create our fake SSH client + ssh_client = ssh.HPELeftHandSSHClient('foo', 'bar', 'biz') + + # Test invalid command output + cmd_out = ['invalid'] + self.assertRaises(exceptions.SSHException, + ssh_client.was_command_successful, + cmd_out) + + # Test valid command output, but command failed + cmd_out = ['', + 'HP StoreVirtual LeftHand OS Command Line Interface', + '(C) Copyright 2007-2013', + '', + 'RESPONSE', + ' result 8080878378'] + self.assertRaises(exceptions.SSHException, + ssh_client.was_command_successful, + cmd_out) diff --git a/test/test_HPELeftHandClient_base.py b/test/test_HPELeftHandClient_base.py index 7f6ed18..fbd37d4 100644 --- a/test/test_HPELeftHandClient_base.py +++ b/test/test_HPELeftHandClient_base.py @@ -52,7 +52,30 @@ class HPELeftHandClientBaseTestCase(unittest.TestCase): unitTest = config['TEST']['unit'].lower() == 'true' startFlask = config['TEST']['start_flask_server'].lower() == 'true' - def setUp(self): + ssh_port = None + if 'ssh_port' in config['TEST']: + ssh_port = int(config['TEST']['ssh_port']) + elif unitTest: + ssh_port = 2200 + else: + ssh_port = 16022 + + # Don't setup SSH unless needed. It slows things down. + withSSH = False + + if 'known_hosts_file' in config['TEST']: + known_hosts_file = config['TEST']['known_hosts_file'] + else: + known_hosts_file = None + + if 'missing_key_policy' in config['TEST']: + missing_key_policy = config['TEST']['missing_key_policy'] + else: + missing_key_policy = None + + def setUp(self, withSSH=False): + + self.withSSH = withSSH cwd = os.path.dirname(os.path.abspath( inspect.getfile(inspect.currentframe()))) @@ -82,10 +105,50 @@ class HPELeftHandClientBaseTestCase(unittest.TestCase): except Exception: pass time.sleep(1) + + if self.withSSH: + self.printHeader('Using paramiko SSH server on port %s' % + self.ssh_port) + + ssh_script = 'HPELeftHandMockServer_ssh.py' + ssh_path = "%s/%s" % (cwd, ssh_script) + + self.mockSshServer = subprocess.Popen([sys.executable, + ssh_path, + str(self.ssh_port)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.PIPE) + time.sleep(1) else: self.printHeader('Using LeftHand ' + self.url_lhos) self.cl = client.HPELeftHandClient(self.url_lhos) + if self.withSSH: + # This seems to slow down the test cases, so only use this when + # requested + if self.unitTest: + # The mock SSH server can be accessed at 0.0.0.0. + ip = '0.0.0.0' + else: + parsed_lh_url = urlparse(self.url_lhos) + ip = parsed_lh_url.hostname.split(':').pop() + try: + # Now that we don't do keep-alive, the conn_timeout needs to + # be set high enough to avoid sometimes slow response in + # the File Persona tests. + self.cl.setSSHOptions( + ip, + self.user, + self.password, + port=self.ssh_port, + conn_timeout=500, + known_hosts_file=self.known_hosts_file, + missing_key_policy=self.missing_key_policy) + except Exception as ex: + print(ex) + self.fail("failed to start ssh client") + if self.debug: self.cl.debug_rest(True) @@ -97,6 +160,8 @@ class HPELeftHandClientBaseTestCase(unittest.TestCase): #TODO: it seems to kill all the process except the last one... #don't know why self.mockServer.kill() + if self.withSSH: + self.mockSshServer.kill() def printHeader(self, name): print("\n##Start testing '%s'" % name) diff --git a/test/test_HPELeftHandClient_volume.py b/test/test_HPELeftHandClient_volume.py index 308a02c..876cd1b 100644 --- a/test/test_HPELeftHandClient_volume.py +++ b/test/test_HPELeftHandClient_volume.py @@ -14,6 +14,8 @@ """Test class of LeftHand Client handling volumes & snapshots """ +from testconfig import config + import test_HPELeftHandClient_base from hpelefthandclient import exceptions @@ -25,6 +27,10 @@ SNAP_NAME1 = 'SNAP_UNIT_TEST1_' + test_HPELeftHandClient_base.TIME SNAP_NAME2 = 'SNAP_UNIT_TEST2_' + test_HPELeftHandClient_base.TIME +def is_live_test(): + return config['TEST']['unit'].lower() == 'false' + + class HPELeftHandClientVolumeTestCase(test_HPELeftHandClient_base. HPELeftHandClientBaseTestCase): @@ -32,7 +38,10 @@ class HPELeftHandClientVolumeTestCase(test_HPELeftHandClient_base. MIN_CG_API_VERSION = '1.2' def setUp(self): - super(HPELeftHandClientVolumeTestCase, self).setUp() + ssh = False + if is_live_test(): + ssh = True + super(HPELeftHandClientVolumeTestCase, self).setUp(withSSH=ssh) try: cluster_info = self.cl.getClusterByName(