Inital API Commit

Creates necessary API files and implements health API route.

Change-Id: Id545d65949fcc48a05565f39b08180d4aa86006f
This commit is contained in:
Samantha Blanco 2017-10-20 14:43:24 -04:00
parent 4dd7e64f0b
commit 9eb6f9c686
14 changed files with 832 additions and 10 deletions

View File

@ -0,0 +1,25 @@
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#PasteDeploy Configuration File
#Used to configure uWSGI middleware pipeline
[filter:authtoken]
paste.filter_factory = keystonemiddleware.auth_token:filter_factory
[app:promenade-api]
paste.app_factory = promenade.promenade:paste_start_promenade
[pipeline:main]
pipeline = authtoken promenade-api

View File

@ -147,7 +147,8 @@ def _matches_filter(document, *, schema, labels):
def _get(documents, kind=None, schema=None, name=None):
if kind is not None:
if schema is not None:
raise AssertionError('Logic error: specified both kind and schema')
msg = "Only kind or schema may be specified, not both"
raise exceptions.ValidationException(msg)
schema = 'promenade/%s/v1' % kind
for document in documents:

View File

75
promenade/control/api.py Normal file
View File

@ -0,0 +1,75 @@
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import falcon
from promenade.control.base import BaseResource, PromenadeRequest
from promenade.control.health_api import HealthResource
from promenade.control.middleware import (AuthMiddleware, ContextMiddleware,
LoggingMiddleware)
from promenade import exceptions as exc
from promenade import logging
LOG = logging.getLogger(__name__)
def start_api():
middlewares = [
AuthMiddleware(),
ContextMiddleware(),
LoggingMiddleware(),
]
control_api = falcon.API(
request_type=PromenadeRequest, middleware=middlewares)
# v1.0 of Promenade API
v1_0_routes = [
# API for managing region data
('/health', HealthResource()),
]
# Set up the 1.0 routes
route_v1_0_prefix = '/api/v1.0'
for path, res in v1_0_routes:
route = '{}{}'.format(route_v1_0_prefix, path)
LOG.info('Adding route: %s Handled by %s', route,
res.__class__.__name__)
control_api.add_route(route, res)
control_api.add_route('/versions', VersionsResource())
# Error handlers (FILO handling)
control_api.add_error_handler(Exception, exc.default_exception_handler)
control_api.add_error_handler(exc.PromenadeException,
exc.PromenadeException.handle)
# built-in error serializer
control_api.set_error_serializer(exc.default_error_serializer)
return control_api
class VersionsResource(BaseResource):
"""
Lists the versions supported by this API
"""
def on_get(self, req, resp):
resp.body = self.to_json({
'v1.0': {
'path': '/api/v1.0',
'status': 'stable'
}
})
resp.status = falcon.HTTP_200

184
promenade/control/base.py Normal file
View File

