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]
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

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
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**
By default, refstack-client installs Tempest into the `.tempest` directory.

View File

@ -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',

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)
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):
"""

View File

@ -1,3 +1,4 @@
python-keystoneclient>=0.10.0
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}
[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