From 88fbd3870cf3872fb0c9b4269503c62458664b3b Mon Sep 17 00:00:00 2001 From: John Davidge Date: Mon, 15 Feb 2016 10:02:54 -0800 Subject: [PATCH] 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 --- neutronclient/neutron/v2_0/purge.py | 147 +++++++++++++++ neutronclient/shell.py | 2 + .../tests/functional/core/test_purge.py | 172 ++++++++++++++++++ neutronclient/tests/unit/test_cli20_purge.py | 100 ++++++++++ .../add-neutron-purge-a89e3d1179dce4b1.yaml | 16 ++ 5 files changed, 437 insertions(+) create mode 100644 neutronclient/neutron/v2_0/purge.py create mode 100644 neutronclient/tests/functional/core/test_purge.py create mode 100644 neutronclient/tests/unit/test_cli20_purge.py create mode 100644 releasenotes/notes/add-neutron-purge-a89e3d1179dce4b1.yaml diff --git a/neutronclient/neutron/v2_0/purge.py b/neutronclient/neutron/v2_0/purge.py new file mode 100644 index 000000000..6d8e9d9b9 --- /dev/null +++ b/neutronclient/neutron/v2_0/purge.py @@ -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)) diff --git a/neutronclient/shell.py b/neutronclient/shell.py index c1f6b0aaf..037e2be80 100644 --- a/neutronclient/shell.py +++ b/neutronclient/shell.py @@ -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, diff --git a/neutronclient/tests/functional/core/test_purge.py b/neutronclient/tests/functional/core/test_purge.py new file mode 100644 index 000000000..91d92a9ef --- /dev/null +++ b/neutronclient/tests/functional/core/test_purge.py @@ -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') diff --git a/neutronclient/tests/unit/test_cli20_purge.py b/neutronclient/tests/unit/test_cli20_purge.py new file mode 100644 index 000000000..9bfd91c53 --- /dev/null +++ b/neutronclient/tests/unit/test_cli20_purge.py @@ -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) diff --git a/releasenotes/notes/add-neutron-purge-a89e3d1179dce4b1.yaml b/releasenotes/notes/add-neutron-purge-a89e3d1179dce4b1.yaml new file mode 100644 index 000000000..a689b89ed --- /dev/null +++ b/releasenotes/notes/add-neutron-purge-a89e3d1179dce4b1.yaml @@ -0,0 +1,16 @@ +--- +features: + - | + New command 'neutron purge ' 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