New HashMap rating module version

This commit is breaking the old HashMap database models and
configuration, migrations can't be applied.

- Renamed BasicHashMap to HashMap.
- Refactored all the API.
- Changed the way configuration is handled:
  - Every model is now accessed via UUID.
  - Every model can be seen as "flat" from the new API.
- Added calculation grouping, you can now apply multiple HashMap
  calculations for different metrics, the sum of every group is then
  applied.
- Added two levels of mapping, directly on the service name and on
  metadata field.
- Refactored HashMap to handle the new core API.
- Fixed problems with SQL and Numeric truncating data.
- Optimized SQL requests and models to use joins and filters.
- Added unit tests for the hashmap module.

Change-Id: Ibb63b0ac88eb92bec42497e0d72bf9800ea7379c
This commit is contained in:
Stéphane Albert 2015-03-02 14:45:20 +01:00
parent 8ee7535cae
commit 2db226974d
23 changed files with 2044 additions and 531 deletions

View File

@ -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.

View File

@ -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

View File

@ -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))

View File

@ -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))

View File

@ -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))

View File

@ -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

View File

@ -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))

View File

@ -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])

View File

@ -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])

View File

@ -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])

View File

@ -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])

View File

@ -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.
"""

View File

@ -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')

View File

@ -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 ###

View File

@ -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)

View File

@ -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 ('<HashMapService[{id}]: '
return ('<HashMapService[{uuid}]: '
'service={service}>').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 ('<HashMapField[{id}]: '
return ('<HashMapField[{uuid}]: '
'field={field}>').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 ('<HashMapGroup[{uuid}]: '
'name={name}>').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 ('<HashMapMapping[{id}]: '
'type={map_type} {key}={value}>').format(
id=self.id,
return ('<HashMapMapping[{uuid}]: '
'type={map_type} {value}={cost}>').format(
uuid=self.mapping_id,
map_type=self.map_type,
key=self.key,
value=self.value)
value=self.value,
cost=self.cost)

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -1,7 +1,6 @@
hacking>=0.9.2,<0.10
coverage>=3.6
discover
testtools
testscenarios
testrepository
mock>=1.0