@ -0,0 +1,184 @@
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import uuid
from oslo_context import context
from jsonschema import validate
import falcon
import falcon.request as request
import falcon.routing as routing
from promenade import exceptions as exc
from promenade import logging
class BaseResource(object):
def __init__(self):
self.logger = logging.getLogger('promenade.control')
def on_options(self, req, resp, **kwargs):
"""
Handle options requests
"""
method_map = routing.create_http_method_map(self)
for method in method_map:
if method_map.get(method).__name__ != 'method_not_allowed':
resp.append_header('Allow', method)
resp.status = falcon.HTTP_200
def req_json(self, req, validate_json_schema=None):
"""
Reads and returns the input json message, optionally validates against
a provided jsonschema
:param req: the falcon request object
:param validate_json_schema: the optional jsonschema to use for
validation
"""
has_input = False
if ((req.content_length is not None or req.content_length != 0)
and (req.content_type is not None
and req.content_type.lower() == 'application/json')):
raw_body = req.stream.read(req.content_length or 0)
if raw_body is not None:
has_input = True
self.info(req.context, 'Input message body: %s' % raw_body)
else:
self.info(req.context, 'No message body specified')
if has_input:
# read the json and validate if necessary
try:
raw_body = raw_body.decode('utf-8')
json_body = json.loads(raw_body)
if validate_json_schema:
# raises an exception if it doesn't validate
validate(json_body, json.loads(validate_json_schema))
return json_body
except json.JSONDecodeError as jex:
self.error(req.context,
"Invalid JSON in request: \n%s" % raw_body)
raise exc.InvalidFormatError(
title='JSON could not be decoded',
description='%s: Invalid JSON in body: %s' % (req.path,
jex))
else:
# No body passed as input. Fail validation if it was asekd for
if validate_json_schema is not None:
raise exc.InvalidFormatError(
title='Json body is required',
description='%s: Bad input, no body provided' % (req.path))
else:
return None
def to_json(self, body_dict):
"""
Thin wrapper around json.dumps, providing the default=str config
"""
return json.dumps(body_dict, default=str)
def log_message(self, ctx, level, msg):
"""
Logs a message with context, and extra populated.
"""
extra = {'user': 'N/A', 'req_id': 'N/A', 'external_ctx': 'N/A'}
if ctx is not None:
extra = {
'user': ctx.user,
'req_id': ctx.request_id,
'external_ctx': ctx.external_marker,
}
self.logger.log(level, msg, extra=extra)
def debug(self, ctx, msg):
"""
Debug logger for resources, incorporating context.
"""
self.log_message(ctx, logging.DEBUG, msg)
def info(self, ctx, msg):
"""
Info logger for resources, incorporating context.
"""
self.log_message(ctx, logging.INFO, msg)
def warn(self, ctx, msg):
"""
Warn logger for resources, incorporating context.
"""
self.log_message(ctx, logging.WARN, msg)
def error(self, ctx, msg):
"""
Error logger for resources, incorporating context.
"""
self.log_message(ctx, logging.ERROR, msg)
class PromenadeRequestContext(context.RequestContext):
"""
Context object for promenade resource requests
"""
def __init__(self, external_marker=None, policy_engine=None, **kwargs):
self.log_level = 'error'
self.request_id = str(uuid.uuid4())
self.external_marker = external_marker
self.policy_engine = policy_engine
self.is_admin_project = False
self.authenticated = False
super(PromenadeRequestContext, self).__init__(**kwargs)
def set_log_level(self, level):
if level in ['error', 'info', 'debug']:
self.log_level = level
def set_user(self, user):
self.user = user
def set_project(self, project):
self.project = project
def add_role(self, role):
self.roles.append(role)
def add_roles(self, roles):
self.roles.extend(roles)
def remove_role(self, role):
self.roles = [x for x in self.roles if x != role]
def set_external_marker(self, marker):
self.external_marker = marker
def set_policy_engine(self, engine):
self.policy_engine = engine
def to_policy_view(self):
policy_dict = {}
policy_dict['user_id'] = self.user_id
policy_dict['user_domain_id'] = self.user_domain_id
policy_dict['project_id'] = self.project_id
policy_dict['project_domain_id'] = self.project_domain_id
policy_dict['roles'] = self.roles
policy_dict['is_admin_project'] = self.is_admin_project
return policy_dict
class PromenadeRequest(request.Request):
context_type = PromenadeRequestContext

View File

@ -0,0 +1,29 @@
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import falcon
from promenade.control import base
class HealthResource(base.BaseResource):
"""
Return empty response/body to show
that promenade is healthy
"""
def on_get(self, req, resp):
"""
It really does nothing right now. It may do more later
"""
resp.status = falcon.HTTP_204

View File

