Add flavor access management support

Change-Id: I1ffd0cbcc4b34393fd0186ccc17496c6adf1ee93
This commit is contained in:
Zhenguo Niu 2017-04-24 14:13:07 +08:00
parent 6fe3d0d43d
commit e0ca68c562
12 changed files with 362 additions and 5 deletions

View File

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

View File

@ -11,4 +11,5 @@ Baremetal Compute API V1 (CURRENT)
.. include:: instance_states.inc .. include:: instance_states.inc
.. include:: instance_networks.inc .. include:: instance_networks.inc
.. include:: flavors.inc .. include:: flavors.inc
.. include:: flavor_access.inc
.. include:: availability_zones.inc .. include:: availability_zones.inc

View File

@ -39,6 +39,12 @@ spec_key_path:
in: path in: path
required: true required: true
type: string 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 # variables in query
all_tenants: all_tenants:
@ -125,6 +131,12 @@ fixed_address:
in: body in: body
required: false required: false
type: string type: string
flavor_access:
description: |
A list of tenants.
in: body
required: true
type: array
flavor_description: flavor_description:
description: | description: |
The description of the flavor. The description of the flavor.
@ -402,6 +414,12 @@ provision_state:
in: body in: body
required: true required: true
type: string type: string
tenant_id_body:
description: |
The UUID of the tenant in a multi-tenancy cloud.
in: body
required: true
type: string
updated_at: updated_at:
description: | description: |
The date and time when the resource was updated. The date and time The date and time when the resource was updated. The date and time

View File

@ -0,0 +1,3 @@
{
"tenant_id": "fake_tenant"
}

View File

@ -0,0 +1,7 @@
{
"flavor_access": [
"tenant1",
"tenant2",
"tenant3"
]
}

View File

@ -21,13 +21,23 @@ from wsme import types as wtypes
from mogan.api.controllers import base from mogan.api.controllers import base
from mogan.api.controllers import link 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.controllers.v1 import types
from mogan.api import expose from mogan.api import expose
from mogan.api import validation
from mogan.common import exception from mogan.common import exception
from mogan.common.i18n import _ from mogan.common.i18n import _
from mogan import objects 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): class Flavor(base.APIBase):
"""API representation of a flavor. """API representation of a flavor.
@ -125,10 +135,74 @@ class FlavorExtraSpecsController(rest.RestController):
flavor.save() 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): class FlavorsController(rest.RestController):
"""REST controller for Flavors.""" """REST controller for Flavors."""
extraspecs = FlavorExtraSpecsController() extraspecs = FlavorExtraSpecsController()
access = FlavorAccessController()
@expose.expose(FlavorCollection) @expose.expose(FlavorCollection)
def get_all(self): def get_all(self):

View File

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

View File

@ -164,6 +164,16 @@ class InstanceNotFound(NotFound):
_msg_fmt = _("Instance %(instance)s could not be found.") _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): class ComputeNodeAlreadyExists(MoganException):
_msg_fmt = _("ComputeNode with node_uuid %(node)s already exists.") _msg_fmt = _("ComputeNode with node_uuid %(node)s already exists.")

View File

@ -179,6 +179,19 @@ class Connection(object):
extra specs dict argument 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 @abc.abstractmethod
def instance_nics_get_by_instance_uuid(self, context, instance_uuid): def instance_nics_get_by_instance_uuid(self, context, instance_uuid):
"""Get the Nics info of an instnace. """Get the Nics info of an instnace.

View File

@ -164,6 +164,13 @@ class Connection(api.Connection):
instance_type_uuid=type_id) instance_type_uuid=type_id)
extra_query.delete() 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 # Then delete the type record
query = model_query(context, models.InstanceTypes) query = model_query(context, models.InstanceTypes)
query = add_identity_filter(query, instance_type_uuid) query = add_identity_filter(query, instance_type_uuid)
@ -470,6 +477,31 @@ class Connection(api.Connection):
raise exception.FlavorExtraSpecsNotFound( raise exception.FlavorExtraSpecsNotFound(
extra_specs_key=key, flavor_id=type_id) 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): def instance_nic_update_or_create(self, context, port_id, values):
with _session_for_write() as session: with _session_for_write() as session:
query = model_query(context, models.InstanceNic).filter_by( 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): def _type_extra_specs_get_query(context, type_id):
return model_query(context, models.InstanceTypeExtraSpecs). \ return model_query(context, models.InstanceTypeExtraSpecs). \
filter_by(instance_type_uuid=type_id) 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)

View File

