From e0ca68c562e7d9aaaec914ab76a0c5a5a5250ab7 Mon Sep 17 00:00:00 2001
From: Zhenguo Niu <Niu.ZGlinux@gmail.com>
Date: Mon, 24 Apr 2017 14:13:07 +0800
Subject: [PATCH] Add flavor access management support

Change-Id: I1ffd0cbcc4b34393fd0186ccc17496c6adf1ee93
---
 api-ref/source/v1/flavor_access.inc           | 98 +++++++++++++++++++
 api-ref/source/v1/index.rst                   |  1 +
 api-ref/source/v1/parameters.yaml             | 18 ++++
 .../flavor-access-add-tenant-req.json         |  3 +
 .../flavor-access-list-resp.json              |  7 ++
 mogan/api/controllers/v1/flavors.py           | 74 ++++++++++++++
 .../controllers/v1/schemas/flavor_access.py   | 26 +++++
 mogan/common/exception.py                     | 10 ++
 mogan/db/api.py                               | 13 +++
 mogan/db/sqlalchemy/api.py                    | 37 +++++++
 mogan/objects/instance_type.py                | 78 ++++++++++++++-
 mogan/tests/unit/objects/test_objects.py      |  2 +-
 12 files changed, 362 insertions(+), 5 deletions(-)
 create mode 100644 api-ref/source/v1/flavor_access.inc
 create mode 100644 api-ref/source/v1/samples/flavor_access/flavor-access-add-tenant-req.json
 create mode 100644 api-ref/source/v1/samples/flavor_access/flavor-access-list-resp.json
 create mode 100644 mogan/api/controllers/v1/schemas/flavor_access.py

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',