@ -0,0 +1,127 @@
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import uuid
from promenade import logging
from promenade import policy
class AuthMiddleware(object):
def __init__(self):
self.logger = logging.getLogger('promenade')
# Authentication
def process_request(self, req, resp):
ctx = req.context
ctx.set_policy_engine(policy.policy_engine)
for k, v in req.headers.items():
self.logger.debug("Request with header %s: %s" % (k, v))
auth_status = req.get_header(
'X-SERVICE-IDENTITY-STATUS') # will be set to Confirmed or Invalid
service = True
if auth_status is None:
auth_status = req.get_header('X-IDENTITY-STATUS')
service = False
if auth_status == 'Confirmed':
# Process account and roles
ctx.authenticated = True
# User Identity, unique within owning domain
ctx.user = req.get_header(
'X-SERVICE-USER-NAME') if service else req.get_header(
'X-USER-NAME')
# Identity-service managed unique identifier
ctx.user_id = req.get_header(
'X-SERVICE-USER-ID') if service else req.get_header(
'X-USER-ID')
# Identity service managed unique identifier of owning domain of
# user name
ctx.user_domain_id = req.get_header(
'X-SERVICE-USER-DOMAIN-ID') if service else req.get_header(
'X-USER-DOMAIN-ID')
# Identity service managed unique identifier
ctx.project_id = req.get_header(
'X-SERVICE-PROJECT-ID') if service else req.get_header(
'X-PROJECT-ID')
# Name of owning domain of project
ctx.project_domain_id = req.get_header(
'X-SERVICE-PROJECT-DOMAIN-ID') if service else req.get_header(
'X-PROJECT-DOMAIN-NAME')
if service:
# comma delimieted list of case-sensitive role names
ctx.add_roles(req.get_header('X-SERVICE-ROLES').split(','))
else:
ctx.add_roles(req.get_header('X-ROLES').split(','))
if req.get_header('X-IS-ADMIN-PROJECT') == 'True':
ctx.is_admin_project = True
else:
ctx.is_admin_project = False
self.logger.debug(
'Request from authenticated user %s with roles %s', ctx.user,
','.join(ctx.roles))
else:
ctx.authenticated = False
class ContextMiddleware(object):
"""
Handle looking at the X-Context_Marker to see if it has value and that
value is a UUID (or close enough). If not, generate one.
"""
def _format_uuid_string(self, string):
return (string.replace('urn:', '').replace('uuid:', '').strip('{}')
.replace('-', '').lower())
def _is_uuid_like(self, val):
try:
return str(uuid.UUID(val)).replace(
'-', '') == self._format_uuid_string(val)
except (TypeError, ValueError, AttributeError):
return False
def process_request(self, req, resp):
ctx = req.context
ext_marker = req.get_header('X-Context-Marker')
if ext_marker is not None and self.is_uuid_like(ext_marker):
# external passed in an ok context marker
ctx.set_external_marker(ext_marker)
else:
# use the request id
ctx.set_external_marker(ctx.request_id)
class LoggingMiddleware(object):
def __init__(self):
self.logger = logging.getLogger('promenade.control')
def process_response(self, req, resp, resource, req_succeeded):
ctx = req.context
extra = {
'user': ctx.user,
'req_id': ctx.request_id,
'external_ctx': ctx.external_marker,
}
resp.append_header('X-Promenade-Req', ctx.request_id)
self.logger.info(
'%s %s - %s', req.method, req.uri, resp.status, extra=extra)
self.logger.debug('Response body:\n%s', resp.body, extra=extra)

View File

