Port to Pecan/WSME for API v2

Routing either V1 or V2 using a Facade. By default, V1 is enabled
but can be deactivated by a confflag.

Pecan controller for Lease created using WSME i/o validation

Implements blueprint: pecan-wsme

Change-Id: I9047abece78632daa13fdebfea9fb8aaa3c60981
This commit is contained in:
Sylvain Bauza 2014-03-05 11:01:28 +01:00
parent 2ced1925b8
commit eb0c81f95a
34 changed files with 2194 additions and 23 deletions

28
climate/api/root.py Normal file
View File

@ -0,0 +1,28 @@
# Copyright (c) 2014 Bull.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pecan
from climate.api.v2 import controllers
class RootController(object):
v2 = controllers.V2Controller()
@pecan.expose(generic=True)
def index(self):
# FIXME: Return version information
return dict()

View File

@ -29,12 +29,6 @@ from climate.openstack.common.middleware import debug
LOG = log.getLogger(__name__)
cli_opts = [
cfg.BoolOpt('log_exchange', default=False,
help='Log request/response exchange details: environ, '
'headers and bodies'),
]
CONF = cfg.CONF
CONF.import_opt('os_auth_host', 'climate.config')
@ -44,8 +38,7 @@ CONF.import_opt('os_admin_username', 'climate.config')
CONF.import_opt('os_admin_password', 'climate.config')
CONF.import_opt('os_admin_tenant_name', 'climate.config')
CONF.import_opt('os_auth_version', 'climate.config')
CONF.register_cli_opts(cli_opts)
CONF.import_opt('log_exchange', 'climate.config')
eventlet.monkey_patch(

View File

83
climate/api/v2/app.py Normal file
View File

@ -0,0 +1,83 @@
# Copyright (c) 2014 Bull.
#
# 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 keystoneclient.middleware import auth_token
from oslo.config import cfg
import pecan
from climate.api.v2 import hooks
from climate.api.v2 import middleware
from climate.openstack.common.middleware import debug
auth_opts = [
cfg.StrOpt('auth_strategy',
default='keystone',
help='The strategy to use for auth: noauth or keystone.'),
]
CONF = cfg.CONF
CONF.register_opts(auth_opts)
CONF.import_opt('log_exchange', 'climate.config')
OPT_GROUP_NAME = 'keystone_authtoken'
def setup_app(pecan_config=None, extra_hooks=None):
app_hooks = [hooks.ConfigHook(),
hooks.DBHook(),
hooks.ContextHook(),
hooks.RPCHook(),
]
# TODO(sbauza): Add stevedore extensions for loading hooks
if extra_hooks:
app_hooks.extend(extra_hooks)
app = pecan.make_app(
pecan_config.app.root,
debug=CONF.debug,
hooks=app_hooks,
wrap_app=middleware.ParsableErrorMiddleware,
guess_content_type_from_ext=False
)
# WSGI middleware for debugging
if CONF.log_exchange:
app = debug.Debug.factory(pecan_config)(app)
# WSGI middleware for Keystone auth
# NOTE(sbauza): ACLs are always active unless for unittesting where
# enable_acl could be set to False
if pecan_config.app.enable_acl:
CONF.register_opts(auth_token.opts, group=OPT_GROUP_NAME)
keystone_config = dict(CONF.get(OPT_GROUP_NAME))
app = auth_token.AuthProtocol(app, conf=keystone_config)
return app
def make_app():
config = {
'app': {
'modules': ['climate.api.v2'],
'root': 'climate.api.root.RootController',
'enable_acl': True,
}
}
# NOTE(sbauza): Fill Pecan config and call modules' path app.setup_app()
app = pecan.load_app(config)
return app

View File

@ -0,0 +1,50 @@
# Copyright (c) 2014 Bull.
#
# 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.
"""Version 2 of the API.
"""
import pecan
from climate.api.v2.controllers import host
from climate.api.v2.controllers import lease
from climate.openstack.common.gettextutils import _ # noqa
from climate.openstack.common import log as logging
LOG = logging.getLogger(__name__)
class V2Controller(pecan.rest.RestController):
"""Version 2 API controller root."""
_routes = {'os-hosts': 'oshosts',
'oshosts': 'None'}
leases = lease.LeasesController()
oshosts = host.HostsController()
@pecan.expose()
def _route(self, args):
"""Overrides the default routing behavior.
It allows to map controller URL with correct controller instance.
By default, it maps with the same name.
"""
try:
args[0] = self._routes.get(args[0], args[0])
except IndexError:
LOG.error(_("No args found on V2 controller"))
return super(V2Controller, self)._route(args)

View File

@ -0,0 +1,52 @@
# Copyright (c) 2014 Bull.
#
# 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 wsme
from wsme import types as wtypes
from climate.api.v2.controllers import types
class _Base(wtypes.DynamicBase):
# NOTE(sbauza): That does respect ISO8601 but with a different sep (' ')
created_at = types.Datetime('%Y-%m-%d %H:%M:%S.%f')
"The time in UTC at which the object is created"
updated_at = types.Datetime('%Y-%m-%d %H:%M:%S.%f')
"The time in UTC at which the object is updated"
def as_dict(self):
cls = type(self)
valid_keys = [item for item in dir(cls)
if item not in dir(_Base)
and wtypes.iswsattr(getattr(cls, item))]
if 'self' in valid_keys:
valid_keys.remove('self')
return self.as_dict_from_keys(valid_keys)
def as_dict_from_keys(self, keys):
res = {}
for key in keys:
value = getattr(self, key, wsme.Unset)
if value != wsme.Unset:
res[key] = value
return res
@classmethod
def convert(cls, rpc_obj):
obj = cls(**rpc_obj)
return obj

View File

@ -0,0 +1,169 @@
# Copyright (c) 2014 Bull.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pecan
from pecan import rest
from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan
from climate.api.v2.controllers import base
from climate.api.v2.controllers import types
from climate import exceptions
from climate.openstack.common.gettextutils import _ # noqa
from climate import policy
from climate.openstack.common import log as logging
LOG = logging.getLogger(__name__)
class Host(base._Base):
id = types.IntegerType()
"The ID of the host"
hypervisor_hostname = wtypes.text
"The hostname of the host"
# FIXME(sbauza): API V1 provides 'name', so mapping is necessary until we
# patch the client
name = hypervisor_hostname
hypervisor_type = wtypes.text
"The type of the hypervisor"
vcpus = types.IntegerType()
"The number of VCPUs of the host"
hypervisor_version = types.IntegerType()
"The version of the hypervisor"
memory_mb = types.IntegerType()
"The memory size (in Mb) of the host"
local_gb = types.IntegerType()
"The disk size (in Gb) of the host"
cpu_info = types.CPUInfo()
"The CPU info JSON data given by the hypervisor"
extra_capas = wtypes.DictType(wtypes.text, types.TextOrInteger())
"Extra capabilities for the host"
@classmethod
def convert(cls, rpc_obj):
extra_keys = [key for key in rpc_obj
if key not in
[i.key for i in wtypes.list_attributes(Host)]]
extra_capas = dict((capa, rpc_obj[capa])
for capa in extra_keys if capa not in ['status'])
rpc_obj['extra_capas'] = extra_capas
obj = cls(**rpc_obj)
return obj
def as_dict(self):
dct = super(Host, self).as_dict()
extra_capas = dct.pop('extra_capas', None)
if extra_capas is not None:
dct.update(extra_capas)
return dct
@classmethod
def sample(cls):
return cls(id=u'1',
hypervisor_hostname=u'host01',
hypervisor_type=u'QEMU',
vcpus=1,
hypervisor_version=1000000,
memory_mb=8192,
local_gb=50,
cpu_info="{\"vendor\": \"Intel\", \"model\": \"qemu32\", "
"\"arch\": \"x86_64\", \"features\": [],"
" \"topology\": {\"cores\": 1}}",
extra_capas={u'vgpus': 2, u'fruits': u'bananas'},
)
class HostsController(rest.RestController):
"""Manages operations on hosts.
"""
@policy.authorize('oshosts', 'get')
@wsme_pecan.wsexpose(Host, types.IntegerType())
def get_one(self, id):
"""Returns the host having this specific uuid
:param id: ID of host
"""
host_dct = pecan.request.hosts_rpcapi.get_computehost(id)
if host_dct is None:
raise exceptions.NotFound(object={'host_id': id})
return Host.convert(host_dct)
@policy.authorize('oshosts', 'get')
@wsme_pecan.wsexpose([Host], q=[])
def get_all(self):
"""Returns all hosts."""
return [Host.convert(host)
for host in
pecan.request.hosts_rpcapi.list_computehosts()]
@policy.authorize('oshosts', 'create')
@wsme_pecan.wsexpose(Host, body=Host, status_code=202)
def post(self, host):
"""Creates a new host.
:param host: a host within the request body.
"""
# here API should go to Keystone API v3 and create trust
host_dct = host.as_dict()
# FIXME(sbauza): DB exceptions are currently catched and return a lease
# equal to None instead of being sent to the API
host = pecan.request.hosts_rpcapi.create_computehost(host_dct)
if host is not None:
return Host.convert(host)
else:
raise exceptions.ClimateException(_("Host can't be created"))
@policy.authorize('oshosts', 'update')
@wsme_pecan.wsexpose(Host, types.IntegerType(), body=Host,
status_code=202)
def put(self, id, host):
"""Update an existing host.
:param id: ID of a host.
:param host: a subset of a Host containing values to update.
"""
host_dct = host.as_dict()
host = pecan.request.hosts_rpcapi.update_computehost(id, host_dct)
if host is None:
raise exceptions.NotFound(object={'host_id': id})
return Host.convert(host)
@policy.authorize('oshosts', 'delete')
# NOTE(sbauza): We need to expose text for parameter type as Manager is
# expecting it and int raises an AttributeError
@wsme_pecan.wsexpose(None, wtypes.text,
status_code=204)
def delete(self, id):
"""Delete an existing host.
:param id: UUID of a host.
"""
try:
pecan.request.hosts_rpcapi.delete_computehost(id)
except TypeError:
# The host was not existing when asking to delete it
raise exceptions.NotFound(object={'host_id': id})

View File

@ -0,0 +1,159 @@
# Copyright (c) 2014 Bull.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pecan
from pecan import rest
from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan
from climate.api.v2.controllers import base
from climate.api.v2.controllers import types
from climate import exceptions
from climate.manager import service
from climate.openstack.common.gettextutils import _ # noqa
from climate import policy
from climate.utils import trusts
class Lease(base._Base):
id = types.UuidType()
"The UUID of the lease"
name = wtypes.text
"The name of the lease"
start_date = types.Datetime(service.LEASE_DATE_FORMAT)
"Datetime when the lease should start"
end_date = types.Datetime(service.LEASE_DATE_FORMAT)
"Datetime when the lease should end"
user_id = types.UuidType()
"The ID of the user who creates the lease"
tenant_id = types.UuidType()
"The ID of the project or tenant the lease belongs to"
trust_id = types.UuidType()
"The ID of the trust created for delegating the rights of the user"
reservations = wtypes.ArrayType(wtypes.DictType(wtypes.text, wtypes.text))
"The list of reservations belonging to the lease"
events = wtypes.ArrayType(wtypes.DictType(wtypes.text, wtypes.text))
"The list of events attached to the lease"
@classmethod
def sample(cls):
return cls(id=u'2bb8720a-0873-4d97-babf-0d906851a1eb',
name=u'lease_test',
start_date=u'2014-01-01 01:23',
end_date=u'2014-02-01 13:37',
user_id=u'efd87807-12d2-4b38-9c70-5f5c2ac427ff',
tenant_id=u'bd9431c1-8d69-4ad3-803a-8d4a6b89fd36',
trust_id=u'35b17138-b364-4e6a-a131-8f3099c5be68',
reservations=[{u'resource_id': u'1234',
u'resource_type': u'virtual:instance'}],
events=[],
)
class LeasesController(rest.RestController):
"""Manages operations on leases.
"""
@policy.authorize('leases', 'get')
@wsme_pecan.wsexpose(Lease, types.UuidType())
def get_one(self, id):
"""Returns the lease having this specific uuid
:param id: ID of lease
"""
lease = pecan.request.rpcapi.get_lease(id)
if lease is None:
raise exceptions.NotFound(object={'lease_id': id})
return Lease.convert(lease)
@policy.authorize('leases', 'get')
@wsme_pecan.wsexpose([Lease], q=[])
def get_all(self):
"""Returns all leases."""
return [Lease.convert(lease)
for lease in pecan.request.rpcapi.list_leases()]
@policy.authorize('leases', 'create')
@wsme_pecan.wsexpose(Lease, body=Lease, status_code=202)
def post(self, lease):
"""Creates a new lease.
:param lease: a lease within the request body.
"""
# here API should go to Keystone API v3 and create trust
trust = trusts.create_trust()
trust_id = trust.id
lease_dct = lease.as_dict()
lease_dct.update({'trust_id': trust_id})
# FIXME(sbauza): DB exceptions are currently catched and return a lease
# equal to None instead of being sent to the API
lease = pecan.request.rpcapi.create_lease(lease_dct)
if lease is not None:
return Lease.convert(lease)
else:
raise exceptions.ClimateException(_("Lease can't be created"))
@policy.authorize('leases', 'update')
@wsme_pecan.wsexpose(Lease, types.UuidType(), body=Lease, status_code=202)
def put(self, id, sublease):
"""Update an existing lease.
:param id: UUID of a lease.
:param lease: a subset of a Lease containing values to update.
"""
sublease_dct = sublease.as_dict()
new_name = sublease_dct.pop('name', None)
end_date = sublease_dct.pop('end_date', None)
start_date = sublease_dct.pop('start_date', None)
if sublease_dct != {}:
raise exceptions.ClimateException('Only name changing and '
'dates changing may be '
'proceeded.')
if new_name:
sublease_dct['name'] = new_name
if end_date:
sublease_dct['end_date'] = end_date
if start_date:
sublease_dct['start_date'] = start_date
lease = pecan.request.rpcapi.update_lease(id, sublease_dct)
if lease is None:
raise exceptions.NotFound(object={'lease_id': id})
return Lease.convert(lease)
@policy.authorize('leases', 'delete')
@wsme_pecan.wsexpose(None, types.UuidType(), status_code=204)
def delete(self, id):
"""Delete an existing lease.
:param id: UUID of a lease.
"""
try:
pecan.request.rpcapi.delete_lease(id)
except TypeError:
# The lease was not existing when asking to delete it
raise exceptions.NotFound(object={'lease_id': id})

View File

@ -0,0 +1,129 @@
# Copyright (c) 2014 Bull.
#
# 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 datetime
import json
import uuid
import six
from wsme import types as wtypes
from wsme import utils as wutils
from climate import exceptions
class UuidType(wtypes.UserType):
"""A simple UUID type."""
basetype = wtypes.text
name = 'uuid'
# FIXME(sbauza): When used with wsexpose decorator WSME will try
# to get the name of the type by accessing it's __name__ attribute.
# Remove this __name__ attribute once it's fixed in WSME.
# https://bugs.launchpad.net/wsme/+bug/1265590
__name__ = name
# FIXME(sbauza): Backport latest trunk of UuidType, WSME==0.6 being buggy
# with validate() returning None
@staticmethod
def validate(value):
try:
return six.text_type(uuid.UUID(value))
except (TypeError, ValueError, AttributeError):
error = 'Value should be UUID format'
raise ValueError(error)
class IntegerType(wtypes.IntegerType):
"""A simple integer type. Can validate a value range.
:param minimum: Possible minimum value
:param maximum: Possible maximum value
Example::
Price = IntegerType(minimum=1)
"""
name = 'integer'
# FIXME(sbauza): When used with wsexpose decorator WSME will try
# to get the name of the type by accessing it's __name__ attribute.
# Remove this __name__ attribute once it's fixed in WSME.
# https://bugs.launchpad.net/wsme/+bug/1265590
__name__ = name
class CPUInfo(wtypes.UserType):
"""A type for matching CPU info from hypervisors."""
basetype = wtypes.text
name = 'cpuinfo'
@staticmethod
def validate(value):
# NOTE(sbauza): cpu_info can be very different from one Nova driver to
# another. We need to keep this method as generic as
# possible, ie. we accept JSONified dict.
try:
cpu_info = json.loads(value)
except TypeError:
raise exceptions.InvalidInput(cls=CPUInfo.name, value=value)
if not isinstance(cpu_info, dict):
raise exceptions.InvalidInput(cls=CPUInfo.name, value=value)
return value
class TextOrInteger(wtypes.UserType):
"""A type for matching either text or integer."""
basetype = wtypes.text
name = 'textorinteger'
@staticmethod
def validate(value):
# NOTE(sbauza): We need to accept non-unicoded Python2 strings
if isinstance(value, six.text_type) or isinstance(value, str) \
or isinstance(value, int):
return value
else:
raise exceptions.InvalidInput(cls=TextOrInteger.name, value=value)
class Datetime(wtypes.UserType):
"""A type for matching unicoded datetime."""
basetype = wtypes.text
name = 'datetime'
#Format must be ISO8601 as default
format = '%Y-%m-%dT%H:%M:%S.%f'
def __init__(self, format=None):
if format:
self.format = format
def validate(self, value):
try:
datetime.datetime.strptime(value, self.format)
except ValueError:
# FIXME(sbauza): Start_date and end_date are given using a specific
# format but are shown in default ISO8601, we must
# fail back to it for verification
try:
wutils.parse_isodatetime(value)
except ValueError:
raise exceptions.InvalidInput(cls=Datetime.name, value=value)
return value

67
climate/api/v2/hooks.py Normal file
View File

@ -0,0 +1,67 @@
# Copyright (c) 2014 Bull.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from oslo.config import cfg
from pecan import hooks
from climate.api import context
from climate.db import api as dbapi
from climate.manager.oshosts import rpcapi as hosts_rpcapi
from climate.manager import rpcapi
from climate.openstack.common import log as logging
LOG = logging.getLogger(__name__)
class ConfigHook(hooks.PecanHook):
"""Attach the configuration object to the request
so controllers can get to it.
"""
def before(self, state):
state.request.cfg = cfg.CONF
class DBHook(hooks.PecanHook):
"""Attach the dbapi object to the request so controllers can get to it."""
def before(self, state):
state.request.dbapi = dbapi.get_instance()
class ContextHook(hooks.PecanHook):
"""Configures a request context and attaches it to the request."""
def before(self, state):
state.request.context = context.ctx_from_headers(state.request.headers)
state.request.context.__enter__()
# NOTE(sbauza): on_error() can be fired before after() if the original
# exception is not catched by WSME. That's necessary to not
# handle context.__exit__() within on_error() as it could
# lead to pop the stack twice for the same request
def after(self, state):
# If no API extensions are loaded, context is empty
if state.request.context:
state.request.context.__exit__(None, None, None)
class RPCHook(hooks.PecanHook):
"""Attach the rpcapi object to the request so controllers can get to it."""
def before(self, state):
state.request.rpcapi = rpcapi.ManagerRPCAPI()
state.request.hosts_rpcapi = hosts_rpcapi.ManagerRPCAPI()

View File

@ -0,0 +1,141 @@
# Copyright (c) 2014 Bull.
#
# 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 webob
from climate.db import exceptions as db_exceptions
from climate import exceptions
from climate.manager import exceptions as manager_exceptions
from climate.openstack.common.gettextutils import _ # noqa
from climate.openstack.common import log as logging
LOG = logging.getLogger(__name__)
class ParsableErrorMiddleware(object):
"""Middleware to replace the plain text message body of an error
response with one formatted so the client can parse it.
Based on pecan.middleware.errordocument
"""
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
# Request for this state, modified by replace_start_response()
# and used when an error is being reported.
state = {}
faultstring = None
def replacement_start_response(status, headers, exc_info=None):
"""Overrides the default response to make errors parsable.
"""
try:
status_code = int(status.split(' ')[0])
except (ValueError, TypeError): # pragma: nocover
raise exceptions.ClimateException(_(
'Status {0} was unexpected').format(status))
else:
if status_code >= 400:
# Remove some headers so we can replace them later
# when we have the full error message and can
# compute the length.
headers = [(h, v)
for (h, v) in headers
if h.lower() != 'content-length'
]
# Save the headers as we need to modify them.
state['status_code'] = status_code
state['headers'] = headers
state['exc_info'] = exc_info
return start_response(status, headers, exc_info)
# NOTE(sbauza): As agreed, XML is not supported with API v2, but can
# still work if no errors are raised
try:
app_iter = self.app(environ, replacement_start_response)
except exceptions.ClimateException as e:
faultstring = "{0} {1}".format(e.__class__.__name__, str(e))
replacement_start_response(
webob.response.Response(status=str(e.code)).status,
[('Content-Type', 'application/json; charset=UTF-8')]
)
else:
if not state:
return app_iter
try:
res_dct = json.loads(app_iter[0])
except ValueError:
return app_iter
else:
try:
faultstring = res_dct['faultstring']
except KeyError:
return app_iter
traceback_marker = 'Traceback (most recent call last):'
remote_marker = 'Remote error: '
if not faultstring:
return app_iter
if traceback_marker in faultstring:
# Cut-off traceback.
faultstring = faultstring.split(traceback_marker, 1)[0]
faultstring = faultstring.split('[u\'', 1)[0]
if remote_marker in faultstring:
# RPC calls put that string on
try:
faultstring = faultstring.split(
remote_marker, 1)[1]
except IndexError:
pass
faultstring = faultstring.rstrip()
try:
(exc_name, exc_value) = faultstring.split(' ', 1)
except (ValueError, AttributeError):
LOG.warning('Incorrect Remote error {0}'.format(faultstring))
else:
cls = getattr(manager_exceptions, exc_name,
getattr(exceptions, exc_name, None))
if cls is not None:
faultstring = str(cls(exc_value))
state['status_code'] = cls.code
else:
# Get the exception from db exceptions and hide
# the message because could contain table/column
# information
cls = getattr(db_exceptions, exc_name, None)
if cls is not None:
faultstring = '{0}: A database error occurred'.format(
cls.__name__)
state['status_code'] = cls.code
# NOTE(sbauza): Client expects a JSON encoded dict
body = [json.dumps(
{'error_code': state['status_code'],
'error_message': faultstring,
'error_name': state['status_code']}
)]
start_response(
webob.response.Response(status=state['status_code']).status,
state['headers'],
state['exc_info']
)
return body

View File

@ -23,6 +23,7 @@ from oslo.config import cfg
gettext.install('climate', unicode=1)
from climate.api.v1 import app as v1_app
from climate.api.v2 import app as v2_app
from climate.openstack.common import log as logging
from climate.utils import service as service_utils
@ -32,12 +33,32 @@ opts = [
help='Port that will be used to listen on'),
]
api_opts = [
cfg.BoolOpt('enable_v1_api',
default=True,
help='Deploy the v1 API.'),
]
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
CONF.register_cli_opts(opts)
CONF.register_opts(api_opts)
cfg.CONF.import_opt('host', 'climate.config')
CONF.import_opt('host', 'climate.config')
class VersionSelectorApplication(object):
"""Maps WSGI versioned apps and defines default WSGI app."""
def __init__(self):
self.v1 = v1_app.make_app()
self.v2 = v2_app.make_app()
def __call__(self, environ, start_response):
if environ['PATH_INFO'].startswith('/v1/'):
return self.v1(environ, start_response)
return self.v2(environ, start_response)
def main():
@ -45,7 +66,10 @@ def main():
cfg.CONF(sys.argv[1:], project='climate', prog='climate-api')
service_utils.prepare_service(sys.argv)
logging.setup("climate")
app = v1_app.make_app()
if not CONF.enable_v1_api:
app = v2_app.make_app()
else:
app = VersionSelectorApplication()
wsgi.server(eventlet.listen((CONF.host, CONF.port), backlog=500), app)

