Post test results signed with RSA key

Added feature to upload test results in to Refstack API signed with
your personal RSA key. Just do upload with key '-i PATH_TO_PRIVATE_KEY'.
You can use your general OpenSSH key ~/.ssh/id-rsa or
generate a new one with 'ssh-keygen -b 4096'.

Change-Id: Icce6d7146f0fa9de892d1f4785ef24f17fc9b286
This commit is contained in:
sslypushenko 2015-04-01 20:17:51 +03:00
parent 748d259296
commit 29bd7040d2
7 changed files with 184 additions and 27 deletions

View File

@ -1,4 +1,4 @@
[DEFAULT] [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_id_option=--load-list $IDFILE
test_list_option=--list test_list_option=--list

View File

@ -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 'upload' command, you can also override the Refstack API server uploaded to
with the --url option. with the --url option.
**Note:**
a. Adding -i <path-to-private-key> 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** **Tempest Hacking**
By default, refstack-client installs Tempest into the `.tempest` directory. By default, refstack-client installs Tempest into the `.tempest` directory.

View File

@ -25,6 +25,7 @@ Tempest configuration file.
""" """
import argparse import argparse
import binascii
import ConfigParser import ConfigParser
import json import json
import logging import logging
@ -33,8 +34,10 @@ import requests
import subprocess import subprocess
import time 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 keystoneclient.v2_0 import client as ksclient
from subunit_processor import SubunitProcessor from subunit_processor import SubunitProcessor
@ -163,20 +166,36 @@ class RefstackClient:
results = subunit_processor.process_stream() results = subunit_processor.process_stream()
return results 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.''' '''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) 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: try:
url = '%s/v1/results/' % self.args.url response = requests.post(endpoint,
headers = {'Content-type': 'application/json'} data=data,
response = requests.post(url,
data=json.dumps(content),
headers=headers) headers=headers)
self.logger.info(url + " Response: " + str(response.text)) self.logger.info(endpoint + " Response: " + str(response.text))
except Exception as e: except Exception as e:
self.logger.critical('Failed to post %s - %s ' % (url, e)) self.logger.info('Failed to post %s - %s ' % (endpoint, e))
raise self.logger.exception(e)
return
def test(self): def test(self):
'''Execute Tempest test against the cloud.''' '''Execute Tempest test against the cloud.'''
@ -240,7 +259,8 @@ class RefstackClient:
# If the user specified the upload argument, then post # If the user specified the upload argument, then post
# the results. # the results.
if self.args.upload: 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: else:
self.logger.error("Problem executing Tempest script. Exit code %d", self.logger.error("Problem executing Tempest script. Exit code %d",
process.returncode) process.returncode)
@ -251,7 +271,8 @@ class RefstackClient:
json_file = open(self.upload_file) json_file = open(self.upload_file)
json_data = json.load(json_file) json_data = json.load(json_file)
json_file.close() 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): def parse_cli_args(args=None):
@ -273,28 +294,37 @@ def parse_cli_args(args=None):
action='count', action='count',
help='Show verbose output.') help='Show verbose output.')
url_arg = argparse.ArgumentParser(add_help=False) shared_args.add_argument('--url',
url_arg.add_argument('--url', action='store',
action='store', required=False,
required=False, default='http://api.refstack.net',
default='http://api.refstack.net', type=str,
type=str, help='Refstack API URL to upload results to '
help='Refstack API URL to upload results to ' '(--url http://localhost:8000).')
'(--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 # Upload command
parser_upload = subparsers.add_parser( parser_upload = subparsers.add_parser(
'upload', parents=[shared_args, url_arg], 'upload', parents=[shared_args],
help='Upload an existing result file.' help='Upload an existing result file.'
) )
parser_upload.add_argument('file', parser_upload.add_argument('file',
type=str, type=str,
help='Path of JSON results file.') help='Path of JSON results file.')
parser_upload.set_defaults(func="upload") parser_upload.set_defaults(func="upload")
# Test command # Test command
parser_test = subparsers.add_parser( parser_test = subparsers.add_parser(
'test', parents=[shared_args, url_arg], 'test', parents=[shared_args],
help='Run Tempest against a cloud.') help='Run Tempest against a cloud.')
parser_test.add_argument('-c', '--conf-file', parser_test.add_argument('-c', '--conf-file',

View File

@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----

View File

@ -45,7 +45,7 @@ class TestRefstackClient(unittest.TestCase):
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
return thing 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. Build argv for test.
:param conf_file_name: Configuration file name :param conf_file_name: Configuration file name
@ -58,6 +58,8 @@ class TestRefstackClient(unittest.TestCase):
'-c', conf_file_name, '-c', conf_file_name,
'--test-cases', 'tempest.api.compute', '--test-cases', 'tempest.api.compute',
'--url', 'http://127.0.0.1'] '--url', 'http://127.0.0.1']
if priv_key:
argv.extend(('-i', priv_key))
if verbose: if verbose:
argv.append(verbose) argv.append(verbose)
return argv return argv
@ -245,6 +247,30 @@ class TestRefstackClient(unittest.TestCase):
'%s' % expected_response '%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): def test_run_tempest(self):
""" """
Test that the test command will run the tempest script using the 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) 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): def test_run_tempest_no_conf_file(self):
""" """
Test when a nonexistent configuration file is passed in. Test when a nonexistent configuration file is passed in.
@ -377,7 +437,8 @@ class TestRefstackClient(unittest.TestCase):
'results': [{'name': 'tempest.passed.test'}]} 'results': [{'name': 'tempest.passed.test'}]}
client.post_results.assert_called_with('http://api.test.org', client.post_results.assert_called_with('http://api.test.org',
expected_json) expected_json,
sign_with=None)
def test_upload_nonexisting_file(self): def test_upload_nonexisting_file(self):
""" """

View File

@ -1,3 +1,4 @@
python-keystoneclient>=0.10.0 python-keystoneclient>=0.10.0
gitpython>=0.3.2.RC1 gitpython>=0.3.2.RC1
python-subunit>=0.0.18 python-subunit>=0.0.18
pycrypto>=2.6.1

View File

@ -23,7 +23,13 @@ distribute = false
commands = {posargs} commands = {posargs}
[testenv:cover] [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] [tox:jenkins]
downloadcache = ~/cache/pip downloadcache = ~/cache/pip