Added api directory to the repository 15/26415/1
authorrl001m <ruilu@research.att.com>
Sun, 17 Dec 2017 14:28:53 +0000 (09:28 -0500)
committerrl001m <ruilu@research.att.com>
Sun, 17 Dec 2017 14:29:01 +0000 (09:29 -0500)
Added the HAS-API module in ONAP

Change-Id: Ie4a3b3d2f478abbf4878a4c618a568d7f07c4fa3
Issue-ID: OPTFRA-15
Signed-off-by: rl001m <ruilu@research.att.com>
13 files changed:
conductor/conductor/api/__init__.py [new file with mode: 0644]
conductor/conductor/api/app.py [new file with mode: 0644]
conductor/conductor/api/app.wsgi [new file with mode: 0644]
conductor/conductor/api/controllers/__init__.py [new file with mode: 0644]
conductor/conductor/api/controllers/errors.py [new file with mode: 0644]
conductor/conductor/api/controllers/root.py [new file with mode: 0644]
conductor/conductor/api/controllers/v1/__init__.py [new file with mode: 0644]
conductor/conductor/api/controllers/v1/plans.py [new file with mode: 0644]
conductor/conductor/api/controllers/v1/root.py [new file with mode: 0644]
conductor/conductor/api/controllers/validator.py [new file with mode: 0644]
conductor/conductor/api/hooks.py [new file with mode: 0644]
conductor/conductor/api/middleware.py [new file with mode: 0644]
conductor/conductor/api/rbac.py [new file with mode: 0644]