@ -1,21 +1,258 @@
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import logging
import traceback
import falcon
LOG = logging.getLogger(__name__)
# Standard error handler
def format_error_resp(req,
resp,
status_code,
message="",
reason="",
error_type=None,
retry=False,
error_list=None,
info_list=None):
"""
Write a error message body and throw a Falcon exception to trigger
an HTTP status
:param req: Falcon request object
:param resp: Falcon response object to update
:param status_code: Falcon status_code constant
:param message: Optional error message to include in the body.
This should be the summary level of the error
message, encompassing an overall result. If
no other messages are passed in the error_list,
this message will be repeated in a generated
message for the output message_list.
:param reason: Optional reason code to include in the body
:param error_type: If specified, the error type will be used,
otherwise, this will be set to
'Unspecified Exception'
:param retry: Optional flag whether client should retry the operation.
:param error_list: optional list of error dictionaries. Minimally,
the dictionary will contain the 'message' field,
but should also contain 'error': True
:param info_list: optional list of info message dictionaries.
Minimally, the dictionary needs to contain a
'message' field, but should also have a
'error': False field.
"""
if error_type is None:
error_type = 'Unspecified Exception'
# since we're handling errors here, if error list is none, set
# up a default error item. If we have info items, add them to the
# message list as well. In both cases, if the error flag is not
# set, set it appropriately.
if error_list is None:
error_list = [{
'message': 'An error ocurred, but was not specified',
'error': True
}]
else:
for error_item in error_list:
if 'error' not in error_item:
error_item['error'] = True
if info_list is None:
info_list = []
else:
for info_item in info_list:
if 'error' not in info_item:
info_item['error'] = False
message_list = error_list + info_list
version = 'N/A'
for part in req.path.split('/'):
if '.' in part and part.startswith('v'):
version = part
break
error_response = {
'kind': 'status',
'apiVersion': version,
'metadata': {},
'status': 'Failure',
'message': message,
'reason': reason,
'details': {
'errorType': error_type,
'errorCount': len(error_list),
'messageList': message_list
},
'code': status_code,
'retry': retry
}
resp.body = json.dumps(error_response, default=str)
resp.content_type = 'application/json'
resp.status = status_code
def default_error_serializer(req, resp, exception):
"""
Writes the default error message body, when we don't handle it otherwise
"""
format_error_resp(
req,
resp,
status_code=exception.status,
message=exception.description,
reason=exception.title,
error_type=exception.__class__.__name__,
error_list=[{
'message': exception.description,
'error': True
}],
info_list=None)
def default_exception_handler(ex, req, resp, params):
"""
Catch-all exception handler for standardized output.
If this is a standard falcon HTTPError, rethrow it for handling
"""
if isinstance(ex, falcon.HTTPError):
# allow the falcon http errors to bubble up and get handled
raise ex
else:
# take care of the uncaught stuff
exc_string = traceback.format_exc()
LOG.error('Unhanded Exception being handled: \n%s', exc_string)
format_error_resp(
req,
resp,
falcon.HTTP_500,
error_type=ex.__class__.__name__,
message="Unhandled Exception raised: %s" % str(ex),
retry=True)
class PromenadeException(Exception):
"""
Base error containing enough information to make a promenade-formatted
error
"""
EXIT_CODE = 1
def __init__(self, message, *, trace=True):
self.message = message
def __init__(self,
title=None,
description=None,
error_list=None,
info_list=None,
status=None,
retry=False,
trace=False):
"""
:param description: The internal error description
:param error_list: The list of errors
:param status: The desired falcon HTTP response code
:param title: The title of the error message
:param error_list: A list of errors to be included in output
messages list
:param info_list: A list of informational messages to be
included in the output messages list
:param retry: Optional retry directive for the consumer
:param trace: Return traceback
"""
self.title = title or self.__class__.title
self.status = status or self.__class__.status
self.description = description
self.error_list = massage_error_list(error_list, description)
self.info_list = info_list
self.retry = retry
self.trace = trace
super().__init__(
PromenadeException._gen_ex_message(title, description))
@staticmethod
def _gen_ex_message(title, description):
ttl = title or 'Exception'
dsc = description or 'No additional decsription'
return '{} : {}'.format(ttl, dsc)
@staticmethod
def handle(ex, req, resp, params):
"""
The handler used for app errors and child classes
"""
format_error_resp(
req,
resp,
ex.status,
message=ex.title,
reason=ex.description,
error_list=ex.error_list,
info_list=ex.info_list,
error_type=ex.__class__.__name__,
retry=ex.retry)
def display(self, debug=False):
if self.trace or debug:
LOG.exception(self.message)
LOG.exception(self.description)
else:
LOG.error(self.message)
LOG.error(self.description)
class ApiError(PromenadeException):
"""
An error to handle general api errors.
"""
title = 'Api Error'
status = falcon.HTTP_400
class InvalidFormatError(PromenadeException):
"""
An exception to cover invalid input formatting
"""
title = 'Invalid Input Error'
status = falcon.HTTP_400
class ValidationException(PromenadeException):
pass
title = 'Validation Error'
def massage_error_list(error_list, placeholder_description):
"""
Returns a best-effort attempt to make a nice error list
"""
output_error_list = []
if error_list:
for error in error_list:
if not error.get('message'):
output_error_list.append({'message': error, 'error': True})
else:
if 'error' not in error:
error['error'] = True
output_error_list.append(error)
if not output_error_list:
output_error_list.append({'message': placeholder_description})
return output_error_list

