Add subunit-describe-calls
Adds new command subunit-describe-calls, documentation, and unittests. subunit-describe-calls is a parser for subunit v1 & v2 streams to determine what REST API calls are made inside of a test and in what order they are called. Later commits will add additional functionality relating to request & response headers & body data along with a stdout output option and stdin input. Change-Id: I468d0d3e3b6098da95a81cc86d9bdd1b47ee1f03
This commit is contained in:
parent
7ae7403e47
commit
c8548fc93c
@ -51,6 +51,7 @@ Command Documentation
|
|||||||
account_generator
|
account_generator
|
||||||
cleanup
|
cleanup
|
||||||
javelin
|
javelin
|
||||||
|
subunit_describe_calls
|
||||||
workspace
|
workspace
|
||||||
|
|
||||||
==================
|
==================
|
||||||
|
5
doc/source/subunit_describe_calls.rst
Normal file
5
doc/source/subunit_describe_calls.rst
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
------------------------------
|
||||||
|
Subunit Describe Calls Utility
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
.. automodule:: tempest.cmd.subunit_describe_calls
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- Adds subunit-describe-calls. A parser for subunit streams to determine what
|
||||||
|
REST API calls are made inside of a test and in what order they are called.
|
@ -34,6 +34,7 @@ console_scripts =
|
|||||||
tempest = tempest.cmd.main:main
|
tempest = tempest.cmd.main:main
|
||||||
skip-tracker = tempest.lib.cmd.skip_tracker:main
|
skip-tracker = tempest.lib.cmd.skip_tracker:main
|
||||||
check-uuid = tempest.lib.cmd.check_uuid:run
|
check-uuid = tempest.lib.cmd.check_uuid:run
|
||||||
|
subunit-describe-calls = tempest.cmd.subunit_describe_calls:entry_point
|
||||||
tempest.cm =
|
tempest.cm =
|
||||||
account-generator = tempest.cmd.account_generator:TempestAccountGenerator
|
account-generator = tempest.cmd.account_generator:TempestAccountGenerator
|
||||||
init = tempest.cmd.init:TempestInit
|
init = tempest.cmd.init:TempestInit
|
||||||
|
259
tempest/cmd/subunit_describe_calls.py
Normal file
259
tempest/cmd/subunit_describe_calls.py
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
# Copyright 2016 Rackspace
|
||||||
|
#
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
"""
|
||||||
|
subunit-describe-calls is a parser for subunit streams to determine what REST
|
||||||
|
API calls are made inside of a test and in what order they are called.
|
||||||
|
|
||||||
|
Runtime Arguments
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
**--subunit, -s**: (Required) The path to the subunit file being parsed
|
||||||
|
|
||||||
|
**--non-subunit-name, -n**: (Optional) The file_name that the logs are being
|
||||||
|
stored in
|
||||||
|
|
||||||
|
**--output-file, -o**: (Required) The path where the JSON output will be
|
||||||
|
written to
|
||||||
|
|
||||||
|
**--ports, -p**: (Optional) The path to a JSON file describing the ports being
|
||||||
|
used by different services
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
|
||||||
|
subunit-describe-calls will take in a file path via the --subunit parameter
|
||||||
|
which contains either a subunit v1 or v2 stream. This is then parsed checking
|
||||||
|
for details contained in the file_bytes of the --non-subunit-name parameter
|
||||||
|
(the default is pythonlogging which is what Tempest uses to store logs). By
|
||||||
|
default the OpenStack Kilo release port defaults (http://bit.ly/22jpF5P)
|
||||||
|
are used unless a file is provided via the --ports option. The resulting output
|
||||||
|
is dumped in JSON output to the path provided in the --output-file option.
|
||||||
|
|
||||||
|
Ports file JSON structure
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
{
|
||||||
|
"<port number>": "<name of service>",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Output file JSON structure
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
{
|
||||||
|
"full_test_name[with_id_and_tags]": [
|
||||||
|
{
|
||||||
|
"name": "The ClassName.MethodName that made the call",
|
||||||
|
"verb": "HTTP Verb",
|
||||||
|
"service": "Name of the service",
|
||||||
|
"url": "A shortened version of the URL called",
|
||||||
|
"status_code": "The status code of the response"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import subunit
|
||||||
|
import testtools
|
||||||
|
|
||||||
|
|
||||||
|
class UrlParser(testtools.TestResult):
|
||||||
|
uuid_re = re.compile(r'(^|[^0-9a-f])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-'
|
||||||
|
'[0-9a-f]{4}-[0-9a-f]{12}([^0-9a-f]|$)')
|
||||||
|
id_re = re.compile(r'(^|[^0-9a-z])[0-9a-z]{8}[0-9a-z]{4}[0-9a-z]{4}'
|
||||||
|
'[0-9a-z]{4}[0-9a-z]{12}([^0-9a-z]|$)')
|
||||||
|
ip_re = re.compile(r'(^|[^0-9])[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]'
|
||||||
|
'{1,3}([^0-9]|$)')
|
||||||
|
url_re = re.compile(r'.*INFO.*Request \((?P<name>.*)\): (?P<code>[\d]{3}) '
|
||||||
|
'(?P<verb>\w*) (?P<url>.*) .*')
|
||||||
|
port_re = re.compile(r'.*:(?P<port>\d+).*')
|
||||||
|
path_re = re.compile(r'http[s]?://[^/]*/(?P<path>.*)')
|
||||||
|
|
||||||
|
# Based on mitaka defaults:
|
||||||
|
# http://docs.openstack.org/mitaka/config-reference/
|
||||||
|
# firewalls-default-ports.html
|
||||||
|
services = {
|
||||||
|
"8776": "Block Storage",
|
||||||
|
"8774": "Nova",
|
||||||
|
"8773": "Nova-API", "8775": "Nova-API",
|
||||||
|
"8386": "Sahara",
|
||||||
|
"35357": "Keystone", "5000": "Keystone",
|
||||||
|
"9292": "Glance", "9191": "Glance",
|
||||||
|
"9696": "Neutron",
|
||||||
|
"6000": "Swift", "6001": "Swift", "6002": "Swift",
|
||||||
|
"8004": "Heat", "8000": "Heat", "8003": "Heat",
|
||||||
|
"8777": "Ceilometer",
|
||||||
|
"80": "Horizon",
|
||||||
|
"8080": "Swift",
|
||||||
|
"443": "SSL",
|
||||||
|
"873": "rsync",
|
||||||
|
"3260": "iSCSI",
|
||||||
|
"3306": "MySQL",
|
||||||
|
"5672": "AMQP"}
|
||||||
|
|
||||||
|
def __init__(self, services=None):
|
||||||
|
super(UrlParser, self).__init__()
|
||||||
|
self.test_logs = {}
|
||||||
|
self.services = services or self.services
|
||||||
|
|
||||||
|
def addSuccess(self, test, details=None):
|
||||||
|
output = test.shortDescription() or test.id()
|
||||||
|
calls = self.parse_details(details)
|
||||||
|
self.test_logs.update({output: calls})
|
||||||
|
|
||||||
|
def addSkip(self, test, err, details=None):
|
||||||
|
output = test.shortDescription() or test.id()
|
||||||
|
calls = self.parse_details(details)
|
||||||
|
self.test_logs.update({output: calls})
|
||||||
|
|
||||||
|
def addError(self, test, err, details=None):
|
||||||
|
output = test.shortDescription() or test.id()
|
||||||
|
calls = self.parse_details(details)
|
||||||
|
self.test_logs.update({output: calls})
|
||||||
|
|
||||||
|
def addFailure(self, test, err, details=None):
|
||||||
|
output = test.shortDescription() or test.id()
|
||||||
|
calls = self.parse_details(details)
|
||||||
|
self.test_logs.update({output: calls})
|
||||||
|
|
||||||
|
def stopTestRun(self):
|
||||||
|
super(UrlParser, self).stopTestRun()
|
||||||
|
|
||||||
|
def startTestRun(self):
|
||||||
|
super(UrlParser, self).startTestRun()
|
||||||
|
|
||||||
|
def parse_details(self, details):
|
||||||
|
if details is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
for _, detail in details.items():
|
||||||
|
for line in detail.as_text().split("\n"):
|
||||||
|
match = self.url_re.match(line)
|
||||||
|
if match is not None:
|
||||||
|
calls.append({
|
||||||
|
"name": match.group("name"),
|
||||||
|
"verb": match.group("verb"),
|
||||||
|
"status_code": match.group("code"),
|
||||||
|
"service": self.get_service(match.group("url")),
|
||||||
|
"url": self.url_path(match.group("url"))})
|
||||||
|
|
||||||
|
return calls
|
||||||
|
|
||||||
|
def get_service(self, url):
|
||||||
|
match = self.port_re.match(url)
|
||||||
|
if match is not None:
|
||||||
|
return self.services.get(match.group("port"), "Unknown")
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def url_path(self, url):
|
||||||
|
match = self.path_re.match(url)
|
||||||
|
if match is not None:
|
||||||
|
path = match.group("path")
|
||||||
|
path = self.uuid_re.sub(r'\1<uuid>\2', path)
|
||||||
|
path = self.ip_re.sub(r'\1<ip>\2', path)
|
||||||
|
path = self.id_re.sub(r'\1<id>\2', path)
|
||||||
|
return path
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
class FileAccumulator(testtools.StreamResult):
|
||||||
|
|
||||||
|
def __init__(self, non_subunit_name='pythonlogging'):
|
||||||
|
super(FileAccumulator, self).__init__()
|
||||||
|
self.route_codes = collections.defaultdict(io.BytesIO)
|
||||||
|
self.non_subunit_name = non_subunit_name
|
||||||
|
|
||||||
|
def status(self, **kwargs):
|
||||||
|
if kwargs.get('file_name') != self.non_subunit_name:
|
||||||
|
return
|
||||||
|
file_bytes = kwargs.get('file_bytes')
|
||||||
|
if not file_bytes:
|
||||||
|
return
|
||||||
|
route_code = kwargs.get('route_code')
|
||||||
|
stream = self.route_codes[route_code]
|
||||||
|
stream.write(file_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
class ArgumentParser(argparse.ArgumentParser):
|
||||||
|
def __init__(self):
|
||||||
|
desc = "Outputs all HTTP calls a given test made that were logged."
|
||||||
|
super(ArgumentParser, self).__init__(description=desc)
|
||||||
|
|
||||||
|
self.prog = "Argument Parser"
|
||||||
|
|
||||||
|
self.add_argument(
|
||||||
|
"-s", "--subunit", metavar="<subunit file>", required=True,
|
||||||
|
default=None, help="The path to the subunit output file.")
|
||||||
|
|
||||||
|
self.add_argument(
|
||||||
|
"-n", "--non-subunit-name", metavar="<non subunit name>",
|
||||||
|
default="pythonlogging",
|
||||||
|
help="The name used in subunit to describe the file contents.")
|
||||||
|
|
||||||
|
self.add_argument(
|
||||||
|
"-o", "--output-file", metavar="<output file>", default=None,
|
||||||
|
help="The output file name for the json.", required=True)
|
||||||
|
|
||||||
|
self.add_argument(
|
||||||
|
"-p", "--ports", metavar="<ports file>", default=None,
|
||||||
|
help="A JSON file describing the ports for each service.")
|
||||||
|
|
||||||
|
|
||||||
|
def parse(subunit_file, non_subunit_name, ports):
|
||||||
|
if ports is not None and os.path.exists(ports):
|
||||||
|
ports = json.loads(open(ports).read())
|
||||||
|
|
||||||
|
url_parser = UrlParser(ports)
|
||||||
|
stream = open(subunit_file, 'rb')
|
||||||
|
suite = subunit.ByteStreamToStreamResult(
|
||||||
|
stream, non_subunit_name=non_subunit_name)
|
||||||
|
result = testtools.StreamToExtendedDecorator(url_parser)
|
||||||
|
accumulator = FileAccumulator(non_subunit_name)
|
||||||
|
result = testtools.StreamResultRouter(result)
|
||||||
|
result.add_rule(accumulator, 'test_id', test_id=None)
|
||||||
|
result.startTestRun()
|
||||||
|
suite.run(result)
|
||||||
|
|
||||||
|
for bytes_io in accumulator.route_codes.values(): # v1 processing
|
||||||
|
bytes_io.seek(0)
|
||||||
|
suite = subunit.ProtocolTestCase(bytes_io)
|
||||||
|
suite.run(url_parser)
|
||||||
|
result.stopTestRun()
|
||||||
|
|
||||||
|
return url_parser
|
||||||
|
|
||||||
|
|
||||||
|
def output(url_parser, output_file):
|
||||||
|
with open(output_file, "w") as outfile:
|
||||||
|
outfile.write(json.dumps(url_parser.test_logs))
|
||||||
|
|
||||||
|
|
||||||
|
def entry_point():
|
||||||
|
cl_args = ArgumentParser().parse_args()
|
||||||
|
parser = parse(cl_args.subunit, cl_args.non_subunit_name, cl_args.ports)
|
||||||
|
output(parser, cl_args.output_file)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
entry_point()
|
BIN
tempest/tests/cmd/sample_streams/calls.subunit
Normal file
BIN
tempest/tests/cmd/sample_streams/calls.subunit
Normal file
Binary file not shown.
83
tempest/tests/cmd/test_subunit_describe_calls.py
Normal file
83
tempest/tests/cmd/test_subunit_describe_calls.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# Copyright 2016 Rackspace
|
||||||
|
#
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from tempest.cmd import subunit_describe_calls
|
||||||
|
from tempest.tests import base
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubunitDescribeCalls(base.TestCase):
|
||||||
|
def test_return_code(self):
|
||||||
|
subunit_file = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
'sample_streams/calls.subunit')
|
||||||
|
p = subprocess.Popen([
|
||||||
|
'subunit-describe-calls', '-s', subunit_file,
|
||||||
|
'-o', tempfile.mkstemp()[1]], stdin=subprocess.PIPE)
|
||||||
|
p.communicate()
|
||||||
|
self.assertEqual(0, p.returncode)
|
||||||
|
|
||||||
|
def test_parse(self):
|
||||||
|
subunit_file = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
'sample_streams/calls.subunit')
|
||||||
|
parser = subunit_describe_calls.parse(
|
||||||
|
subunit_file, "pythonlogging", None)
|
||||||
|
expected_result = {
|
||||||
|
'bar': [{'name': 'AgentsAdminTestJSON:setUp',
|
||||||
|
'service': 'Nova',
|
||||||
|
'status_code': '200',
|
||||||
|
'url': 'v2.1/<id>/os-agents',
|
||||||
|
'verb': 'POST'},
|
||||||
|
{'name': 'AgentsAdminTestJSON:test_create_agent',
|
||||||
|
'service': 'Nova',
|
||||||
|
'status_code': '200',
|
||||||
|
'url': 'v2.1/<id>/os-agents',
|
||||||
|
'verb': 'POST'},
|
||||||
|
{'name': 'AgentsAdminTestJSON:tearDown',
|
||||||
|
'service': 'Nova',
|
||||||
|
'status_code': '200',
|
||||||
|
'url': 'v2.1/<id>/os-agents/1',
|
||||||
|
'verb': 'DELETE'},
|
||||||
|
{'name': 'AgentsAdminTestJSON:_run_cleanups',
|
||||||
|
'service': 'Nova',
|
||||||
|
'status_code': '200',
|
||||||
|
'url': 'v2.1/<id>/os-agents/2',
|
||||||
|
'verb': 'DELETE'}],
|
||||||
|
'foo': [{'name': 'AgentsAdminTestJSON:setUp',
|
||||||
|
'service': 'Nova',
|
||||||
|
'status_code': '200',
|
||||||
|
'url': 'v2.1/<id>/os-agents',
|
||||||
|
'verb': 'POST'},
|
||||||
|
{'name': 'AgentsAdminTestJSON:test_delete_agent',
|
||||||
|
'service': 'Nova',
|
||||||
|
'status_code': '200',
|
||||||
|
'url': 'v2.1/<id>/os-agents/3',
|
||||||
|
'verb': 'DELETE'},
|
||||||
|
{'name': 'AgentsAdminTestJSON:test_delete_agent',
|
||||||
|
'service': 'Nova',
|
||||||
|
'status_code': '200',
|
||||||
|
'url': 'v2.1/<id>/os-agents',
|
||||||
|
'verb': 'GET'},
|
||||||
|
{'name': 'AgentsAdminTestJSON:tearDown',
|
||||||
|
'service': 'Nova',
|
||||||
|
'status_code': '404',
|
||||||
|
'url': 'v2.1/<id>/os-agents/3',
|
||||||
|
'verb': 'DELETE'}]}
|
||||||
|
self.assertEqual(expected_result, parser.test_logs)
|
Loading…
Reference in New Issue
Block a user