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__)
|
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 = cfg.CONF
|
||||||
|
|
||||||
CONF.import_opt('os_auth_host', 'climate.config')
|
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_password', 'climate.config')
|
||||||
CONF.import_opt('os_admin_tenant_name', 'climate.config')
|
CONF.import_opt('os_admin_tenant_name', 'climate.config')
|
||||||
CONF.import_opt('os_auth_version', 'climate.config')
|
CONF.import_opt('os_auth_version', 'climate.config')
|
||||||
|
CONF.import_opt('log_exchange', 'climate.config')
|
||||||
CONF.register_cli_opts(cli_opts)
|
|
||||||
|
|
||||||
|
|
||||||
eventlet.monkey_patch(
|
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)
|
gettext.install('climate', unicode=1)
|
||||||
|
|
||||||
from climate.api.v1 import app as v1_app
|
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.openstack.common import log as logging
|
||||||
from climate.utils import service as service_utils
|
from climate.utils import service as service_utils
|
||||||
|
|
||||||
@ -32,12 +33,32 @@ opts = [
|
|||||||
help='Port that will be used to listen on'),
|
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__)
|
LOG = logging.getLogger(__name__)
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
CONF.register_cli_opts(opts)
|
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():
|
def main():
|
||||||
@ -45,7 +66,10 @@ def main():
|
|||||||
cfg.CONF(sys.argv[1:], project='climate', prog='climate-api')
|
cfg.CONF(sys.argv[1:], project='climate', prog='climate-api')
|
||||||
service_utils.prepare_service(sys.argv)
|
service_utils.prepare_service(sys.argv)
|
||||||
logging.setup("climate")
|
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)
|
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 '
|
'However, the node name must be valid within '
|
||||||
'an AMQP key, and if using ZeroMQ, a valid '
|
'an AMQP key, and if using ZeroMQ, a valid '
|
||||||
'hostname, FQDN, or IP address'),
|
'hostname, FQDN, or IP address'),
|
||||||
|
cfg.BoolOpt('log_exchange', default=False,
|
||||||
|
help='Log request/response exchange details: environ, '
|
||||||
|
'headers and bodies'),
|
||||||
]
|
]
|
||||||
|
|
||||||
os_opts = [
|
os_opts = [
|
||||||
|
@ -45,6 +45,11 @@ IMPL = db_api.DBAPI(db_options.CONF.database.backend,
|
|||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_instance():
|
||||||
|
"""Return a DB API instance."""
|
||||||
|
return IMPL
|
||||||
|
|
||||||
|
|
||||||
def setup_db():
|
def setup_db():
|
||||||
"""Set up database, create tables, etc.
|
"""Set up database, create tables, etc.
|
||||||
|
|
||||||
|
@ -88,3 +88,8 @@ class TaskFailed(ClimateException):
|
|||||||
|
|
||||||
class Timeout(ClimateException):
|
class Timeout(ClimateException):
|
||||||
msg_fmt = _('Current task failed with timeout')
|
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):
|
resource_id=None):
|
||||||
return {'id': id,
|
return {'id': id,
|
||||||
'name': name,
|
'name': name,
|
||||||
'user_id': 'fake',
|
'user_id': _get_fake_random_uuid(),
|
||||||
'tenant_id': 'fake',
|
'tenant_id': _get_fake_random_uuid(),
|
||||||
'start_date': start_date,
|
'start_date': start_date,
|
||||||
'end_date': end_date,
|
'end_date': end_date,
|
||||||
'trust': 'trust',
|
'trust': 'trust',
|
||||||
|
@ -22,7 +22,18 @@ policy_data = """
|
|||||||
|
|
||||||
"admin_api": "rule:admin",
|
"admin_api": "rule:admin",
|
||||||
"climate:leases": "rule:admin_or_owner",
|
"climate:leases": "rule:admin_or_owner",
|
||||||
|
"climate:oshosts": "rule:admin_api",
|
||||||
|
|
||||||
"climate:leases:get": "rule:admin_or_owner",
|
"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):
|
def test_adminpolicy(self):
|
||||||
target = {'user_id': self.context.user_id,
|
target = {'user_id': self.context.user_id,
|
||||||
'tenant_id': self.context.tenant_id}
|
'tenant_id': self.context.tenant_id}
|
||||||
action = "climate:os-hosts"
|
action = "climate:oshosts"
|
||||||
self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce,
|
self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce,
|
||||||
self.context, action, target)
|
self.context, action, target)
|
||||||
|
|
||||||
def test_elevatedpolicy(self):
|
def test_elevatedpolicy(self):
|
||||||
target = {'user_id': self.context.user_id,
|
target = {'user_id': self.context.user_id,
|
||||||
'tenant_id': self.context.tenant_id}
|
'tenant_id': self.context.tenant_id}
|
||||||
action = "climate:os-hosts"
|
action = "climate:oshosts"
|
||||||
self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce,
|
self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce,
|
||||||
self.context, action, target)
|
self.context, action, target)
|
||||||
elevated_context = self.context.elevated()
|
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
|
# Add any Sphinx extension module names here, as strings. They can be extensions
|
||||||
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||||
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.todo', 'sphinx.ext.coverage',
|
extensions = ['sphinx.ext.autodoc',
|
||||||
'sphinx.ext.pngmath', 'sphinx.ext.viewcode', 'sphinxcontrib.httpdomain']
|
'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.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
@ -42,8 +52,8 @@ source_suffix = '.rst'
|
|||||||
master_doc = 'index'
|
master_doc = 'index'
|
||||||
|
|
||||||
# General information about the project.
|
# General information about the project.
|
||||||
project = u'climate'
|
project = u'Climate'
|
||||||
copyright = u'2013, Mirantis Inc.'
|
copyright = u'2013, Mirantis Inc.;2014, Bull.'
|
||||||
|
|
||||||
# The version info for the project you're documenting, acts as replacement for
|
# The version info for the project you're documenting, acts as replacement for
|
||||||
# |version| and |release|, also used in various other places throughout the
|
# |version| and |release|, also used in various other places throughout the
|
||||||
|
@ -7,3 +7,4 @@ This page includes documentation for Climate APIs.
|
|||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
|
|
||||||
rest_api_v1.0
|
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)
|
# ZeroMQ, a valid hostname, FQDN, or IP address (string value)
|
||||||
#host=climate
|
#host=climate
|
||||||
|
|
||||||
|
# Log request/response exchange details: environ, headers and
|
||||||
|
# bodies (boolean value)
|
||||||
|
#log_exchange=false
|
||||||
|
|
||||||
# Protocol used to access OpenStack Identity service (string
|
# Protocol used to access OpenStack Identity service (string
|
||||||
# value)
|
# value)
|
||||||
#os_auth_protocol=http
|
#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
|
# The strategy to use for auth: noauth or keystone. (string
|
||||||
# bodies (boolean value)
|
# value)
|
||||||
#log_exchange=false
|
#auth_strategy=keystone
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Options defined in climate.cmd.api
|
# 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 that will be used to listen on (integer value)
|
||||||
#port=1234
|
#port=1234
|
||||||
|
|
||||||
|
@ -12,7 +12,10 @@ posix_ipc
|
|||||||
python-novaclient>=2.17.0
|
python-novaclient>=2.17.0
|
||||||
netaddr>=0.7.6
|
netaddr>=0.7.6
|
||||||
python-keystoneclient>=0.7.0
|
python-keystoneclient>=0.7.0
|
||||||
|
pecan>=0.4.5
|
||||||
Routes>=1.12.3
|
Routes>=1.12.3
|
||||||
SQLAlchemy>=0.7.8,<=0.9.99
|
SQLAlchemy>=0.7.8,<=0.9.99
|
||||||
stevedore>=0.14
|
stevedore>=0.14
|
||||||
WebOb>=1.2.3
|
WebOb>=1.2.3
|
||||||
|
WSME>=0.6
|
||||||
|
|
||||||
|
@ -15,3 +15,4 @@ testrepository>=0.0.18
|
|||||||
testtools>=0.9.34
|
testtools>=0.9.34
|
||||||
coverage>=3.6
|
coverage>=3.6
|
||||||
pylint==0.25.2
|
pylint==0.25.2
|
||||||
|
sphinxcontrib-pecanwsme>=0.6
|
||||||
|
Loading…
Reference in New Issue
Block a user