diff --git a/src/lib/charm/openstack/masakari.py b/src/lib/charm/openstack/masakari.py index 0bea541..a5dbd26 100644 --- a/src/lib/charm/openstack/masakari.py +++ b/src/lib/charm/openstack/masakari.py @@ -22,10 +22,17 @@ class MasakariCharm(charms_openstack.charm.HAOpenStackCharm): service_name = name = 'masakari' # First release supported - release = 'mitaka' + release = 'rocky' # List of packages to install for this charm - packages = ['libapache2-mod-wsgi', 'apache2', 'python-apt', 'cinder-common'] + packages = ['apache2', 'python-apt', + 'cinder-common', 'python3-oslo.policy', 'python3-pymysql', + 'python3-keystoneauth1', 'python3-oslo.db', + 'python3-oslo.service', 'python3-oslo.middleware', + 'python3-oslo.messaging', 'python3-oslo.versionedobjects', + 'python3-novaclient', 'python3-keystonemiddleware', + 'python3-taskflow', 'libapache2-mod-wsgi-py3', + 'python3-microversion-parse'] api_ports = { 'masakari': { @@ -59,7 +66,8 @@ class MasakariCharm(charms_openstack.charm.HAOpenStackCharm): } - sync_cmd = ['masakari-manage', '--config-file', '/etc/masakari/masakari.conf', 'db', 'sync'] + sync_cmd = ['masakari-manage', '--config-file', + '/etc/masakari/masakari.conf', 'db', 'sync'] def get_amqp_credentials(self): return ('masakari', 'masakari') @@ -92,8 +100,10 @@ class MasakariCharm(charms_openstack.charm.HAOpenStackCharm): 'git', 'clone', '-b', 'stable/{}'.format(os_release), 'https://github.com/openstack/masakari.git', git_dir]) subprocess.check_call([ - 'sudo', 'python', 'setup.py', 'install'], cwd=git_dir) + 'sudo', 'python3', 'setup.py', 'install'], cwd=git_dir) subprocess.check_call(['mkdir', '-p', '/var/lock/masakari', '/var/log/masakari', '/var/lib/masakari']) subprocess.check_call(['cp', 'templates/masakari-engine.service', '/lib/systemd/system']) + subprocess.check_call(['cp', 'templates/wsgi.py', '/usr/local/lib/python3.6/dist-packages/masakari/api/openstack/wsgi.py']) subprocess.check_call(['systemctl', 'daemon-reload']) subprocess.check_call(['systemctl', 'start', 'masakari-engine']) + subprocess.check_call(['cp', 'templates/api-paste.ini', '/etc/masakari/']) diff --git a/src/reactive/masakari_handlers.py b/src/reactive/masakari_handlers.py index 79b504f..09fcb9d 100644 --- a/src/reactive/masakari_handlers.py +++ b/src/reactive/masakari_handlers.py @@ -11,6 +11,7 @@ # 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 subprocess import charms_openstack.charm as charm import charms.reactive as reactive @@ -39,6 +40,7 @@ def render_config(*args): # charm_class.upgrade_if_available(args) charm_class.render_with_interfaces(args) charm_class.assess_status() + subprocess.check_call(['chgrp', '-R', 'ubuntu', '/etc/masakari']) reactive.set_state('config.rendered') # db_sync checks if sync has been done so rerunning is a noop diff --git a/src/templates/api-paste.ini b/src/templates/api-paste.ini new file mode 100644 index 0000000..5cee230 --- /dev/null +++ b/src/templates/api-paste.ini @@ -0,0 +1,45 @@ +[composite:masakari_api] +use = call:masakari.api.urlmap:urlmap_factory +/: apiversions +/v1: masakari_api_v1 + + +[composite:masakari_api_v1] +use = call:masakari.api.auth:pipeline_factory_v1 +keystone = cors http_proxy_to_wsgi request_id faultwrap sizelimit authtoken keystonecontext osapi_masakari_app_v1 + +# filters +[filter:cors] +paste.filter_factory = oslo_middleware.cors:filter_factory +oslo_config_project = masakari + +[filter:http_proxy_to_wsgi] +paste.filter_factory = oslo_middleware.http_proxy_to_wsgi:HTTPProxyToWSGI.factory + +[filter:request_id] +paste.filter_factory = oslo_middleware:RequestId.factory + +[filter:faultwrap] +paste.filter_factory = masakari.api.openstack:FaultWrapper.factory + +[filter:sizelimit] +paste.filter_factory = oslo_middleware:RequestBodySizeLimiter.factory + +[filter:authtoken] +paste.filter_factory = keystonemiddleware.auth_token:filter_factory + +[filter:keystonecontext] +paste.filter_factory = masakari.api.auth:MasakariKeystoneContext.factory + +[filter:noauth2] +paste.filter_factory = masakari.api.auth:NoAuthMiddleware.factory + +# apps +[app:osapi_masakari_app_v1] +paste.app_factory = masakari.api.openstack.ha:APIRouterV1.factory + +[pipeline:apiversions] +pipeline = faultwrap http_proxy_to_wsgi apiversionsapp + +[app:apiversionsapp] +paste.app_factory = masakari.api.openstack.ha.versions:Versions.factory diff --git a/src/templates/masakari-api.conf b/src/templates/masakari-api.conf index 224b45e..eddabf2 100644 --- a/src/templates/masakari-api.conf +++ b/src/templates/masakari-api.conf @@ -5,9 +5,10 @@ Listen {{ options.service_listen_info.masakari.public_port }} WSGIProcessGroup masakari-api WSGIScriptAlias / /usr/local/bin/masakari-wsgi WSGIApplicationGroup %{GLOBAL} - = 2.4> - ErrorLogFormat "%{cu}t %M" - + + Require all granted + + ErrorLogFormat "%{cu}t %M" ErrorLog /var/log/apache2/masakari_error.log CustomLog /var/log/apache2/masakari_access.log combined diff --git a/src/templates/wsgi.py b/src/templates/wsgi.py new file mode 100644 index 0000000..74ee48a --- /dev/null +++ b/src/templates/wsgi.py @@ -0,0 +1,1085 @@ +# Copyright 2016 NTT DATA +# 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 inspect + +import microversion_parse +from oslo_log import log as logging +from oslo_serialization import jsonutils +from oslo_utils import strutils +import six +from six.moves import http_client as http +import webob + +from masakari.api import api_version_request as api_version +from masakari.api import versioned_method +from masakari import exception +from masakari import i18n +from masakari.i18n import _ +from masakari import utils +from masakari import wsgi + + +LOG = logging.getLogger(__name__) + +_SUPPORTED_CONTENT_TYPES = ( + 'application/json', + 'application/vnd.openstack.masakari+json', +) + +_MEDIA_TYPE_MAP = { + 'application/vnd.openstack.masakari+json': 'json', + 'application/json': 'json', +} + +# These are typically automatically created by routes as either defaults +# collection or member methods. +_ROUTES_METHODS = [ + 'create', + 'delete', + 'show', + 'update', +] + +_METHODS_WITH_BODY = [ + 'POST', + 'PUT', +] + +# The default api version request if none is requested in the headers +DEFAULT_API_VERSION = "1.0" + +# name of attribute to keep version method information +VER_METHOD_ATTR = 'versioned_methods' + +# Names of headers used by clients to request a specific version +# of the REST API +API_VERSION_REQUEST_HEADER = 'OpenStack-API-Version' + + +def get_supported_content_types(): + return _SUPPORTED_CONTENT_TYPES + + +def get_media_map(): + return dict(_MEDIA_TYPE_MAP.items()) + + +# NOTE: This function allows a get on both a dict-like and an +# object-like object. cache_db_items() is used on both versioned objects and +# dicts, so the function can't be totally changed over to [] syntax, nor +# can it be changed over to use getattr(). +def item_get(item, item_key): + if hasattr(item, '__getitem__'): + return item[item_key] + else: + return getattr(item, item_key) + + +class Request(wsgi.Request): + """Add some OpenStack API-specific logic to the base webob.Request.""" + + def __init__(self, *args, **kwargs): + super(Request, self).__init__(*args, **kwargs) + self._extension_data = {'db_items': {}} + if not hasattr(self, 'api_version_request'): + self.api_version_request = api_version.APIVersionRequest() + + def cache_db_items(self, key, items, item_key='id'): + """Allow API methods to store objects from a DB query to be + used by API extensions within the same API request. + + An instance of this class only lives for the lifetime of a + single API request, so there's no need to implement full + cache management. + """ + db_items = self._extension_data['db_items'].setdefault(key, {}) + for item in items: + db_items[item_get(item, item_key)] = item + + def get_db_items(self, key): + """Allow an API extension to get previously stored objects within + the same API request. + + Note that the object data will be slightly stale. + """ + return self._extension_data['db_items'][key] + + def get_db_item(self, key, item_key): + """Allow an API extension to get a previously stored object + within the same API request. + + Note that the object data will be slightly stale. + """ + return self.get_db_items(key).get(item_key) + + def best_match_content_type(self): + """Determine the requested response content-type.""" + if 'masakari.best_content_type' not in self.environ: + # Calculate the best MIME type + content_type = None + + # Check URL path suffix + parts = self.path.rsplit('.', 1) + if len(parts) > 1: + possible_type = 'application/' + parts[1] + if possible_type in get_supported_content_types(): + content_type = possible_type + + if not content_type: + content_type = self.accept.best_match( + get_supported_content_types()) + + self.environ['masakari.best_content_type'] = (content_type or + 'application/json') + + return self.environ['masakari.best_content_type'] + + def get_content_type(self): + """Determine content type of the request body. + + Does not do any body introspection, only checks header + + """ + if "Content-Type" not in self.headers: + return None + + content_type = self.content_type + + # NOTE: text/plain is the default for eventlet and + # other webservers which use mimetools.Message.gettype() + # whereas twisted defaults to ''. + if not content_type or content_type == 'text/plain': + return None + + if content_type not in get_supported_content_types(): + raise exception.InvalidContentType(content_type=content_type) + + return content_type + + def best_match_language(self): + """Determine the best available language for the request. + + :returns: the best language match or None if the 'Accept-Language' + header was not available in the request. + """ + if not self.accept_language: + return None + return self.accept_language.best_match(i18n.get_available_languages()) + + def set_api_version_request(self): + """Set API version request based on the request header information.""" + hdr_string = microversion_parse.get_version( + self.headers, service_type='masakari') + + if hdr_string is None: + self.api_version_request = api_version.APIVersionRequest( + api_version.DEFAULT_API_VERSION) + elif hdr_string == 'latest': + # 'latest' is a special keyword which is equivalent to + # requesting the maximum version of the API supported + self.api_version_request = api_version.max_api_version() + else: + self.api_version_request = api_version.APIVersionRequest( + hdr_string) + + # Check that the version requested is within the global + # minimum/maximum of supported API versions + if not self.api_version_request.matches( + api_version.min_api_version(), + api_version.max_api_version()): + raise exception.InvalidGlobalAPIVersion( + req_ver=self.api_version_request.get_string(), + min_ver=api_version.min_api_version().get_string(), + max_ver=api_version.max_api_version().get_string()) + + +class ActionDispatcher(object): + """Maps method name to local methods through action name.""" + + def dispatch(self, *args, **kwargs): + """Find and call local method.""" + action = kwargs.pop('action', 'default') + action_method = getattr(self, str(action), self.default) + return action_method(*args, **kwargs) + + def default(self, data): + raise NotImplementedError() + + +class JSONDeserializer(ActionDispatcher): + + def _from_json(self, datastring): + try: + return jsonutils.loads(datastring) + except ValueError: + msg = _("cannot understand JSON") + raise exception.MalformedRequestBody(reason=msg) + + def deserialize(self, datastring, action='default'): + return self.dispatch(datastring, action=action) + + def default(self, datastring): + return {'body': self._from_json(datastring)} + + +class JSONDictSerializer(ActionDispatcher): + """Default JSON request body serialization.""" + + def serialize(self, data, action='default'): + return self.dispatch(data, action=action) + + def default(self, data): + return six.text_type(jsonutils.dumps(data)) + + +def response(code): + """Attaches response code to a method. + + This decorator associates a response code with a method. Note + that the function attributes are directly manipulated; the method + is not wrapped. + """ + + def decorator(func): + func.wsgi_code = code + return func + return decorator + + +class ResponseObject(object): + """Bundles a response object + + Object that app methods may return in order to allow its response + to be modified by extensions in the code. Its use is optional (and + should only be used if you really know what you are doing). + """ + + def __init__(self, obj, code=None, headers=None): + """Builds a response object.""" + + self.obj = obj + self._default_code = http.OK + self._code = code + self._headers = headers or {} + self.serializer = JSONDictSerializer() + + def __getitem__(self, key): + """Retrieves a header with the given name.""" + + return self._headers[key.lower()] + + def __setitem__(self, key, value): + """Sets a header with the given name to the given value.""" + + self._headers[key.lower()] = value + + def __delitem__(self, key): + """Deletes the header with the given name.""" + + del self._headers[key.lower()] + + def serialize(self, request, content_type): + """Serializes the wrapped object. + + Utility method for serializing the wrapped object. Returns a + webob.Response object. + """ + + serializer = self.serializer + + body = None + if self.obj is not None: + body = serializer.serialize(self.obj) + response = webob.Response(body=body) + if response.headers.get('Content-Length'): + if six.PY3: + response.headers['Content-Length'] = (str( + response.headers['Content-Length'])) + else: + # NOTE: we need to encode 'Content-Length' header, since + # webob.Response auto sets it if "body" attr is presented. + # github.com/Pylons/webob/blob/1.5.0b0/webob/response.py#L147 + response.headers['Content-Length'] = utils.utf8( + response.headers['Content-Length']) + response.status_int = self.code + for hdr, value in self._headers.items(): + if six.PY3: + response.headers[hdr] = str(value) + else: + response.headers[hdr] = utils.utf8(value) + if six.PY3: + response.headers['Content-Type'] = str(content_type) + else: + response.headers['Content-Type'] = utils.utf8(content_type) + return response + + @property + def code(self): + """Retrieve the response status.""" + + return self._code or self._default_code + + @property + def headers(self): + """Retrieve the headers.""" + + return self._headers.copy() + + +def action_peek(body): + """Determine action to invoke. + + This looks inside the json body and fetches out the action method + name. + """ + + try: + decoded = jsonutils.loads(body) + except ValueError: + msg = _("cannot understand JSON") + raise exception.MalformedRequestBody(reason=msg) + + # Make sure there's exactly one key... + if len(decoded) != 1: + msg = _("too many body keys") + raise exception.MalformedRequestBody(reason=msg) + + # Return the action name + return list(decoded.keys())[0] + + +class ResourceExceptionHandler(object): + """Context manager to handle Resource exceptions. + + Used when processing exceptions generated by API implementation + methods (or their extensions). Converts most exceptions to Fault + exceptions, with the appropriate logging. + """ + + def __enter__(self): + return None + + def __exit__(self, ex_type, ex_value, ex_traceback): + if not ex_value: + return True + + if isinstance(ex_value, exception.Forbidden): + raise Fault(webob.exc.HTTPForbidden( + explanation=ex_value.format_message())) + elif isinstance(ex_value, exception.VersionNotFoundForAPIMethod): + raise + elif isinstance(ex_value, exception.Invalid): + raise Fault(exception.ConvertedException( + code=ex_value.code, + explanation=ex_value.format_message())) + elif isinstance(ex_value, TypeError): + exc_info = (ex_type, ex_value, ex_traceback) + LOG.error('Exception handling resource: %s', ex_value, + exc_info=exc_info) + raise Fault(webob.exc.HTTPBadRequest()) + elif isinstance(ex_value, Fault): + LOG.info("Fault thrown: %s", ex_value) + raise ex_value + elif isinstance(ex_value, webob.exc.HTTPException): + LOG.info("HTTP exception thrown: %s", ex_value) + raise Fault(ex_value) + + # We didn't handle the exception + return False + + +class Resource(wsgi.Application): + """WSGI app that handles (de)serialization and controller dispatch. + + WSGI app that reads routing information supplied by RoutesMiddleware + and calls the requested action method upon its controller. All + controller action methods must accept a 'req' argument, which is the + incoming wsgi.Request. If the operation is a PUT or POST, the controller + method must also accept a 'body' argument (the deserialized request body). + They may raise a webob.exc exception or return a dict, which will be + serialized by requested content type. + + Exceptions derived from webob.exc.HTTPException will be automatically + wrapped in Fault() to provide API friendly error responses. + + """ + support_api_request_version = False + + def __init__(self, controller, inherits=None): + """:param controller: object that implement methods created by routes + lib + :param inherits: another resource object that this resource should + inherit extensions from. Any action extensions that + are applied to the parent resource will also apply + to this resource. + """ + + self.controller = controller + + self.default_serializers = dict(json=JSONDictSerializer) + + # Copy over the actions dictionary + self.wsgi_actions = {} + if controller: + self.register_actions(controller) + + # Save a mapping of extensions + self.wsgi_extensions = {} + self.wsgi_action_extensions = {} + self.inherits = inherits + + def register_actions(self, controller): + """Registers controller actions with this resource.""" + + actions = getattr(controller, 'wsgi_actions', {}) + for key, method_name in actions.items(): + self.wsgi_actions[key] = getattr(controller, method_name) + + def register_extensions(self, controller): + """Registers controller extensions with this resource.""" + + extensions = getattr(controller, 'wsgi_extensions', []) + for method_name, action_name in extensions: + # Look up the extending method + extension = getattr(controller, method_name) + + if action_name: + # Extending an action... + if action_name not in self.wsgi_action_extensions: + self.wsgi_action_extensions[action_name] = [] + self.wsgi_action_extensions[action_name].append(extension) + else: + # Extending a regular method + if method_name not in self.wsgi_extensions: + self.wsgi_extensions[method_name] = [] + self.wsgi_extensions[method_name].append(extension) + + def get_action_args(self, request_environment): + """Parse dictionary created by routes library.""" + + # NOTE: Check for get_action_args() override in the + # controller + if hasattr(self.controller, 'get_action_args'): + return self.controller.get_action_args(request_environment) + + try: + args = request_environment['wsgiorg.routing_args'][1].copy() + except (KeyError, IndexError, AttributeError): + return {} + + try: + del args['controller'] + except KeyError: + pass + + try: + del args['format'] + except KeyError: + pass + + return args + + def get_body(self, request): + content_type = request.get_content_type() + + return content_type, request.body + + def deserialize(self, body): + return JSONDeserializer().deserialize(body) + + def pre_process_extensions(self, extensions, request, action_args): + # List of callables for post-processing extensions + post = [] + + for ext in extensions: + if inspect.isgeneratorfunction(ext): + response = None + + # If it's a generator function, the part before the + # yield is the preprocessing stage + try: + with ResourceExceptionHandler(): + gen = ext(req=request, **action_args) + response = next(gen) + except Fault as ex: + response = ex + + # We had a response... + if response: + return response, [] + + # No response, queue up generator for post-processing + post.append(gen) + else: + # Regular functions only perform post-processing + post.append(ext) + + # None is response, it means we keep going. We reverse the + # extension list for post-processing. + return None, reversed(post) + + def post_process_extensions(self, extensions, resp_obj, request, + action_args): + for ext in extensions: + response = None + if inspect.isgenerator(ext): + # If it's a generator, run the second half of + # processing + try: + with ResourceExceptionHandler(): + response = ext.send(resp_obj) + except StopIteration: + # Normal exit of generator + continue + except Fault as ex: + response = ex + else: + # Regular functions get post-processing... + try: + with ResourceExceptionHandler(): + response = ext(req=request, resp_obj=resp_obj, + **action_args) + except exception.VersionNotFoundForAPIMethod: + # If an attached extension (@wsgi.extends) for the + # method has no version match its not an error. We + # just don't run the extends code + continue + except Fault as ex: + response = ex + + # We had a response... + if response: + return response + + return None + + def _should_have_body(self, request): + return request.method in _METHODS_WITH_BODY + + @webob.dec.wsgify(RequestClass=Request) + def __call__(self, request): + """WSGI method that controls (de)serialization and method dispatch.""" + + if self.support_api_request_version: + # Set the version of the API requested based on the header + try: + request.set_api_version_request() + except exception.InvalidAPIVersionString as e: + return Fault(webob.exc.HTTPBadRequest( + explanation=e.format_message())) + except exception.InvalidGlobalAPIVersion as e: + return Fault(webob.exc.HTTPNotAcceptable( + explanation=e.format_message())) + + # Identify the action, its arguments, and the requested + # content type + action_args = self.get_action_args(request.environ) + action = action_args.pop('action', None) + + # NOTE: we filter out InvalidContentTypes early so we + # know everything is good from here on out. + try: + content_type, body = self.get_body(request) + accept = request.best_match_content_type() + except exception.InvalidContentType: + msg = _("Unsupported Content-Type") + return Fault(webob.exc.HTTPUnsupportedMediaType(explanation=msg)) + + # NOTE: Splitting the function up this way allows for + # auditing by external tools that wrap the existing + # function. If we try to audit __call__(), we can + # run into troubles due to the @webob.dec.wsgify() + # decorator. + return self._process_stack(request, action, action_args, + content_type, body, accept) + + def _process_stack(self, request, action, action_args, + content_type, body, accept): + """Implement the processing stack.""" + + # Get the implementing method + try: + meth, extensions = self.get_method(request, action, + content_type, body) + except (AttributeError, TypeError): + return Fault(webob.exc.HTTPNotFound()) + except KeyError as ex: + msg = _("There is no such action: %s") % ex.args[0] + return Fault(webob.exc.HTTPBadRequest(explanation=msg)) + except exception.MalformedRequestBody: + msg = _("Malformed request body") + return Fault(webob.exc.HTTPBadRequest(explanation=msg)) + except webob.exc.HTTPMethodNotAllowed as e: + return Fault(e) + + if body: + msg = _("Action: '%(action)s', calling method: %(meth)s, body: " + "%(body)s") % {'action': action, + 'body': six.text_type(body, 'utf-8'), + 'meth': str(meth)} + LOG.debug(strutils.mask_password(msg)) + else: + LOG.debug("Calling method '%(meth)s'", + {'meth': str(meth)}) + + # Now, deserialize the request body... + try: + contents = {} + if self._should_have_body(request): + # allow empty body with PUT and POST + if request.content_length == 0: + contents = {'body': None} + else: + contents = self.deserialize(body) + except exception.MalformedRequestBody: + msg = _("Malformed request body") + return Fault(webob.exc.HTTPBadRequest(explanation=msg)) + + # Update the action args + action_args.update(contents) + + project_id = action_args.pop("project_id", None) + context = request.environ.get('masakari.context') + if (context and project_id and (project_id != context.project_id)): + msg = _("Malformed request URL: URL's project_id '%(project_id)s'" + " doesn't match Context's project_id" + " '%(context_project_id)s'") % { + 'project_id': project_id, + 'context_project_id': context.project_id + } + return Fault(webob.exc.HTTPBadRequest(explanation=msg)) + + # Run pre-processing extensions + response, post = self.pre_process_extensions(extensions, + request, action_args) + + if not response: + try: + with ResourceExceptionHandler(): + action_result = self.dispatch(meth, request, action_args) + except Fault as ex: + response = ex + + if not response: + # No exceptions; convert action_result into a + # ResponseObject + resp_obj = None + if type(action_result) is dict or action_result is None: + resp_obj = ResponseObject(action_result) + elif isinstance(action_result, ResponseObject): + resp_obj = action_result + else: + response = action_result + + # Run post-processing extensions + if resp_obj: + # Do a preserialize to set up the response object + if hasattr(meth, 'wsgi_code'): + resp_obj._default_code = meth.wsgi_code + # Process post-processing extensions + response = self.post_process_extensions(post, resp_obj, + request, action_args) + + if resp_obj and not response: + response = resp_obj.serialize(request, accept) + + if hasattr(response, 'headers'): + for hdr, val in list(response.headers.items()): + if six.PY3: + response.headers[hdr] = str(val) + else: + # Headers must be utf-8 strings + response.headers[hdr] = utils.utf8(val) + + if not request.api_version_request.is_null(): + response.headers[API_VERSION_REQUEST_HEADER] = \ + 'masakari ' + request.api_version_request.get_string() + response.headers.add('Vary', API_VERSION_REQUEST_HEADER) + + return response + + def get_method(self, request, action, content_type, body): + meth, extensions = self._get_method(request, + action, + content_type, + body) + if self.inherits: + _meth, parent_ext = self.inherits.get_method(request, + action, + content_type, + body) + extensions.extend(parent_ext) + return meth, extensions + + def _get_method(self, request, action, content_type, body): + """Look up the action-specific method and its extensions.""" + # Look up the method + try: + if not self.controller: + meth = getattr(self, action) + else: + meth = getattr(self.controller, action) + except AttributeError: + if (not self.wsgi_actions or + action not in _ROUTES_METHODS + ['action']): + if self.controller: + msg = _("The request method: '%(method)s' with action: " + "'%(action)s' is not allowed on this " + "resource") % { + 'method': request.method, 'action': action + } + raise webob.exc.HTTPMethodNotAllowed( + explanation=msg, body_template='${explanation}') + # Propagate the error + raise + else: + return meth, self.wsgi_extensions.get(action, []) + + if action == 'action': + action_name = action_peek(body) + else: + action_name = action + + # Look up the action method + return (self.wsgi_actions[action_name], + self.wsgi_action_extensions.get(action_name, [])) + + def dispatch(self, method, request, action_args): + """Dispatch a call to the action-specific method.""" + + try: + return method(req=request, **action_args) + except exception.VersionNotFoundForAPIMethod: + # We deliberately don't return any message information + # about the exception to the user so it looks as if + # the method is simply not implemented. + return Fault(webob.exc.HTTPNotFound()) + + +class ResourceV1(Resource): + support_api_request_version = True + + +def action(name): + """Mark a function as an action. + + The given name will be taken as the action key in the body. + + This is also overloaded to allow extensions to provide + non-extending definitions of create and delete operations. + """ + + def decorator(func): + func.wsgi_action = name + return func + return decorator + + +def extends(*args, **kwargs): + """Indicate a function extends an operation. + + Can be used as either:: + + @extends + def index(...): + pass + + or as:: + + @extends(action='resize') + def _action_resize(...): + pass + """ + + def decorator(func): + # Store enough information to find what we're extending + func.wsgi_extends = (func.__name__, kwargs.get('action')) + return func + + # If we have positional arguments, call the decorator + if args: + return decorator(*args) + + # OK, return the decorator instead + return decorator + + +class ControllerMetaclass(type): + """Controller metaclass. + + This metaclass automates the task of assembling a dictionary + mapping action keys to method names. + """ + + def __new__(mcs, name, bases, cls_dict): + """Adds the wsgi_actions dictionary to the class.""" + + # Find all actions + actions = {} + extensions = [] + versioned_methods = None + # start with wsgi actions from base classes + for base in bases: + actions.update(getattr(base, 'wsgi_actions', {})) + + if base.__name__ == "Controller": + # NOTE: This resets the VER_METHOD_ATTR attribute + # between API controller class creations. This allows us + # to use a class decorator on the API methods that doesn't + # require naming explicitly what method is being versioned as + # it can be implicit based on the method decorated. It is a bit + # ugly. + if VER_METHOD_ATTR in base.__dict__: + versioned_methods = getattr(base, VER_METHOD_ATTR) + delattr(base, VER_METHOD_ATTR) + + for key, value in cls_dict.items(): + if not callable(value): + continue + if getattr(value, 'wsgi_action', None): + actions[value.wsgi_action] = key + elif getattr(value, 'wsgi_extends', None): + extensions.append(value.wsgi_extends) + + # Add the actions and extensions to the class dict + cls_dict['wsgi_actions'] = actions + cls_dict['wsgi_extensions'] = extensions + if versioned_methods: + cls_dict[VER_METHOD_ATTR] = versioned_methods + + return super(ControllerMetaclass, mcs).__new__(mcs, name, bases, + cls_dict) + + +@six.add_metaclass(ControllerMetaclass) +class Controller(object): + """Default controller.""" + + _view_builder_class = None + + def __init__(self, view_builder=None): + """Initialize controller with a view builder instance.""" + if view_builder: + self._view_builder = view_builder + elif self._view_builder_class: + self._view_builder = self._view_builder_class() + else: + self._view_builder = None + + def __getattribute__(self, key): + + def version_select(*args, **kwargs): + """Look for the method which matches the name supplied and version + constraints and calls it with the supplied arguments. + + @return: Returns the result of the method called + @raises: VersionNotFoundForAPIMethod if there is no method which + matches the name and version constraints + """ + + # The first arg to all versioned methods is always the request + # object. The version for the request is attached to the + # request object + if len(args) == 0: + ver = kwargs['req'].api_version_request + else: + ver = args[0].api_version_request + + func_list = self.versioned_methods[key] + for func in func_list: + if ver.matches(func.start_version, func.end_version): + # Update the version_select wrapper function so + # other decorator attributes like wsgi.response + # are still respected. + functools.update_wrapper(version_select, func.func) + return func.func(self, *args, **kwargs) + + # No version match + raise exception.VersionNotFoundForAPIMethod(version=ver) + + try: + version_meth_dict = object.__getattribute__(self, VER_METHOD_ATTR) + except AttributeError: + # No versioning on this class + return object.__getattribute__(self, key) + + if version_meth_dict and key in object.__getattribute__( + self, VER_METHOD_ATTR): + return version_select + + return object.__getattribute__(self, key) + + # NOTE: This decorator MUST appear first (the outermost + # decorator) on an API method for it to work correctly + @classmethod + def api_version(cls, min_ver, max_ver=None): + """Decorator for versioning api methods. + + Add the decorator to any method which takes a request object + as the first parameter and belongs to a class which inherits from + wsgi.Controller. + + @min_ver: string representing minimum version + @max_ver: optional string representing maximum version + """ + + def decorator(f): + obj_min_ver = api_version.APIVersionRequest(min_ver) + if max_ver: + obj_max_ver = api_version.APIVersionRequest(max_ver) + else: + obj_max_ver = api_version.APIVersionRequest() + + # Add to list of versioned methods registered + func_name = f.__name__ + new_func = versioned_method.VersionedMethod( + func_name, obj_min_ver, obj_max_ver, f) + + func_dict = getattr(cls, VER_METHOD_ATTR, {}) + if not func_dict: + setattr(cls, VER_METHOD_ATTR, func_dict) + + func_list = func_dict.get(func_name, []) + if not func_list: + func_dict[func_name] = func_list + func_list.append(new_func) + # Ensure the list is sorted by minimum version (reversed) + # so later when we work through the list in order we find + # the method which has the latest version which supports + # the version requested. + is_intersect = Controller.check_for_versions_intersection( + func_list) + + if is_intersect: + raise exception.ApiVersionsIntersect( + name=new_func.name, + min_ver=new_func.start_version, + max_ver=new_func.end_version, + ) + + func_list.sort(key=lambda f: f.start_version, reverse=True) + + return f + + return decorator + + @staticmethod + def is_valid_body(body, entity_name): + if not (body and entity_name in body): + return False + + def is_dict(d): + try: + d.get(None) + return True + except AttributeError: + return False + + return is_dict(body[entity_name]) + + @staticmethod + def check_for_versions_intersection(func_list): + """Determines function list contains version intervals intersections. + + General algorithm: + https://en.wikipedia.org/wiki/Intersection_algorithm + + :param func_list: list of VersionedMethod objects + :return: boolean + """ + pairs = [] + counter = 0 + + for f in func_list: + pairs.append((f.start_version, 1, f)) + pairs.append((f.end_version, -1, f)) + + def compare(x): + return x[0] + + pairs.sort(key=compare) + + for p in pairs: + counter += p[1] + + if counter > 1: + return True + + return False + + +class Fault(webob.exc.HTTPException): + """Wrap webob.exc.HTTPException to provide API friendly response.""" + + _fault_names = { + http.BAD_REQUEST: "badRequest", + http.UNAUTHORIZED: "unauthorized", + http.FORBIDDEN: "forbidden", + http.NOT_FOUND: "itemNotFound", + http.METHOD_NOT_ALLOWED: "badMethod", + http.CONFLICT: "conflictingRequest", + http.REQUEST_ENTITY_TOO_LARGE: "overLimit", + http.UNSUPPORTED_MEDIA_TYPE: "badMediaType", + http.NOT_IMPLEMENTED: "notImplemented", + http.SERVICE_UNAVAILABLE: "serviceUnavailable", + # TODO(Dinesh_Bhor) Replace it with symbolic constant when it is + # defined in six.moves.http_client + 429: "overLimit" + } + + def __init__(self, exception): + """Create a Fault for the given webob.exc.exception.""" + self.wrapped_exc = exception + for key, value in list(self.wrapped_exc.headers.items()): + self.wrapped_exc.headers[key] = str(value) + self.status_int = exception.status_int + + @webob.dec.wsgify(RequestClass=Request) + def __call__(self, req): + """Generate a WSGI response based on the exception passed to ctor.""" + + user_locale = req.best_match_language() + # Replace the body with fault details. + code = self.wrapped_exc.status_int + fault_name = self._fault_names.get(code, "masakariFault") + explanation = self.wrapped_exc.explanation + LOG.debug("Returning %(code)s to user: %(explanation)s", + {'code': code, 'explanation': explanation}) + + explanation = i18n.translate(explanation, user_locale) + fault_data = { + fault_name: { + 'code': code, + 'message': explanation}} + if code == http.REQUEST_ENTITY_TOO_LARGE or code == 429: + retry = self.wrapped_exc.headers.get('Retry-After', None) + if retry: + fault_data[fault_name]['retryAfter'] = retry + + if not req.api_version_request.is_null(): + self.wrapped_exc.headers[API_VERSION_REQUEST_HEADER] = \ + 'masakari ' + req.api_version_request.get_string() + self.wrapped_exc.headers.add('Vary', API_VERSION_REQUEST_HEADER) + + self.wrapped_exc.content_type = 'application/json' + self.wrapped_exc.charset = 'UTF-8' + self.wrapped_exc.text = JSONDictSerializer().serialize(fault_data) + + return self.wrapped_exc + + def __str__(self): + return self.wrapped_exc.__str__() diff --git a/src/test-requirements.txt b/src/test-requirements.txt index 7d907c4..4cbbd86 100644 --- a/src/test-requirements.txt +++ b/src/test-requirements.txt @@ -1,20 +1,2 @@ -# charm-proof -charm-tools>=2.0.0 -# amulet deployment helpers -bzr+lp:charm-helpers#egg=charmhelpers -# BEGIN: Amulet OpenStack Charm Helper Requirements -# Liberty client lower constraints -amulet>=1.14.3,<2.0 -bundletester>=0.6.1,<1.0 -python-keystoneclient>=1.7.1,<2.0 -python-designateclient>=1.5,<2.0 -python-cinderclient>=1.4.0,<2.0 -python-glanceclient>=1.1.0,<2.0 -python-heatclient>=0.8.0,<1.0 -python-neutronclient>=3.1.0,<4.0 -python-novaclient>=2.30.1,<3.0 -python-openstackclient>=1.7.0,<2.0 -python-swiftclient>=2.6.0,<3.0 -pika>=0.10.0,<1.0 -distro-info -# END: Amulet OpenStack Charm Helper Requirements \ No newline at end of file +# zaza +git+https://github.com/openstack-charmers/zaza.git#egg=zaza diff --git a/src/tests/README.md b/src/tests/README.md deleted file mode 100644 index a8d9e14..0000000 --- a/src/tests/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Overview - -This directory provides Amulet tests to verify basic deployment functionality -from the perspective of this charm, its requirements and its features, as -exercised in a subset of the full OpenStack deployment test bundle topology. - -For full details on functional testing of OpenStack charms please refer to -the [functional testing](http://docs.openstack.org/developer/charm-guide/testing.html#functional-testing) -section of the OpenStack Charm Guide. \ No newline at end of file diff --git a/src/tests/basic_deployment.py b/src/tests/basic_deployment.py deleted file mode 100644 index 82e14c0..0000000 --- a/src/tests/basic_deployment.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright 2016 Canonical Ltd -# -# 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 amulet -import json -import subprocess -import time - - -import charmhelpers.contrib.openstack.amulet.deployment as amulet_deployment -import charmhelpers.contrib.openstack.amulet.utils as os_amulet_utils - -# Use DEBUG to turn on debug logging -u = os_amulet_utils.OpenStackAmuletUtils(os_amulet_utils.DEBUG) - - -class MasakariCharmDeployment(amulet_deployment.OpenStackAmuletDeployment): - """Amulet tests on a basic masakari deployment.""" - - def __init__(self, series, openstack=None, source=None, stable=False): - """Deploy the entire test environment.""" - super(MasakariCharmDeployment, self).__init__(series, openstack, - source, stable) - self._add_services() - self._add_relations() - self._configure_services() - self._deploy() - - u.log.info('Waiting on extended status checks...') - exclude_services = ['mysql', 'mongodb'] - self._auto_wait_for_status(exclude_services=exclude_services) - - self._initialize_tests() - - def _add_services(self): - """Add services - - Add the services that we're testing, where masakari is local, - and the rest of the service are from lp branches that are - compatible with the local charm (e.g. stable or next). - """ - this_service = {'name': 'masakari'} - other_services = [ - {'name': 'mysql'}, - {'name': 'rabbitmq-server'}, - {'name': 'keystone'}, - ] - super(MasakariCharmDeployment, self)._add_services(this_service, - other_services) - - def _add_relations(self): - """Add all of the relations for the services.""" - relations = { - 'keystone:shared-db': 'mysql:shared-db', - 'masakari:amqp': 'rabbitmq-server:amqp', - 'masakari:identity-service': 'keystone:identity-service', - 'masakari:shared-db': 'mysql:shared-db', - } - super(MasakariCharmDeployment, self)._add_relations(relations) - - def _configure_services(self): - """Configure all of the services.""" - keystone_config = {'admin-password': 'openstack', - 'admin-token': 'ubuntutesting'} - configs = {'keystone': keystone_config} - super(MasakariCharmDeployment, self)._configure_services(configs) - - def _get_token(self): - return self.keystone.service_catalog.catalog['token']['id'] - - def _initialize_tests(self): - """Perform final initialization before tests get run.""" - # Access the sentries for inspecting service units - self.masakari_sentry = self.d.sentry['masakari'][0] - self.mysql_sentry = self.d.sentry['mysql'][0] - self.keystone_sentry = self.d.sentry['keystone'][0] - self.rabbitmq_sentry = self.d.sentry['rabbitmq-server'][0] - self.masakari_svcs = ['haproxy', 'masakari'] - - # Authenticate admin with keystone endpoint - self.keystone = u.authenticate_keystone_admin(self.keystone_sentry, - user='admin', - password='openstack', - tenant='admin') - - def check_and_wait(self, check_command, interval=2, max_wait=200, - desc=None): - waited = 0 - while not check_command() or waited > max_wait: - if desc: - u.log.debug(desc) - time.sleep(interval) - waited = waited + interval - if waited > max_wait: - raise Exception('cmd failed {}'.format(check_command)) - - def _run_action(self, unit_id, action, *args): - command = ["juju", "action", "do", "--format=json", unit_id, action] - command.extend(args) - output = subprocess.check_output(command) - output_json = output.decode(encoding="UTF-8") - data = json.loads(output_json) - action_id = data[u'Action queued with id'] - return action_id - - def _wait_on_action(self, action_id): - command = ["juju", "action", "fetch", "--format=json", action_id] - while True: - try: - output = subprocess.check_output(command) - except Exception as e: - print(e) - return False - output_json = output.decode(encoding="UTF-8") - data = json.loads(output_json) - if data[u"status"] == "completed": - return True - elif data[u"status"] == "failed": - return False - time.sleep(2) - - def test_100_services(self): - """Verify the expected services are running on the corresponding - service units.""" - u.log.debug('Checking system services on units...') - - service_names = { - self.masakari_sentry: self.masakari_svcs, - } - - ret = u.validate_services_by_name(service_names) - if ret: - amulet.raise_status(amulet.FAIL, msg=ret) - - u.log.debug('OK') \ No newline at end of file diff --git a/src/tests/bundles/bionic-rocky.yaml b/src/tests/bundles/bionic-rocky.yaml new file mode 100644 index 0000000..66db38d --- /dev/null +++ b/src/tests/bundles/bionic-rocky.yaml @@ -0,0 +1,156 @@ +series: bionic +relations: +- - nova-compute:amqp + - rabbitmq-server:amqp +- - neutron-gateway:amqp + - rabbitmq-server:amqp +- - neutron-gateway:amqp-nova + - rabbitmq-server:amqp +- - keystone:shared-db + - mysql:shared-db +- - cinder:identity-service + - keystone:identity-service +- - nova-cloud-controller:identity-service + - keystone:identity-service +- - glance:identity-service + - keystone:identity-service +- - neutron-api:identity-service + - keystone:identity-service +- - neutron-openvswitch:neutron-plugin-api + - neutron-api:neutron-plugin-api +- - cinder:shared-db + - mysql:shared-db +- - neutron-api:shared-db + - mysql:shared-db +- - cinder:amqp + - rabbitmq-server:amqp +- - neutron-api:amqp + - rabbitmq-server:amqp +- - neutron-gateway:neutron-plugin-api + - neutron-api:neutron-plugin-api +- - glance:shared-db + - mysql:shared-db +- - glance:amqp + - rabbitmq-server:amqp +- - nova-cloud-controller:image-service + - glance:image-service +- - nova-compute:image-service + - glance:image-service +- - nova-cloud-controller:amqp + - rabbitmq-server:amqp +- - nova-cloud-controller:quantum-network-service + - neutron-gateway:quantum-network-service +- - nova-compute:neutron-plugin + - neutron-openvswitch:neutron-plugin +- - neutron-openvswitch:amqp + - rabbitmq-server:amqp +- - nova-cloud-controller:shared-db + - mysql:shared-db +- - nova-cloud-controller:neutron-api + - neutron-api:neutron-api +- - nova-cloud-controller:cloud-compute + - nova-compute:cloud-compute +- - masakari:shared-db + - mysql:shared-db +- - masakari:amqp + - rabbitmq-server:amqp +- - masakari:identity-service + - keystone:identity-service +- - glance:ceph + - ceph-mon:client +- - ceph-mon:osd + - ceph-osd:mon +- - cinder:storage-backend + - cinder-ceph:storage-backend +- - cinder-ceph:ceph + - ceph-mon:client +- - cinder-ceph:ceph-access + - nova-compute:ceph-access +applications: + glance: + charm: cs:~openstack-charmers-next/glance + num_units: 1 + options: + openstack-origin: cloud:bionic-rocky + worker-multiplier: 0.25 + cinder: + charm: cs:~openstack-charmers-next/cinder + num_units: 1 + options: + block-device: "None" + glance-api-version: 2 + keystone: + charm: cs:~openstack-charmers-next/keystone + num_units: 1 + options: + admin-password: openstack + openstack-origin: cloud:bionic-rocky + worker-multiplier: 0.25 + mysql: + charm: cs:~openstack-charmers-next/percona-cluster + num_units: 1 + options: + innodb-buffer-pool-size: 256M + max-connections: 1000 + neutron-api: + charm: cs:~openstack-charmers-next/neutron-api + num_units: 1 + options: + flat-network-providers: physnet1 + neutron-security-groups: true + openstack-origin: cloud:bionic-rocky + worker-multiplier: 0.25 + neutron-gateway: + charm: cs:~openstack-charmers-next/neutron-gateway + num_units: 1 + options: + bridge-mappings: physnet1:br-ex + openstack-origin: cloud:bionic-rocky + worker-multiplier: 0.25 + neutron-openvswitch: + charm: cs:~openstack-charmers-next/neutron-openvswitch + num_units: 0 + nova-cloud-controller: + charm: cs:~openstack-charmers-next/nova-cloud-controller + num_units: 1 + options: + network-manager: Neutron + openstack-origin: cloud:bionic-rocky + worker-multiplier: 0.25 + debug: true + nova-compute: + charm: cs:~openstack-charmers-next/nova-compute + num_units: 3 + constraints: mem=4G + options: + config-flags: default_ephemeral_format=ext4 + enable-live-migration: true + enable-resize: true + migration-auth-type: ssh + openstack-origin: cloud:bionic-rocky + debug: true + cpu-model: kvm64 + cpu-mode: custom + rabbitmq-server: + charm: cs:~openstack-charmers-next/rabbitmq-server + num_units: 1 + masakari: + charm: masakari + series: bionic + num_units: 1 + options: + openstack-origin: cloud:bionic-rocky + ceph-mon: + charm: ceph-mon + num_units: 3 + options: + expected-osd-count: 3 + ceph-osd: + charm: ceph-osd + constraints: mem=1G + num_units: 3 + storage: + osd-devices: cinder,40G + cinder-ceph: + charm: cinder-ceph + diff --git a/src/tests/gate-basic-trusty-icehouse b/src/tests/gate-basic-trusty-icehouse deleted file mode 100755 index db6500c..0000000 --- a/src/tests/gate-basic-trusty-icehouse +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python -# -# Copyright 2016 Canonical Ltd -# -# 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. - -"""Amulet tests on a basic SDN Charm deployment on trusty-icehouse.""" - -from basic_deployment import MasakariCharmDeployment - -if __name__ == '__main__': - deployment = MasakariCharmDeployment(series='trusty') - deployment.run_tests() \ No newline at end of file diff --git a/src/tests/gate-basic-trusty-liberty b/src/tests/gate-basic-trusty-liberty deleted file mode 100755 index 6fde5e9..0000000 --- a/src/tests/gate-basic-trusty-liberty +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python -# -# Copyright 2016 Canonical Ltd -# -# 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. - -"""Amulet tests on a basic SDN Charm deployment on trusty-liberty.""" - -from basic_deployment import MasakariCharmDeployment - -if __name__ == '__main__': - deployment = MasakariCharmDeployment(series='trusty', - openstack='cloud:trusty-liberty', - source='cloud:trusty-updates/liberty') - deployment.run_tests() \ No newline at end of file diff --git a/src/tests/gate-basic-trusty-mitaka b/src/tests/gate-basic-trusty-mitaka deleted file mode 100755 index 5f12213..0000000 --- a/src/tests/gate-basic-trusty-mitaka +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python -# -# Copyright 2016 Canonical Ltd -# -# 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. - -"""Amulet tests on a basic SDN Charm deployment on trusty-mitaka.""" - -from basic_deployment import MasakariCharmDeployment - -if __name__ == '__main__': - deployment = MasakariCharmDeployment(series='trusty', - openstack='cloud:trusty-mitaka', - source='cloud:trusty-updates/mitaka') - deployment.run_tests() \ No newline at end of file diff --git a/src/tests/gate-basic-xenial-mitaka b/src/tests/gate-basic-xenial-mitaka deleted file mode 100755 index 94f2073..0000000 --- a/src/tests/gate-basic-xenial-mitaka +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python -# -# Copyright 2016 Canonical Ltd -# -# 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. - -"""Amulet tests on a basic SDN Charm deployment on xenial-mitaka.""" - -from basic_deployment import MasakariCharmDeployment - -if __name__ == '__main__': - deployment = MasakariCharmDeployment(series='xenial') - deployment.run_tests() \ No newline at end of file diff --git a/src/tests/tests.yaml b/src/tests/tests.yaml index 8ba143b..853c87c 100644 --- a/src/tests/tests.yaml +++ b/src/tests/tests.yaml @@ -1,17 +1,13 @@ -# Bootstrap the model if necessary. -bootstrap: True -# Re-use bootstrap node instead of destroying/re-bootstrapping. -reset: True -# Use tox/requirements to drive the venv instead of bundletester's venv feature. -virtualenv: False -# Leave makefile empty, otherwise unit/lint tests will rerun ahead of amulet. -makefile: [] -# Do not specify juju PPA sources. Juju is presumed to be pre-installed -# and configured in all test runner environments. -#sources: -# Do not specify or rely on system packages. -#packages: -# Do not specify python packages here. Use test-requirements.txt -# and tox instead. ie. The venv is constructed before bundletester -# is invoked. -#python-packages: \ No newline at end of file +charm_name: masakari +tests: + - zaza.charm_tests.nova.tests.CirrosGuestCreateTest +configure: + - zaza.charm_tests.glance.setup.add_cirros_image + - zaza.charm_tests.glance.setup.add_lts_image + - zaza.charm_tests.neutron.setup.basic_overcloud_network + - zaza.charm_tests.nova.setup.create_flavors + - zaza.charm_tests.nova.setup.manage_ssh_key +gate_bundles: + - bionic-rocky +smoke_bundles: + - bionic-rocky diff --git a/src/tox.ini b/src/tox.ini index 879671c..ce45106 100644 --- a/src/tox.ini +++ b/src/tox.ini @@ -1,6 +1,3 @@ -# Source charm: ./src/tox.ini -# This file is managed centrally by release-tools and should not be modified -# within individual charm repos. [tox] envlist = pep8 skipsdist = True @@ -8,46 +5,31 @@ skipsdist = True [testenv] setenv = VIRTUAL_ENV={envdir} PYTHONHASHSEED=0 - AMULET_SETUP_TIMEOUT=2700 whitelist_externals = juju -passenv = HOME TERM AMULET_* +passenv = HOME TERM CS_API_* OS_* AMULET_* deps = -r{toxinidir}/test-requirements.txt install_command = - pip install --allow-unverified python-apt {opts} {packages} + pip install {opts} {packages} [testenv:pep8] -basepython = python2.7 +basepython = python3 +deps=charm-tools commands = charm-proof -[testenv:func27-noop] -# DRY RUN - For Debug -basepython = python2.7 +[testenv:func-noop] +basepython = python3 commands = - bundletester -vl DEBUG -r json -o func-results.json --test-pattern "gate-*" -n --no-destroy + true -[testenv:func27] -# Run all gate tests which are +x (expected to always pass) -basepython = python2.7 +[testenv:func] +basepython = python3 commands = - bundletester -vl DEBUG -r json -o func-results.json --test-pattern "gate-*" --no-destroy + functest-run-suite --keep-model -[testenv:func27-smoke] -# Run a specific test as an Amulet smoke test (expected to always pass) -basepython = python2.7 +[testenv:func-smoke] +basepython = python3 commands = - bundletester -vl DEBUG -r json -o func-results.json gate-basic-xenial-mitaka --no-destroy - -[testenv:func27-dfs] -# Run all deploy-from-source tests which are +x (may not always pass!) -basepython = python2.7 -commands = - bundletester -vl DEBUG -r json -o func-results.json --test-pattern "dfs-*" --no-destroy - -[testenv:func27-dev] -# Run all development test targets which are +x (may not always pass!) -basepython = python2.7 -commands = - bundletester -vl DEBUG -r json -o func-results.json --test-pattern "dev-*" --no-destroy + functest-run-suite --keep-model --smoke [testenv:venv] -commands = {posargs} \ No newline at end of file +commands = {posargs}