Merge of test.py and cli from tempest
This commit is contained in:
191
tempest/cli/__init__.py
Normal file
191
tempest/cli/__init__.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# Copyright 2013 OpenStack Foundation
|
||||
# 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 functools
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
import testtools
|
||||
|
||||
import tempest.cli.output_parser
|
||||
from tempest import config
|
||||
from tempest import exceptions
|
||||
from tempest.openstack.common import log as logging
|
||||
from tempest.openstack.common import versionutils
|
||||
import tempest.test
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
CONF = config.CONF
|
||||
|
||||
|
||||
def execute(cmd, action, flags='', params='', fail_ok=False,
|
||||
merge_stderr=False):
|
||||
"""Executes specified command for the given action."""
|
||||
cmd = ' '.join([os.path.join(CONF.cli.cli_dir, cmd),
|
||||
flags, action, params])
|
||||
LOG.info("running: '%s'" % cmd)
|
||||
cmd = shlex.split(cmd.encode('utf-8'))
|
||||
result = ''
|
||||
result_err = ''
|
||||
stdout = subprocess.PIPE
|
||||
stderr = subprocess.STDOUT if merge_stderr else subprocess.PIPE
|
||||
proc = subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
|
||||
result, result_err = proc.communicate()
|
||||
if not fail_ok and proc.returncode != 0:
|
||||
raise exceptions.CommandFailed(proc.returncode,
|
||||
cmd,
|
||||
result,
|
||||
result_err)
|
||||
return result
|
||||
|
||||
|
||||
def check_client_version(client, version):
|
||||
"""Checks if the client's version is compatible with the given version
|
||||
|
||||
@param client: The client to check.
|
||||
@param version: The version to compare against.
|
||||
@return: True if the client version is compatible with the given version
|
||||
parameter, False otherwise.
|
||||
"""
|
||||
current_version = execute(client, '', params='--version',
|
||||
merge_stderr=True)
|
||||
|
||||
if not current_version.strip():
|
||||
raise exceptions.TempestException('"%s --version" output was empty' %
|
||||
client)
|
||||
|
||||
return versionutils.is_compatible(version, current_version,
|
||||
same_major=False)
|
||||
|
||||
|
||||
def min_client_version(*args, **kwargs):
|
||||
"""A decorator to skip tests if the client used isn't of the right version.
|
||||
|
||||
@param client: The client command to run. For python-novaclient, this is
|
||||
'nova', for python-cinderclient this is 'cinder', etc.
|
||||
@param version: The minimum version required to run the CLI test.
|
||||
"""
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*func_args, **func_kwargs):
|
||||
if not check_client_version(kwargs['client'], kwargs['version']):
|
||||
msg = "requires %s client version >= %s" % (kwargs['client'],
|
||||
kwargs['version'])
|
||||
raise testtools.TestCase.skipException(msg)
|
||||
return func(*func_args, **func_kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class ClientTestBase(tempest.test.BaseTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not CONF.cli.enabled:
|
||||
msg = "cli testing disabled"
|
||||
raise cls.skipException(msg)
|
||||
super(ClientTestBase, cls).setUpClass()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.parser = tempest.cli.output_parser
|
||||
super(ClientTestBase, self).__init__(*args, **kwargs)
|
||||
|
||||
def nova(self, action, flags='', params='', admin=True, fail_ok=False):
|
||||
"""Executes nova command for the given action."""
|
||||
flags += ' --endpoint-type %s' % CONF.compute.endpoint_type
|
||||
return self.cmd_with_auth(
|
||||
'nova', action, flags, params, admin, fail_ok)
|
||||
|
||||
def nova_manage(self, action, flags='', params='', fail_ok=False,
|
||||
merge_stderr=False):
|
||||
"""Executes nova-manage command for the given action."""
|
||||
return execute(
|
||||
'nova-manage', action, flags, params, fail_ok, merge_stderr)
|
||||
|
||||
def keystone(self, action, flags='', params='', admin=True, fail_ok=False):
|
||||
"""Executes keystone command for the given action."""
|
||||
return self.cmd_with_auth(
|
||||
'keystone', action, flags, params, admin, fail_ok)
|
||||
|
||||
def glance(self, action, flags='', params='', admin=True, fail_ok=False):
|
||||
"""Executes glance command for the given action."""
|
||||
flags += ' --os-endpoint-type %s' % CONF.image.endpoint_type
|
||||
return self.cmd_with_auth(
|
||||
'glance', action, flags, params, admin, fail_ok)
|
||||
|
||||
def ceilometer(self, action, flags='', params='', admin=True,
|
||||
fail_ok=False):
|
||||
"""Executes ceilometer command for the given action."""
|
||||
flags += ' --os-endpoint-type %s' % CONF.telemetry.endpoint_type
|
||||
return self.cmd_with_auth(
|
||||
'ceilometer', action, flags, params, admin, fail_ok)
|
||||
|
||||
def heat(self, action, flags='', params='', admin=True,
|
||||
fail_ok=False):
|
||||
"""Executes heat command for the given action."""
|
||||
flags += ' --os-endpoint-type %s' % CONF.orchestration.endpoint_type
|
||||
return self.cmd_with_auth(
|
||||
'heat', action, flags, params, admin, fail_ok)
|
||||
|
||||
def cinder(self, action, flags='', params='', admin=True, fail_ok=False):
|
||||
"""Executes cinder command for the given action."""
|
||||
flags += ' --endpoint-type %s' % CONF.volume.endpoint_type
|
||||
return self.cmd_with_auth(
|
||||
'cinder', action, flags, params, admin, fail_ok)
|
||||
|
||||
def swift(self, action, flags='', params='', admin=True, fail_ok=False):
|
||||
"""Executes swift command for the given action."""
|
||||
flags += ' --os-endpoint-type %s' % CONF.object_storage.endpoint_type
|
||||
return self.cmd_with_auth(
|
||||
'swift', action, flags, params, admin, fail_ok)
|
||||
|
||||
def neutron(self, action, flags='', params='', admin=True, fail_ok=False):
|
||||
"""Executes neutron command for the given action."""
|
||||
flags += ' --endpoint-type %s' % CONF.network.endpoint_type
|
||||
return self.cmd_with_auth(
|
||||
'neutron', action, flags, params, admin, fail_ok)
|
||||
|
||||
def sahara(self, action, flags='', params='', admin=True,
|
||||
fail_ok=False, merge_stderr=True):
|
||||
"""Executes sahara command for the given action."""
|
||||
flags += ' --endpoint-type %s' % CONF.data_processing.endpoint_type
|
||||
return self.cmd_with_auth(
|
||||
'sahara', action, flags, params, admin, fail_ok, merge_stderr)
|
||||
|
||||
def cmd_with_auth(self, cmd, action, flags='', params='',
|
||||
admin=True, fail_ok=False, merge_stderr=False):
|
||||
"""Executes given command with auth attributes appended."""
|
||||
# TODO(jogo) make admin=False work
|
||||
creds = ('--os-username %s --os-tenant-name %s --os-password %s '
|
||||
'--os-auth-url %s' %
|
||||
(CONF.identity.admin_username,
|
||||
CONF.identity.admin_tenant_name,
|
||||
CONF.identity.admin_password,
|
||||
CONF.identity.uri))
|
||||
flags = creds + ' ' + flags
|
||||
return execute(cmd, action, flags, params, fail_ok, merge_stderr)
|
||||
|
||||
def assertTableStruct(self, items, field_names):
|
||||
"""Verify that all items has keys listed in field_names."""
|
||||
for item in items:
|
||||
for field in field_names:
|
||||
self.assertIn(field, item)
|
||||
|
||||
def assertFirstLineStartsWith(self, lines, beginning):
|
||||
self.assertTrue(lines[0].startswith(beginning),
|
||||
msg=('Beginning of first line has invalid content: %s'
|
||||
% lines[:3]))
|
171
tempest/cli/output_parser.py
Normal file
171
tempest/cli/output_parser.py
Normal file
@@ -0,0 +1,171 @@
|
||||
# Copyright 2013 OpenStack Foundation
|
||||
# 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.
|
||||
|
||||
"""Collection of utilities for parsing CLI clients output."""
|
||||
|
||||
import re
|
||||
|
||||
from tempest import exceptions
|
||||
from tempest.openstack.common import log as logging
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
delimiter_line = re.compile('^\+\-[\+\-]+\-\+$')
|
||||
|
||||
|
||||
def details_multiple(output_lines, with_label=False):
|
||||
"""Return list of dicts with item details from cli output tables.
|
||||
|
||||
If with_label is True, key '__label' is added to each items dict.
|
||||
For more about 'label' see OutputParser.tables().
|
||||
"""
|
||||
items = []
|
||||
tables_ = tables(output_lines)
|
||||
for table_ in tables_:
|
||||
if 'Property' not in table_['headers'] \
|
||||
or 'Value' not in table_['headers']:
|
||||
raise exceptions.InvalidStructure()
|
||||
item = {}
|
||||
for value in table_['values']:
|
||||
item[value[0]] = value[1]
|
||||
if with_label:
|
||||
item['__label'] = table_['label']
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def details(output_lines, with_label=False):
|
||||
"""Return dict with details of first item (table) found in output."""
|
||||
items = details_multiple(output_lines, with_label)
|
||||
return items[0]
|
||||
|
||||
|
||||
def listing(output_lines):
|
||||
"""Return list of dicts with basic item info parsed from cli output.
|
||||
"""
|
||||
|
||||
items = []
|
||||
table_ = table(output_lines)
|
||||
for row in table_['values']:
|
||||
item = {}
|
||||
for col_idx, col_key in enumerate(table_['headers']):
|
||||
item[col_key] = row[col_idx]
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def tables(output_lines):
|
||||
"""Find all ascii-tables in output and parse them.
|
||||
|
||||
Return list of tables parsed from cli output as dicts.
|
||||
(see OutputParser.table())
|
||||
|
||||
And, if found, label key (separated line preceding the table)
|
||||
is added to each tables dict.
|
||||
"""
|
||||
tables_ = []
|
||||
|
||||
table_ = []
|
||||
label = None
|
||||
|
||||
start = False
|
||||
header = False
|
||||
|
||||
if not isinstance(output_lines, list):
|
||||
output_lines = output_lines.split('\n')
|
||||
|
||||
for line in output_lines:
|
||||
if delimiter_line.match(line):
|
||||
if not start:
|
||||
start = True
|
||||
elif not header:
|
||||
# we are after head area
|
||||
header = True
|
||||
else:
|
||||
# table ends here
|
||||
start = header = None
|
||||
table_.append(line)
|
||||
|
||||
parsed = table(table_)
|
||||
parsed['label'] = label
|
||||
tables_.append(parsed)
|
||||
|
||||
table_ = []
|
||||
label = None
|
||||
continue
|
||||
if start:
|
||||
table_.append(line)
|
||||
else:
|
||||
if label is None:
|
||||
label = line
|
||||
else:
|
||||
LOG.warn('Invalid line between tables: %s' % line)
|
||||
if len(table_) > 0:
|
||||
LOG.warn('Missing end of table')
|
||||
|
||||
return tables_
|
||||
|
||||
|
||||
def table(output_lines):
|
||||
"""Parse single table from cli output.
|
||||
|
||||
Return dict with list of column names in 'headers' key and
|
||||
rows in 'values' key.
|
||||
"""
|
||||
table_ = {'headers': [], 'values': []}
|
||||
columns = None
|
||||
|
||||
if not isinstance(output_lines, list):
|
||||
output_lines = output_lines.split('\n')
|
||||
|
||||
if not output_lines[-1]:
|
||||
# skip last line if empty (just newline at the end)
|
||||
output_lines = output_lines[:-1]
|
||||
|
||||
for line in output_lines:
|
||||
if delimiter_line.match(line):
|
||||
columns = _table_columns(line)
|
||||
continue
|
||||
if '|' not in line:
|
||||
LOG.warn('skipping invalid table line: %s' % line)
|
||||
continue
|
||||
row = []
|
||||
for col in columns:
|
||||
row.append(line[col[0]:col[1]].strip())
|
||||
if table_['headers']:
|
||||
table_['values'].append(row)
|
||||
else:
|
||||
table_['headers'] = row
|
||||
|
||||
return table_
|
||||
|
||||
|
||||
def _table_columns(first_table_row):
|
||||
"""Find column ranges in output line.
|
||||
|
||||
Return list of tuples (start,end) for each column
|
||||
detected by plus (+) characters in delimiter line.
|
||||
"""
|
||||
positions = []
|
||||
start = 1 # there is '+' at 0
|
||||
while start < len(first_table_row):
|
||||
end = first_table_row.find('+', start)
|
||||
if end == -1:
|
||||
break
|
||||
positions.append((start, end))
|
||||
start = end + 1
|
||||
return positions
|
230
tempest/exceptions.py
Normal file
230
tempest/exceptions.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# Copyright 2012 OpenStack Foundation
|
||||
# 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 testtools
|
||||
|
||||
|
||||
class TempestException(Exception):
|
||||
"""
|
||||
Base Tempest Exception
|
||||
|
||||
To correctly use this class, inherit from it and define
|
||||
a 'message' property. That message will get printf'd
|
||||
with the keyword arguments provided to the constructor.
|
||||
"""
|
||||
message = "An unknown exception occurred"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(TempestException, self).__init__()
|
||||
try:
|
||||
self._error_string = self.message % kwargs
|
||||
except Exception:
|
||||
# at least get the core message out if something happened
|
||||
self._error_string = self.message
|
||||
if len(args) > 0:
|
||||
# If there is a non-kwarg parameter, assume it's the error
|
||||
# message or reason description and tack it on to the end
|
||||
# of the exception message
|
||||
# Convert all arguments into their string representations...
|
||||
args = ["%s" % arg for arg in args]
|
||||
self._error_string = (self._error_string +
|
||||
"\nDetails: %s" % '\n'.join(args))
|
||||
|
||||
def __str__(self):
|
||||
return self._error_string
|
||||
|
||||
|
||||
class RestClientException(TempestException,
|
||||
testtools.TestCase.failureException):
|
||||
pass
|
||||
|
||||
|
||||
class RFCViolation(RestClientException):
|
||||
message = "RFC Violation"
|
||||
|
||||
|
||||
class InvalidConfiguration(TempestException):
|
||||
message = "Invalid Configuration"
|
||||
|
||||
|
||||
class InvalidCredentials(TempestException):
|
||||
message = "Invalid Credentials"
|
||||
|
||||
|
||||
class InvalidHttpSuccessCode(RestClientException):
|
||||
message = "The success code is different than the expected one"
|
||||
|
||||
|
||||
class NotFound(RestClientException):
|
||||
message = "Object not found"
|
||||
|
||||
|
||||
class Unauthorized(RestClientException):
|
||||
message = 'Unauthorized'
|
||||
|
||||
|
||||
class InvalidServiceTag(RestClientException):
|
||||
message = "Invalid service tag"
|
||||
|
||||
|
||||
class TimeoutException(TempestException):
|
||||
message = "Request timed out"
|
||||
|
||||
|
||||
class BuildErrorException(TempestException):
|
||||
message = "Server %(server_id)s failed to build and is in ERROR status"
|
||||
|
||||
|
||||
class ImageKilledException(TempestException):
|
||||
message = "Image %(image_id)s 'killed' while waiting for '%(status)s'"
|
||||
|
||||
|
||||
class AddImageException(TempestException):
|
||||
message = "Image %(image_id)s failed to become ACTIVE in the allotted time"
|
||||
|
||||
|
||||
class EC2RegisterImageException(TempestException):
|
||||
message = ("Image %(image_id)s failed to become 'available' "
|
||||
"in the allotted time")
|
||||
|
||||
|
||||
class VolumeBuildErrorException(TempestException):
|
||||
message = "Volume %(volume_id)s failed to build and is in ERROR status"
|
||||
|
||||
|
||||
class SnapshotBuildErrorException(TempestException):
|
||||
message = "Snapshot %(snapshot_id)s failed to build and is in ERROR status"
|
||||
|
||||
|
||||
class VolumeBackupException(TempestException):
|
||||
message = "Volume backup %(backup_id)s failed and is in ERROR status"
|
||||
|
||||
|
||||
class StackBuildErrorException(TempestException):
|
||||
message = ("Stack %(stack_identifier)s is in %(stack_status)s status "
|
||||
"due to '%(stack_status_reason)s'")
|
||||
|
||||
|
||||
class StackResourceBuildErrorException(TempestException):
|
||||
message = ("Resource %(resource_name)s in stack %(stack_identifier)s is "
|
||||
"in %(resource_status)s status due to "
|
||||
"'%(resource_status_reason)s'")
|
||||
|
||||
|
||||
class BadRequest(RestClientException):
|
||||
message = "Bad request"
|
||||
|
||||
|
||||
class UnprocessableEntity(RestClientException):
|
||||
message = "Unprocessable entity"
|
||||
|
||||
|
||||
class AuthenticationFailure(RestClientException):
|
||||
message = ("Authentication with user %(user)s and password "
|
||||
"%(password)s failed auth using tenant %(tenant)s.")
|
||||
|
||||
|
||||
class EndpointNotFound(TempestException):
|
||||
message = "Endpoint not found"
|
||||
|
||||
|
||||
class RateLimitExceeded(TempestException):
|
||||
message = "Rate limit exceeded"
|
||||
|
||||
|
||||
class OverLimit(TempestException):
|
||||
message = "Quota exceeded"
|
||||
|
||||
|
||||
class ServerFault(TempestException):
|
||||
message = "Got server fault"
|
||||
|
||||
|
||||
class ImageFault(TempestException):
|
||||
message = "Got image fault"
|
||||
|
||||
|
||||
class IdentityError(TempestException):
|
||||
message = "Got identity error"
|
||||
|
||||
|
||||
class Conflict(RestClientException):
|
||||
message = "An object with that identifier already exists"
|
||||
|
||||
|
||||
class SSHTimeout(TempestException):
|
||||
message = ("Connection to the %(host)s via SSH timed out.\n"
|
||||
"User: %(user)s, Password: %(password)s")
|
||||
|
||||
|
||||
class SSHExecCommandFailed(TempestException):
|
||||
"""Raised when remotely executed command returns nonzero status."""
|
||||
message = ("Command '%(command)s', exit status: %(exit_status)d, "
|
||||
"Error:\n%(strerror)s")
|
||||
|
||||
|
||||
class ServerUnreachable(TempestException):
|
||||
message = "The server is not reachable via the configured network"
|
||||
|
||||
|
||||
class TearDownException(TempestException):
|
||||
message = "%(num)d cleanUp operation failed"
|
||||
|
||||
|
||||
class ResponseWithNonEmptyBody(RFCViolation):
|
||||
message = ("RFC Violation! Response with %(status)d HTTP Status Code "
|
||||
"MUST NOT have a body")
|
||||
|
||||
|
||||
class ResponseWithEntity(RFCViolation):
|
||||
message = ("RFC Violation! Response with 205 HTTP Status Code "
|
||||
"MUST NOT have an entity")
|
||||
|
||||
|
||||
class InvalidHTTPResponseBody(RestClientException):
|
||||
message = "HTTP response body is invalid json or xml"
|
||||
|
||||
|
||||
class InvalidHTTPResponseHeader(RestClientException):
|
||||
message = "HTTP response header is invalid"
|
||||
|
||||
|
||||
class InvalidContentType(RestClientException):
|
||||
message = "Invalid content type provided"
|
||||
|
||||
|
||||
class UnexpectedResponseCode(RestClientException):
|
||||
message = "Unexpected response code received"
|
||||
|
||||
|
||||
class InvalidStructure(TempestException):
|
||||
message = "Invalid structure of table with details"
|
||||
|
||||
|
||||
class CommandFailed(Exception):
|
||||
def __init__(self, returncode, cmd, output, stderr):
|
||||
super(CommandFailed, self).__init__()
|
||||
self.returncode = returncode
|
||||
self.cmd = cmd
|
||||
self.stdout = output
|
||||
self.stderr = stderr
|
||||
|
||||
def __str__(self):
|
||||
return ("Command '%s' returned non-zero exit status %d.\n"
|
||||
"stdout:\n%s\n"
|
||||
"stderr:\n%s" % (self.cmd,
|
||||
self.returncode,
|
||||
self.stdout,
|
||||
self.stderr))
|
618
tempest/test.py
Normal file
618
tempest/test.py
Normal file
@@ -0,0 +1,618 @@
|
||||
# Copyright 2012 OpenStack Foundation
|
||||
# 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 atexit
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib
|
||||
import uuid
|
||||
|
||||
import fixtures
|
||||
import testresources
|
||||
import testscenarios
|
||||
import testtools
|
||||
|
||||
from tempest import clients
|
||||
import tempest.common.generator.valid_generator as valid
|
||||
from tempest.common import isolated_creds
|
||||
from tempest import config
|
||||
from tempest import exceptions
|
||||
from tempest.openstack.common import importutils
|
||||
from tempest.openstack.common import log as logging
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
CONF = config.CONF
|
||||
|
||||
# All the successful HTTP status codes from RFC 2616
|
||||
HTTP_SUCCESS = (200, 201, 202, 203, 204, 205, 206)
|
||||
|
||||
|
||||
def attr(*args, **kwargs):
|
||||
"""A decorator which applies the testtools attr decorator
|
||||
|
||||
This decorator applies the testtools.testcase.attr if it is in the list of
|
||||
attributes to testtools we want to apply.
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
if 'type' in kwargs and isinstance(kwargs['type'], str):
|
||||
f = testtools.testcase.attr(kwargs['type'])(f)
|
||||
if kwargs['type'] == 'smoke':
|
||||
f = testtools.testcase.attr('gate')(f)
|
||||
elif 'type' in kwargs and isinstance(kwargs['type'], list):
|
||||
for attr in kwargs['type']:
|
||||
f = testtools.testcase.attr(attr)(f)
|
||||
if attr == 'smoke':
|
||||
f = testtools.testcase.attr('gate')(f)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def safe_setup(f):
|
||||
"""A decorator used to wrap the setUpClass for cleaning up resources
|
||||
when setUpClass failed.
|
||||
"""
|
||||
|
||||
def decorator(cls):
|
||||
try:
|
||||
f(cls)
|
||||
except Exception as se:
|
||||
etype, value, trace = sys.exc_info()
|
||||
LOG.exception("setUpClass failed: %s" % se)
|
||||
try:
|
||||
cls.tearDownClass()
|
||||
except Exception as te:
|
||||
LOG.exception("tearDownClass failed: %s" % te)
|
||||
try:
|
||||
raise etype(value), None, trace
|
||||
finally:
|
||||
del trace # for avoiding circular refs
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def services(*args, **kwargs):
|
||||
"""A decorator used to set an attr for each service used in a test case
|
||||
|
||||
This decorator applies a testtools attr for each service that gets
|
||||
exercised by a test case.
|
||||
"""
|
||||
service_list = {
|
||||
'compute': CONF.service_available.nova,
|
||||
'image': CONF.service_available.glance,
|
||||
'baremetal': CONF.service_available.ironic,
|
||||
'volume': CONF.service_available.cinder,
|
||||
'orchestration': CONF.service_available.heat,
|
||||
# NOTE(mtreinish) nova-network will provide networking functionality
|
||||
# if neutron isn't available, so always set to True.
|
||||
'network': True,
|
||||
'identity': True,
|
||||
'object_storage': CONF.service_available.swift,
|
||||
'dashboard': CONF.service_available.horizon,
|
||||
'telemetry': CONF.service_available.ceilometer,
|
||||
'data_processing': CONF.service_available.sahara
|
||||
}
|
||||
|
||||
def decorator(f):
|
||||
for service in args:
|
||||
if service not in service_list:
|
||||
raise exceptions.InvalidServiceTag('%s is not a valid service'
|
||||
% service)
|
||||
attr(type=list(args))(f)
|
||||
|
||||
@functools.wraps(f)
|
||||
def wrapper(self, *func_args, **func_kwargs):
|
||||
for service in args:
|
||||
if not service_list[service]:
|
||||
msg = 'Skipped because the %s service is not available' % (
|
||||
service)
|
||||
raise testtools.TestCase.skipException(msg)
|
||||
return f(self, *func_args, **func_kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def stresstest(*args, **kwargs):
|
||||
"""Add stress test decorator
|
||||
|
||||
For all functions with this decorator a attr stress will be
|
||||
set automatically.
|
||||
|
||||
@param class_setup_per: allowed values are application, process, action
|
||||
``application``: once in the stress job lifetime
|
||||
``process``: once in the worker process lifetime
|
||||
``action``: on each action
|
||||
@param allow_inheritance: allows inheritance of this attribute
|
||||
"""
|
||||
def decorator(f):
|
||||
if 'class_setup_per' in kwargs:
|
||||
setattr(f, "st_class_setup_per", kwargs['class_setup_per'])
|
||||
else:
|
||||
setattr(f, "st_class_setup_per", 'process')
|
||||
if 'allow_inheritance' in kwargs:
|
||||
setattr(f, "st_allow_inheritance", kwargs['allow_inheritance'])
|
||||
else:
|
||||
setattr(f, "st_allow_inheritance", False)
|
||||
attr(type='stress')(f)
|
||||
return f
|
||||
return decorator
|
||||
|
||||
|
||||
def skip_because(*args, **kwargs):
|
||||
"""A decorator useful to skip tests hitting known bugs
|
||||
|
||||
@param bug: bug number causing the test to skip
|
||||
@param condition: optional condition to be True for the skip to have place
|
||||
@param interface: skip the test if it is the same as self._interface
|
||||
"""
|
||||
def decorator(f):
|
||||
@functools.wraps(f)
|
||||
def wrapper(self, *func_args, **func_kwargs):
|
||||
skip = False
|
||||
if "condition" in kwargs:
|
||||
if kwargs["condition"] is True:
|
||||
skip = True
|
||||
elif "interface" in kwargs:
|
||||
if kwargs["interface"] == self._interface:
|
||||
skip = True
|
||||
else:
|
||||
skip = True
|
||||
if "bug" in kwargs and skip is True:
|
||||
if not kwargs['bug'].isdigit():
|
||||
raise ValueError('bug must be a valid bug number')
|
||||
msg = "Skipped until Bug: %s is resolved." % kwargs["bug"]
|
||||
raise testtools.TestCase.skipException(msg)
|
||||
return f(self, *func_args, **func_kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def requires_ext(*args, **kwargs):
|
||||
"""A decorator to skip tests if an extension is not enabled
|
||||
|
||||
@param extension
|
||||
@param service
|
||||
"""
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*func_args, **func_kwargs):
|
||||
if not is_extension_enabled(kwargs['extension'],
|
||||
kwargs['service']):
|
||||
msg = "Skipped because %s extension: %s is not enabled" % (
|
||||
kwargs['service'], kwargs['extension'])
|
||||
raise testtools.TestCase.skipException(msg)
|
||||
return func(*func_args, **func_kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def is_extension_enabled(extension_name, service):
|
||||
"""A function that will check the list of enabled extensions from config
|
||||
|
||||
"""
|
||||
config_dict = {
|
||||
'compute': CONF.compute_feature_enabled.api_extensions,
|
||||
'compute_v3': CONF.compute_feature_enabled.api_v3_extensions,
|
||||
'volume': CONF.volume_feature_enabled.api_extensions,
|
||||
'network': CONF.network_feature_enabled.api_extensions,
|
||||
'object': CONF.object_storage_feature_enabled.discoverable_apis,
|
||||
}
|
||||
if len(config_dict[service]) == 0:
|
||||
return False
|
||||
if config_dict[service][0] == 'all':
|
||||
return True
|
||||
if extension_name in config_dict[service]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
at_exit_set = set()
|
||||
|
||||
|
||||
def validate_tearDownClass():
|
||||
if at_exit_set:
|
||||
LOG.error(
|
||||
"tearDownClass does not call the super's "
|
||||
"tearDownClass in these classes: \n"
|
||||
+ str(at_exit_set))
|
||||
|
||||
|
||||
atexit.register(validate_tearDownClass)
|
||||
|
||||
if sys.version_info >= (2, 7):
|
||||
class BaseDeps(testtools.TestCase,
|
||||
testtools.testcase.WithAttributes,
|
||||
testresources.ResourcedTestCase):
|
||||
pass
|
||||
else:
|
||||
# Define asserts for py26
|
||||
import unittest2
|
||||
|
||||
class BaseDeps(testtools.TestCase,
|
||||
testtools.testcase.WithAttributes,
|
||||
testresources.ResourcedTestCase,
|
||||
unittest2.TestCase):
|
||||
pass
|
||||
|
||||
|
||||
class BaseTestCase(BaseDeps):
|
||||
|
||||
setUpClassCalled = False
|
||||
_service = None
|
||||
|
||||
network_resources = {}
|
||||
|
||||
# NOTE(sdague): log_format is defined inline here instead of using the oslo
|
||||
# default because going through the config path recouples config to the
|
||||
# stress tests too early, and depending on testr order will fail unit tests
|
||||
log_format = ('%(asctime)s %(process)d %(levelname)-8s '
|
||||
'[%(name)s] %(message)s')
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if hasattr(super(BaseTestCase, cls), 'setUpClass'):
|
||||
super(BaseTestCase, cls).setUpClass()
|
||||
cls.setUpClassCalled = True
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
at_exit_set.discard(cls)
|
||||
if hasattr(super(BaseTestCase, cls), 'tearDownClass'):
|
||||
super(BaseTestCase, cls).tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
super(BaseTestCase, self).setUp()
|
||||
if not self.setUpClassCalled:
|
||||
raise RuntimeError("setUpClass does not calls the super's"
|
||||
"setUpClass in the "
|
||||
+ self.__class__.__name__)
|
||||
at_exit_set.add(self.__class__)
|
||||
test_timeout = os.environ.get('OS_TEST_TIMEOUT', 0)
|
||||
try:
|
||||
test_timeout = int(test_timeout)
|
||||
except ValueError:
|
||||
test_timeout = 0
|
||||
if test_timeout > 0:
|
||||
self.useFixture(fixtures.Timeout(test_timeout, gentle=True))
|
||||
|
||||
if (os.environ.get('OS_STDOUT_CAPTURE') == 'True' or
|
||||
os.environ.get('OS_STDOUT_CAPTURE') == '1'):
|
||||
stdout = self.useFixture(fixtures.StringStream('stdout')).stream
|
||||
self.useFixture(fixtures.MonkeyPatch('sys.stdout', stdout))
|
||||
if (os.environ.get('OS_STDERR_CAPTURE') == 'True' or
|
||||
os.environ.get('OS_STDERR_CAPTURE') == '1'):
|
||||
stderr = self.useFixture(fixtures.StringStream('stderr')).stream
|
||||
self.useFixture(fixtures.MonkeyPatch('sys.stderr', stderr))
|
||||
if (os.environ.get('OS_LOG_CAPTURE') != 'False' and
|
||||
os.environ.get('OS_LOG_CAPTURE') != '0'):
|
||||
self.useFixture(fixtures.LoggerFixture(nuke_handlers=False,
|
||||
format=self.log_format,
|
||||
level=None))
|
||||
|
||||
@classmethod
|
||||
def get_client_manager(cls, interface=None):
|
||||
"""
|
||||
Returns an OpenStack client manager
|
||||
"""
|
||||
cls.isolated_creds = isolated_creds.IsolatedCreds(
|
||||
cls.__name__, network_resources=cls.network_resources)
|
||||
|
||||
force_tenant_isolation = getattr(cls, 'force_tenant_isolation', None)
|
||||
if CONF.compute.allow_tenant_isolation or force_tenant_isolation:
|
||||
creds = cls.isolated_creds.get_primary_creds()
|
||||
if getattr(cls, '_interface', None):
|
||||
os = clients.Manager(credentials=creds,
|
||||
interface=cls._interface,
|
||||
service=cls._service)
|
||||
elif interface:
|
||||
os = clients.Manager(credentials=creds,
|
||||
interface=interface,
|
||||
service=cls._service)
|
||||
else:
|
||||
os = clients.Manager(credentials=creds,
|
||||
service=cls._service)
|
||||
else:
|
||||
if getattr(cls, '_interface', None):
|
||||
os = clients.Manager(interface=cls._interface,
|
||||
service=cls._service)
|
||||
elif interface:
|
||||
os = clients.Manager(interface=interface, service=cls._service)
|
||||
else:
|
||||
os = clients.Manager(service=cls._service)
|
||||
return os
|
||||
|
||||
@classmethod
|
||||
def clear_isolated_creds(cls):
|
||||
"""
|
||||
Clears isolated creds if set
|
||||
"""
|
||||
if getattr(cls, 'isolated_creds'):
|
||||
cls.isolated_creds.clear_isolated_creds()
|
||||
|
||||
@classmethod
|
||||
def _get_identity_admin_client(cls):
|
||||
"""
|
||||
Returns an instance of the Identity Admin API client
|
||||
"""
|
||||
os = clients.AdminManager(interface=cls._interface,
|
||||
service=cls._service)
|
||||
admin_client = os.identity_client
|
||||
return admin_client
|
||||
|
||||
@classmethod
|
||||
def set_network_resources(self, network=False, router=False, subnet=False,
|
||||
dhcp=False):
|
||||
"""Specify which network resources should be created
|
||||
|
||||
@param network
|
||||
@param router
|
||||
@param subnet
|
||||
@param dhcp
|
||||
"""
|
||||
# network resources should be set only once from callers
|
||||
# in order to ensure that even if it's called multiple times in
|
||||
# a chain of overloaded methods, the attribute is set only
|
||||
# in the leaf class
|
||||
if not self.network_resources:
|
||||
self.network_resources = {
|
||||
'network': network,
|
||||
'router': router,
|
||||
'subnet': subnet,
|
||||
'dhcp': dhcp}
|
||||
|
||||
def assertEmpty(self, list, msg=None):
|
||||
self.assertTrue(len(list) == 0, msg)
|
||||
|
||||
def assertNotEmpty(self, list, msg=None):
|
||||
self.assertTrue(len(list) > 0, msg)
|
||||
|
||||
|
||||
class NegativeAutoTest(BaseTestCase):
|
||||
|
||||
_resources = {}
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(NegativeAutoTest, cls).setUpClass()
|
||||
os = cls.get_client_manager()
|
||||
cls.client = os.negative_client
|
||||
os_admin = clients.AdminManager(interface=cls._interface,
|
||||
service=cls._service)
|
||||
cls.admin_client = os_admin.negative_client
|
||||
|
||||
@staticmethod
|
||||
def load_tests(*args):
|
||||
"""
|
||||
Wrapper for testscenarios to set the mandatory scenarios variable
|
||||
only in case a real test loader is in place. Will be automatically
|
||||
called in case the variable "load_tests" is set.
|
||||
"""
|
||||
if getattr(args[0], 'suiteClass', None) is not None:
|
||||
loader, standard_tests, pattern = args
|
||||
else:
|
||||
standard_tests, module, loader = args
|
||||
for test in testtools.iterate_tests(standard_tests):
|
||||
schema_file = getattr(test, '_schema_file', None)
|
||||
schema = getattr(test, '_schema', None)
|
||||
if schema_file is not None:
|
||||
setattr(test, 'scenarios',
|
||||
NegativeAutoTest.generate_scenario(schema_file))
|
||||
elif schema is not None:
|
||||
setattr(test, 'scenarios',
|
||||
NegativeAutoTest.generate_scenario(schema))
|
||||
return testscenarios.load_tests_apply_scenarios(*args)
|
||||
|
||||
@staticmethod
|
||||
def generate_scenario(description):
|
||||
"""
|
||||
Generates the test scenario list for a given description.
|
||||
|
||||
:param description: A file or dictionary with the following entries:
|
||||
name (required) name for the api
|
||||
http-method (required) one of HEAD,GET,PUT,POST,PATCH,DELETE
|
||||
url (required) the url to be appended to the catalog url with '%s'
|
||||
for each resource mentioned
|
||||
resources: (optional) A list of resource names such as "server",
|
||||
"flavor", etc. with an element for each '%s' in the url. This
|
||||
method will call self.get_resource for each element when
|
||||
constructing the positive test case template so negative
|
||||
subclasses are expected to return valid resource ids when
|
||||
appropriate.
|
||||
json-schema (optional) A valid json schema that will be used to
|
||||
create invalid data for the api calls. For "GET" and "HEAD",
|
||||
the data is used to generate query strings appended to the url,
|
||||
otherwise for the body of the http call.
|
||||
"""
|
||||
LOG.debug(description)
|
||||
generator = importutils.import_class(
|
||||
CONF.negative.test_generator)()
|
||||
generator.validate_schema(description)
|
||||
schema = description.get("json-schema", None)
|
||||
resources = description.get("resources", [])
|
||||
scenario_list = []
|
||||
expected_result = None
|
||||
for resource in resources:
|
||||
if isinstance(resource, dict):
|
||||
expected_result = resource['expected_result']
|
||||
resource = resource['name']
|
||||
LOG.debug("Add resource to test %s" % resource)
|
||||
scn_name = "inv_res_%s" % (resource)
|
||||
scenario_list.append((scn_name, {"resource": (resource,
|
||||
str(uuid.uuid4())),
|
||||
"expected_result": expected_result
|
||||
}))
|
||||
if schema is not None:
|
||||
for name, schema, expected_result in generator.generate(schema):
|
||||
if (expected_result is None and
|
||||
"default_result_code" in description):
|
||||
expected_result = description["default_result_code"]
|
||||
scenario_list.append((name,
|
||||
{"schema": schema,
|
||||
"expected_result": expected_result}))
|
||||
LOG.debug(scenario_list)
|
||||
return scenario_list
|
||||
|
||||
def execute(self, description):
|
||||
"""
|
||||
Execute a http call on an api that are expected to
|
||||
result in client errors. First it uses invalid resources that are part
|
||||
of the url, and then invalid data for queries and http request bodies.
|
||||
|
||||
:param description: A json file or dictionary with the following
|
||||
entries:
|
||||
name (required) name for the api
|
||||
http-method (required) one of HEAD,GET,PUT,POST,PATCH,DELETE
|
||||
url (required) the url to be appended to the catalog url with '%s'
|
||||
for each resource mentioned
|
||||
resources: (optional) A list of resource names such as "server",
|
||||
"flavor", etc. with an element for each '%s' in the url. This
|
||||
method will call self.get_resource for each element when
|
||||
constructing the positive test case template so negative
|
||||
subclasses are expected to return valid resource ids when
|
||||
appropriate.
|
||||
json-schema (optional) A valid json schema that will be used to
|
||||
create invalid data for the api calls. For "GET" and "HEAD",
|
||||
the data is used to generate query strings appended to the url,
|
||||
otherwise for the body of the http call.
|
||||
|
||||
"""
|
||||
LOG.info("Executing %s" % description["name"])
|
||||
LOG.debug(description)
|
||||
method = description["http-method"]
|
||||
url = description["url"]
|
||||
|
||||
resources = [self.get_resource(r) for
|
||||
r in description.get("resources", [])]
|
||||
|
||||
if hasattr(self, "resource"):
|
||||
# Note(mkoderer): The resources list already contains an invalid
|
||||
# entry (see get_resource).
|
||||
# We just send a valid json-schema with it
|
||||
valid_schema = None
|
||||
schema = description.get("json-schema", None)
|
||||
if schema:
|
||||
valid_schema = \
|
||||
valid.ValidTestGenerator().generate_valid(schema)
|
||||
new_url, body = self._http_arguments(valid_schema, url, method)
|
||||
elif hasattr(self, "schema"):
|
||||
new_url, body = self._http_arguments(self.schema, url, method)
|
||||
else:
|
||||
raise Exception("testscenarios are not active. Please make sure "
|
||||
"that your test runner supports the load_tests "
|
||||
"mechanism")
|
||||
|
||||
if "admin_client" in description and description["admin_client"]:
|
||||
client = self.admin_client
|
||||
else:
|
||||
client = self.client
|
||||
resp, resp_body = client.send_request(method, new_url,
|
||||
resources, body=body)
|
||||
self._check_negative_response(resp.status, resp_body)
|
||||
|
||||
def _http_arguments(self, json_dict, url, method):
|
||||
LOG.debug("dict: %s url: %s method: %s" % (json_dict, url, method))
|
||||
if not json_dict:
|
||||
return url, None
|
||||
elif method in ["GET", "HEAD", "PUT", "DELETE"]:
|
||||
return "%s?%s" % (url, urllib.urlencode(json_dict)), None
|
||||
else:
|
||||
return url, json.dumps(json_dict)
|
||||
|
||||
def _check_negative_response(self, result, body):
|
||||
expected_result = getattr(self, "expected_result", None)
|
||||
self.assertTrue(result >= 400 and result < 500 and result != 413,
|
||||
"Expected client error, got %s:%s" %
|
||||
(result, body))
|
||||
self.assertTrue(expected_result is None or expected_result == result,
|
||||
"Expected %s, got %s:%s" %
|
||||
(expected_result, result, body))
|
||||
|
||||
@classmethod
|
||||
def set_resource(cls, name, resource):
|
||||
"""
|
||||
This function can be used in setUpClass context to register a resoruce
|
||||
for a test.
|
||||
|
||||
:param name: The name of the kind of resource such as "flavor", "role",
|
||||
etc.
|
||||
:resource: The id of the resource
|
||||
"""
|
||||
cls._resources[name] = resource
|
||||
|
||||
def get_resource(self, name):
|
||||
"""
|
||||
Return a valid uuid for a type of resource. If a real resource is
|
||||
needed as part of a url then this method should return one. Otherwise
|
||||
it can return None.
|
||||
|
||||
:param name: The name of the kind of resource such as "flavor", "role",
|
||||
etc.
|
||||
"""
|
||||
if isinstance(name, dict):
|
||||
name = name['name']
|
||||
if hasattr(self, "resource") and self.resource[0] == name:
|
||||
LOG.debug("Return invalid resource (%s) value: %s" %
|
||||
(self.resource[0], self.resource[1]))
|
||||
return self.resource[1]
|
||||
if name in self._resources:
|
||||
return self._resources[name]
|
||||
return None
|
||||
|
||||
|
||||
def SimpleNegativeAutoTest(klass):
|
||||
"""
|
||||
This decorator registers a test function on basis of the class name.
|
||||
"""
|
||||
@attr(type=['negative', 'gate'])
|
||||
def generic_test(self):
|
||||
if hasattr(self, '_schema'):
|
||||
self.execute(self._schema)
|
||||
|
||||
cn = klass.__name__
|
||||
cn = cn.replace('JSON', '')
|
||||
cn = cn.replace('Test', '')
|
||||
# NOTE(mkoderer): replaces uppercase chars inside the class name with '_'
|
||||
lower_cn = re.sub('(?<!^)(?=[A-Z])', '_', cn).lower()
|
||||
func_name = 'test_%s' % lower_cn
|
||||
setattr(klass, func_name, generic_test)
|
||||
return klass
|
||||
|
||||
|
||||
def call_until_true(func, duration, sleep_for):
|
||||
"""
|
||||
Call the given function until it returns True (and return True) or
|
||||
until the specified duration (in seconds) elapses (and return
|
||||
False).
|
||||
|
||||
:param func: A zero argument callable that returns True on success.
|
||||
:param duration: The number of seconds for which to attempt a
|
||||
successful call of the function.
|
||||
:param sleep_for: The number of seconds to sleep after an unsuccessful
|
||||
invocation of the function.
|
||||
"""
|
||||
now = time.time()
|
||||
timeout = now + duration
|
||||
while now < timeout:
|
||||
if func():
|
||||
return True
|
||||
time.sleep(sleep_for)
|
||||
now = time.time()
|
||||
return False
|
Reference in New Issue
Block a user