diff --git a/.testr.conf b/.testr.conf index db26af9..68a05a5 100644 --- a/.testr.conf +++ b/.testr.conf @@ -1,4 +1,4 @@ [DEFAULT] -test_command=OS_STDOUT_CAPTURE=1 OS_STDERR_CAPTURE=1 ${PYTHON:-python} -m subunit.run discover -t ./ ./refstack_client/tests/unit $LISTOPT $IDOPTION +test_command=OS_STDOUT_CAPTURE=1 OS_STDERR_CAPTURE=1 coverage run -m subunit.run discover -t ./ -s ./refstack_client/tests/unit $LISTOPT $IDOPTION test_id_option=--load-list $IDFILE test_list_option=--list diff --git a/README.rst b/README.rst index ab18c51..afbdfa9 100644 --- a/README.rst +++ b/README.rst @@ -68,6 +68,14 @@ We've created an "easy button" for Ubuntu, Centos, RHEL and openSuSe. 'upload' command, you can also override the Refstack API server uploaded to with the --url option. + **Note:** + + a. Adding -i option will upload test result with + digital signature. For signing refstack-client uses private RSA key. + OpenSSH format of rsa keys supported, so you can just use your ssh key + '~/.ssh/id-rsa' or generate a new one with 'ssh-keygen -b 4096'. + For now, signed test results can be considereded as private. + **Tempest Hacking** By default, refstack-client installs Tempest into the `.tempest` directory. diff --git a/refstack_client/refstack_client.py b/refstack_client/refstack_client.py index 11caa3c..65dc6d6 100755 --- a/refstack_client/refstack_client.py +++ b/refstack_client/refstack_client.py @@ -25,6 +25,7 @@ Tempest configuration file. """ import argparse +import binascii import ConfigParser import json import logging @@ -33,8 +34,10 @@ import requests import subprocess import time +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import PKCS1_v1_5 from keystoneclient.v2_0 import client as ksclient - from subunit_processor import SubunitProcessor @@ -163,20 +166,36 @@ class RefstackClient: results = subunit_processor.process_stream() return results - def post_results(self, url, content): + def post_results(self, url, content, sign_with=None): '''Post the combined results back to the server.''' + endpoint = '%s/v1/results/' % url + headers = {'Content-type': 'application/json'} + data = json.dumps(content) self.logger.debug('API request content: %s ' % content) + if sign_with: + data_hash = SHA256.new() + data_hash.update(data.encode('utf-8')) + with open(sign_with) as key_pair_file: + try: + key = RSA.importKey(key_pair_file.read()) + except (IOError, ValueError) as e: + self.logger.info('Error during upload key pair %s' + '' % key_pair_file) + self.logger.exception(e) + return + signer = PKCS1_v1_5.new(key) + sign = signer.sign(data_hash) + headers['X-Signature'] = binascii.b2a_hex(sign) + headers['X-Public-Key'] = key.publickey().exportKey('OpenSSH') try: - url = '%s/v1/results/' % self.args.url - headers = {'Content-type': 'application/json'} - - response = requests.post(url, - data=json.dumps(content), + response = requests.post(endpoint, + data=data, headers=headers) - self.logger.info(url + " Response: " + str(response.text)) + self.logger.info(endpoint + " Response: " + str(response.text)) except Exception as e: - self.logger.critical('Failed to post %s - %s ' % (url, e)) - raise + self.logger.info('Failed to post %s - %s ' % (endpoint, e)) + self.logger.exception(e) + return def test(self): '''Execute Tempest test against the cloud.''' @@ -240,7 +259,8 @@ class RefstackClient: # If the user specified the upload argument, then post # the results. if self.args.upload: - self.post_results(self.args.url, content) + self.post_results(self.args.url, content, + sign_with=self.args.priv_key) else: self.logger.error("Problem executing Tempest script. Exit code %d", process.returncode) @@ -251,7 +271,8 @@ class RefstackClient: json_file = open(self.upload_file) json_data = json.load(json_file) json_file.close() - self.post_results(self.args.url, json_data) + self.post_results(self.args.url, json_data, + sign_with=self.args.priv_key) def parse_cli_args(args=None): @@ -273,28 +294,37 @@ def parse_cli_args(args=None): action='count', help='Show verbose output.') - url_arg = argparse.ArgumentParser(add_help=False) - url_arg.add_argument('--url', - action='store', - required=False, - default='http://api.refstack.net', - type=str, - help='Refstack API URL to upload results to ' - '(--url http://localhost:8000).') + shared_args.add_argument('--url', + action='store', + required=False, + default='http://api.refstack.net', + type=str, + help='Refstack API URL to upload results to ' + '(--url http://localhost:8000).') + + shared_args.add_argument('-i', '--sign', + type=str, + required=False, + dest='priv_key', + help='Private RSA key. ' + 'OpenSSH RSA keys format supported (' + '-i ~/.ssh/id-rsa)') # Upload command parser_upload = subparsers.add_parser( - 'upload', parents=[shared_args, url_arg], + 'upload', parents=[shared_args], help='Upload an existing result file.' ) + parser_upload.add_argument('file', type=str, help='Path of JSON results file.') + parser_upload.set_defaults(func="upload") # Test command parser_test = subparsers.add_parser( - 'test', parents=[shared_args, url_arg], + 'test', parents=[shared_args], help='Run Tempest against a cloud.') parser_test.add_argument('-c', '--conf-file', diff --git a/refstack_client/tests/unit/rsa_key b/refstack_client/tests/unit/rsa_key new file mode 100644 index 0000000..bf29c66 --- /dev/null +++ b/refstack_client/tests/unit/rsa_key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/refstack_client/tests/unit/test_client.py b/refstack_client/tests/unit/test_client.py index 4f655cd..29fcedd 100755 --- a/refstack_client/tests/unit/test_client.py +++ b/refstack_client/tests/unit/test_client.py @@ -45,7 +45,7 @@ class TestRefstackClient(unittest.TestCase): self.addCleanup(patcher.stop) return thing - def mock_argv(self, conf_file_name=None, verbose=None): + def mock_argv(self, conf_file_name=None, verbose=None, priv_key=None): """ Build argv for test. :param conf_file_name: Configuration file name @@ -58,6 +58,8 @@ class TestRefstackClient(unittest.TestCase): '-c', conf_file_name, '--test-cases', 'tempest.api.compute', '--url', 'http://127.0.0.1'] + if priv_key: + argv.extend(('-i', priv_key)) if verbose: argv.append(verbose) return argv @@ -245,6 +247,30 @@ class TestRefstackClient(unittest.TestCase): '%s' % expected_response ) + def test_post_results_with_sign(self): + """ + Test the post_results method, ensuring a requests call is made. + """ + args = rc.parse_cli_args(self.mock_argv(priv_key='rsa_key')) + client = rc.RefstackClient(args) + client.logger.info = MagicMock() + content = {'duration_seconds': 0, + 'cpid': 'test-id', + 'results': [{'name': 'tempest.passed.test'}]} + expected_response = json.dumps({'test_id': 42}) + + @httmock.urlmatch(netloc=r'(.*\.)?127.0.0.1$', path='/v1/results/') + def refstack_api_mock(url, request): + return expected_response + + with httmock.HTTMock(refstack_api_mock): + client.post_results("http://127.0.0.1", content, + sign_with=self.test_path + '/rsa_key') + client.logger.info.assert_called_with( + 'http://127.0.0.1/v1/results/ Response: ' + '%s' % expected_response + ) + def test_run_tempest(self): """ Test that the test command will run the tempest script using the @@ -299,6 +325,40 @@ class TestRefstackClient(unittest.TestCase): self.assertTrue(client.post_results.called) + def test_run_tempest_upload_with_sign(self): + """ + Test that the test command will run the tempest script and call + post_results when the --upload argument is passed in. + """ + argv = self.mock_argv(verbose='-vv', priv_key='rsa_key') + argv.append('--upload') + args = rc.parse_cli_args(argv) + client = rc.RefstackClient(args) + client.tempest_dir = self.test_path + mock_popen = self.patch( + 'refstack_client.refstack_client.subprocess.Popen', + return_value=MagicMock(returncode=0)) + self.patch("os.path.isfile", return_value=True) + self.mock_keystone() + client.get_passed_tests = MagicMock(return_value=['test']) + client.post_results = MagicMock() + client._save_json_results = MagicMock() + client.test() + mock_popen.assert_called_with( + ('%s/run_tempest.sh' % self.test_path, '-C', self.conf_file_name, + '-V', '-t', '--', 'tempest.api.compute'), + stderr=None + ) + + self.assertTrue(client.post_results.called) + client.post_results.assert_called_with( + 'http://127.0.0.1', + {'duration_seconds': 0, + 'cpid': 'test-id', + 'results': ['test']}, + sign_with='rsa_key' + ) + def test_run_tempest_no_conf_file(self): """ Test when a nonexistent configuration file is passed in. @@ -377,7 +437,8 @@ class TestRefstackClient(unittest.TestCase): 'results': [{'name': 'tempest.passed.test'}]} client.post_results.assert_called_with('http://api.test.org', - expected_json) + expected_json, + sign_with=None) def test_upload_nonexisting_file(self): """ diff --git a/requirements.txt b/requirements.txt index 74d6418..2bdf036 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ python-keystoneclient>=0.10.0 gitpython>=0.3.2.RC1 -python-subunit>=0.0.18 \ No newline at end of file +python-subunit>=0.0.18 +pycrypto>=2.6.1 \ No newline at end of file diff --git a/tox.ini b/tox.ini index 0120bca..8568604 100644 --- a/tox.ini +++ b/tox.ini @@ -23,7 +23,13 @@ distribute = false commands = {posargs} [testenv:cover] -commands = python setup.py testr --coverage --testr-args='{posargs}' +commands = coverage run \ + --include './refstack_client/*' \ + --omit './refstack_client/tests*' \ + -m testtools.run discover refstack_client.tests.unit + coverage report + coverage html -d cover + /bin/rm .coverage [tox:jenkins] downloadcache = ~/cache/pip