View File

@ -24,6 +24,9 @@ cli_opts = [
'However, the node name must be valid within '
'an AMQP key, and if using ZeroMQ, a valid '
'hostname, FQDN, or IP address'),
cfg.BoolOpt('log_exchange', default=False,
help='Log request/response exchange details: environ, '
'headers and bodies'),
]
os_opts = [

View File

@ -45,6 +45,11 @@ IMPL = db_api.DBAPI(db_options.CONF.database.backend,
LOG = logging.getLogger(__name__)
def get_instance():
"""Return a DB API instance."""
return IMPL
def setup_db():
"""Set up database, create tables, etc.

View File

@ -88,3 +88,8 @@ class TaskFailed(ClimateException):
class Timeout(ClimateException):
msg_fmt = _('Current task failed with timeout')
class InvalidInput(ClimateException):
code = 400
msg_fmt = _("Expected a %(cls)s type but received %(value)s.")

View File

@ -0,0 +1,216 @@
# Copyright (c) 2014 Bull.
#
# 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.
"""Base classes for API tests."""
import six
from oslo.config import cfg
import pecan
import pecan.testing
from climate.api import context as api_context
from climate.api.v2 import app
from climate import context
from climate.manager.oshosts import rpcapi as hosts_rpcapi
from climate.manager import rpcapi
from climate import tests
PATH_PREFIX = '/v2'
class APITest(tests.TestCase):
"""Used for unittests tests of Pecan controllers.
"""
# SOURCE_DATA = {'test_source': {'somekey': '666'}}
def setUp(self):
def fake_ctx_from_headers(headers):
if not headers:
return context.ClimateContext(
user_id='fake', tenant_id='fake', roles=['member'])
roles = headers.get('X-Roles', six.text_type('member')).split(',')
return context.ClimateContext(
user_id=headers.get('X-User-Id', 'fake'),
tenant_id=headers.get('X-Tenant-Id', 'fake'),
auth_token=headers.get('X-Auth-Token', None),
service_catalog=None,
user_name=headers.get('X-User-Name', 'fake'),
tenant_name=headers.get('X-Tenant-Name', 'fake'),
roles=roles,
)
super(APITest, self).setUp()
cfg.CONF.set_override("auth_version", "v2.0", group=app.OPT_GROUP_NAME)
self.app = self._make_app()
# NOTE(sbauza): Context is taken from Keystone auth middleware, we need
# to simulate here
self.api_context = api_context
self.fake_ctx_from_headers = self.patch(self.api_context,
'ctx_from_headers')
self.fake_ctx_from_headers.side_effect = fake_ctx_from_headers
self.rpcapi = rpcapi.ManagerRPCAPI
self.hosts_rpcapi = hosts_rpcapi.ManagerRPCAPI
# self.patch(rpcapi.ManagerRPCAPI, 'list_leases').return_value = []
def reset_pecan():
pecan.set_config({}, overwrite=True)
self.addCleanup(reset_pecan)
def _make_app(self, enable_acl=False):
# Determine where we are so we can set up paths in the config
# NOTE(sbauza): Keystone middleware auth can be deactivated using
# enable_acl set to False
self.config = {
'app': {
'modules': ['climate.api.v2'],
'root': 'climate.api.root.RootController',
'enable_acl': enable_acl,
},
}
return pecan.testing.load_test_app(self.config)
def _request_json(self, path, params, expect_errors=False, headers=None,
method="post", extra_environ=None, status=None,
path_prefix=PATH_PREFIX):
"""Sends simulated HTTP request to Pecan test app.
:param path: url path of target service
:param params: content for wsgi.input of request
:param expect_errors: Boolean value; whether an error is expected based
on request
:param headers: a dictionary of headers to send along with the request
:param method: Request method type. Appropriate method function call
should be used rather than passing attribute in.
:param extra_environ: a dictionary of environ variables to send along
with the request
:param status: expected status code of response
:param path_prefix: prefix of the url path
"""
full_path = path_prefix + path
print('%s: %s %s' % (method.upper(), full_path, params))
response = getattr(self.app, "%s_json" % method)(
str(full_path),
params=params,
headers=headers,
status=status,
extra_environ=extra_environ,
expect_errors=expect_errors
)
print('GOT:%s' % response)
return response
def put_json(self, path, params, expect_errors=False, headers=None,
extra_environ=None, status=None):
"""Sends simulated HTTP PUT request to Pecan test app.
:param path: url path of target service
:param params: content for wsgi.input of request
:param expect_errors: Boolean value; whether an error is expected based
on request
:param headers: a dictionary of headers to send along with the request
:param extra_environ: a dictionary of environ variables to send along
with the request
:param status: expected status code of response
"""
return self._request_json(path=path, params=params,
expect_errors=expect_errors,
headers=headers, extra_environ=extra_environ,
status=status, method="put")
def post_json(self, path, params, expect_errors=False, headers=None,
extra_environ=None, status=None):
"""Sends simulated HTTP POST request to Pecan test app.
:param path: url path of target service
:param params: content for wsgi.input of request
:param expect_errors: Boolean value; whether an error is expected based
on request
:param headers: a dictionary of headers to send along with the request
:param extra_environ: a dictionary of environ variables to send along
with the request
:param status: expected status code of response
"""
return self._request_json(path=path, params=params,
expect_errors=expect_errors,
headers=headers, extra_environ=extra_environ,
status=status, method="post")
def delete(self, path, expect_errors=False, headers=None,
extra_environ=None, status=None, path_prefix=PATH_PREFIX):
"""Sends simulated HTTP DELETE request to Pecan test app.
:param path: url path of target service
:param expect_errors: Boolean value; whether an error is expected based
on request
:param headers: a dictionary of headers to send along with the request
:param extra_environ: a dictionary of environ variables to send along
with the request
:param status: expected status code of response
:param path_prefix: prefix of the url path
"""
full_path = path_prefix + path
print('DELETE: %s' % (full_path))
response = self.app.delete(str(full_path),
headers=headers,
status=status,
extra_environ=extra_environ,
expect_errors=expect_errors)
print('GOT:%s' % response)
return response
def get_json(self, path, expect_errors=False, headers=None,
extra_environ=None, q=[], path_prefix=PATH_PREFIX, **params):
"""Sends simulated HTTP GET request to Pecan test app.
:param path: url path of target service
:param expect_errors: Boolean value;whether an error is expected based
on request
:param headers: a dictionary of headers to send along with the request
:param extra_environ: a dictionary of environ variables to send along
with the request
:param q: list of queries consisting of: field, value, op, and type
keys
:param path_prefix: prefix of the url path
:param params: content for wsgi.input of request
"""
full_path = path_prefix + path
query_params = {'q.field': [],
'q.value': [],
'q.op': [],
}
for query in q:
for name in ['field', 'op', 'value']:
query_params['q.%s' % name].append(query.get(name, ''))
all_params = {}
all_params.update(params)
if q:
all_params.update(query_params)
print('GET: %s %r' % (full_path, all_params))
response = self.app.get(full_path,
params=all_params,
headers=headers,
extra_environ=extra_environ,
expect_errors=expect_errors)
if not expect_errors:
response = response.json
print('GOT:%s' % response)
return response

View File

@ -0,0 +1,74 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# -*- encoding: utf-8 -*-
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
Tests for ACL. Checks whether certain kinds of requests
are blocked or allowed to be processed.
"""
from oslo.config import cfg
from climate.api.v2 import app
from climate import policy
from climate.tests import api
from climate.tests.api import utils
class TestACL(api.APITest):
def setUp(self):
super(TestACL, self).setUp()
self.environ = {'fake.cache': utils.FakeMemcache()}
self.policy = policy
self.path = '/v2/os-hosts'
self.patch(
self.hosts_rpcapi, 'list_computehosts').return_value = []
def get_json(self, path, expect_errors=False, headers=None, q=[], **param):
return super(TestACL, self).get_json(path,
expect_errors=expect_errors,
headers=headers,
q=q,
extra_environ=self.environ,
path_prefix='',
**param)
def _make_app(self):
cfg.CONF.set_override('cache', 'fake.cache', group=app.OPT_GROUP_NAME)
return super(TestACL, self)._make_app(enable_acl=True)
def test_non_authenticated(self):
response = self.get_json(self.path, expect_errors=True)
self.assertEqual(response.status_int, 401)
def test_authenticated(self):
response = self.get_json(self.path,
headers={'X-Auth-Token': utils.ADMIN_TOKEN})
self.assertEqual(response, [])
def test_non_admin(self):
response = self.get_json(self.path,
headers={'X-Auth-Token': utils.MEMBER_TOKEN},
expect_errors=True)
self.assertEqual(response.status_int, 403)
def test_non_admin_with_admin_header(self):
response = self.get_json(self.path,
headers={'X-Auth-Token': utils.MEMBER_TOKEN,
'X-Roles': 'admin'},
expect_errors=True)
self.assertEqual(response.status_int, 403)

View File

@ -0,0 +1,34 @@
# Copyright (c) 2014 Bull.
#
# 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 climate.tests import api
class TestRoot(api.APITest):
def test_root(self):
response = self.get_json('/',
expect_errors=True,
path_prefix='')
self.assertEqual(response.status_int, 200)
self.assertEqual(response.content_type, "text/html")
self.assertEqual(response.body, '')
def test_bad_uri(self):
response = self.get_json('/bad/path',
expect_errors=True,
path_prefix='')
self.assertEqual(response.status_int, 404)
self.assertEqual(response.content_type, "text/plain")

View File

@ -0,0 +1,63 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# -*- encoding: utf-8 -*-
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
Utils for testing the API service.
"""
import datetime
import json
ADMIN_TOKEN = '4562138218392831'
MEMBER_TOKEN = '4562138218392832'
class FakeMemcache(object):
"""Fake cache that is used for keystone tokens lookup."""
_cache = {
'tokens/%s' % ADMIN_TOKEN: {
'access': {
'token': {'id': ADMIN_TOKEN},
'user': {'id': 'user_id1',
'name': 'user_name1',
'tenantId': '123i2910',
'tenantName': 'mytenant',
'roles': [{'name': 'admin'}]},
}
},
'tokens/%s' % MEMBER_TOKEN: {
'access': {
'token': {'id': MEMBER_TOKEN},
'user': {'id': 'user_id2',
'name': 'user-good',
'tenantId': 'project-good',
'tenantName': 'goodies',
'roles': [{'name': 'Member'}]},
}
}
}
def __init__(self):
self.set_key = None
self.set_value = None
self.token_expiration = None
def get(self, key):
dt = datetime.datetime.utcnow() + datetime.timedelta(minutes=5)
return json.dumps((self._cache.get(key), dt.isoformat()))
def set(self, key, value, timeout=None):
self.set_value = value
self.set_key = key

View File

View File

@ -0,0 +1,396 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# -*- encoding: utf-8 -*-
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
Tests for API /os-hosts/ methods
"""
import six
import uuid
from climate.tests import api
def fake_computehost(**kw):
return {
u'id': kw.get('id', u'1'),
u'hypervisor_hostname': kw.get('hypervisor_hostname', u'host01'),
u'hypervisor_type': kw.get('hypervisor_type', u'QEMU'),
u'vcpus': kw.get('vcpus', 1),
u'hypervisor_version': kw.get('hypervisor_version', 1000000),
u'memory_mb': kw.get('memory_mb', 8192),
u'local_gb': kw.get('local_gb', 50),
u'cpu_info': kw.get('cpu_info',
u"{\"vendor\": \"Intel\", \"model\": \"qemu32\", "
"\"arch\": \"x86_64\", \"features\": [],"
" \"topology\": {\"cores\": 1}}",
),
u'extra_capas': kw.get('extra_capas',
{u'vgpus': 2, u'fruits': u'bananas'}),
}
def fake_computehost_request_body(include=[], **kw):
computehost_body = fake_computehost(**kw)
computehost_body['name'] = kw.get('name',
computehost_body['hypervisor_hostname'])
include.append('name')
include.append('extra_capas')
return dict((key, computehost_body[key])
for key in computehost_body if key in include)
def fake_computehost_from_rpc(**kw):
# NOTE(sbauza): Extra capabilites are returned as extra key/value pairs
# from the Manager when searching from a specific node.
computehost = fake_computehost(**kw)
extra_capas = computehost.pop('extra_capas', None)
if extra_capas is not None:
computehost.update(extra_capas)
return computehost
class TestIncorrectHostFromRPC(api.APITest):
def setUp(self):
super(TestIncorrectHostFromRPC, self).setUp()
self.path = '/os-hosts'
self.patch(
self.hosts_rpcapi, 'list_computehosts').return_value = [
fake_computehost_from_rpc(hypervisor_type=1)
]
self.headers = {'X-Roles': 'admin'}
def test_bad_list(self):
expected = {
u'error_code': 400,
u'error_message': u"Invalid input for field/attribute "
u"hypervisor_type. Value: '1'. Wrong type. "
u"Expected '<type 'unicode'>', "
u"got '<type 'int'>'",
u'error_name': 400
}
response = self.get_json(self.path, expect_errors=True,
headers=self.headers)
self.assertEqual(400, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
class TestListHosts(api.APITest):
def setUp(self):
super(TestListHosts, self).setUp()
self.path = '/os-hosts'
self.patch(self.hosts_rpcapi, 'list_computehosts').return_value = []
self.headers = {'X-Roles': 'admin'}
def test_empty(self):
response = self.get_json(self.path, headers=self.headers)
self.assertEqual([], response)
def test_one(self):
self.patch(
self.hosts_rpcapi, 'list_computehosts'
).return_value = [fake_computehost_from_rpc(id=1)]
response = self.get_json(self.path, headers=self.headers)
self.assertEqual([fake_computehost(id=1)], response)
def test_multiple(self):
id1 = six.text_type('1')
id2 = six.text_type('2')
self.patch(
self.hosts_rpcapi, 'list_computehosts').return_value = [
fake_computehost_from_rpc(id=id1),
fake_computehost_from_rpc(id=id2)
]
response = self.get_json(self.path, headers=self.headers)
self.assertEqual([fake_computehost(id=id1), fake_computehost(id=id2)],
response)
def test_rpc_exception_list(self):
def fake_list_computehosts(*args, **kwargs):
raise Exception("Nah...")
expected = {
u'error_code': 500,
u'error_message': u"Nah...",
u'error_name': 500
}
self.patch(
self.hosts_rpcapi, 'list_computehosts'
).side_effect = fake_list_computehosts
response = self.get_json(self.path, headers=self.headers,
expect_errors=True)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
class TestShowHost(api.APITest):
def setUp(self):
super(TestShowHost, self).setUp()
self.id1 = six.text_type('1')
self.path = '/os-hosts/{0}'.format(self.id1)
self.patch(
self.hosts_rpcapi, 'get_computehost'
).return_value = fake_computehost_from_rpc(id=self.id1)
self.headers = {'X-Roles': 'admin'}
def test_one(self):
response = self.get_json(self.path, headers=self.headers)
self.assertEqual(fake_computehost(id=self.id1), response)
def test_empty(self):
expected = {
u'error_code': 404,
u'error_message': u"Object with {{'host_id': "
u"{0}}} not found".format(self.id1),
u'error_name': 404
}
self.patch(self.hosts_rpcapi, 'get_computehost').return_value = None
response = self.get_json(self.path, expect_errors=True,
headers=self.headers)
self.assertEqual(404, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_rpc_exception_get(self):
def fake_get_computehost(*args, **kwargs):
raise Exception("Nah...")
expected = {
u'error_code': 500,
u'error_message': u"Nah...",
u'error_name': 500
}
self.patch(
self.hosts_rpcapi, 'get_computehost'
).side_effect = fake_get_computehost
response = self.get_json(self.path, expect_errors=True,
headers=self.headers)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
class TestCreateHost(api.APITest):
def setUp(self):
super(TestCreateHost, self).setUp()
self.id1 = six.text_type(uuid.uuid4())
self.fake_computehost = fake_computehost(id=self.id1)
self.fake_computehost_body = fake_computehost_request_body(id=self.id1)
self.path = '/os-hosts'
self.patch(
self.hosts_rpcapi, 'create_computehost'
).return_value = fake_computehost_from_rpc(id=self.id1)
self.headers = {'X-Roles': 'admin'}
def test_create_one(self):
response = self.post_json(self.path, self.fake_computehost_body,
headers=self.headers)
self.assertEqual(202, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(self.fake_computehost, response.json)
def test_create_wrong_attr(self):
expected = {
"error_name": 400,
"error_message": "Invalid input for field/attribute name. "
"Value: '1'. Wrong type. "
"Expected '<type 'unicode'>', got '<type 'int'>'",
"error_code": 400
}
response = self.post_json(self.path,
fake_computehost_request_body(name=1),
expect_errors=True, headers=self.headers)
self.assertEqual(400, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_create_with_empty_body(self):
expected = {
"error_name": 500,
"error_message": "'NoneType' object has no attribute 'as_dict'",
"error_code": 500
}
response = self.post_json(self.path, None, expect_errors=True,
headers=self.headers)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_empty_response(self):
expected = {
u'error_code': 500,
u'error_message': u"Host can't be created",
u'error_name': 500
}
self.patch(self.hosts_rpcapi, 'create_computehost').return_value = None
response = self.post_json(self.path, self.fake_computehost_body,
expect_errors=True, headers=self.headers)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_rpc_exception_create(self):
def fake_create_computehost(*args, **kwargs):
raise Exception("Nah...")
expected = {
u'error_code': 500,
u'error_message': u"Nah...",
u'error_name': 500
}
self.patch(
self.hosts_rpcapi, 'create_computehost'
).side_effect = fake_create_computehost
response = self.post_json(self.path, self.fake_computehost_body,
expect_errors=True, headers=self.headers)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
class TestUpdateHost(api.APITest):
def setUp(self):
super(TestUpdateHost, self).setUp()
self.id1 = six.text_type('1')
self.fake_computehost = fake_computehost(id=self.id1, name='updated')
self.fake_computehost_body = fake_computehost_request_body(
exclude=['reservations', 'events'],
id=self.id1,
name='updated'
)
self.path = '/os-hosts/{0}'.format(self.id1)
self.patch(
self.hosts_rpcapi, 'update_computehost'
).return_value = fake_computehost_from_rpc(id=self.id1, name='updated')
self.headers = {'X-Roles': 'admin'}
def test_update_one(self):
response = self.put_json(self.path, self.fake_computehost_body,
headers=self.headers)
self.assertEqual(202, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(self.fake_computehost, response.json)
def test_update_with_empty_body(self):
expected = {
"error_name": 500,
"error_message": "'NoneType' object has no attribute 'as_dict'",
"error_code": 500
}
response = self.put_json(self.path, None, expect_errors=True,
headers=self.headers)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_empty_response(self):
expected = {
u'error_code': 404,
u'error_message': u"Object with {{'host_id': "
u"{0}}} not found".format(self.id1),
u'error_name': 404
}
self.patch(self.hosts_rpcapi, 'update_computehost').return_value = None
response = self.put_json(self.path, self.fake_computehost_body,
expect_errors=True, headers=self.headers)
self.assertEqual(404, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_rpc_exception_update(self):
def fake_update_computehost(*args, **kwargs):
raise Exception("Nah...")
expected = {
u'error_code': 500,
u'error_message': u"Nah...",
u'error_name': 500
}
self.patch(
self.hosts_rpcapi, 'update_computehost'
).side_effect = fake_update_computehost
response = self.put_json(self.path, self.fake_computehost_body,
expect_errors=True, headers=self.headers)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
class TestDeleteHost(api.APITest):
def setUp(self):
super(TestDeleteHost, self).setUp()
self.id1 = six.text_type('1')
self.path = '/os-hosts/{0}'.format(self.id1)
self.patch(self.hosts_rpcapi, 'delete_computehost')
self.headers = {'X-Roles': 'admin'}
def test_delete_one(self):
response = self.delete(self.path, headers=self.headers)
self.assertEqual(204, response.status_int)
self.assertEqual(None, response.content_type)
self.assertEqual('', response.body)
def test_delete_not_existing_computehost(self):
def fake_delete_computehost(*args, **kwargs):
raise TypeError("Nah...")
expected = {
u'error_code': 404,
u'error_message': u"Object with {{'host_id': "
u"u'{0}'}} not found".format(self.id1),
u'error_name': 404
}
self.patch(
self.hosts_rpcapi, 'delete_computehost'
).side_effect = fake_delete_computehost
response = self.delete(self.path, expect_errors=True,
headers=self.headers)
self.assertEqual(404, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_rpc_exception_delete(self):
def fake_delete_computehost(*args, **kwargs):
raise Exception("Nah...")
expected = {
u'error_code': 500,
u'error_message': u"Nah...",
u'error_name': 500
}
self.patch(
self.hosts_rpcapi, 'delete_computehost'
).side_effect = fake_delete_computehost
response = self.delete(self.path, expect_errors=True,
headers=self.headers)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)

View File

@ -0,0 +1,372 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# -*- encoding: utf-8 -*-
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
Tests for API /leases/ methods
"""
import six
import uuid
from climate.tests import api
from climate.utils import trusts
def fake_lease(**kw):
return {
u'id': kw.get('id', u'2bb8720a-0873-4d97-babf-0d906851a1eb'),
u'name': kw.get('name', u'lease_test'),
u'start_date': kw.get('start_date', u'2014-01-01 01:23'),
u'end_date': kw.get('end_date', u'2014-02-01 13:37'),
u'trust_id': kw.get('trust_id',
u'35b17138-b364-4e6a-a131-8f3099c5be68'),
u'user_id': kw.get('user_id', u'efd87807-12d2-4b38-9c70-5f5c2ac427ff'),
u'tenant_id': kw.get('tenant_id',
u'bd9431c1-8d69-4ad3-803a-8d4a6b89fd36'),
u'reservations': kw.get('reservations', [
{
u'resource_id': u'1234',
u'resource_type': u'virtual:instance'
}
]),
u'events': kw.get('events', []),
}
def fake_lease_request_body(exclude=[], **kw):
exclude.append('id')
exclude.append('trust_id')
exclude.append('user_id')
exclude.append('tenant_id')
lease_body = fake_lease(**kw)
return dict((key, lease_body[key])
for key in lease_body if key not in exclude)
def fake_trust(id=fake_lease()['trust_id']):
return type('Trust', (), {
'id': id,
})
class TestIncorrectLeaseFromRPC(api.APITest):
def setUp(self):
super(TestIncorrectLeaseFromRPC, self).setUp()
self.path = '/leases'
self.patch(
self.rpcapi, 'list_leases').return_value = [fake_lease(id=1)]
def test_bad_list(self):
expected = {
u'error_code': 400,
u'error_message': u"Invalid input for field/attribute id. "
u"Value: '1'. Value should be UUID format",
u'error_name': 400
}
response = self.get_json(self.path, expect_errors=True)
self.assertEqual(400, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
class TestListLeases(api.APITest):
def setUp(self):
super(TestListLeases, self).setUp()
self.fake_lease = fake_lease()
self.path = '/leases'
self.patch(self.rpcapi, 'list_leases').return_value = []
def test_empty(self):
response = self.get_json(self.path)
self.assertEqual([], response)
def test_one(self):
self.patch(self.rpcapi, 'list_leases').return_value = [self.fake_lease]
response = self.get_json(self.path)
self.assertEqual([self.fake_lease], response)
def test_multiple(self):
id1 = six.text_type(uuid.uuid4())
id2 = six.text_type(uuid.uuid4())
self.patch(
self.rpcapi, 'list_leases').return_value = [
fake_lease(id=id1),
fake_lease(id=id2)
]
response = self.get_json(self.path)
self.assertEqual([fake_lease(id=id1), fake_lease(id=id2)], response)
def test_rpc_exception_list(self):
def fake_list_leases(*args, **kwargs):
raise Exception("Nah...")
expected = {
u'error_code': 500,
u'error_message': u"Nah...",
u'error_name': 500
}
self.patch(
self.rpcapi, 'list_leases').side_effect = fake_list_leases
response = self.get_json(self.path, expect_errors=True)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
class TestShowLease(api.APITest):
def setUp(self):
super(TestShowLease, self).setUp()
self.id1 = six.text_type(uuid.uuid4())
self.fake_lease = fake_lease(id=self.id1)
self.path = '/leases/{0}'.format(self.id1)
self.patch(self.rpcapi, 'get_lease').return_value = self.fake_lease
def test_one(self):
response = self.get_json(self.path)
self.assertEqual(self.fake_lease, response)
def test_empty(self):
expected = {
u'error_code': 404,
u'error_message': u"Object with {{'lease_id': "
u"u'{0}'}} not found".format(self.id1),
u'error_name': 404
}
self.patch(self.rpcapi, 'get_lease').return_value = None
response = self.get_json(self.path, expect_errors=True)
self.assertEqual(404, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_rpc_exception_get(self):
def fake_get_lease(*args, **kwargs):
raise Exception("Nah...")
expected = {
u'error_code': 500,
u'error_message': u"Nah...",
u'error_name': 500
}
self.patch(
self.rpcapi, 'get_lease').side_effect = fake_get_lease
response = self.get_json(self.path, expect_errors=True)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
class TestCreateLease(api.APITest):
def setUp(self):
super(TestCreateLease, self).setUp()
self.id1 = six.text_type(uuid.uuid4())
self.fake_lease = fake_lease(id=self.id1)
self.fake_lease_body = fake_lease_request_body(id=self.id1)
self.path = '/leases'
self.patch(self.rpcapi, 'create_lease').return_value = self.fake_lease
self.trusts = trusts
self.patch(self.trusts, 'create_trust').return_value = fake_trust()
def test_create_one(self):
response = self.post_json(self.path, self.fake_lease_body)
self.assertEqual(202, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(self.fake_lease, response.json)
def test_create_wrong_attr(self):
expected = {
"error_name": 400,
"error_message": "Invalid input for field/attribute name. "
"Value: '1'. Wrong type. "
"Expected '<type 'unicode'>', got '<type 'int'>'",
"error_code": 400
}
response = self.post_json(self.path, fake_lease_request_body(name=1),
expect_errors=True)
self.assertEqual(400, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_create_with_empty_body(self):
expected = {
"error_name": 500,
"error_message": "'NoneType' object has no attribute 'as_dict'",
"error_code": 500
}
response = self.post_json(self.path, None, expect_errors=True)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_empty_response(self):
expected = {
u'error_code': 500,
u'error_message': u"Lease can't be created",
u'error_name': 500
}
self.patch(self.rpcapi, 'create_lease').return_value = None
response = self.post_json(self.path, self.fake_lease_body,
expect_errors=True)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_rpc_exception_create(self):
def fake_create_lease(*args, **kwargs):
raise Exception("Nah...")
expected = {
u'error_code': 500,
u'error_message': u"Nah...",
u'error_name': 500
}
self.patch(
self.rpcapi, 'create_lease').side_effect = fake_create_lease
response = self.post_json(self.path, self.fake_lease_body,
expect_errors=True)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
class TestUpdateLease(api.APITest):
def setUp(self):
super(TestUpdateLease, self).setUp()
self.id1 = six.text_type(uuid.uuid4())
self.fake_lease = fake_lease(id=self.id1, name='updated')
self.fake_lease_body = fake_lease_request_body(
exclude=['reservations', 'events'],
id=self.id1,
name='updated'
)
self.path = '/leases/{0}'.format(self.id1)
self.patch(self.rpcapi, 'update_lease').return_value = self.fake_lease
def test_update_one(self):
response = self.put_json(self.path, self.fake_lease_body)
self.assertEqual(202, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(self.fake_lease, response.json)
def test_update_one_with_extra_attrs(self):
expected = {
"error_name": 500,
"error_message": "Only name changing and dates changing "
"may be proceeded.",
"error_code": 500
}
response = self.put_json(self.path, fake_lease_request_body(name='a'),
expect_errors=True)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_update_with_empty_body(self):
expected = {
"error_name": 500,
"error_message": "'NoneType' object has no attribute 'as_dict'",
"error_code": 500
}
response = self.put_json(self.path, None, expect_errors=True)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_empty_response(self):
expected = {
u'error_code': 404,
u'error_message': u"Object with {{'lease_id': "
u"u'{0}'}} not found".format(self.id1),
u'error_name': 404
}
self.patch(self.rpcapi, 'update_lease').return_value = None
response = self.put_json(self.path, self.fake_lease_body,
expect_errors=True)
self.assertEqual(404, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_rpc_exception_update(self):
def fake_update_lease(*args, **kwargs):
raise Exception("Nah...")
expected = {
u'error_code': 500,
u'error_message': u"Nah...",
u'error_name': 500
}
self.patch(
self.rpcapi, 'update_lease').side_effect = fake_update_lease
response = self.put_json(self.path, self.fake_lease_body,
expect_errors=True)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
class TestDeleteLease(api.APITest):
def setUp(self):
super(TestDeleteLease, self).setUp()
self.id1 = six.text_type(uuid.uuid4())
self.path = '/leases/{0}'.format(self.id1)
self.patch(self.rpcapi, 'delete_lease')
def test_delete_one(self):
response = self.delete(self.path)
self.assertEqual(204, response.status_int)
self.assertEqual(None, response.content_type)
self.assertEqual('', response.body)
def test_delete_not_existing_lease(self):
def fake_delete_lease(*args, **kwargs):
raise TypeError("Nah...")
expected = {
u'error_code': 404,
u'error_message': u"Object with {{'lease_id': "
u"u'{0}'}} not found".format(self.id1),
u'error_name': 404
}
self.patch(
self.rpcapi, 'delete_lease').side_effect = fake_delete_lease
response = self.delete(self.path, expect_errors=True)
self.assertEqual(404, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)
def test_rpc_exception_delete(self):
def fake_delete_lease(*args, **kwargs):
raise Exception("Nah...")
expected = {
u'error_code': 500,
u'error_message': u"Nah...",
u'error_name': 500
}
self.patch(
self.rpcapi, 'delete_lease').side_effect = fake_delete_lease
response = self.delete(self.path, expect_errors=True)
self.assertEqual(500, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, response.json)

View File

@ -75,8 +75,8 @@ def _get_fake_virt_lease_values(id=_get_fake_lease_uuid(),
resource_id=None):
return {'id': id,
'name': name,
'user_id': 'fake',
'tenant_id': 'fake',
'user_id': _get_fake_random_uuid(),
'tenant_id': _get_fake_random_uuid(),
'start_date': start_date,
'end_date': end_date,
'trust': 'trust',

View File

@ -22,7 +22,18 @@ policy_data = """
"admin_api": "rule:admin",
"climate:leases": "rule:admin_or_owner",
"climate:oshosts": "rule:admin_api",
"climate:leases:get": "rule:admin_or_owner",
"climate:os-hosts": "rule:admin_api"
"climate:leases:create": "rule:admin_or_owner",
"climate:leases:delete": "rule:admin_or_owner",
"climate:leases:update": "rule:admin_or_owner",
"climate:plugins:get": "@",
"climate:oshosts:get": "rule:admin_api",
"climate:oshosts:create": "rule:admin_api",
"climate:oshosts:delete": "rule:admin_api",
"climate:oshosts:update": "rule:admin_api"
}
"""

View File

@ -99,14 +99,14 @@ class ClimatePolicyTestCase(tests.TestCase):
def test_adminpolicy(self):
target = {'user_id': self.context.user_id,
'tenant_id': self.context.tenant_id}
action = "climate:os-hosts"
action = "climate:oshosts"
self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce,
self.context, action, target)
def test_elevatedpolicy(self):
target = {'user_id': self.context.user_id,
'tenant_id': self.context.tenant_id}
action = "climate:os-hosts"
action = "climate:oshosts"
self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce,
self.context, action, target)
elevated_context = self.context.elevated()

View File

@ -0,0 +1,9 @@
dl.toggle dt {
background-color: #eeffcc;
border: 1px solid #ac9;
display: inline;
}
dl.toggle dd {
display: none;
}

View File

@ -0,0 +1,9 @@
/*global $,document*/
$(document).ready(function () {
"use strict";
$("dl.toggle > dt").click(
function (event) {
$(this).next().toggle(250);
}
);
});

View File

@ -26,8 +26,18 @@ import os
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.todo', 'sphinx.ext.coverage',
'sphinx.ext.pngmath', 'sphinx.ext.viewcode', 'sphinxcontrib.httpdomain']
extensions = ['sphinx.ext.autodoc',
'sphinx.ext.doctest',
'sphinx.ext.todo',
'sphinx.ext.coverage',
'sphinx.ext.pngmath',
'sphinx.ext.viewcode',
'sphinxcontrib.httpdomain',
'sphinxcontrib.pecanwsme.rest',
'wsmeext.sphinxext',
]
wsme_protocols = ['restjson', 'restxml']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@ -42,8 +52,8 @@ source_suffix = '.rst'
master_doc = 'index'
# General information about the project.
project = u'climate'
copyright = u'2013, Mirantis Inc.'
project = u'Climate'
copyright = u'2013, Mirantis Inc.;2014, Bull.'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the

View File

@ -7,3 +7,4 @@ This page includes documentation for Climate APIs.
:maxdepth: 1
rest_api_v1.0
rest_api_v2

View File

@ -0,0 +1,57 @@
Climate REST API v2
*********************
1 General API information
=========================
This section contains base information about the Climate REST API design,
including operations with different Climate resource types and examples of
possible requests and responses. Climate supports JSON data serialization
format, which means that requests with non empty body have to contain
"application/json" Content-Type header or it should be added ".json" extension
to the resource name in the request.
This should look like the following:
.. sourcecode:: http
GET /v2/leases.json
or
.. sourcecode:: http
GET /v2/leases
Accept: application/json
2 Leases
=======
**Description**
Lease is the main abstraction for the user in the Climate case. Lease means
some kind of contract where start time, end time and resources to be reserved
are mentioned.
.. rest-controller:: climate.api.v2.controllers.lease:LeasesController
:webprefix: /v2/leases
.. autotype:: climate.api.v2.controllers.lease.Lease
:members:
3 Hosts
=======
**Description**
Host is the abstraction for a computehost in the Climate case. Host means
a specific type of resource to be allocated.
.. rest-controller:: climate.api.v2.controllers.host:HostsController
:webprefix: /v2/os-hosts
.. autotype:: climate.api.v2.controllers.host.Host
:members:

View File

@ -204,6 +204,10 @@
# ZeroMQ, a valid hostname, FQDN, or IP address (string value)
#host=climate
# Log request/response exchange details: environ, headers and
# bodies (boolean value)
#log_exchange=false
# Protocol used to access OpenStack Identity service (string
# value)
#os_auth_protocol=http
@ -231,18 +235,21 @@
#
# Options defined in climate.api.v1.app
# Options defined in climate.api.v2.app
#
# Log request/response exchange details: environ, headers and
# bodies (boolean value)
#log_exchange=false
# The strategy to use for auth: noauth or keystone. (string
# value)
#auth_strategy=keystone
#
# Options defined in climate.cmd.api
#
# Deploy the v1 API. (boolean value)
#enable_v1_api=true
# Port that will be used to listen on (integer value)
#port=1234

View File

@ -12,7 +12,10 @@ posix_ipc
python-novaclient>=2.17.0
netaddr>=0.7.6
python-keystoneclient>=0.7.0
pecan>=0.4.5
Routes>=1.12.3
SQLAlchemy>=0.7.8,<=0.9.99
stevedore>=0.14
WebOb>=1.2.3
WSME>=0.6

View File

@ -15,3 +15,4 @@ testrepository>=0.0.18
testtools>=0.9.34
coverage>=3.6
pylint==0.25.2
sphinxcontrib-pecanwsme>=0.6