diff --git a/cloudkitty/api/v1/types.py b/cloudkitty/api/v1/types.py index 8d9f98ad..fe8460ce 100644 --- a/cloudkitty/api/v1/types.py +++ b/cloudkitty/api/v1/types.py @@ -15,11 +15,25 @@ # # @author: Stéphane Albert # +from oslo.utils import uuidutils +from wsme import exc from wsme import types as wtypes from cloudkitty.i18n import _LE +class UuidType(wtypes.UuidType): + """A simple UUID type.""" + basetype = wtypes.text + name = 'uuid' + + @staticmethod + def validate(value): + if not uuidutils.is_uuid_like(value): + raise exc.InvalidType(_LE("Invalid UUID, got '%s'") % value) + return value + + # Code taken from ironic types class MultiType(wtypes.UserType): """A complex type that represents one or more types. diff --git a/cloudkitty/billing/hash/__init__.py b/cloudkitty/billing/hash/__init__.py index efa0d0ac..2f1e3084 100644 --- a/cloudkitty/billing/hash/__init__.py +++ b/cloudkitty/billing/hash/__init__.py @@ -15,210 +15,34 @@ # # @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 - from cloudkitty import billing -from cloudkitty.billing.hash.db import api -from cloudkitty.db import api as db_api +from cloudkitty.billing.hash.controllers import root as root_api +from cloudkitty.billing.hash.db import api as hash_db_api +from cloudkitty.db import api as ck_db_api from cloudkitty.openstack.common import log as logging LOG = logging.getLogger(__name__) -MAP_TYPE = wtypes.Enum(wtypes.text, 'flat', 'rate') +class HashMap(billing.BillingProcessorBase): + """HashMap rating module. -class Mapping(wtypes.Base): - - map_type = wtypes.wsattr(MAP_TYPE, default='rate', name='type') - """Type of the mapping.""" - - value = wtypes.wsattr(float, mandatory=True) - """Value of the mapping.""" - - @classmethod - def sample(cls): - sample = cls(value=4.2) - return sample - - -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): - if len(args) > 2: - # Taken from base _route function - if request is None: - from pecan import request # noqa - method = request.params.get('_method', request.method).lower() - if request.method == 'GET' and method in ('delete', 'put'): - pecan.abort(405) - - if request.method == 'GET': - return routing.lookup_controller(self.get_mapping, args) - return super(BasicHashMapConfigController, self)._route(args) - - @wsme_pecan.wsexpose(Mapping, wtypes.text, wtypes.text, wtypes.text) - def get_mapping(self, service, field, key): - """Get a mapping from full path. - - """ - hashmap = api.get_instance() - try: - return hashmap.get_mapping(service, field, key) - except (api.NoSuchService, api.NoSuchField, api.NoSuchMapping) as e: - pecan.abort(400, str(e)) - - @wsme_pecan.wsexpose([wtypes.text]) - def get(self): - """Get the service list - - :return: List of every services' name. - """ - hashmap = api.get_instance() - return [service.name for service in hashmap.list_services()] - - @wsme_pecan.wsexpose([wtypes.text], wtypes.text, wtypes.text) - def get_one(self, service=None, field=None): - """Return the list of every sub keys. - - :param service: (Optional) Filter on this service. - :param field: (Optional) Filter on this field. - """ - hashmap = api.get_instance() - if field: - try: - return [mapping.key for mapping in hashmap.list_mappings( - service, - field)] - except (api.NoSuchService, api.NoSuchField) as e: - pecan.abort(400, str(e)) - - else: - try: - return [f.name for f in hashmap.list_fields(service)] - except api.NoSuchService as e: - pecan.abort(400, str(e)) - - # FIXME (sheeprine): Still a problem with our routing and the different - # object types. For service/field it's text or a mapping. - @wsme_pecan.wsexpose(None, wtypes.text, wtypes.text, wtypes.text, - body=Mapping) - def post(self, service, field=None, key=None, mapping=None): - """Create hashmap fields. - - :param service: Name of the service to create. - :param field: (Optional) Name of the field to create. - :param key: (Optional) Name of the key to create. - :param mapping: (Optional) Mapping object to create. - """ - hashmap = api.get_instance() - if field: - if key: - if mapping: - try: - # FIXME(sheeprine): We should return the result - hashmap.create_mapping( - service, - field, - key, - value=mapping.value, - map_type=mapping.map_type - ) - pecan.response.headers['Location'] = pecan.request.path - except api.MappingAlreadyExists as e: - pecan.abort(409, str(e)) - else: - e = ValueError('Mapping can\'t be empty.') - pecan.abort(400, str(e)) - else: - try: - hashmap.create_field(service, field) - pecan.response.headers['Location'] = pecan.request.path - except api.FieldAlreadyExists as e: - pecan.abort(409, str(e)) - else: - try: - hashmap.create_service(service) - pecan.response.headers['Location'] = pecan.request.path - except api.ServiceAlreadyExists as e: - pecan.abort(409, str(e)) - self.notify_reload() - pecan.response.status = 201 - - @wsme_pecan.wsexpose(None, wtypes.text, wtypes.text, wtypes.text, - body=Mapping) - def put(self, service, field, key, mapping): - """Modify hashmap fields - - :param service: Filter on this service. - :param field: Filter on this field. - :param key: Modify the content of this key. - :param mapping: Mapping object to update. - """ - hashmap = api.get_instance() - try: - hashmap.update_mapping( - service, - field, - key, - value=mapping.value, - map_type=mapping.map_type - ) - pecan.response.headers['Location'] = pecan.request.path - pecan.response.status = 204 - except (api.NoSuchService, api.NoSuchField, api.NoSuchMapping) as e: - pecan.abort(400, str(e)) - - @wsme_pecan.wsexpose(None, wtypes.text, wtypes.text, wtypes.text) - def delete(self, service, field=None, key=None): - """Delete the parent and all the sub keys recursively. - - :param service: Name of the service to delete. - :param field: (Optional) Name of the field to delete. - :param key: (Optional) Name of the key to delete. - """ - hashmap = api.get_instance() - try: - if field: - if key: - hashmap.delete_mapping(service, field, key) - else: - hashmap.delete_field(service, field) - else: - hashmap.delete_service(service) - except (api.NoSuchService, api.NoSuchField, api.NoSuchMapping) as e: - pecan.abort(400, str(e)) - pecan.response.status = 204 - - -class BasicHashMap(billing.BillingProcessorBase): + HashMap can be used to map arbitrary fields of a resource to different + costs. + """ module_name = 'hashmap' description = 'Basic hashmap billing module.' hot_config = True - config_controller = BasicHashMapConfigController + config_controller = root_api.HashMapConfigController - db_api = api.get_instance() + db_api = hash_db_api.get_instance() def __init__(self, tenant_id=None): - super(BasicHashMap, self).__init__(tenant_id) - self._billing_info = {} + super(HashMap, self).__init__(tenant_id) + self._service_mappings = {} + self._field_mappings = {} + self._res = {} self._load_billing_rates() @property @@ -227,67 +51,127 @@ class BasicHashMap(billing.BillingProcessorBase): :returns: bool if module is enabled """ - # FIXME(sheeprine): Hardcoded values to check the state - api = db_api.get_instance() - module_db = api.get_module_enable_state() + db_api = ck_db_api.get_instance() + module_db = db_api.get_module_enable_state() return module_db.get_state('hashmap') or False def reload_config(self): + """Reload the module's configuration. + + """ self._load_billing_rates() + def _load_mappings(self, mappings_uuid_list): + hashmap = hash_db_api.get_instance() + mappings = {} + for mapping_uuid in mappings_uuid_list: + mapping_db = hashmap.get_mapping(uuid=mapping_uuid) + if mapping_db.group_id: + group_name = mapping_db.group.name + else: + group_name = '_DEFAULT_' + if group_name not in mappings: + mappings[group_name] = {} + mapping_value = mapping_db.value + map_dict = {} + map_dict['cost'] = mapping_db.cost + map_dict['type'] = mapping_db.map_type + if mapping_value: + mappings[group_name][mapping_value] = map_dict + else: + mappings[group_name] = map_dict + return mappings + + def _load_service_mappings(self, service_name, service_uuid): + hashmap = hash_db_api.get_instance() + mappings_uuid_list = hashmap.list_mappings(service_uuid=service_uuid) + mappings = self._load_mappings(mappings_uuid_list) + if mappings: + self._service_mappings[service_name] = mappings + + def _load_field_mappings(self, service_name, field_name, field_uuid): + hashmap = hash_db_api.get_instance() + mappings_uuid_list = hashmap.list_mappings(field_uuid=field_uuid) + mappings = self._load_mappings(mappings_uuid_list) + if mappings: + self._field_mappings[service_name] = {} + self._field_mappings[service_name][field_name] = mappings + def _load_billing_rates(self): - self._billing_info = {} - hashmap = api.get_instance() - services = hashmap.list_services() - for service in services: - service = service[0] - self._billing_info[service] = {} - fields = hashmap.list_fields(service) - for field in fields: - field = field[0] - self._billing_info[service][field] = {} - mappings = hashmap.list_mappings(service, field) - for mapping in mappings: - mapping = mapping[0] - mapping_db = hashmap.get_mapping(service, field, mapping) - map_dict = {} - map_dict['value'] = mapping_db.value - map_dict['type'] = mapping_db.map_type - self._billing_info[service][field][mapping] = map_dict + self._service_mappings = {} + self._field_mappings = {} + hashmap = hash_db_api.get_instance() + services_uuid_list = hashmap.list_services() + for service_uuid in services_uuid_list: + service_db = hashmap.get_service(uuid=service_uuid) + service_name = service_db.name + self._load_service_mappings(service_name, service_uuid) + fields_uuid_list = hashmap.list_fields(service_uuid) + for field_uuid in fields_uuid_list: + field_db = hashmap.get_field(uuid=field_uuid) + field_name = field_db.name + self._load_field_mappings(service_name, field_name, field_uuid) - def process_service(self, name, data): - if name not in self._billing_info: + def add_billing_informations(self, data): + if 'billing' not in data: + data['billing'] = {'price': 0} + for entry in self._res.values(): + res = entry['rate'] * entry['flat'] + data['billing']['price'] += res * data['vol']['qty'] + + def update_result(self, group, map_type, value): + if group not in self._res: + self._res[group] = {'flat': 0, + 'rate': 1} + + if map_type == 'rate': + self._res[group]['rate'] *= value + elif map_type == 'flat': + new_flat = value + cur_flat = self._res[group]['flat'] + if new_flat > cur_flat: + self._res[group]['flat'] = new_flat + + def process_service_map(self, service_name, data): + if service_name not in self._service_mappings: return - serv_b_info = self._billing_info[name] - for entry in data: - flat = 0 - rate = 1 - entry_desc = entry['desc'] - for field in serv_b_info: - if field not in entry_desc: - continue - b_info = serv_b_info[field] - key = entry_desc[field] + serv_map = self._service_mappings[service_name] + for group_name, mapping in serv_map.items(): + self.update_result(group_name, + mapping['type'], + mapping['cost']) - value = 0 - if key in b_info: - value = b_info[key]['value'] - elif '_DEFAULT_' in b_info: - value = b_info['_DEFAULT_'] - - if value: - if b_info[key]['type'] == 'rate': - rate *= value - elif b_info[key]['type'] == 'flat': - new_flat = 0 - new_flat = value - if new_flat > flat: - flat = new_flat - entry['billing'] = {'price': flat * rate} + def process_field_map(self, service_name, data): + if service_name not in self._field_mappings: + return {} + field_map = self._field_mappings[service_name] + desc_data = data['desc'] + for field_name, group_mappings in field_map.items(): + if field_name not in desc_data: + continue + for group_name, mappings in group_mappings.items(): + mapping_default = mappings.pop('_DEFAULT_', {}) + matched = False + for mapping_value, mapping in mappings.items(): + if desc_data[field_name] == mapping_value: + self.update_result( + group_name, + mapping['type'], + mapping['cost']) + matched = True + if not matched and mapping_default: + self.update_result( + group_name, + mapping_default['type'], + mapping_default['cost']) def process(self, data): for cur_data in data: cur_usage = cur_data['usage'] - for service in cur_usage: - self.process_service(service, cur_usage[service]) + for service_name, service_data in cur_usage.items(): + for item in service_data: + self._res = {} + self.process_service_map(service_name, item) + self.process_field_map(service_name, item) + self.add_billing_informations(item) return data diff --git a/cloudkitty/billing/hash/controllers/__init__.py b/cloudkitty/billing/hash/controllers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cloudkitty/billing/hash/controllers/field.py b/cloudkitty/billing/hash/controllers/field.py new file mode 100644 index 00000000..245b44c1 --- /dev/null +++ b/cloudkitty/billing/hash/controllers/field.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 +import wsmeext.pecan as wsme_pecan + +from cloudkitty.api.v1 import types as ck_types +from cloudkitty.billing.hash.datamodels import field as field_models +from cloudkitty.billing.hash.db import api as db_api + + +class HashMapFieldsController(rest.RestController): + """Controller responsible of fields management. + + """ + + @wsme_pecan.wsexpose(field_models.FieldCollection, + ck_types.UuidType(), + status_code=200) + def get_all(self, service_id): + """Get the field list. + + :param service_id: Service's UUID to filter on. + :return: List of every fields. + """ + hashmap = db_api.get_instance() + field_list = [] + fields_uuid_list = hashmap.list_fields(service_id) + for field_uuid in fields_uuid_list: + field_db = hashmap.get_field(field_uuid) + field_list.append(field_models.Field( + **field_db.export_model())) + res = field_models.FieldCollection(fields=field_list) + return res + + @wsme_pecan.wsexpose(field_models.Field, + ck_types.UuidType(), + status_code=200) + def get_one(self, field_id): + """Return a field. + + :param field_id: UUID of the field to filter on. + """ + hashmap = db_api.get_instance() + try: + field_db = hashmap.get_field(uuid=field_id) + return field_models.Field(**field_db.export_model()) + except db_api.NoSuchField as e: + pecan.abort(400, str(e)) + + @wsme_pecan.wsexpose(field_models.Field, + body=field_models.Field, + status_code=201) + def post(self, field_data): + """Create a field. + + :param field_data: Informations about the field to create. + """ + hashmap = db_api.get_instance() + try: + field_db = hashmap.create_field( + field_data.service_id, + field_data.name) + pecan.response.location = pecan.request.path_url + if pecan.response.location[-1] != '/': + pecan.response.location += '/' + pecan.response.location += field_db.field_id + return field_models.Field( + **field_db.export_model()) + except (db_api.FieldAlreadyExists, db_api.NoSuchService) as e: + pecan.abort(409, str(e)) + + @wsme_pecan.wsexpose(None, + ck_types.UuidType(), + status_code=204) + def delete(self, field_id): + """Delete the field and all the sub keys recursively. + + :param field_id: UUID of the field to delete. + """ + hashmap = db_api.get_instance() + try: + hashmap.delete_field(uuid=field_id) + except db_api.NoSuchService as e: + pecan.abort(400, str(e)) diff --git a/cloudkitty/billing/hash/controllers/group.py b/cloudkitty/billing/hash/controllers/group.py new file mode 100644 index 00000000..5b69a101 --- /dev/null +++ b/cloudkitty/billing/hash/controllers/group.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 +import wsmeext.pecan as wsme_pecan + +from cloudkitty.api.v1 import types as ck_types +from cloudkitty.billing.hash.datamodels import group as group_models +from cloudkitty.billing.hash.datamodels import mapping as mapping_models +from cloudkitty.billing.hash.db import api as db_api + + +class HashMapGroupsController(rest.RestController): + """Controller responsible of groups management. + + """ + _custom_actions = { + 'mappings': ['GET'] + } + + @wsme_pecan.wsexpose(mapping_models.MappingCollection, + ck_types.UuidType()) + def mappings(self, group_id): + """Get the mappings attached to the group. + + :param group_id: UUID of the group to filter on. + """ + hashmap = db_api.get_instance() + mapping_list = [] + mappings_uuid_list = hashmap.list_mappings(group_uuid=group_id) + for mapping_uuid in mappings_uuid_list: + mapping_db = hashmap.get_mapping(uuid=mapping_uuid) + mapping_list.append(mapping_models.Mapping( + **mapping_db.export_model())) + res = mapping_models.MappingCollection(mappings=mapping_list) + return res + + @wsme_pecan.wsexpose(group_models.GroupCollection) + def get_all(self): + """Get the group list + + :return: List of every group. + """ + hashmap = db_api.get_instance() + group_list = [] + groups_uuid_list = hashmap.list_groups() + for group_uuid in groups_uuid_list: + group_db = hashmap.get_group(uuid=group_uuid) + group_list.append(group_models.Group( + **group_db.export_model())) + res = group_models.GroupCollection(groups=group_list) + return res + + @wsme_pecan.wsexpose(group_models.Group, + ck_types.UuidType()) + def get_one(self, group_id): + """Return a group. + + :param group_id: UUID of the group to filter on. + """ + hashmap = db_api.get_instance() + try: + group_db = hashmap.get_group(uuid=group_id) + return group_models.Group(**group_db.export_model()) + except db_api.NoSuchGroup as e: + pecan.abort(400, str(e)) + + @wsme_pecan.wsexpose(group_models.Group, + body=group_models.Group, + status_code=201) + def post(self, group_data): + """Create a group. + + :param group_data: Informations about the group to create. + """ + hashmap = db_api.get_instance() + try: + group_db = hashmap.create_group(group_data.name) + pecan.response.location = pecan.request.path_url + if pecan.response.location[-1] != '/': + pecan.response.location += '/' + pecan.response.location += group_db.group_id + return group_models.Group( + **group_db.export_model()) + except db_api.GroupAlreadyExists as e: + pecan.abort(409, str(e)) + + @wsme_pecan.wsexpose(None, + ck_types.UuidType(), + bool, + status_code=204) + def delete(self, group_id, recursive=False): + """Delete a group. + + :param group_id: UUID of the group to delete. + :param recursive: Delete mappings recursively. + """ + hashmap = db_api.get_instance() + try: + hashmap.delete_group(uuid=group_id, recurse=recursive) + except db_api.NoSuchGroup as e: + pecan.abort(400, str(e)) diff --git a/cloudkitty/billing/hash/controllers/mapping.py b/cloudkitty/billing/hash/controllers/mapping.py new file mode 100644 index 00000000..350f98ea --- /dev/null +++ b/cloudkitty/billing/hash/controllers/mapping.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 +import wsmeext.pecan as wsme_pecan + +from cloudkitty.api.v1 import types as ck_types +from cloudkitty.billing.hash.datamodels import group as group_models +from cloudkitty.billing.hash.datamodels import mapping as mapping_models +from cloudkitty.billing.hash.db import api as db_api + + +class HashMapMappingsController(rest.RestController): + """Controller responsible of mappings management. + + """ + + _custom_actions = { + 'group': ['GET'] + } + + @wsme_pecan.wsexpose(group_models.Group, + ck_types.UuidType()) + def group(self, mapping_id): + """Get the group attached to the mapping. + + :param mapping_id: UUID of the mapping to filter on. + """ + hashmap = db_api.get_instance() + try: + group_db = hashmap.get_group_from_mapping( + uuid=mapping_id) + return group_models.Group(**group_db.export_model()) + except db_api.MappingHasNoGroup as e: + pecan.abort(404, str(e)) + + @wsme_pecan.wsexpose(mapping_models.MappingCollection, + ck_types.UuidType(), + ck_types.UuidType(), + ck_types.UuidType(), + bool, + status_code=200) + def get_all(self, + service_id=None, + field_id=None, + group_id=None, + no_group=False): + """Get the mapping list + + :param service_id: Service UUID to filter on. + :param field_id: Field UUID to filter on. + :param group_id: Group UUID to filter on. + :param no_group: Filter on orphaned mappings. + :return: List of every mappings. + """ + hashmap = db_api.get_instance() + mapping_list = [] + mappings_uuid_list = hashmap.list_mappings(service_uuid=service_id, + field_uuid=field_id, + group_uuid=group_id) + for mapping_uuid in mappings_uuid_list: + mapping_db = hashmap.get_mapping(uuid=mapping_uuid) + mapping_list.append(mapping_models.Mapping( + **mapping_db.export_model())) + res = mapping_models.MappingCollection(mappings=mapping_list) + return res + + @wsme_pecan.wsexpose(mapping_models.Mapping, + ck_types.UuidType()) + def get_one(self, mapping_id): + """Return a mapping. + + :param mapping_id: UUID of the mapping to filter on. + """ + hashmap = db_api.get_instance() + try: + mapping_db = hashmap.get_mapping(uuid=mapping_id) + return mapping_models.Mapping( + **mapping_db.export_model()) + except db_api.NoSuchMapping as e: + pecan.abort(400, str(e)) + + @wsme_pecan.wsexpose(mapping_models.Mapping, + body=mapping_models.Mapping, + status_code=201) + def post(self, mapping_data): + """Create a mapping. + + :param mapping_data: Informations about the mapping to create. + """ + hashmap = db_api.get_instance() + try: + mapping_db = hashmap.create_mapping( + value=mapping_data.value, + map_type=mapping_data.map_type, + cost=mapping_data.cost, + field_id=mapping_data.field_id, + group_id=mapping_data.group_id, + service_id=mapping_data.service_id) + pecan.response.location = pecan.request.path_url + if pecan.response.location[-1] != '/': + pecan.response.location += '/' + pecan.response.location += mapping_db.mapping_id + return mapping_models.Mapping( + **mapping_db.export_model()) + except db_api.MappingAlreadyExists as e: + pecan.abort(409, str(e)) + + @wsme_pecan.wsexpose(None, + ck_types.UuidType(), + body=mapping_models.Mapping, + status_code=302) + def put(self, mapping_id, mapping): + """Update a mapping. + + :param mapping_id: UUID of the mapping to update. + :param mapping: Mapping data to insert. + """ + hashmap = db_api.get_instance() + try: + hashmap.update_mapping( + mapping_id, + mapping_id=mapping.mapping_id, + value=mapping.value, + cost=mapping.cost, + map_type=mapping.map_type, + group_id=mapping.group_id) + pecan.response.headers['Location'] = pecan.request.path + except (db_api.NoSuchService, + db_api.NoSuchField, + db_api.NoSuchMapping) as e: + pecan.abort(400, str(e)) + + @wsme_pecan.wsexpose(None, + ck_types.UuidType(), + status_code=204) + def delete(self, mapping_id): + """Delete a mapping. + + :param mapping_id: UUID of the mapping to delete. + """ + hashmap = db_api.get_instance() + try: + hashmap.delete_mapping(uuid=mapping_id) + except (db_api.NoSuchService, + db_api.NoSuchField, + db_api.NoSuchMapping) as e: + pecan.abort(400, str(e)) diff --git a/cloudkitty/billing/hash/controllers/root.py b/cloudkitty/billing/hash/controllers/root.py new file mode 100644 index 00000000..324aae90 --- /dev/null +++ b/cloudkitty/billing/hash/controllers/root.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from cloudkitty.billing.hash.controllers import field as field_api +from cloudkitty.billing.hash.controllers import group as group_api +from cloudkitty.billing.hash.controllers import mapping as mapping_api +from cloudkitty.billing.hash.controllers import service as service_api +from cloudkitty.billing.hash.datamodels import mapping as mapping_models + + +class HashMapConfigController(rest.RestController): + """Controller exposing all management sub controllers. + + """ + + _custom_actions = { + 'types': ['GET'] + } + + services = service_api.HashMapServicesController() + fields = field_api.HashMapFieldsController() + groups = group_api.HashMapGroupsController() + mappings = mapping_api.HashMapMappingsController() + + @wsme_pecan.wsexpose([wtypes.text]) + def get_types(self): + """Return the list of every mapping type available. + + """ + return mapping_models.MAP_TYPE.values diff --git a/cloudkitty/billing/hash/controllers/service.py b/cloudkitty/billing/hash/controllers/service.py new file mode 100644 index 00000000..4e88dd83 --- /dev/null +++ b/cloudkitty/billing/hash/controllers/service.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 +import wsmeext.pecan as wsme_pecan + +from cloudkitty.api.v1 import types as ck_types +from cloudkitty.billing.hash.controllers import field as field_api +from cloudkitty.billing.hash.datamodels import service as service_models +from cloudkitty.billing.hash.db import api as db_api + + +class HashMapServicesController(rest.RestController): + """Controller responsible of services management. + + """ + + fields = field_api.HashMapFieldsController() + + @wsme_pecan.wsexpose(service_models.ServiceCollection) + def get_all(self): + """Get the service list + + :return: List of every services. + """ + hashmap = db_api.get_instance() + service_list = [] + services_uuid_list = hashmap.list_services() + for service_uuid in services_uuid_list: + service_db = hashmap.get_service(uuid=service_uuid) + service_list.append(service_models.Service( + **service_db.export_model())) + res = service_models.ServiceCollection(services=service_list) + return res + + @wsme_pecan.wsexpose(service_models.Service, ck_types.UuidType()) + def get_one(self, service_id): + """Return a service. + + :param service_id: UUID of the service to filter on. + """ + hashmap = db_api.get_instance() + try: + service_db = hashmap.get_service(uuid=service_id) + return service_models.Service(**service_db.export_model()) + except db_api.NoSuchService as e: + pecan.abort(400, str(e)) + + @wsme_pecan.wsexpose(service_models.Service, + body=service_models.Service, + status_code=201) + def post(self, service_data): + """Create hashmap service. + + :param service_data: Informations about the service to create. + """ + hashmap = db_api.get_instance() + try: + service_db = hashmap.create_service(service_data.name) + pecan.response.location = pecan.request.path_url + if pecan.response.location[-1] != '/': + pecan.response.location += '/' + pecan.response.location += service_db.service_id + return service_models.Service( + **service_db.export_model()) + except db_api.ServiceAlreadyExists as e: + pecan.abort(409, str(e)) + + @wsme_pecan.wsexpose(None, ck_types.UuidType(), status_code=204) + def delete(self, service_id): + """Delete the service and all the sub keys recursively. + + :param service_id: UUID of the service to delete. + """ + hashmap = db_api.get_instance() + try: + hashmap.delete_service(uuid=service_id) + except db_api.NoSuchService as e: + pecan.abort(400, str(e)) diff --git a/cloudkitty/billing/hash/datamodels/__init__.py b/cloudkitty/billing/hash/datamodels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cloudkitty/billing/hash/datamodels/field.py b/cloudkitty/billing/hash/datamodels/field.py new file mode 100644 index 00000000..aedb6a5c --- /dev/null +++ b/cloudkitty/billing/hash/datamodels/field.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 + +from cloudkitty.api.v1 import types as ck_types + + +class Field(wtypes.Base): + """Type describing a field. + + A field is mapping a value of the 'desc' dict of the CloudKitty data. It's + used to map the name of a metadata. + """ + + field_id = wtypes.wsattr(ck_types.UuidType(), + mandatory=False, + readonly=True) + """UUID of the field.""" + + name = wtypes.wsattr(wtypes.text, mandatory=True) + """Name of the field.""" + + service_id = wtypes.wsattr(ck_types.UuidType(), + mandatory=True) + """UUID of the parent service.""" + + @classmethod + def sample(cls): + sample = cls(field_id='ac55b000-a05b-4832-b2ff-265a034886ab', + name='image_id', + service_id='a733d0e1-1ec9-4800-8df8-671e4affd017') + return sample + + +class FieldCollection(wtypes.Base): + """Type describing a list of fields. + + """ + + fields = [Field] + """List of fields.""" + + @classmethod + def sample(cls): + sample = Field.sample() + return cls(fields=[sample]) diff --git a/cloudkitty/billing/hash/datamodels/group.py b/cloudkitty/billing/hash/datamodels/group.py new file mode 100644 index 00000000..00942ae2 --- /dev/null +++ b/cloudkitty/billing/hash/datamodels/group.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 + +from cloudkitty.api.v1 import types as ck_types + + +class Group(wtypes.Base): + """Type describing a group. + + A group is used to divide calculations. It can be used to create a group + for the instance rating (flavor) and one if we have premium images + (image_id). So you can take into account multiple parameters during the + rating. + """ + + group_id = wtypes.wsattr(ck_types.UuidType(), + mandatory=False, + readonly=True) + """UUID of the group.""" + + name = wtypes.wsattr(wtypes.text, mandatory=True) + """Name of the group.""" + + @classmethod + def sample(cls): + sample = cls(group_id='afe898cb-86d8-4557-ad67-f4f01891bbee', + name='instance_rating') + return sample + + +class GroupCollection(wtypes.Base): + """Type describing a list of groups. + + """ + + groups = [Group] + """List of groups.""" + + @classmethod + def sample(cls): + sample = Group.sample() + return cls(groups=[sample]) diff --git a/cloudkitty/billing/hash/datamodels/mapping.py b/cloudkitty/billing/hash/datamodels/mapping.py new file mode 100644 index 00000000..f0fd890a --- /dev/null +++ b/cloudkitty/billing/hash/datamodels/mapping.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 wsme import types as wtypes + +from cloudkitty.api.v1 import types as ck_types + +MAP_TYPE = wtypes.Enum(wtypes.text, 'flat', 'rate') + + +class Mapping(wtypes.Base): + """Type describing a Mapping. + + A mapping is used to apply rating rules based on a value, if the parent is + a field then it's check the value of a metadata. If it's a service then it + directly apply the rate to the volume. + """ + + mapping_id = wtypes.wsattr(ck_types.UuidType(), + mandatory=False, + readonly=True) + """UUID of the mapping.""" + + value = wtypes.wsattr(wtypes.text, mandatory=False) + """Key of the mapping.""" + + map_type = wtypes.wsattr(MAP_TYPE, default='flat', name='type') + """Type of the mapping.""" + + cost = wtypes.wsattr(decimal.Decimal, mandatory=True) + """Value of the mapping.""" + + service_id = wtypes.wsattr(ck_types.UuidType(), + mandatory=False) + """UUID of the service.""" + + field_id = wtypes.wsattr(ck_types.UuidType(), + mandatory=False) + """UUID of the field.""" + + group_id = wtypes.wsattr(ck_types.UuidType(), + mandatory=False) + """UUID of the hashmap group.""" + + @classmethod + def sample(cls): + sample = cls(mapping_id='39dbd39d-f663-4444-a795-fb19d81af136', + field_id='ac55b000-a05b-4832-b2ff-265a034886ab', + value='m1.micro', + map_type='flat', + cost=decimal.Decimal('4.2')) + return sample + + +class MappingCollection(wtypes.Base): + """Type describing a list of mappings. + + """ + + mappings = [Mapping] + """List of mappings.""" + + @classmethod + def sample(cls): + sample = Mapping.sample() + return cls(mappings=[sample]) diff --git a/cloudkitty/billing/hash/datamodels/service.py b/cloudkitty/billing/hash/datamodels/service.py new file mode 100644 index 00000000..829bebd0 --- /dev/null +++ b/cloudkitty/billing/hash/datamodels/service.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 + +from cloudkitty.api.v1 import types as ck_types + + +class Service(wtypes.Base): + """Type describing a service. + + A service is directly mapped to the usage key, the collected service. + """ + + service_id = wtypes.wsattr(ck_types.UuidType(), + mandatory=False, + readonly=True) + """UUID of the service.""" + + name = wtypes.wsattr(wtypes.text, mandatory=True) + """Name of the service.""" + + @classmethod + def sample(cls): + sample = cls(service_id='a733d0e1-1ec9-4800-8df8-671e4affd017', + name='compute') + return sample + + +class ServiceCollection(wtypes.Base): + """Type describing a list of services. + + """ + + services = [Service] + """List of services.""" + + @classmethod + def sample(cls): + sample = Service.sample() + return cls(services=[sample]) diff --git a/cloudkitty/billing/hash/db/api.py b/cloudkitty/billing/hash/db/api.py index 0afac28c..c54b5a66 100644 --- a/cloudkitty/billing/hash/db/api.py +++ b/cloudkitty/billing/hash/db/api.py @@ -35,59 +35,98 @@ def get_instance(): class NoSuchService(Exception): """Raised when the service doesn't exist.""" - def __init__(self, service): + def __init__(self, name=None, uuid=None): super(NoSuchService, self).__init__( - "No such service: %s" % service) - self.service = service + "No such service: %s (UUID: %s)" % (name, uuid)) + self.name = name + self.uuid = uuid class NoSuchField(Exception): """Raised when the field doesn't exist for the service.""" - def __init__(self, service, field): + def __init__(self, uuid): super(NoSuchField, self).__init__( - "No such field for %s service: %s" % (service, field,)) - self.service = service - self.field = field + "No such field: %s" % uuid) + self.uuid = uuid + + +class NoSuchGroup(Exception): + """Raised when the group doesn't exist.""" + + def __init__(self, name=None, uuid=None): + super(NoSuchGroup, self).__init__( + "No such group: %s (UUID: %s)" % (name, uuid)) + self.name = name + self.uuid = uuid class NoSuchMapping(Exception): """Raised when the mapping doesn't exist.""" - def __init__(self, service, field, key): - super(NoSuchMapping, self).__init__( - "No such key for %s service and %s field: %s" - % (service, field, key,)) - self.service = service - self.field = field - self.key = key + def __init__(self, uuid): + msg = ("No such mapping: %s" % uuid) + super(NoSuchMapping, self).__init__(msg) + self.uuid = uuid + + +class NoSuchType(Exception): + """Raised when a mapping type is not handled.""" + + def __init__(self, map_type): + msg = ("No mapping type: %s" + % (map_type)) + super(NoSuchType, self).__init__(msg) + self.map_type = map_type class ServiceAlreadyExists(Exception): """Raised when the service already exists.""" - def __init__(self, service): + def __init__(self, name, uuid): super(ServiceAlreadyExists, self).__init__( - "Service %s already exists" % service) - self.service = service + "Service %s already exists (UUID: %s)" % (name, uuid)) + self.name = name + self.uuid = uuid class FieldAlreadyExists(Exception): """Raised when the field already exists.""" - def __init__(self, field): + def __init__(self, field, uuid): super(FieldAlreadyExists, self).__init__( - "Field %s already exists" % field) + "Field %s already exists (UUID: %s)" % (field, uuid)) self.field = field + self.uuid = uuid + + +class GroupAlreadyExists(Exception): + """Raised when the group already exists.""" + + def __init__(self, name, uuid): + super(GroupAlreadyExists, self).__init__( + "Group %s already exists (UUID: %s)" % (name, uuid)) + self.name = name + self.uuid = uuid class MappingAlreadyExists(Exception): """Raised when the mapping already exists.""" - def __init__(self, mapping): + def __init__(self, mapping, uuid): super(MappingAlreadyExists, self).__init__( - "Mapping %s already exists" % mapping) + "Mapping %s already exists (UUID: %s)" % (mapping, uuid)) self.mapping = mapping + self.uuid = uuid + + +class MappingHasNoGroup(Exception): + """Raised when the mapping is not attached to a group.""" + + def __init__(self, uuid): + super(MappingHasNoGroup, self).__init__( + "Mapping has no group (UUID: %s)" % uuid) + self.uuid = uuid @six.add_metaclass(abc.ABCMeta) @@ -101,119 +140,147 @@ class HashMap(object): """ @abc.abstractmethod - def get_service(self, service): + def get_service(self, name=None, uuid=None): """Return a service object. - :param service: The service to filter on. + :param name: Filter on a service name. + :param uuid: The uuid of the service to get. """ @abc.abstractmethod - def get_field(self, service, field): + def get_field(self, uuid=None, service_uuid=None, name=None): """Return a field object. - :param service: The service to filter on. - :param field: The field to filter on. + :param uuid: UUID of the field to get. + :param service_uuid: UUID of the service to filter on. (Used with name) + :param name: Name of the field to filter on. (Used with service_uuid) """ @abc.abstractmethod - def get_mapping(self, service, field, key): - """Return a field object. + def get_group(self, uuid): + """Return a group object. - :param service: The service to filter on. - :param field: The field to filter on. - :param key: The field to filter on. - :param key: Value of the field to filter on. + :param uuid: UUID of the group to get. + """ + + @abc.abstractmethod + def get_mapping(self, uuid): + """Return a mapping object. + + :param uuid: UUID of the mapping to get. """ @abc.abstractmethod def list_services(self): - """Return a list of every services. + """Return an UUID list of every service. """ @abc.abstractmethod - def list_fields(self, service): - """Return a list of every fields in a service. + def list_fields(self, service_uuid): + """Return an UUID list of every field in a service. - :param service: The service to filter on. + :param service_uuid: The service UUID to filter on. """ @abc.abstractmethod - def list_mappings(self, service, field): - """Return a list of every mapping. + def list_groups(self): + """Return an UUID list of every group. - :param service: The service to filter on. - :param field: The key to filter on. """ @abc.abstractmethod - def create_service(self, service): + def list_mappings(self, + service_uuid=None, + field_uuid=None, + group_uuid=None, + no_group=False): + """Return an UUID list of every mapping. + + :param service_uuid: The service to filter on. + :param field_uuid: The field to filter on. + :param group_uuid: The group to filter on. + :param no_group: Filter on mappings without a group. + + :return list(str): List of mappings' UUID. + """ + + @abc.abstractmethod + def create_service(self, name): """Create a new service. - :param service: + :param name: Name of the service to create. """ @abc.abstractmethod - def create_field(self, service, field): + def create_field(self, service_uuid, name): """Create a new field. - :param service: - :param field: + :param service_uuid: UUID of the parent service. + :param name: Name of the field. """ @abc.abstractmethod - def create_mapping(self, service, field, key, value, map_type='rate'): + def create_group(self, name): + """Create a new group. + + :param name: The name of the group. + """ + + @abc.abstractmethod + def create_mapping(self, + cost, + map_type='rate', + value=None, + service_id=None, + field_id=None, + group_id=None): """Create a new service/field mapping. - :param service: Service the mapping is applying to. - :param field: Field the mapping is applying to. - :param key: Value of the field this mapping is applying to. - :param value: Pricing value to apply to this mapping. - :param map_type: The type of pricing rule. + :param cost: Rating value to apply to this mapping. + :param map_type: The type of rating rule. + :param value: Value of the field this mapping is applying to. + :param service_id: Service the mapping is applying to. + :param field_id: Field the mapping is applying to. + :param group_id: The group of calculations to apply. """ @abc.abstractmethod - def update_mapping(self, service, field, key, **kwargs): + def update_mapping(self, uuid, **kwargs): """Update a mapping. - :param service: Service the mapping is applying to. - :param field: Field the mapping is applying to. - :param key: Value of the field this mapping is applying to. - :param value: Pricing value to apply to this mapping. - :param map_type: The type of pricing rule. + :param uuid UUID of the mapping to modify. + :param cost: Rating value to apply to this mapping. + :param map_type: The type of rating rule. + :param value: Value of the field this mapping is applying to. + :param group_id: The group of calculations to apply. """ @abc.abstractmethod - def update_or_create_mapping(self, service, field, key, **kwargs): - """Update or create a mapping. - - :param service: Service the mapping is applying to. - :param field: Field the mapping is applying to. - :param key: Value of the field this mapping is applying to. - :param value: Pricing value to apply to this mapping. - :param map_type: The type of pricing rule. - """ - - @abc.abstractmethod - def delete_service(self, service): + def delete_service(self, name=None, uuid=None): """Delete a service recursively. - :param service: Service to delete. + :param name: Name of the service to delete. + :param uuid: UUID of the service to delete. """ @abc.abstractmethod - def delete_field(self, service, field): + def delete_field(self, uuid): """Delete a field recursively. - :param service: Service the field is applying to. - :param field: field to delete. + :param uuid UUID of the field to delete. + """ + + def delete_group(self, uuid, recurse=True): + """Delete a group and all mappings recursively. + + :param uuid: UUID of the group to delete. + :param recurse: Delete attached mappings recursively. """ @abc.abstractmethod - def delete_mapping(self, service, field, key): - """Delete a mapping recursively. + def delete_mapping(self, uuid): + """Delete a mapping - :param service: Service the field is applying to. - :param field: Field the mapping is applying to. - :param key: key to delete. + :param uuid: UUID of the mapping to delete. """ diff --git a/cloudkitty/billing/hash/db/sqlalchemy/alembic/versions/3dd7e13527f3_initial_migration.py b/cloudkitty/billing/hash/db/sqlalchemy/alembic/versions/3dd7e13527f3_initial_migration.py new file mode 100644 index 00000000..bdad19a6 --- /dev/null +++ b/cloudkitty/billing/hash/db/sqlalchemy/alembic/versions/3dd7e13527f3_initial_migration.py @@ -0,0 +1,81 @@ +"""Initial migration + +Revision ID: 3dd7e13527f3 +Revises: None +Create Date: 2015-03-10 13:06:41.067563 + +""" + +# revision identifiers, used by Alembic. +revision = '3dd7e13527f3' +down_revision = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table('hashmap_services', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('service_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + sa.UniqueConstraint('service_id'), + mysql_charset='utf8', + mysql_engine='InnoDB' + ) + op.create_table('hashmap_groups', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('group_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('group_id'), + sa.UniqueConstraint('name'), + mysql_charset='utf8', + mysql_engine='InnoDB' + ) + op.create_table('hashmap_fields', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('field_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('service_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['service_id'], ['hashmap_services.id'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('field_id'), + sa.UniqueConstraint('field_id', 'name', name='uniq_field'), + sa.UniqueConstraint('service_id', 'name', name='uniq_map_service_field'), + mysql_charset='utf8', + mysql_engine='InnoDB' + ) + op.create_table('hashmap_maps', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('mapping_id', sa.String(length=36), nullable=False), + sa.Column('value', sa.String(length=255), nullable=True), + sa.Column('cost', sa.Numeric(20, 8), nullable=False), + sa.Column('map_type', sa.Enum('flat', 'rate', name='enum_map_type'), + nullable=False), + sa.Column('service_id', sa.Integer(), nullable=True), + sa.Column('field_id', sa.Integer(), nullable=True), + sa.Column('group_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['field_id'], ['hashmap_fields.id'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['group_id'], ['hashmap_groups.id'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['service_id'], ['hashmap_services.id'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('mapping_id'), + sa.UniqueConstraint('value', 'field_id', name='uniq_field_mapping'), + sa.UniqueConstraint('value', 'service_id', name='uniq_service_mapping'), + mysql_charset='utf8', + mysql_engine='InnoDB' + ) + + +def downgrade(): + op.drop_table('hashmap_maps') + op.drop_table('hashmap_fields') + op.drop_table('hashmap_groups') + op.drop_table('hashmap_services') diff --git a/cloudkitty/billing/hash/db/sqlalchemy/alembic/versions/48676342515a_initial_migration.py b/cloudkitty/billing/hash/db/sqlalchemy/alembic/versions/48676342515a_initial_migration.py deleted file mode 100644 index 9d47eb09..00000000 --- a/cloudkitty/billing/hash/db/sqlalchemy/alembic/versions/48676342515a_initial_migration.py +++ /dev/null @@ -1,60 +0,0 @@ -"""initial migration - -Revision ID: 48676342515a -Revises: None -Create Date: 2014-08-05 17:13:10.323228 - -""" - -# revision identifiers, used by Alembic. -revision = '48676342515a' -down_revision = None - -from alembic import op -import sqlalchemy as sa - - -def upgrade(): - ### commands auto generated by Alembic - please adjust! ### - op.create_table('hashmap_services', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name'), - mysql_charset='utf8', - mysql_engine='InnoDB' - ) - op.create_table('hashmap_fields', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('service_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['service_id'], ['hashmap_services.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('service_id', 'name', name='uniq_map_service_field'), - mysql_charset='utf8', - mysql_engine='InnoDB' - ) - op.create_table('hashmap_maps', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('key', sa.String(length=255), nullable=False), - sa.Column('value', sa.Float(), nullable=False), - sa.Column('map_type', sa.Enum('flat', 'rate', name='enum_map_type'), nullable=False), - sa.Column('field_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['field_id'], ['hashmap_fields.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('key', 'field_id', name='uniq_mapping'), - mysql_charset='utf8', - mysql_engine='InnoDB' - ) - ### end Alembic commands ### - - -def downgrade(): - ### commands auto generated by Alembic - please adjust! ### - op.create_table('hashmap_alembic', - sa.Column('version_num', sa.VARCHAR(length=32), nullable=False) - ) - op.drop_table('hashmap_maps') - op.drop_table('hashmap_fields') - op.drop_table('hashmap_services') - ### end Alembic commands ### diff --git a/cloudkitty/billing/hash/db/sqlalchemy/api.py b/cloudkitty/billing/hash/db/sqlalchemy/api.py index 7db6f34a..744c097f 100644 --- a/cloudkitty/billing/hash/db/sqlalchemy/api.py +++ b/cloudkitty/billing/hash/db/sqlalchemy/api.py @@ -17,6 +17,7 @@ # from oslo.db import exception from oslo.db.sqlalchemy import utils +from oslo.utils import uuidutils import six import sqlalchemy @@ -38,189 +39,309 @@ class HashMap(api.HashMap): def get_migration(self): return migration - def get_service(self, service): + def get_service(self, name=None, uuid=None): session = db.get_session() try: q = session.query(models.HashMapService) - res = q.filter_by( - name=service, - ).one() + if name: + q = q.filter( + models.HashMapService.name == name) + elif uuid: + q = q.filter( + models.HashMapService.service_id == uuid) + else: + raise ValueError('You must specify either name or uuid.') + res = q.one() return res except sqlalchemy.orm.exc.NoResultFound: - raise api.NoSuchService(service) + raise api.NoSuchService(name=name, uuid=uuid) - def get_field(self, service, field): + def get_field(self, uuid=None, service_uuid=None, name=None): session = db.get_session() try: - service_db = self.get_service(service) q = session.query(models.HashMapField) - res = q.filter_by( - service_id=service_db.id, - name=field - ).one() + if uuid: + q = q.filter( + models.HashMapField.field_id == uuid) + elif service_uuid and name: + q = q.join( + models.HashMapField.service) + q = q.filter( + models.HashMapService.service_id == service_uuid, + models.HashMapField.name == name) + else: + raise ValueError('You must specify either an uuid' + ' or a service_uuid and a name.') + res = q.one() return res except sqlalchemy.orm.exc.NoResultFound: - raise api.NoSuchField(service, field) + raise api.NoSuchField(uuid) - def get_mapping(self, service, field, key): + def get_group(self, uuid): session = db.get_session() try: - field_db = self.get_field(service, field) - q = session.query(models.HashMapMapping) - res = q.filter_by( - key=key, - field_id=field_db.id - ).one() + q = session.query(models.HashMapGroup) + q = q.filter( + models.HashMapGroup.group_id == uuid) + res = q.one() return res except sqlalchemy.orm.exc.NoResultFound: - raise api.NoSuchMapping(service, field, key) + raise api.NoSuchGroup(uuid=uuid) + + def get_mapping(self, uuid): + session = db.get_session() + try: + q = session.query(models.HashMapMapping) + q = q.filter( + models.HashMapMapping.mapping_id == uuid) + res = q.one() + return res + except sqlalchemy.orm.exc.NoResultFound: + raise api.NoSuchMapping(uuid) + + def get_group_from_mapping(self, uuid): + session = db.get_session() + try: + q = session.query(models.HashMapGroup) + q = q.join( + models.HashMapGroup.mappings) + q = q.filter( + models.HashMapMapping.mapping_id == uuid) + res = q.one() + return res + except sqlalchemy.orm.exc.NoResultFound: + raise api.MappingHasNoGroup(uuid=uuid) def list_services(self): session = db.get_session() q = session.query(models.HashMapService) res = q.values( - models.HashMapService.name - ) - return res + models.HashMapService.service_id) + return [uuid[0] for uuid in res] - def list_fields(self, service): + def list_fields(self, service_uuid): session = db.get_session() - service_db = self.get_service(service) q = session.query(models.HashMapField) - res = q.filter_by( - service_id=service_db.id - ).values( - models.HashMapField.name - ) - return res + q = q.join( + models.HashMapField.service) + q = q.filter( + models.HashMapService.service_id == service_uuid) + res = q.values(models.HashMapField.field_id) + return [uuid[0] for uuid in res] - def list_mappings(self, service, field): + def list_groups(self): session = db.get_session() - field_db = self.get_field(service, field) - q = session.query(models.HashMapMapping) - res = q.filter_by( - field_id=field_db.id - ).values( - models.HashMapMapping.key - ) - return res + q = session.query(models.HashMapGroup) + res = q.values( + models.HashMapGroup.group_id) + return [uuid[0] for uuid in res] - def create_service(self, service): + def list_mappings(self, + service_uuid=None, + field_uuid=None, + group_uuid=None, + no_group=False): + + session = db.get_session() + q = session.query(models.HashMapMapping) + if service_uuid: + q = q.join( + models.HashMapMapping.service) + q = q.filter( + models.HashMapService.service_id == service_uuid) + elif field_uuid: + q = q.join( + models.HashMapMapping.field) + q = q.filter(models.HashMapField.field_id == field_uuid) + if group_uuid: + q = q.join( + models.HashMapMapping.group) + q = q.filter(models.HashMapGroup.group_id == group_uuid) + elif not service_uuid and not field_uuid: + raise ValueError('You must specify either service_uuid,' + ' field_uuid or group_uuid.') + elif no_group: + q = q.filter(models.HashMapMapping.group_id == None) # noqa + res = q.values( + models.HashMapMapping.mapping_id + ) + return [uuid[0] for uuid in res] + + def create_service(self, name): session = db.get_session() try: with session.begin(): - service_db = models.HashMapService(name=service) + service_db = models.HashMapService(name=name) + service_db.service_id = uuidutils.generate_uuid() session.add(service_db) - session.flush() - # TODO(sheeprine): return object - return service_db + return service_db except exception.DBDuplicateEntry: - raise api.ServiceAlreadyExists(service) + service_db = self.get_service(name=name) + raise api.ServiceAlreadyExists( + service_db.name, + service_db.service_id) - def create_field(self, service, field): - try: - service_db = self.get_service(service) - except api.NoSuchService: - service_db = self.create_service(service) + def create_field(self, service_uuid, name): + service_db = self.get_service(uuid=service_uuid) session = db.get_session() try: with session.begin(): field_db = models.HashMapField( service_id=service_db.id, - name=field) + name=name, + field_id=uuidutils.generate_uuid()) session.add(field_db) - session.flush() - # TODO(sheeprine): return object - return field_db + return field_db except exception.DBDuplicateEntry: - raise api.FieldAlreadyExists(field) + field_db = self.get_field(service_uuid=service_uuid, + name=name) + raise api.FieldAlreadyExists(field_db.name, field_db.field_id) - def create_mapping(self, service, field, key, value, map_type='rate'): + def create_group(self, name): + session = db.get_session() try: - field_db = self.get_field(service, field) - except (api.NoSuchField, api.NoSuchService): - field_db = self.create_field(service, field) + with session.begin(): + group_db = models.HashMapGroup( + name=name, + group_id=uuidutils.generate_uuid()) + session.add(group_db) + return group_db + except exception.DBDuplicateEntry: + raise api.GroupAlreadyExists(name, group_db.group_id) + + def create_mapping(self, + cost, + map_type='rate', + value=None, + service_id=None, + field_id=None, + group_id=None): + if field_id and service_id: + raise ValueError('You can only specify one parent.') + field_fk = None + if field_id: + field_db = self.get_field(uuid=field_id) + field_fk = field_db.id + service_fk = None + if service_id: + service_db = self.get_service(uuid=service_id) + service_fk = service_db.id + if not value and not service_id: + raise ValueError('You must either specify a value' + ' or a service_id') + elif value and service_id: + raise ValueError('You can\'t specify a value' + ' and a service_id') + if group_id: + group_db = self.get_group(uuid=group_id) session = db.get_session() try: with session.begin(): field_map = models.HashMapMapping( - field_id=field_db.id, - key=key, + mapping_id=uuidutils.generate_uuid(), value=value, + cost=cost, + field_id=field_fk, + service_id=service_fk, map_type=map_type) + if group_id: + field_map.group_id = group_db.id session.add(field_map) - # TODO(sheeprine): return object - return field_map + return field_map except exception.DBDuplicateEntry: - raise api.MappingAlreadyExists(key) + raise api.MappingAlreadyExists(value, field_map.field_id) + except exception.DBError: + raise api.NoSuchType(map_type) - def update_mapping(self, service, field, key, **kwargs): - field_db = self.get_field(service, field) + def update_mapping(self, uuid, **kwargs): session = db.get_session() try: with session.begin(): q = session.query(models.HashMapMapping) - field_map = q.filter_by( - key=key, - field_id=field_db.id - ).with_lockmode('update').one() + q = q.filter( + models.HashMapMapping.mapping_id == uuid + ) + mapping_db = q.with_lockmode('update').one() if kwargs: + # Resolve FK + if 'group_id' in kwargs: + group_id = kwargs.pop('group_id') + if group_id: + group_db = self.get_group(group_id) + mapping_db.group_id = group_db.id + # Service and Field shouldn't be updated + excluded_cols = ['mapping_id', 'service_id', 'field_id'] + for col in excluded_cols: + if col in kwargs: + kwargs.pop(col) for attribute, value in six.iteritems(kwargs): - if hasattr(field_map, attribute): - setattr(field_map, attribute, value) + if hasattr(mapping_db, attribute): + setattr(mapping_db, attribute, value) else: raise ValueError('No such attribute: {}'.format( attribute)) else: raise ValueError('No attribute to update.') - return field_map + return mapping_db except sqlalchemy.orm.exc.NoResultFound: - raise api.NoSuchMapping(service, field, key) + raise api.NoSuchMapping(uuid) - def update_or_create_mapping(self, service, field, key, **kwargs): - try: - return self.create_mapping( - service, - field, - key, - **kwargs - ) - except api.MappingAlreadyExists: - return self.update_mapping(service, field, key, **kwargs) - - def delete_service(self, service): + def delete_service(self, name=None, uuid=None): session = db.get_session() - r = utils.model_query( + q = utils.model_query( models.HashMapService, session - ).filter_by( - name=service, - ).delete() + ) + if name: + q = q.filter_by(name=name) + elif uuid: + q = q.filter_by(service_id=uuid) + else: + raise ValueError('You must specify either name or uuid.') + r = q.delete() if not r: - raise api.NoSuchService(service) + raise api.NoSuchService(name, uuid) - def delete_field(self, service, field): + def delete_field(self, uuid): session = db.get_session() - service_db = self.get_service(service) - r = utils.model_query( + q = utils.model_query( models.HashMapField, session - ).filter_by( - service_id=service_db.id, - name=field, - ).delete() + ) + q = q.filter_by( + field_id=uuid + ) + r = q.delete() if not r: - raise api.NoSuchField(service, field) + raise api.NoSuchField(uuid) - def delete_mapping(self, service, field, key): + def delete_group(self, uuid, recurse=True): session = db.get_session() - field = self.get_field(service, field) - r = utils.model_query( - models.HashMapMapping, + q = utils.model_query( + models.HashMapGroup, session ).filter_by( - field_id=field.id, - key=key, - ).delete() + group_id=uuid, + ) + with session.begin(): + try: + r = q.with_lockmode('update').one() + except sqlalchemy.orm.exc.NoResultFound: + raise api.NoSuchGroup(uuid=uuid) + if recurse: + for mapping in r.mappings: + session.delete(mapping) + q.delete() + + def delete_mapping(self, uuid): + session = db.get_session() + q = utils.model_query( + models.HashMapMapping, + session + ) + q = q.filter_by( + mapping_id=uuid + ) + r = q.delete() if not r: - raise api.NoSuchMapping(service, field, key) + raise api.NoSuchMapping(uuid) diff --git a/cloudkitty/billing/hash/db/sqlalchemy/models.py b/cloudkitty/billing/hash/db/sqlalchemy/models.py index b1b6d323..fcad75a8 100644 --- a/cloudkitty/billing/hash/db/sqlalchemy/models.py +++ b/cloudkitty/billing/hash/db/sqlalchemy/models.py @@ -21,35 +21,75 @@ from sqlalchemy.ext import declarative from sqlalchemy import orm from sqlalchemy import schema - Base = declarative.declarative_base() class HashMapBase(models.ModelBase): __table_args__ = {'mysql_charset': "utf8", 'mysql_engine': "InnoDB"} + fk_to_resolve = {} + + def save(self, session=None): + from cloudkitty import db + + if session is None: + session = db.get_session() + + super(HashMapBase, self).save(session=session) + + def as_dict(self): + d = {} + for c in self.__table__.columns: + if c.name == 'id': + continue + d[c.name] = self[c.name] + return d + + def _recursive_resolve(self, path): + obj = self + for attr in path.split('.'): + if hasattr(obj, attr): + obj = getattr(obj, attr) + else: + return None + return obj + + def export_model(self): + res = self.as_dict() + for fk, mapping in self.fk_to_resolve.items(): + res[fk] = self._recursive_resolve(mapping) + return res class HashMapService(Base, HashMapBase): """An hashmap service. """ - __tablename__ = 'hashmap_services' id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + service_id = sqlalchemy.Column(sqlalchemy.String(36), + nullable=False, + unique=True) name = sqlalchemy.Column( sqlalchemy.String(255), nullable=False, unique=True ) - fields = orm.relationship('HashMapField') + fields = orm.relationship('HashMapField', + backref=orm.backref( + 'service', + lazy='immediate')) + mappings = orm.relationship('HashMapMapping', + backref=orm.backref( + 'service', + lazy='immediate')) def __repr__(self): - return ('').format( - id=self.id, + uuid=self.service_id, service=self.name) @@ -57,18 +97,23 @@ class HashMapField(Base, HashMapBase): """An hashmap field. """ - __tablename__ = 'hashmap_fields' + fk_to_resolve = {'service_id': 'service.service_id'} @declarative.declared_attr def __table_args__(cls): - args = (schema.UniqueConstraint('service_id', 'name', + args = (schema.UniqueConstraint('field_id', 'name', + name='uniq_field'), + schema.UniqueConstraint('service_id', 'name', name='uniq_map_service_field'), HashMapBase.__table_args__,) return args id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + field_id = sqlalchemy.Column(sqlalchemy.String(36), + nullable=False, + unique=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) service_id = sqlalchemy.Column( @@ -77,48 +122,92 @@ class HashMapField(Base, HashMapBase): ondelete='CASCADE'), nullable=False ) - field_maps = orm.relationship('HashMapMapping') + mappings = orm.relationship('HashMapMapping', + backref=orm.backref( + 'field', + lazy='immediate')) def __repr__(self): - return ('').format( - id=self.id, - field=self.field) + uuid=self.field_id, + field=self.name) + + +class HashMapGroup(Base, HashMapBase): + """A grouping of hashmap calculations. + + """ + __tablename__ = 'hashmap_groups' + + id = sqlalchemy.Column(sqlalchemy.Integer, + primary_key=True) + group_id = sqlalchemy.Column(sqlalchemy.String(36), + nullable=False, + unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), + nullable=False, + unique=True) + mappings = orm.relationship('HashMapMapping', + backref=orm.backref( + 'group', + lazy='immediate')) + + def __repr__(self): + return ('').format( + uuid=self.group_id, + name=self.name) class HashMapMapping(Base, HashMapBase): """A mapping between a field a value and a type. """ - __tablename__ = 'hashmap_maps' + fk_to_resolve = {'service_id': 'service.service_id', + 'field_id': 'field.field_id', + 'group_id': 'group.group_id'} @declarative.declared_attr def __table_args__(cls): - args = (schema.UniqueConstraint('key', 'field_id', - name='uniq_mapping'), + args = (schema.UniqueConstraint('value', 'field_id', + name='uniq_field_mapping'), + schema.UniqueConstraint('value', 'service_id', + name='uniq_service_mapping'), HashMapBase.__table_args__,) return args id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) - key = sqlalchemy.Column(sqlalchemy.String(255), - nullable=False) - value = sqlalchemy.Column(sqlalchemy.Float, - nullable=False) + mapping_id = sqlalchemy.Column(sqlalchemy.String(36), + nullable=False, + unique=True) + value = sqlalchemy.Column(sqlalchemy.String(255), + nullable=True) + cost = sqlalchemy.Column(sqlalchemy.Numeric(20, 8), + nullable=False) map_type = sqlalchemy.Column(sqlalchemy.Enum('flat', 'rate', name='enum_map_type'), nullable=False) + service_id = sqlalchemy.Column(sqlalchemy.Integer, + sqlalchemy.ForeignKey('hashmap_services.id', + ondelete='CASCADE'), + nullable=True) field_id = sqlalchemy.Column(sqlalchemy.Integer, sqlalchemy.ForeignKey('hashmap_fields.id', ondelete='CASCADE'), - nullable=False) + nullable=True) + group_id = sqlalchemy.Column(sqlalchemy.Integer, + sqlalchemy.ForeignKey('hashmap_groups.id', + ondelete='SET NULL'), + nullable=True) def __repr__(self): - return ('').format( - id=self.id, + return ('').format( + uuid=self.mapping_id, map_type=self.map_type, - key=self.key, - value=self.value) + value=self.value, + cost=self.cost) diff --git a/cloudkitty/tests/__init__.py b/cloudkitty/tests/__init__.py index 85db5fd5..0a07c41b 100644 --- a/cloudkitty/tests/__init__.py +++ b/cloudkitty/tests/__init__.py @@ -16,13 +16,14 @@ # @author: Gauvain Pocentek # from oslo.config import fixture as config_fixture +from oslotest import base import testscenarios -import testtools +from cloudkitty import db from cloudkitty.db import api as db_api -class TestCase(testscenarios.TestWithScenarios, testtools.TestCase): +class TestCase(testscenarios.TestWithScenarios, base.BaseTestCase): scenarios = [ ('sqlite', dict(db_url='sqlite:///')) ] @@ -34,3 +35,7 @@ class TestCase(testscenarios.TestWithScenarios, testtools.TestCase): self.conn = db_api.get_instance() migration = self.conn.get_migration() migration.upgrade('head') + + def tearDown(self): + db.get_engine().dispose() + super(TestCase, self).tearDown() diff --git a/cloudkitty/tests/test_hashmap.py b/cloudkitty/tests/test_hashmap.py new file mode 100644 index 00000000..b46f75e4 --- /dev/null +++ b/cloudkitty/tests/test_hashmap.py @@ -0,0 +1,520 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 + +import mock +from oslo.utils import uuidutils + +from cloudkitty.billing import hash +from cloudkitty.billing.hash.db import api +from cloudkitty import tests + +TEST_TS = 1388577600 +FAKE_UUID = '6c1b8a30-797f-4b7e-ad66-9879b79059fb' +CK_RESOURCES_DATA = [{ + "period": { + "begin": "2014-10-01T00:00:00", + "end": "2014-10-01T01:00:00"}, + "usage": { + "compute": [ + { + "desc": { + "availability_zone": "nova", + "flavor": "m1.nano", + "image_id": "f5600101-8fa2-4864-899e-ebcb7ed6b568", + "memory": "64", + "metadata": { + "farm": "prod"}, + "name": "prod1", + "project_id": "f266f30b11f246b589fd266f85eeec39", + "user_id": "55b3379b949243009ee96972fbf51ed1", + "vcpus": "1"}, + "vol": { + "qty": 1, + "unit": "instance"} + }, + { + "desc": { + "availability_zone": "nova", + "flavor": "m1.tiny", + "image_id": "a41fba37-2429-4f15-aa00-b5bc4bf557bf", + "memory": "512", + "metadata": { + "farm": "dev"}, + "name": "dev1", + "project_id": "f266f30b11f246b589fd266f85eeec39", + "user_id": "55b3379b949243009ee96972fbf51ed1", + "vcpus": "1"}, + "vol": { + "qty": 1, + "unit": "instance"}}, + { + "desc": { + "availability_zone": "nova", + "flavor": "m1.nano", + "image_id": "a41fba37-2429-4f15-aa00-b5bc4bf557bf", + "memory": "64", + "metadata": { + "farm": "dev"}, + "name": "dev2", + "project_id": "f266f30b11f246b589fd266f85eeec39", + "user_id": "55b3379b949243009ee96972fbf51ed1", + "vcpus": "1"}, + "vol": { + "qty": 1, + "unit": "instance"}}]}}] + + +class HashMapRatingTest(tests.TestCase): + def setUp(self): + super(HashMapRatingTest, self).setUp() + self._tenant_id = 'f266f30b11f246b589fd266f85eeec39' + self._db_api = hash.HashMap.db_api + self._db_api.get_migration().upgrade('head') + self._hash = hash.HashMap(self._tenant_id) + + # Group tests + @mock.patch.object(uuidutils, 'generate_uuid', + return_value=FAKE_UUID) + def test_create_group(self, patch_generate_uuid): + self._db_api.create_group('test_group') + groups = self._db_api.list_groups() + self.assertEqual([FAKE_UUID], groups) + patch_generate_uuid.assert_called_once() + + def test_create_duplicate_group(self): + self._db_api.create_group('test_group') + self.assertRaises(api.GroupAlreadyExists, + self._db_api.create_group, + 'test_group') + + def test_delete_group(self): + group_db = self._db_api.create_group('test_group') + self._db_api.delete_group(group_db.group_id) + groups = self._db_api.list_groups() + self.assertEqual([], groups) + + def test_delete_unknown_group(self): + self.assertRaises(api.NoSuchGroup, + self._db_api.delete_group, + uuidutils.generate_uuid()) + + def test_recursive_delete_group(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + group_db = self._db_api.create_group('test_group') + self._db_api.create_mapping( + value='m1.tiny', + cost='1.337', + map_type='flat', + field_id=field_db.field_id, + group_id=group_db.group_id) + self._db_api.delete_group(group_db.group_id) + mappings = self._db_api.list_mappings(field_uuid=field_db.field_id) + self.assertEqual([], mappings) + groups = self._db_api.list_groups() + self.assertEqual([], groups) + + def test_non_recursive_delete_group(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + group_db = self._db_api.create_group('test_group') + mapping_db = self._db_api.create_mapping( + value='m1.tiny', + cost='1.337', + map_type='flat', + field_id=field_db.field_id, + group_id=group_db.group_id) + self._db_api.delete_group(group_db.group_id, False) + mappings = self._db_api.list_mappings(field_uuid=field_db.field_id) + self.assertEqual([mapping_db.mapping_id], mappings) + groups = self._db_api.list_groups() + self.assertEqual([], groups) + new_mapping_db = self._db_api.get_mapping(mapping_db.mapping_id) + self.assertEqual(None, new_mapping_db.group_id) + + def test_list_mappings_from_group(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + group_db = self._db_api.create_group('test_group') + mapping_tiny = self._db_api.create_mapping( + value='m1.tiny', + cost='1.337', + map_type='flat', + field_id=field_db.field_id, + group_id=group_db.group_id) + mapping_small = self._db_api.create_mapping( + value='m1.small', + cost='3.1337', + map_type='flat', + field_id=field_db.field_id, + group_id=group_db.group_id) + self._db_api.create_mapping( + value='m1.large', + cost='42', + map_type='flat', + field_id=field_db.field_id) + mappings = self._db_api.list_mappings(field_uuid=field_db.field_id, + group_uuid=group_db.group_id) + self.assertEqual([mapping_tiny.mapping_id, + mapping_small.mapping_id], + mappings) + + def test_list_mappings_without_group(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + group_db = self._db_api.create_group('test_group') + self._db_api.create_mapping( + value='m1.tiny', + cost='1.337', + map_type='flat', + field_id=field_db.field_id, + group_id=group_db.group_id) + self._db_api.create_mapping( + value='m1.small', + cost='3.1337', + map_type='flat', + field_id=field_db.field_id, + group_id=group_db.group_id) + mapping_no_group = self._db_api.create_mapping( + value='m1.large', + cost='42', + map_type='flat', + field_id=field_db.field_id) + mappings = self._db_api.list_mappings(field_uuid=field_db.field_id, + no_group=True) + self.assertEqual([mapping_no_group.mapping_id], + mappings) + + # Service tests + @mock.patch.object(uuidutils, 'generate_uuid', + return_value=FAKE_UUID) + def test_create_service(self, patch_generate_uuid): + self._db_api.create_service('compute') + services = self._db_api.list_services() + self.assertEqual([FAKE_UUID], services) + patch_generate_uuid.assert_called_once() + + def test_create_duplicate_service(self): + self._db_api.create_service('compute') + self.assertRaises(api.ServiceAlreadyExists, + self._db_api.create_service, + 'compute') + + def test_delete_service_by_name(self): + self._db_api.create_service('compute') + self._db_api.delete_service('compute') + services = self._db_api.list_services() + self.assertEqual([], services) + + def test_delete_service_by_uuid(self): + service_db = self._db_api.create_service('compute') + self._db_api.delete_service(uuid=service_db.service_id) + services = self._db_api.list_services() + self.assertEqual([], services) + + def test_delete_unknown_service_by_name(self): + self.assertRaises(api.NoSuchService, + self._db_api.delete_service, + 'dummy') + + def test_delete_unknown_service_by_uuid(self): + self.assertRaises( + api.NoSuchService, + self._db_api.delete_service, + uuid='6e8de9fc-ee17-4b60-b81a-c9320e994e76') + + # Field tests + def test_create_field_in_existing_service(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + fields = self._db_api.list_fields(service_db.service_id) + self.assertEqual([field_db.field_id], fields) + + def test_create_duplicate_field(self): + service_db = self._db_api.create_service('compute') + self._db_api.create_field(service_db.service_id, + 'flavor') + self.assertRaises(api.FieldAlreadyExists, + self._db_api.create_field, + service_db.service_id, + 'flavor') + + def test_delete_field(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, 'flavor') + self._db_api.delete_field(field_db.field_id) + services = self._db_api.list_services() + self.assertEqual([service_db.service_id], services) + fields = self._db_api.list_fields(service_db.service_id) + self.assertEqual([], fields) + + def test_delete_unknown_field(self): + self.assertRaises(api.NoSuchField, + self._db_api.delete_field, + uuidutils.generate_uuid()) + + def test_recursive_delete_field_from_service(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + self._db_api.delete_service(uuid=service_db.service_id) + self.assertRaises(api.NoSuchField, + self._db_api.get_field, + field_db.field_id) + + # Mapping tests + def test_create_mapping(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + mapping_db = self._db_api.create_mapping( + value='m1.tiny', + cost='1.337', + map_type='flat', + field_id=field_db.field_id) + mappings = self._db_api.list_mappings(field_uuid=field_db.field_id) + self.assertEqual([mapping_db.mapping_id], mappings) + + def test_list_mappings_from_services(self): + service_db = self._db_api.create_service('compute') + mapping_db = self._db_api.create_mapping( + cost='1.337', + map_type='flat', + service_id=service_db.service_id) + mappings = self._db_api.list_mappings( + service_uuid=service_db.service_id) + self.assertEqual([mapping_db.mapping_id], mappings) + + def test_list_mappings_from_fields(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + mapping_db = self._db_api.create_mapping( + value='m1.tiny', + cost='1.337', + map_type='flat', + field_id=field_db.field_id) + mappings = self._db_api.list_mappings( + field_uuid=field_db.field_id) + self.assertEqual([mapping_db.mapping_id], mappings) + + def test_create_mapping_with_incorrect_type(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + self.assertRaises(api.NoSuchType, + self._db_api.create_mapping, + value='m1.tiny', + cost='1.337', + map_type='invalid', + field_id=field_db.field_id) + + def test_create_mapping_with_two_parents(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + self.assertRaises(ValueError, + self._db_api.create_mapping, + value='m1.tiny', + cost='1.337', + map_type='flat', + service_id=service_db.service_id, + field_id=field_db.field_id) + + def test_update_mapping(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + mapping_db = self._db_api.create_mapping( + value='m1.tiny', + cost='1.337', + map_type='flat', + field_id=field_db.field_id) + new_mapping_db = self._db_api.update_mapping( + uuid=mapping_db.mapping_id, + value='42', + map_type='rate') + self.assertEqual('42', new_mapping_db.value) + self.assertEqual('rate', new_mapping_db.map_type) + + def test_update_mapping_inside_group(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + mapping_db = self._db_api.create_mapping( + value='m1.tiny', + cost='1.337', + map_type='flat', + field_id=field_db.field_id) + group_db = self._db_api.create_group('test_group') + new_mapping_db = self._db_api.update_mapping( + mapping_db.mapping_id, + value='42', + map_type='rate', + group_id=group_db.group_id) + self.assertEqual('42', new_mapping_db.value) + self.assertEqual('rate', new_mapping_db.map_type) + self.assertEqual(group_db.id, new_mapping_db.group_id) + + def test_delete_mapping(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + mapping_db = self._db_api.create_mapping( + value='m1.tiny', + cost='1.337', + map_type='flat', + field_id=field_db.field_id) + self._db_api.delete_mapping(mapping_db.mapping_id) + mappings = self._db_api.list_mappings(field_uuid=field_db.field_id) + self.assertEqual([], mappings) + + # Processing tests + def test_load_billing_rates(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + group_db = self._db_api.create_group('test_group') + self._db_api.create_mapping( + cost='1.42', + map_type='rate', + service_id=service_db.service_id) + self._db_api.create_mapping( + value='m1.tiny', + cost='1.337', + map_type='flat', + field_id=field_db.field_id) + self._db_api.create_mapping( + value='m1.large', + cost='13.37', + map_type='rate', + field_id=field_db.field_id, + group_id=group_db.group_id) + self._hash.reload_config() + service_expect = { + 'compute': { + '_DEFAULT_': { + 'cost': decimal.Decimal('1.42'), + 'type': 'rate'}}} + + field_expect = { + 'compute': { + 'flavor': { + '_DEFAULT_': { + 'm1.tiny': { + 'cost': decimal.Decimal('1.337'), + 'type': 'flat'}}, + 'test_group': { + 'm1.large': { + 'cost': decimal.Decimal('13.37'), + 'type': 'rate'}}}}} + self.assertEqual(service_expect, + self._hash._service_mappings) + self.assertEqual(field_expect, + self._hash._field_mappings) + + def test_process_service_map(self): + service_db = self._db_api.create_service('compute') + group_db = self._db_api.create_group('test_group') + self._db_api.create_mapping( + cost='1.337', + map_type='flat', + service_id=service_db.service_id, + group_id=group_db.group_id) + self._db_api.create_mapping( + cost='1.42', + map_type='flat', + service_id=service_db.service_id) + self._hash.reload_config() + actual_data = CK_RESOURCES_DATA[:] + expected_data = CK_RESOURCES_DATA[:] + compute_list = expected_data[0]['usage']['compute'] + compute_list[0]['billing'] = {'price': decimal.Decimal('2.757')} + compute_list[1]['billing'] = {'price': decimal.Decimal('2.757')} + compute_list[2]['billing'] = {'price': decimal.Decimal('2.757')} + self._hash.process(actual_data) + self.assertEqual(expected_data, actual_data) + + def test_process_field_map(self): + service_db = self._db_api.create_service('compute') + flavor_field = self._db_api.create_field(service_db.service_id, + 'flavor') + image_field = self._db_api.create_field(service_db.service_id, + 'image_id') + group_db = self._db_api.create_group('test_group') + self._db_api.create_mapping( + value='m1.nano', + cost='1.337', + map_type='flat', + field_id=flavor_field.field_id, + group_id=group_db.group_id) + self._db_api.create_mapping( + value='a41fba37-2429-4f15-aa00-b5bc4bf557bf', + cost='1.10', + map_type='rate', + field_id=image_field.field_id, + group_id=group_db.group_id) + self._db_api.create_mapping( + value='m1.tiny', + cost='1.42', + map_type='flat', + field_id=flavor_field.field_id) + self._hash.reload_config() + actual_data = CK_RESOURCES_DATA[:] + expected_data = CK_RESOURCES_DATA[:] + compute_list = expected_data[0]['usage']['compute'] + compute_list[0]['billing'] = {'price': decimal.Decimal('1.337')} + compute_list[1]['billing'] = {'price': decimal.Decimal('1.42')} + compute_list[2]['billing'] = {'price': decimal.Decimal('1.47070')} + self._hash.process(actual_data) + self.assertEqual(expected_data, actual_data) + + def test_process_billing(self): + service_db = self._db_api.create_service('compute') + field_db = self._db_api.create_field(service_db.service_id, + 'flavor') + self._db_api.create_group('test_group') + self._db_api.create_mapping( + cost='1.00', + map_type='flat', + service_id=service_db.service_id) + self._db_api.create_mapping( + value='m1.nano', + cost='1.337', + map_type='flat', + field_id=field_db.field_id) + self._db_api.create_mapping( + value='m1.tiny', + cost='1.42', + map_type='flat', + field_id=field_db.field_id) + self._hash.reload_config() + actual_data = CK_RESOURCES_DATA[:] + expected_data = CK_RESOURCES_DATA[:] + compute_list = expected_data[0]['usage']['compute'] + compute_list[0]['billing'] = {'price': decimal.Decimal('1.337')} + compute_list[1]['billing'] = {'price': decimal.Decimal('1.42')} + compute_list[2]['billing'] = {'price': decimal.Decimal('1.42')} + self._hash.process(actual_data) + self.assertEqual(expected_data, actual_data) diff --git a/doc/source/webapi/billing/hashmap.rst b/doc/source/webapi/billing/hashmap.rst index 5ecaf3f6..54a73895 100644 --- a/doc/source/webapi/billing/hashmap.rst +++ b/doc/source/webapi/billing/hashmap.rst @@ -2,26 +2,41 @@ HashMap Module REST API ======================= -.. rest-controller:: cloudkitty.billing.hash:BasicHashMapConfigController +.. rest-controller:: cloudkitty.billing.hash.controllers.root:HashMapConfigController :webprefix: /v1/billing/module_config/hashmap -.. http:get:: /v1/billing/hashmap/modules/config/(service)/(field)/(key) +.. rest-controller:: cloudkitty.billing.hash.controllers.service:HashMapServicesController + :webprefix: /v1/billing/module_config/hashmap/services - Get a mapping from full path - - :param service: Filter on this service. - :param field: Filter on this field. - :param key: Filter on this key. - :type service: :class:`unicode` - :type field: :class:`unicode` - :type key: :class:`unicode` - :type mapping: :class:`Mapping` - :return: A mapping - - :return type: :class:`Mapping` - - -.. autotype:: cloudkitty.billing.hash.Mapping +.. autotype:: cloudkitty.billing.hash.datamodels.service.Service :members: +.. autotype:: cloudkitty.billing.hash.datamodels.service.ServiceCollection + :members: +.. rest-controller:: cloudkitty.billing.hash.controllers.field:HashMapFieldsController + :webprefix: /v1/billing/module_config/hashmap/fields + +.. autotype:: cloudkitty.billing.hash.datamodels.field.Field + :members: + +.. autotype:: cloudkitty.billing.hash.datamodels.field.FieldCollection + :members: + +.. rest-controller:: cloudkitty.billing.hash.controllers.mapping:HashMapMappingsController + :webprefix: /v1/billing/module_config/hashmap/mappings + +.. autotype:: cloudkitty.billing.hash.datamodels.mapping.Mapping + :members: + +.. autotype:: cloudkitty.billing.hash.datamodels.mapping.MappingCollection + :members: + +.. rest-controller:: cloudkitty.billing.hash.controllers.group:HashMapGroupsController + :webprefix: /v1/billing/module_config/hashmap/groups + +.. autotype:: cloudkitty.billing.hash.datamodels.group.Group + :members: + +.. autotype:: cloudkitty.billing.hash.datamodels.group.GroupCollection + :members: diff --git a/setup.cfg b/setup.cfg index e9fffcfb..0fdeed95 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,7 +39,7 @@ cloudkitty.transformers = cloudkitty.billing.processors = noop = cloudkitty.billing.noop:Noop - hashmap = cloudkitty.billing.hash:BasicHashMap + hashmap = cloudkitty.billing.hash:HashMap cloudkitty.storage.backends = sqlalchemy = cloudkitty.storage.sqlalchemy:SQLAlchemyStorage diff --git a/test-requirements.txt b/test-requirements.txt index 1b688b04..4b3bf3c6 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,6 @@ hacking>=0.9.2,<0.10 coverage>=3.6 discover -testtools testscenarios testrepository mock>=1.0