Files
tempest/tempest/cmd/subunit_describe_calls.py
Masayuki Igawa bbbaad68c3 Fix docs markup consistency
This commit fixes docs consistency about markups, mainly. Originally,
some of command options were written with strong emphasis. However,
double dashes are converted to a single dash with it. And this commit
also fixes some inconsistencies and weird indentations.

Change-Id: Iff1e8e320dcb1fa69ca0fce139c58727fca7b729
2017-11-21 19:02:54 +09:00

316 lines
11 KiB
Python

# 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``: (Optional) The path to the subunit file being parsed,
defaults to stdin
* ``--non-subunit-name, -n``: (Optional) The file_name that the logs are being
stored in
* ``--output-file, -o``: (Optional) The path where the JSON output will be
written to. This contains more information than is present in stdout.
* ``--ports, -p``: (Optional) The path to a JSON file describing the ports
being used by different services
Usage
-----
subunit-describe-calls will take in either stdin subunit v1 or v2 stream or a
file path which contains either a subunit v1 or v2 stream passed via the
--subunit parameter. 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",
"request_headers": "The headers of the request",
"request_body": "The body of the request",
"response_headers": "The headers of the response",
"response_body": "The body of the response"
}
]
}
"""
import argparse
import collections
import io
import json
import os
import re
import sys
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>.*)')
request_re = re.compile(r'.* Request - Headers: (?P<headers>.*)')
response_re = re.compile(r'.* Response - Headers: (?P<headers>.*)')
body_re = re.compile(r'.*Body: (?P<body>.*)')
# Based on newton defaults:
# http://docs.openstack.org/newton/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",
"8082": "murano"}
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():
in_request = False
in_response = False
current_call = {}
for line in detail.as_text().split("\n"):
url_match = self.url_re.match(line)
request_match = self.request_re.match(line)
response_match = self.response_re.match(line)
body_match = self.body_re.match(line)
if url_match is not None:
if current_call != {}:
calls.append(current_call.copy())
current_call = {}
in_request, in_response = False, False
current_call.update({
"name": url_match.group("name"),
"verb": url_match.group("verb"),
"status_code": url_match.group("code"),
"service": self.get_service(url_match.group("url")),
"url": self.url_path(url_match.group("url"))})
elif request_match is not None:
in_request, in_response = True, False
current_call.update(
{"request_headers": request_match.group("headers")})
elif in_request and body_match is not None:
in_request = False
current_call.update(
{"request_body": body_match.group(
"body")})
elif response_match is not None:
in_request, in_response = False, True
current_call.update(
{"response_headers": response_match.group(
"headers")})
elif in_response and body_match is not None:
in_response = False
current_call.update(
{"response_body": body_match.group("body")})
if current_call != {}:
calls.append(current_call.copy())
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 = "subunit-describe-calls"
self.add_argument(
"-s", "--subunit", metavar="<subunit file>",
nargs="?", type=argparse.FileType('rb'), default=sys.stdin,
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.")
self.add_argument(
"-p", "--ports", metavar="<ports file>", default=None,
help="A JSON file describing the ports for each service.")
def parse(stream, non_subunit_name, ports):
if ports is not None and os.path.exists(ports):
ports = json.loads(open(ports).read())
url_parser = UrlParser(ports)
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):
if output_file is not None:
with open(output_file, "w") as outfile:
outfile.write(json.dumps(url_parser.test_logs))
return
for test_name in url_parser.test_logs:
items = url_parser.test_logs[test_name]
sys.stdout.write('{0}\n'.format(test_name))
if not items:
sys.stdout.write('\n')
continue
for item in items:
sys.stdout.write('\t- {0} {1} request for {2} to {3}\n'.format(
item.get('status_code'), item.get('verb'),
item.get('service'), item.get('url')))
sys.stdout.write('\n')
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()