basic API service

Implement the basic boilerplate for the smaug APIs, using WSGI and
OSLO services.
Smaug-api service now can respond the request of the resource plans.

Follow these steps to using smaug API service:

1 download the source code form github.

2 install dependency libraries
pip install -r requirements.txt

3 install smaug API service
python setup.py install

4 start smaug API service
python /usr/local/bin/smaug-api --config-file /etc/smaug/smaug.conf

4 using the resource plans RESTAPI (now only available with auth_strategy  noauth )
http://10.229.46.128:8799/v1/55daed3cc6da42c6aa25e55d15bb1757/plans

Closes-Bug: #1514745
Change-Id: Id27c624714c1839818937b94c1ecd33f393ec282
This commit is contained in:
chenying 2015-11-19 19:17:04 +08:00
parent 409b447de1
commit b2a624c783
49 changed files with 4053 additions and 0 deletions

34
etc/api-paste.ini Normal file
View File

@ -0,0 +1,34 @@
#############
# OpenStack #
#############
[composite:osapi_smaug]
use = egg:Paste#urlmap
/: apiversions
/v1: openstack_smaug_api_v1
[composite:openstack_smaug_api_v1]
use = call:smaug.api.middleware.auth:pipeline_factory
noauth = request_id catch_errors noauth apiv1
keystone = request_id catch_errors authtoken keystonecontext apiv1
[filter:request_id]
paste.filter_factory = oslo_middleware:RequestId.factory
[filter:catch_errors]
paste.filter_factory = oslo_middleware:CatchErrors.factory
[filter:noauth]
paste.filter_factory = smaug.api.middleware.auth:NoAuthMiddleware.factory
[filter:keystonecontext]
paste.filter_factory = smaug.api.middleware.auth:SmaugKeystoneContext.factory
[filter:authtoken]
paste.filter_factory = keystonemiddleware.auth_token:filter_factory
[app:apiversions]
paste.app_factory = smaug.api.versions:Versions.factory
[app:apiv1]
paste.app_factory = smaug.api.v1.router:APIRouter.factory

10
etc/policy.json Normal file
View File

@ -0,0 +1,10 @@
{
"context_is_admin": "role:admin",
"admin_or_owner": "is_admin:True or project_id:%(project_id)s",
"default": "rule:admin_or_owner",
"admin_api": "is_admin:True",
"plan:create": "",
"plan:delete": "rule:admin_or_owner"
}

23
etc/smaug.conf Normal file
View File

@ -0,0 +1,23 @@
#[keystone_authtoken]
#signing_dir = /var/cache/smaug
#cafile = /opt/stack/data/ca-bundle.pem
#auth_uri = http://192.168.1.102:5000
#project_domain_id = default
#project_name = service
#user_domain_id = default
#password = nomoresecrete
#username = smaug
#auth_url = http://http://192.168.1.102:35357
#auth_plugin = password
[DEFAULT]
api_paste_config = /etc/smaug/api-paste.ini
logging_context_format_string = %(asctime)s.%(msecs)03d %(color)s%(levelname)s %(name)s [%(request_id)s %(user_id)s %(project_id)s%(color)s] %(instance)s%(color)s%(message)s
logging_debug_format_suffix = from (pid=%(process)d) %(funcName)s %(pathname)s:%(lineno)d
logging_default_format_string = %(asctime)s.%(msecs)03d %(color)s%(levelname)s %(name)s [-%(color)s] %(instance)s%(color)s%(message)s
logging_exception_prefix = %(color)s%(asctime)s.%(msecs)03d TRACE %(name)s %(instance)s
verbose = True
debug = True
auth_strategy = noauth
log_dir = /var/log/
rpc_backend = rabbit

View File

@ -4,3 +4,27 @@
pbr>=1.6
Babel>=1.3
eventlet>=0.17.4
greenlet>=0.3.2
keystonemiddleware>=4.0.0
kombu>=3.0.7
oslo.config>=2.7.0 # Apache-2.0
oslo.concurrency>=2.3.0 # Apache-2.0
oslo.context>=0.2.0 # Apache-2.0
oslo.db>=3.2.0 # Apache-2.0
oslo.log>=1.12.0 # Apache-2.0
oslo.messaging>2.6.1,!=2.8.0 # Apache-2.0
oslo.middleware>=3.0.0 # Apache-2.0
oslo.policy>=0.5.0 # Apache-2.0
oslo.serialization>=1.10.0 # Apache-2.0
oslo.service>=1.0.0 # Apache-2.0
Paste
PasteDeploy>=1.5.0
requests>=2.8.1
Routes!=2.0,!=2.1,>=1.12.3;python_version=='2.7'
Routes!=2.0,>=1.12.3;python_version!='2.7'
six>=1.9.0
SQLAlchemy<1.1.0,>=0.9.9
sqlalchemy-migrate>=0.9.6
WebOb>=1.2.3
oslo.i18n>=1.5.0 # Apache-2.0

View File

@ -1,5 +1,6 @@
[metadata]
name = smaug
version = 0.0.1
summary = Application Data Protection as a Service for OpenStack
description-file =
README.rst
@ -22,6 +23,15 @@ classifier =
[files]
packages =
smaug
data_files =
/etc/smaug =
etc/api-paste.ini
etc/policy.json
etc/smaug.conf
[entry_points]
console_scripts =
smaug-api = smaug.cmd.api:main
[build_sphinx]
source-dir = doc/source

0
smaug/api/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,158 @@
# Copyright 2010 OpenStack Foundation
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
Common Auth Middleware.
"""
import os
from oslo_config import cfg
from oslo_log import log as logging
from oslo_middleware import request_id
from oslo_serialization import jsonutils
import webob.dec
import webob.exc
from smaug.api.openstack import wsgi
from smaug import context
from smaug.i18n import _
from smaug.wsgi import common as base_wsgi
use_forwarded_for_opt = cfg.BoolOpt(
'use_forwarded_for',
default=False,
help='Treat X-Forwarded-For as the canonical remote address. '
'Only enable this if you have a sanitizing proxy.')
CONF = cfg.CONF
CONF.register_opt(use_forwarded_for_opt)
LOG = logging.getLogger(__name__)
def pipeline_factory(loader, global_conf, **local_conf):
"""A paste pipeline replica that keys off of auth_strategy."""
pipeline = local_conf[CONF.auth_strategy]
pipeline = pipeline.split()
filters = [loader.get_filter(n) for n in pipeline[:-1]]
app = loader.get_app(pipeline[-1])
filters.reverse()
for filter in filters:
app = filter(app)
return app
class InjectContext(base_wsgi.Middleware):
"""Add a 'smaug.context' to WSGI environ."""
def __init__(self, context, *args, **kwargs):
self.context = context
super(InjectContext, self).__init__(*args, **kwargs)
@webob.dec.wsgify(RequestClass=base_wsgi.Request)
def __call__(self, req):
req.environ['smaug.context'] = self.context
return self.application
class SmaugKeystoneContext(base_wsgi.Middleware):
"""Make a request context from keystone headers."""
@webob.dec.wsgify(RequestClass=base_wsgi.Request)
def __call__(self, req):
user_id = req.headers.get('X_USER')
user_id = req.headers.get('X_USER_ID', user_id)
if user_id is None:
LOG.debug("Neither X_USER_ID nor X_USER found in request")
return webob.exc.HTTPUnauthorized()
# get the roles
roles = [r.strip() for r in req.headers.get('X_ROLE', '').split(',')]
if 'X_TENANT_ID' in req.headers:
# This is the new header since Keystone went to ID/Name
project_id = req.headers['X_TENANT_ID']
else:
# This is for legacy compatibility
project_id = req.headers['X_TENANT']
project_name = req.headers.get('X_TENANT_NAME')
req_id = req.environ.get(request_id.ENV_REQUEST_ID)
# Get the auth token
auth_token = req.headers.get('X_AUTH_TOKEN',
req.headers.get('X_STORAGE_TOKEN'))
# Build a context, including the auth_token...
remote_address = req.remote_addr
service_catalog = None
if req.headers.get('X_SERVICE_CATALOG') is not None:
try:
catalog_header = req.headers.get('X_SERVICE_CATALOG')
service_catalog = jsonutils.loads(catalog_header)
except ValueError:
raise webob.exc.HTTPInternalServerError(
explanation=_('Invalid service catalog json.'))
if CONF.use_forwarded_for:
remote_address = req.headers.get('X-Forwarded-For', remote_address)
ctx = context.RequestContext(user_id,
project_id,
project_name=project_name,
roles=roles,
auth_token=auth_token,
remote_address=remote_address,
service_catalog=service_catalog,
request_id=req_id)
req.environ['smaug.context'] = ctx
return self.application
class NoAuthMiddleware(base_wsgi.Middleware):
"""Return a fake token if one isn't specified."""
@webob.dec.wsgify(RequestClass=wsgi.Request)
def __call__(self, req):
if 'X-Auth-Token' not in req.headers:
user_id = req.headers.get('X-Auth-User', 'admin')
project_id = req.headers.get('X-Auth-Project-Id', 'admin')
os_url = os.path.join(req.url, project_id)
res = webob.Response()
# NOTE(vish): This is expecting and returning Auth(1.1), whereas
# keystone uses 2.0 auth. We should probably allow
# 2.0 auth here as well.
res.headers['X-Auth-Token'] = '%s:%s' % (user_id, project_id)
res.headers['X-Server-Management-Url'] = os_url
res.content_type = 'text/plain'
res.status = '204'
return res
token = req.headers['X-Auth-Token']
user_id, _sep, project_id = token.partition(':')
project_id = project_id or user_id
remote_address = getattr(req, 'remote_address', '127.0.0.1')
if CONF.use_forwarded_for:
remote_address = req.headers.get('X-Forwarded-For', remote_address)
ctx = context.RequestContext(user_id,
project_id,
is_admin=True,
remote_address=remote_address)
req.environ['smaug.context'] = ctx
return self.application

