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:
parent
748d259296
commit
29bd7040d2
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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',
|
||||||
|
51
refstack_client/tests/unit/rsa_key
Normal file
51
refstack_client/tests/unit/rsa_key
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
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
|
||||||
|
-----END RSA PRIVATE KEY-----
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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
|
8
tox.ini
8
tox.ini
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user