diff options
author | unicell <unicell@gmail.com> | 2012-08-13 20:19:54 +0800 |
---|---|---|
committer | unicell <unicell@gmail.com> | 2012-08-27 23:45:05 +0800 |
commit | 34c012c709cc5ae577330c7d67ba060293158210 (patch) | |
tree | e04765cbb3e09e9047c51aa9e03d4b1db15ac80c | |
parent | 5e012d8d45935b68a5ce5d50ed043d4bb8066cf8 (diff) | |
download | nova-34c012c709cc5ae577330c7d67ba060293158210.tar.gz nova-34c012c709cc5ae577330c7d67ba060293158210.tar.xz nova-34c012c709cc5ae577330c7d67ba060293158210.zip |
Implement project specific flavors API
blueprint project-specific-flavors
This change implements API extension to manage project specific flavor
types, so that non-public flavor type can only see by projects with
access rights.
Change-Id: Ie2d2c605065b0c76897f843a4548a0c984a05f1a
20 files changed, 838 insertions, 25 deletions
diff --git a/bin/nova-manage b/bin/nova-manage index 264c13426..7b3769629 100755 --- a/bin/nova-manage +++ b/bin/nova-manage @@ -828,11 +828,12 @@ class InstanceTypeCommands(object): def _print_instance_types(self, name, val): deleted = ('', ', inactive')[val["deleted"] == 1] + is_public = ('private', 'public')[val["is_public"] == 1] print ("%s: Memory: %sMB, VCPUS: %s, Root: %sGB, Ephemeral: %sGb, " - "FlavorID: %s, Swap: %sMB, RXTX Factor: %s, ExtraSpecs %s") % ( + "FlavorID: %s, Swap: %sMB, RXTX Factor: %s, %s, ExtraSpecs %s") % ( name, val["memory_mb"], val["vcpus"], val["root_gb"], val["ephemeral_gb"], val["flavorid"], val["swap"], - val["rxtx_factor"], val["extra_specs"]) + val["rxtx_factor"], is_public, val["extra_specs"]) @args('--name', dest='name', metavar='<name>', help='Name of instance type/flavor') @@ -848,12 +849,15 @@ class InstanceTypeCommands(object): @args('--swap', dest='swap', metavar='<swap>', help='Swap') @args('--rxtx_factor', dest='rxtx_factor', metavar='<rxtx_factor>', help='rxtx_factor') + @args('--is_public', dest="is_public", metavar='<is_public>', + help='Make flavor accessible to the public') def create(self, name, memory, vcpus, root_gb, ephemeral_gb, flavorid, - swap=0, rxtx_factor=1): + swap=0, rxtx_factor=1, is_public=True): """Creates instance types / flavors""" try: instance_types.create(name, memory, vcpus, root_gb, - ephemeral_gb, flavorid, swap, rxtx_factor) + ephemeral_gb, flavorid, swap, rxtx_factor, + is_public) except exception.InvalidInput, e: print "Must supply valid parameters to create instance_type" print e diff --git a/etc/nova/policy.json b/etc/nova/policy.json index 8bc2f6f5f..2a722b9a1 100644 --- a/etc/nova/policy.json +++ b/etc/nova/policy.json @@ -35,6 +35,7 @@ "compute_extension:disk_config": [], "compute_extension:extended_server_attributes": [["rule:admin_api"]], "compute_extension:extended_status": [], + "compute_extension:flavor_access": [], "compute_extension:flavorextradata": [], "compute_extension:flavorextraspecs": [], "compute_extension:flavormanage": [["rule:admin_api"]], diff --git a/nova/api/openstack/compute/__init__.py b/nova/api/openstack/compute/__init__.py index 70ec80dc2..9bf36bf25 100644 --- a/nova/api/openstack/compute/__init__.py +++ b/nova/api/openstack/compute/__init__.py @@ -90,7 +90,8 @@ class APIRouter(nova.api.openstack.APIRouter): self.resources['flavors'] = flavors.create_resource() mapper.resource("flavor", "flavors", controller=self.resources['flavors'], - collection={'detail': 'GET'}) + collection={'detail': 'GET'}, + member={'action': 'POST'}) self.resources['image_metadata'] = image_metadata.create_resource() image_metadata_controller = self.resources['image_metadata'] diff --git a/nova/api/openstack/compute/contrib/flavor_access.py b/nova/api/openstack/compute/contrib/flavor_access.py new file mode 100644 index 000000000..433d0c75d --- /dev/null +++ b/nova/api/openstack/compute/contrib/flavor_access.py @@ -0,0 +1,240 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 OpenStack, LLC. +# All Rights Reserved. +# +# 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. + +"""The flavor access extension.""" + +import webob + +from nova.api.openstack import extensions +from nova.api.openstack import wsgi +from nova.api.openstack import xmlutil +from nova.compute import instance_types +from nova import exception + + +authorize = extensions.soft_extension_authorizer('compute', 'flavor_access') + + +def make_flavor(elem): + elem.set('{%s}is_public' % Flavor_access.namespace, + '%s:is_public' % Flavor_access.alias) + + +def make_flavor_access(elem): + elem.set('flavor_id') + elem.set('tenant_id') + + +class FlavorextradatumTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('flavor', selector='flavor') + make_flavor(root) + alias = Flavor_access.alias + namespace = Flavor_access.namespace + return xmlutil.SlaveTemplate(root, 1, nsmap={alias: namespace}) + + +class FlavorextradataTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('flavors') + elem = xmlutil.SubTemplateElement(root, 'flavor', selector='flavors') + make_flavor(elem) + alias = Flavor_access.alias + namespace = Flavor_access.namespace + return xmlutil.SlaveTemplate(root, 1, nsmap={alias: namespace}) + + +class FlavorAccessTemplate(xmlutil.TemplateBuilder): + def construct(self): + def wrapped(obj, do_raise=False): + # wrap bare list in dict + return dict(flavor_access=obj) + + root = xmlutil.TemplateElement('flavor_access', selector=wrapped) + elem = xmlutil.SubTemplateElement(root, 'access', + selector='flavor_access') + make_flavor_access(elem) + return xmlutil.MasterTemplate(root, 1) + + +def _marshall_flavor_access(flavor_id): + rval = [] + try: + access_list = instance_types.\ + get_instance_type_access_by_flavor_id(flavor_id) + except exception.FlavorNotFound: + explanation = _("Flavor not found.") + raise webob.exc.HTTPNotFound(explanation=explanation) + + for access in access_list: + rval.append({'flavor_id': flavor_id, + 'tenant_id': access['project_id']}) + + return {'flavor_access': rval} + + +class FlavorAccessController(object): + """The flavor access API controller for the OpenStack API.""" + + def __init__(self): + super(FlavorAccessController, self).__init__() + + @wsgi.serializers(xml=FlavorAccessTemplate) + def index(self, req, flavor_id): + context = req.environ['nova.context'] + authorize(context) + + try: + flavor = instance_types.get_instance_type_by_flavor_id(flavor_id) + except exception.FlavorNotFound: + explanation = _("Flavor not found.") + raise webob.exc.HTTPNotFound(explanation=explanation) + + # public flavor to all projects + if flavor['is_public']: + explanation = _("Access list not available for public flavors.") + raise webob.exc.HTTPNotFound(explanation=explanation) + + # private flavor to listed projects only + return _marshall_flavor_access(flavor_id) + + +class FlavorActionController(wsgi.Controller): + """The flavor access API controller for the OpenStack API.""" + + def _check_body(self, body): + if body is None or body == "": + raise webob.exc.HTTPBadRequest(explanation=_("No request body")) + + def _get_flavor_refs(self, context): + """Return a dictionary mapping flavorid to flavor_ref.""" + + flavor_refs = instance_types.get_all_types(context) + rval = {} + for name, obj in flavor_refs.iteritems(): + rval[obj['flavorid']] = obj + return rval + + def _extend_flavor(self, flavor_rval, flavor_ref): + key = "%s:is_public" % (Flavor_access.alias) + flavor_rval[key] = flavor_ref['is_public'] + + @wsgi.extends + def show(self, req, resp_obj, id): + context = req.environ['nova.context'] + if authorize(context): + # Attach our slave template to the response object + resp_obj.attach(xml=FlavorextradatumTemplate()) + + try: + flavor_ref = instance_types.get_instance_type_by_flavor_id(id) + except exception.FlavorNotFound: + explanation = _("Flavor not found.") + raise webob.exc.HTTPNotFound(explanation=explanation) + + self._extend_flavor(resp_obj.obj['flavor'], flavor_ref) + + @wsgi.extends + def detail(self, req, resp_obj): + context = req.environ['nova.context'] + if authorize(context): + # Attach our slave template to the response object + resp_obj.attach(xml=FlavorextradataTemplate()) + + flavors = list(resp_obj.obj['flavors']) + flavor_refs = self._get_flavor_refs(context) + + for flavor_rval in flavors: + flavor_ref = flavor_refs[flavor_rval['id']] + self._extend_flavor(flavor_rval, flavor_ref) + + @wsgi.extends(action='create') + def create(self, req, body, resp_obj): + context = req.environ['nova.context'] + if authorize(context): + # Attach our slave template to the response object + resp_obj.attach(xml=FlavorextradatumTemplate()) + + try: + fid = resp_obj.obj['flavor']['id'] + flavor_ref = instance_types.get_instance_type_by_flavor_id(fid) + except exception.FlavorNotFound: + explanation = _("Flavor not found.") + raise webob.exc.HTTPNotFound(explanation=explanation) + + self._extend_flavor(resp_obj.obj['flavor'], flavor_ref) + + @wsgi.serializers(xml=FlavorAccessTemplate) + @wsgi.action("addTenantAccess") + def _addTenantAccess(self, req, id, body): + context = req.environ['nova.context'] + authorize(context) + self._check_body(body) + + vals = body['addTenantAccess'] + tenant = vals['tenant'] + + try: + instance_types.add_instance_type_access(id, tenant, context) + except exception.FlavorAccessExists as err: + raise webob.exc.HTTPConflict(explanation=str(err)) + + return _marshall_flavor_access(id) + + @wsgi.serializers(xml=FlavorAccessTemplate) + @wsgi.action("removeTenantAccess") + def _removeTenantAccess(self, req, id, body): + context = req.environ['nova.context'] + authorize(context) + self._check_body(body) + + vals = body['removeTenantAccess'] + tenant = vals['tenant'] + + try: + instance_types.remove_instance_type_access(id, tenant, context) + except exception.FlavorAccessNotFound, e: + raise webob.exc.HTTPNotFound(explanation=str(e)) + + return _marshall_flavor_access(id) + + +class Flavor_access(extensions.ExtensionDescriptor): + """Flavor access supprt""" + + name = "FlavorAccess" + alias = "os-flavor-access" + namespace = ("http://docs.openstack.org/compute/ext/" + "flavor_access/api/v2") + updated = "2012-08-01T00:00:00+00:00" + + def get_resources(self): + resources = [] + + res = extensions.ResourceExtension( + 'os-flavor-access', + controller=FlavorAccessController(), + parent=dict(member_name='flavor', collection_name='flavors')) + resources.append(res) + + return resources + + def get_controller_extensions(self): + extension = extensions.ControllerExtension( + self, 'flavors', FlavorActionController()) + + return [extension] diff --git a/nova/api/openstack/compute/contrib/flavorextradata.py b/nova/api/openstack/compute/contrib/flavorextradata.py index 5a6558ad0..834b68c60 100644 --- a/nova/api/openstack/compute/contrib/flavorextradata.py +++ b/nova/api/openstack/compute/contrib/flavorextradata.py @@ -35,10 +35,10 @@ authorize = extensions.soft_extension_authorizer('compute', 'flavorextradata') class FlavorextradataController(wsgi.Controller): - def _get_flavor_refs(self): + def _get_flavor_refs(self, context): """Return a dictionary mapping flavorid to flavor_ref.""" - flavor_refs = instance_types.get_all_types() + flavor_refs = instance_types.get_all_types(context) rval = {} for name, obj in flavor_refs.iteritems(): rval[obj['flavorid']] = obj @@ -71,7 +71,7 @@ class FlavorextradataController(wsgi.Controller): resp_obj.attach(xml=FlavorextradataTemplate()) flavors = list(resp_obj.obj['flavors']) - flavor_refs = self._get_flavor_refs() + flavor_refs = self._get_flavor_refs(context) for flavor_rval in flavors: flavor_ref = flavor_refs[flavor_rval['id']] diff --git a/nova/api/openstack/compute/contrib/flavormanage.py b/nova/api/openstack/compute/contrib/flavormanage.py index 4dedcf981..3bcbf6981 100644 --- a/nova/api/openstack/compute/contrib/flavormanage.py +++ b/nova/api/openstack/compute/contrib/flavormanage.py @@ -65,11 +65,12 @@ class FlavorManageController(wsgi.Controller): ephemeral_gb = vals.get('OS-FLV-EXT-DATA:ephemeral') swap = vals.get('swap') rxtx_factor = vals.get('rxtx_factor') + is_public = vals.get('os-flavor-access:is_public') try: flavor = instance_types.create(name, memory_mb, vcpus, root_gb, ephemeral_gb, flavorid, - swap, rxtx_factor) + swap, rxtx_factor, is_public) except exception.InstanceTypeExists as err: raise webob.exc.HTTPConflict(explanation=str(err)) diff --git a/nova/api/openstack/compute/flavors.py b/nova/api/openstack/compute/flavors.py index 56b2e18ab..1a96c1346 100644 --- a/nova/api/openstack/compute/flavors.py +++ b/nova/api/openstack/compute/flavors.py @@ -91,12 +91,36 @@ class Controller(wsgi.Controller): return self._view_builder.show(req, flavor) + def _get_is_public(self, req): + """Parse is_public into something usable.""" + is_public = req.params.get('is_public', None) + + if is_public is None: + # preserve default value of showing only public flavors + return True + elif is_public is True or \ + is_public.lower() in ['t', 'true', 'yes', '1']: + return True + elif is_public is False or \ + is_public.lower() in ['f', 'false', 'no', '0']: + return False + elif is_public.lower() == 'none': + # value to match all flavors, ignore is_public + return None + else: + msg = _('Invalid is_public filter [%s]') % req.params['is_public'] + raise webob.exc.HTTPBadRequest(explanation=msg) + def _get_flavors(self, req): """Helper function that returns a list of flavor dicts.""" filters = {} context = req.environ['nova.context'] - if not context.is_admin: + if context.is_admin: + # Only admin has query access to all flavor types + filters['is_public'] = self._get_is_public(req) + else: + filters['is_public'] = True filters['disabled'] = False if 'minRam' in req.params: @@ -113,7 +137,7 @@ class Controller(wsgi.Controller): msg = _('Invalid minDisk filter [%s]') % req.params['minDisk'] raise webob.exc.HTTPBadRequest(explanation=msg) - flavors = instance_types.get_all_types(filters=filters) + flavors = instance_types.get_all_types(context, filters=filters) flavors_list = flavors.values() sorted_flavors = sorted(flavors_list, key=lambda item: item['flavorid']) diff --git a/nova/compute/instance_types.py b/nova/compute/instance_types.py index dfcf235dd..e31933240 100644 --- a/nova/compute/instance_types.py +++ b/nova/compute/instance_types.py @@ -27,6 +27,7 @@ from nova import db from nova import exception from nova import flags from nova.openstack.common import log as logging +from nova import utils FLAGS = flags.FLAGS LOG = logging.getLogger(__name__) @@ -35,7 +36,7 @@ INVALID_NAME_REGEX = re.compile("[^\w\.\- ]") def create(name, memory, vcpus, root_gb, ephemeral_gb, flavorid, swap=None, - rxtx_factor=None): + rxtx_factor=None, is_public=True): """Creates instance types.""" if swap is None: @@ -80,6 +81,9 @@ def create(name, memory, vcpus, root_gb, ephemeral_gb, flavorid, swap=None, # in through json as an integer, so we convert it here. kwargs['flavorid'] = unicode(flavorid) + # ensure is_public attribute is boolean + kwargs['is_public'] = utils.bool_from_str(is_public) + try: return db.instance_type_create(context.get_admin_context(), kwargs) except exception.DBError, e: @@ -97,12 +101,14 @@ def destroy(name): raise exception.InstanceTypeNotFoundByName(instance_type_name=name) -def get_all_types(inactive=False, filters=None): +def get_all_types(ctxt=None, inactive=False, filters=None): """Get all non-deleted instance_types. Pass true as argument if you want deleted instance types returned also. """ - ctxt = context.get_admin_context() + if ctxt is None: + ctxt = context.get_admin_context() + inst_types = db.instance_type_get_all( ctxt, inactive=inactive, filters=filters) @@ -120,30 +126,60 @@ def get_default_instance_type(): return get_instance_type_by_name(name) -def get_instance_type(instance_type_id): +def get_instance_type(instance_type_id, ctxt=None): """Retrieves single instance type by id.""" if instance_type_id is None: return get_default_instance_type() - ctxt = context.get_admin_context() + if ctxt is None: + ctxt = context.get_admin_context() + return db.instance_type_get(ctxt, instance_type_id) -def get_instance_type_by_name(name): +def get_instance_type_by_name(name, ctxt=None): """Retrieves single instance type by name.""" if name is None: return get_default_instance_type() - ctxt = context.get_admin_context() + if ctxt is None: + ctxt = context.get_admin_context() + return db.instance_type_get_by_name(ctxt, name) # TODO(termie): flavor-specific code should probably be in the API that uses # flavors. -def get_instance_type_by_flavor_id(flavorid, read_deleted="yes"): +def get_instance_type_by_flavor_id(flavorid, ctxt=None, read_deleted="yes"): """Retrieve instance type by flavorid. :raises: FlavorNotFound """ - ctxt = context.get_admin_context(read_deleted=read_deleted) + if ctxt is None: + ctxt = context.get_admin_context(read_deleted=read_deleted) + return db.instance_type_get_by_flavor_id(ctxt, flavorid) + + +def get_instance_type_access_by_flavor_id(flavorid, ctxt=None): + """Retrieve instance type access list by flavor id""" + if ctxt is None: + ctxt = context.get_admin_context() + + return db.instance_type_access_get_by_flavor_id(ctxt, flavorid) + + +def add_instance_type_access(flavorid, projectid, ctxt=None): + """Add instance type access for project""" + if ctxt is None: + ctxt = context.get_admin_context() + + return db.instance_type_access_add(ctxt, flavorid, projectid) + + +def remove_instance_type_access(flavorid, projectid, ctxt=None): + """Remove instance type access for project""" + if ctxt is None: + ctxt = context.get_admin_context() + + return db.instance_type_access_remove(ctxt, flavorid, projectid) diff --git a/nova/db/api.py b/nova/db/api.py index d4be854b3..f718f0047 100644 --- a/nova/db/api.py +++ b/nova/db/api.py @@ -1445,6 +1445,21 @@ def instance_type_destroy(context, name): return IMPL.instance_type_destroy(context, name) +def instance_type_access_get_by_flavor_id(context, flavor_id): + """Get flavor access by flavor id.""" + return IMPL.instance_type_access_get_by_flavor_id(context, flavor_id) + + +def instance_type_access_add(context, flavor_id, project_id): + """Add flavor access for project.""" + return IMPL.instance_type_access_add(context, flavor_id, project_id) + + +def instance_type_access_remove(context, flavor_id, project_id): + """Remove flavor access for project.""" + return IMPL.instance_type_access_remove(context, flavor_id, project_id) + + #################### diff --git a/nova/db/sqlalchemy/api.py b/nova/db/sqlalchemy/api.py index da1376c30..3f6c504ab 100644 --- a/nova/db/sqlalchemy/api.py +++ b/nova/db/sqlalchemy/api.py @@ -3872,6 +3872,19 @@ def instance_type_get_all(context, inactive=False, filters=None): query = query.filter( models.InstanceTypes.disabled == filters['disabled']) + if 'is_public' in filters and filters['is_public'] is not None: + the_filter = [models.InstanceTypes.is_public == filters['is_public']] + if filters['is_public'] and context.project_id is not None: + the_filter.extend([ + models.InstanceTypes.projects.any( + project_id=context.project_id, deleted=False) + ]) + if len(the_filter) > 1: + query = query.filter(or_(*the_filter)) + else: + query = query.filter(the_filter[0]) + del filters['is_public'] + inst_types = query.order_by("name").all() return [_dict_with_extra_specs(i) for i in inst_types] @@ -3936,6 +3949,71 @@ def instance_type_destroy(context, name): 'updated_at': literal_column('updated_at')}) +@require_context +def _instance_type_access_query(context, session=None): + return model_query(context, models.InstanceTypeProjects, session=session, + read_deleted="yes") + + +@require_admin_context +def instance_type_access_get_by_flavor_id(context, flavor_id): + """Get flavor access list by flavor id""" + instance_type_ref = _instance_type_get_query(context).\ + filter_by(flavorid=flavor_id).\ + first() + + return [r for r in instance_type_ref.projects] + + +@require_admin_context +def instance_type_access_add(context, flavor_id, project_id): + """Add given tenant to the flavor access list""" + session = get_session() + with session.begin(): + instance_type_ref = instance_type_get_by_flavor_id(context, flavor_id, + session=session) + instance_type_id = instance_type_ref['id'] + access_ref = _instance_type_access_query(context, session=session).\ + filter_by(instance_type_id=instance_type_id).\ + filter_by(project_id=project_id).first() + + if not access_ref: + access_ref = models.InstanceTypeProjects() + access_ref.instance_type_id = instance_type_id + access_ref.project_id = project_id + access_ref.save(session=session) + elif access_ref.deleted: + access_ref.update({'deleted': False, + 'deleted_at': None}) + access_ref.save(session=session) + else: + raise exception.FlavorAccessExists(flavor_id=flavor_id, + project_id=project_id) + + return access_ref + + +@require_admin_context +def instance_type_access_remove(context, flavor_id, project_id): + """Remove given tenant from the flavor access list""" + session = get_session() + with session.begin(): + instance_type_ref = instance_type_get_by_flavor_id(context, flavor_id, + session=session) + instance_type_id = instance_type_ref['id'] + access_ref = _instance_type_access_query(context, session=session).\ + filter_by(instance_type_id=instance_type_id).\ + filter_by(project_id=project_id).first() + + if access_ref: + access_ref.update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + else: + raise exception.FlavorAccessNotFound(flavor_id=flavor_id, + project_id=project_id) + + ######################## # User-provided metadata diff --git a/nova/db/sqlalchemy/migrate_repo/versions/132_add_instance_type_projects.py b/nova/db/sqlalchemy/migrate_repo/versions/132_add_instance_type_projects.py new file mode 100644 index 000000000..312ebbfc1 --- /dev/null +++ b/nova/db/sqlalchemy/migrate_repo/versions/132_add_instance_type_projects.py @@ -0,0 +1,67 @@ +# Copyright 2012 OpenStack LLC. +# +# 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 sqlalchemy import Boolean, Column, DateTime, String, ForeignKey, Integer +from sqlalchemy import MetaData, String, Table + +from nova.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +def upgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + + instance_types = Table('instance_types', meta, autoload=True) + is_public = Column('is_public', Boolean) + + instance_types.create_column(is_public) + instance_types.update().values(is_public=True).execute() + + # New table. + instance_type_projects = Table('instance_type_projects', meta, + Column('created_at', DateTime(timezone=False)), + Column('updated_at', DateTime(timezone=False)), + Column('deleted_at', DateTime(timezone=False)), + Column('deleted', Boolean(), default=False), + Column('id', Integer, primary_key=True, nullable=False), + Column('instance_type_id', + Integer, + ForeignKey('instance_types.id'), + nullable=False), + Column('project_id', String(length=255)), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + + try: + instance_type_projects.create() + except Exception: + LOG.error(_("Table |%s| not created!"), repr(instance_type_projects)) + raise + + +def downgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + + instance_types = Table('instance_types', meta, autoload=True) + is_public = Column('is_public', Boolean) + + instance_types.drop_column(is_public) + + instance_type_projects = Table( + 'instance_type_projects', meta, autoload=True) + instance_type_projects.drop() diff --git a/nova/db/sqlalchemy/models.py b/nova/db/sqlalchemy/models.py index a37b1c215..65d5eb5e0 100644 --- a/nova/db/sqlalchemy/models.py +++ b/nova/db/sqlalchemy/models.py @@ -336,6 +336,7 @@ class InstanceTypes(BASE, NovaBase): rxtx_factor = Column(Float, nullable=False, default=1) vcpu_weight = Column(Integer, nullable=True) disabled = Column(Boolean, default=False) + is_public = Column(Boolean, default=True) instances = relationship(Instance, backref=backref('instance_type', uselist=False), @@ -821,6 +822,21 @@ class InstanceSystemMetadata(BASE, NovaBase): primaryjoin=primary_join) +class InstanceTypeProjects(BASE, NovaBase): + """Represent projects associated instance_types""" + __tablename__ = "instance_type_projects" + id = Column(Integer, primary_key=True) + instance_type_id = Column(Integer, ForeignKey('instance_types.id'), + nullable=False) + project_id = Column(String(255)) + + instance_type = relationship(InstanceTypes, backref="projects", + foreign_keys=instance_type_id, + primaryjoin='and_(' + 'InstanceTypeProjects.instance_type_id == InstanceTypes.id,' + 'InstanceTypeProjects.deleted == False)') + + class InstanceTypeExtraSpecs(BASE, NovaBase): """Represents additional specs as key/value pairs for an instance_type""" __tablename__ = 'instance_type_extra_specs' diff --git a/nova/exception.py b/nova/exception.py index 83c3f9c03..95a43a05f 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -776,6 +776,11 @@ class FlavorNotFound(NotFound): message = _("Flavor %(flavor_id)s could not be found.") +class FlavorAccessNotFound(NotFound): + message = _("Flavor access not found for %(flavor_id) / " + "%(project_id) combination.") + + class SchedulerHostFilterNotFound(NotFound): message = _("Scheduler Host Filter %(filter_name)s could not be found.") @@ -849,6 +854,11 @@ class InstanceTypeExists(Duplicate): message = _("Instance Type %(name)s already exists.") +class FlavorAccessExists(Duplicate): + message = _("Flavor access alreay exists for flavor %(flavor_id)s " + "and project %(project_id)s combination.") + + class VolumeTypeExists(Duplicate): message = _("Volume Type %(name)s already exists.") diff --git a/nova/tests/api/openstack/compute/contrib/test_flavor_access.py b/nova/tests/api/openstack/compute/contrib/test_flavor_access.py new file mode 100644 index 000000000..0bf1f1b66 --- /dev/null +++ b/nova/tests/api/openstack/compute/contrib/test_flavor_access.py @@ -0,0 +1,299 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import datetime + +from lxml import etree +from webob import exc + +from nova.api.openstack.compute.contrib import flavor_access +from nova.api.openstack.compute import flavors +from nova.compute import instance_types +from nova import context +from nova import exception +from nova import test +from nova.tests.api.openstack import fakes + + +def generate_instance_type(flavorid, ispublic): + return { + 'id': flavorid, + 'flavorid': str(flavorid), + 'root_gb': 1, + 'ephemeral_gb': 1, + 'name': u'test', + 'deleted': False, + 'created_at': datetime.datetime(2012, 1, 1, 1, 1, 1, 1), + 'updated_at': None, + 'memory_mb': 512, + 'vcpus': 1, + 'swap': 512, + 'rxtx_factor': 1.0, + 'extra_specs': {}, + 'deleted_at': None, + 'vcpu_weight': None, + 'is_public': bool(ispublic) + } + + +INSTANCE_TYPES = { + '0': generate_instance_type(0, True), + '1': generate_instance_type(1, True), + '2': generate_instance_type(2, False), + '3': generate_instance_type(3, False)} + + +ACCESS_LIST = [{'flavor_id': '2', 'project_id': 'proj2'}, + {'flavor_id': '2', 'project_id': 'proj3'}, + {'flavor_id': '3', 'project_id': 'proj3'}] + + +def fake_get_instance_type_access_by_flavor_id(flavorid): + res = [] + for access in ACCESS_LIST: + if access['flavor_id'] == flavorid: + res.append(access) + return res + + +def fake_get_instance_type_by_flavor_id(flavorid): + return INSTANCE_TYPES[flavorid] + + +def _has_flavor_access(flavorid, projectid): + for access in ACCESS_LIST: + if access['flavor_id'] == flavorid and \ + access['project_id'] == projectid: + return True + return False + + +def fake_get_all_types(context, inactive=0, filters=None): + if filters == None or filters['is_public'] == None: + return INSTANCE_TYPES + + res = {} + for k, v in INSTANCE_TYPES.iteritems(): + if filters['is_public'] and _has_flavor_access(k, context.project_id): + res.update({k: v}) + continue + if v['is_public'] == filters['is_public']: + res.update({k: v}) + + return res + + +class FakeRequest(object): + environ = {"nova.context": context.get_admin_context()} + + +class FlavorAccessTest(test.TestCase): + def setUp(self): + super(FlavorAccessTest, self).setUp() + self.flavor_controller = flavors.Controller() + self.flavor_access_controller = flavor_access.FlavorAccessController() + self.flavor_action_controller = flavor_access.FlavorActionController() + self.req = FakeRequest() + self.context = self.req.environ['nova.context'] + self.stubs.Set(instance_types, 'get_instance_type_by_flavor_id', + fake_get_instance_type_by_flavor_id) + self.stubs.Set(instance_types, 'get_all_types', fake_get_all_types) + self.stubs.Set(instance_types, 'get_instance_type_access_by_flavor_id', + fake_get_instance_type_access_by_flavor_id) + + def _verify_flavor_list(self, result, expected): + # result already sorted by flavor_id + self.assertEqual(len(result), len(expected)) + + for d1, d2 in zip(result, expected): + self.assertEqual(d1['id'], d2['id']) + + def test_list_flavor_access_public(self): + # query os-flavor-access on public flavor should return 404 + req = fakes.HTTPRequest.blank('/v2/fake/flavors/os-flavor-access', + use_admin_context=True) + self.assertRaises(exc.HTTPNotFound, + self.flavor_access_controller.index, + self.req, '1') + + def test_list_flavor_access_private(self): + expected = {'flavor_access': [ + {'flavor_id': '2', 'tenant_id': 'proj2'}, + {'flavor_id': '2', 'tenant_id': 'proj3'}]} + result = self.flavor_access_controller.index(self.req, '2') + self.assertEqual(result, expected) + + def test_list_flavor_with_admin_default_proj1(self): + expected = {'flavors': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/flavors', + use_admin_context=True) + req.environ['nova.context'].project_id = 'proj1' + result = self.flavor_controller.index(req) + self._verify_flavor_list(result['flavors'], expected['flavors']) + + def test_list_flavor_with_admin_default_proj2(self): + expected = {'flavors': [{'id': '0'}, {'id': '1'}, {'id': '2'}]} + req = fakes.HTTPRequest.blank('/v2/fake/flavors', + use_admin_context=True) + req.environ['nova.context'].project_id = 'proj2' + result = self.flavor_controller.index(req) + self._verify_flavor_list(result['flavors'], expected['flavors']) + + def test_list_flavor_with_admin_ispublic_true(self): + expected = {'flavors': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/flavors?is_public=true', + use_admin_context=True) + result = self.flavor_controller.index(req) + self._verify_flavor_list(result['flavors'], expected['flavors']) + + def test_list_flavor_with_admin_ispublic_false(self): + expected = {'flavors': [{'id': '2'}, {'id': '3'}]} + req = fakes.HTTPRequest.blank('/v2/fake/flavors?is_public=false', + use_admin_context=True) + result = self.flavor_controller.index(req) + self._verify_flavor_list(result['flavors'], expected['flavors']) + + def test_list_flavor_with_admin_ispublic_false_proj2(self): + expected = {'flavors': [{'id': '2'}, {'id': '3'}]} + req = fakes.HTTPRequest.blank('/v2/fake/flavors?is_public=false', + use_admin_context=True) + req.environ['nova.context'].project_id = 'proj2' + result = self.flavor_controller.index(req) + self._verify_flavor_list(result['flavors'], expected['flavors']) + + def test_list_flavor_with_admin_ispublic_none(self): + expected = {'flavors': [{'id': '0'}, {'id': '1'}, {'id': '2'}, + {'id': '3'}]} + req = fakes.HTTPRequest.blank('/v2/fake/flavors?is_public=none', + use_admin_context=True) + result = self.flavor_controller.index(req) + self._verify_flavor_list(result['flavors'], expected['flavors']) + + def test_list_flavor_with_no_admin_default(self): + expected = {'flavors': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/flavors', + use_admin_context=False) + result = self.flavor_controller.index(req) + self._verify_flavor_list(result['flavors'], expected['flavors']) + + def test_list_flavor_with_no_admin_ispublic_true(self): + expected = {'flavors': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/flavors?is_public=true', + use_admin_context=False) + result = self.flavor_controller.index(req) + self._verify_flavor_list(result['flavors'], expected['flavors']) + + def test_list_flavor_with_no_admin_ispublic_false(self): + expected = {'flavors': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/flavors?is_public=false', + use_admin_context=False) + result = self.flavor_controller.index(req) + self._verify_flavor_list(result['flavors'], expected['flavors']) + + def test_list_flavor_with_no_admin_ispublic_none(self): + expected = {'flavors': [{'id': '0'}, {'id': '1'}]} + req = fakes.HTTPRequest.blank('/v2/fake/flavors?is_public=none', + use_admin_context=False) + result = self.flavor_controller.index(req) + self._verify_flavor_list(result['flavors'], expected['flavors']) + + def test_add_tenant_access(self): + def stub_add_instance_type_access(flavorid, projectid, ctxt=None): + self.assertEqual('3', flavorid, "flavorid") + self.assertEqual("proj2", projectid, "projectid") + self.stubs.Set(instance_types, 'add_instance_type_access', + stub_add_instance_type_access) + expected = {'flavor_access': + [{'flavor_id': '3', 'tenant_id': 'proj3'}]} + body = {'addTenantAccess': {'tenant': 'proj2'}} + req = fakes.HTTPRequest.blank('/v2/fake/flavors/2/action', + use_admin_context=True) + result = self.flavor_action_controller.\ + _addTenantAccess(req, '3', body) + self.assertEqual(result, expected) + + def test_add_tenant_access_with_already_added_access(self): + def stub_add_instance_type_access(flavorid, projectid, ctxt=None): + raise exception.FlavorAccessExists() + self.stubs.Set(instance_types, 'add_instance_type_access', + stub_add_instance_type_access) + body = {'addTenantAccess': {'tenant': 'proj2'}} + req = fakes.HTTPRequest.blank('/v2/fake/flavors/2/action', + use_admin_context=True) + self.assertRaises(exc.HTTPConflict, + self.flavor_action_controller._addTenantAccess, + self.req, '3', body) + + def test_remove_tenant_access_with_bad_access(self): + def stub_remove_instance_type_access(flavorid, projectid, ctxt=None): + self.assertEqual('3', flavorid, "flavorid") + self.assertEqual("proj2", projectid, "projectid") + expected = {'flavor_access': [ + {'flavor_id': '3', 'tenant_id': 'proj3'}]} + self.stubs.Set(instance_types, 'remove_instance_type_access', + stub_remove_instance_type_access) + body = {'removeTenantAccess': {'tenant': 'proj2'}} + req = fakes.HTTPRequest.blank('/v2/fake/flavors/2/action', + use_admin_context=True) + result = self.flavor_action_controller.\ + _addTenantAccess(req, '3', body) + self.assertEqual(result, expected) + + def test_remove_tenant_access_with_bad_access(self): + def stub_remove_instance_type_access(flavorid, projectid, ctxt=None): + raise exception.FlavorAccessNotFound() + self.stubs.Set(instance_types, 'remove_instance_type_access', + stub_remove_instance_type_access) + body = {'removeTenantAccess': {'tenant': 'proj2'}} + req = fakes.HTTPRequest.blank('/v2/fake/flavors/2/action', + use_admin_context=True) + self.assertRaises(exc.HTTPNotFound, + self.flavor_action_controller._removeTenantAccess, + self.req, '3', body) + + +class FlavorAccessSerializerTest(test.TestCase): + def test_xml_declaration(self): + access_list = [{'flavor_id': '2', 'tenant_id': 'proj2'}] + serializer = flavor_access.FlavorAccessTemplate() + output = serializer.serialize(access_list) + has_dec = output.startswith("<?xml version='1.0' encoding='UTF-8'?>") + self.assertTrue(has_dec) + + def test_serializer_empty(self): + access_list = [] + + serializer = flavor_access.FlavorAccessTemplate() + text = serializer.serialize(access_list) + tree = etree.fromstring(text) + self.assertEqual(len(tree), 0) + + def test_serializer(self): + access_list = [{'flavor_id': '2', 'tenant_id': 'proj2'}, + {'flavor_id': '2', 'tenant_id': 'proj3'}] + + serializer = flavor_access.FlavorAccessTemplate() + text = serializer.serialize(access_list) + tree = etree.fromstring(text) + + self.assertEqual('flavor_access', tree.tag) + self.assertEqual(len(access_list), len(tree)) + + for i in range(len(access_list)): + self.assertEqual('access', tree[i].tag) + self.assertEqual(access_list[i]['flavor_id'], + tree[i].get('flavor_id')) + self.assertEqual(access_list[i]['tenant_id'], + tree[i].get('tenant_id')) diff --git a/nova/tests/api/openstack/compute/contrib/test_flavor_manage.py b/nova/tests/api/openstack/compute/contrib/test_flavor_manage.py index 140efc325..84ff8a1ea 100644 --- a/nova/tests/api/openstack/compute/contrib/test_flavor_manage.py +++ b/nova/tests/api/openstack/compute/contrib/test_flavor_manage.py @@ -46,7 +46,8 @@ def fake_get_instance_type_by_flavor_id(flavorid): 'extra_specs': {}, 'deleted_at': None, 'vcpu_weight': None, - 'id': 7 + 'id': 7, + 'is_public': True } @@ -55,7 +56,7 @@ def fake_destroy(flavorname): def fake_create(name, memory_mb, vcpus, root_gb, ephemeral_gb, - flavorid, swap, rxtx_factor): + flavorid, swap, rxtx_factor, is_public): newflavor = fake_get_instance_type_by_flavor_id(flavorid) newflavor["name"] = name @@ -65,6 +66,7 @@ def fake_create(name, memory_mb, vcpus, root_gb, ephemeral_gb, newflavor["ephemeral_gb"] = int(ephemeral_gb) newflavor["swap"] = swap newflavor["rxtx_factor"] = float(rxtx_factor) + newflavor["is_public"] = bool(is_public) return newflavor @@ -100,6 +102,7 @@ class FlavorManageTest(test.TestCase): "id": 1234, "swap": 512, "rxtx_factor": 1, + "os-flavor-access:is_public": True, } } @@ -124,11 +127,12 @@ class FlavorManageTest(test.TestCase): "id": 1235, "swap": 512, "rxtx_factor": 1, + "os-flavor-access:is_public": True, } } def fake_create(name, memory_mb, vcpus, root_gb, ephemeral_gb, - flavorid, swap, rxtx_factor): + flavorid, swap, rxtx_factor, is_public): raise exception.InstanceTypeExists() self.stubs.Set(instance_types, "create", fake_create) diff --git a/nova/tests/api/openstack/compute/contrib/test_flavorextradata.py b/nova/tests/api/openstack/compute/contrib/test_flavorextradata.py index 4f24b08ad..3c4a84e2a 100644 --- a/nova/tests/api/openstack/compute/contrib/test_flavorextradata.py +++ b/nova/tests/api/openstack/compute/contrib/test_flavorextradata.py @@ -39,7 +39,8 @@ def fake_get_instance_type_by_flavor_id(flavorid): 'rxtx_factor': 1.0, 'extra_specs': {}, 'deleted_at': None, - 'vcpu_weight': None + 'vcpu_weight': None, + 'is_public': True } @@ -72,6 +73,7 @@ class FlavorextradataTest(test.TestCase): 'OS-FLV-EXT-DATA:ephemeral': 1, 'swap': 512, 'rxtx_factor': 1, + 'os-flavor-access:is_public': True, } } @@ -93,6 +95,7 @@ class FlavorextradataTest(test.TestCase): 'OS-FLV-EXT-DATA:ephemeral': 1, 'swap': 512, 'rxtx_factor': 1, + 'os-flavor-access:is_public': True, }, { 'id': '2', @@ -103,6 +106,7 @@ class FlavorextradataTest(test.TestCase): 'OS-FLV-EXT-DATA:ephemeral': 1, 'swap': 512, 'rxtx_factor': 1, + 'os-flavor-access:is_public': True, }, ] diff --git a/nova/tests/api/openstack/compute/test_extensions.py b/nova/tests/api/openstack/compute/test_extensions.py index 4110d3ea3..200f56a56 100644 --- a/nova/tests/api/openstack/compute/test_extensions.py +++ b/nova/tests/api/openstack/compute/test_extensions.py @@ -166,6 +166,7 @@ class ExtensionControllerTest(ExtensionTestCase): "DiskConfig", "ExtendedStatus", "ExtendedServerAttributes", + "FlavorAccess", "FlavorExtraSpecs", "FlavorExtraData", "FlavorManage", diff --git a/nova/tests/policy.json b/nova/tests/policy.json index 61ec0152a..08577ed04 100644 --- a/nova/tests/policy.json +++ b/nova/tests/policy.json @@ -92,6 +92,7 @@ "compute_extension:disk_config": [], "compute_extension:extended_server_attributes": [], "compute_extension:extended_status": [], + "compute_extension:flavor_access": [], "compute_extension:flavorextradata": [], "compute_extension:flavorextraspecs": [], "compute_extension:flavormanage": [], diff --git a/nova/tests/test_utils.py b/nova/tests/test_utils.py index b4e1ebb3d..c7a323875 100644 --- a/nova/tests/test_utils.py +++ b/nova/tests/test_utils.py @@ -329,8 +329,17 @@ class GenericUtilsTestCase(test.TestCase): self.assertTrue(utils.bool_from_str('true')) self.assertTrue(utils.bool_from_str('True')) self.assertTrue(utils.bool_from_str('tRuE')) + self.assertTrue(utils.bool_from_str('yes')) + self.assertTrue(utils.bool_from_str('Yes')) + self.assertTrue(utils.bool_from_str('YeS')) + self.assertTrue(utils.bool_from_str('y')) + self.assertTrue(utils.bool_from_str('Y')) self.assertFalse(utils.bool_from_str('False')) self.assertFalse(utils.bool_from_str('false')) + self.assertFalse(utils.bool_from_str('no')) + self.assertFalse(utils.bool_from_str('No')) + self.assertFalse(utils.bool_from_str('n')) + self.assertFalse(utils.bool_from_str('N')) self.assertFalse(utils.bool_from_str('0')) self.assertFalse(utils.bool_from_str(None)) self.assertFalse(utils.bool_from_str('junk')) diff --git a/nova/utils.py b/nova/utils.py index c1a12cade..7eb7aa662 100644 --- a/nova/utils.py +++ b/nova/utils.py @@ -918,7 +918,9 @@ def bool_from_str(val): try: return True if int(val) else False except ValueError: - return val.lower() == 'true' + return val.lower() == 'true' or \ + val.lower() == 'yes' or \ + val.lower() == 'y' def is_valid_ipv4(address): |