View File

@ -0,0 +1,58 @@
# Copyright (c) 2013 OpenStack Foundation
#
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
WSGI middleware for OpenStack API controllers.
"""
from oslo_log import log as logging
import routes
LOG = logging.getLogger(__name__)
class APIMapper(routes.Mapper):
def routematch(self, url=None, environ=None):
if url is "":
result = self._match("", environ)
return result[0], result[1]
return routes.Mapper.routematch(self, url, environ)
def connect(self, *args, **kwargs):
# NOTE(inhye): Default the format part of a route to only accept json
# and xml so it doesn't eat all characters after a '.'
# in the url.
kwargs.setdefault('requirements', {})
if not kwargs['requirements'].get('format'):
kwargs['requirements']['format'] = 'json|xml'
return routes.Mapper.connect(self, *args, **kwargs)
class ProjectMapper(APIMapper):
def resource(self, member_name, collection_name, **kwargs):
if 'parent_resource' not in kwargs:
kwargs['path_prefix'] = '{project_id}/'
else:
parent_resource = kwargs['parent_resource']
p_collection = parent_resource['collection_name']
p_member = parent_resource['member_name']
kwargs['path_prefix'] = '{project_id}/%s/:%s_id' % (p_collection,
p_member)
routes.Mapper.resource(self,
member_name,
collection_name,
**kwargs)

1013
smaug/api/openstack/wsgi.py Normal file

File diff suppressed because it is too large Load Diff

0
smaug/api/v1/__init__.py Normal file
View File

95
smaug/api/v1/plans.py Normal file
View File

@ -0,0 +1,95 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""The plans api."""
from oslo_log import log as logging
import webob
from webob import exc
from smaug.api.openstack import wsgi
from smaug.i18n import _LI
LOG = logging.getLogger(__name__)
class PlansController(wsgi.Controller):
"""The Plans API controller for the OpenStack API."""
def __init__(self):
super(PlansController, self).__init__()
def show(self, req, id):
"""Return data about the given plan."""
context = req.environ['smaug.context']
LOG.info(_LI("Show plan with id: %s"), id, context=context)
# TODO(chenying)
return {'Smaug': "Plans show test."}
def delete(self, req, id):
"""Delete a plan."""
context = req.environ['smaug.context']
LOG.info(_LI("Delete plan with id: %s"), id, context=context)
# TODO(chenying)
return webob.Response(status_int=202)
def index(self, req):
"""Returns a summary list of plans."""
# TODO(chenying)
return {'plan': "Plans index test."}
def detail(self, req):
"""Returns a detailed list of plans."""
# TODO(chenying)
return {'plan': "Plans detail test."}
def create(self, req, body):
"""Creates a new plan."""
if not self.is_valid_body(body, 'plan'):
raise exc.HTTPUnprocessableEntity()
LOG.debug('Create plans request body: %s', body)
context = req.environ['smaug.context']
LOG.debug('Create plans request context: %s', context)
# TODO(chenying)
return {'plan': "Create a plan test."}
def update(self, req, id, body):
"""Update a plan."""
context = req.environ['smaug.context']
if not body:
raise exc.HTTPUnprocessableEntity()
if 'plan' not in body:
raise exc.HTTPUnprocessableEntity()
plan = body['plan']
LOG.info(_LI("Update plan : %s"), plan, context=context)
return {'plan': "Update a plan test."}
def create_resource():
return wsgi.Resource(PlansController())

30
smaug/api/v1/router.py Normal file
View File

@ -0,0 +1,30 @@
# 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 smaug.api.openstack import ProjectMapper
from smaug.api.v1 import plans
from smaug.wsgi import common as wsgi_common
class APIRouter(wsgi_common.Router):
@classmethod
def factory(cls, global_conf, **local_conf):
return cls(ProjectMapper())
def __init__(self, mapper):
plans_resources = plans.create_resource()
mapper.resource("plan", "plans",
controller=plans_resources,
collection={'detail': 'GET'},
member={'action': 'POST'})
super(APIRouter, self).__init__(mapper)

54
smaug/api/versions.py Normal file
View File

@ -0,0 +1,54 @@
# 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 httplib
from oslo_log import log as logging
import webob.dec
from oslo_serialization import jsonutils
from smaug.api.openstack import wsgi
LOG = logging.getLogger(__name__)
class Versions(object):
@classmethod
def factory(cls, global_config, **local_config):
return cls()
@webob.dec.wsgify(RequestClass=wsgi.Request)
def __call__(self, req):
"""Respond to a request for all OpenStack API versions."""
def build_version_object(version, path, status):
return {
'id': 'v%s' % version,
'status': status,
'links': [
{
'rel': 'self',
'href': '%s/%s/' % (req.host_url, path),
},
],
}
version_objs = []
version_objs.extend([
build_version_object(1.0, 'v1', 'CURRENT'),
])
response = webob.Response(request=req,
status=httplib.MULTIPLE_CHOICES,
content_type='application/json')
response.body = jsonutils.dumps(dict(versions=version_objs))
return response

0
smaug/cmd/__init__.py Normal file
View File

47
smaug/cmd/api.py Normal file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env python
# 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.
"""Starter script for smaug OS API."""
import eventlet
eventlet.monkey_patch()
import sys
from oslo_config import cfg
from oslo_log import log as logging
# Need to register global_opts
from smaug.common import config # noqa
from smaug import i18n
i18n.enable_lazy()
from smaug import rpc
from smaug import service
from smaug import version
CONF = cfg.CONF
def main():
CONF(sys.argv[1:], project='smaug',
version=version.version_string())
logging.setup(CONF, "smaug")
rpc.init(CONF)
launcher = service.process_launcher()
server = service.WSGIService('osapi_smaug')
launcher.launch_service(server, workers=server.workers)
launcher.wait()

0
smaug/common/__init__.py Normal file
View File

61
smaug/common/config.py Normal file
View File

@ -0,0 +1,61 @@
# 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.
"""Command-line flag library.
Emulates gflags by wrapping cfg.ConfigOpts.
The idea is to move fully to cfg eventually, and this wrapper is a
stepping stone.
"""
import socket
from oslo_config import cfg
from oslo_log import log as logging
CONF = cfg.CONF
logging.register_options(CONF)
core_opts = [
cfg.StrOpt('api_paste_config',
default="api-paste.ini",
help='File name for the paste.deploy config for smaug-api')
]
debug_opts = [
]
CONF.register_cli_opts(core_opts)
CONF.register_cli_opts(debug_opts)
global_opts = [
cfg.StrOpt('scheduler_topic',
default='Smaug-scheduler',
help='The topic that scheduler nodes listen on'),
cfg.StrOpt('scheduler_manager',
default='Smaug.scheduler.manager.SchedulerManager',
help='Full class name for the Manager for scheduler'),
cfg.StrOpt('host',
default=socket.gethostname(),
help='Name of this node. This can be an opaque identifier. '
'It is not necessarily a host name, FQDN, or IP address.'),
cfg.StrOpt('auth_strategy',
default='keystone',
choices=['noauth', 'keystone'],
help='The strategy to use for auth. Supports noauth or '
'keystone.'),
]
CONF.register_opts(global_opts)

166
smaug/context.py Normal file
View File

@ -0,0 +1,166 @@
# Copyright 2011 OpenStack Foundation
#
# 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.
"""RequestContext: context for requests that persist through all of smaug."""
import copy
from oslo_config import cfg
from oslo_context import context
from oslo_log import log as logging
from oslo_utils import timeutils
import six
from smaug.i18n import _
from smaug import policy
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
class RequestContext(context.RequestContext):
"""Security context and request information.
Represents the user taking a given action within the system.
"""
def __init__(self, user_id, project_id, is_admin=None, read_deleted="no",
roles=None, project_name=None, remote_address=None,
timestamp=None, request_id=None, auth_token=None,
overwrite=True, quota_class=None, service_catalog=None,
domain=None, user_domain=None, project_domain=None,
**kwargs):
"""Initialize RequestContext.
:param read_deleted: 'no' indicates deleted records are hidden, 'yes'
indicates deleted records are visible, 'only' indicates that
*only* deleted records are visible.
:param overwrite: Set to False to ensure that the greenthread local
copy of the index is not overwritten.
:param kwargs: Extra arguments that might be present, but we ignore
because they possibly came in from older rpc messages.
"""
super(RequestContext, self).__init__(auth_token=auth_token,
user=user_id,
tenant=project_id,
domain=domain,
user_domain=user_domain,
project_domain=project_domain,
is_admin=is_admin,
request_id=request_id)
self.roles = roles or []
self.project_name = project_name
self.read_deleted = read_deleted
self.remote_address = remote_address
if not timestamp:
timestamp = timeutils.utcnow()
elif isinstance(timestamp, six.string_types):
timestamp = timeutils.parse_isotime(timestamp)
self.timestamp = timestamp
self.quota_class = quota_class
if service_catalog:
# Only include required parts of service_catalog
self.service_catalog = [s for s in service_catalog
if s.get('type') in
('identity', 'compute', 'object-store')]
else:
# if list is empty or none
self.service_catalog = []
# We need to have RequestContext attributes defined
# when policy.check_is_admin invokes request logging
# to make it loggable.
if self.is_admin is None:
self.is_admin = policy.check_is_admin(self.roles, self)
elif self.is_admin and 'admin' not in self.roles:
self.roles.append('admin')
def _get_read_deleted(self):
return self._read_deleted
def _set_read_deleted(self, read_deleted):
if read_deleted not in ('no', 'yes', 'only'):
raise ValueError(_("read_deleted can only be one of 'no', "
"'yes' or 'only', not %r") % read_deleted)
self._read_deleted = read_deleted
def _del_read_deleted(self):
del self._read_deleted
read_deleted = property(_get_read_deleted, _set_read_deleted,
_del_read_deleted)
def to_dict(self):
result = super(RequestContext, self).to_dict()
result['user_id'] = self.user_id
result['project_id'] = self.project_id
result['project_name'] = self.project_name
result['domain'] = self.domain
result['read_deleted'] = self.read_deleted
result['roles'] = self.roles
result['remote_address'] = self.remote_address
result['timestamp'] = self.timestamp.isoformat()
result['quota_class'] = self.quota_class
result['service_catalog'] = self.service_catalog
result['request_id'] = self.request_id
return result
@classmethod
def from_dict(cls, values):
return cls(**values)
def elevated(self, read_deleted=None, overwrite=False):
"""Return a version of this context with admin flag set."""
context = self.deepcopy()
context.is_admin = True
if 'admin' not in context.roles:
context.roles.append('admin')
if read_deleted is not None:
context.read_deleted = read_deleted
return context
def deepcopy(self):
return copy.deepcopy(self)
@property
def project_id(self):
return self.tenant
@project_id.setter
def project_id(self, value):
self.tenant = value
@property
def user_id(self):
return self.user
@user_id.setter
def user_id(self, value):
self.user = value
def get_admin_context(read_deleted="no"):
return RequestContext(user_id=None,
project_id=None,
is_admin=True,
read_deleted=read_deleted,
overwrite=False)

