summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--conductor/conductor/api/__init__.py19
-rw-r--r--conductor/conductor/api/app.py137
-rw-r--r--conductor/conductor/api/app.wsgi9
-rw-r--r--conductor/conductor/api/controllers/__init__.py54
-rw-r--r--conductor/conductor/api/controllers/errors.py149
-rw-r--r--conductor/conductor/api/controllers/root.py64
-rw-r--r--conductor/conductor/api/controllers/v1/__init__.py19
-rw-r--r--conductor/conductor/api/controllers/v1/plans.py261
-rw-r--r--conductor/conductor/api/controllers/v1/root.py47
-rw-r--r--conductor/conductor/api/controllers/validator.py63
-rw-r--r--conductor/conductor/api/hooks.py137
-rw-r--r--conductor/conductor/api/middleware.py132
-rw-r--r--conductor/conductor/api/rbac.py106
13 files changed, 1197 insertions, 0 deletions
diff --git a/conductor/conductor/api/__init__.py b/conductor/conductor/api/__init__.py
new file mode 100644
index 0000000..f2bbdfd
--- /dev/null
+++ b/conductor/conductor/api/__init__.py
@@ -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
index 0000000..70d54b5
--- /dev/null
+++ b/conductor/conductor/api/app.py
@@ -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
index 0000000..573d3d2
--- /dev/null
+++ b/conductor/conductor/api/app.wsgi
@@ -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
index 0000000..4f46681
--- /dev/null
+++ b/conductor/conductor/api/controllers/__init__.py
@@ -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
index 0000000..6216721
--- /dev/null
+++ b/conductor/conductor/api/controllers/errors.py
@@ -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
index 0000000..d7c4a7e
--- /dev/null
+++ b/conductor/conductor/api/controllers/root.py
@@ -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
index 0000000..f2bbdfd
--- /dev/null
+++ b/conductor/conductor/api/controllers/v1/__init__.py
@@ -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
index 0000000..fa635f7
--- /dev/null
+++ b/conductor/conductor/api/controllers/v1/plans.py
@@ -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
index 0000000..87b4a35
--- /dev/null
+++ b/conductor/conductor/api/controllers/v1/root.py
@@ -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
index 0000000..f9bff3f
--- /dev/null
+++ b/conductor/conductor/api/controllers/validator.py
@@ -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
index 0000000..08677cc
--- /dev/null
+++ b/conductor/conductor/api/hooks.py
@@ -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
index 0000000..dc0664a
--- /dev/null
+++ b/conductor/conductor/api/middleware.py
@@ -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
index 0000000..6caaad3
--- /dev/null
+++ b/conductor/conductor/api/rbac.py
@@ -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]