Support cleanup of tenant resources with a single API call

The addition of the 'neutron purge' command allows cloud admins
to conveniently delete multiple neutron resources associated
with a given tenant.

The command will delete all supported resources provided that
they can be deleted (not in use, etc) and feedback the amount
of each resource deleted to the user. A completion percentage
is also given to keep the user informed of progress.

Currently supports deletion of:

Networks
Subnets (implicitly)
Routers
Ports (including router interfaces)
Floating IPs
Security Groups

This feature can be easily extended to support more resource
types in the future.

DocImpact: Update API documentation to describe neutron-purge usage

Change-Id: I5a366d3537191045eb53f9cccd8cd0f7ce54a63b
Closes-Bug: 1511574
Partially-Implements: blueprint tenant-delete
This commit is contained in:
John Davidge 2016-02-15 10:02:54 -08:00
parent 9e0befb643
commit 88fbd3870c
5 changed files with 437 additions and 0 deletions

View File

@ -0,0 +1,147 @@
# Copyright 2016 Cisco Systems
# 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 sys
from neutronclient._i18n import _
from neutronclient.neutron import v2_0 as neutronV20
class Purge(neutronV20.NeutronCommand):
def _pluralize(self, string):
return string + 's'
def _get_resources(self, neutron_client, resource_types, tenant_id):
resources = []
for resource_type in resource_types:
resources.append([])
resource_type_plural = self._pluralize(resource_type)
opts = {'fields': ['id', 'tenant_id']}
if resource_type_plural == 'ports':
opts['fields'].append('device_id')
opts['fields'].append('device_owner')
function = getattr(neutron_client, 'list_%s' %
resource_type_plural)
if callable(function):
returned_resources = function(**opts).get(resource_type_plural,
[])
for resource in returned_resources:
if resource['tenant_id'] == tenant_id:
index = resource_types.index(resource_type)
resources[index].append(resource)
self.total_resources += 1
return resources
def _delete_resource(self, neutron_client, resource_type, resource):
resource_id = resource['id']
if resource_type == 'port':
if resource.get('device_owner', '') == 'network:router_interface':
body = {'port_id': resource_id}
neutron_client.remove_interface_router(resource['device_id'],
body)
return
function = getattr(neutron_client, 'delete_%s' % resource_type)
if callable(function):
function(resource_id)
def _purge_resources(self, neutron_client, resource_types,
tenant_resources):
deleted = {}
failed = {}
failures = False
for resources in tenant_resources:
index = tenant_resources.index(resources)
resource_type = resource_types[index]
failed[resource_type] = 0
deleted[resource_type] = 0
for resource in resources:
try:
self._delete_resource(neutron_client, resource_type,
resource)
deleted[resource_type] += 1
self.deleted_resources += 1
except Exception:
failures = True
failed[resource_type] += 1
self.total_resources -= 1
percent_complete = 100
if self.total_resources > 0:
percent_complete = (self.deleted_resources /
float(self.total_resources)) * 100
sys.stdout.write("\rPurging resources: %d%% complete." %
percent_complete)
sys.stdout.flush()
return (deleted, failed, failures)
def _build_message(self, deleted, failed, failures):
msg = ''
deleted_msg = []
for resource, value in deleted.items():
if value:
if not msg:
msg = 'Deleted'
if not value == 1:
resource = self._pluralize(resource)
deleted_msg.append(" %d %s" % (value, resource))
if deleted_msg:
msg += ','.join(deleted_msg)
failed_msg = []
if failures:
if msg:
msg += '. '
msg += 'The following resources could not be deleted:'
for resource, value in failed.items():
if value:
if not value == 1:
resource = self._pluralize(resource)
failed_msg.append(" %d %s" % (value, resource))
msg += ','.join(failed_msg)
if msg:
msg += '.'
else:
msg = _('Tenant has no supported resources.')
return msg
def get_parser(self, prog_name):
parser = super(Purge, self).get_parser(prog_name)
parser.add_argument(
'tenant', metavar='TENANT',
help=_('ID of Tenant owning the resources to be deleted.'))
return parser
def run(self, parsed_args):
neutron_client = self.get_client()
self.any_failures = False
# A list of the types of resources supported in the order in which
# they should be deleted.
resource_types = ['floatingip', 'port', 'router',
'network', 'security_group']
deleted = {}
failed = {}
self.total_resources = 0
self.deleted_resources = 0
resources = self._get_resources(neutron_client, resource_types,
parsed_args.tenant)
deleted, failed, failures = self._purge_resources(neutron_client,
resource_types,
resources)
print('\n%s' % self._build_message(deleted, failed, failures))

View File