171
smaug/exception.py Normal file
View File

@ -0,0 +1,171 @@
# 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.
"""Smaug base exception handling.
Includes decorator for re-raising Smaug-type exceptions.
SHOULD include dedicated exception logging.
"""
import sys
from oslo_config import cfg
from oslo_log import log as logging
import six
import webob.exc
from webob.util import status_generic_reasons
from webob.util import status_reasons
from smaug.i18n import _, _LE
LOG = logging.getLogger(__name__)
exc_log_opts = [
cfg.BoolOpt('fatal_exception_format_errors',
default=False,
help='Make exception message format errors fatal.'),
]
CONF = cfg.CONF
CONF.register_opts(exc_log_opts)
class ConvertedException(webob.exc.WSGIHTTPException):
def __init__(self, code=500, title="", explanation=""):
self.code = code
# There is a strict rule about constructing status line for HTTP:
# '...Status-Line, consisting of the protocol version followed by a
# numeric status code and its associated textual phrase, with each
# element separated by SP characters'
# (http://www.faqs.org/rfcs/rfc2616.html)
# 'code' and 'title' can not be empty because they correspond
# to numeric status code and its associated text
if title:
self.title = title
else:
try:
self.title = status_reasons[self.code]
except KeyError:
generic_code = self.code // 100
self.title = status_generic_reasons[generic_code]
self.explanation = explanation
super(ConvertedException, self).__init__()
class Error(Exception):
pass
class SmaugException(Exception):
"""Base Smaug Exception
To correctly use this class, inherit from it and define
a 'message' property. That message will get printf'd
with the keyword arguments provided to the constructor.
"""
message = _("An unknown exception occurred.")
code = 500
headers = {}
safe = False
def __init__(self, message=None, **kwargs):
self.kwargs = kwargs
self.kwargs['message'] = message
if 'code' not in self.kwargs:
try:
self.kwargs['code'] = self.code
except AttributeError:
pass
for k, v in self.kwargs.items():
if isinstance(v, Exception):
self.kwargs[k] = six.text_type(v)
if self._should_format():
try:
message = self.message % kwargs
except Exception:
exc_info = sys.exc_info()
# kwargs doesn't match a variable in the message
# log the issue and the kwargs
LOG.exception(_LE('Exception in string format operation'))
for name, value in kwargs.items():
LOG.error(_LE("%(name)s: %(value)s"),
{'name': name, 'value': value})
if CONF.fatal_exception_format_errors:
six.reraise(*exc_info)
# at least get the core message out if something happened
message = self.message
elif isinstance(message, Exception):
message = six.text_type(message)
# NOTE(luisg): We put the actual message in 'msg' so that we can access
# it, because if we try to access the message via 'message' it will be
# overshadowed by the class' message attribute
self.msg = message
super(SmaugException, self).__init__(message)
def _should_format(self):
return self.kwargs['message'] is None or '%(message)' in self.message
def __unicode__(self):
return six.text_type(self.msg)
class NotAuthorized(SmaugException):
message = _("Not authorized.")
code = 403
class AdminRequired(NotAuthorized):
message = _("User does not have admin privileges")
class PolicyNotAuthorized(NotAuthorized):
message = _("Policy doesn't allow %(action)s to be performed.")
class Invalid(SmaugException):
message = _("Unacceptable parameters.")
code = 400
class InvalidInput(Invalid):
message = _("Invalid input received: %(reason)s")
class NotFound(SmaugException):
message = _("Resource could not be found.")
code = 404
safe = True
class ConfigNotFound(NotFound):
message = _("Could not find config at %(path)s")
class MalformedRequestBody(SmaugException):
message = _("Malformed message body: %(reason)s")
class InvalidContentType(Invalid):
message = _("Invalid content type %(content_type)s.")
class PasteAppNotFound(NotFound):
message = _("Could not load paste app '%(name)s' from %(path)s")

48
smaug/i18n.py Normal file
View File

@ -0,0 +1,48 @@
# 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.
"""oslo.i18n integration module.
See http://docs.openstack.org/developer/oslo.i18n/usage.html .
"""
import oslo_i18n as i18n
DOMAIN = 'smaug'
_translators = i18n.TranslatorFactory(domain=DOMAIN)
# The primary translation function using the well-known name "_"
_ = _translators.primary
# Translators for log levels.
#
# The abbreviated names are meant to reflect the usual use of a short
# name like '_'. The "L" is for "log" and the other letter comes from
# the level.
_LI = _translators.log_info
_LW = _translators.log_warning
_LE = _translators.log_error
_LC = _translators.log_critical
def enable_lazy(enable=True):
return i18n.enable_lazy(enable)
def translate(value, user_locale=None):
return i18n.translate(value, user_locale)
def get_available_languages():
return i18n.get_available_languages(DOMAIN)

93
smaug/policy.py Normal file
View File

