From 1f21760064f7d848a0bb62695de64857cacb36eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Albert?= Date: Mon, 2 Mar 2015 16:53:42 +0100 Subject: [PATCH] Split the api controllers and resources - Move the v1 controllers to a separate controllers package - Split each controller into separate packages - Move the v1 resources to a datamodels package - Split each resource into separate packages - Get the RPC client and RPC target from a global object rather than from the request context - Changed the base Controller API (breaking change) Co-Authored-By: Guillaume Espanel Change-Id: Ic73d9e85dbfd637b40dc45111e21dc7c800b6ed0 --- cloudkitty/api/app.py | 12 +- cloudkitty/api/config.py | 2 +- cloudkitty/api/controllers/v1.py | 366 ------------------ cloudkitty/api/{controllers => }/root.py | 6 +- .../api/{controllers => v1}/__init__.py | 0 cloudkitty/api/v1/controllers/__init__.py | 34 ++ cloudkitty/api/v1/controllers/billing.py | 164 ++++++++ cloudkitty/api/v1/controllers/collector.py | 83 ++++ cloudkitty/api/v1/controllers/report.py | 61 +++ cloudkitty/api/v1/controllers/storage.py | 74 ++++ cloudkitty/api/v1/datamodels/__init__.py | 0 cloudkitty/api/v1/datamodels/billing.py | 101 +++++ cloudkitty/api/v1/datamodels/collector.py | 41 ++ cloudkitty/api/v1/datamodels/storage.py | 56 +++ cloudkitty/api/{controllers => v1}/types.py | 0 cloudkitty/billing/__init__.py | 182 +++------ cloudkitty/billing/hash/__init__.py | 49 +-- cloudkitty/billing/noop.py | 21 +- cloudkitty/rpc.py | 38 ++ doc/source/webapi/billing/hashmap.rst | 5 +- doc/source/webapi/root.rst | 8 +- doc/source/webapi/v1.rst | 35 +- 22 files changed, 745 insertions(+), 593 deletions(-) delete mode 100644 cloudkitty/api/controllers/v1.py rename cloudkitty/api/{controllers => }/root.py (96%) rename cloudkitty/api/{controllers => v1}/__init__.py (100%) create mode 100644 cloudkitty/api/v1/controllers/__init__.py create mode 100644 cloudkitty/api/v1/controllers/billing.py create mode 100644 cloudkitty/api/v1/controllers/collector.py create mode 100644 cloudkitty/api/v1/controllers/report.py create mode 100644 cloudkitty/api/v1/controllers/storage.py create mode 100644 cloudkitty/api/v1/datamodels/__init__.py create mode 100644 cloudkitty/api/v1/datamodels/billing.py create mode 100644 cloudkitty/api/v1/datamodels/collector.py create mode 100644 cloudkitty/api/v1/datamodels/storage.py rename cloudkitty/api/{controllers => v1}/types.py (100%) create mode 100644 cloudkitty/rpc.py diff --git a/cloudkitty/api/app.py b/cloudkitty/api/app.py index e51eb444..0ebef575 100644 --- a/cloudkitty/api/app.py +++ b/cloudkitty/api/app.py @@ -19,18 +19,13 @@ import os from wsgiref import simple_server from oslo.config import cfg -try: - import oslo_messaging as messaging -except ImportError: - from oslo import messaging from paste import deploy import pecan from cloudkitty.api import config as api_config from cloudkitty.api import hooks -from cloudkitty.common import rpc -from cloudkitty import config # noqa from cloudkitty.openstack.common import log as logging +from cloudkitty import rpc from cloudkitty import storage @@ -69,10 +64,7 @@ def setup_app(pecan_config=None, extra_hooks=None): app_conf = get_pecan_config() - target = messaging.Target(topic='cloudkitty', - version='1.0') - - client = rpc.get_client(target) + client = rpc.get_client() storage_backend = storage.get_storage() diff --git a/cloudkitty/api/config.py b/cloudkitty/api/config.py index edddb058..105c0ac4 100644 --- a/cloudkitty/api/config.py +++ b/cloudkitty/api/config.py @@ -16,7 +16,7 @@ from cloudkitty import config # noqa # Pecan Application Configurations app = { - 'root': 'cloudkitty.api.controllers.root.RootController', + 'root': 'cloudkitty.api.root.RootController', 'modules': ['cloudkitty.api'], 'static_root': '%(confdir)s/public', 'template_path': '%(confdir)s/templates', diff --git a/cloudkitty/api/controllers/v1.py b/cloudkitty/api/controllers/v1.py deleted file mode 100644 index e558c886..00000000 --- a/cloudkitty/api/controllers/v1.py +++ /dev/null @@ -1,366 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# 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. -# -# @author: Stéphane Albert -# -import datetime -import decimal - -from oslo.config import cfg -import pecan -from pecan import rest -from stevedore import extension -from wsme import types as wtypes -import wsmeext.pecan as wsme_pecan - -from cloudkitty.api.controllers import types as cktypes -from cloudkitty import config # noqa -from cloudkitty.db import api as db_api -from cloudkitty.openstack.common import log as logging -from cloudkitty import storage as ck_storage -from cloudkitty import utils as ck_utils - -CONF = cfg.CONF -LOG = logging.getLogger(__name__) - -CLOUDKITTY_SERVICES = wtypes.Enum(wtypes.text, - *CONF.collect.services) - - -class ResourceDescriptor(wtypes.Base): - """Type describing a resource in CloudKitty. - - """ - - service = CLOUDKITTY_SERVICES - """Name of the service.""" - - # FIXME(sheeprine): values should be dynamic - # Testing with ironic dynamic type - desc = {wtypes.text: cktypes.MultiType(wtypes.text, int, float, dict)} - """Description of the resources parameters.""" - - volume = decimal.Decimal - """Number of resources.""" - - def to_json(self): - res_dict = {} - res_dict[self.service] = [{'desc': self.desc, - 'vol': {'qty': self.volume, - 'unit': 'undef'} - }] - return res_dict - - @classmethod - def sample(cls): - sample = cls(service='compute', - desc={ - 'image_id': 'a41fba37-2429-4f15-aa00-b5bc4bf557bf' - }, - volume=decimal.Decimal(1)) - return sample - - -class RatedResource(ResourceDescriptor): - """Represents a rated CloudKitty resource.""" - - billing = decimal.Decimal - - def to_json(self): - res_dict = super(RatedResource, self).to_json() - res_dict['billing'] = self.billing - return res_dict - - -class ServiceToCollectorMapping(wtypes.Base): - """Type describing a service to collector mapping. - - """ - - service = wtypes.text - """Name of the service.""" - - collector = wtypes.text - """Name of the collector.""" - - def to_json(self): - res_dict = {} - res_dict[self.service] = self.collector - return res_dict - - @classmethod - def sample(cls): - sample = cls(service='compute', - collector='ceilometer') - return sample - - -class MappingController(rest.RestController): - """REST Controller managing service to collector mapping. - - """ - - def __init__(self): - self._db = db_api.get_instance().get_service_to_collector_mapping() - - @wsme_pecan.wsexpose([wtypes.text]) - def get_all(self): - """Return the list of every services mapped. - - :return: List of every services mapped. - """ - return [mapping.service for mapping in self._db.list_services()] - - @wsme_pecan.wsexpose(ServiceToCollectorMapping, wtypes.text) - def get_one(self, service): - """Return a service to collector mapping. - - :param service: Name of the service to filter on. - """ - try: - return self._db.get_mapping(service) - except db_api.NoSuchMapping as e: - pecan.abort(400, str(e)) - pecan.response.status = 200 - - @wsme_pecan.wsexpose(ServiceToCollectorMapping, - wtypes.text, - body=wtypes.text) - def post(self, service, collector): - """Create or modify a mapping. - - :param service: Name of the service to map a collector to. - :param collector: Name of the collector. - """ - return self._db.set_mapping(service, collector) - - @wsme_pecan.wsexpose(None, body=wtypes.text) - def delete(self, service): - """Delete a mapping. - - :param service: Name of the service to suppress the mapping from. - """ - try: - self._db.delete_mapping(service) - except db_api.NoSuchMapping as e: - pecan.abort(400, str(e)) - pecan.response.status = 204 - - -class CollectorController(rest.RestController): - """REST Controller managing collector modules. - - """ - - mapping = MappingController() - - _custom_actions = { - 'state': ['GET', 'POST'] - } - - def __init__(self): - self._db = db_api.get_instance().get_module_enable_state() - - @wsme_pecan.wsexpose(bool, wtypes.text) - def state(self, collector): - """Query the enable state of a collector. - - :param collector: Name of the collector. - :return: State of the collector. - """ - return self._db.get_state('collector_{}'.format(collector)) - - @wsme_pecan.wsexpose(bool, wtypes.text, body=bool) - def post_state(self, collector, state): - """Set the enable state of a collector. - - :param collector: Name of the collector. - :param state: New state for the collector. - :return: State of the collector. - """ - return self._db.set_state('collector_{}'.format(collector), state) - - -class ModulesController(rest.RestController): - """REST Controller managing billing modules. - - """ - - def __init__(self): - self.extensions = extension.ExtensionManager( - 'cloudkitty.billing.processors', - # FIXME(sheeprine): don't want to load it here as we just need the - # controller - invoke_on_load=True - ) - self.expose_modules() - - def expose_modules(self): - """Load billing modules to expose API controllers. - - """ - for ext in self.extensions: - # FIXME(sheeprine): we should notify two modules with same name - if not hasattr(self, ext.name): - setattr(self, ext.name, ext.obj.controller()) - - @wsme_pecan.wsexpose([wtypes.text]) - def get(self): - """Return the list of loaded modules. - - :return: Name of every loaded modules. - """ - return [ext for ext in self.extensions.names()] - - -class BillingController(rest.RestController): - - _custom_actions = { - 'quote': ['POST'], - } - - modules = ModulesController() - - @wsme_pecan.wsexpose(float, body=[ResourceDescriptor]) - def quote(self, res_data): - """Get an instant quote based on multiple resource descriptions. - - :param res_data: List of resource descriptions. - :return: Total price for these descriptions. - """ - client = pecan.request.rpc_client.prepare(namespace='billing') - res_dict = {} - for res in res_data: - if res.service not in res_dict: - res_dict[res.service] = [] - json_data = res.to_json() - res_dict[res.service].extend(json_data[res.service]) - - res = client.call({}, 'quote', res_data=[{'usage': res_dict}]) - return res - - -class ReportController(rest.RestController): - """REST Controller managing the reporting. - - """ - - _custom_actions = { - 'total': ['GET'], - 'tenants': ['GET'] - } - - @wsme_pecan.wsexpose([wtypes.text], - datetime.datetime, - datetime.datetime) - def tenants(self, begin=None, end=None): - """Return the list of rated tenants. - - """ - storage = pecan.request.storage_backend - tenants = storage.get_tenants(begin, end) - return tenants - - @wsme_pecan.wsexpose(float, - datetime.datetime, - datetime.datetime, - wtypes.text) - def total(self, begin=None, end=None, tenant_id=None): - """Return the amount to pay for a given period. - - """ - storage = pecan.request.storage_backend - # FIXME(sheeprine): We should filter on user id. - # Use keystone token information by default but make it overridable and - # enforce it by policy engine - total = storage.get_total(begin, end, tenant_id) - return total - - -class DataFrame(wtypes.Base): - """Type describing a stored dataframe.""" - - begin = datetime.datetime - """Begin date for the sample.""" - - end = datetime.datetime - """End date for the sample.""" - - tenant_id = wtypes.text - """Tenant owner of the sample.""" - - resources = [RatedResource] - """A resource list.""" - - def to_json(self): - return {'begin': self.begin, - 'end': self.end, - 'tenant_id': self.tenant_id, - 'resources': self.resources} - - -class StorageController(rest.RestController): - """REST Controller to access stored data frames.""" - - @wsme_pecan.wsexpose([DataFrame], datetime.datetime, datetime.datetime, - wtypes.text) - def get_all(self, begin, end, tenant_id=None): - """Return a list of rated resources for a time period and a tenant. - - :param begin: Start of the period - :param end: End of the period - :return: List of RatedResource objects. - """ - - begin_ts = ck_utils.dt2ts(begin) - end_ts = ck_utils.dt2ts(end) - backend = pecan.request.storage_backend - - try: - frames = backend.get_time_frame(begin_ts, end_ts, - tenant_id=tenant_id) - except ck_storage.NoTimeFrame: - return [] - - ret = [] - for frame in frames: - for service, data_list in frame['usage'].items(): - resources = [] - for data in data_list: - desc = data['desc'] if data['desc'] else {} - price = decimal.Decimal(data['billing']['price']) - resource = RatedResource(service=service, - desc=desc, - volume=data['vol']['qty'], - billing=price) - resources.append(resource) - data_frame = DataFrame( - begin=ck_utils.iso2dt(frame['period']['begin']), - end=ck_utils.iso2dt(frame['period']['end']), - tenant_id=tenant_id, # FIXME - resources=resources) - ret.append(data_frame) - return ret - - -class V1Controller(rest.RestController): - """API version 1 controller. - - """ - - collector = CollectorController() - billing = BillingController() - report = ReportController() - storage = StorageController() diff --git a/cloudkitty/api/controllers/root.py b/cloudkitty/api/root.py similarity index 96% rename from cloudkitty/api/controllers/root.py rename to cloudkitty/api/root.py index 97034a4b..e131a0f5 100644 --- a/cloudkitty/api/controllers/root.py +++ b/cloudkitty/api/root.py @@ -20,7 +20,7 @@ from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan -from cloudkitty.api.controllers import v1 +from cloudkitty.api.v1 import controllers as v1_api from cloudkitty.openstack.common import log as logging LOG = logging.getLogger(__name__) @@ -112,7 +112,7 @@ class RootController(rest.RestController): """ - v1 = v1.V1Controller() + v1 = v1_api.V1Controller() @wsme_pecan.wsexpose([APIVersion]) def get(self): @@ -124,7 +124,7 @@ class RootController(rest.RestController): ver1 = APIVersion( id='v1', status='EXPERIMENTAL', - updated='2014-08-11T16:00:00Z', + updated='2015-03-09T16:00:00Z', links=[ APILink( rel='self', diff --git a/cloudkitty/api/controllers/__init__.py b/cloudkitty/api/v1/__init__.py similarity index 100% rename from cloudkitty/api/controllers/__init__.py rename to cloudkitty/api/v1/__init__.py diff --git a/cloudkitty/api/v1/controllers/__init__.py b/cloudkitty/api/v1/controllers/__init__.py new file mode 100644 index 00000000..4f33713a --- /dev/null +++ b/cloudkitty/api/v1/controllers/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Objectif Libre +# +# 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. +# +# @author: Stéphane Albert +# +from pecan import rest + +from cloudkitty.api.v1.controllers import billing as billing_api +from cloudkitty.api.v1.controllers import collector as collector_api +from cloudkitty.api.v1.controllers import report as report_api +from cloudkitty.api.v1.controllers import storage as storage_api + + +class V1Controller(rest.RestController): + """API version 1 controller. + + """ + + billing = billing_api.BillingController() + collector = collector_api.CollectorController() + report = report_api.ReportController() + storage = storage_api.StorageController() diff --git a/cloudkitty/api/v1/controllers/billing.py b/cloudkitty/api/v1/controllers/billing.py new file mode 100644 index 00000000..f4a2ddb4 --- /dev/null +++ b/cloudkitty/api/v1/controllers/billing.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Objectif Libre +# +# 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. +# +# @author: Stéphane Albert +# +from oslo.config import cfg +import pecan +from pecan import rest +from stevedore import extension +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from cloudkitty.api.v1.datamodels import billing as billing_models +from cloudkitty import config # noqa +from cloudkitty.openstack.common import log as logging + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + + +class ModulesController(rest.RestController): + """REST Controller managing billing modules.""" + + def __init__(self): + self.extensions = extension.ExtensionManager( + 'cloudkitty.billing.processors', + # FIXME(sheeprine): don't want to load it here as we just need the + # controller + invoke_on_load=True + ) + + @wsme_pecan.wsexpose(billing_models.CloudkittyModuleCollection) + def get_all(self): + """return the list of loaded modules. + + :return: name of every loaded modules. + """ + modules_list = [] + for module in self.extensions: + infos = module.obj.module_info.copy() + infos['module_id'] = infos.pop('name') + modules_list.append(billing_models.CloudkittyModule(**infos)) + + return billing_models.CloudkittyModuleCollection( + modules=modules_list + ) + + @wsme_pecan.wsexpose(billing_models.CloudkittyModule, wtypes.text) + def get_one(self, module_id): + """return a module + + :return: CloudKittyModule + """ + try: + module = self.extensions[module_id] + except KeyError: + pecan.abort(404) + infos = module.obj.module_info.copy() + infos['module_id'] = infos.pop('name') + return billing_models.CloudkittyModule(**infos) + + @wsme_pecan.wsexpose(billing_models.CloudkittyModule, + wtypes.text, + body=billing_models.CloudkittyModule, + status_code=302) + def put(self, module_id, module): + """Change the state of a module (enabled/disabled) + + :param module_id: name of the module to modify + :param module: CloudKittyModule object describing the new desired state + ## :return: CloudKittyModule object describing the desired state + """ + try: + self.extensions[module_id].obj.set_state(module.enabled) + except KeyError: + pecan.abort(404) + pecan.response.location = pecan.request.path + + +class UnconfigurableController(rest.RestController): + """This controller raises an error when requested.""" + + @wsme_pecan.wsexpose(None) + def put(self): + self.abort() + + @wsme_pecan.wsexpose(None) + def get(self): + self.abort() + + def abort(self): + pecan.abort(409, "Module is not configurable") + + +class ModulesExposer(rest.RestController): + """REST Controller exposing billing modules. + + This is the controller that exposes the modules own configuration + settings. + """ + + def __init__(self): + self.extensions = extension.ExtensionManager( + 'cloudkitty.billing.processors', + # FIXME(sheeprine): don't want to load it here as we just need the + # controller + invoke_on_load=True + ) + self.expose_modules() + + def expose_modules(self): + """Load billing modules to expose API controllers.""" + for ext in self.extensions: + # FIXME(sheeprine): we should notify two modules with same name + if not hasattr(self, ext.name): + if not ext.obj.config_controller: + ext.obj.config_controller = UnconfigurableController + setattr(self, ext.name, ext.obj.config_controller()) + + +class BillingController(rest.RestController): + """The BillingController is exposed by the API. + + The BillingControler connects the ModulesExposer, ModulesController + and a quote action to the API. + """ + + _custom_actions = { + 'quote': ['POST'], + } + + modules = ModulesController() + module_config = ModulesExposer() + + @wsme_pecan.wsexpose(float, + body=billing_models.CloudkittyResourceCollection) + def quote(self, res_data): + """Get an instant quote based on multiple resource descriptions. + + :param res_data: List of resource descriptions. + :return: Total price for these descriptions. + """ + client = pecan.request.rpc_client.prepare(namespace='billing') + res_dict = {} + for res in res_data.resources: + if res.service not in res_dict: + res_dict[res.service] = [] + json_data = res.to_json() + res_dict[res.service].extend(json_data[res.service]) + + res = client.call({}, 'quote', res_data=[{'usage': res_dict}]) + return res diff --git a/cloudkitty/api/v1/controllers/collector.py b/cloudkitty/api/v1/controllers/collector.py new file mode 100644 index 00000000..e14e5509 --- /dev/null +++ b/cloudkitty/api/v1/controllers/collector.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Objectif Libre +# +# 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. +# +# @author: Stéphane Albert +# +import pecan +from pecan import rest +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from cloudkitty.api.v1.datamodels import collector as collector_models +from cloudkitty.db import api as db_api + + +class MappingController(rest.RestController): + """REST Controller managing service to collector mappings.""" + + def __init__(self): + self._db = db_api.get_instance().get_service_to_collector_mapping() + + @wsme_pecan.wsexpose([wtypes.text]) + def get_all(self): + """Return the list of every services mapped. + + :return: List of every services mapped. + """ + return [mapping.service for mapping in self._db.list_services()] + + @wsme_pecan.wsexpose(collector_models.ServiceToCollectorMapping, + wtypes.text) + def get_one(self, service): + """Return a service to collector mapping. + + :param service: Name of the service to filter on. + """ + try: + return self._db.get_mapping(service) + except db_api.NoSuchMapping as e: + pecan.abort(400, str(e)) + + +class CollectorController(rest.RestController): + """REST Controller managing collector modules.""" + + mapping = MappingController() + + _custom_actions = { + 'state': ['GET', 'POST'] + } + + def __init__(self): + self._db = db_api.get_instance().get_module_enable_state() + + @wsme_pecan.wsexpose(bool, wtypes.text) + def state(self, collector): + """Query the enable state of a collector. + + :param collector: Name of the collector. + :return: State of the collector. + """ + return self._db.get_state('collector_{}'.format(collector)) + + @wsme_pecan.wsexpose(bool, wtypes.text, body=bool) + def post_state(self, collector, state): + """Set the enable state of a collector. + + :param collector: Name of the collector. + :param state: New state for the collector. + :return: State of the collector. + """ + return self._db.set_state('collector_{}'.format(collector), state) diff --git a/cloudkitty/api/v1/controllers/report.py b/cloudkitty/api/v1/controllers/report.py new file mode 100644 index 00000000..750c683a --- /dev/null +++ b/cloudkitty/api/v1/controllers/report.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Objectif Libre +# +# 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. +# +# @author: Stéphane Albert +# +import datetime +import decimal + +import pecan +from pecan import rest +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + + +class ReportController(rest.RestController): + """REST Controller managing the reporting. + + """ + + _custom_actions = { + 'total': ['GET'], + 'tenants': ['GET'] + } + + @wsme_pecan.wsexpose([wtypes.text], + datetime.datetime, + datetime.datetime) + def tenants(self, begin=None, end=None): + """Return the list of rated tenants. + + """ + storage = pecan.request.storage_backend + tenants = storage.get_tenants(begin, end) + return tenants + + @wsme_pecan.wsexpose(decimal.Decimal, + datetime.datetime, + datetime.datetime, + wtypes.text) + def total(self, begin=None, end=None, tenant_id=None): + """Return the amount to pay for a given period. + + """ + storage = pecan.request.storage_backend + # FIXME(sheeprine): We should filter on user id. + # Use keystone token information by default but make it overridable and + # enforce it by policy engine + total = storage.get_total(begin, end, tenant_id) + return total diff --git a/cloudkitty/api/v1/controllers/storage.py b/cloudkitty/api/v1/controllers/storage.py new file mode 100644 index 00000000..98745c44 --- /dev/null +++ b/cloudkitty/api/v1/controllers/storage.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Objectif Libre +# +# 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. +# +# @author: Stéphane Albert +# +import datetime +import decimal + +import pecan +from pecan import rest +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from cloudkitty.api.v1.datamodels import storage as storage_models +from cloudkitty import storage as ck_storage +from cloudkitty import utils as ck_utils + + +class StorageController(rest.RestController): + """REST Controller to access stored data frames.""" + + @wsme_pecan.wsexpose([storage_models.DataFrame], + datetime.datetime, + datetime.datetime, + wtypes.text) + def get_all(self, begin, end, tenant_id=None): + """Return a list of rated resources for a time period and a tenant. + + :param begin: Start of the period + :param end: End of the period + :return: List of RatedResource objects. + """ + + begin_ts = ck_utils.dt2ts(begin) + end_ts = ck_utils.dt2ts(end) + backend = pecan.request.storage_backend + try: + frames = backend.get_time_frame(begin_ts, end_ts, + tenant_id=tenant_id) + except ck_storage.NoTimeFrame: + return [] + + ret = [] + for frame in frames: + for service, data_list in frame['usage'].items(): + resources = [] + for data in data_list: + desc = data['desc'] if data['desc'] else {} + price = decimal.Decimal(data['billing']['price']) + resource = storage_models.RatedResource( + service=service, + desc=desc, + volume=data['vol']['qty'], + billing=price) + resources.append(resource) + data_frame = storage_models.DataFrame( + begin=ck_utils.iso2dt(frame['period']['begin']), + end=ck_utils.iso2dt(frame['period']['end']), + tenant_id=tenant_id, # FIXME + resources=resources) + ret.append(data_frame) + return ret diff --git a/cloudkitty/api/v1/datamodels/__init__.py b/cloudkitty/api/v1/datamodels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cloudkitty/api/v1/datamodels/billing.py b/cloudkitty/api/v1/datamodels/billing.py new file mode 100644 index 00000000..ad412709 --- /dev/null +++ b/cloudkitty/api/v1/datamodels/billing.py @@ -0,0 +1,101 @@ + +# -*- coding: utf-8 -*- +# Copyright 2014 Objectif Libre +# +# 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. +# +# @author: Stéphane Albert +# +import decimal + +from oslo.config import cfg +from wsme import types as wtypes + +from cloudkitty.api.v1 import types as cktypes +from cloudkitty import config # noqa + +CONF = cfg.CONF +CLOUDKITTY_SERVICES = wtypes.Enum(wtypes.text, + *CONF.collect.services) + + +class CloudkittyResource(wtypes.Base): + """Type describing a resource in CloudKitty. + + """ + + service = CLOUDKITTY_SERVICES + """Name of the service.""" + + # FIXME(sheeprine): values should be dynamic + # Testing with ironic dynamic type + desc = {wtypes.text: cktypes.MultiType(wtypes.text, int, float, dict)} + """Description of the resources parameters.""" + + volume = decimal.Decimal + """Volume of resources.""" + + def to_json(self): + res_dict = {} + res_dict[self.service] = [{'desc': self.desc, + 'vol': {'qty': self.volume, + 'unit': 'undef'} + }] + return res_dict + + @classmethod + def sample(cls): + sample = cls(service='compute', + desc={ + 'image_id': 'a41fba37-2429-4f15-aa00-b5bc4bf557bf' + }, + volume=decimal.Decimal(1)) + return sample + + +class CloudkittyResourceCollection(wtypes.Base): + """A list of CloudKittyResources.""" + + resources = [CloudkittyResource] + + +class CloudkittyModule(wtypes.Base): + """A billing extension summary + + """ + + module_id = wtypes.wsattr(wtypes.text, mandatory=True) + """Name of the extension.""" + + description = wtypes.wsattr(wtypes.text, mandatory=False) + """Short description of the extension.""" + + enabled = wtypes.wsattr(bool, default=False) + """Extension status.""" + + hot_config = wtypes.wsattr(bool, default=False, name='hot-config') + """On-the-fly configuration support.""" + + @classmethod + def sample(cls): + sample = cls(name='example', + description='Sample extension.', + enabled=True, + hot_config=False) + return sample + + +class CloudkittyModuleCollection(wtypes.Base): + """A list of billing extensions.""" + + modules = [CloudkittyModule] diff --git a/cloudkitty/api/v1/datamodels/collector.py b/cloudkitty/api/v1/datamodels/collector.py new file mode 100644 index 00000000..50705ee6 --- /dev/null +++ b/cloudkitty/api/v1/datamodels/collector.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Objectif Libre +# +# 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. +# +# @author: Stéphane Albert +# +from wsme import types as wtypes + + +class ServiceToCollectorMapping(wtypes.Base): + """Type describing a service to collector mapping. + + """ + + service = wtypes.text + """Name of the service.""" + + collector = wtypes.text + """Name of the collector.""" + + def to_json(self): + res_dict = {} + res_dict[self.service] = self.collector + return res_dict + + @classmethod + def sample(cls): + sample = cls(service='compute', + collector='ceilometer') + return sample diff --git a/cloudkitty/api/v1/datamodels/storage.py b/cloudkitty/api/v1/datamodels/storage.py new file mode 100644 index 00000000..0c968f7a --- /dev/null +++ b/cloudkitty/api/v1/datamodels/storage.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Objectif Libre +# +# 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. +# +# @author: Stéphane Albert +# +import datetime +import decimal + +from wsme import types as wtypes + +from cloudkitty.api.v1.datamodels import billing as billing_resources + + +class RatedResource(billing_resources.CloudkittyResource): + """Represents a rated CloudKitty resource.""" + + billing = decimal.Decimal + + def to_json(self): + res_dict = super(RatedResource, self).to_json() + res_dict['billing'] = self.billing + return res_dict + + +class DataFrame(wtypes.Base): + """Type describing a stored dataframe.""" + + begin = datetime.datetime + """Begin date for the sample.""" + + end = datetime.datetime + """End date for the sample.""" + + tenant_id = wtypes.text + """Tenant owner of the sample.""" + + resources = [RatedResource] + """A resource list.""" + + def to_json(self): + return {'begin': self.begin, + 'end': self.end, + 'tenant_id': self.tenant_id, + 'resources': self.resources} diff --git a/cloudkitty/api/controllers/types.py b/cloudkitty/api/v1/types.py similarity index 100% rename from cloudkitty/api/controllers/types.py rename to cloudkitty/api/v1/types.py diff --git a/cloudkitty/billing/__init__.py b/cloudkitty/billing/__init__.py index 556d093e..eb314ba2 100644 --- a/cloudkitty/billing/__init__.py +++ b/cloudkitty/billing/__init__.py @@ -17,148 +17,34 @@ # import abc -import pecan -from pecan import rest import six -from wsme import types as wtypes -import wsmeext.pecan as wsme_pecan from cloudkitty.db import api as db_api - - -class BillingModuleNotConfigurable(Exception): - def __init__(self, module): - self.module = module - super(BillingModuleNotConfigurable, self).__init__( - 'Module %s not configurable.' % module) - - -class ExtensionSummary(wtypes.Base): - """A billing extension summary - - """ - - name = wtypes.wsattr(wtypes.text, mandatory=True) - """Name of the extension.""" - - description = wtypes.text - """Short description of the extension.""" - - enabled = wtypes.wsattr(bool, default=False) - """Extension status.""" - - hot_config = wtypes.wsattr(bool, default=False, name='hot-config') - """On-the-fly configuration support.""" - - @classmethod - def sample(cls): - sample = cls(name='example', - description='Sample extension.', - enabled=True, - hot_config=False) - return sample - - -@six.add_metaclass(abc.ABCMeta) -class BillingEnableController(rest.RestController): - """REST Controller to enable or disable a billing module. - - """ - - @wsme_pecan.wsexpose(bool) - def get(self): - """Get module status - - """ - api = db_api.get_instance() - module_db = api.get_module_enable_state() - return module_db.get_state(self.module_name) or False - - @wsme_pecan.wsexpose(bool, body=bool) - def put(self, state): - """Set module status - - :param state: State to set. - :return: New state set for the module. - """ - api = db_api.get_instance() - module_db = api.get_module_enable_state() - client = pecan.request.rpc_client.prepare(namespace='billing', - fanout=True) - if state: - operation = 'enable_module' - else: - operation = 'disable_module' - client.cast({}, operation, name=self.module_name) - return module_db.set_state(self.module_name, state) - - -@six.add_metaclass(abc.ABCMeta) -class BillingConfigController(rest.RestController): - """REST Controller managing internal configuration of billing modules. - - """ - - def notify_reload(self): - client = pecan.request.rpc_client.prepare(namespace='billing', - fanout=True) - client.cast({}, 'reload_module', name=self.module_name) - - def _not_configurable(self): - try: - raise BillingModuleNotConfigurable(self.module_name) - except BillingModuleNotConfigurable as e: - pecan.abort(400, str(e)) - - @wsme_pecan.wsexpose() - def get(self): - """Get current module configuration - - """ - self._not_configurable() - - @wsme_pecan.wsexpose() - def put(self): - """Set current module configuration - - """ - self._not_configurable() - - -@six.add_metaclass(abc.ABCMeta) -class BillingController(rest.RestController): - """REST Controller used to manage billing system. - - """ - - def __init__(self): - if not hasattr(self, 'config'): - self.config = BillingConfigController() - if not hasattr(self, 'enabled'): - self.enabled = BillingEnableController() - if hasattr(self, 'module_name'): - self.config.module_name = self.module_name - self.enabled.module_name = self.module_name - - @wsme_pecan.wsexpose(ExtensionSummary) - def get_all(self): - """Get extension summary. - - """ - extension_summary = ExtensionSummary(**self.get_module_info()) - return extension_summary - - @abc.abstractmethod - def get_module_info(self): - """Get module informations - - """ +from cloudkitty import rpc @six.add_metaclass(abc.ABCMeta) class BillingProcessorBase(object): + """Provides the Cloudkitty integration code to the billing processors. - controller = BillingController + Every billing processor shoud sublclass this and override at least + module_name, description. + + config_controller can be left at None to use the default one. + """ + + module_name = None + description = None + config_controller = None + hot_config = False + + @property + def module_info(self): + return { + 'name': self.module_name, + 'description': self.description, + 'hot_config': self.hot_config, + 'enabled': self.enabled, } def __init__(self, tenant_id=None): self._tenant_id = tenant_id @@ -170,11 +56,22 @@ class BillingProcessorBase(object): :returns: bool if module is enabled """ - @abc.abstractmethod - def reload_config(self): - """Trigger configuration reload + def set_state(self, enabled): + """Enable or disable a module + :param enabled: (bool) The state to put the module in. + :return: bool """ + api = db_api.get_instance() + module_db = api.get_module_enable_state() + client = rpc.get_client().prepare(namespace='billing', + fanout=True) + if enabled: + operation = 'enable_module' + else: + operation = 'disable_module' + client.cast({}, operation, name=self.module_name) + return module_db.set_state(self.module_name, enabled) @abc.abstractmethod def process(self, data): @@ -184,3 +81,14 @@ class BillingProcessorBase(object): resources. :type data: dict(str:?) """ + + @abc.abstractmethod + def reload_config(self): + """Trigger configuration reload + + """ + + def notify_reload(self): + client = rpc.get_rpc_client().prepare(namespace='billing', + fanout=True) + client.cast({}, 'reload_module', name=self.module_name) diff --git a/cloudkitty/billing/hash/__init__.py b/cloudkitty/billing/hash/__init__.py index b5e96777..efa0d0ac 100644 --- a/cloudkitty/billing/hash/__init__.py +++ b/cloudkitty/billing/hash/__init__.py @@ -16,6 +16,7 @@ # @author: Stéphane Albert # import pecan +from pecan import rest from pecan import routing from wsme import types as wtypes import wsmeext.pecan as wsme_pecan @@ -44,7 +45,19 @@ class Mapping(wtypes.Base): return sample -class BasicHashMapConfigController(billing.BillingConfigController): +class BasicHashMapConfigController(rest.RestController): + """RestController for hashmap's configuration.""" + + _custom_actions = { + 'types': ['GET'] + } + + @wsme_pecan.wsexpose([wtypes.text]) + def get_types(self): + """Return the list of every mapping type available. + + """ + return MAP_TYPE.values @pecan.expose() def _route(self, args, request=None): @@ -194,37 +207,13 @@ class BasicHashMapConfigController(billing.BillingConfigController): pecan.response.status = 204 -class BasicHashMapController(billing.BillingController): - - module_name = 'hashmap' - - _custom_actions = { - 'types': ['GET'] - } - - config = BasicHashMapConfigController() - - def get_module_info(self): - module = BasicHashMap() - infos = { - 'name': self.module_name, - 'description': 'Basic hashmap billing module.', - 'enabled': module.enabled, - 'hot_config': True, - } - return infos - - @wsme_pecan.wsexpose([wtypes.text]) - def get_types(self): - """Return the list of every mapping type available. - - """ - return MAP_TYPE.values - - class BasicHashMap(billing.BillingProcessorBase): - controller = BasicHashMapController + module_name = 'hashmap' + description = 'Basic hashmap billing module.' + hot_config = True + config_controller = BasicHashMapConfigController + db_api = api.get_instance() def __init__(self, tenant_id=None): diff --git a/cloudkitty/billing/noop.py b/cloudkitty/billing/noop.py index 7575c31c..87c6ecd5 100644 --- a/cloudkitty/billing/noop.py +++ b/cloudkitty/billing/noop.py @@ -18,27 +18,10 @@ from cloudkitty import billing -class NoopController(billing.BillingController): - - module_name = 'noop' - - def get_module_info(self): - module = Noop() - infos = { - 'name': self.module_name, - 'description': 'Dummy test module.', - 'enabled': module.enabled, - 'hot_config': False, - } - return infos - - class Noop(billing.BillingProcessorBase): - controller = NoopController - - def __init__(self, tenant_id=None): - super(Noop, self).__init__(tenant_id) + module_name = "noop" + description = 'Dummy test module.' @property def enabled(self): diff --git a/cloudkitty/rpc.py b/cloudkitty/rpc.py new file mode 100644 index 00000000..4edbde0e --- /dev/null +++ b/cloudkitty/rpc.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Objectif Libre +# +# 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. +# +# @author: Guillaume Espanel +# +from oslo import messaging + +from cloudkitty.common import rpc + + +_RPC_CLIENT = None +_RPC_TARGET = None + + +def get_target(): + global _RPC_TARGET + if _RPC_TARGET is None: + _RPC_TARGET = messaging.Target(topic='cloudkitty', version=1.0) + return _RPC_TARGET + + +def get_client(): + global _RPC_CLIENT + if _RPC_CLIENT is None: + _RPC_CLIENT = rpc.get_client(get_target()) + return _RPC_CLIENT diff --git a/doc/source/webapi/billing/hashmap.rst b/doc/source/webapi/billing/hashmap.rst index 4c84c50c..5ecaf3f6 100644 --- a/doc/source/webapi/billing/hashmap.rst +++ b/doc/source/webapi/billing/hashmap.rst @@ -2,11 +2,8 @@ HashMap Module REST API ======================= -.. rest-controller:: cloudkitty.billing.hash:BasicHashMapController - :webprefix: /v1/billing/modules/hashmap - .. rest-controller:: cloudkitty.billing.hash:BasicHashMapConfigController - :webprefix: /v1/billing/modules/hashmap/config + :webprefix: /v1/billing/module_config/hashmap .. http:get:: /v1/billing/hashmap/modules/config/(service)/(field)/(key) diff --git a/doc/source/webapi/root.rst b/doc/source/webapi/root.rst index d08a2e44..d602b4f8 100644 --- a/doc/source/webapi/root.rst +++ b/doc/source/webapi/root.rst @@ -2,15 +2,15 @@ CloudKitty REST API (root) ========================== -.. rest-controller:: cloudkitty.api.controllers.root:RootController +.. rest-controller:: cloudkitty.api.root:RootController :webprefix: / / .. Dirty hack till the bug is fixed so we can specify root path -.. autotype:: cloudkitty.api.controllers.root.APILink +.. autotype:: cloudkitty.api.root.APILink :members: -.. autotype:: cloudkitty.api.controllers.root.APIMediaType +.. autotype:: cloudkitty.api.root.APIMediaType :members: -.. autotype:: cloudkitty.api.controllers.root.APIVersion +.. autotype:: cloudkitty.api.root.APIVersion :members: diff --git a/doc/source/webapi/v1.rst b/doc/source/webapi/v1.rst index eef2435d..4c579a83 100644 --- a/doc/source/webapi/v1.rst +++ b/doc/source/webapi/v1.rst @@ -5,53 +5,50 @@ CloudKitty REST API (v1) Collector ========= -.. rest-controller:: cloudkitty.api.controllers.v1:CollectorController +.. rest-controller:: cloudkitty.api.v1.controllers.collector:CollectorController :webprefix: /v1/collector -.. rest-controller:: cloudkitty.api.controllers.v1:MappingController +.. rest-controller:: cloudkitty.api.v1.controllers.collector:MappingController :webprefix: /v1/collector/mapping -.. autotype:: cloudkitty.api.controllers.v1.MetricToCollectorMapping - :members: - Billing ======= -.. rest-controller:: cloudkitty.billing:BillingEnableController - :webprefix: /v1/billing/modules/(module)/enabled - -.. rest-controller:: cloudkitty.billing:BillingConfigController - :webprefix: /v1/billing/modules/(module)/config - -.. rest-controller:: cloudkitty.api.controllers.v1:ModulesController +.. rest-controller:: cloudkitty.api.v1.controllers.billing:ModulesController :webprefix: /v1/billing/modules -.. rest-controller:: cloudkitty.api.controllers.v1:BillingController +.. rest-controller:: cloudkitty.api.v1.controllers.billing:BillingController :webprefix: /v1/billing -.. autotype:: cloudkitty.billing.ExtensionSummary +.. autotype:: cloudkitty.api.v1.datamodels.billing.CloudkittyModule :members: -.. autotype:: cloudkitty.api.controllers.v1.ResourceDescriptor +.. autotype:: cloudkitty.api.v1.datamodels.billing.CloudkittyModuleCollection + :members: + +.. autotype:: cloudkitty.api.v1.datamodels.billing.CloudkittyResource + :members: + +.. autotype:: cloudkitty.api.v1.datamodels.billing.CloudkittyResourceCollection :members: Report ====== -.. rest-controller:: cloudkitty.api.controllers.v1:ReportController +.. rest-controller:: cloudkitty.api.v1.controllers.report:ReportController :webprefix: /v1/report Storage ======= -.. rest-controller:: cloudkitty.api.controllers.v1:StorageController +.. rest-controller:: cloudkitty.api.v1.controllers.storage:StorageController :webprefix: /v1/storage -.. autotype:: cloudkitty.api.controllers.v1.DataFrame +.. autotype:: cloudkitty.api.v1.datamodels.storage.DataFrame :members: -.. autotype:: cloudkitty.api.controllers.v1.RatedResource +.. autotype:: cloudkitty.api.v1.datamodels.storage.RatedResource :members: