# Copyright 2013 Red Hat, Inc # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. from oslo_db import exception as db_exc from oslo_db.sqlalchemy import utils as sqlalchemyutils from oslo_utils import versionutils import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy import sql from sqlalchemy.sql import expression import nova.conf from nova.db.api import api as api_db_api from nova.db.api import models as api_models from nova.db import utils as db_utils from nova import exception from nova.notifications.objects import base as notification from nova.notifications.objects import flavor as flavor_notification from nova import objects from nova.objects import base from nova.objects import fields OPTIONAL_FIELDS = ['extra_specs', 'projects'] # Remove these fields in version 2.0 of the object. DEPRECATED_FIELDS = ['deleted', 'deleted_at'] # Non-joined fields which can be updated. MUTABLE_FIELDS = set(['description']) CONF = nova.conf.CONF def _dict_with_extra_specs(flavor_model): extra_specs = {x['key']: x['value'] for x in flavor_model['extra_specs']} return dict(flavor_model, extra_specs=extra_specs) # NOTE(danms): There are some issues with the oslo_db context manager # decorators with static methods. We pull these out for now and can # move them back into the actual staticmethods on the object when those # issues are resolved. @api_db_api.context_manager.reader def _get_projects_from_db(context, flavorid): db_flavor = context.session.query(api_models.Flavors).filter_by( flavorid=flavorid ).options( orm.joinedload(api_models.Flavors.projects) ).first() if not db_flavor: raise exception.FlavorNotFound(flavor_id=flavorid) return [x['project_id'] for x in db_flavor['projects']] @api_db_api.context_manager.writer def _flavor_add_project(context, flavor_id, project_id): project = api_models.FlavorProjects() project.update({'flavor_id': flavor_id, 'project_id': project_id}) try: project.save(context.session) except db_exc.DBDuplicateEntry: raise exception.FlavorAccessExists(flavor_id=flavor_id, project_id=project_id) @api_db_api.context_manager.writer def _flavor_del_project(context, flavor_id, project_id): result = context.session.query(api_models.FlavorProjects).\ filter_by(project_id=project_id).\ filter_by(flavor_id=flavor_id).\ delete() if result == 0: raise exception.FlavorAccessNotFound(flavor_id=flavor_id, project_id=project_id) @api_db_api.context_manager.writer def _flavor_extra_specs_add(context, flavor_id, specs, max_retries=10): writer = api_db_api.context_manager.writer for attempt in range(max_retries): try: spec_refs = context.session.query( api_models.FlavorExtraSpecs).\ filter_by(flavor_id=flavor_id).\ filter(api_models.FlavorExtraSpecs.key.in_( specs.keys())).\ all() existing_keys = set() for spec_ref in spec_refs: key = spec_ref["key"] existing_keys.add(key) with writer.savepoint.using(context): spec_ref.update({"value": specs[key]}) for key, value in specs.items(): if key in existing_keys: continue spec_ref = api_models.FlavorExtraSpecs() with writer.savepoint.using(context): spec_ref.update({"key": key, "value": value, "flavor_id": flavor_id}) context.session.add(spec_ref) return specs except db_exc.DBDuplicateEntry: # a concurrent transaction has been committed, # try again unless this was the last attempt if attempt == max_retries - 1: raise exception.FlavorExtraSpecUpdateCreateFailed( id=flavor_id, retries=max_retries) @api_db_api.context_manager.writer def _flavor_extra_specs_del(context, flavor_id, key): result = context.session.query(api_models.FlavorExtraSpecs).\ filter_by(flavor_id=flavor_id).\ filter_by(key=key).\ delete() if result == 0: raise exception.FlavorExtraSpecsNotFound( extra_specs_key=key, flavor_id=flavor_id) @api_db_api.context_manager.writer def _flavor_create(context, values): specs = values.get('extra_specs') db_specs = [] if specs: for k, v in specs.items(): db_spec = api_models.FlavorExtraSpecs() db_spec['key'] = k db_spec['value'] = v db_specs.append(db_spec) projects = values.get('projects') db_projects = [] if projects: for project in set(projects): db_project = api_models.FlavorProjects() db_project['project_id'] = project db_projects.append(db_project) values['extra_specs'] = db_specs values['projects'] = db_projects db_flavor = api_models.Flavors() db_flavor.update(values) try: db_flavor.save(context.session) except db_exc.DBDuplicateEntry as e: if 'flavorid' in e.columns: raise exception.FlavorIdExists(flavor_id=values['flavorid']) raise exception.FlavorExists(name=values['name']) except Exception as e: raise db_exc.DBError(e) return _dict_with_extra_specs(db_flavor) @api_db_api.context_manager.writer def _flavor_destroy(context, flavor_id=None, flavorid=None): query = context.session.query(api_models.Flavors) if flavor_id is not None: query = query.filter(api_models.Flavors.id == flavor_id) else: query = query.filter(api_models.Flavors.flavorid == flavorid) result = query.first() if not result: raise exception.FlavorNotFound(flavor_id=(flavor_id or flavorid)) context.session.query(api_models.FlavorProjects).\ filter_by(flavor_id=result.id).delete() context.session.query(api_models.FlavorExtraSpecs).\ filter_by(flavor_id=result.id).delete() context.session.delete(result) return result # TODO(berrange): Remove NovaObjectDictCompat # TODO(mriedem): Remove NovaPersistentObject in version 2.0 @base.NovaObjectRegistry.register class Flavor(base.NovaPersistentObject, base.NovaObject, base.NovaObjectDictCompat): # Version 1.0: Initial version # Version 1.1: Added save_projects(), save_extra_specs(), removed # remotable from save() # Version 1.2: Added description field. Note: this field should not be # persisted with the embedded instance.flavor. VERSION = '1.2' fields = { 'id': fields.IntegerField(), 'name': fields.StringField(nullable=True), 'memory_mb': fields.IntegerField(), 'vcpus': fields.IntegerField(), 'root_gb': fields.IntegerField(), 'ephemeral_gb': fields.IntegerField(), 'flavorid': fields.StringField(), 'swap': fields.IntegerField(), 'rxtx_factor': fields.FloatField(nullable=True, default=1.0), 'vcpu_weight': fields.IntegerField(nullable=True), 'disabled': fields.BooleanField(), 'is_public': fields.BooleanField(), 'extra_specs': fields.DictOfStringsField(), 'projects': fields.ListOfStringsField(), 'description': fields.StringField(nullable=True) } def __init__(self, *args, **kwargs): super(Flavor, self).__init__(*args, **kwargs) self._orig_extra_specs = {} self._orig_projects = [] def obj_make_compatible(self, primitive, target_version): super(Flavor, self).obj_make_compatible(primitive, target_version) target_version = versionutils.convert_version_to_tuple(target_version) if target_version < (1, 2) and 'description' in primitive: del primitive['description'] @staticmethod def _from_db_object(context, flavor, db_flavor, expected_attrs=None): if expected_attrs is None: expected_attrs = [] flavor._context = context for name, field in flavor.fields.items(): if name in OPTIONAL_FIELDS: continue if name in DEPRECATED_FIELDS and name not in db_flavor: continue value = db_flavor[name] if isinstance(field, fields.IntegerField): value = value if value is not None else 0 flavor[name] = value # NOTE(danms): This is to support processing the API flavor # model, which does not have these deprecated fields. When we # remove compatibility with the old InstanceType model, we can # remove this as well. if any(f not in db_flavor for f in DEPRECATED_FIELDS): flavor.deleted_at = None flavor.deleted = False if 'extra_specs' in expected_attrs: flavor.extra_specs = db_flavor['extra_specs'] if 'projects' in expected_attrs: if 'projects' in db_flavor: flavor['projects'] = [x['project_id'] for x in db_flavor['projects']] else: flavor._load_projects() flavor.obj_reset_changes() return flavor @staticmethod @api_db_api.context_manager.reader def _flavor_get_query_from_db(context): query = context.session.query(api_models.Flavors).options( orm.joinedload(api_models.Flavors.extra_specs) ) if not context.is_admin: the_filter = [api_models.Flavors.is_public == sql.true()] the_filter.extend([ api_models.Flavors.projects.any(project_id=context.project_id) ]) query = query.filter(sa.or_(*the_filter)) return query @staticmethod @db_utils.require_context def _flavor_get_from_db(context, id): """Returns a dict describing specific flavor.""" result = Flavor._flavor_get_query_from_db(context).\ filter_by(id=id).\ first() if not result: raise exception.FlavorNotFound(flavor_id=id) return _dict_with_extra_specs(result) @staticmethod @db_utils.require_context def _flavor_get_by_name_from_db(context, name): """Returns a dict describing specific flavor.""" result = Flavor._flavor_get_query_from_db(context).\ filter_by(name=name).\ first() if not result: raise exception.FlavorNotFoundByName(flavor_name=name) return _dict_with_extra_specs(result) @staticmethod @db_utils.require_context def _flavor_get_by_flavor_id_from_db(context, flavor_id): """Returns a dict describing specific flavor_id.""" result = Flavor._flavor_get_query_from_db(context).\ filter_by(flavorid=flavor_id).\ order_by(expression.asc(api_models.Flavors.id)).\ first() if not result: raise exception.FlavorNotFound(flavor_id=flavor_id) return _dict_with_extra_specs(result) @staticmethod def _get_projects_from_db(context, flavorid): return _get_projects_from_db(context, flavorid) @base.remotable def _load_projects(self): self.projects = self._get_projects_from_db(self._context, self.flavorid) self.obj_reset_changes(['projects']) def obj_load_attr(self, attrname): # NOTE(danms): Only projects could be lazy-loaded right now if attrname != 'projects': raise exception.ObjectActionError( action='obj_load_attr', reason='unable to load %s' % attrname) self._load_projects() def obj_reset_changes(self, fields=None, recursive=False): super(Flavor, self).obj_reset_changes(fields=fields, recursive=recursive) if fields is None or 'extra_specs' in fields: self._orig_extra_specs = (dict(self.extra_specs) if self.obj_attr_is_set('extra_specs') else {}) if fields is None or 'projects' in fields: self._orig_projects = (list(self.projects) if self.obj_attr_is_set('projects') else []) def obj_what_changed(self): changes = super(Flavor, self).obj_what_changed() if ('extra_specs' in self and self.extra_specs != self._orig_extra_specs): changes.add('extra_specs') if 'projects' in self and self.projects != self._orig_projects: changes.add('projects') return changes @classmethod def _obj_from_primitive(cls, context, objver, primitive): self = super(Flavor, cls)._obj_from_primitive(context, objver, primitive) changes = self.obj_what_changed() if 'extra_specs' not in changes: # This call left extra_specs "clean" so update our tracker self._orig_extra_specs = (dict(self.extra_specs) if self.obj_attr_is_set('extra_specs') else {}) if 'projects' not in changes: # This call left projects "clean" so update our tracker self._orig_projects = (list(self.projects) if self.obj_attr_is_set('projects') else []) return self @base.remotable_classmethod def get_by_id(cls, context, id): db_flavor = cls._flavor_get_from_db(context, id) return cls._from_db_object(context, cls(context), db_flavor, expected_attrs=['extra_specs']) @base.remotable_classmethod def get_by_name(cls, context, name): db_flavor = cls._flavor_get_by_name_from_db(context, name) return cls._from_db_object(context, cls(context), db_flavor, expected_attrs=['extra_specs']) @base.remotable_classmethod def get_by_flavor_id(cls, context, flavor_id, read_deleted=None): db_flavor = cls._flavor_get_by_flavor_id_from_db(context, flavor_id) return cls._from_db_object(context, cls(context), db_flavor, expected_attrs=['extra_specs']) @staticmethod def _flavor_add_project(context, flavor_id, project_id): return _flavor_add_project(context, flavor_id, project_id) @staticmethod def _flavor_del_project(context, flavor_id, project_id): return _flavor_del_project(context, flavor_id, project_id) def _add_access(self, project_id): self._flavor_add_project(self._context, self.id, project_id) @base.remotable def add_access(self, project_id): if 'projects' in self.obj_what_changed(): raise exception.ObjectActionError(action='add_access', reason='projects modified') self._add_access(project_id) self._load_projects() self._send_notification(fields.NotificationAction.UPDATE) def _remove_access(self, project_id): self._flavor_del_project(self._context, self.id, project_id) @base.remotable def remove_access(self, project_id): if 'projects' in self.obj_what_changed(): raise exception.ObjectActionError(action='remove_access', reason='projects modified') self._remove_access(project_id) self._load_projects() self._send_notification(fields.NotificationAction.UPDATE) @staticmethod def _flavor_create(context, updates): return _flavor_create(context, updates) @base.remotable def create(self): if self.obj_attr_is_set('id'): raise exception.ObjectActionError(action='create', reason='already created') updates = self.obj_get_changes() expected_attrs = [] for attr in OPTIONAL_FIELDS: if attr in updates: expected_attrs.append(attr) db_flavor = self._flavor_create(self._context, updates) self._from_db_object(self._context, self, db_flavor, expected_attrs=expected_attrs) self._send_notification(fields.NotificationAction.CREATE) @base.remotable def save_projects(self, to_add=None, to_delete=None): """Add or delete projects. :param:to_add: A list of projects to add :param:to_delete: A list of projects to remove """ to_add = to_add if to_add is not None else [] to_delete = to_delete if to_delete is not None else [] for project_id in to_add: self._add_access(project_id) for project_id in to_delete: self._remove_access(project_id) self.obj_reset_changes(['projects']) @staticmethod def _flavor_extra_specs_add(context, flavor_id, specs, max_retries=10): return _flavor_extra_specs_add(context, flavor_id, specs, max_retries) @staticmethod def _flavor_extra_specs_del(context, flavor_id, key): return _flavor_extra_specs_del(context, flavor_id, key) @base.remotable def save_extra_specs(self, to_add=None, to_delete=None): """Add or delete extra_specs. :param:to_add: A dict of new keys to add/update :param:to_delete: A list of keys to remove """ to_add = to_add if to_add is not None else {} to_delete = to_delete if to_delete is not None else [] if to_add: self._flavor_extra_specs_add(self._context, self.id, to_add) for key in to_delete: self._flavor_extra_specs_del(self._context, self.id, key) self.obj_reset_changes(['extra_specs']) # NOTE(mriedem): This method is not remotable since we only expect the API # to be able to make updates to a flavor. @api_db_api.context_manager.writer def _save(self, context, values): db_flavor = context.session.query(api_models.Flavors).\ filter_by(id=self.id).first() if not db_flavor: raise exception.FlavorNotFound(flavor_id=self.id) db_flavor.update(values) db_flavor.save(context.session) # Refresh ourselves from the DB object so we get the new updated_at. self._from_db_object(context, self, db_flavor) self.obj_reset_changes() def save(self): updates = self.obj_get_changes() projects = updates.pop('projects', None) extra_specs = updates.pop('extra_specs', None) if updates: # Only allowed to update from the whitelist of mutable fields. if set(updates.keys()) - MUTABLE_FIELDS: raise exception.ObjectActionError( action='save', reason='read-only fields were changed') self._save(self._context, updates) if extra_specs is not None: deleted_keys = (set(self._orig_extra_specs.keys()) - set(extra_specs.keys())) added_keys = self.extra_specs else: added_keys = deleted_keys = None if projects is not None: deleted_projects = set(self._orig_projects) - set(projects) added_projects = set(projects) - set(self._orig_projects) else: added_projects = deleted_projects = None # NOTE(danms): The first remotable method we call will reset # our of the original values for projects and extra_specs. Thus, # we collect the added/deleted lists for both above and /then/ # call these methods to update them. if added_keys or deleted_keys: self.save_extra_specs(self.extra_specs, deleted_keys) if added_projects or deleted_projects: self.save_projects(added_projects, deleted_projects) if (added_keys or deleted_keys or added_projects or deleted_projects or updates): self._send_notification(fields.NotificationAction.UPDATE) @staticmethod def _flavor_destroy(context, flavor_id=None, flavorid=None): return _flavor_destroy(context, flavor_id=flavor_id, flavorid=flavorid) @base.remotable def destroy(self): # NOTE(danms): Historically the only way to delete a flavor # is via name, which is not very precise. We need to be able to # support the light construction of a flavor object and subsequent # delete request with only our name filled out. However, if we have # our id property, we should instead delete with that since it's # far more specific. if 'id' in self: db_flavor = self._flavor_destroy(self._context, flavor_id=self.id) else: db_flavor = self._flavor_destroy(self._context, flavorid=self.flavorid) self._from_db_object(self._context, self, db_flavor) self._send_notification(fields.NotificationAction.DELETE) def _send_notification(self, action): # NOTE(danms): Instead of making the below notification # lazy-load projects (which is a problem for instance-bound # flavors and compute-cell operations), just load them here. if 'projects' not in self: # If the flavor is deleted we can't lazy-load projects. # FlavorPayload will orphan the flavor which will make the # NotificationPayloadBase set projects=None in the notification # payload. if action != fields.NotificationAction.DELETE: self._load_projects() notification_type = flavor_notification.FlavorNotification payload_type = flavor_notification.FlavorPayload payload = payload_type(self) notification_type( publisher=notification.NotificationPublisher( host=CONF.host, source=fields.NotificationSource.API), event_type=notification.EventType(object="flavor", action=action), priority=fields.NotificationPriority.INFO, payload=payload).emit(self._context) @api_db_api.context_manager.reader def _flavor_get_all_from_db(context, inactive, filters, sort_key, sort_dir, limit, marker): """Returns all flavors. """ filters = filters or {} query = Flavor._flavor_get_query_from_db(context) if 'min_memory_mb' in filters: query = query.filter( api_models.Flavors.memory_mb >= filters['min_memory_mb']) if 'min_root_gb' in filters: query = query.filter( api_models.Flavors.root_gb >= filters['min_root_gb']) if 'disabled' in filters: query = query.filter( api_models.Flavors.disabled == filters['disabled']) if 'is_public' in filters and filters['is_public'] is not None: the_filter = [api_models.Flavors.is_public == filters['is_public']] if filters['is_public'] and context.project_id is not None: the_filter.extend([api_models.Flavors.projects.any( project_id=context.project_id)]) if len(the_filter) > 1: query = query.filter(sa.or_(*the_filter)) else: query = query.filter(the_filter[0]) marker_row = None if marker is not None: marker_row = Flavor._flavor_get_query_from_db(context).\ filter_by(flavorid=marker).\ first() if not marker_row: raise exception.MarkerNotFound(marker=marker) query = sqlalchemyutils.paginate_query(query, api_models.Flavors, limit, [sort_key, 'id'], marker=marker_row, sort_dir=sort_dir) return [_dict_with_extra_specs(i) for i in query.all()] @base.NovaObjectRegistry.register class FlavorList(base.ObjectListBase, base.NovaObject): VERSION = '1.1' fields = { 'objects': fields.ListOfObjectsField('Flavor'), } @base.remotable_classmethod def get_all(cls, context, inactive=False, filters=None, sort_key='flavorid', sort_dir='asc', limit=None, marker=None): api_db_flavors = _flavor_get_all_from_db(context, inactive=inactive, filters=filters, sort_key=sort_key, sort_dir=sort_dir, limit=limit, marker=marker) return base.obj_make_list(context, cls(context), objects.Flavor, api_db_flavors, expected_attrs=['extra_specs'])