@ -0,0 +1,93 @@
# Copyright (c) 2011 OpenStack Foundation
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Policy Engine For Smaug"""
from oslo_config import cfg
from oslo_policy import opts as policy_opts
from oslo_policy import policy
from smaug import exception
CONF = cfg.CONF
policy_opts.set_defaults(cfg.CONF, 'policy.json')
_ENFORCER = None
def init():
global _ENFORCER
if not _ENFORCER:
_ENFORCER = policy.Enforcer(CONF)
def enforce_action(context, action):
"""Checks that the action can be done by the given context.
Applies a check to ensure the context's project_id and user_id can be
applied to the given action using the policy enforcement api.
"""
return enforce(context, action, {'project_id': context.project_id,
'user_id': context.user_id})
def enforce(context, action, target):
"""Verifies that the action is valid on the target in this context.
:param context: smaug context
:param action: string representing the action to be checked
this should be colon separated for clarity.
i.e. ``compute:create_instance``,
``compute:attach_volume``,
``volume:attach_volume``
:param object: dictionary representing the object of the action
for object creation this should be a dictionary representing the
location of the object e.g. ``{'project_id': context.project_id}``
:raises PolicyNotAuthorized: if verification fails.
"""
init()
return _ENFORCER.enforce(action, target, context.to_dict(),
do_raise=True,
exc=exception.PolicyNotAuthorized,
action=action)
def check_is_admin(roles, context=None):
"""Whether or not user is admin according to policy setting.
Can use roles or user_id from context to determine if user is admin.
In a multi-domain configuration, roles alone may not be sufficient.
"""
init()
# include project_id on target to avoid KeyError if context_is_admin
# policy definition is missing, and default admin_or_owner rule
# attempts to apply. Since our credentials dict does not include a
# project_id, this target can never match as a generic rule.
target = {'project_id': ''}
if context is None:
credentials = {'roles': roles}
else:
credentials = {'roles': context.roles,
'user_id': context.user_id
}
return _ENFORCER.enforce('context_is_admin', target, credentials)

137
smaug/rpc.py Normal file
View File

@ -0,0 +1,137 @@
# 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.
__all__ = [
'init',
'cleanup',
'set_defaults',
'add_extra_exmods',
'clear_extra_exmods',
'get_allowed_exmods',
'RequestContextSerializer',
'get_client',
'get_server',
'get_notifier',
'TRANSPORT_ALIASES',
]
from oslo_config import cfg
import oslo_messaging as messaging
from oslo_serialization import jsonutils
import smaug.context
import smaug.exception
CONF = cfg.CONF
TRANSPORT = None
NOTIFIER = None
ALLOWED_EXMODS = [
smaug.exception.__name__,
]
EXTRA_EXMODS = []
TRANSPORT_ALIASES = {}
def init(conf):
global TRANSPORT, NOTIFIER
exmods = get_allowed_exmods()
TRANSPORT = messaging.get_transport(conf,
allowed_remote_exmods=exmods,
aliases=TRANSPORT_ALIASES)
serializer = RequestContextSerializer(JsonPayloadSerializer())
NOTIFIER = messaging.Notifier(TRANSPORT, serializer=serializer)
def initialized():
return None not in [TRANSPORT, NOTIFIER]
def cleanup():
global TRANSPORT, NOTIFIER
assert TRANSPORT is not None
assert NOTIFIER is not None
TRANSPORT.cleanup()
TRANSPORT = NOTIFIER = None
def set_defaults(control_exchange):
messaging.set_transport_defaults(control_exchange)
def add_extra_exmods(*args):
EXTRA_EXMODS.extend(args)
def clear_extra_exmods():
del EXTRA_EXMODS[:]
def get_allowed_exmods():
return ALLOWED_EXMODS + EXTRA_EXMODS
class JsonPayloadSerializer(messaging.NoOpSerializer):
@staticmethod
def serialize_entity(context, entity):
return jsonutils.to_primitive(entity, convert_instances=True)
class RequestContextSerializer(messaging.Serializer):
def __init__(self, base):
self._base = base
def serialize_entity(self, context, entity):
if not self._base:
return entity
return self._base.serialize_entity(context, entity)
def deserialize_entity(self, context, entity):
if not self._base:
return entity
return self._base.deserialize_entity(context, entity)
def serialize_context(self, context):
_context = context.to_dict()
return _context
def deserialize_context(self, context):
return smaug.context.RequestContext.from_dict(context)
def get_client(target, version_cap=None, serializer=None):
assert TRANSPORT is not None
serializer = RequestContextSerializer(serializer)
return messaging.RPCClient(TRANSPORT,
target,
version_cap=version_cap,
serializer=serializer)
def get_server(target, endpoints, serializer=None):
assert TRANSPORT is not None
serializer = RequestContextSerializer(serializer)
return messaging.get_rpc_server(TRANSPORT,
target,
endpoints,
executor='eventlet',
serializer=serializer)
def get_notifier(service=None, host=None, publisher_id=None):
assert NOTIFIER is not None
if not publisher_id:
publisher_id = "%s.%s" % (service, host or CONF.host)
return NOTIFIER.prepare(publisher_id=publisher_id)

191
smaug/service.py Normal file
View File

@ -0,0 +1,191 @@
# 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.
"""Generic Node base class for all workers that run on hosts."""
import os
from oslo_concurrency import processutils
from oslo_config import cfg
from oslo_log import log as logging
from oslo_service import service
from oslo_utils import importutils
from smaug import exception
from smaug.i18n import _
from smaug import rpc
from smaug.wsgi import common as wsgi_common
from smaug.wsgi import eventlet_server as wsgi
LOG = logging.getLogger(__name__)
service_opts = [
cfg.StrOpt('osapi_smaug_listen',
default="0.0.0.0",
help='IP address on which OpenStack Smaug API listens'),
cfg.PortOpt('osapi_smaug_listen_port',
default=8799,
help='Port on which OpenStack Smaug API listens'),
cfg.IntOpt('osapi_smaug_workers',
help='Number of workers for OpenStack Smaug API service. '
'The default is equal to the number of CPUs available.'), ]
CONF = cfg.CONF
CONF.register_opts(service_opts)
class WSGIService(service.ServiceBase):
"""Provides ability to launch API from a 'paste' configuration."""
def __init__(self, name, loader=None):
"""Initialize, but do not start the WSGI server.
:param name: The name of the WSGI server given to the loader.
:param loader: Loads the WSGI application using the given name.
:returns: None
"""
self.name = name
self.manager = self._get_manager()
self.loader = loader or wsgi_common.Loader()
self.app = self.loader.load_app(name)
self.host = getattr(CONF, '%s_listen' % name, "0.0.0.0")
self.port = getattr(CONF, '%s_listen_port' % name, 0)
self.workers = (getattr(CONF, '%s_workers' % name, None) or
processutils.get_worker_count())
if self.workers and self.workers < 1:
worker_name = '%s_workers' % name
msg = (_("%(worker_name)s value of %(workers)d is invalid, "
"must be greater than 0.") %
{'worker_name': worker_name,
'workers': self.workers})
raise exception.InvalidInput(msg)
self.server = wsgi.Server(name,
self.app,
host=self.host,
port=self.port)
def _get_manager(self):
"""Initialize a Manager object appropriate for this service.
Use the service name to look up a Manager subclass from the
configuration and initialize an instance. If no class name
is configured, just return None.
:returns: a Manager instance, or None.
"""
fl = '%s_manager' % self.name
if fl not in CONF:
return None
manager_class_name = CONF.get(fl, None)
if not manager_class_name:
return None
manager_class = importutils.import_class(manager_class_name)
return manager_class()
def start(self):
"""Start serving this service using loaded configuration.
Also, retrieve updated port number in case '0' was passed in, which
indicates a random port should be used.
:returns: None
"""
if self.manager:
self.manager.init_host()
self.server.start()
self.port = self.server.port
def stop(self):
"""Stop serving this API.
:returns: None
"""
self.server.stop()
def wait(self):
"""Wait for the service to stop serving this API.
:returns: None
"""
self.server.wait()
def reset(self):
"""Reset server greenpool size to default.
:returns: None
"""
self.server.reset()
def process_launcher():
return service.ProcessLauncher(CONF)
# NOTE(vish): the global launcher is to maintain the existing
# functionality of calling service.serve +
# service.wait
_launcher = None
def serve(server, workers=None):
global _launcher
if _launcher:
raise RuntimeError(_('serve() can only be called once'))
_launcher = service.launch(CONF, server, workers=workers)
def wait():
LOG.debug('Full set of CONF:')
for flag in CONF:
flag_get = CONF.get(flag, None)
# hide flag contents from log if contains a password
# should use secret flag when switch over to openstack-common
if ("_password" in flag or "_key" in flag or
(flag == "sql_connection" and
("mysql:" in flag_get or "postgresql:" in flag_get))):
LOG.debug('%s : FLAG SET ', flag)
else:
LOG.debug('%(flag)s : %(flag_get)s',
{'flag': flag, 'flag_get': flag_get})
try:
_launcher.wait()
except KeyboardInterrupt:
_launcher.stop()
rpc.cleanup()
class Launcher(object):
def __init__(self):
self.launch_service = serve
self.wait = wait
def get_launcher():
# Note(lpetrut): ProcessLauncher uses green pipes which fail on Windows
# due to missing support of non-blocking I/O pipes. For this reason, the
# service must be spawned differently on Windows, using the ServiceLauncher
# class instead.
if os.name == 'nt':
return Launcher()
else:
return process_launcher()

View File

@ -13,10 +13,53 @@
# 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
from oslo_config import cfg
from oslo_log import log
from oslotest import base
from smaug.common import config # noqa Need to register global_opts
from smaug.tests.unit import conf_fixture
test_opts = [
]
CONF = cfg.CONF
CONF.register_opts(test_opts)
LOG = log.getLogger(__name__)
class TestCase(base.BaseTestCase):
"""Test case base class for all unit tests."""
def setUp(self):
"""Run before each test method to initialize test environment."""
super(TestCase, self).setUp()
conf_fixture.set_defaults(CONF)
CONF([], default_config_files=[])
self.override_config('policy_file',
os.path.join(
os.path.abspath(
os.path.join(
os.path.dirname(__file__),
'..',
)
),
'tests/unit/policy.json'),
group='oslo_policy')
def override_config(self, name, override, group=None):
"""Cleanly override CONF variables."""
CONF.set_override(name, override, group)
self.addCleanup(CONF.clear_override, name, group)
def flags(self, **kw):
"""Override CONF variables for a test."""
for k, v in kw.items():
self.override_config(k, v)

View File

View File

View File

@ -0,0 +1,102 @@
# Copyright 2010 OpenStack Foundation
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import uuid
import routes
import webob
import webob.dec
import webob.request
from smaug.api.openstack import wsgi as os_wsgi
from smaug import context
from smaug.wsgi import common as wsgi
FAKE_UUID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
FAKE_UUIDS = {}
class Context(object):
pass
class FakeRouter(wsgi.Router):
def __init__(self, ext_mgr=None):
pass
@webob.dec.wsgify
def __call__(self, req):
res = webob.Response()
res.status = '200'
res.headers['X-Test-Success'] = 'True'
return res
@webob.dec.wsgify
def fake_wsgi(self, req):
return self.application
class FakeToken(object):
id_count = 0
def __getitem__(self, key):
return getattr(self, key)
def __init__(self, **kwargs):
FakeToken.id_count += 1
self.id = FakeToken.id_count
for k, v in kwargs.items():
setattr(self, k, v)
class FakeRequestContext(context.RequestContext):
def __init__(self, *args, **kwargs):
kwargs['auth_token'] = kwargs.get('auth_token', 'fake_auth_token')
super(FakeRequestContext, self).__init__(*args, **kwargs)
class HTTPRequest(webob.Request):
@classmethod
def blank(cls, *args, **kwargs):
if args is not None:
if args[0].find('v1') == 0:
kwargs['base_url'] = 'http://localhost/v1'
else:
kwargs['base_url'] = 'http://localhost/v2'
use_admin_context = kwargs.pop('use_admin_context', False)
out = os_wsgi.Request.blank(*args, **kwargs)
out.environ['smaug.context'] = FakeRequestContext(
'fake_user',
'fakeproject',
is_admin=use_admin_context)
return out
class TestRouter(wsgi.Router):
def __init__(self, controller):
mapper = routes.Mapper()
mapper.resource("test", "tests",
controller=os_wsgi.Resource(controller))
super(TestRouter, self).__init__(mapper)
def get_fake_uuid(token=0):
if token not in FAKE_UUIDS:
FAKE_UUIDS[token] = str(uuid.uuid4())
return FAKE_UUIDS[token]

View File

@ -0,0 +1,76 @@
# Copyright (c) 2012 OpenStack Foundation
#
# 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_middleware import request_id
import webob
import smaug.api.middleware.auth
from smaug.tests import base
class TestSmaugKeystoneContextMiddleware(base.TestCase):
def setUp(self):
super(TestSmaugKeystoneContextMiddleware, self).setUp()
@webob.dec.wsgify()
def fake_app(req):
self.context = req.environ['smaug.context']
return webob.Response()
self.context = None
self.middleware = (smaug.api.middleware.auth
.SmaugKeystoneContext(fake_app))
self.request = webob.Request.blank('/')
self.request.headers['X_TENANT_ID'] = 'testtenantid'
self.request.headers['X_AUTH_TOKEN'] = 'testauthtoken'
def test_no_user_or_user_id(self):
response = self.request.get_response(self.middleware)
self.assertEqual('401 Unauthorized', response.status)
def test_user_only(self):
self.request.headers['X_USER'] = 'testuser'
response = self.request.get_response(self.middleware)
self.assertEqual('200 OK', response.status)
self.assertEqual('testuser', self.context.user_id)
def test_user_id_only(self):
self.request.headers['X_USER_ID'] = 'testuserid'
response = self.request.get_response(self.middleware)
self.assertEqual('200 OK', response.status)
self.assertEqual('testuserid', self.context.user_id)
def test_user_id_trumps_user(self):
self.request.headers['X_USER_ID'] = 'testuserid'
self.request.headers['X_USER'] = 'testuser'
response = self.request.get_response(self.middleware)
self.assertEqual('200 OK', response.status)
self.assertEqual('testuserid', self.context.user_id)
def test_tenant_id_name(self):
self.request.headers['X_USER_ID'] = 'testuserid'
self.request.headers['X_TENANT_NAME'] = 'testtenantname'
response = self.request.get_response(self.middleware)
self.assertEqual('200 OK', response.status)
self.assertEqual('testtenantid', self.context.project_id)
self.assertEqual('testtenantname', self.context.project_name)
def test_request_id_extracted_from_env(self):
req_id = 'dummy-request-id'
self.request.headers['X_PROJECT_ID'] = 'testtenantid'
self.request.headers['X_USER_ID'] = 'testuserid'
self.request.environ[request_id.ENV_REQUEST_ID] = req_id
self.request.get_response(self.middleware)
self.assertEqual(req_id, self.context.request_id)

View File

@ -0,0 +1,125 @@
# 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 smaug.api.openstack import wsgi
from smaug.tests import base
class RequestTest(base.TestCase):
def test_content_type_missing(self):
request = wsgi.Request.blank('/tests/123', method='POST')
request.body = b"<body />"
self.assertIsNone(request.get_content_type())
def test_content_type_unsupported(self):
request = wsgi.Request.blank('/tests/123', method='POST')
request.headers["Content-Type"] = "text/html"
request.body = b"asdf<br />"
self.assertIsNone(request.get_content_type())
def test_content_type_with_charset(self):
request = wsgi.Request.blank('/tests/123')
request.headers["Content-Type"] = "application/json; charset=UTF-8"
result = request.get_content_type()
self.assertEqual("application/json", result)
def test_content_type_from_accept(self):
for content_type in ('application/json',):
request = wsgi.Request.blank('/tests/123')
request.headers["Accept"] = content_type
result = request.best_match_content_type()
self.assertEqual(content_type, result)
def test_content_type_from_accept_best(self):
request = wsgi.Request.blank('/tests/123')
request.headers["Accept"] = "application/xml, application/json"
result = request.best_match_content_type()
self.assertEqual("application/json", result)
def test_content_type_from_query_extension(self):
request = wsgi.Request.blank('/tests/123.json')
result = request.best_match_content_type()
self.assertEqual("application/json", result)
request = wsgi.Request.blank('/tests/123.invalid')
result = request.best_match_content_type()
self.assertEqual("application/json", result)
def test_content_type_accept_default(self):
request = wsgi.Request.blank('/tests/123.unsupported')
request.headers["Accept"] = "application/unsupported1"
result = request.best_match_content_type()
self.assertEqual("application/json", result)
class ActionDispatcherTest(base.TestCase):
def test_dispatch(self):
serializer = wsgi.ActionDispatcher()
serializer.create = lambda x: 'pants'
self.assertEqual('pants', serializer.dispatch({}, action='create'))
def test_dispatch_action_none(self):
serializer = wsgi.ActionDispatcher()
serializer.create = lambda x: 'pants'
serializer.default = lambda x: 'trousers'
self.assertEqual('trousers', serializer.dispatch({}, action=None))
def test_dispatch_default(self):
serializer = wsgi.ActionDispatcher()
serializer.create = lambda x: 'pants'
serializer.default = lambda x: 'trousers'
self.assertEqual('trousers', serializer.dispatch({}, action='update'))
class DictSerializerTest(base.TestCase):
def test_dispatch_default(self):
serializer = wsgi.DictSerializer()
self.assertEqual('', serializer.serialize({}, 'update'))
class JSONDictSerializerTest(base.TestCase):
def test_json(self):
input_dict = dict(servers=dict(a=(2, 3)))
expected_json = '{"servers":{"a":[2,3]}}'
serializer = wsgi.JSONDictSerializer()
result = serializer.serialize(input_dict)
result = result.replace('\n', '').replace(' ', '')
self.assertEqual(expected_json, result)
class TextDeserializerTest(base.TestCase):
def test_dispatch_default(self):
deserializer = wsgi.TextDeserializer()
self.assertEqual({}, deserializer.deserialize({}, 'update'))
class JSONDeserializerTest(base.TestCase):
def test_json(self):
data = """{"a": {
"a1": "1",
"a2": "2",
"bs": ["1", "2", "3", {"c": {"c1": "1"}}],
"d": {"e": "1"},
"f": "1"}}"""
as_dict = {
'body': {
'a': {
'a1': '1',
'a2': '2',
'bs': ['1', '2', '3', {'c': {'c1': '1'}}],
'd': {'e': '1'},
'f': '1',
},
},
}
deserializer = wsgi.JSONDeserializer()
self.assertEqual(as_dict, deserializer.deserialize(data))

View File

View File

@ -0,0 +1,37 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from smaug.api.openstack import ProjectMapper
from smaug.api.v1 import router
from smaug.tests import base
from smaug.tests.unit.api import fakes
class PlansRouterTestCase(base.TestCase):
def setUp(self):
super(PlansRouterTestCase, self).setUp()
mapper = ProjectMapper()
self.app = router.APIRouter(mapper)
def test_plans(self):
req = fakes.HTTPRequest.blank('/fakeproject/plans')
req.method = 'GET'
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(200, response.status_int)
def test_plans_detail(self):
req = fakes.HTTPRequest.blank('/fakeproject/plans/detail')
req.method = 'GET'
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(200, response.status_int)

View File

@ -0,0 +1,26 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from oslo_config import cfg
CONF = cfg.CONF
CONF.import_opt('policy_file', 'smaug.policy', group='oslo_policy')
def set_defaults(conf):
conf.set_default('policy_file', 'smaug.tests.unit/policy.json',
group='oslo_policy')
conf.set_default('policy_dirs', [], group='oslo_policy')
conf.set_default('auth_strategy', 'noauth')

View File

@ -0,0 +1,10 @@
{
"context_is_admin": "role:admin",
"admin_or_owner": "is_admin:True or project_id:%(project_id)s",
"default": "rule:admin_or_owner",
"admin_api": "is_admin:True",
"plan:create": "",
"plan:delete": "rule:admin_or_owner"
}

View File

@ -0,0 +1,59 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import sys
try:
from unittest import mock
except ImportError:
import mock
from oslo_config import cfg
from smaug.cmd import api as smaug_api
from smaug.tests import base
from smaug import version
CONF = cfg.CONF
class TestSmaugApiCmd(base.TestCase):
"""Unit test cases for python modules under smaug/cmd."""
def setUp(self):
super(TestSmaugApiCmd, self).setUp()
sys.argv = ['smaug-api']
CONF(sys.argv[1:], project='smaug', version=version.version_string())
def tearDown(self):
super(TestSmaugApiCmd, self).tearDown()
@mock.patch('smaug.service.WSGIService')
@mock.patch('smaug.service.process_launcher')
@mock.patch('smaug.rpc.init')
@mock.patch('oslo_log.log.setup')
def test_main(self, log_setup, rpc_init, process_launcher,
wsgi_service):
launcher = process_launcher.return_value
server = wsgi_service.return_value
server.workers = mock.sentinel.worker_count
smaug_api.main()
self.assertEqual('smaug', CONF.project)
self.assertEqual(CONF.version, version.version_string())
log_setup.assert_called_once_with(CONF, "smaug")
rpc_init.assert_called_once_with(CONF)
process_launcher.assert_called_once_with()
wsgi_service.assert_called_once_with('osapi_smaug')
launcher.launch_service.assert_called_once_with(server,
workers=server.workers)
launcher.wait.assert_called_once_with()

View File

@ -0,0 +1,94 @@
# 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 smaug import context
from smaug.tests import base
class ContextTestCase(base.TestCase):
def test_request_context_sets_is_admin(self):
ctxt = context.RequestContext('111',
'222',
roles=['admin', 'weasel'])
self.assertEqual(True, ctxt.is_admin)
def test_request_context_sets_is_admin_upcase(self):
ctxt = context.RequestContext('111',
'222',
roles=['Admin', 'weasel'])
self.assertEqual(True, ctxt.is_admin)
def test_request_context_read_deleted(self):
ctxt = context.RequestContext('111',
'222',
read_deleted='yes')
self.assertEqual('yes', ctxt.read_deleted)
ctxt.read_deleted = 'no'
self.assertEqual('no', ctxt.read_deleted)
def test_request_context_read_deleted_invalid(self):
self.assertRaises(ValueError,
context.RequestContext,
'111',
'222',
read_deleted=True)
ctxt = context.RequestContext('111', '222')
self.assertRaises(ValueError,
setattr,
ctxt,
'read_deleted',
True)
def test_request_context_elevated(self):
user_context = context.RequestContext(
'fake_user', 'fake_project', admin=False)
self.assertFalse(user_context.is_admin)
admin_context = user_context.elevated()
self.assertFalse(user_context.is_admin)
self.assertTrue(admin_context.is_admin)
self.assertFalse('admin' in user_context.roles)
self.assertTrue('admin' in admin_context.roles)
def test_service_catalog_nova_and_swift(self):
service_catalog = [
{u'type': u'compute', u'name': u'nova'},
{u'type': u's3', u'name': u's3'},
{u'type': u'image', u'name': u'glance'},
{u'type': u'volume', u'name': u'cinder'},
{u'type': u'ec2', u'name': u'ec2'},
{u'type': u'object-store', u'name': u'swift'},
{u'type': u'identity', u'name': u'keystone'},
{u'type': None, u'name': u'S_withtypeNone'},
{u'type': u'co', u'name': u'S_partofcompute'}]
compute_catalog = [{u'type': u'compute', u'name': u'nova'}]
object_catalog = [{u'name': u'swift', u'type': u'object-store'}]
ctxt = context.RequestContext('111', '222',
service_catalog=service_catalog)
self.assertEqual(3, len(ctxt.service_catalog))
return_compute = [v for v in ctxt.service_catalog if
v['type'] == u'compute']
return_object = [v for v in ctxt.service_catalog if
v['type'] == u'object-store']
self.assertEqual(compute_catalog, return_compute)
self.assertEqual(object_catalog, return_object)
def test_user_identity(self):
ctx = context.RequestContext("user", "tenant",
domain="domain",
user_domain="user-domain",
project_domain="project-domain")
self.assertEqual('user tenant domain user-domain project-domain',
ctx.to_dict()["user_identity"])

View File

@ -0,0 +1,118 @@
# 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 smaug import exception
from smaug.tests import base
import mock
import six
import webob.util
class SmaugExceptionTestCase(base.TestCase):
def test_default_error_msg(self):
class FakeSmaugException(exception.SmaugException):
message = "default message"
exc = FakeSmaugException()
self.assertEqual('default message', six.text_type(exc))
def test_error_msg(self):
self.assertEqual('test',
six.text_type(exception.SmaugException('test')))
def test_default_error_msg_with_kwargs(self):
class FakeSmaugException(exception.SmaugException):
message = "default message: %(code)s"
exc = FakeSmaugException(code=500)
self.assertEqual('default message: 500', six.text_type(exc))
def test_error_msg_exception_with_kwargs(self):
# NOTE(dprince): disable format errors for this test
self.flags(fatal_exception_format_errors=False)
class FakeSmaugException(exception.SmaugException):
message = "default message: %(misspelled_code)s"
exc = FakeSmaugException(code=500)
self.assertEqual('default message: %(misspelled_code)s',
six.text_type(exc))
def test_default_error_code(self):
class FakeSmaugException(exception.SmaugException):
code = 404
exc = FakeSmaugException()
self.assertEqual(404, exc.kwargs['code'])
def test_error_code_from_kwarg(self):
class FakeSmaugException(exception.SmaugException):
code = 500
exc = FakeSmaugException(code=404)
self.assertEqual(404, exc.kwargs['code'])
def test_error_msg_is_exception_to_string(self):
msg = 'test message'
exc1 = Exception(msg)
exc2 = exception.SmaugException(exc1)
self.assertEqual(msg, exc2.msg)
def test_exception_kwargs_to_string(self):
msg = 'test message'
exc1 = Exception(msg)
exc2 = exception.SmaugException(kwarg1=exc1)
self.assertEqual(msg, exc2.kwargs['kwarg1'])
def test_message_in_format_string(self):
class FakeSmaugException(exception.SmaugException):
message = 'FakeSmaugException: %(message)s'
exc = FakeSmaugException(message='message')
self.assertEqual('FakeSmaugException: message', six.text_type(exc))
def test_message_and_kwarg_in_format_string(self):
class FakeSmaugException(exception.SmaugException):
message = 'Error %(code)d: %(message)s'
exc = FakeSmaugException(message='message', code=404)
self.assertEqual('Error 404: message', six.text_type(exc))
def test_message_is_exception_in_format_string(self):
class FakeSmaugException(exception.SmaugException):
message = 'Exception: %(message)s'
msg = 'test message'
exc1 = Exception(msg)
exc2 = FakeSmaugException(message=exc1)
self.assertEqual('Exception: test message', six.text_type(exc2))
class SmaugConvertedExceptionTestCase(base.TestCase):
def test_default_args(self):
exc = exception.ConvertedException()
self.assertNotEqual('', exc.title)
self.assertEqual(500, exc.code)
self.assertEqual('', exc.explanation)
def test_standard_status_code(self):
with mock.patch.dict(webob.util.status_reasons, {200: 'reason'}):
exc = exception.ConvertedException(code=200)
self.assertEqual('reason', exc.title)
@mock.patch.dict(webob.util.status_reasons, {500: 'reason'})
def test_generic_status_code(self):
with mock.patch.dict(webob.util.status_generic_reasons,
{5: 'generic_reason'}):
exc = exception.ConvertedException(code=599)
self.assertEqual('generic_reason', exc.title)

View File

@ -0,0 +1,100 @@
# 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.
"""
Unit Tests for remote procedure calls using queue
"""
import mock
from oslo_concurrency import processutils
from oslo_config import cfg
from smaug import exception
from smaug import service
from smaug.tests import base
from smaug.wsgi import common as wsgi
CONF = cfg.CONF
class TestWSGIService(base.TestCase):
def setUp(self):
super(TestWSGIService, self).setUp()
@mock.patch('smaug.utils.find_config')
def test_service_random_port(self, mock_find_config):
with mock.patch.object(wsgi.Loader, 'load_app') as mock_load_app:
test_service = service.WSGIService("test_service")
self.assertEqual(0, test_service.port)
test_service.start()
self.assertNotEqual(0, test_service.port)
test_service.stop()
self.assertTrue(mock_load_app.called)
@mock.patch('smaug.utils.find_config')
def test_reset_pool_size_to_default(self, mock_find_config):
with mock.patch.object(wsgi.Loader, 'load_app') as mock_load_app:
test_service = service.WSGIService("test_service")
test_service.start()
# Stopping the service, which in turn sets pool size to 0
test_service.stop()
self.assertEqual(0, test_service.server._pool.size)
# Resetting pool size to default
test_service.reset()
test_service.start()
self.assertEqual(1000, test_service.server._pool.size)
self.assertTrue(mock_load_app.called)
@mock.patch('smaug.utils.find_config')
@mock.patch('smaug.wsgi.common.Loader.load_app')
@mock.patch('smaug.wsgi.eventlet_server.Server')
def test_workers_set_default(self, wsgi_server, mock_load_app,
mock_find_config):
test_service = service.WSGIService("osapi_smaug")
self.assertEqual(processutils.get_worker_count(), test_service.workers)
@mock.patch('smaug.utils.find_config')
@mock.patch('smaug.wsgi.common.Loader.load_app')
@mock.patch('smaug.wsgi.eventlet_server.Server')
def test_workers_set_good_user_setting(self, wsgi_server,
mock_load_app,
mock_find_config):
self.override_config('osapi_smaug_workers', 8)
test_service = service.WSGIService("osapi_smaug")
self.assertEqual(8, test_service.workers)
@mock.patch('smaug.utils.find_config')
@mock.patch('smaug.wsgi.common.Loader.load_app')
@mock.patch('smaug.wsgi.eventlet_server.Server')
def test_workers_set_zero_user_setting(self, wsgi_server,
mock_load_app,
mock_find_config):
self.override_config('osapi_smaug_workers', 0)
test_service = service.WSGIService("osapi_smaug")
# If a value less than 1 is used, defaults to number of procs available
self.assertEqual(processutils.get_worker_count(), test_service.workers)
@mock.patch('smaug.utils.find_config')
@mock.patch('smaug.wsgi.common.Loader.load_app')
@mock.patch('smaug.wsgi.eventlet_server.Server')
def test_workers_set_negative_user_setting(self, wsgi_server,
mock_load_app,
mock_find_config):
self.override_config('osapi_smaug_workers', -1)
self.assertRaises(exception.InvalidInput,
service.WSGIService,
"osapi_smaug")
self.assertFalse(wsgi_server.called)

View File

View File

@ -0,0 +1,91 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
Test WSGI basics and provide some helper functions for other WSGI tests.
"""
import mock
import six
import tempfile
import routes
import webob
from smaug.tests import base
from smaug.wsgi import common as wsgi_common
class CommonTest(base.TestCase):
def test_debug(self):
class Application(wsgi_common.Application):
"""Dummy application to test debug."""
def __call__(self, environ, start_response):
start_response("200", [("X-Test", "checking")])
return [b'Test result']
with mock.patch('sys.stdout', new=six.StringIO()) as mock_stdout:
mock_stdout.buffer = six.BytesIO()
application = wsgi_common.Debug(Application())
result = webob.Request.blank('/').get_response(application)
self.assertEqual(b"Test result", result.body)
def test_router(self):
class Application(wsgi_common.Application):
"""Test application to call from router."""
def __call__(self, environ, start_response):
start_response("200", [])
return [b'Router result']
class Router(wsgi_common.Router):
"""Test router."""
def __init__(self):
mapper = routes.Mapper()
mapper.connect("/test", controller=Application())
super(Router, self).__init__(mapper)
result = webob.Request.blank('/test').get_response(Router())
self.assertEqual(b"Router result", result.body)
result = webob.Request.blank('/bad').get_response(Router())
self.assertNotEqual(b"Router result", result.body)
class LoaderNormalFilesystemTest(base.TestCase):
"""Loader tests with normal filesystem (unmodified os.path module)."""
_paste_config = """
[app:test_app]
use = egg:Paste#static
document_root = /tmp
"""
def setUp(self):
super(LoaderNormalFilesystemTest, self).setUp()
self.config = tempfile.NamedTemporaryFile(mode="w+t")
self.config.write(self._paste_config.lstrip())
self.config.seek(0)
self.config.flush()
self.loader = wsgi_common.Loader(self.config.name)
self.addCleanup(self.config.close)
def test_config_found(self):
self.assertEqual(self.config.name, self.loader.config_path)
def test_app_found(self):
url_parser = self.loader.load_app("test_app")
self.assertEqual("/tmp", url_parser.directory)

