diff --git a/.testr.conf b/.testr.conf index db65e39..5baea03 100644 --- a/.testr.conf +++ b/.testr.conf @@ -2,6 +2,6 @@ test_command=OS_STDOUT_CAPTURE=${OS_STDOUT_CAPTURE:-1} \ OS_STDERR_CAPTURE=${OS_STDERR_CAPTURE:-1} \ OS_TEST_TIMEOUT=${OS_TEST_TIMEOUT:-7200} \ - ${PYTHON:-python} -m subunit.run discover ${OS_TEST_PATH:-./tests} $LISTOPT $IDOPTION + ${PYTHON:-python} -m subunit.run discover ${OS_TEST_PATH:-./ekko/tests} $LISTOPT $IDOPTION test_id_option=--load-list $IDFILE test_list_option=--list diff --git a/ekko/api/__init__.py b/ekko/api/__init__.py new file mode 100644 index 0000000..c5a60f3 --- /dev/null +++ b/ekko/api/__init__.py @@ -0,0 +1,58 @@ +# +# 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. + +from oslo_config import cfg + +# TODO(pbourke): add i18n support +_ = lambda x: x + +API_SERVICE_OPTS = [ + cfg.StrOpt('host_ip', + default='0.0.0.0', + help=_('The IP address on which ekko-api listens.')), + cfg.PortOpt('port', + default=6800, + help=_('The TCP port on which ekko-api listens.')), + cfg.IntOpt('max_limit', + default=1000, + help=_('The maximum number of items returned in a single ' + 'response from a collection resource.')), + cfg.StrOpt('public_endpoint', + default=None, + # TODO(pbourke): decide on port for ekko + help=_("Public URL to use when building the links to the API " + "resources (for example, \"https://ekko.rocks:6800\")." + " If None the links will be built using the request's " + "host URL. If the API is operating behind a proxy, you " + "will want to change this to represent the proxy's URL. " + "Defaults to None.")), + cfg.IntOpt('api_workers', + help=_('Number of workers for OpenStack ekko API service. ' + 'The default is equal to the number of CPUs available ' + 'if that can be determined, else a default worker ' + 'count of 1 is returned.')), + cfg.BoolOpt('enable_ssl_api', + default=False, + help=_("Enable the integrated stand-alone API to service " + "requests via HTTPS instead of HTTP. If there is a " + "front-end service performing HTTPS offloading from " + "the service, this option should be False; note, you " + "will want to change public API endpoint to represent " + "SSL termination URL with 'public_endpoint' option.")), +] + +CONF = cfg.CONF +opt_group = cfg.OptGroup(name='api', + title='Options for the ekko-api service') +CONF.register_group(opt_group) +CONF.register_opts(API_SERVICE_OPTS, opt_group) diff --git a/ekko/api/app.py b/ekko/api/app.py new file mode 100644 index 0000000..9871d87 --- /dev/null +++ b/ekko/api/app.py @@ -0,0 +1,95 @@ +# +# 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 pecan + +from oslo_config import cfg + +from ekko.api import config +# TODO(pbourke): port hooks module +# from ekko.api import hooks +from ekko.api import middleware + +# TODO(pbourke): add i18n support +_ = lambda x: x + +api_opts = [ + cfg.StrOpt( + 'auth_strategy', + default='keystone', + help=_('Authentication strategy used by ekko-api: one of "keystone" ' + 'or "noauth". "noauth" should not be used in a production ' + 'environment because all authentication will be disabled.')), + cfg.BoolOpt('debug_tracebacks_in_api', + default=False, + help=_('Return server tracebacks in the API response for any ' + 'error responses. WARNING: this is insecure ' + 'and should not be used in a production environment.')), + cfg.BoolOpt('pecan_debug', + default=False, + help=_('Enable pecan debug mode. WARNING: this is insecure ' + 'and should not be used in a production environment.')), +] + +CONF = cfg.CONF +CONF.register_opts(api_opts) + + +def get_pecan_config(): + # Set up the pecan configuration + filename = config.__file__.replace('.pyc', '.py') + return pecan.configuration.conf_from_file(filename) + + +def setup_app(pecan_config=None, extra_hooks=None): + # TODO(pbourke): port hooks module + # app_hooks = [hooks.ConfigHook(), + # hooks.DBHook(), + # hooks.ContextHook(pecan_config.app.acl_public_routes), + # hooks.RPCHook(), + # hooks.NoExceptionTracebackHook(), + # hooks.PublicUrlHook()] + # if extra_hooks: + # app_hooks.extend(extra_hooks) + + if not pecan_config: + pecan_config = get_pecan_config() + + # if pecan_config.app.enable_acl: + # app_hooks.append(hooks.TrustedCallHook()) + + pecan.configuration.set_config(dict(pecan_config), overwrite=True) + + app = pecan.make_app( + pecan_config.app.root, + static_root=pecan_config.app.static_root, + debug=CONF.pecan_debug, + force_canonical=getattr(pecan_config.app, 'force_canonical', True), + # hooks=app_hooks, + wrap_app=middleware.ParsableErrorMiddleware, + ) + + # if pecan_config.app.enable_acl: + # app = acl.install(app, cfg.CONF, pecan_config.app.acl_public_routes) + + return app + + +class VersionSelectorApplication(object): + def __init__(self): + pc = get_pecan_config() + pc.app.enable_acl = (CONF.auth_strategy == 'keystone') + self.v1 = setup_app(pecan_config=pc) + + def __call__(self, environ, start_response): + return self.v1(environ, start_response) diff --git a/ekko/api/config.py b/ekko/api/config.py new file mode 100644 index 0000000..f738d42 --- /dev/null +++ b/ekko/api/config.py @@ -0,0 +1,39 @@ +# +# 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. + +# Server Specific Configurations +# See https://pecan.readthedocs.org/en/latest/configuration.html#server-configuration # noqa +server = { + 'port': '6800', + 'host': '0.0.0.0' +} + +# Pecan Application Configurations +# See https://pecan.readthedocs.org/en/latest/configuration.html#application-configuration # noqa +app = { + 'root': 'ekko.api.controllers.root.RootController', + 'modules': ['ekko.api'], + 'static_root': '%(confdir)s/public', + 'debug': False, + 'enable_acl': True, + 'acl_public_routes': [ + '/', + '/v1', + ], +} + +# WSME Configurations +# See https://wsme.readthedocs.org/en/latest/integrate.html#configuration +wsme = { + 'debug': False, +} diff --git a/tests/__init__.py b/ekko/api/controllers/__init__.py similarity index 100% rename from tests/__init__.py rename to ekko/api/controllers/__init__.py diff --git a/ekko/api/controllers/base.py b/ekko/api/controllers/base.py new file mode 100644 index 0000000..ff2832a --- /dev/null +++ b/ekko/api/controllers/base.py @@ -0,0 +1,112 @@ +# +# 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 datetime +import functools + +from webob import exc +import wsme +from wsme import types as wtypes + +# TODO(pbourke): add i18n support +_ = lambda x: x + + +class APIBase(wtypes.Base): + + created_at = wsme.wsattr(datetime.datetime, readonly=True) + """The time in UTC at which the object is created""" + + updated_at = wsme.wsattr(datetime.datetime, readonly=True) + """The time in UTC at which the object is updated""" + + def as_dict(self): + """Render this object as a dict of its fields.""" + return dict((k, getattr(self, k)) + for k in self.fields + if hasattr(self, k) and + getattr(self, k) != wsme.Unset) + + def unset_fields_except(self, except_list=None): + """Unset fields so they don't appear in the message body. + + :param except_list: A list of fields that won't be touched. + + """ + if except_list is None: + except_list = [] + + for k in self.as_dict(): + if k not in except_list: + setattr(self, k, wsme.Unset) + + +@functools.total_ordering +class Version(object): + """API Version object.""" + + string = 'X-OpenStack-Ekko-API-Version' + """HTTP Header string carrying the requested version""" + + min_string = 'X-OpenStack-Ekko-API-Minimum-Version' + """HTTP response header""" + + max_string = 'X-OpenStack-Ekko-API-Maximum-Version' + """HTTP response header""" + + def __init__(self, headers, default_version, latest_version): + """Create an API Version object from the supplied headers. + + :param headers: webob headers + :param default_version: version to use if not specified in headers + :param latest_version: version to use if latest is requested + :raises: webob.HTTPNotAcceptable + """ + (self.major, self.minor) = Version.parse_headers( + headers, default_version, latest_version) + + def __repr__(self): + return '%s.%s' % (self.major, self.minor) + + @staticmethod + def parse_headers(headers, default_version, latest_version): + """Determine the API version requested based on the headers supplied. + + :param headers: webob headers + :param default_version: version to use if not specified in headers + :param latest_version: version to use if latest is requested + :returns: a tupe of (major, minor) version numbers + :raises: webob.HTTPNotAcceptable + """ + version_str = headers.get(Version.string, default_version) + + if version_str.lower() == 'latest': + parse_str = latest_version + else: + parse_str = version_str + + try: + version = tuple(int(i) for i in parse_str.split('.')) + except ValueError: + version = () + + if len(version) != 2: + raise exc.HTTPNotAcceptable(_( + "Invalid value for %s header") % Version.string) + return version + + def __gt__(a, b): + return (a.major, a.minor) > (b.major, b.minor) + + def __eq__(a, b): + return (a.major, a.minor) == (b.major, b.minor) diff --git a/ekko/api/controllers/root.py b/ekko/api/controllers/root.py new file mode 100644 index 0000000..a3df428 --- /dev/null +++ b/ekko/api/controllers/root.py @@ -0,0 +1,111 @@ +# +# 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 pecan +from pecan import rest +from wsme import types as wtypes + +from ekko.api.controllers import base +from ekko.api.controllers import v1 +from ekko.api.controllers.v1 import versions +from ekko.api import expose + +ID_VERSION1 = 'v1' + + +class Version(base.APIBase): + """An API version representation. + + This class represents an API version, including the minimum and + maximum minor versions that are supported within the major version. + """ + + id = wtypes.text + """The ID of the (major) version, also acts as the release number""" + + status = wtypes.text + """Status of the version. + + One of: + * CURRENT - the latest version of API, + * SUPPORTED - supported, but not latest, version of API, + * DEPRECATED - supported, but deprecated, version of API. + """ + + version = wtypes.text + """The current, maximum supported (major.minor) version of API.""" + + min_version = wtypes.text + """Minimum supported (major.minor) version of API.""" + + def __init__(self, id, min_version, version, status='CURRENT'): + self.id = id + self.status = status + self.version = version + self.min_version = min_version + + +class Root(base.APIBase): + + name = wtypes.text + """The name of the API""" + + description = wtypes.text + """Some information about this API""" + + versions = [Version] + """Links to all the versions available in this API""" + + default_version = Version + """A link to the default version of the API""" + + @staticmethod + def convert(): + root = Root() + root.name = "OpenStack Ekko API" + root.description = ("Block-based backups stored in object-storage.") + root.default_version = Version(ID_VERSION1, + versions.MIN_VERSION_STRING, + versions.MAX_VERSION_STRING) + root.versions = [root.default_version] + return root + + +class RootController(rest.RestController): + + _versions = [ID_VERSION1] + """All supported API versions""" + + _default_version = ID_VERSION1 + """The default API version""" + + v1 = v1.Controller() + + @expose.expose(Root) + def get(self): + # NOTE: The reason why convert() it's being called for every + # request is because we need to get the host url from + # the request object to make the links. + return Root.convert() + + @pecan.expose() + def _route(self, args): + """Overrides the default routing behavior. + + It redirects the request to the default version of the ekko API + if the version number is not specified in the url. + """ + + if args[0] and args[0] not in self._versions: + args = [self._default_version] + args + return super(RootController, self)._route(args) diff --git a/ekko/api/controllers/v1/__init__.py b/ekko/api/controllers/v1/__init__.py new file mode 100644 index 0000000..eeeebed --- /dev/null +++ b/ekko/api/controllers/v1/__init__.py @@ -0,0 +1,119 @@ +# 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 pecan +from pecan import rest +from webob import exc +from wsme import types as wtypes + +from ekko.api.controllers import base +from ekko.api.controllers.v1 import versions +from ekko.api import expose + +# TODO(pbourke): add i18n support +_ = lambda x: x + +BASE_VERSION = versions.BASE_VERSION + +MIN_VER = base.Version( + {base.Version.string: versions.MIN_VERSION_STRING}, + versions.MIN_VERSION_STRING, versions.MAX_VERSION_STRING) +MAX_VER = base.Version( + {base.Version.string: versions.MAX_VERSION_STRING}, + versions.MIN_VERSION_STRING, versions.MAX_VERSION_STRING) + + +class MediaType(base.APIBase): + """A media type representation.""" + + base = wtypes.text + type = wtypes.text + + def __init__(self, base, type): + self.base = base + self.type = type + + +class V1(base.APIBase): + """The representation of the version 1 of the API.""" + + id = wtypes.text + """The ID of the version, also acts as the release number""" + + media_types = [MediaType] + """An array of supported media types for this version""" + + @staticmethod + def convert(): + v1 = V1() + v1.id = "v1" + + v1.media_types = [MediaType('application/json', + 'application/vnd.openstack.ekko.v1+json')] + + return v1 + + +class Controller(rest.RestController): + """Version 1 API controller root.""" + + @expose.expose(V1) + def get(self): + # NOTE: The reason why convert() it's being called for every + # request is because we need to get the host url from + # the request object to make the links. + return V1.convert() + + def _check_version(self, version, headers=None): + if headers is None: + headers = {} + # ensure that major version in the URL matches the header + if version.major != BASE_VERSION: + raise exc.HTTPNotAcceptable(_( + "Mutually exclusive versions requested. Version %(ver)s " + "requested but not supported by this service. The supported " + "version range is: [%(min)s, %(max)s].") % + {'ver': version, 'min': versions.MIN_VERSION_STRING, + 'max': versions.MAX_VERSION_STRING}, + headers=headers) + # ensure the minor version is within the supported range + if version < MIN_VER or version > MAX_VER: + raise exc.HTTPNotAcceptable(_( + "Version %(ver)s was requested but the minor version is not " + "supported by this service. The supported version range is: " + "[%(min)s, %(max)s].") % + {'ver': version, 'min': versions.MIN_VERSION_STRING, + 'max': versions.MAX_VERSION_STRING}, + headers=headers) + + @pecan.expose() + def _route(self, args): + v = base.Version(pecan.request.headers, versions.MIN_VERSION_STRING, + versions.MAX_VERSION_STRING) + + # Always set the min and max headers + pecan.response.headers[base.Version.min_string] = ( + versions.MIN_VERSION_STRING) + pecan.response.headers[base.Version.max_string] = ( + versions.MAX_VERSION_STRING) + + # assert that requested version is supported + self._check_version(v, pecan.response.headers) + pecan.response.headers[base.Version.string] = str(v) + pecan.request.version = v + + return super(Controller, self)._route(args) + + +__all__ = (Controller) diff --git a/ekko/api/controllers/v1/versions.py b/ekko/api/controllers/v1/versions.py new file mode 100644 index 0000000..c2b9d84 --- /dev/null +++ b/ekko/api/controllers/v1/versions.py @@ -0,0 +1,32 @@ +# +# 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. + +# This is the version 1 API +BASE_VERSION = 1 + +# Here goes a short log of changes in every version. +# Refer to doc/source/webapi/v1.rst for a detailed explanation of what +# each version contains. +# +# v1.0: Initial version of the Ekko API + +MINOR_0_INITIAL_VERSION = 0 + +# When adding another version, update MINOR_MAX_VERSION and also update +# doc/source/webapi/v1.rst with a detailed explanation of what the version has +# changed. +MINOR_MAX_VERSION = MINOR_0_INITIAL_VERSION + +# String representations of the minor and maximum versions +MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_0_INITIAL_VERSION) +MAX_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_MAX_VERSION) diff --git a/tests/test_noop.py b/ekko/api/expose.py similarity index 68% rename from tests/test_noop.py rename to ekko/api/expose.py index 3e949c5..9412211 100644 --- a/tests/test_noop.py +++ b/ekko/api/expose.py @@ -1,3 +1,4 @@ +# # 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 @@ -10,14 +11,11 @@ # License for the specific language governing permissions and limitations # under the License. - -from oslotest import base +import wsmeext.pecan as wsme_pecan -class NoopTest(base.BaseTestCase): - - def setUp(self): - super(NoopTest, self).setUp() - - def test_noop(self): - self.assertTrue(True) +def expose(*args, **kwargs): + """Ensure that only JSON, and not XML, is supported.""" + if 'rest_content_types' not in kwargs: + kwargs['rest_content_types'] = ('json',) + return wsme_pecan.wsexpose(*args, **kwargs) diff --git a/ekko/api/middleware/__init__.py b/ekko/api/middleware/__init__.py new file mode 100644 index 0000000..5266385 --- /dev/null +++ b/ekko/api/middleware/__init__.py @@ -0,0 +1,22 @@ +# +# 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. + +from ekko.api.middleware import auth_token +from ekko.api.middleware import parsable_error + + +ParsableErrorMiddleware = parsable_error.ParsableErrorMiddleware +AuthTokenMiddleware = auth_token.AuthTokenMiddleware + +__all__ = (ParsableErrorMiddleware, + AuthTokenMiddleware) diff --git a/ekko/api/middleware/auth_token.py b/ekko/api/middleware/auth_token.py new file mode 100644 index 0000000..484bd57 --- /dev/null +++ b/ekko/api/middleware/auth_token.py @@ -0,0 +1,64 @@ +# +# 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 re + +from keystonemiddleware import auth_token +from oslo_log import log + +from ekko.common import exception + +LOG = log.getLogger(__name__) + +# TODO(pbourke): add i18n support +_ = lambda x: x + + +class AuthTokenMiddleware(auth_token.AuthProtocol): + """A wrapper on Keystone auth_token middleware. + + Does not perform verification of authentication tokens + for public routes in the API. + + """ + def __init__(self, app, conf, public_api_routes=[]): + self._ekko_app = app + # TODO(mrda): Remove .xml and ensure that doesn't result in a + # 401 Authentication Required instead of 404 Not Found + route_pattern_tpl = '%s(\.json|\.xml)?$' + + try: + self.public_api_routes = [re.compile(route_pattern_tpl % route_tpl) + for route_tpl in public_api_routes] + except re.error as e: + msg = _('Cannot compile public API routes: %s') % e + + LOG.error(msg) + raise exception.ConfigInvalid(error_msg=msg) + + super(AuthTokenMiddleware, self).__init__(app, conf) + + def __call__(self, env, start_response): + # TODO(pbourke): ironic uses a utils.safe_rstrip function here. + path = env.get('PATH_INFO').rstrip('/') + + # The information whether the API call is being performed against the + # public API is required for some other components. Saving it to the + # WSGI environment is reasonable thereby. + env['is_public_api'] = any(map(lambda pattern: re.match(pattern, path), + self.public_api_routes)) + + if env['is_public_api']: + return self._ekko_app(env, start_response) + + return super(AuthTokenMiddleware, self).__call__(env, start_response) diff --git a/ekko/api/middleware/parsable_error.py b/ekko/api/middleware/parsable_error.py new file mode 100644 index 0000000..40c5d61 --- /dev/null +++ b/ekko/api/middleware/parsable_error.py @@ -0,0 +1,93 @@ +# +# 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. + +""" +Middleware to replace the plain text message body of an error +response with one formatted so the client can parse it. + +Based on pecan.middleware.errordocument +""" + +import json +from xml import etree as et + +from oslo_log import log +import six +import webob + + +LOG = log.getLogger(__name__) + +# TODO(pbourke): add i18n support +_ = _LE = lambda x: x + + +class ParsableErrorMiddleware(object): + """Replace error body with something the client can parse.""" + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + # Request for this state, modified by replace_start_response() + # and used when an error is being reported. + state = {} + + def replacement_start_response(status, headers, exc_info=None): + """Overrides the default response to make errors parsable.""" + try: + status_code = int(status.split(' ')[0]) + state['status_code'] = status_code + except (ValueError, TypeError): # pragma: nocover + raise Exception(_( + 'ErrorDocumentMiddleware received an invalid ' + 'status %s') % status) + else: + if (state['status_code'] // 100) not in (2, 3): + # Remove some headers so we can replace them later + # when we have the full error message and can + # compute the length. + headers = [(h, v) + for (h, v) in headers + if h not in ('Content-Length', 'Content-Type') + ] + # Save the headers in case we need to modify them. + state['headers'] = headers + return start_response(status, headers, exc_info) + + app_iter = self.app(environ, replacement_start_response) + if (state['status_code'] // 100) not in (2, 3): + req = webob.Request(environ) + if (req.accept.best_match(['application/json', 'application/xml']) + == 'application/xml'): + try: + # simple check xml is valid + body = [et.ElementTree.tostring( + et.ElementTree.fromstring('' + + '\n'.join(app_iter) + + ''))] + except et.ElementTree.ParseError as err: + LOG.error(_LE('Error parsing HTTP response: %s'), err) + body = ['%s' % state['status_code'] + + ''] + state['headers'].append(('Content-Type', 'application/xml')) + else: + if six.PY3: + app_iter = [i.decode('utf-8') for i in app_iter] + body = [json.dumps({'error_message': '\n'.join(app_iter)})] + if six.PY3: + body = [item.encode('utf-8') for item in body] + state['headers'].append(('Content-Type', 'application/json')) + state['headers'].append(('Content-Length', str(len(body[0])))) + else: + body = app_iter + return body diff --git a/ekko/cmd/__init__.py b/ekko/cmd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ekko/cmd/api.py b/ekko/cmd/api.py new file mode 100644 index 0000000..bd4a0a5 --- /dev/null +++ b/ekko/cmd/api.py @@ -0,0 +1,34 @@ +# +# 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 sys + +from oslo_config import cfg + +from ekko.common import service as ekko_service + +CONF = cfg.CONF + + +def main(): + # Parse config file and command line options, then start logging + ekko_service.prepare_service(sys.argv) + + # Build and start the WSGI app + launcher = ekko_service.process_launcher() + server = ekko_service.WSGIService('ekko_api', CONF.api.enable_ssl_api) + launcher.launch_service(server, workers=server.workers) + launcher.wait() + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ekko/common/__init__.py b/ekko/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ekko/common/config.py b/ekko/common/config.py new file mode 100644 index 0000000..5a34570 --- /dev/null +++ b/ekko/common/config.py @@ -0,0 +1,26 @@ +# +# 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. + +from oslo_config import cfg + +# from ekko.common import rpc +# from ekko import version + + +def parse_args(argv, default_config_files=None): + # rpc.set_defaults(control_exchange='ekko') + cfg.CONF(argv[1:], + project='ekko', + # version=version.version_info.release_string(), + default_config_files=default_config_files) + # rpc.init(cfg.CONF) diff --git a/ekko/common/exception.py b/ekko/common/exception.py new file mode 100644 index 0000000..064461b --- /dev/null +++ b/ekko/common/exception.py @@ -0,0 +1,131 @@ +# +# 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. + +from oslo_config import cfg +from oslo_log import log as logging +import six +from six.moves import http_client + +# TODO(pbourke): add i18n support +_ = _LE = _LW = lambda x: x + + +LOG = logging.getLogger(__name__) + +exc_log_opts = [ + cfg.BoolOpt('fatal_exception_format_errors', + default=False, + help=_('Used if there is a formatting error when generating ' + 'an exception message (a programming error). If True, ' + 'raise an exception; if False, use the unformatted ' + 'message.')), +] + +CONF = cfg.CONF +CONF.register_opts(exc_log_opts) + + +class EkkoException(Exception): + """Base Ekko Exception + + To correctly use this class, inherit from it and define + a '_msg_fmt' property. That message will get printf'd + with the keyword arguments provided to the constructor. + + If you need to access the message from an exception you should use + six.text_type(exc) + + """ + _msg_fmt = _("An unknown exception occurred.") + code = http_client.INTERNAL_SERVER_ERROR + headers = {} + safe = False + + def __init__(self, message=None, **kwargs): + self.kwargs = kwargs + + if 'code' not in self.kwargs: + try: + self.kwargs['code'] = self.code + except AttributeError: + pass + + if not message: + # Check if class is using deprecated 'message' attribute. + if (hasattr(self, 'message') and self.message): + LOG.warning(_LW("Exception class: %s Using the 'message' " + "attribute in an exception has been " + "deprecated. The exception class should be " + "modified to use the '_msg_fmt' " + "attribute."), self.__class__.__name__) + self._msg_fmt = self.message + + try: + message = self._msg_fmt % kwargs + + except Exception as e: + # kwargs doesn't match a variable in self._msg_fmt + # log the issue and the kwargs + LOG.exception(_LE('Exception in string format operation')) + for name, value in kwargs.items(): + LOG.error("%s: %s" % (name, value)) + + if CONF.fatal_exception_format_errors: + raise e + else: + # at least get the core self._msg_fmt out if something + # happened + message = self._msg_fmt + + super(EkkoException, self).__init__(message) + + def __str__(self): + """Encode to utf-8 then wsme api can consume it as well.""" + if not six.PY3: + return unicode(self.args[0]).encode('utf-8') + + return self.args[0] + + def __unicode__(self): + """Return a unicode representation of the exception message.""" + return unicode(self.args[0]) + + +class NotAuthorized(EkkoException): + _msg_fmt = _("Not authorized.") + code = http_client.FORBIDDEN + + +class OperationNotPermitted(NotAuthorized): + _msg_fmt = _("Operation not permitted.") + + +class Invalid(EkkoException): + _msg_fmt = _("Unacceptable parameters.") + code = http_client.BAD_REQUEST + + +class Conflict(EkkoException): + _msg_fmt = _('Conflict.') + code = http_client.CONFLICT + + +class TemporaryFailure(EkkoException): + _msg_fmt = _("Resource temporarily unavailable, please retry.") + code = http_client.SERVICE_UNAVAILABLE + + +class NotAcceptable(EkkoException): + # TODO(deva): We need to set response headers in the API for this exception + _msg_fmt = _("Request not acceptable.") + code = http_client.NOT_ACCEPTABLE diff --git a/ekko/common/service.py b/ekko/common/service.py new file mode 100644 index 0000000..be2b2d9 --- /dev/null +++ b/ekko/common/service.py @@ -0,0 +1,109 @@ +# +# 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 socket + +from oslo_concurrency import processutils +from oslo_config import cfg +from oslo_log import log +from oslo_service import service +from oslo_service import wsgi + +from ekko.api import app +from ekko.common import config + +# TODO(pbourke): add i18n support +_ = lambda x: x + + +service_opts = [ + cfg.IntOpt('periodic_interval', + default=60, + help=_('Seconds between running periodic tasks.')), + cfg.StrOpt('host', + default=socket.getfqdn(), + help=_('Name of this node. This can be an opaque identifier. ' + 'It is not necessarily a hostname, FQDN, or IP address. ' + 'However, the node name must be valid within ' + 'an AMQP key, and if using ZeroMQ, a valid ' + 'hostname, FQDN, or IP address.')), +] + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + +CONF.register_opts(service_opts) + + +def prepare_service(argv=[]): + log.register_options(CONF) + config.parse_args(argv) + log.setup(CONF, 'ekko') + + +def process_launcher(): + return service.ProcessLauncher(CONF) + + +class WSGIService(service.ServiceBase): + """Provides ability to launch ekko API from wsgi app.""" + + def __init__(self, name, use_ssl=False): + """Initialize, but do not start the WSGI server. + + :param name: The name of the WSGI server given to the loader. + :param use_ssl: Wraps the socket in an SSL context if True. + :returns: None + """ + self.name = name + self.app = app.VersionSelectorApplication() + self.workers = (CONF.api.api_workers or + processutils.get_worker_count()) + if self.workers and self.workers < 1: + raise Exception( + _("api_workers value of %d is invalid, " + "must be greater than 0.") % self.workers) + + self.server = wsgi.Server(CONF, name, self.app, + host=CONF.api.host_ip, + port=CONF.api.port, + use_ssl=use_ssl) + + def start(self): + """Start serving this service using loaded configuration. + + :returns: None + """ + self.server.start() + + def stop(self): + """Stop serving this API. + + :returns: None + """ + self.server.stop() + + def wait(self): + """Wait for the service to stop serving this API. + + :returns: None + """ + self.server.wait() + + def reset(self): + """Reset server greenpool size to default. + + :returns: None + """ + self.server.reset() diff --git a/ekko/tests/base.py b/ekko/tests/base.py index 1c30cdb..bc07e11 100644 --- a/ekko/tests/base.py +++ b/ekko/tests/base.py @@ -1,23 +1,142 @@ -# -*- coding: utf-8 -*- - -# Copyright 2010-2011 OpenStack Foundation -# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. # -# 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 +# 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 +# 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. +# 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. -from oslotest import base +"""Base classes for our unit tests. + +Allows overriding of config for use of fakes, and some black magic for +inline callbacks. + +""" + +# import copy +import os +import sys +# import tempfile + +import eventlet +eventlet.monkey_patch(os=False) +import fixtures +from oslo_config import cfg +from oslo_context import context as ekko_context +from oslo_log import log as logging +import testtools + +# from ekko.objects import base as objects_base +from ekko.tests.unit import conf_fixture +# from ekko.tests.unit import policy_fixture -class TestCase(base.BaseTestCase): +CONF = cfg.CONF +logging.register_options(CONF) +CONF.set_override('use_stderr', False) +logging.setup(CONF, 'ekko') + + +class ReplaceModule(fixtures.Fixture): + """Replace a module with a fake module.""" + + def __init__(self, name, new_value): + self.name = name + self.new_value = new_value + + def _restore(self, old_value): + sys.modules[self.name] = old_value + + def setUp(self): + super(ReplaceModule, self).setUp() + old_value = sys.modules.get(self.name) + sys.modules[self.name] = self.new_value + self.addCleanup(self._restore, old_value) + + +class TestingException(Exception): + pass + + +class TestCase(testtools.TestCase): """Test case base class for all unit tests.""" + + def setUp(self): + """Run before each test method to initialize test environment.""" + super(TestCase, self).setUp() + self.context = ekko_context.get_admin_context() + test_timeout = os.environ.get('OS_TEST_TIMEOUT', 0) + try: + test_timeout = int(test_timeout) + except ValueError: + # If timeout value is invalid do not set a timeout. + test_timeout = 0 + if test_timeout > 0: + self.useFixture(fixtures.Timeout(test_timeout, gentle=True)) + self.useFixture(fixtures.NestedTempfile()) + self.useFixture(fixtures.TempHomeDir()) + # self.config(tempdir=tempfile.tempdir) + + 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)) + + self.log_fixture = self.useFixture(fixtures.FakeLogger()) + self.useFixture(conf_fixture.ConfFixture(CONF)) + + # NOTE(danms): Make sure to reset us back to non-remote objects + # for each test to avoid interactions. Also, backup the object + # registry + # objects_base.EkkoObject.indirection_api = None + # self._base_test_obj_backup = copy.copy( + # objects_base.EkkoObjectRegistry.obj_classes()) + # self.addCleanup(self._restore_obj_registry) + + self.addCleanup(self._clear_attrs) + self.useFixture(fixtures.EnvironmentVariable('http_proxy')) + # self.policy = self.useFixture(policy_fixture.PolicyFixture()) + # CONF.set_override('fatal_exception_format_errors', True) + + # def _restore_obj_registry(self): + # objects_base.EkkoObjectRegistry._registry._obj_classes = ( + # self._base_test_obj_backup) + + def _clear_attrs(self): + # Delete attributes that don't start with _ so they don't pin + # memory around unnecessarily for the duration of the test + # suite + for key in [k for k in self.__dict__.keys() if k[0] != '_']: + del self.__dict__[key] + + def config(self, **kw): + """Override config options for a test.""" + group = kw.pop('group', None) + for k, v in kw.items(): + CONF.set_override(k, v, group) + + def path_get(self, project_file=None): + """Get the absolute path to a file. Used for testing the API. + + :param project_file: File whose path to return. Default: None. + :returns: path to the specified file, or path to project root. + """ + root = os.path.abspath(os.path.join(os.path.dirname(__file__), + '..', + '..', + ) + ) + if project_file: + return os.path.join(root, project_file) + else: + return root diff --git a/ekko/tests/unit/__init__.py b/ekko/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ekko/tests/unit/api/__init__.py b/ekko/tests/unit/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ekko/tests/unit/api/base.py b/ekko/tests/unit/api/base.py new file mode 100644 index 0000000..620a5db --- /dev/null +++ b/ekko/tests/unit/api/base.py @@ -0,0 +1,237 @@ +# +# 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. + +# NOTE: Ported from ceilometer/tests/api.py (subsequently moved to +# ceilometer/tests/api/__init__.py). This should be oslo'ified: +# https://bugs.launchpad.net/ekko/+bug/1255115. + +import mock +from oslo_config import cfg +import pecan +import pecan.testing +from six.moves.urllib import parse as urlparse + +# from ekko.tests.unit.db import base +from ekko.tests import base + +PATH_PREFIX = '/v1' + +cfg.CONF.import_group('keystone_authtoken', 'keystonemiddleware.auth_token') + + +# class BaseApiTest(base.DbTestCase): +class BaseApiTest(base.TestCase): + """Pecan controller functional testing class. + + Used for functional tests of Pecan controllers where you need to + test your literal application and its integration with the + framework. + """ + + SOURCE_DATA = {'test_source': {'somekey': '666'}} + + def setUp(self): + super(BaseApiTest, self).setUp() + cfg.CONF.set_override("auth_version", "v2.0", + group='keystone_authtoken') + cfg.CONF.set_override("admin_user", "admin", + group='keystone_authtoken') + self.app = self._make_app() + + def reset_pecan(): + pecan.set_config({}, overwrite=True) + + self.addCleanup(reset_pecan) + + p = mock.patch('ekko.api.controllers.v1.Controller._check_version') + self._check_version = p.start() + self.addCleanup(p.stop) + + def _make_app(self, enable_acl=False): + # Determine where we are so we can set up paths in the config + root_dir = self.path_get() + + self.config = { + 'app': { + 'root': 'ekko.api.controllers.root.RootController', + 'modules': ['ekko.api'], + 'static_root': '%s/public' % root_dir, + 'template_path': '%s/api/templates' % root_dir, + 'enable_acl': enable_acl, + 'acl_public_routes': ['/', '/v1'], + }, + } + + return pecan.testing.load_test_app(self.config) + + def _request_json(self, path, params, expect_errors=False, headers=None, + method="post", extra_environ=None, status=None, + path_prefix=PATH_PREFIX): + """Sends simulated HTTP request to Pecan test app. + + :param path: url path of target service + :param params: content for wsgi.input of request + :param expect_errors: Boolean value; whether an error is expected based + on request + :param headers: a dictionary of headers to send along with the request + :param method: Request method type. Appropriate method function call + should be used rather than passing attribute in. + :param extra_environ: a dictionary of environ variables to send along + with the request + :param status: expected status code of response + :param path_prefix: prefix of the url path + """ + full_path = path_prefix + path + print('%s: %s %s' % (method.upper(), full_path, params)) + response = getattr(self.app, "%s_json" % method)( + str(full_path), + params=params, + headers=headers, + status=status, + extra_environ=extra_environ, + expect_errors=expect_errors + ) + print('GOT:%s' % response) + return response + + def put_json(self, path, params, expect_errors=False, headers=None, + extra_environ=None, status=None): + """Sends simulated HTTP PUT request to Pecan test app. + + :param path: url path of target service + :param params: content for wsgi.input of request + :param expect_errors: Boolean value; whether an error is expected based + on request + :param headers: a dictionary of headers to send along with the request + :param extra_environ: a dictionary of environ variables to send along + with the request + :param status: expected status code of response + """ + return self._request_json(path=path, params=params, + expect_errors=expect_errors, + headers=headers, extra_environ=extra_environ, + status=status, method="put") + + def post_json(self, path, params, expect_errors=False, headers=None, + extra_environ=None, status=None): + """Sends simulated HTTP POST request to Pecan test app. + + :param path: url path of target service + :param params: content for wsgi.input of request + :param expect_errors: Boolean value; whether an error is expected based + on request + :param headers: a dictionary of headers to send along with the request + :param extra_environ: a dictionary of environ variables to send along + with the request + :param status: expected status code of response + """ + return self._request_json(path=path, params=params, + expect_errors=expect_errors, + headers=headers, extra_environ=extra_environ, + status=status, method="post") + + def patch_json(self, path, params, expect_errors=False, headers=None, + extra_environ=None, status=None): + """Sends simulated HTTP PATCH request to Pecan test app. + + :param path: url path of target service + :param params: content for wsgi.input of request + :param expect_errors: Boolean value; whether an error is expected based + on request + :param headers: a dictionary of headers to send along with the request + :param extra_environ: a dictionary of environ variables to send along + with the request + :param status: expected status code of response + """ + return self._request_json(path=path, params=params, + expect_errors=expect_errors, + headers=headers, extra_environ=extra_environ, + status=status, method="patch") + + def delete(self, path, expect_errors=False, headers=None, + extra_environ=None, status=None, path_prefix=PATH_PREFIX): + """Sends simulated HTTP DELETE request to Pecan test app. + + :param path: url path of target service + :param expect_errors: Boolean value; whether an error is expected based + on request + :param headers: a dictionary of headers to send along with the request + :param extra_environ: a dictionary of environ variables to send along + with the request + :param status: expected status code of response + :param path_prefix: prefix of the url path + """ + full_path = path_prefix + path + print('DELETE: %s' % (full_path)) + response = self.app.delete(str(full_path), + headers=headers, + status=status, + extra_environ=extra_environ, + expect_errors=expect_errors) + print('GOT:%s' % response) + return response + + def get_json(self, path, expect_errors=False, headers=None, + extra_environ=None, q=[], path_prefix=PATH_PREFIX, **params): + """Sends simulated HTTP GET request to Pecan test app. + + :param path: url path of target service + :param expect_errors: Boolean value;whether an error is expected based + on request + :param headers: a dictionary of headers to send along with the request + :param extra_environ: a dictionary of environ variables to send along + with the request + :param q: list of queries consisting of: field, value, op, and type + keys + :param path_prefix: prefix of the url path + :param params: content for wsgi.input of request + """ + full_path = path_prefix + path + query_params = {'q.field': [], + 'q.value': [], + 'q.op': [], + } + for query in q: + for name in ['field', 'op', 'value']: + query_params['q.%s' % name].append(query.get(name, '')) + all_params = {} + all_params.update(params) + if q: + all_params.update(query_params) + print('GET: %s %r' % (full_path, all_params)) + response = self.app.get(full_path, + params=all_params, + headers=headers, + extra_environ=extra_environ, + expect_errors=expect_errors) + if not expect_errors: + response = response.json + print('GOT:%s' % response) + return response + + def validate_link(self, link, bookmark=False): + """Checks if the given link can get correct data.""" + # removes the scheme and net location parts of the link + url_parts = list(urlparse.urlparse(link)) + url_parts[0] = url_parts[1] = '' + + # bookmark link should not have the version in the URL + if bookmark and url_parts[2].startswith(PATH_PREFIX): + return False + + full_path = urlparse.urlunparse(url_parts) + try: + self.get_json(full_path, path_prefix='') + return True + except Exception: + return False diff --git a/ekko/tests/unit/api/test_base.py b/ekko/tests/unit/api/test_base.py new file mode 100644 index 0000000..4de5ebd --- /dev/null +++ b/ekko/tests/unit/api/test_base.py @@ -0,0 +1,117 @@ +# +# 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 mock +from six.moves import http_client +from webob import exc + +from ekko.api.controllers import base as cbase +from ekko.tests.unit.api import base + + +class TestBase(base.BaseApiTest): + + def test_api_setup(self): + pass + + def test_bad_uri(self): + response = self.get_json('/bad/path', + expect_errors=True, + headers={"Accept": "application/json"}) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + self.assertEqual("application/json", response.content_type) + self.assertTrue(response.json['error_message']) + + +class TestVersion(base.BaseApiTest): + + @mock.patch('ekko.api.controllers.base.Version.parse_headers') + def test_init(self, mock_parse): + a = mock.Mock() + b = mock.Mock() + mock_parse.return_value = (a, b) + v = cbase.Version('test', 'foo', 'bar') + + mock_parse.assert_called_with('test', 'foo', 'bar') + self.assertEqual(a, v.major) + self.assertEqual(b, v.minor) + + @mock.patch('ekko.api.controllers.base.Version.parse_headers') + def test_repr(self, mock_parse): + mock_parse.return_value = (123, 456) + v = cbase.Version('test', mock.ANY, mock.ANY) + result = "%s" % v + self.assertEqual('123.456', result) + + @mock.patch('ekko.api.controllers.base.Version.parse_headers') + def test_repr_with_strings(self, mock_parse): + mock_parse.return_value = ('abc', 'def') + v = cbase.Version('test', mock.ANY, mock.ANY) + result = "%s" % v + self.assertEqual('abc.def', result) + + def test_parse_headers_ok(self): + version = cbase.Version.parse_headers( + {cbase.Version.string: '123.456'}, mock.ANY, mock.ANY) + self.assertEqual((123, 456), version) + + def test_parse_headers_latest(self): + for s in ['latest', 'LATEST']: + version = cbase.Version.parse_headers( + {cbase.Version.string: s}, mock.ANY, '1.9') + self.assertEqual((1, 9), version) + + def test_parse_headers_bad_length(self): + self.assertRaises( + exc.HTTPNotAcceptable, + cbase.Version.parse_headers, + {cbase.Version.string: '1'}, + mock.ANY, + mock.ANY) + self.assertRaises( + exc.HTTPNotAcceptable, + cbase.Version.parse_headers, + {cbase.Version.string: '1.2.3'}, + mock.ANY, + mock.ANY) + + def test_parse_no_header(self): + # this asserts that the minimum version string of "1.1" is applied + version = cbase.Version.parse_headers({}, '1.1', '1.5') + self.assertEqual((1, 1), version) + + def test_equals(self): + ver_1 = cbase.Version( + {cbase.Version.string: '123.456'}, mock.ANY, mock.ANY) + ver_2 = cbase.Version( + {cbase.Version.string: '123.456'}, mock.ANY, mock.ANY) + self.assertTrue(hasattr(ver_1, '__eq__')) + self.assertTrue(ver_1 == ver_2) + + def test_greaterthan(self): + ver_1 = cbase.Version( + {cbase.Version.string: '123.457'}, mock.ANY, mock.ANY) + ver_2 = cbase.Version( + {cbase.Version.string: '123.456'}, mock.ANY, mock.ANY) + self.assertTrue(hasattr(ver_1, '__gt__')) + self.assertTrue(ver_1 > ver_2) + + def test_lessthan(self): + # __lt__ is created by @functools.total_ordering, make sure it exists + # and works + ver_1 = cbase.Version( + {cbase.Version.string: '123.456'}, mock.ANY, mock.ANY) + ver_2 = cbase.Version( + {cbase.Version.string: '123.457'}, mock.ANY, mock.ANY) + self.assertTrue(hasattr(ver_1, '__lt__')) + self.assertTrue(ver_1 < ver_2) diff --git a/ekko/tests/unit/conf_fixture.py b/ekko/tests/unit/conf_fixture.py new file mode 100644 index 0000000..7770c0b --- /dev/null +++ b/ekko/tests/unit/conf_fixture.py @@ -0,0 +1,37 @@ +# +# 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 fixtures +from oslo_config import cfg + +from ekko.common import config + +CONF = cfg.CONF +CONF.import_opt('host', 'ekko.common.service') + + +class ConfFixture(fixtures.Fixture): + """Fixture to manage global conf settings.""" + + def __init__(self, conf): + self.conf = conf + + def setUp(self): + super(ConfFixture, self).setUp() + + self.conf.set_default('host', 'fake-mini') + # self.conf.set_default('connection', "sqlite://", group='database') + # self.conf.set_default('sqlite_synchronous', False, group='database') + self.conf.set_default('verbose', True) + config.parse_args([], default_config_files=[]) + self.addCleanup(self.conf.reset) diff --git a/requirements.txt b/requirements.txt index 7e74d9d..1d9a51d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,10 @@ pbr>=1.6 # Apache-2.0 six>=1.9.0 # MIT oslo.utils>=3.4.0 # Apache-2.0 stevedore>=1.5.0 # Apache-2.0 +oslo.concurrency>=2.3.0 # Apache-2.0 +oslo.config>=3.4.0 # Apache-2.0 +oslo.log>=1.14.0 # Apache-2.0 +oslo.service>=1.0.0 # Apache-2.0 +pecan>=1.0.0 # BSD +WSME>=0.8 # MIT +keystonemiddleware>=4.0.0,!=4.1.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 9aaceed..7fee618 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,6 +33,8 @@ ekko.storage.compression_drivers = zlib = ekko.storage._compression_drivers.zlib_:ZlibCompression ekko.storage.encryption_drivers = noop = ekko.storage._encryption_drivers.noop:NoopEncryption +console_scripts = + ekko-api = ekko.cmd.api:main [files] packages = diff --git a/tox.ini b/tox.ini index 16c831d..0b52857 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ install_command = pip install -U {opts} {packages} setenv = VIRTUAL_ENV={envdir} + TESTS_DIR=./ekko/tests/unit/ deps = -r{toxinidir}/test-requirements.txt commands = python setup.py testr --slowest --testr-args='{posargs}'