From a5f99317acb24f757651e4a840d0e17324084300 Mon Sep 17 00:00:00 2001 From: Paul Bourke Date: Mon, 25 Jan 2016 15:25:35 +0000 Subject: [PATCH] Add bare bones ekko-api Foundation for ekko-api, ported over from the Ironic project. The goal here was to copy enough to give a solid foundation that conforms to the "OpenStack way", without a lot of extra frills that we either may not need right away, or makes it harder see what's going on starting out. After installation the service can be started via 'ekko-api', which listens on port 6800 by default. This was chosen from the list of unassigned ports at http://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.txt There are still plenty of TODO's, primarily keystone integration and config file generation. Foundations for these are here but completely untested as of this commit. Change-Id: If136a3bb66949ef710d741eaf3691f36f7b60692 --- .testr.conf | 2 +- ekko/api/__init__.py | 58 +++++ ekko/api/app.py | 95 ++++++++ ekko/api/config.py | 39 ++++ {tests => ekko/api/controllers}/__init__.py | 0 ekko/api/controllers/base.py | 112 +++++++++ ekko/api/controllers/root.py | 111 +++++++++ ekko/api/controllers/v1/__init__.py | 119 ++++++++++ ekko/api/controllers/v1/versions.py | 32 +++ tests/test_noop.py => ekko/api/expose.py | 16 +- ekko/api/middleware/__init__.py | 22 ++ ekko/api/middleware/auth_token.py | 64 ++++++ ekko/api/middleware/parsable_error.py | 93 ++++++++ ekko/cmd/__init__.py | 0 ekko/cmd/api.py | 34 +++ ekko/common/__init__.py | 0 ekko/common/config.py | 26 +++ ekko/common/exception.py | 131 +++++++++++ ekko/common/service.py | 109 +++++++++ ekko/tests/base.py | 149 ++++++++++-- ekko/tests/unit/__init__.py | 0 ekko/tests/unit/api/__init__.py | 0 ekko/tests/unit/api/base.py | 237 ++++++++++++++++++++ ekko/tests/unit/api/test_base.py | 117 ++++++++++ ekko/tests/unit/conf_fixture.py | 37 +++ requirements.txt | 7 + setup.cfg | 2 + tox.ini | 1 + 28 files changed, 1588 insertions(+), 25 deletions(-) create mode 100644 ekko/api/__init__.py create mode 100644 ekko/api/app.py create mode 100644 ekko/api/config.py rename {tests => ekko/api/controllers}/__init__.py (100%) create mode 100644 ekko/api/controllers/base.py create mode 100644 ekko/api/controllers/root.py create mode 100644 ekko/api/controllers/v1/__init__.py create mode 100644 ekko/api/controllers/v1/versions.py rename tests/test_noop.py => ekko/api/expose.py (68%) create mode 100644 ekko/api/middleware/__init__.py create mode 100644 ekko/api/middleware/auth_token.py create mode 100644 ekko/api/middleware/parsable_error.py create mode 100644 ekko/cmd/__init__.py create mode 100644 ekko/cmd/api.py create mode 100644 ekko/common/__init__.py create mode 100644 ekko/common/config.py create mode 100644 ekko/common/exception.py create mode 100644 ekko/common/service.py create mode 100644 ekko/tests/unit/__init__.py create mode 100644 ekko/tests/unit/api/__init__.py create mode 100644 ekko/tests/unit/api/base.py create mode 100644 ekko/tests/unit/api/test_base.py create mode 100644 ekko/tests/unit/conf_fixture.py 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}'