Repository: ManageIQ/integration_tests

Issue:
Be able to run rake tasks on appliances
For distributed testing, we'll need to be able to change an appliance's region. This is traditionally done via the "black console", which wraps a rake task:
"rake evm:db:region -- --region (region_number)". Additionally, appliances will soon start shipping with databases disabled; enabling the DB may also be a rake task.

We've already got SSH functionality via paramiko, so this can probably a pretty thin wrapper around that, targeted at running rake tasks.



Discussion:

Resolving patch:
diff --git a/fixtures/ssh_client.py b/fixtures/ssh_client.py
new file mode 100644
--- /dev/null
+++ b/fixtures/ssh_client.py
@@ -0,0 +1,59 @@
+from urlparse import urlparse
+
+import pytest
+
+from utils.ssh import SSHClient
+
+@pytest.fixture
+def ssh_client(mozwebqa):
+    """SSH Client Fixture
+
+    By default, it will connect to the host named in the mozwebqa baseurl on use,
+    using the 'ssh' credentials in your credentials file. The mozwebqa fixture is used
+    for this, which will open up a selenium browser by default. If this is undesired,
+    remember to use the skip_selenium mark.
+
+    Examples:
+
+        @pytest.mark.skip_selenium
+        def test_ssh(ssh_client):
+            # Run a basic command
+            exit_status, output = ssh_client.run_command('ls -al')
+            # Exit status is the numeric return code from the called command,
+            # so 0 means everything is OK.
+            assert exit_status == 0
+
+            # Run a task using the CFME rails runner CLI
+            exit_status, output = ssh_client.run_rails_command('do stuff')
+
+            # More useful: Run a rake task using the correct invokation
+            exit_status, output = ssh_client.run_rake_command('evm:stop')
+
+    Additionally, the ssh_client fixture can be used to create other ssh clients,
+    if you need to connect to multiple hosts in a test run.
+
+        def test_multiple_ssh(ssh_client):
+            # Normal behavior still works
+            ssh_client.run_command('some_command')
+
+            # Instantiate a client aimed at a different hostname
+            ssh_client_2 = ssh_client(hostname='different.host')
+            ssh_client_2.run_command('some_other_command')
+
+            # Username and password can be changed, too
+            ssh_client_3 = ssh_client(username='foo', password='bar')
+
+            # Hint: **credentials['credentials_key'], e.g.
+            ssh_client_4 = ssh_client(hostname='different.host', **credentials['ssh'])
+
+
+    """
+
+    ssh_credentials = mozwebqa.credentials['ssh']
+    parsed_url = urlparse(mozwebqa.base_url)
+    connect_kwargs = {
+        'username': ssh_credentials['username'],
+        'password': ssh_credentials['password'],
+        'hostname': parsed_url.hostname,
+    }
+    return SSHClient(**connect_kwargs)
diff --git a/utils/ssh.py b/utils/ssh.py
--- a/utils/ssh.py
+++ b/utils/ssh.py
@@ -1,8 +1,67 @@
 import paramiko
 
-def ssh_client(self, hostname, user, pwd):
-    """ Create an ssh client that auto-accepts host heys"""
-    client = paramiko.SSHClient()
-    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
-    client.connect(hostname, username=user, password=pwd, allow_agent=False)
-    return client
+class SSHClient(paramiko.SSHClient):
+    """paramiko.SSHClient wrapper
+
+    Allows copying/overriding and use as a context manager
+    Constructor kwargs are handed directly to paramiko.SSHClient.connect()
+    """
+    def __init__(self, **connect_kwargs):
+        super(SSHClient, self).__init__()
+        self.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+
+        # Set up some sane defaults
+        if 'timeout' not in connect_kwargs:
+            connect_kwargs['timeout'] = 10
+        if 'allow_agent' not in connect_kwargs:
+            connect_kwargs['allow_agent'] = False
+        self._connect_kwargs = connect_kwargs
+
+    def __call__(self, **connect_kwargs):
+        # Update a copy of this instance's connect kwargs with passed in kwargs,
+        # then return a new instance with the updated kwargs
+        new_connect_kwargs = dict(self._connect_kwargs)
+        new_connect_kwargs.update(connect_kwargs)
+        new_client = SSHClient(**new_connect_kwargs)
+        return new_client
+
+    def __enter__(self):
+        self.connect(**self._connect_kwargs)
+        return self
+
+    def __exit__(self, *args, **kwargs):
+        self.close()
+
+    def run_command(self, command):
+        return command_runner(self, command)
+
+    def run_rails_command(self, command):
+        return rails_runner(self, command)
+
+    def run_rake_command(self, command):
+        return rake_runner(self, command)
+
+
+def command_runner(client, command):
+    template = '%s\n'
+    command = template % command
+    with client as ctx:
+        transport = ctx.get_transport()
+        session = transport.open_session()
+        session.set_combine_stderr(True)
+        session.exec_command(command)
+        exit_status = session.recv_exit_status()
+        output = session.recv(-1)
+        return exit_status, output
+
+    # Returning two things so tuple unpacking the return works even if the ssh client fails
+    return None, None
+
+def rails_runner(client, command):
+    template = '/var/www/miq/vmdb/script/rails runner %s'
+    return command_runner(client, template % command)
+
+def rake_runner(client, command):
+    template = '/var/www/miq/vmdb/script/rake -f /var/www/miq/vmdb/Rakefile %s'
+    return rails_runner(client, template % command)
+


Test patch:
diff --git a/utils/tests/test_ssh_client.py b/utils/tests/test_ssh_client.py
new file mode 100644
--- /dev/null
+++ b/utils/tests/test_ssh_client.py
@@ -0,0 +1,38 @@
+import pytest
+from unittestzero import Assert
+
+from utils.randomness import generate_random_string
+
+pytestmark = [
+    pytest.mark.nondestructive,
+    pytest.mark.skip_selenium,
+]
+
+def test_ssh_client_run_command(ssh_client):
+    # Make sure the ssh command runner works
+    exit_status, output = ssh_client.run_command('echo Testing!')
+    Assert.equal(exit_status, 0)
+    Assert.contains(output, 'Testing!')
+
+def test_ssh_client_copies(ssh_client):
+    ssh_client_kwargs = {
+        'username': generate_random_string(),
+        'password': generate_random_string(),
+        'hostname': generate_random_string(),
+    }
+
+    # Make sure the ssh copy mechanism works
+    ssh_client_copy = ssh_client(**ssh_client_kwargs)
+    orig_kwargs = ssh_client._connect_kwargs
+    copy_kwargs = ssh_client_copy._connect_kwargs
+    Assert.not_equal(orig_kwargs['username'], copy_kwargs['username'])
+    Assert.not_equal(orig_kwargs['password'], copy_kwargs['password'])
+    Assert.not_equal(orig_kwargs['hostname'], copy_kwargs['hostname'])
+
+    # And also make sure the ssh copy only updates new kwargs
+    ssh_client_copy = ssh_client(hostname=generate_random_string())
+    copy_kwargs = ssh_client_copy._connect_kwargs
+    Assert.equal(orig_kwargs['username'], copy_kwargs['username'])
+    Assert.equal(orig_kwargs['password'], copy_kwargs['password'])
+    Assert.not_equal(orig_kwargs['hostname'], copy_kwargs['hostname'])
+