diff --git a/climate/api/root.py b/climate/api/root.py new file mode 100644 index 00000000..5bb6f846 --- /dev/null +++ b/climate/api/root.py @@ -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() diff --git a/climate/api/v1/app.py b/climate/api/v1/app.py index f61dd0c1..f9466731 100644 --- a/climate/api/v1/app.py +++ b/climate/api/v1/app.py @@ -29,12 +29,6 @@ from climate.openstack.common.middleware import debug LOG = log.getLogger(__name__) -cli_opts = [ - cfg.BoolOpt('log_exchange', default=False, - help='Log request/response exchange details: environ, ' - 'headers and bodies'), -] - CONF = cfg.CONF CONF.import_opt('os_auth_host', 'climate.config') @@ -44,8 +38,7 @@ CONF.import_opt('os_admin_username', 'climate.config') CONF.import_opt('os_admin_password', 'climate.config') CONF.import_opt('os_admin_tenant_name', 'climate.config') CONF.import_opt('os_auth_version', 'climate.config') - -CONF.register_cli_opts(cli_opts) +CONF.import_opt('log_exchange', 'climate.config') eventlet.monkey_patch( diff --git a/climate/api/v2/__init__.py b/climate/api/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/climate/api/v2/app.py b/climate/api/v2/app.py new file mode 100644 index 00000000..bd41a75a --- /dev/null +++ b/climate/api/v2/app.py @@ -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 diff --git a/climate/api/v2/controllers/__init__.py b/climate/api/v2/controllers/__init__.py new file mode 100644 index 00000000..f7cf96a0 --- /dev/null +++ b/climate/api/v2/controllers/__init__.py @@ -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) diff --git a/climate/api/v2/controllers/base.py b/climate/api/v2/controllers/base.py new file mode 100644 index 00000000..3db7b58c --- /dev/null +++ b/climate/api/v2/controllers/base.py @@ -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 diff --git a/climate/api/v2/controllers/host.py b/climate/api/v2/controllers/host.py new file mode 100644 index 00000000..814d7d54 --- /dev/null +++ b/climate/api/v2/controllers/host.py @@ -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}) diff --git a/climate/api/v2/controllers/lease.py b/climate/api/v2/controllers/lease.py new file mode 100644 index 00000000..95fb707f --- /dev/null +++ b/climate/api/v2/controllers/lease.py @@ -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}) diff --git a/climate/api/v2/controllers/types.py b/climate/api/v2/controllers/types.py new file mode 100644 index 00000000..45eabd7b --- /dev/null +++ b/climate/api/v2/controllers/types.py @@ -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 diff --git a/climate/api/v2/hooks.py b/climate/api/v2/hooks.py new file mode 100644 index 00000000..2d4001aa --- /dev/null +++ b/climate/api/v2/hooks.py @@ -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() diff --git a/climate/api/v2/middleware/__init__.py b/climate/api/v2/middleware/__init__.py new file mode 100644 index 00000000..232aed10 --- /dev/null +++ b/climate/api/v2/middleware/__init__.py @@ -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 diff --git a/climate/cmd/api.py b/climate/cmd/api.py index 6c02ea26..d10131ea 100644 --- a/climate/cmd/api.py +++ b/climate/cmd/api.py @@ -23,6 +23,7 @@ from oslo.config import cfg gettext.install('climate', unicode=1) from climate.api.v1 import app as v1_app +from climate.api.v2 import app as v2_app from climate.openstack.common import log as logging from climate.utils import service as service_utils @@ -32,12 +33,32 @@ opts = [ help='Port that will be used to listen on'), ] +api_opts = [ + cfg.BoolOpt('enable_v1_api', + default=True, + help='Deploy the v1 API.'), +] + LOG = logging.getLogger(__name__) CONF = cfg.CONF CONF.register_cli_opts(opts) +CONF.register_opts(api_opts) -cfg.CONF.import_opt('host', 'climate.config') +CONF.import_opt('host', 'climate.config') + + +class VersionSelectorApplication(object): + """Maps WSGI versioned apps and defines default WSGI app.""" + + def __init__(self): + self.v1 = v1_app.make_app() + self.v2 = v2_app.make_app() + + def __call__(self, environ, start_response): + if environ['PATH_INFO'].startswith('/v1/'): + return self.v1(environ, start_response) + return self.v2(environ, start_response) def main(): @@ -45,7 +66,10 @@ def main(): cfg.CONF(sys.argv[1:], project='climate', prog='climate-api') service_utils.prepare_service(sys.argv) logging.setup("climate") - app = v1_app.make_app() + if not CONF.enable_v1_api: + app = v2_app.make_app() + else: + app = VersionSelectorApplication() wsgi.server(eventlet.listen((CONF.host, CONF.port), backlog=500), app) diff --git a/climate/config.py b/climate/config.py index dcb69947..17622031 100644 --- a/climate/config.py +++ b/climate/config.py @@ -24,6 +24,9 @@ cli_opts = [ 'However, the node name must be valid within ' 'an AMQP key, and if using ZeroMQ, a valid ' 'hostname, FQDN, or IP address'), + cfg.BoolOpt('log_exchange', default=False, + help='Log request/response exchange details: environ, ' + 'headers and bodies'), ] os_opts = [ diff --git a/climate/db/api.py b/climate/db/api.py index 8d8e5037..03cafc10 100644 --- a/climate/db/api.py +++ b/climate/db/api.py @@ -45,6 +45,11 @@ IMPL = db_api.DBAPI(db_options.CONF.database.backend, LOG = logging.getLogger(__name__) +def get_instance(): + """Return a DB API instance.""" + return IMPL + + def setup_db(): """Set up database, create tables, etc. diff --git a/climate/exceptions.py b/climate/exceptions.py index adde1d01..f458498b 100644 --- a/climate/exceptions.py +++ b/climate/exceptions.py @@ -88,3 +88,8 @@ class TaskFailed(ClimateException): class Timeout(ClimateException): msg_fmt = _('Current task failed with timeout') + + +class InvalidInput(ClimateException): + code = 400 + msg_fmt = _("Expected a %(cls)s type but received %(value)s.") diff --git a/climate/tests/api/__init__.py b/climate/tests/api/__init__.py index e69de29b..1ff4f9a3 100644 --- a/climate/tests/api/__init__.py +++ b/climate/tests/api/__init__.py @@ -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 diff --git a/climate/tests/api/test_acl.py b/climate/tests/api/test_acl.py new file mode 100644 index 00000000..832e2e07 --- /dev/null +++ b/climate/tests/api/test_acl.py @@ -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) diff --git a/climate/tests/api/v1/test_context.py b/climate/tests/api/test_context.py similarity index 100% rename from climate/tests/api/v1/test_context.py rename to climate/tests/api/test_context.py diff --git a/climate/tests/api/test_root.py b/climate/tests/api/test_root.py new file mode 100644 index 00000000..9aced3f6 --- /dev/null +++ b/climate/tests/api/test_root.py @@ -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") diff --git a/climate/tests/api/utils.py b/climate/tests/api/utils.py new file mode 100644 index 00000000..d6816c0c --- /dev/null +++ b/climate/tests/api/utils.py @@ -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 diff --git a/climate/tests/api/v2/__init__.py b/climate/tests/api/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/climate/tests/api/v2/test_hosts.py b/climate/tests/api/v2/test_hosts.py new file mode 100644 index 00000000..cdde7502 --- /dev/null +++ b/climate/tests/api/v2/test_hosts.py @@ -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 '', " + u"got ''", + 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 '', got ''", + "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) diff --git a/climate/tests/api/v2/test_leases.py b/climate/tests/api/v2/test_leases.py new file mode 100644 index 00000000..817a840e --- /dev/null +++ b/climate/tests/api/v2/test_leases.py @@ -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 '', got ''", + "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) diff --git a/climate/tests/db/sqlalchemy/test_sqlalchemy_api.py b/climate/tests/db/sqlalchemy/test_sqlalchemy_api.py index f5b78ed2..4c729ae8 100644 --- a/climate/tests/db/sqlalchemy/test_sqlalchemy_api.py +++ b/climate/tests/db/sqlalchemy/test_sqlalchemy_api.py @@ -78,8 +78,8 @@ def _get_fake_virt_lease_values(id=_get_fake_lease_uuid(), resource_id=None): return {'id': id, 'name': name, - 'user_id': 'fake', - 'tenant_id': 'fake', + 'user_id': _get_fake_random_uuid(), + 'tenant_id': _get_fake_random_uuid(), 'start_date': start_date, 'end_date': end_date, 'trust': 'trust', diff --git a/climate/tests/fake_policy.py b/climate/tests/fake_policy.py index cbd96773..7080b039 100644 --- a/climate/tests/fake_policy.py +++ b/climate/tests/fake_policy.py @@ -22,7 +22,18 @@ policy_data = """ "admin_api": "rule:admin", "climate:leases": "rule:admin_or_owner", + "climate:oshosts": "rule:admin_api", + "climate:leases:get": "rule:admin_or_owner", - "climate:os-hosts": "rule:admin_api" + "climate:leases:create": "rule:admin_or_owner", + "climate:leases:delete": "rule:admin_or_owner", + "climate:leases:update": "rule:admin_or_owner", + + "climate:plugins:get": "@", + + "climate:oshosts:get": "rule:admin_api", + "climate:oshosts:create": "rule:admin_api", + "climate:oshosts:delete": "rule:admin_api", + "climate:oshosts:update": "rule:admin_api" } """ diff --git a/climate/tests/test_policy.py b/climate/tests/test_policy.py index e3daa060..40de21c3 100644 --- a/climate/tests/test_policy.py +++ b/climate/tests/test_policy.py @@ -99,14 +99,14 @@ class ClimatePolicyTestCase(tests.TestCase): def test_adminpolicy(self): target = {'user_id': self.context.user_id, 'tenant_id': self.context.tenant_id} - action = "climate:os-hosts" + action = "climate:oshosts" self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce, self.context, action, target) def test_elevatedpolicy(self): target = {'user_id': self.context.user_id, 'tenant_id': self.context.tenant_id} - action = "climate:os-hosts" + action = "climate:oshosts" self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce, self.context, action, target) elevated_context = self.context.elevated() diff --git a/doc/source/_static/toggle.css b/doc/source/_static/toggle.css new file mode 100644 index 00000000..1aa09085 --- /dev/null +++ b/doc/source/_static/toggle.css @@ -0,0 +1,9 @@ +dl.toggle dt { + background-color: #eeffcc; + border: 1px solid #ac9; + display: inline; +} + +dl.toggle dd { + display: none; +} diff --git a/doc/source/_static/toggle.js b/doc/source/_static/toggle.js new file mode 100644 index 00000000..6d8384f1 --- /dev/null +++ b/doc/source/_static/toggle.js @@ -0,0 +1,9 @@ +/*global $,document*/ +$(document).ready(function () { + "use strict"; + $("dl.toggle > dt").click( + function (event) { + $(this).next().toggle(250); + } + ); +}); \ No newline at end of file diff --git a/doc/source/conf.py b/doc/source/conf.py index 9da8d6d7..b9374415 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -26,8 +26,18 @@ import os # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.todo', 'sphinx.ext.coverage', - 'sphinx.ext.pngmath', 'sphinx.ext.viewcode', 'sphinxcontrib.httpdomain'] +extensions = ['sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.pngmath', + 'sphinx.ext.viewcode', + 'sphinxcontrib.httpdomain', + 'sphinxcontrib.pecanwsme.rest', + 'wsmeext.sphinxext', + ] + +wsme_protocols = ['restjson', 'restxml'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -42,8 +52,8 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = u'climate' -copyright = u'2013, Mirantis Inc.' +project = u'Climate' +copyright = u'2013, Mirantis Inc.;2014, Bull.' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/doc/source/restapi/index.rst b/doc/source/restapi/index.rst index 5071bcc0..27d1d07a 100644 --- a/doc/source/restapi/index.rst +++ b/doc/source/restapi/index.rst @@ -7,3 +7,4 @@ This page includes documentation for Climate APIs. :maxdepth: 1 rest_api_v1.0 + rest_api_v2 diff --git a/doc/source/restapi/rest_api_v2.rst b/doc/source/restapi/rest_api_v2.rst new file mode 100644 index 00000000..0556c32d --- /dev/null +++ b/doc/source/restapi/rest_api_v2.rst @@ -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: diff --git a/etc/climate/climate.conf.sample b/etc/climate/climate.conf.sample index 63916338..c2f6510c 100644 --- a/etc/climate/climate.conf.sample +++ b/etc/climate/climate.conf.sample @@ -204,6 +204,10 @@ # ZeroMQ, a valid hostname, FQDN, or IP address (string value) #host=climate +# Log request/response exchange details: environ, headers and +# bodies (boolean value) +#log_exchange=false + # Protocol used to access OpenStack Identity service (string # value) #os_auth_protocol=http @@ -231,18 +235,21 @@ # -# Options defined in climate.api.v1.app +# Options defined in climate.api.v2.app # -# Log request/response exchange details: environ, headers and -# bodies (boolean value) -#log_exchange=false +# The strategy to use for auth: noauth or keystone. (string +# value) +#auth_strategy=keystone # # Options defined in climate.cmd.api # +# Deploy the v1 API. (boolean value) +#enable_v1_api=true + # Port that will be used to listen on (integer value) #port=1234 diff --git a/requirements.txt b/requirements.txt index e8ab7160..c5626042 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,10 @@ posix_ipc python-novaclient>=2.17.0 netaddr>=0.7.6 python-keystoneclient>=0.7.0 +pecan>=0.4.5 Routes>=1.12.3 SQLAlchemy>=0.7.8,<=0.9.99 stevedore>=0.14 WebOb>=1.2.3 +WSME>=0.6 + diff --git a/test-requirements.txt b/test-requirements.txt index 3baeba6e..99ca5715 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -15,3 +15,4 @@ testrepository>=0.0.18 testtools>=0.9.34 coverage>=3.6 pylint==0.25.2 +sphinxcontrib-pecanwsme>=0.6