View File

@ -0,0 +1,81 @@
# 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.
"""Unit tests for `smaug.wsgi`."""
import mock
from oslo_config import cfg
import testtools
from smaug.tests import base
from smaug.wsgi import eventlet_server as wsgi
CONF = cfg.CONF
class WSGIServerTest(base.TestCase):
"""WSGI server tests."""
def _ipv6_configured():
try:
with open('/proc/net/if_inet6') as f:
return len(f.read()) > 0
except IOError:
return False
def test_no_app(self):
server = wsgi.Server("test_app", None,
host="127.0.0.1", port=0)
self.assertEqual("test_app", server.name)
def test_start_random_port(self):
server = wsgi.Server("test_random_port", None, host="127.0.0.1")
server.start()
self.assertNotEqual(0, server.port)
server.stop()
server.wait()
@testtools.skipIf(not _ipv6_configured(),
"Test requires an IPV6 configured interface")
def test_start_random_port_with_ipv6(self):
server = wsgi.Server("test_random_port",
None,
host="::1")
server.start()
self.assertEqual("::1", server.host)
self.assertNotEqual(0, server.port)
server.stop()
server.wait()
def test_server_pool_waitall(self):
# test pools waitall method gets called while stopping server
server = wsgi.Server("test_server", None,
host="127.0.0.1")
server.start()
with mock.patch.object(server._pool,
'waitall') as mock_waitall:
server.stop()
server.wait()
mock_waitall.assert_called_once_with()
def test_reset_pool_size_to_default(self):
server = wsgi.Server("test_resize", None, host="127.0.0.1")
server.start()
# Stopping the server, which in turn sets pool size to 0
server.stop()
self.assertEqual(0, server._pool.size)
# Resetting pool size to default
server.reset()
server.start()
self.assertEqual(1000, server._pool.size)