@ -66,6 +66,7 @@ from neutronclient.neutron.v2_0 import network
from neutronclient.neutron.v2_0.nsx import networkgateway
from neutronclient.neutron.v2_0.nsx import qos_queue
from neutronclient.neutron.v2_0 import port
from neutronclient.neutron.v2_0 import purge
from neutronclient.neutron.v2_0.qos import bandwidth_limit_rule
from neutronclient.neutron.v2_0.qos import policy as qos_policy
from neutronclient.neutron.v2_0.qos import rule as qos_rule
@ -171,6 +172,7 @@ COMMAND_V2 = {
'port-create': port.CreatePort,
'port-delete': port.DeletePort,
'port-update': port.UpdatePort,
'purge': purge.Purge,
'quota-list': quota.ListQuota,
'quota-show': quota.ShowQuota,
'quota-delete': quota.DeleteQuota,

View File

@ -0,0 +1,172 @@
# Copyright 2016 Cisco Systems
# 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.
from neutronclient.tests.functional import base
from tempest_lib import exceptions
class PurgeNeutronClientCLITest(base.ClientTestBase):
def _safe_cleanup(self, delete_command):
try:
self.neutron(delete_command)
except exceptions.CommandFailed:
# This resource was already purged successfully
pass
def _create_subnet(self, name, tenant_id, cidr):
params = ('%(name)s --name %(name)s --tenant-id %(tenant)s '
'%(cidr)s' % {'name': name,
'tenant': tenant_id,
'cidr': cidr})
subnet = self.parser.listing(self.neutron('subnet-create',
params=params))
for row in subnet:
if row['Field'] == 'id':
return row['Value']
def _create_router(self, name, tenant_id):
params = ('%(name)s --tenant_id %(tenant)s' % {'name': name,
'tenant': tenant_id})
router = self.parser.listing(self.neutron('router-create',
params=params))
for row in router:
if row['Field'] == 'id':
return row['Value']
def _create_floatingip(self, network, tenant_id):
params = ('%(network)s --tenant-id %(tenant)s' %
{'network': network, 'tenant': tenant_id})
floatingip = self.parser.listing(self.neutron('floatingip-create',
params=params))
for row in floatingip:
if row['Field'] == 'id':
return row['Value']
def _create_resources(self, name, tenant_id, shared_tenant_id=None):
# If no shared_tenant_id is provided, create the resources for the
# current tenant to test that they will be deleted when not in use.
if not shared_tenant_id:
shared_tenant_id = tenant_id
self.neutron('net-create',
params=('%(name)s --router:external True '
'--tenant-id %(tenant)s' % {'name': name,
'tenant': tenant_id}))
self.addCleanup(self._safe_cleanup, 'net-delete %s' % name)
self.neutron('net-create',
params=('%(name)s-shared --shared '
'--tenant-id %(tenant)s' %
{'name': name, 'tenant': shared_tenant_id}))
self.addCleanup(self._safe_cleanup,
'net-delete %s-shared' % name)
subnet = self._create_subnet(name, tenant_id, '192.168.71.0/24')
self.addCleanup(self._safe_cleanup, 'subnet-delete %s' % name)
subnet = self._create_subnet('%s-shared' % name, tenant_id,
'192.168.81.0/24')
self.addCleanup(self._safe_cleanup, 'subnet-delete %s-shared' % name)
router = self._create_router(name, tenant_id)
self.addCleanup(self._safe_cleanup, 'router-delete %s' % name)
self.neutron('router-interface-add',
params=('%(router)s %(subnet)s '
'--tenant-id %(tenant)s' % {'router': router,
'subnet': subnet,
'tenant': tenant_id}))
self.neutron('port-create',
params=('%(name)s --name %(name)s '
'--tenant-id %(tenant)s' % {'name': name,
'tenant': tenant_id}))
self.addCleanup(self._safe_cleanup, 'port-delete %s' % name)
self.neutron('port-create',
params=('%(name)s-shared --name %(name)s-shared '
'--tenant-id %(tenant)s' % {'name': name,
'tenant': tenant_id}))
self.addCleanup(self._safe_cleanup, 'port-delete %s-shared' % name)
self.neutron('security-group-create',
params=('%(name)s --tenant-id %(tenant)s' %
{'name': name, 'tenant': tenant_id}))
self.addCleanup(self._safe_cleanup, 'security-group-delete %s' % name)
floatingip = self._create_floatingip(name, tenant_id)
self.addCleanup(self._safe_cleanup, ('floatingip-delete '
'%s' % floatingip))
return floatingip
def _verify_deletion(self, resources, resource_type):
purged = True
no_purge_purged = True
for row in resources:
if resource_type == 'port' and row.get('id', None):
port = self.parser.listing(self.neutron('port-show',
params=row['id']))
port_dict = {}
for row in port:
port_dict[row['Field']] = row['Value']
if port_dict['device_owner'] == 'network:router_interface':
if port_dict['tenant_id'] == 'purge-tenant':
purged = False
elif port_dict['tenant_id'] == 'no-purge-tenant':
no_purge_purged = False
if not purged or not no_purge_purged:
self.addCleanup(self.neutron,
('router-interface-delete %(router)s '
'port=%(port)s' %
{'router': port_dict['device_id'],
'port': port_dict['id']}))
if (row.get('name') == 'purge-me' or
row.get('id') == self.purge_floatingip):
purged = False
elif ('no-purge' in row.get('name', '') or
row.get('id') == self.no_purge_floatingip):
no_purge_purged = False
if not purged:
self.fail('%s not deleted by neutron purge' % resource_type)
if no_purge_purged:
self.fail('%s owned by another tenant incorrectly deleted '
'by neutron purge' % resource_type)
def test_purge(self):
self.purge_floatingip = self._create_resources('purge-me',
'purge-tenant')
self.no_purge_floatingip = self._create_resources('no-purge',
'no-purge-tenant',
'purge-tenant')
purge_output = self.neutron('purge', params='purge-tenant').strip()
if not purge_output:
self.fail('Purge command did not return feedback')
networks = self.parser.listing(self.neutron('net-list'))
subnets = self.parser.listing(self.neutron('subnet-list'))
routers = self.parser.listing(self.neutron('router-list'))
ports = self.parser.listing(self.neutron('port-list'))
floatingips = self.parser.listing(self.neutron('floatingip-list'))
self._verify_deletion(networks, 'network')
self._verify_deletion(subnets, 'subnet')
self._verify_deletion(ports, 'port')
self._verify_deletion(routers, 'router')
self._verify_deletion(floatingips, 'floatingip')

View File

@ -0,0 +1,100 @@
# Copyright 2016 Cisco Systems
# 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 sys
from neutronclient.neutron.v2_0 import purge
from neutronclient.tests.unit import test_cli20
class CLITestV20Purge(test_cli20.CLITestV20Base):
def setUp(self):
super(CLITestV20Purge, self).setUp()
self.resource_types = ['floatingip', 'port', 'router',
'network', 'security_group']
def _generate_resources_dict(self, value=0):
resources_dict = {}
resources_dict['true'] = value
for resource_type in self.resource_types:
resources_dict[resource_type] = value
return resources_dict
def _verify_suffix(self, resources, message):
for resource, value in resources.items():
if value > 0:
suffix = list('%(value)d %(resource)s' %
{'value': value, 'resource': resource})
if value != 1:
suffix.append('s')
suffix = ''.join(suffix)
self.assertIn(suffix, message)
else:
self.assertNotIn(resource, message)
def _verify_message(self, message, deleted, failed):
message = message.split('.')
success_prefix = "Deleted "
failure_prefix = "The following resources could not be deleted: "
if not deleted['true']:
for msg in message:
self.assertNotIn(success_prefix, msg)
message = message[0]
if not failed['true']:
expected = 'Tenant has no supported resources'
self.assertEqual(expected, message)
else:
self.assertIn(failure_prefix, message)
self._verify_suffix(failed, message)
else:
resources_deleted = message[0]
self.assertIn(success_prefix, resources_deleted)
self._verify_suffix(deleted, resources_deleted)
if failed['true']:
resources_failed = message[1]
self.assertIn(failure_prefix, resources_failed)
self._verify_suffix(failed, resources_failed)
else:
for msg in message:
self.assertNotIn(failure_prefix, msg)
def _verify_result(self, my_purge, deleted, failed):
message = my_purge._build_message(deleted, failed, failed['true'])
self._verify_message(message, deleted, failed)
def test_build_message(self):
my_purge = purge.Purge(test_cli20.MyApp(sys.stdout), None)
# Verify message when tenant has no supported resources
deleted = self._generate_resources_dict()
failed = self._generate_resources_dict()
self._verify_result(my_purge, deleted, failed)
# Verify message when tenant has supported resources,
# and they are all deleteable
deleted = self._generate_resources_dict(1)
self._verify_result(my_purge, deleted, failed)
# Verify message when tenant has supported resources,
# and some are not deleteable
failed = self._generate_resources_dict(1)
self._verify_result(my_purge, deleted, failed)
# Verify message when tenant has supported resources,
# and all are not deleteable
deleted = self._generate_resources_dict()
self._verify_result(my_purge, deleted, failed)

View File

@ -0,0 +1,16 @@
---
features:
- |
New command 'neutron purge <tenant_id>' will delete all
supported resources owned by the given tenant, provided
that the user has sufficient authorization and the
resources in question are not shared, in use, or
otherwise undeletable.
Supported resources are:
* Networks
* Subnets
* Routers
* Ports
* Floating IPs
* Security Groups