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:
parent
9e0befb643
commit
88fbd3870c
|
@ -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))
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
|
@ -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)
|
|
@ -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
|
Loading…
Reference in New Issue