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}'