@ -21,6 +21,9 @@ from mogan.objects import base
from mogan.objects import fields as object_fields from mogan.objects import fields as object_fields
OPTIONAL_FIELDS = ['extra_specs', 'projects']
@base.MoganObjectRegistry.register @base.MoganObjectRegistry.register
class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat): class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat):
# Version 1.0: Initial version # Version 1.0: Initial version
@ -34,11 +37,40 @@ class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat):
'description': object_fields.StringField(nullable=True), 'description': object_fields.StringField(nullable=True),
'is_public': object_fields.BooleanField(), 'is_public': object_fields.BooleanField(),
'extra_specs': object_fields.FlexibleDictField(), 'extra_specs': object_fields.FlexibleDictField(),
'projects': object_fields.ListOfStringsField(),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(InstanceType, self).__init__(*args, **kwargs) super(InstanceType, self).__init__(*args, **kwargs)
self._orig_extra_specs = {} 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): def obj_reset_changes(self, fields=None, recursive=False):
super(InstanceType, self).obj_reset_changes(fields=fields, 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) self._orig_extra_specs = (dict(self.extra_specs)
if self.obj_attr_is_set('extra_specs') if self.obj_attr_is_set('extra_specs')
else {}) 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): def obj_what_changed(self):
changes = super(InstanceType, self).obj_what_changed() changes = super(InstanceType, self).obj_what_changed()
if ('extra_specs' in self and if ('extra_specs' in self and
self.extra_specs != self._orig_extra_specs): self.extra_specs != self._orig_extra_specs):
changes.add('extra_specs') changes.add('extra_specs')
if 'projects' in self and self.projects != self._orig_projects:
changes.add('projects')
return changes return changes
@staticmethod @staticmethod
def _from_db_object_list(db_objects, cls, context): def _from_db_object_list(db_objects, cls, context):
"""Converts a list of database entities to a list of formal objects.""" """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] for obj in db_objects]
@classmethod @classmethod
@ -73,15 +112,17 @@ class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat):
"""Find a Instance Type and return a Instance Type object.""" """Find a Instance Type and return a Instance Type object."""
db_instance_type = cls.dbapi.instance_type_get(context, db_instance_type = cls.dbapi.instance_type_get(context,
instance_type_uuid) instance_type_uuid)
instance_type = InstanceType._from_db_object(context, cls(context), instance_type = InstanceType._from_db_object(
db_instance_type) context, cls(context), db_instance_type,
expected_attrs=['extra_specs', 'projects'])
return instance_type return instance_type
def create(self, context=None): def create(self, context=None):
"""Create a Instance Type record in the DB.""" """Create a Instance Type record in the DB."""
values = self.obj_get_changes() values = self.obj_get_changes()
db_instance_type = self.dbapi.instance_type_create(context, values) 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): def destroy(self, context=None):
"""Delete the Instance Type from the DB.""" """Delete the Instance Type from the DB."""
@ -90,6 +131,7 @@ class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat):
def save(self, context=None): def save(self, context=None):
updates = self.obj_get_changes() updates = self.obj_get_changes()
projects = updates.pop('projects', None)
extra_specs = updates.pop('extra_specs', None) extra_specs = updates.pop('extra_specs', None)
if extra_specs is not None: if extra_specs is not None:
deleted_keys = (set(self._orig_extra_specs.keys()) - deleted_keys = (set(self._orig_extra_specs.keys()) -
@ -98,8 +140,18 @@ class InstanceType(base.MoganObject, object_base.VersionedObjectDictCompat):
else: else:
added_keys = deleted_keys = None 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: if added_keys or deleted_keys:
self.save_extra_specs(context, self.extra_specs, 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) self.dbapi.instance_type_update(context, self.uuid, updates)
def save_extra_specs(self, context, to_add=None, to_delete=None): 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: for key in to_delete:
self.dbapi.type_extra_specs_delete(context, ident, key) self.dbapi.type_extra_specs_delete(context, ident, key)
self.obj_reset_changes(['extra_specs']) 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'])

View File

@ -391,7 +391,7 @@ expected_object_fingerprints = {
'ComputeDiskList': '1.0-33a2e1bb91ad4082f9f63429b77c1244', 'ComputeDiskList': '1.0-33a2e1bb91ad4082f9f63429b77c1244',
'InstanceFault': '1.0-6b5b01b2cc7b6b547837acb168ec6eb9', 'InstanceFault': '1.0-6b5b01b2cc7b6b547837acb168ec6eb9',
'InstanceFaultList': '1.0-43e8aad0258652921f929934e9e048fd', 'InstanceFaultList': '1.0-43e8aad0258652921f929934e9e048fd',
'InstanceType': '1.0-589b096651fcdb30898ff50f748dd948', 'InstanceType': '1.0-d1cf232312ff8101aa5a19908b476d67',
'MyObj': '1.1-aad62eedc5a5cc8bcaf2982c285e753f', 'MyObj': '1.1-aad62eedc5a5cc8bcaf2982c285e753f',
'InstanceNic': '1.0-78744332fe105f9c1796dc5295713d9f', 'InstanceNic': '1.0-78744332fe105f9c1796dc5295713d9f',
'InstanceNics': '1.0-33a2e1bb91ad4082f9f63429b77c1244', 'InstanceNics': '1.0-33a2e1bb91ad4082f9f63429b77c1244',