70
smaug/utils.py Normal file
View File

@ -0,0 +1,70 @@
# 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.
"""Utilities and helper functions."""
import os
from oslo_config import cfg
from oslo_log import log as logging
import six
from smaug import exception
from smaug.i18n import _
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
def find_config(config_path):
"""Find a configuration file using the given hint.
:param config_path: Full or relative path to the config.
:returns: Full path of the config, if it exists.
:raises: `smaug.exception.ConfigNotFound`
"""
possible_locations = [
config_path,
os.path.join("/var/lib/smaug", "etc", "smaug", config_path),
os.path.join("/var/lib/smaug", "etc", config_path),
os.path.join("/var/lib/smaug", config_path),
"/etc/smaug/%s" % config_path,
]
for path in possible_locations:
if os.path.exists(path):
return os.path.abspath(path)
raise exception.ConfigNotFound(path=os.path.abspath(config_path))
def check_string_length(value, name, min_length=0, max_length=None):
"""Check the length of specified string.
:param value: the value of the string
:param name: the name of the string
:param min_length: the min_length of the string
:param max_length: the max_length of the string
"""
if not isinstance(value, six.string_types):
msg = _("%s is not a string or unicode") % name
raise exception.InvalidInput(message=msg)
if len(value) < min_length:
msg = _("%(name)s has a minimum character requirement of "
"%(min_length)s.") % {'name': name, 'min_length': min_length}
raise exception.InvalidInput(message=msg)
if max_length and len(value) > max_length:
msg = _("%(name)s has more than %(max_length)s "
"characters.") % {'name': name, 'max_length': max_length}
raise exception.InvalidInput(message=msg)

