Manager support for project cascade delete

Adds manager support for project cascade delete.
This operation is done in atomic operation, with just one delete request
to the backend. It keeps the normal delete behavior that requires the
whole subtree disabled. Otherwise, no project is deleted.
A new parameter "parent_project_id" was also added to the test helper
method _create_projects_hierarchy to ease the creation of
sub-hierarchies.

Co-Authored-By: Henrique Truta <henrique@lsd.ufcg.edu.br>

Change-Id: I066494945d99a9a4d6f97568486a74c68d8d2cd7
Partially-Implements: bp project-tree-deletion
This commit is contained in:
Paulo Ewerton 2015-11-11 14:59:19 +00:00 committed by Henrique Truta
parent 62d4b2f669
commit c25786a50a
3 changed files with 155 additions and 31 deletions

View File

@ -276,14 +276,11 @@ class Manager(manager.Manager):
action=_('cannot enable project %s since it has '
'disabled parents') % project_id)
def _assert_whole_subtree_is_disabled(self, project_id):
subtree_list = self.list_projects_in_subtree(project_id)
for ref in subtree_list:
if ref.get('enabled', True):
raise exception.ForbiddenAction(
action=_('cannot disable project %s since '
'its subtree contains enabled '
'projects') % project_id)
def _check_whole_subtree_is_disabled(self, project_id, subtree_list=None):
if not subtree_list:
subtree_list = self.list_projects_in_subtree(project_id)
subtree_enabled = [ref.get('enabled', True) for ref in subtree_list]
return (not any(subtree_enabled))
def update_project(self, project_id, project, initiator=None):
# Use the driver directly to prevent using old cached value.
@ -325,8 +322,13 @@ class Manager(manager.Manager):
# project acting as a domain to be disabled irrespective of the
# state of its children. Disabling a project acting as domain
# effectively disables its children.
if not original_project.get('is_domain'):
self._assert_whole_subtree_is_disabled(project_id)
if (not original_project.get('is_domain') and not
self._check_whole_subtree_is_disabled(project_id)):
raise exception.ForbiddenAction(
action=_('cannot disable project %(project_id)s since its '
'subtree contains enabled projects.')
% {'project_id': project_id})
self._disable_project(project_id)
ret = self.driver.update_project(project_id, project)
@ -344,7 +346,27 @@ class Manager(manager.Manager):
return ret
def delete_project(self, project_id, initiator=None):
def _pre_delete_cleanup_project(self, project_id, project, initiator=None):
project_user_ids = (
self.assignment_api.list_user_ids_for_project(project_id))
for user_id in project_user_ids:
payload = {'user_id': user_id, 'project_id': project_id}
self._emit_invalidate_user_project_tokens_notification(payload)
def _post_delete_cleanup_project(self, project_id, project,
initiator=None):
self.assignment_api.delete_project_assignments(project_id)
self.get_project.invalidate(self, project_id)
self.get_project_by_name.invalidate(self, project['name'],
project['domain_id'])
self.credential_api.delete_credentials_for_project(project_id)
notifications.Audit.deleted(self._PROJECT, project_id, initiator)
# Invalidate user role assignments cache region, as it may
# be caching role assignments where the target is
# the specified project
assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate()
def delete_project(self, project_id, initiator=None, cascade=False):
# Use the driver directly to prevent using old cached value.
project = self.driver.get_project(project_id)
if project['is_domain'] and project['enabled']:
@ -353,27 +375,37 @@ class Manager(manager.Manager):
'domain. Please disable the project %s first.')
% project.get('id'))
if not self.is_leaf_project(project_id):
if not self.is_leaf_project(project_id) and not cascade:
raise exception.ForbiddenAction(
action=_('cannot delete the project %s since it is not '
'a leaf in the hierarchy.') % project_id)
'a leaf in the hierarchy. Use the cascade option '
'if you want to delete a whole subtree.')
% project_id)
project_user_ids = (
self.assignment_api.list_user_ids_for_project(project_id))
for user_id in project_user_ids:
payload = {'user_id': user_id, 'project_id': project_id}
self._emit_invalidate_user_project_tokens_notification(payload)
ret = self.driver.delete_project(project_id)
self.assignment_api.delete_project_assignments(project_id)
self.get_project.invalidate(self, project_id)
self.get_project_by_name.invalidate(self, project['name'],
project['domain_id'])
self.credential_api.delete_credentials_for_project(project_id)
notifications.Audit.deleted(self._PROJECT, project_id, initiator)
if cascade:
# Getting reversed project's subtrees list, i.e. from the leaves
# to the root, so we do not break parent_id FK.
subtree_list = self.list_projects_in_subtree(project_id)
subtree_list.reverse()
if not self._check_whole_subtree_is_disabled(
project_id, subtree_list=subtree_list):
raise exception.ForbiddenAction(
action=_('cannot delete project %(project_id)s since its '
'subtree contains enabled projects.')
% {'project_id': project_id})
# Invalidate user role assignments cache region, as it may be caching
# role assignments where the target is the specified project
assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate()
project_list = subtree_list + [project]
projects_ids = [x['id'] for x in project_list]
for prj in project_list:
self._pre_delete_cleanup_project(prj['id'], prj, initiator)
ret = self.driver.delete_projects_from_ids(projects_ids)
for prj in project_list:
self._post_delete_cleanup_project(prj['id'], prj, initiator)
else:
self._pre_delete_cleanup_project(project_id, project, initiator)
ret = self.driver.delete_project(project_id)
self._post_delete_cleanup_project(project_id, project, initiator)
return ret

