diff --git a/api-ref/source/v1/flavor_access.inc b/api-ref/source/v1/flavor_access.inc new file mode 100644 index 00000000..10ade12b --- /dev/null +++ b/api-ref/source/v1/flavor_access.inc @@ -0,0 +1,98 @@ +.. -*- rst -*- + +================ + Flavors access +================ + +Lists tenants who have access to a private flavor and adds private +flavor access to and removes private flavor access from tenants. By +default, only administrators can manage private flavor access. A private +flavor has ``is_public`` set to ``false`` while a public flavor has +``is_public`` set to ``true``. + +List Flavor Access Information For Given Flavor +=============================================== + +.. rest_method:: GET /flavors/{flavor_uuid}/access + +Lists flavor access information. + +Normal response codes: 200 + +Error response codes: unauthorized(401), forbidden(403) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - flavor_uuid: flavor_uuid_path + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - flavor_access: flavor_access + +**Example List Flavor Access Information For Given Flavor: JSON response** + +.. literalinclude:: samples/flavor_access/flavor-access-list-resp.json + :language: javascript + +Add Flavor Access To Tenant +=========================== + +.. rest_method:: POST /flavors/{flavor_uuid}/access + +Adds flavor access to a tenant and flavor. + +Specify the ``tenant_id`` in the request body. + +Normal response codes: 204 + +Error response codes: badRequest(400), unauthorized(401), +forbidden(403), conflict(409) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - flavor_uuid: flavor_uuid_path + - tenant_id: tenant_id_body + +**Example Add Flavor Access To Tenant: JSON response** + +.. literalinclude:: samples/flavor_access/flavor-access-add-tenant-req.json + :language: javascript + +Response +-------- + +If successful, this method does not return content in the response body. + +Remove Flavor Access From Tenant +================================ + +.. rest_method:: DELETE /flavors/{flavor_uuid}/access/{tenant_id} + +Removes flavor access from a tenant and flavor. + +Normal response codes: 204 + +Error response codes: badRequest(400), unauthorized(401), forbidden(403), +itemNotFound(404), conflict(409) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - flavor_uuid: flavor_uuid_path + - tenant_id: tenant_id_path + +Response +-------- + +If successful, this method does not return content in the response body. diff --git a/api-ref/source/v1/index.rst b/api-ref/source/v1/index.rst index 364c509e..19fcc6c7 100644 --- a/api-ref/source/v1/index.rst +++ b/api-ref/source/v1/index.rst @@ -11,4 +11,5 @@ Baremetal Compute API V1 (CURRENT) .. include:: instance_states.inc .. include:: instance_networks.inc .. include:: flavors.inc +.. include:: flavor_access.inc .. include:: availability_zones.inc diff --git a/api-ref/source/v1/parameters.yaml b/api-ref/source/v1/parameters.yaml index 72f254dc..62b6217d 100644 --- a/api-ref/source/v1/parameters.yaml +++ b/api-ref/source/v1/parameters.yaml @@ -39,6 +39,12 @@ spec_key_path: in: path required: true type: string +tenant_id_path: + description: | + The UUID of the tenant in a multi-tenancy cloud. + in: path + required: true + type: string # variables in query all_tenants: @@ -125,6 +131,12 @@ fixed_address: in: body required: false type: string +flavor_access: + description: | + A list of tenants. + in: body + required: true + type: array flavor_description: description: | The description of the flavor. @@ -402,6 +414,12 @@ provision_state: in: body required: true type: string +tenant_id_body: + description: | + The UUID of the tenant in a multi-tenancy cloud. + in: body + required: true + type: string updated_at: description: | The date and time when the resource was updated. The date and time diff --git a/api-ref/source/v1/samples/flavor_access/flavor-access-add-tenant-req.json b/api-ref/source/v1/samples/flavor_access/flavor-access-add-tenant-req.json new file mode 100644 index 00000000..4ced4ab6 --- /dev/null +++ b/api-ref/source/v1/samples/flavor_access/flavor-access-add-tenant-req.json @@ -0,0 +1,3 @@ +{ + "tenant_id": "fake_tenant" +} diff --git a/api-ref/source/v1/samples/flavor_access/flavor-access-list-resp.json b/api-ref/source/v1/samples/flavor_access/flavor-access-list-resp.json new file mode 100644 index 00000000..b9857656 --- /dev/null +++ b/api-ref/source/v1/samples/flavor_access/flavor-access-list-resp.json @@ -0,0 +1,7 @@ +{ + "flavor_access": [ + "tenant1", + "tenant2", + "tenant3" + ] +} diff --git a/mogan/api/controllers/v1/flavors.py b/mogan/api/controllers/v1/flavors.py index 31534927..bbbf833c 100644 --- a/mogan/api/controllers/v1/flavors.py +++ b/mogan/api/controllers/v1/flavors.py @@ -21,13 +21,23 @@ from wsme import types as wtypes from mogan.api.controllers import base from mogan.api.controllers import link +from mogan.api.controllers.v1.schemas import flavor_access from mogan.api.controllers.v1 import types from mogan.api import expose +from mogan.api import validation from mogan.common import exception from mogan.common.i18n import _ from mogan import objects +def _marshall_flavor_access(flavor): + rval = [] + for project_id in flavor.projects: + rval.append(project_id) + + return {'flavor_access': rval} + + class Flavor(base.APIBase): """API representation of a flavor. @@ -125,10 +135,74 @@ class FlavorExtraSpecsController(rest.RestController): flavor.save() +class FlavorAccessController(rest.RestController): + """REST controller for flavor access.""" + + @expose.expose(wtypes.text, types.uuid) + def get_all(self, flavor_uuid): + """Retrieve a list of extra specs of the queried flavor.""" + + flavor = objects.InstanceType.get(pecan.request.context, + flavor_uuid) + + # public flavor to all projects + if flavor.is_public: + msg = _("Access list not available for public flavors.") + raise wsme.exc.ClientSideError( + msg, status_code=http_client.NOT_FOUND) + + # private flavor to listed projects only + return _marshall_flavor_access(flavor) + + @expose.expose(None, types.uuid, body=types.jsontype, + status_code=http_client.NO_CONTENT) + def post(self, flavor_uuid, tenant): + """Add flavor access for the given tenant.""" + validation.check_schema(tenant, flavor_access.add_tenant_access) + + flavor = objects.InstanceType.get(pecan.request.context, + flavor_uuid) + if flavor.is_public: + msg = _("Can not add access to a public flavor.") + raise wsme.exc.ClientSideError( + msg, status_code=http_client.CONFLICT) + + try: + flavor.projects.append(tenant['tenant_id']) + flavor.save() + except exception.FlavorNotFound as e: + raise wsme.exc.ClientSideError( + e.message, status_code=http_client.NOT_FOUND) + except exception.FlavorAccessExists as err: + raise wsme.exc.ClientSideError( + err.message, status_code=http_client.CONFLICT) + + @expose.expose(None, types.uuid, types.uuid, + status_code=http_client.NO_CONTENT) + def delete(self, flavor_uuid, tenant_id): + """Remove flavor access for the given tenant.""" + + flavor = objects.InstanceType.get(pecan.request.context, + flavor_uuid) + try: + # TODO(zhenguo): this should be synchronized. + if tenant_id in flavor.projects: + flavor.projects.remove(tenant_id) + flavor.save() + else: + raise exception.FlavorAccessNotFound(flavor_id=flavor.uuid, + project_id=tenant_id) + except (exception.FlavorAccessNotFound, + exception.FlavorNotFound) as e: + raise wsme.exc.ClientSideError( + e.message, status_code=http_client.NOT_FOUND) + + class FlavorsController(rest.RestController): """REST controller for Flavors.""" extraspecs = FlavorExtraSpecsController() + access = FlavorAccessController() @expose.expose(FlavorCollection) def get_all(self): diff --git a/mogan/api/controllers/v1/schemas/flavor_access.py b/mogan/api/controllers/v1/schemas/flavor_access.py new file mode 100644 index 00000000..5391f810 --- /dev/null +++ b/mogan/api/controllers/v1/schemas/flavor_access.py @@ -0,0 +1,26 @@ +# Copyright 2017 Huawei Technologies Co.,LTD. +# 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. + + +add_tenant_access = { + 'type': 'object', + 'properties': { + 'tenant_id': { + 'type': 'string', 'format': 'uuid', + }, + }, + 'required': ['tenant_id'], + 'additionalProperties': False, +} diff --git a/mogan/common/exception.py b/mogan/common/exception.py index 6cd98055..28d0c43d 100644 --- a/mogan/common/exception.py +++ b/mogan/common/exception.py @@ -164,6 +164,16 @@ class InstanceNotFound(NotFound): _msg_fmt = _("Instance %(instance)s could not be found.") +class FlavorAccessExists(MoganException): + _msg_fmt = _("Flavor access already exists for flavor %(flavor_id)s " + "and project %(project_id)s combination.") + + +class FlavorAccessNotFound(NotFound): + _msg_fmt = _("Flavor access not found for %(flavor_id)s / " + "%(project_id)s combination.") + + class ComputeNodeAlreadyExists(MoganException): _msg_fmt = _("ComputeNode with node_uuid %(node)s already exists.") diff --git a/mogan/db/api.py b/mogan/db/api.py index e7adf3ad..d43c2344 100644 --- a/mogan/db/api.py +++ b/mogan/db/api.py @@ -179,6 +179,19 @@ class Connection(object): extra specs dict argument """ + # Flavor access + @abc.abstractmethod + def flavor_access_add(self, context, flavor_id, project_id): + """Add flavor access for project.""" + + @abc.abstractmethod + def flavor_access_get(self, context, flavor_id): + """Get flavor access by flavor id.""" + + @abc.abstractmethod + def flavor_access_remove(self, context, flavor_id, project_id): + """Remove flavor access for project.""" + @abc.abstractmethod def instance_nics_get_by_instance_uuid(self, context, instance_uuid): """Get the Nics info of an instnace. diff --git a/mogan/db/sqlalchemy/api.py b/mogan/db/sqlalchemy/api.py index 7adaaac7..de605435 100644 --- a/mogan/db/sqlalchemy/api.py +++ b/mogan/db/sqlalchemy/api.py @@ -164,6 +164,13 @@ class Connection(api.Connection): instance_type_uuid=type_id) extra_query.delete() + # Clean up all access related to this flavor + project_query = model_query( + context, + models.InstanceTypeProjects).filter_by( + instance_type_uuid=type_id) + project_query.delete() + # Then delete the type record query = model_query(context, models.InstanceTypes) query = add_identity_filter(query, instance_type_uuid) @@ -470,6 +477,31 @@ class Connection(api.Connection): raise exception.FlavorExtraSpecsNotFound( extra_specs_key=key, flavor_id=type_id) + def flavor_access_get(self, context, flavor_id): + return _flavor_access_query(context, flavor_id) + + def flavor_access_add(self, context, flavor_id, project_id): + access_ref = models.InstanceTypeProjects() + access_ref.update({"instance_type_uuid": flavor_id, + "project_id": project_id}) + with _session_for_write() as session: + try: + session.add(access_ref) + session.flush() + except db_exc.DBDuplicateEntry: + raise exception.FlavorAccessExists(flavor_id=flavor_id, + project_id=project_id) + return access_ref + + def flavor_access_remove(self, context, flavor_id, project_id): + count = _flavor_access_query(context, flavor_id). \ + filter_by(project_id=project_id). \ + delete(synchronize_session=False) + + if count == 0: + raise exception.FlavorAccessNotFound(flavor_id=flavor_id, + project_id=project_id) + def instance_nic_update_or_create(self, context, port_id, values): with _session_for_write() as session: query = model_query(context, models.InstanceNic).filter_by( @@ -884,3 +916,8 @@ def _type_get_id_from_type(context, type_id): def _type_extra_specs_get_query(context, type_id): return model_query(context, models.InstanceTypeExtraSpecs). \ filter_by(instance_type_uuid=type_id) + + +def _flavor_access_query(context, flavor_id): + return model_query(context, models.InstanceTypeProjects). \ + filter_by(instance_type_uuid=flavor_id) diff --git a/mogan/objects/instance_type.py b/mogan/objects/instance_type.py index 529295dc..69964a1b 100644 --- a/mogan/objects/instance_type.py +++ b/mogan/objects/instance_type.py @@ -21,6 +21,9 @@ from mogan.objects import base from mogan.objects import fields as object_fields +OPTIONAL_FIELDS = ['extra_specs', 'projects'] + + @base.MoganObjectRegistry.register class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat): # Version 1.0: Initial version @@ -34,11 +37,40 @@ class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat): 'description': object_fields.StringField(nullable=True), 'is_public': object_fields.BooleanField(), 'extra_specs': object_fields.FlexibleDictField(), + 'projects': object_fields.ListOfStringsField(), } def __init__(self, *args, **kwargs): super(InstanceType, self).__init__(*args, **kwargs) self._orig_extra_specs = {} + self._orig_projects = {} + + @staticmethod + def _from_db_object(context, flavor, db_flavor, expected_attrs=None): + if expected_attrs is None: + expected_attrs = [] + + for name, field in flavor.fields.items(): + if name in OPTIONAL_FIELDS: + continue + value = db_flavor[name] + if isinstance(field, object_fields.IntegerField): + value = value if value is not None else 0 + flavor[name] = value + + if 'extra_specs' in expected_attrs: + flavor.extra_specs = db_flavor['extra_specs'] + + if 'projects' in expected_attrs: + flavor._load_projects(context) + + flavor.obj_reset_changes() + return flavor + + def _load_projects(self, context): + self.projects = [x['project_id'] for x in + self.dbapi.flavor_access_get(context, self.uuid)] + self.obj_reset_changes(['projects']) def obj_reset_changes(self, fields=None, recursive=False): super(InstanceType, self).obj_reset_changes(fields=fields, @@ -47,18 +79,25 @@ class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat): 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(InstanceType, 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 @staticmethod def _from_db_object_list(db_objects, cls, context): """Converts a list of database entities to a list of formal objects.""" - return [InstanceType._from_db_object(context, cls(context), obj) + return [InstanceType._from_db_object(context, cls(context), obj, + expected_attrs=['extra_specs']) for obj in db_objects] @classmethod @@ -73,15 +112,17 @@ class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat): """Find a Instance Type and return a Instance Type object.""" db_instance_type = cls.dbapi.instance_type_get(context, instance_type_uuid) - instance_type = InstanceType._from_db_object(context, cls(context), - db_instance_type) + instance_type = InstanceType._from_db_object( + context, cls(context), db_instance_type, + expected_attrs=['extra_specs', 'projects']) return instance_type def create(self, context=None): """Create a Instance Type record in the DB.""" values = self.obj_get_changes() db_instance_type = self.dbapi.instance_type_create(context, values) - self._from_db_object(context, self, db_instance_type) + self._from_db_object(context, self, db_instance_type, + expected_attrs=['extra_specs']) def destroy(self, context=None): """Delete the Instance Type from the DB.""" @@ -90,6 +131,7 @@ class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat): def save(self, context=None): updates = self.obj_get_changes() + projects = updates.pop('projects', None) extra_specs = updates.pop('extra_specs', None) if extra_specs is not None: deleted_keys = (set(self._orig_extra_specs.keys()) - @@ -98,8 +140,18 @@ class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat): 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 + if added_keys or deleted_keys: self.save_extra_specs(context, self.extra_specs, deleted_keys) + + if added_projects or deleted_projects: + self.save_projects(context, added_projects, deleted_projects) + self.dbapi.instance_type_update(context, self.uuid, updates) def save_extra_specs(self, context, to_add=None, to_delete=None): @@ -119,3 +171,21 @@ class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat): for key in to_delete: self.dbapi.type_extra_specs_delete(context, ident, key) self.obj_reset_changes(['extra_specs']) + + def save_projects(self, context, 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 + """ + ident = self.uuid + + 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.dbapi.flavor_access_add(context, ident, project_id) + + for project_id in to_delete: + self.dbapi.flavor_access_remove(context, ident, project_id) + self.obj_reset_changes(['projects']) diff --git a/mogan/tests/unit/objects/test_objects.py b/mogan/tests/unit/objects/test_objects.py index 35d66815..d73dd42f 100644 --- a/mogan/tests/unit/objects/test_objects.py +++ b/mogan/tests/unit/objects/test_objects.py @@ -391,7 +391,7 @@ expected_object_fingerprints = { 'ComputeDiskList': '1.0-33a2e1bb91ad4082f9f63429b77c1244', 'InstanceFault': '1.0-6b5b01b2cc7b6b547837acb168ec6eb9', 'InstanceFaultList': '1.0-43e8aad0258652921f929934e9e048fd', - 'InstanceType': '1.0-589b096651fcdb30898ff50f748dd948', + 'InstanceType': '1.0-d1cf232312ff8101aa5a19908b476d67', 'MyObj': '1.1-aad62eedc5a5cc8bcaf2982c285e753f', 'InstanceNic': '1.0-78744332fe105f9c1796dc5295713d9f', 'InstanceNics': '1.0-33a2e1bb91ad4082f9f63429b77c1244',