23
smaug/version.py Normal file
View File

@ -0,0 +1,23 @@
# Copyright 2011 OpenStack Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from pbr import version as pbr_version
SMAUG_VENDOR = "OpenStack Foundation"
SMAUG_PRODUCT = "OpenStack Smaug"
SMAUG_PACKAGE = None # OS distro package version suffix
loaded = False
version_info = pbr_version.VersionInfo('Smaug')
version_string = version_info.version_string

0
smaug/wsgi/__init__.py Normal file
View File

290
smaug/wsgi/common.py Normal file
View File

@ -0,0 +1,290 @@
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Utility methods for working with WSGI servers."""
import sys
from oslo_config import cfg
from oslo_log import log as logging
from paste import deploy
import routes.middleware
import six
import webob.dec
import webob.exc
from smaug import exception
from smaug.i18n import _, _LE
from smaug import utils
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
class Request(webob.Request):
pass
class Application(object):
"""Base WSGI application wrapper. Subclasses need to implement __call__."""
@classmethod
def factory(cls, global_config, **local_config):
"""Used for paste app factories in paste.deploy config files.
Any local configuration (that is, values under the [app:APPNAME]
section of the paste config) will be passed into the `__init__` method
as kwargs.
A hypothetical configuration would look like:
[app:wadl]
latest_version = 1.3
paste.app_factory = smaug.api.fancy_api:Wadl.factory
which would result in a call to the `Wadl` class as
import smaug.api.fancy_api
fancy_api.Wadl(latest_version='1.3')
You could of course re-implement the `factory` method in subclasses,
but using the kwarg passing it shouldn't be necessary.
"""
return cls(**local_config)
def __call__(self, environ, start_response):
"""Subclasses will probably want to implement __call__ like this:
@webob.dec.wsgify(RequestClass=Request)
def __call__(self, req):
# Any of the following objects work as responses:
# Option 1: simple string
res = 'message\n'
# Option 2: a nicely formatted HTTP exception page
res = exc.HTTPForbidden(explanation='Nice try')
# Option 3: a webob Response object (in case you need to play with
# headers, or you want to be treated like an iterable)
res = Response();
res.app_iter = open('somefile')
# Option 4: any wsgi app to be run next
res = self.application
# Option 5: you can get a Response object for a wsgi app, too, to
# play with headers etc
res = req.get_response(self.application)
# You can then just return your response...
return res
# ... or set req.response and return None.
req.response = res
See the end of http://pythonpaste.org/webob/modules/dec.html
for more info.
"""
raise NotImplementedError(_('You must implement __call__'))
class Middleware(Application):
"""Base WSGI middleware.
These classes require an application to be
initialized that will be called next. By default the middleware will
simply call its wrapped app, or you can override __call__ to customize its
behavior.
"""
@classmethod
def factory(cls, global_config, **local_config):
"""Used for paste app factories in paste.deploy config files.
Any local configuration (that is, values under the [filter:APPNAME]
section of the paste config) will be passed into the `__init__` method
as kwargs.
A hypothetical configuration would look like:
[filter:analytics]
redis_host = 127.0.0.1
paste.filter_factory = smaug.api.analytics:Analytics.factory
which would result in a call to the `Analytics` class as
import smaug.api.analytics
analytics.Analytics(app_from_paste, redis_host='127.0.0.1')
You could of course re-implement the `factory` method in subclasses,
but using the kwarg passing it shouldn't be necessary.
"""
def _factory(app):
return cls(app, **local_config)
return _factory
def __init__(self, application):
self.application = application
def process_request(self, req):
"""Called on each request.
If this returns None, the next application down the stack will be
executed. If it returns a response then that response will be returned
and execution will stop here.
"""
return None
def process_response(self, response):
"""Do whatever you'd like to the response."""
return response
@webob.dec.wsgify(RequestClass=Request)
def __call__(self, req):
response = self.process_request(req)
if response:
return response
response = req.get_response(self.application)
return self.process_response(response)
class Debug(Middleware):
"""Helper class for debugging a WSGI application.
Can be inserted into any WSGI application chain to get information
about the request and response.
"""
@webob.dec.wsgify(RequestClass=Request)
def __call__(self, req):
print(('*' * 40) + ' REQUEST ENVIRON') # noqa
for key, value in req.environ.items():
print(key, '=', value) # noqa
print() # noqa
resp = req.get_response(self.application)
print(('*' * 40) + ' RESPONSE HEADERS') # noqa
for (key, value) in resp.headers.items():
print(key, '=', value) # noqa
print() # noqa
resp.app_iter = self.print_generator(resp.app_iter)
return resp
@staticmethod
def print_generator(app_iter):
"""Iterator that prints the contents of a wrapper string."""
print(('*' * 40) + ' BODY') # noqa
for part in app_iter:
if six.PY3:
sys.stdout.flush()
sys.stdout.buffer.write(part) # pylint: disable=E1101
sys.stdout.buffer.flush() # pylint: disable=E1101
else:
sys.stdout.write(part)
sys.stdout.flush()
yield part
print() # noqa
class Router(object):
"""WSGI middleware that maps incoming requests to WSGI apps."""
def __init__(self, mapper):
"""Create a router for the given routes.Mapper.
Each route in `mapper` must specify a 'controller', which is a
WSGI app to call. You'll probably want to specify an 'action' as
well and have your controller be an object that can route
the request to the action-specific method.
Examples:
mapper = routes.Mapper()
sc = ServerController()
# Explicit mapping of one route to a controller+action
mapper.connect(None, '/svrlist', controller=sc, action='list')
# Actions are all implicitly defined
mapper.resource('server', 'servers', controller=sc)
# Pointing to an arbitrary WSGI app. You can specify the
# {path_info:.*} parameter so the target app can be handed just that
# section of the URL.
mapper.connect(None, '/v1.0/{path_info:.*}', controller=BlogApp())
"""
self.map = mapper
self._router = routes.middleware.RoutesMiddleware(self._dispatch,
self.map)
@webob.dec.wsgify(RequestClass=Request)
def __call__(self, req):
"""Route the incoming request to a controller based on self.map.
If no match, return a 404.
"""
return self._router
@staticmethod
@webob.dec.wsgify(RequestClass=Request)
def _dispatch(req):
"""Dispatch the request to the appropriate controller.
Called by self._router after matching the incoming request to a route
and putting the information into req.environ. Either returns 404
or the routed WSGI app's response.
"""
match = req.environ['wsgiorg.routing_args'][1]
if not match:
return webob.exc.HTTPNotFound()
app = match['controller']
return app
class Loader(object):
"""Used to load WSGI applications from paste configurations."""
def __init__(self, config_path=None):
"""Initialize the loader, and attempt to find the config.
:param config_path: Full or relative path to the paste config.
:returns: None
"""
config_path = config_path or CONF.api_paste_config
self.config_path = utils.find_config(config_path)
def load_app(self, name):
"""Return the paste URLMap wrapped WSGI application.
:param name: Name of the application to load.
:returns: Paste URLMap object wrapping the requested application.
:raises: `smaug.exception.PasteAppNotFound`
"""
try:
return deploy.loadapp("config:%s" % self.config_path, name=name)
except LookupError:
LOG.exception(_LE("Error loading app %s"), name)
raise exception.PasteAppNotFound(name=name, path=self.config_path)

