Merge "Port to Pecan/WSME for API v2"
This commit is contained in:
commit
376e30641c
28
climate/api/root.py
Normal file
28
climate/api/root.py
Normal 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()
|
@ -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(
|
||||
|
0
climate/api/v2/__init__.py
Normal file
0
climate/api/v2/__init__.py
Normal file
83
climate/api/v2/app.py
Normal file
83
climate/api/v2/app.py
Normal 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
|
50
climate/api/v2/controllers/__init__.py
Normal file
50
climate/api/v2/controllers/__init__.py
Normal 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)
|
52
climate/api/v2/controllers/base.py
Normal file
52
climate/api/v2/controllers/base.py
Normal 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
|
169
climate/api/v2/controllers/host.py
Normal file
169
climate/api/v2/controllers/host.py
Normal 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})
|
159
climate/api/v2/controllers/lease.py
Normal file
159
climate/api/v2/controllers/lease.py
Normal 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})
|
129
climate/api/v2/controllers/types.py
Normal file
129
climate/api/v2/controllers/types.py
Normal 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
67
climate/api/v2/hooks.py
Normal 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()
|
141
climate/api/v2/middleware/__init__.py
Normal file
141
climate/api/v2/middleware/__init__.py
Normal 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
|
@ -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)
|
||||
|
||||
|
@ -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 = [
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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.")
|
||||
|
@ -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
|
74
climate/tests/api/test_acl.py
Normal file
74
climate/tests/api/test_acl.py
Normal 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)
|
34
climate/tests/api/test_root.py
Normal file
34
climate/tests/api/test_root.py
Normal 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")
|
63
climate/tests/api/utils.py
Normal file
63
climate/tests/api/utils.py
Normal 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
|
0
climate/tests/api/v2/__init__.py
Normal file
0
climate/tests/api/v2/__init__.py
Normal file
396
climate/tests/api/v2/test_hosts.py
Normal file
396
climate/tests/api/v2/test_hosts.py
Normal 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)
|
372
climate/tests/api/v2/test_leases.py
Normal file
372
climate/tests/api/v2/test_leases.py
Normal 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)
|
@ -78,8 +78,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',
|
||||
|
@ -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"
|
||||
}
|
||||
"""
|
||||
|
@ -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()
|
||||
|
9
doc/source/_static/toggle.css
Normal file
9
doc/source/_static/toggle.css
Normal file
@ -0,0 +1,9 @@
|
||||
dl.toggle dt {
|
||||
background-color: #eeffcc;
|
||||
border: 1px solid #ac9;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
dl.toggle dd {
|
||||
display: none;
|
||||
}
|
9
doc/source/_static/toggle.js
Normal file
9
doc/source/_static/toggle.js
Normal file
@ -0,0 +1,9 @@
|
||||
/*global $,document*/
|
||||
$(document).ready(function () {
|
||||
"use strict";
|
||||
$("dl.toggle > dt").click(
|
||||
function (event) {
|
||||
$(this).next().toggle(250);
|
||||
}
|
||||
);
|
||||
});
|
@ -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
|
||||
|
@ -7,3 +7,4 @@ This page includes documentation for Climate APIs.
|
||||
:maxdepth: 1
|
||||
|
||||
rest_api_v1.0
|
||||
rest_api_v2
|
||||
|
57
doc/source/restapi/rest_api_v2.rst
Normal file
57
doc/source/restapi/rest_api_v2.rst
Normal 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:
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -15,3 +15,4 @@ testrepository>=0.0.18
|
||||
testtools>=0.9.34
|
||||
coverage>=3.6
|
||||
pylint==0.25.2
|
||||
sphinxcontrib-pecanwsme>=0.6
|
||||
|
Loading…
Reference in New Issue
Block a user