diff --git a/conductor/conductor/api/__init__.py b/conductor/conductor/api/__init__.py
new file mode 100644 (file)
index 0000000..f2bbdfd
--- /dev/null
@@ -0,0 +1,19 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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.
+#
+# -------------------------------------------------------------------------
+#
+
diff --git a/conductor/conductor/api/app.py b/conductor/conductor/api/app.py
new file mode 100644 (file)
index 0000000..70d54b5
--- /dev/null
@@ -0,0 +1,137 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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 os
+import uuid
+
+from oslo_config import cfg
+from oslo_log import log
+from paste import deploy
+import pecan
+
+from conductor.api import hooks
+from conductor.api import middleware
+from conductor import service
+
+LOG = log.getLogger(__name__)
+
+CONF = cfg.CONF
+
+OPTS = [
+    cfg.StrOpt('api_paste_config',
+               default="api_paste.ini",
+               help="Configuration file for WSGI definition of API."
+               ),
+]
+
+API_OPTS = [
+    cfg.BoolOpt('pecan_debug',
+                default=False,
+                help='Toggle Pecan Debug Middleware.'),
+    cfg.IntOpt('default_api_return_limit',
+               min=1,
+               default=100,
+               help='Default maximum number of items returned by API request.'
+               ),
+]
+
+CONF.register_opts(OPTS)
+CONF.register_opts(API_OPTS, group='api')
+
+# Pull in service opts. We use them here.
+OPTS = service.OPTS
+CONF.register_opts(OPTS)
+
+# Can call like so to force a particular config:
+# conductor-api --port=8091 -- --config-file=my_config
+#
+# For api command-line options:
+# conductor-api -- --help
+
+
+def setup_app(pecan_config=None, conf=None):
+    if conf is None:
+        raise RuntimeError("No configuration passed")
+
+    app_hooks = [
+        hooks.ConfigHook(conf),
+        hooks.MessagingHook(conf),
+    ]
+
+    pecan_config = pecan_config or {
+        "app": {
+            'root': 'conductor.api.controllers.root.RootController',
+            'modules': ['conductor.api'],
+        }
+    }
+
+    pecan.configuration.set_config(dict(pecan_config), overwrite=True)
+
+    app = pecan.make_app(
+        pecan_config['app']['root'],
+        debug=conf.api.pecan_debug,
+        hooks=app_hooks,
+        wrap_app=middleware.ParsableErrorMiddleware,
+        guess_content_type_from_ext=False,
+        default_renderer='json',
+        force_canonical=False,
+    )
+
+    return app
+
+
+# pastedeploy uses ConfigParser to handle global_conf, since Python 3's
+# ConfigParser doesn't allow storing objects as config values. Only strings
+# are permitted. Thus, to be able to pass an object created before paste
+# loads the app, we store them in a global variable. Then each loaded app
+# stores it's configuration using a unique key to be concurrency safe.
+global APPCONFIGS
+APPCONFIGS = {}
+
+
+def load_app(conf):
+    global APPCONFIGS
+
+    # Build the WSGI app
+    cfg_file = None
+    cfg_path = conf.api_paste_config
+    if not os.path.isabs(cfg_path):
+        cfg_file = conf.find_file(cfg_path)
+    elif os.path.exists(cfg_path):
+        cfg_file = cfg_path
+
+    if not cfg_file:
+        raise cfg.ConfigFilesNotFoundError([conf.api_paste_config])
+
+    configkey = str(uuid.uuid4())
+    APPCONFIGS[configkey] = conf
+
+    LOG.info("Full WSGI config used: %s" % cfg_file)
+    return deploy.loadapp("config:" + cfg_file,
+                          global_conf={'configkey': configkey})
+
+
+def app_factory(global_config, **local_conf):
+    global APPCONFIGS
+    conf = APPCONFIGS.get(global_config.get('configkey'))
+    return setup_app(conf=conf)
+
+
+def build_wsgi_app(argv=None):
+    return load_app(service.prepare_service(argv=argv))
diff --git a/conductor/conductor/api/app.wsgi b/conductor/conductor/api/app.wsgi
new file mode 100644 (file)
index 0000000..573d3d2
--- /dev/null
@@ -0,0 +1,9 @@
+"""Use this file for deploying the API under mod_wsgi.
+See http://pecan.readthedocs.org/en/latest/deployment.html for details.
+"""
+from conductor import service
+from conductor.api import app
+
+# Initialize the oslo configuration library and logging
+conf = service.prepare_service([])
+application = app.load_app(conf)
\ No newline at end of file
diff --git a/conductor/conductor/api/controllers/__init__.py b/conductor/conductor/api/controllers/__init__.py
new file mode 100644 (file)
index 0000000..4f46681
--- /dev/null
@@ -0,0 +1,54 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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 os import path
+
+from notario import exceptions
+from notario.utils import forced_leaf_validator
+import pecan
+import six
+
+
+#
+# Error Handler
+#
+def error(url, msg=None, **kwargs):
+    """Error handler"""
+    if msg:
+        pecan.request.context['error_message'] = msg
+    if kwargs:
+        pecan.request.context['kwargs'] = kwargs
+    url = path.join(url, '?error_message=%s' % msg)
+    pecan.redirect(url, internal=True)
+
+
+#
+# Notario Custom Validators
+#
+@forced_leaf_validator
+def string_or_dict(_object, *args):
+    """Validator - Must be Basestring or Dictionary"""
+    error_msg = 'not of type dictionary or string'
+
+    if isinstance(_object, six.string_types):
+        return
+    if isinstance(_object, dict):
+        return
+    raise exceptions.Invalid('dict or basestring type', pair='value',
+                             msg=None, reason=error_msg, *args)
diff --git a/conductor/conductor/api/controllers/errors.py b/conductor/conductor/api/controllers/errors.py
new file mode 100644 (file)
index 0000000..6216721
--- /dev/null
@@ -0,0 +1,149 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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 traceback
+
+from oslo_log import log
+import pecan
+from webob.exc import status_map
+
+from conductor.i18n import _
+
+LOG = log.getLogger(__name__)
+
+
+def error_wrapper(func):
+    """Error decorator."""
+    def func_wrapper(self, **kw):
+        """Wrapper."""
+
+        kwargs = func(self, **kw)
+        status = status_map.get(pecan.response.status_code)
+        message = getattr(status, 'explanation', '')
+        explanation = \
+            pecan.request.context.get('error_message', message)
+        error_type = status.__name__
+        title = status.title
+        traceback = getattr(kwargs, 'traceback', None)
+
+        LOG.error(explanation)
+
+        # Modeled after Heat's format
+        error = {
+            "explanation": explanation,
+            "code": pecan.response.status_code,
+            "error": {
+                "message": message,
+                "type": error_type,
+            },
+            "title": title,
+        }
+        if traceback:
+            error['error']['traceback'] = traceback
+        return error
+    return func_wrapper
+
+
+class ErrorsController(object):
+    """Errors Controller /errors/{error_name}"""
+
+    @pecan.expose('json')
+    @error_wrapper
+    def schema(self, **kw):
+        """400"""
+        pecan.request.context['error_message'] = \
+            str(pecan.request.validation_error)
+        pecan.response.status = 400
+        return pecan.request.context.get('kwargs')
+
+    @pecan.expose('json')
+    @error_wrapper
+    def invalid(self, **kw):
+        """400"""
+        pecan.response.status = 400
+        return pecan.request.context.get('kwargs')
+
+    @pecan.expose()
+    def unauthorized(self, **kw):
+        """401"""
+        # This error is terse and opaque on purpose.
+        # Don't give any clues to help AuthN along.
+        pecan.response.status = 401
+        pecan.response.content_type = 'text/plain'
+        LOG.error('unauthorized')
+        traceback.print_stack()
+        LOG.error(self.__class__)
+        LOG.error(kw)
+        pecan.response.body = _('Authentication required')
+        LOG.error(pecan.response.body)
+        return pecan.response
+
+    @pecan.expose('json')
+    @error_wrapper
+    def forbidden(self, **kw):
+        """403"""
+        pecan.response.status = 403
+        return pecan.request.context.get('kwargs')
+
+    @pecan.expose('json')
+    @error_wrapper
+    def not_found(self, **kw):
+        """404"""
+        pecan.response.status = 404
+        return pecan.request.context.get('kwargs')
+
+    @pecan.expose('json')
+    @error_wrapper
+    def not_allowed(self, **kw):
+        """405"""
+        kwargs = pecan.request.context.get('kwargs')
+        if kwargs:
+            allow = kwargs.get('allow', None)
+            if allow:
+                pecan.response.headers['Allow'] = allow
+        pecan.response.status = 405
+        return kwargs
+
+    @pecan.expose('json')
+    @error_wrapper
+    def conflict(self, **kw):
+        """409"""
+        pecan.response.status = 409
+        return pecan.request.context.get('kwargs')
+
+    @pecan.expose('json')
+    @error_wrapper
+    def server_error(self, **kw):
+        """500"""
+        pecan.response.status = 500
+        return pecan.request.context.get('kwargs')
+
+    @pecan.expose('json')
+    @error_wrapper
+    def unimplemented(self, **kw):
+        """501"""
+        pecan.response.status = 501
+        return pecan.request.context.get('kwargs')
+
+    @pecan.expose('json')
+    @error_wrapper
+    def unavailable(self, **kw):
+        """503"""
+        pecan.response.status = 503
+        return pecan.request.context.get('kwargs')
diff --git a/conductor/conductor/api/controllers/root.py b/conductor/conductor/api/controllers/root.py
new file mode 100644 (file)
index 0000000..d7c4a7e
--- /dev/null
@@ -0,0 +1,64 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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 conductor.api.controllers import errors
+from conductor.api.controllers.v1 import root as v1
+
+MEDIA_TYPE_JSON = 'application/vnd.onap.has-%s+json'
+
+
+class RootController(object):
+    """Root Controller /"""
+
+    def __init__(self):
+        self.errors = errors.ErrorsController()
+        self.v1 = v1.V1Controller()
+
+    @pecan.expose(generic=True, template='json')
+    def index(self):
+        """Catchall for all methods"""
+        base_url = pecan.request.application_url
+        available = [{'tag': 'v1', 'date': '2016-11-01T00:00:00Z', }]
+        collected = [version_descriptor(base_url, v['tag'], v['date'])
+                     for v in available]
+        versions = {'versions': collected}
+        return versions
+
+
+def version_descriptor(base_url, version, released_on):
+    """Version Descriptor"""
+    url = version_url(base_url, version)
+    return {
+        'id': version,
+        'links': [
+            {'href': url, 'rel': 'self', },
+            {'href': 'https://wiki.onap.org/pages/viewpage.action?pageId=16005528',
+             'rel': 'describedby', 'type': 'text/html', }],
+        'media-types': [
+            {'base': 'application/json', 'type': MEDIA_TYPE_JSON % version, }],
+        'status': 'EXPERIMENTAL',
+        'updated': released_on,
+    }
+
+
+def version_url(base_url, version_number):
+    """Version URL"""
+    return '%s/%s' % (base_url, version_number)
diff --git a/conductor/conductor/api/controllers/v1/__init__.py b/conductor/conductor/api/controllers/v1/__init__.py
new file mode 100644 (file)
index 0000000..f2bbdfd
--- /dev/null
@@ -0,0 +1,19 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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.
+#
+# -------------------------------------------------------------------------
+#
+
diff --git a/conductor/conductor/api/controllers/v1/plans.py b/conductor/conductor/api/controllers/v1/plans.py
new file mode 100644 (file)
index 0000000..fa635f7
--- /dev/null
@@ -0,0 +1,261 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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 six
+import yaml
+from yaml.constructor import ConstructorError
+
+from notario import decorators
+from notario.validators import types
+from oslo_log import log
+import pecan
+from pecan_notario import validate
+
+from conductor.api.controllers import error
+from conductor.api.controllers import string_or_dict
+from conductor.api.controllers import validator
+from conductor.i18n import _, _LI
+
+LOG = log.getLogger(__name__)
+
+CREATE_SCHEMA = (
+    (decorators.optional('files'), types.dictionary),
+    (decorators.optional('id'), types.string),
+    (decorators.optional('limit'), types.integer),
+    (decorators.optional('name'), types.string),
+    ('template', string_or_dict),
+    (decorators.optional('template_url'), types.string),
+    (decorators.optional('timeout'), types.integer),
+)
+
+
+class PlansBaseController(object):
+    """Plans Base Controller - Common Methods"""
+
+    def plan_link(self, plan_id):
+        return [
+            {
+                "href": "%(url)s/v1/%(endpoint)s/%(id)s" %
+                        {
+                            'url': pecan.request.application_url,
+                            'endpoint': 'plans',
+                            'id': plan_id,
+                        },
+                "rel": "self"
+            }
+        ]
+
+    def plans_get(self, plan_id=None):
+        ctx = {}
+        method = 'plans_get'
+        if plan_id:
+            args = {'plan_id': plan_id}
+            LOG.debug('Plan {} requested.'.format(plan_id))
+        else:
+            args = {}
+            LOG.debug('All plans requested.')
+
+        plans_list = []
+
+        client = pecan.request.controller
+        result = client.call(ctx, method, args)
+        plans = result and result.get('plans')
+
+        for the_plan in plans:
+            the_plan_id = the_plan.get('id')
+            the_plan['links'] = [self.plan_link(the_plan_id)]
+            plans_list.append(the_plan)
+
+        if plan_id:
+            if len(plans_list) == 1:
+                return plans_list[0]
+            else:
+                # For a single plan, we return None if not found
+                return None
+        else:
+            # For all plans, it's ok to return an empty list
+            return plans_list
+
+    def plan_create(self, args):
+        ctx = {}
+        method = 'plan_create'
+
+        # TODO(jdandrea): Enhance notario errors to use similar syntax
+        # valid_keys = ['files', 'id', 'limit', 'name',
+        #               'template', 'template_url', 'timeout']
+        # if not set(args.keys()).issubset(valid_keys):
+        #     invalid = [name for name in args if name not in valid_keys]
+        #     invalid_str = ', '.join(invalid)
+        #     error('/errors/invalid',
+        #           _('Invalid keys found: {}').format(invalid_str))
+        # required_keys = ['template']
+        # if not set(required_keys).issubset(args):
+        #     required = [name for name in required_keys if name not in args]
+        #     required_str = ', '.join(required)
+        #     error('/errors/invalid',
+        #           _('Missing required keys: {}').format(required_str))
+
+        LOG.debug('Plan creation requested (name "{}").'.format(
+            args.get('name')))
+
+        client = pecan.request.controller
+        result = client.call(ctx, method, args)
+        plan = result and result.get('plan')
+        if plan:
+            plan_name = plan.get('name')
+            plan_id = plan.get('id')
+            plan['links'] = [self.plan_link(plan_id)]
+            LOG.info(_LI('Plan {} (name "{}") created.').format(
+                plan_id, plan_name))
+        return plan
+
+    def plan_delete(self, plan):
+        ctx = {}
+        method = 'plans_delete'
+
+        plan_name = plan.get('name')
+        plan_id = plan.get('id')
+        LOG.debug('Plan {} (name "{}") deletion requested.'.format(
+            plan_id, plan_name))
+
+        args = {'plan_id': plan_id}
+        client = pecan.request.controller
+        client.call(ctx, method, args)
+        LOG.info(_LI('Plan {} (name "{}") deleted.').format(
+            plan_id, plan_name))
+
+
+class PlansItemController(PlansBaseController):
+    """Plans Item Controller /v1/plans/{plan_id}"""
+
+    def __init__(self, uuid4):
+        """Initializer."""
+        self.uuid = uuid4
+        self.plan = self.plans_get(plan_id=self.uuid)
+
+        if not self.plan:
+            error('/errors/not_found',
+                  _('Plan {} not found').format(self.uuid))
+        pecan.request.context['plan_id'] = self.uuid
+
+    @classmethod
+    def allow(cls):
+        """Allowed methods"""
+        return 'GET,DELETE'
+
+    @pecan.expose(generic=True, template='json')
+    def index(self):
+        """Catchall for unallowed methods"""
+        message = _('The {} method is not allowed.').format(
+            pecan.request.method)
+        kwargs = {'allow': self.allow()}
+        error('/errors/not_allowed', message, **kwargs)
+
+    @index.when(method='OPTIONS', template='json')
+    def index_options(self):
+        """Options"""
+        pecan.response.headers['Allow'] = self.allow()
+        pecan.response.status = 204
+
+    @index.when(method='GET', template='json')
+    def index_get(self):
+        """Get plan"""
+        return {"plans": [self.plan]}
+
+    @index.when(method='DELETE', template='json')
+    def index_delete(self):
+        """Delete a Plan"""
+        self.plan_delete(self.plan)
+        pecan.response.status = 204
+
+
+class PlansController(PlansBaseController):
+    """Plans Controller /v1/plans"""
+
+    @classmethod
+    def allow(cls):
+        """Allowed methods"""
+        return 'GET,POST'
+
+    @pecan.expose(generic=True, template='json')
+    def index(self):
+        """Catchall for unallowed methods"""
+        message = _('The {} method is not allowed.').format(
+            pecan.request.method)
+        kwargs = {'allow': self.allow()}
+        error('/errors/not_allowed', message, **kwargs)
+
+    @index.when(method='OPTIONS', template='json')
+    def index_options(self):
+        """Options"""
+        pecan.response.headers['Allow'] = self.allow()
+        pecan.response.status = 204
+
+    @index.when(method='GET', template='json')
+    def index_get(self):
+        """Get all the plans"""
+        plans = self.plans_get()
+        return {"plans": plans}
+
+    @index.when(method='POST', template='json')
+    @validate(CREATE_SCHEMA, '/errors/schema')
+    def index_post(self):
+        """Create a Plan"""
+
+        # Look for duplicate keys in the YAML/JSON, first in the
+        # entire request, and then again if the template parameter
+        # value is itself an embedded JSON/YAML string.
+        where = "API Request"
+        try:
+            parsed = yaml.load(pecan.request.text, validator.UniqueKeyLoader)
+            if 'template' in parsed:
+                where = "Template"
+                template = parsed['template']
+                if isinstance(template, six.string_types):
+                    yaml.load(template, validator.UniqueKeyLoader)
+        except ConstructorError as exc:
+            # Only bail on the duplicate key problem (problem and problem_mark
+            # attributes are available in ConstructorError):
+            if exc.problem is \
+                    validator.UniqueKeyLoader.DUPLICATE_KEY_PROBLEM_MARK:
+                # ConstructorError messages have a two line snippet.
+                # Grab it, get rid of the second line, and strip any
+                # remaining whitespace so we can fashion a one line msg.
+                snippet = exc.problem_mark.get_snippet()
+                snippet = snippet.split('\n')[0].strip()
+                msg = _('{} has a duplicate key on line {}: {}')
+                error('/errors/invalid',
+                      msg.format(where, exc.problem_mark.line + 1, snippet))
+        except Exception as exc:
+            # Let all others pass through for now.
+            pass
+
+        args = pecan.request.json
+        plan = self.plan_create(args)
+
+        if not plan:
+            error('/errors/server_error', _('Unable to create Plan.'))
+        else:
+            pecan.response.status = 201
+            return plan
+
+    @pecan.expose()
+    def _lookup(self, uuid4, *remainder):
+        """Pecan subcontroller routing callback"""
+        return PlansItemController(uuid4), remainder
diff --git a/conductor/conductor/api/controllers/v1/root.py b/conductor/conductor/api/controllers/v1/root.py
new file mode 100644 (file)
index 0000000..87b4a35
--- /dev/null
@@ -0,0 +1,47 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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
+import pecan
+from pecan import secure
+
+from conductor.api.controllers import error
+from conductor.api.controllers.v1 import plans
+from conductor.i18n import _
+
+LOG = log.getLogger(__name__)
+
+
+class V1Controller(secure.SecureController):
+    """Version 1 API controller root."""
+
+    plans = plans.PlansController()
+
+    @classmethod
+    def check_permissions(cls):
+        """SecureController permission check callback"""
+        return True
+        # error('/errors/unauthorized', msg)
+
+    @pecan.expose(generic=True, template='json')
+    def index(self):
+        """Catchall for unallowed methods"""
+        message = _('The %s method is not allowed.') % pecan.request.method
+        kwargs = {}
+        error('/errors/not_allowed', message, **kwargs)
diff --git a/conductor/conductor/api/controllers/validator.py b/conductor/conductor/api/controllers/validator.py
new file mode 100644 (file)
index 0000000..f9bff3f
--- /dev/null
@@ -0,0 +1,63 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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 yaml.constructor import ConstructorError
+from yaml.nodes import MappingNode
+
+try:
+    from yaml import CLoader as Loader
+except ImportError:
+    from yaml import Loader
+
+
+class UniqueKeyLoader(Loader):
+    """Unique Key Loader for PyYAML
+
+    Ensures no duplicate keys on any given level.
+
+    https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-2084028
+    """
+
+    DUPLICATE_KEY_PROBLEM_MARK = "found duplicate key"
+
+    def construct_mapping(self, node, deep=False):
+        """Check for duplicate keys while constructing a mapping."""
+        if not isinstance(node, MappingNode):
+            raise ConstructorError(
+                None, None, "expected a mapping node, but found %s" % node.id,
+                node.start_mark)
+        mapping = {}
+        for key_node, value_node in node.value:
+            key = self.construct_object(key_node, deep=deep)
+            try:
+                hash(key)
+            except (TypeError) as exc:
+                raise ConstructorError("while constructing a mapping",
+                                       node.start_mark,
+                                       "found unacceptable key (%s)" % exc,
+                                       key_node.start_mark)
+            # check for duplicate keys
+            if key in mapping:
+                raise ConstructorError("while constructing a mapping",
+                                       node.start_mark,
+                                       self.DUPLICATE_KEY_PROBLEM_MARK,
+                                       key_node.start_mark)
+            value = self.construct_object(value_node, deep=deep)
+            mapping[key] = value
+        return mapping
diff --git a/conductor/conductor/api/hooks.py b/conductor/conductor/api/hooks.py
new file mode 100644 (file)
index 0000000..08677cc
--- /dev/null
@@ -0,0 +1,137 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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
+from pecan import hooks
+
+# from conductor.common.models import plan
+# from conductor.common.music import api
+from conductor.common.music import messaging as music_messaging
+# from conductor.common.music.model import base
+from conductor import messaging
+
+LOG = log.getLogger(__name__)
+
+
+class ConfigHook(hooks.PecanHook):
+    """Attach the configuration object to the request.
+
+    That allows controllers to get it.
+    """
+
+    def __init__(self, conf):
+        super(ConfigHook, self).__init__()
+        self.conf = conf
+
+    def on_route(self, state):
+        state.request.cfg = self.conf
+
+
+class MessagingHook(hooks.PecanHook):
+    """Create and attach a controller RPC client to the request."""
+
+    def __init__(self, conf):
+        super(MessagingHook, self).__init__()
+        topic = "controller"
+        transport = messaging.get_transport(conf=conf)
+        target = music_messaging.Target(topic=topic)
+        self.controller = \
+            music_messaging.RPCClient(conf=conf,
+                                      transport=transport,
+                                      target=target)
+
+    def on_route(self, state):
+        state.request.controller = self.controller
+
+
+# NOTE: We no longer use ModelHook, since the API should be asking
+# the controller (via RPC) for info about plans, not requesting them directly.
+
+# class ModelHook(hooks.PecanHook):
+#     """Create and attach dynamic model classes to the request."""
+#
+#     def __init__(self, conf):
+#         super(ModelHook, self).__init__()
+#
+#         # TODO(jdandrea) Move this to DBHook?
+#         music = api.API()
+#         music.keyspace_create(keyspace=conf.keyspace)
+#
+#         # Dynamically create a plan class for the specified keyspace
+#         self.Plan = base.create_dynamic_model(
+#             keyspace=conf.keyspace, baseclass=plan.Plan, classname="Plan")
+#
+#     def before(self, state):
+#         state.request.models = {
+#             "Plan": self.Plan,
+#         }
+
+
+# class DBHook(hooks.PecanHook):
+#
+#     def __init__(self):
+#         self.storage_connection = DBHook.get_connection('metering')
+#         self.event_storage_connection = DBHook.get_connection('event')
+#
+#         if (not self.storage_connection
+#            and not self.event_storage_connection):
+#             raise Exception("API failed to start. Failed to connect to "
+#                             "databases, purpose:  %s" %
+#                             ', '.join(['metering', 'event']))
+#
+#     def before(self, state):
+#         state.request.storage_conn = self.storage_connection
+#         state.request.event_storage_conn = self.event_storage_connection
+#
+#     @staticmethod
+#     def get_connection(purpose):
+#         try:
+#             return storage.get_connection_from_config(cfg.CONF, purpose)
+#         except Exception as err:
+#             params = {"purpose": purpose, "err": err}
+#             LOG.exception(_LE("Failed to connect to db, purpose %(purpose)s "
+#                               "retry later: %(err)s") % params)
+#
+#
+# class NotifierHook(hooks.PecanHook):
+#     """Create and attach a notifier to the request.
+#     Usually, samples will be push to notification bus by notifier when they
+#     are posted via /v2/meters/ API.
+#     """
+#
+#     def __init__(self):
+#         transport = messaging.get_transport()
+#         self.notifier = oslo_messaging.Notifier(
+#             transport, driver=cfg.CONF.publisher_notifier.homing_driver,
+#             publisher_id="conductor.api")
+#
+#     def before(self, state):
+#         state.request.notifier = self.notifier
+#
+#
+# class TranslationHook(hooks.PecanHook):
+#
+#     def after(self, state):
+#         # After a request has been done, we need to see if
+#         # ClientSideError has added an error onto the response.
+#         # If it has we need to get it info the thread-safe WSGI
+#         # environ to be used by the ParsableErrorMiddleware.
+#         if hasattr(state.response, 'translatable_error'):
+#             state.request.environ['translatable_error'] = (
+#                 state.response.translatable_error)
diff --git a/conductor/conductor/api/middleware.py b/conductor/conductor/api/middleware.py
new file mode 100644 (file)
index 0000000..dc0664a
--- /dev/null
@@ -0,0 +1,132 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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 lxml import etree
+from oslo_log import log
+import six
+import webob
+
+from conductor import i18n
+from conductor.i18n import _
+
+LOG = log.getLogger(__name__)
+
+
+class ParsableErrorMiddleware(object):
+    """Replace error body with something the client can parse."""
+
+    @staticmethod
+    def best_match_language(accept_language):
+        """Determines best available locale from the Accept-Language header.
+
+        :returns: the best language match or None if the 'Accept-Language'
+                  header was not available in the request.
+        """
+        if not accept_language:
+            return None
+        all_languages = i18n.get_available_languages()
+        return accept_language.best_match(all_languages)
+
+    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)
+            error = environ.get('translatable_error')
+            user_locale = self.best_match_language(req.accept_language)
+            if (req.accept.best_match(['application/json', 'application/xml'])
+               == 'application/xml'):
+                content_type = 'application/xml'
+                try:
+                    # simple check xml is valid
+                    fault = etree.fromstring(b'\n'.join(app_iter))
+                    # Add the translated error to the xml data
+                    if error is not None:
+                        for fault_string in fault.findall('faultstring'):
+                            fault_string.text = i18n.translate(error,
+                                                               user_locale)
+                    error_message = etree.tostring(fault)
+                    body = b''.join((b'<error_message>',
+                                     error_message,
+                                     b'</error_message>'))
+                except etree.XMLSyntaxError as err:
+                    LOG.error(_('Error parsing HTTP response: %s'), err)
+                    error_message = state['status_code']
+                    body = '<error_message>%s</error_message>' % error_message
+                    if six.PY3:
+                        body = body.encode('utf-8')
+            else:
+                content_type = 'application/json'
+                app_data = b'\n'.join(app_iter)
+                if six.PY3:
+                    app_data = app_data.decode('utf-8')
+                try:
+                    fault = json.loads(app_data)
+                    if error is not None and 'faultstring' in fault:
+                        fault['faultstring'] = i18n.translate(error,
+                                                              user_locale)
+                except ValueError as err:
+                    fault = app_data
+                body = json.dumps({'error_message': fault})
+                if six.PY3:
+                    body = body.encode('utf-8')
+
+            state['headers'].append(('Content-Length', str(len(body))))
+            state['headers'].append(('Content-Type', content_type))
+            body = [body]
+        else:
+            body = app_iter
+        return body
diff --git a/conductor/conductor/api/rbac.py b/conductor/conductor/api/rbac.py
new file mode 100644 (file)
index 0000000..6caaad3
--- /dev/null
@@ -0,0 +1,106 @@
+#
+# -------------------------------------------------------------------------
+#   Copyright (c) 2015-2017 AT&T Intellectual Property
+#
+#   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 Lists (ACL's) control access the API server."""
+
+from oslo_config import cfg
+from oslo_policy import policy
+import pecan
+
+_ENFORCER = None
+
+CONF = cfg.CONF
+
+
+def reset():
+    global _ENFORCER
+    if _ENFORCER:
+        _ENFORCER.clear()
+        _ENFORCER = None
+
+
+def _has_rule(name):
+    return name in _ENFORCER.rules.keys()
+
+
+def enforce(policy_name, request):
+    """Return the user and project the request should be limited to.
+
+    :param request: HTTP request
+    :param policy_name: the policy name to validate AuthZ against.
+    """
+    global _ENFORCER
+    if not _ENFORCER:
+        _ENFORCER = policy.Enforcer(CONF)
+        _ENFORCER.load_rules()
+
+    rule_method = "homing:" + policy_name
+    headers = request.headers
+
+    policy_dict = dict()
+    policy_dict['roles'] = headers.get('X-Roles', "").split(",")
+    policy_dict['user_id'] = (headers.get('X-User-Id'))
+    policy_dict['project_id'] = (headers.get('X-Project-Id'))
+
+    # maintain backward compat with Juno and previous by allowing the action if
+    # there is no rule defined for it
+    if ((_has_rule('default') or _has_rule(rule_method)) and
+            not _ENFORCER.enforce(rule_method, {}, policy_dict)):
+        pecan.core.abort(status_code=403, detail='RBAC Authorization Failed')
+
+
+# TODO(fabiog): these methods are still used because the scoping part is really
+# convoluted and difficult to separate out.
+
+def get_limited_to(headers):
+    """Return the user and project the request should be limited to.
+
+    :param headers: HTTP headers dictionary
+    :return: A tuple of (user, project), set to None if there's no limit on
+    one of these.
+    """
+    global _ENFORCER
+    if not _ENFORCER:
+        _ENFORCER = policy.Enforcer(CONF)
+        _ENFORCER.load_rules()
+
+    policy_dict = dict()
+    policy_dict['roles'] = headers.get('X-Roles', "").split(",")
+    policy_dict['user_id'] = (headers.get('X-User-Id'))
+    policy_dict['project_id'] = (headers.get('X-Project-Id'))
+
+    # maintain backward compat with Juno and previous by using context_is_admin
+    # rule if the segregation rule (added in Kilo) is not defined
+    rule_name = 'segregation' if _has_rule(
+        'segregation') else 'context_is_admin'
+    if not _ENFORCER.enforce(rule_name,
+                             {},
+                             policy_dict):
+        return headers.get('X-User-Id'), headers.get('X-Project-Id')
+
+    return None, None
+
+
+def get_limited_to_project(headers):
+    """Return the project the request should be limited to.
+
+    :param headers: HTTP headers dictionary
+    :return: A project, or None if there's no limit on it.
+    """
+    return get_limited_to(headers)[1]