90
promenade/policy.py Normal file
View File

@ -0,0 +1,90 @@
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import functools
import falcon
from promenade import exceptions as ex
# TODO: Add policy_engine
policy_engine = None
class ApiEnforcer(object):
"""
A decorator class for enforcing RBAC policies
"""
def __init__(self, action):
self.action = action
def __call__(self, f):
@functools.wraps(f)
def secure_handler(slf, req, resp, *args, **kwargs):
ctx = req.context
policy_eng = ctx.policy_engine
slf.info(ctx, "Policy Engine: %s" % policy_eng.__class__.__name__)
# perform auth
slf.info(ctx, "Enforcing policy %s on request %s" %
(self.action, ctx.request_id))
# policy engine must be configured
if policy_eng is None:
slf.error(
ctx,
"Error-Policy engine required-action: %s" % self.action)
raise ex.PromenadeException(
title="Auth is not being handled by any policy engine",
status=falcon.HTTP_500,
retry=False)
authorized = False
try:
if policy_eng.authorize(self.action, ctx):
# authorized
slf.info(ctx, "Request is authorized")
authorized = True
except Exception:
# couldn't service the auth request
slf.error(
ctx,
"Error - Expectation Failed - action: %s" % self.action)
raise ex.ApiError(
title="Expectation Failed",
status=falcon.HTTP_417,
retry=False)
if authorized:
return f(slf, req, resp, *args, **kwargs)
else:
slf.error(
ctx,
"Auth check failed. Authenticated:%s" % ctx.authenticated)
# raise the appropriate response exeception
if ctx.authenticated:
slf.error(
ctx,
"Error: Forbidden access - action: %s" % self.action)
raise ex.ApiError(
title="Forbidden",
status=falcon.HTTP_403,
description="Credentials do not permit access",
retry=False)
else:
slf.error(ctx, "Error - Unauthenticated access")
raise ex.ApiError(
title="Unauthenticated",
status=falcon.HTTP_401,
description="Credentials are not established",
retry=False)
return secure_handler

31
promenade/promenade.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from promenade.control import api
from promenade import logging
def start_promenade():
# Setup root logger
logging.setup(verbose=False)
# TODO: Add policy engine to start
# Start the API
return api.start_api()
# Initialization compatible with PasteDeploy
def paste_start_promenade(global_conf, **kwargs):
return promenade
promenade = start_promenade()

View File

@ -1,8 +1,9 @@
from . import logging
import hashlib
import io
import tarfile
from promenade import logging
__all__ = ['TarBundler']
LOG = logging.getLogger(__name__)

View File

@ -1,5 +1,19 @@
from . import exceptions
from . import logging
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from promenade import exceptions
from promenade import logging
import jsonschema
import os
import pkg_resources

View File

@ -1,7 +1,11 @@
click==6.7
falcon==1.2.0
jinja2==2.9.6
jsonpath-ng==1.4.3
jsonschema==2.6.0
keystonemiddleware==4.17.0
oslo.context>=2.14.0
PasteDeploy==1.5.2
pbr==3.0.1
pyyaml==3.12
requests==2.18.4

View File

@ -2,14 +2,18 @@ certifi==2017.7.27.1
chardet==3.0.4
click==6.7
decorator==4.1.2
falcon==1.2.0
idna==2.6
Jinja2==2.9.6
jsonpath-ng==1.4.3
jsonschema==2.6.0
keystonemiddleware==4.17.0
MarkupSafe==1.0
oslo.context==2.19.1
PasteDeploy==1.5.2
pbr==3.0.1
ply==3.10
PyYAML==3.12
requests==2.18.4
six==1.11.0
urllib3==1.22
urllib3==1.22