View File

@ -0,0 +1,282 @@
# 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.
"""Methods for working with eventlet WSGI servers."""
from __future__ import print_function
import errno
import os
import socket
import ssl
import time
import eventlet
import eventlet.wsgi
import greenlet
from oslo_config import cfg
from oslo_log import log as logging
from oslo_service import service
from oslo_utils import excutils
from oslo_utils import netutils
from smaug import exception
from smaug.i18n import _, _LE, _LI
socket_opts = [
cfg.BoolOpt('tcp_keepalive',
default=True,
help="Sets the value of TCP_KEEPALIVE (True/False) for each "
"server socket."),
cfg.IntOpt('tcp_keepidle',
default=600,
help="Sets the value of TCP_KEEPIDLE in seconds for each "
"server socket. Not supported on OS X."),
cfg.IntOpt('tcp_keepalive_interval',
help="Sets the value of TCP_KEEPINTVL in seconds for each "
"server socket. Not supported on OS X."),
cfg.IntOpt('tcp_keepalive_count',
help="Sets the value of TCP_KEEPCNT for each "
"server socket. Not supported on OS X."),
cfg.StrOpt('ssl_ca_file',
help="CA certificate file to use to verify "
"connecting clients"),
cfg.StrOpt('ssl_cert_file',
help="Certificate file to use when starting "
"the server securely"),
cfg.StrOpt('ssl_key_file',
help="Private key file to use when starting "
"the server securely"),
]
eventlet_opts = [
cfg.IntOpt('max_header_line',
default=16384,
help="Maximum line size of message headers to be accepted. "
"max_header_line may need to be increased when using "
"large tokens (typically those generated by the "
"Keystone v3 API with big service catalogs)."),
cfg.IntOpt('client_socket_timeout', default=900,
help="Timeout for client connections\' socket operations. "
"If an incoming connection is idle for this number of "
"seconds it will be closed. A value of \'0\' means "
"wait forever."),
cfg.BoolOpt('wsgi_keep_alive',
default=True,
help='If False, closes the client socket connection '
'explicitly. Setting it to True to maintain backward '
'compatibility. Recommended setting is set it to False.'),
]
CONF = cfg.CONF
CONF.register_opts(socket_opts)
CONF.register_opts(eventlet_opts)
LOG = logging.getLogger(__name__)
class Server(service.ServiceBase):
"""Server class to manage a WSGI server, serving a WSGI application."""
default_pool_size = 1000
def __init__(self, name, app, host=None, port=None, pool_size=None,
protocol=eventlet.wsgi.HttpProtocol, backlog=128):
"""Initialize, but do not start, a WSGI server.
:param name: Pretty name for logging.
:param app: The WSGI application to serve.
:param host: IP address to serve the application.
:param port: Port number to server the application.
:param pool_size: Maximum number of eventlets to spawn concurrently.
:returns: None
"""
# Allow operators to customize http requests max header line size.
eventlet.wsgi.MAX_HEADER_LINE = CONF.max_header_line
self.client_socket_timeout = CONF.client_socket_timeout or None
self.name = name
self.app = app
self._host = host or "0.0.0.0"
self._port = port or 0
self._server = None
self._socket = None
self._protocol = protocol
self.pool_size = pool_size or self.default_pool_size
self._pool = eventlet.GreenPool(self.pool_size)
self._logger = logging.getLogger("eventlet.wsgi.server")
if backlog < 1:
raise exception.InvalidInput(
reason='The backlog must be more than 1')
bind_addr = (host, port)
# TODO(dims): eventlet's green dns/socket module does not actually
# support IPv6 in getaddrinfo(). We need to get around this in the
# future or monitor upstream for a fix
try:
info = socket.getaddrinfo(bind_addr[0],
bind_addr[1],
socket.AF_UNSPEC,
socket.SOCK_STREAM)[0]
family = info[0]
bind_addr = info[-1]
except Exception:
family = socket.AF_INET
cert_file = CONF.ssl_cert_file
key_file = CONF.ssl_key_file
ca_file = CONF.ssl_ca_file
self._use_ssl = cert_file or key_file
if cert_file and not os.path.exists(cert_file):
raise RuntimeError(_("Unable to find cert_file : %s")
% cert_file)
if ca_file and not os.path.exists(ca_file):
raise RuntimeError(_("Unable to find ca_file : %s") % ca_file)
if key_file and not os.path.exists(key_file):
raise RuntimeError(_("Unable to find key_file : %s")
% key_file)
if self._use_ssl and (not cert_file or not key_file):
raise RuntimeError(_("When running server in SSL mode, you "
"must specify both a cert_file and "
"key_file option value in your "
"configuration file."))
retry_until = time.time() + 30
while not self._socket and time.time() < retry_until:
try:
self._socket = eventlet.listen(bind_addr, backlog=backlog,
family=family)
except socket.error as err:
if err.args[0] != errno.EADDRINUSE:
raise
eventlet.sleep(0.1)
if not self._socket:
raise RuntimeError(_("Could not bind to %(host)s:%(port)s "
"after trying for 30 seconds") %
{'host': host, 'port': port})
(self._host, self._port) = self._socket.getsockname()[0:2]
LOG.info(_LI("%(name)s listening on %(_host)s:%(_port)s"),
{'name': self.name, '_host': self._host, '_port': self._port})
def start(self):
"""Start serving a WSGI application.
:returns: None
:raises: smaug.exception.InvalidInput
"""
# The server socket object will be closed after server exits,
# but the underlying file descriptor will remain open, and will
# give bad file descriptor error. So duplicating the socket object,
# to keep file descriptor usable.
dup_socket = self._socket.dup()
dup_socket.setsockopt(socket.SOL_SOCKET,
socket.SO_REUSEADDR, 1)
# NOTE(praneshp): Call set_tcp_keepalive in oslo to set
# tcp keepalive parameters. Sockets can hang around forever
# without keepalive
netutils.set_tcp_keepalive(dup_socket,
CONF.tcp_keepalive,
CONF.tcp_keepidle,
CONF.tcp_keepalive_count,
CONF.tcp_keepalive_interval)
if self._use_ssl:
try:
ssl_kwargs = {
'server_side': True,
'certfile': CONF.ssl_cert_file,
'keyfile': CONF.ssl_key_file,
'cert_reqs': ssl.CERT_NONE,
}
if CONF.ssl_ca_file:
ssl_kwargs['ca_certs'] = CONF.ssl_ca_file
ssl_kwargs['cert_reqs'] = ssl.CERT_REQUIRED
dup_socket = ssl.wrap_socket(dup_socket,
**ssl_kwargs)
except Exception:
with excutils.save_and_reraise_exception():
LOG.error(_LE("Failed to start %(name)s on %(_host)s: "
"%(_port)s with SSL "
"support."), self.__dict__)
wsgi_kwargs = {
'func': eventlet.wsgi.server,
'sock': dup_socket,
'site': self.app,
'protocol': self._protocol,
'custom_pool': self._pool,
'log': self._logger,
'socket_timeout': self.client_socket_timeout,
'keepalive': CONF.wsgi_keep_alive
}
self._server = eventlet.spawn(**wsgi_kwargs)
@property
def host(self):
return self._host
@property
def port(self):
return self._port
def stop(self):
"""Stop this server.
This is not a very nice action, as currently the method by which a
server is stopped is by killing its eventlet.
:returns: None
"""
LOG.info(_LI("Stopping WSGI server."))
if self._server is not None:
# Resize pool to stop new requests from being processed
self._pool.resize(0)
self._server.kill()
def wait(self):
"""Block, until the server has stopped.
Waits on the server's eventlet to finish, then returns.
:returns: None
"""
try:
if self._server is not None:
self._pool.waitall()
self._server.wait()
except greenlet.GreenletExit:
LOG.info(_LI("WSGI server has stopped."))
def reset(self):
"""Reset server greenpool size to default.
:returns: None
"""
self._pool.resize(self.pool_size)

View File

@ -33,3 +33,6 @@ show-source = True
ignore = E123,E125
builtins = _
exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build
[hacking]
import_exceptions = smaug.i18n