diff --git a/qinling b/qinling deleted file mode 160000 index e089c31e..00000000 --- a/qinling +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e089c31e73c1e313293ea794a25a30b15b4dad8e diff --git a/qinling/__init__.py b/qinling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qinling/api/__init__.py b/qinling/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qinling/api/access_control.py b/qinling/api/access_control.py new file mode 100644 index 00000000..a6107697 --- /dev/null +++ b/qinling/api/access_control.py @@ -0,0 +1,81 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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. + +"""Access Control API server.""" + +from keystonemiddleware import auth_token +from oslo_config import cfg +from oslo_policy import policy + +from qinling import exceptions as exc + +_ENFORCER = None + + +def setup(app): + if cfg.CONF.pecan.auth_enable: + conf = dict(cfg.CONF.keystone_authtoken) + + # Change auth decisions of requests to the app itself. + conf.update({'delay_auth_decision': True}) + + _ensure_enforcer_initialization() + + return auth_token.AuthProtocol(app, conf) + else: + return app + + +def enforce(action, context, target=None, do_raise=True, + exc=exc.NotAllowedException): + """Verifies that the action is valid on the target in this context. + + :param action: String, representing the action to be checked. + This should be colon separated for clarity. + i.e. ``workflows:create`` + :param context: Mistral context. + :param target: Dictionary, representing the object of the action. + For object creation, this should be a dictionary + representing the location of the object. + e.g. ``{'project_id': context.project_id}`` + :param do_raise: if True (the default), raises specified exception. + :param exc: Exception to be raised if not authorized. Default is + mistral.exceptions.NotAllowedException. + + :return: returns True if authorized and False if not authorized and + do_raise is False. + """ + + target_obj = { + 'project_id': context.project_id, + 'user_id': context.user_id, + } + + target_obj.update(target or {}) + _ensure_enforcer_initialization() + + return _ENFORCER.enforce( + action, + target_obj, + context.to_dict(), + do_raise=do_raise, + exc=exc + ) + + +def _ensure_enforcer_initialization(): + global _ENFORCER + if not _ENFORCER: + _ENFORCER = policy.Enforcer(cfg.CONF) + _ENFORCER.load_rules() diff --git a/qinling/api/app.py b/qinling/api/app.py new file mode 100644 index 00000000..55504f52 --- /dev/null +++ b/qinling/api/app.py @@ -0,0 +1,53 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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 +import pecan + +from qinling.api import access_control +from qinling import context as ctx + + +def get_pecan_config(): + # Set up the pecan configuration. + opts = cfg.CONF.pecan + + cfg_dict = { + "app": { + "root": opts.root, + "modules": opts.modules, + "debug": opts.debug, + "auth_enable": opts.auth_enable + } + } + + return pecan.configuration.conf_from_dict(cfg_dict) + + +def setup_app(config=None): + if not config: + config = get_pecan_config() + + app_conf = dict(config.app) + app = pecan.make_app( + app_conf.pop('root'), + hooks=lambda: [ctx.ContextHook(), ctx.AuthHook()], + logging=getattr(config, 'logging', {}), + **app_conf + ) + + # Set up access control. + app = access_control.setup(app) + + return app diff --git a/qinling/api/controllers/__init__.py b/qinling/api/controllers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qinling/api/controllers/root.py b/qinling/api/controllers/root.py new file mode 100644 index 00000000..d1b85d35 --- /dev/null +++ b/qinling/api/controllers/root.py @@ -0,0 +1,78 @@ +# Copyright 2013 - Mirantis, Inc. +# +# 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_log import log as logging +import pecan +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from qinling.api.controllers.v1 import resources +from qinling.api.controllers.v1 import root as v1_root + +LOG = logging.getLogger(__name__) + +API_STATUS = wtypes.Enum(str, 'SUPPORTED', 'CURRENT', 'DEPRECATED') + + +class APIVersion(resources.Resource): + """An API Version.""" + + id = wtypes.text + "The version identifier." + + status = API_STATUS + "The status of the API (SUPPORTED, CURRENT or DEPRECATED)." + + links = wtypes.ArrayType(resources.Link) + "The link to the versioned API." + + @classmethod + def sample(cls): + return cls( + id='v1.0', + status='CURRENT', + links=[ + resources.Link(target_name='v1', rel="self", + href='http://example.com:7070/v1') + ] + ) + + +class APIVersions(resources.Resource): + """API Versions.""" + versions = wtypes.ArrayType(APIVersion) + + @classmethod + def sample(cls): + v1 = APIVersion(id='v1.0', status='CURRENT', rel="self", + href='http://example.com:7070/v1') + return cls(versions=[v1]) + + +class RootController(object): + v1 = v1_root.Controller() + + @wsme_pecan.wsexpose(APIVersions) + def index(self): + LOG.info("Fetching API versions.") + + host_url_v1 = '%s/%s' % (pecan.request.host_url, 'v1') + api_v1 = APIVersion( + id='v1.0', + status='CURRENT', + links=[resources.Link(href=host_url_v1, target='v1', + rel="self", )] + ) + + return APIVersions(versions=[api_v1]) diff --git a/qinling/api/controllers/v1/__init__.py b/qinling/api/controllers/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qinling/api/controllers/v1/function.py b/qinling/api/controllers/v1/function.py new file mode 100644 index 00000000..3edfefd2 --- /dev/null +++ b/qinling/api/controllers/v1/function.py @@ -0,0 +1,67 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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 json + +from oslo_log import log as logging +import pecan +from pecan import rest +import wsmeext.pecan as wsme_pecan + +from qinling.api.controllers.v1 import resources +from qinling import exceptions as exc +from qinling.utils import rest_utils + +LOG = logging.getLogger(__name__) + +POST_REQUIRED = set(['name', 'runtime', 'code']) + + +class FunctionsController(rest.RestController): + @rest_utils.wrap_pecan_controller_exception + @pecan.expose() + def post(self, **kwargs): + """Create a new function. + + :param func: Function object. + """ + LOG.info("Create function, params=%s", kwargs) + + if not POST_REQUIRED.issubset(set(kwargs.keys())): + raise exc.InputException( + 'Required param is missing. Required: %s' % POST_REQUIRED + ) + + func = resources.Function() + + func.name = kwargs['name'] + func.runtime = kwargs['runtime'] + func.code = json.loads(kwargs['code']) + + if func.code.get('package', False): + data = kwargs['package'].file.read() + print data + + pecan.response.status = 201 + return func.to_json() + + @rest_utils.wrap_wsme_controller_exception + @wsme_pecan.wsexpose(resources.Functions) + def get_all(self): + LOG.info("Get all functions.") + + funcs = resources.Functions() + funcs.functions = [] + + return funcs diff --git a/qinling/api/controllers/v1/resources.py b/qinling/api/controllers/v1/resources.py new file mode 100644 index 00000000..af8e4e87 --- /dev/null +++ b/qinling/api/controllers/v1/resources.py @@ -0,0 +1,213 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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 json +from wsme import types as wtypes + +from qinling.api.controllers.v1 import types + +PROVIDER_TYPES = wtypes.Enum(str, 'docker', 'fission') + + +class Resource(wtypes.Base): + """REST API Resource.""" + + _wsme_attributes = [] + + def to_dict(self): + d = {} + + for attr in self._wsme_attributes: + attr_val = getattr(self, attr.name) + if not isinstance(attr_val, wtypes.UnsetType): + d[attr.name] = attr_val + + return d + + @classmethod + def from_dict(cls, d): + obj = cls() + + for key, val in d.items(): + if hasattr(obj, key): + setattr(obj, key, val) + + return obj + + def __str__(self): + """WSME based implementation of __str__.""" + + res = "%s [" % type(self).__name__ + + first = True + for attr in self._wsme_attributes: + if not first: + res += ', ' + else: + first = False + + res += "%s='%s'" % (attr.name, getattr(self, attr.name)) + + return res + "]" + + def to_json(self): + return json.dumps(self.to_dict()) + + @classmethod + def get_fields(cls): + obj = cls() + + return [attr.name for attr in obj._wsme_attributes] + + +class ResourceList(Resource): + """Resource containing the list of other resources.""" + + next = wtypes.text + """A link to retrieve the next subset of the resource list""" + + @property + def collection(self): + return getattr(self, self._type) + + @classmethod + def convert_with_links(cls, resources, limit, url=None, fields=None, + **kwargs): + resource_collection = cls() + + setattr(resource_collection, resource_collection._type, resources) + + resource_collection.next = resource_collection.get_next( + limit, + url=url, + fields=fields, + **kwargs + ) + + return resource_collection + + def has_next(self, limit): + """Return whether resources has more items.""" + return len(self.collection) and len(self.collection) == limit + + def get_next(self, limit, url=None, fields=None, **kwargs): + """Return a link to the next subset of the resources.""" + if not self.has_next(limit): + return wtypes.Unset + + q_args = ''.join( + ['%s=%s&' % (key, value) for key, value in kwargs.items()] + ) + + resource_args = ( + '?%(args)slimit=%(limit)d&marker=%(marker)s' % + { + 'args': q_args, + 'limit': limit, + 'marker': self.collection[-1].id + } + ) + + # Fields is handled specially here, we can move it above when it's + # supported by all resources query. + if fields: + resource_args += '&fields=%s' % fields + + next_link = "%(host_url)s/v2/%(resource)s%(args)s" % { + 'host_url': url, + 'resource': self._type, + 'args': resource_args + } + + return next_link + + def to_dict(self): + d = {} + + for attr in self._wsme_attributes: + attr_val = getattr(self, attr.name) + + if isinstance(attr_val, list): + if isinstance(attr_val[0], Resource): + d[attr.name] = [v.to_dict() for v in attr_val] + elif not isinstance(attr_val, wtypes.UnsetType): + d[attr.name] = attr_val + + return d + + +class Link(Resource): + """Web link.""" + + href = wtypes.text + target = wtypes.text + rel = wtypes.text + + @classmethod + def sample(cls): + return cls(href='http://example.com/here', + target='here', rel='self') + + +class Function(Resource): + """Function resource.""" + + id = wtypes.text + name = wtypes.text + description = wtypes.text + memorysize = int + timeout = int + runtime = wtypes.text + code = types.jsontype + provider = PROVIDER_TYPES + created_at = wtypes.text + updated_at = wtypes.text + + @classmethod + def sample(cls): + return cls( + id='123e4567-e89b-12d3-a456-426655440000', + name='hello_world', + description='this is the first function.', + memorysize=1, + timeout=1, + runtime='python2.7', + code={'zip': True}, + provider='docker', + created_at='1970-01-01T00:00:00.000000', + updated_at='1970-01-01T00:00:00.000000' + ) + + +class Functions(ResourceList): + """A collection of Function resources.""" + + functions = [Function] + + def __init__(self, **kwargs): + self._type = 'functions' + + super(Functions, self).__init__(**kwargs) + + @classmethod + def sample(cls): + sample = cls() + sample.functions = [Function.sample()] + sample.next = ( + "http://localhost:7070/v1/functions?" + "sort_keys=id,name&sort_dirs=asc,desc&limit=10&" + "marker=123e4567-e89b-12d3-a456-426655440000" + ) + + return sample diff --git a/qinling/api/controllers/v1/root.py b/qinling/api/controllers/v1/root.py new file mode 100644 index 00000000..0df47226 --- /dev/null +++ b/qinling/api/controllers/v1/root.py @@ -0,0 +1,39 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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 wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from qinling.api.controllers.v1 import function +from qinling.api.controllers.v1 import resources + + +class RootResource(resources.Resource): + """Root resource for API version 1. + + It references all other resources belonging to the API. + """ + + uri = wtypes.text + + +class Controller(object): + """API root controller for version 1.""" + + functions = function.FunctionsController() + + @wsme_pecan.wsexpose(RootResource) + def index(self): + return RootResource(uri='%s/%s' % (pecan.request.host_url, 'v1')) diff --git a/qinling/api/controllers/v1/types.py b/qinling/api/controllers/v1/types.py new file mode 100644 index 00000000..658d3e5c --- /dev/null +++ b/qinling/api/controllers/v1/types.py @@ -0,0 +1,125 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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 json + +from oslo_utils import uuidutils +import six +from wsme import types as wtypes + +from qinling import exceptions as exc + + +class ListType(wtypes.UserType): + """A simple list type.""" + + basetype = wtypes.text + name = 'list' + + @staticmethod + def validate(value): + """Validate and convert the input to a ListType. + + :param value: A comma separated string of values + :returns: A list of values. + """ + items = [v.strip().lower() for v in six.text_type(value).split(',')] + + # remove empty items. + return [x for x in items if x] + + @staticmethod + def frombasetype(value): + return ListType.validate(value) if value is not None else None + + +class UniqueListType(ListType): + """A simple list type with no duplicate items.""" + + name = 'uniquelist' + + @staticmethod + def validate(value): + """Validate and convert the input to a UniqueListType. + + :param value: A comma separated string of values. + :returns: A list with no duplicate items. + """ + items = ListType.validate(value) + + seen = set() + + return [x for x in items if not (x in seen or seen.add(x))] + + @staticmethod + def frombasetype(value): + return UniqueListType.validate(value) if value is not None else None + + +class UuidType(wtypes.UserType): + """A simple UUID type. + + The builtin UuidType class in wsme.types doesn't work properly with pecan. + """ + + basetype = wtypes.text + name = 'uuid' + + @staticmethod + def validate(value): + if not uuidutils.is_uuid_like(value): + raise exc.InputException( + "Expected a uuid but received %s." % value + ) + + return value + + @staticmethod + def frombasetype(value): + return UuidType.validate(value) if value is not None else None + + +class JsonType(wtypes.UserType): + """A simple JSON type.""" + + basetype = wtypes.text + name = 'json' + + def validate(self, value): + if not value: + return {} + + if not isinstance(value, dict): + raise exc.InputException( + 'JsonType field value must be a dictionary [actual=%s]' % value + ) + + return value + + def frombasetype(self, value): + if isinstance(value, dict): + return value + try: + return json.loads(value) if value is not None else None + except TypeError as e: + raise ValueError(e) + + def tobasetype(self, value): + # Value must be a dict. + return json.dumps(value) if value is not None else None + + +uuid = UuidType() +list = ListType() +uniquelist = UniqueListType() +jsontype = JsonType() diff --git a/qinling/api/service.py b/qinling/api/service.py new file mode 100644 index 00000000..937fb04e --- /dev/null +++ b/qinling/api/service.py @@ -0,0 +1,52 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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_concurrency import processutils +from oslo_config import cfg +from oslo_service import service +from oslo_service import wsgi + +from qinling.api import app + + +class WSGIService(service.ServiceBase): + """Provides ability to launch Mistral API from wsgi app.""" + + def __init__(self, name): + self.name = name + self.app = app.setup_app() + self.workers = ( + cfg.CONF.api.api_workers or processutils.get_worker_count() + ) + + self.server = wsgi.Server( + cfg.CONF, + name, + self.app, + host=cfg.CONF.api.host, + port=cfg.CONF.api.port, + use_ssl=cfg.CONF.api.enable_ssl_api + ) + + def start(self): + self.server.start() + + def stop(self): + self.server.stop() + + def wait(self): + self.server.wait() + + def reset(self): + self.server.reset() diff --git a/qinling/cmd/__init__.py b/qinling/cmd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qinling/cmd/launch.py b/qinling/cmd/launch.py new file mode 100644 index 00000000..6899ed1e --- /dev/null +++ b/qinling/cmd/launch.py @@ -0,0 +1,150 @@ +# Copyright 2017 - Catalyst IT Limited +# +# 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 + +import eventlet + +eventlet.monkey_patch( + os=True, + select=True, + socket=True, + thread=False if '--use-debugger' in sys.argv else True, + time=True) + +import os # noqa + +# If ../qingling/__init__.py exists, add ../ to Python search path, so that +# it will override what happens to be installed in /usr/(local/)lib/python... +POSSIBLE_TOPDIR = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]), + os.pardir, + os.pardir)) +if os.path.exists(os.path.join(POSSIBLE_TOPDIR, 'qinling', '__init__.py')): + sys.path.insert(0, POSSIBLE_TOPDIR) + +from oslo_config import cfg # noqa +from oslo_log import log as logging # noqa +from oslo_service import service # noqa + +from qinling.api import service as api_service # noqa +from qinling import config # noqa +from qinling import version # noqa + +CONF = cfg.CONF + + +def launch_api(): + launcher = service.ProcessLauncher(cfg.CONF) + + server = api_service.WSGIService('qinling_api') + + launcher.launch_service(server, workers=server.workers) + + launcher.wait() + + +def launch_any(options): + # Launch the servers on different threads. + threads = [eventlet.spawn(LAUNCH_OPTIONS[option]) + for option in options] + + [thread.wait() for thread in threads] + + +LAUNCH_OPTIONS = { + 'api': launch_api, +} + +QINLING_TITLE = r""" + /^L_ ,."\ + /~\ __ /~ \ ./ \ + / _\ _/ \ /T~\|~\_\ / \_ /~| _^ + / \ /W \ / V^\/X /~ T . \/ \ ,v-./ + ,'`-. /~ ^ H , . \/ ; . \ `. \-' / + M ~ | . ; / , _ : . ~\_,-' + / ~ . \ / : ' \ ,/` + I o. ^ oP '98b - _ 9.` `\9b. + 8oO888. oO888P d888b9bo. .8o 888o. 8bo. o 988o. + 88888888888888888888888888bo.98888888bo. 98888bo. .d888P + 88888888888888888888888888888888888888888888888888888888888 + _ __ _ + ___ _ (_) ___ / / (_) ___ ___ _ + / _ `/ / / / _ \ / / / / / _ \ / _ `/ + \_, / /_/ /_//_//_/ /_/ /_//_/ \_, / + /_/ /___/ + +Function as a Service in OpenStack, version: %s +""" % version.version_string() + + +def print_server_info(): + print(QINLING_TITLE) + + comp_str = ("[%s]" % ','.join(LAUNCH_OPTIONS) + if cfg.CONF.server == ['all'] else cfg.CONF.server) + + print('Launching server components %s...' % comp_str) + + +def get_properly_ordered_parameters(): + """Orders launch parameters in the right order. + + In oslo it's important the order of the launch parameters. + if --config-file came after the command line parameters the command + line parameters are ignored. + So to make user command line parameters are never ignored this method + moves --config-file to be always first. + """ + args = sys.argv[1:] + + for arg in sys.argv[1:]: + if arg == '--config-file' or arg.startswith('--config-file='): + if "=" in arg: + conf_file_value = arg.split("=", 1)[1] + else: + conf_file_value = args[args.index(arg) + 1] + args.remove(conf_file_value) + args.remove(arg) + args.insert(0, "--config-file") + args.insert(1, conf_file_value) + + return args + + +def main(): + try: + config.parse_args(get_properly_ordered_parameters()) + print_server_info() + + logging.setup(CONF, 'Qingling') + + if cfg.CONF.server == ['all']: + # Launch all servers. + launch_any(LAUNCH_OPTIONS.keys()) + else: + # Validate launch option. + if set(cfg.CONF.server) - set(LAUNCH_OPTIONS.keys()): + raise Exception('Valid options are all or any combination of ' + ', '.join(LAUNCH_OPTIONS.keys())) + + # Launch distinct set of server(s). + launch_any(set(cfg.CONF.server)) + + except RuntimeError as excp: + sys.stderr.write("ERROR: %s\n" % excp) + sys.exit(1) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/qinling/config.py b/qinling/config.py new file mode 100644 index 00000000..6705d1dd --- /dev/null +++ b/qinling/config.py @@ -0,0 +1,117 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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. + +""" +Configuration options registration and useful routines. +""" +import itertools + +from oslo_config import cfg +from oslo_log import log + +from qinling import version + +launch_opt = cfg.ListOpt( + 'server', + default=['all'], + help='Specifies which qinling server to start by the launch script.' +) + +api_opts = [ + cfg.StrOpt('host', default='0.0.0.0', help='Qinling API server host.'), + cfg.PortOpt('port', default=7070, help='Qinling API server port.'), + cfg.BoolOpt( + 'enable_ssl_api', + default=False, + help='Enable the integrated stand-alone API to service requests' + 'via HTTPS instead of HTTP.' + ), + cfg.IntOpt( + 'api_workers', + help='Number of workers for Qinling API service ' + 'default is equal to the number of CPUs available if that can ' + 'be determined, else a default worker count of 1 is returned.' + ) +] + +pecan_opts = [ + cfg.StrOpt( + 'root', + default='qinling.api.controllers.root.RootController', + help='Pecan root controller' + ), + cfg.ListOpt( + 'modules', + default=["qinling.api"], + help='A list of modules where pecan will search for applications.' + ), + cfg.BoolOpt( + 'debug', + default=False, + help='Enables the ability to display tracebacks in the browser and' + ' interactively debug during development.' + ), + cfg.BoolOpt( + 'auth_enable', + default=True, + help='Enables user authentication in pecan.' + ) +] + +CONF = cfg.CONF +API_GROUP = 'api' +PECAN_GROUP = 'pecan' +CLI_OPTS = [launch_opt] + +CONF.register_opts(api_opts, group=API_GROUP) +CONF.register_opts(pecan_opts, group=PECAN_GROUP) +CONF.register_cli_opts(CLI_OPTS) + +default_group_opts = itertools.chain( + CLI_OPTS, + [] +) + + +def list_opts(): + return [ + (API_GROUP, api_opts), + (PECAN_GROUP, pecan_opts), + (None, default_group_opts) + ] + + +_DEFAULT_LOG_LEVELS = [ + 'eventlet.wsgi.server=WARN', + 'oslo_service.periodic_task=INFO', + 'oslo_service.loopingcall=INFO', + 'oslo_db=WARN', + 'oslo_concurrency.lockutils=WARN' +] + + +def parse_args(args=None, usage=None, default_config_files=None): + default_log_levels = log.get_default_log_levels() + default_log_levels.extend(_DEFAULT_LOG_LEVELS) + log.set_defaults(default_log_levels=default_log_levels) + + log.register_options(CONF) + + CONF( + args=args, + project='qinling', + version=version, + usage=usage, + default_config_files=default_config_files + ) diff --git a/qinling/context.py b/qinling/context.py new file mode 100644 index 00000000..afdb9a77 --- /dev/null +++ b/qinling/context.py @@ -0,0 +1,69 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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_context import context as oslo_context +import pecan +from pecan import hooks + +from qinling import exceptions as exc + +CONF = cfg.CONF +ALLOWED_WITHOUT_AUTH = ['/', '/v1/'] + + +def authenticate(req): + # Refer to: + # https://docs.openstack.org/developer/keystonemiddleware/middlewarearchitecture.html#exchanging-user-information + identity_status = req.headers.get('X-Identity-Status') + service_identity_status = req.headers.get('X-Service-Identity-Status') + + if (identity_status == 'Confirmed' or + service_identity_status == 'Confirmed'): + return + + if req.headers.get('X-Auth-Token'): + msg = 'Auth token is invalid: %s' % req.headers['X-Auth-Token'] + else: + msg = 'Authentication required' + + raise exc.UnauthorizedException(msg) + + +class AuthHook(hooks.PecanHook): + def before(self, state): + if state.request.path in ALLOWED_WITHOUT_AUTH: + return + + if not CONF.pecan.auth_enable: + return + + try: + authenticate(state.request) + except Exception as e: + msg = "Failed to validate access token: %s" % str(e) + + pecan.abort( + status_code=401, + detail=msg, + headers={'Server-Error-Message': msg} + ) + + +class ContextHook(hooks.PecanHook): + def on_route(self, state): + context_obj = oslo_context.RequestContext.from_environ( + state.request.environ + ) + state.request.context['qinling_context'] = context_obj diff --git a/qinling/exceptions.py b/qinling/exceptions.py new file mode 100644 index 00000000..b17969a7 --- /dev/null +++ b/qinling/exceptions.py @@ -0,0 +1,64 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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. + + +class QinlingException(Exception): + """Qinling specific exception. + + Reserved for situations that are not critical for program continuation. + It is possible to recover from this type of problems automatically and + continue program execution. Such problems may be related with invalid user + input (such as invalid syntax) or temporary environmental problems. + + In case if an instance of a certain exception type bubbles up to API layer + then this type of exception it must be associated with an http code so it's + clear how to represent it for a client. + + To correctly use this class, inherit from it and define a 'message' and + 'http_code' properties. + """ + message = "An unknown exception occurred" + http_code = 500 + + def __init__(self, message=None): + if message is not None: + self.message = message + + super(QinlingException, self).__init__( + '%d: %s' % (self.http_code, self.message)) + + @property + def code(self): + """This is here for webob to read. + + https://github.com/Pylons/webob/blob/master/webob/exc.py + """ + return self.http_code + + def __str__(self): + return self.message + + +class InputException(QinlingException): + http_code = 400 + + +class UnauthorizedException(QinlingException): + http_code = 401 + message = "Unauthorized" + + +class NotAllowedException(QinlingException): + http_code = 403 + message = "Operation not allowed" diff --git a/qinling/tests/__init__.py b/qinling/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qinling/tests/base.py b/qinling/tests/base.py new file mode 100644 index 00000000..1c30cdb5 --- /dev/null +++ b/qinling/tests/base.py @@ -0,0 +1,23 @@ +# -*- 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 +# +# 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 oslotest import base + + +class TestCase(base.BaseTestCase): + + """Test case base class for all unit tests.""" diff --git a/qinling/tests/test_qinling.py b/qinling/tests/test_qinling.py new file mode 100644 index 00000000..80dd8b07 --- /dev/null +++ b/qinling/tests/test_qinling.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- + +# 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. + +""" +test_qinling +---------------------------------- + +Tests for `qinling` module. +""" + +from qinling.tests import base + + +class TestQinling(base.TestCase): + + def test_something(self): + pass diff --git a/qinling/utils/__init__.py b/qinling/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qinling/utils/rest_utils.py b/qinling/utils/rest_utils.py new file mode 100644 index 00000000..256f50e8 --- /dev/null +++ b/qinling/utils/rest_utils.py @@ -0,0 +1,72 @@ +# Copyright 2017 Catalyst IT Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functools +import json + +from oslo_log import log as logging +import pecan +import six +import webob +from wsme import exc as wsme_exc + +from qinling import exceptions as exc + +LOG = logging.getLogger(__name__) + + +def wrap_wsme_controller_exception(func): + """Decorator for controllers method. + + This decorator wraps controllers method to manage wsme exceptions: + In case of expected error it aborts the request with specific status code. + """ + + @functools.wraps(func) + def wrapped(*args, **kwargs): + try: + return func(*args, **kwargs) + except exc.QinlingException as e: + pecan.response.translatable_error = e + + LOG.error('Error during API call: %s' % str(e)) + raise wsme_exc.ClientSideError( + msg=six.text_type(e), + status_code=e.http_code + ) + + return wrapped + + +def wrap_pecan_controller_exception(func): + """Decorator for controllers method. + + This decorator wraps controllers method to manage pecan exceptions: + In case of expected error it aborts the request with specific status code. + """ + + @functools.wraps(func) + def wrapped(*args, **kwargs): + try: + return func(*args, **kwargs) + except exc.QinlingException as e: + LOG.error('Error during API call: %s' % str(e)) + return webob.Response( + status=e.http_code, + content_type='application/json', + body=json.dumps(dict(faultstring=six.text_type(e))), + charset='UTF-8' + ) + + return wrapped diff --git a/qinling/version.py b/qinling/version.py new file mode 100644 index 00000000..b206e88e --- /dev/null +++ b/qinling/version.py @@ -0,0 +1,18 @@ +# Copyright 2017 Catalyst IT Limited +# +# 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 pbr import version + +version_info = version.VersionInfo('qinling') +version_string = version_info.version_string