View File

@ -34,3 +34,9 @@ class SqlIdentityV8(test_backend_sql.SqlIdentity):
def test_delete_projects_from_ids_with_no_existing_project_id(self):
self.skipTest('Operation not supported in v8 and earlier drivers')
def test_delete_project_cascade(self):
self.skipTest('Operation not supported in v8 and earlier drivers')
def test_delete_large_project_cascade(self):
self.skipTest('Operation not supported in v8 and earlier drivers')

View File

@ -2524,7 +2524,8 @@ class IdentityTests(AssignmentTestHelperMixin):
def _create_projects_hierarchy(self, hierarchy_size=2,
domain_id=DEFAULT_DOMAIN_ID,
is_domain=False):
is_domain=False,
parent_project_id=None):
"""Creates a project hierarchy with specified size.
:param hierarchy_size: the desired hierarchy size, default is 2 -
@ -2532,12 +2533,20 @@ class IdentityTests(AssignmentTestHelperMixin):
:param domain_id: domain where the projects hierarchy will be created.
:param is_domain: if the hierarchy will have the is_domain flag active
or not.
:param parent_project_id: if the intention is to create a
sub-hierarchy, sets the sub-hierarchy root. Defaults to creating
a new hierarchy, i.e. a new root project.
:returns projects: a list of the projects in the created hierarchy.
"""
project = unit.new_project_ref(domain_id=domain_id,
is_domain=is_domain)
if parent_project_id:
project = unit.new_project_ref(parent_id=parent_project_id,
domain_id=domain_id,
is_domain=is_domain)
else:
project = unit.new_project_ref(domain_id=domain_id,
is_domain=is_domain)
project_id = project['id']
self.resource_api.create_project(project_id, project)
@ -3356,6 +3365,83 @@ class IdentityTests(AssignmentTestHelperMixin):
# no error.
self.resource_api.driver.delete_projects_from_ids([uuid.uuid4().hex])
def test_delete_project_cascade(self):
# create a hierarchy with 3 levels
projects_hierarchy = self._create_projects_hierarchy(hierarchy_size=3)
root_project = projects_hierarchy[0]
project1 = projects_hierarchy[1]
project2 = projects_hierarchy[2]
# Disabling all projects before attempting to delete
for project in (project2, project1, root_project):
project['enabled'] = False
self.resource_api.update_project(project['id'], project)
self.resource_api.delete_project(root_project['id'], cascade=True)
for project in projects_hierarchy:
self.assertRaises(exception.ProjectNotFound,
self.resource_api.get_project,
project['id'])
def test_delete_large_project_cascade(self):
"""Try delete a large project with cascade true
Tree we will create::
+-p1-+
| |
p5 p2
| |
p6 +-p3-+
| |
p7 p4
"""
# create a hierarchy with 4 levels
projects_hierarchy = self._create_projects_hierarchy(hierarchy_size=4)
p1 = projects_hierarchy[0]
# Add the left branch to the hierarchy (p5, p6)
self._create_projects_hierarchy(hierarchy_size=2,
parent_project_id=p1['id'])
# Add p7 to the hierarchy
p3_id = projects_hierarchy[2]['id']
self._create_projects_hierarchy(hierarchy_size=1,
parent_project_id=p3_id)
# Reverse the hierarchy to disable the leaf first
prjs_hierarchy = ([p1] + self.resource_api.list_projects_in_subtree(
p1['id']))[::-1]
# Disabling all projects before attempting to delete
for project in prjs_hierarchy:
project['enabled'] = False
self.resource_api.update_project(project['id'], project)
self.resource_api.delete_project(p1['id'], cascade=True)
for project in prjs_hierarchy:
self.assertRaises(exception.ProjectNotFound,
self.resource_api.get_project,
project['id'])
def test_cannot_delete_project_cascade_with_enabled_child(self):
# create a hierarchy with 3 levels
projects_hierarchy = self._create_projects_hierarchy(hierarchy_size=3)
root_project = projects_hierarchy[0]
project1 = projects_hierarchy[1]
project2 = projects_hierarchy[2]
project2['enabled'] = False
self.resource_api.update_project(project2['id'], project2)
# Cannot cascade delete root_project, since project1 is enabled
self.assertRaises(exception.ForbiddenAction,
self.resource_api.delete_project,
root_project['id'],
cascade=True)
# Ensuring no project was deleted, not even project2
self.resource_api.get_project(root_project['id'])
self.resource_api.get_project(project1['id'])
self.resource_api.get_project(project2['id'])
def test_hierarchical_projects_crud(self):
# create a hierarchy with just a root project (which is a leaf as well)
projects_hierarchy = self._create_projects_hierarchy(hierarchy_size=1)