From 227d4c64ef4ac04b5fed6cdff035821bf0e6ae7e Mon Sep 17 00:00:00 2001 From: Steve Martinelli Date: Thu, 8 Sep 2016 15:13:38 -0700 Subject: [PATCH] Add project purge command to osc See the initial implementation: https://github.com/openstack/ospurge/blob/master/ospurge/client.py Partial-Bug: 1584596 Change-Id: I3aa86af7c85e7ca3b7f04b43e8e07125f7d956d1 --- doc/source/command-objects/project-purge.rst | 42 +++ doc/source/commands.rst | 1 + openstackclient/common/project_purge.py | 168 ++++++++++ .../tests/unit/common/test_project_purge.py | 314 ++++++++++++++++++ .../notes/bug-1584596-5b3109487b451bec.yaml | 5 + setup.cfg | 1 + 6 files changed, 531 insertions(+) create mode 100644 doc/source/command-objects/project-purge.rst create mode 100644 openstackclient/common/project_purge.py create mode 100644 openstackclient/tests/unit/common/test_project_purge.py create mode 100644 releasenotes/notes/bug-1584596-5b3109487b451bec.yaml diff --git a/doc/source/command-objects/project-purge.rst b/doc/source/command-objects/project-purge.rst new file mode 100644 index 0000000000..0ad0bbf969 --- /dev/null +++ b/doc/source/command-objects/project-purge.rst @@ -0,0 +1,42 @@ +============= +project purge +============= + +Clean resources associated with a specific project. + +Block Storage v1, v2; Compute v2; Image v1, v2 + +project purge +------------- + +Clean resources associated with a project + +.. program:: project purge +.. code:: bash + + openstack project purge + [--dry-run] + [--keep-project] + [--auth-project | --project ] + [--project-domain ] + +.. option:: --dry-run + + List a project's resources + +.. option:: --keep-project + + Clean project resources, but don't delete the project. + +.. option:: --auth-project + + Delete resources of the project used to authenticate + +.. option:: --project + + Project to clean (name or ID) + +.. option:: --project-domain + + Domain the project belongs to (name or ID). This can be + used in case collisions between project names exist. diff --git a/doc/source/commands.rst b/doc/source/commands.rst index 4e0a9258e2..ba3e335c7d 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -251,6 +251,7 @@ Those actions with an opposite action are noted in parens if applicable. live server migration if possible * ``pause`` (``unpause``) - stop one or more servers and leave them in memory * ``query`` - Query resources by Elasticsearch query string or json format DSL. +* ``purge`` - clean resources associated with a specific project * ``reboot`` - forcibly reboot a server * ``rebuild`` - rebuild a server using (most of) the same arguments as in the original create * ``remove`` (``add``) - remove an object from a group of objects diff --git a/openstackclient/common/project_purge.py b/openstackclient/common/project_purge.py new file mode 100644 index 0000000000..dff954e725 --- /dev/null +++ b/openstackclient/common/project_purge.py @@ -0,0 +1,168 @@ +# Copyright 2012 OpenStack Foundation +# +# 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 logging + +from osc_lib.command import command +from osc_lib import utils + +from openstackclient.i18n import _ +from openstackclient.identity import common as identity_common + + +LOG = logging.getLogger(__name__) + + +class ProjectPurge(command.Command): + _description = _("Clean resources associated with a project") + + def get_parser(self, prog_name): + parser = super(ProjectPurge, self).get_parser(prog_name) + parser.add_argument( + '--dry-run', + action='store_true', + help=_("List a project's resources"), + ) + parser.add_argument( + '--keep-project', + action='store_true', + help=_("Clean project resources, but don't delete the project"), + ) + project_group = parser.add_mutually_exclusive_group(required=True) + project_group.add_argument( + '--auth-project', + action='store_true', + help=_('Delete resources of the project used to authenticate'), + ) + project_group.add_argument( + '--project', + metavar='', + help=_('Project to clean (name or ID)'), + ) + identity_common.add_project_domain_option_to_parser(parser) + return parser + + def take_action(self, parsed_args): + identity_client = self.app.client_manager.identity + + if parsed_args.auth_project: + project_id = self.app.client_manager.auth_ref.project_id + elif parsed_args.project: + try: + project_id = identity_common.find_project( + identity_client, + parsed_args.project, + parsed_args.project_domain, + ).id + except AttributeError: # using v2 auth and supplying a domain + project_id = utils.find_resource( + identity_client.tenants, + parsed_args.project, + ).id + + # delete all non-identity resources + self.delete_resources(parsed_args.dry_run, project_id) + + # clean up the project + if not parsed_args.keep_project: + LOG.warning(_('Deleting project: %s'), project_id) + if not parsed_args.dry_run: + identity_client.projects.delete(project_id) + + def delete_resources(self, dry_run, project_id): + # servers + try: + compute_client = self.app.client_manager.compute + search_opts = {'tenant_id': project_id} + data = compute_client.servers.list(search_opts=search_opts) + self.delete_objects( + compute_client.servers.delete, data, 'server', dry_run) + except Exception: + pass + + # images + try: + image_client = self.app.client_manager.image + data = image_client.images.list(owner=project_id) + self.delete_objects( + image_client.images.delete, data, 'image', dry_run) + except Exception: + pass + + # volumes, snapshots, backups + volume_client = self.app.client_manager.volume + search_opts = {'project_id': project_id} + try: + data = volume_client.volume_snapshots.list(search_opts=search_opts) + self.delete_objects( + self.delete_one_volume_snapshot, + data, + 'volume snapshot', + dry_run) + except Exception: + pass + try: + data = volume_client.backups.list(search_opts=search_opts) + self.delete_objects( + self.delete_one_volume_backup, + data, + 'volume backup', + dry_run) + except Exception: + pass + try: + data = volume_client.volumes.list(search_opts=search_opts) + self.delete_objects( + volume_client.volumes.force_delete, data, 'volume', dry_run) + except Exception: + pass + + def delete_objects(self, func_delete, data, resource, dry_run): + result = 0 + for i in data: + LOG.warning(_('Deleting %(resource)s : %(id)s') % + {'resource': resource, 'id': i.id}) + if not dry_run: + try: + func_delete(i.id) + except Exception as e: + result += 1 + LOG.error(_("Failed to delete %(resource)s with " + "ID '%(id)s': %(e)s") + % {'resource': resource, 'id': i.id, 'e': e}) + if result > 0: + total = len(data) + msg = (_("%(result)s of %(total)s %(resource)ss failed " + "to delete.") % + {'result': result, + 'total': total, + 'resource': resource}) + LOG.error(msg) + + def delete_one_volume_snapshot(self, snapshot_id): + volume_client = self.app.client_manager.volume + try: + volume_client.volume_snapshots.delete(snapshot_id) + except Exception: + # Only volume v2 support deleting by force + volume_client.volume_snapshots.delete(snapshot_id, force=True) + + def delete_one_volume_backup(self, backup_id): + volume_client = self.app.client_manager.volume + try: + volume_client.backups.delete(backup_id) + except Exception: + # Only volume v2 support deleting by force + volume_client.backups.delete(backup_id, force=True) diff --git a/openstackclient/tests/unit/common/test_project_purge.py b/openstackclient/tests/unit/common/test_project_purge.py new file mode 100644 index 0000000000..05a8aa3e22 --- /dev/null +++ b/openstackclient/tests/unit/common/test_project_purge.py @@ -0,0 +1,314 @@ +# 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 mock + +from osc_lib import exceptions + +from openstackclient.common import project_purge +from openstackclient.tests.unit.compute.v2 import fakes as compute_fakes +from openstackclient.tests.unit import fakes +from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes +from openstackclient.tests.unit.image.v2 import fakes as image_fakes +from openstackclient.tests.unit import utils as tests_utils +from openstackclient.tests.unit.volume.v2 import fakes as volume_fakes + + +class TestProjectPurgeInit(tests_utils.TestCommand): + + def setUp(self): + super(TestProjectPurgeInit, self).setUp() + compute_client = compute_fakes.FakeComputev2Client( + endpoint=fakes.AUTH_URL, + token=fakes.AUTH_TOKEN, + ) + self.app.client_manager.compute = compute_client + self.servers_mock = compute_client.servers + self.servers_mock.reset_mock() + + volume_client = volume_fakes.FakeVolumeClient( + endpoint=fakes.AUTH_URL, + token=fakes.AUTH_TOKEN, + ) + self.app.client_manager.volume = volume_client + self.volumes_mock = volume_client.volumes + self.volumes_mock.reset_mock() + self.snapshots_mock = volume_client.volume_snapshots + self.snapshots_mock.reset_mock() + self.backups_mock = volume_client.backups + self.backups_mock.reset_mock() + + identity_client = identity_fakes.FakeIdentityv3Client( + endpoint=fakes.AUTH_URL, + token=fakes.AUTH_TOKEN, + ) + self.app.client_manager.identity = identity_client + self.domains_mock = identity_client.domains + self.domains_mock.reset_mock() + self.projects_mock = identity_client.projects + self.projects_mock.reset_mock() + + image_client = image_fakes.FakeImagev2Client( + endpoint=fakes.AUTH_URL, + token=fakes.AUTH_TOKEN, + ) + self.app.client_manager.image = image_client + self.images_mock = image_client.images + self.images_mock.reset_mock() + + +class TestProjectPurge(TestProjectPurgeInit): + + project = identity_fakes.FakeProject.create_one_project() + server = compute_fakes.FakeServer.create_one_server() + image = image_fakes.FakeImage.create_one_image() + volume = volume_fakes.FakeVolume.create_one_volume() + backup = volume_fakes.FakeBackup.create_one_backup() + snapshot = volume_fakes.FakeSnapshot.create_one_snapshot() + + def setUp(self): + super(TestProjectPurge, self).setUp() + self.projects_mock.get.return_value = self.project + self.projects_mock.delete.return_value = None + self.images_mock.list.return_value = [self.image] + self.images_mock.delete.return_value = None + self.servers_mock.list.return_value = [self.server] + self.servers_mock.delete.return_value = None + self.volumes_mock.list.return_value = [self.volume] + self.volumes_mock.delete.return_value = None + self.volumes_mock.force_delete.return_value = None + self.snapshots_mock.list.return_value = [self.snapshot] + self.snapshots_mock.delete.return_value = None + self.backups_mock.list.return_value = [self.backup] + self.backups_mock.delete.return_value = None + + self.cmd = project_purge.ProjectPurge(self.app, None) + + def test_project_no_options(self): + arglist = [] + verifylist = [] + + self.assertRaises(tests_utils.ParserException, self.check_parser, + self.cmd, arglist, verifylist) + + def test_project_purge_with_project(self): + arglist = [ + '--project', self.project.id, + ] + verifylist = [ + ('dry_run', False), + ('keep_project', False), + ('auth_project', False), + ('project', self.project.id), + ('project_domain', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.projects_mock.get.assert_called_once_with(self.project.id) + self.projects_mock.delete.assert_called_once_with(self.project.id) + self.servers_mock.list.assert_called_once_with( + search_opts={'tenant_id': self.project.id}) + self.images_mock.list.assert_called_once_with( + owner=self.project.id) + volume_search_opts = {'project_id': self.project.id} + self.volumes_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.snapshots_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.backups_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.servers_mock.delete.assert_called_once_with(self.server.id) + self.images_mock.delete.assert_called_once_with(self.image.id) + self.volumes_mock.force_delete.assert_called_once_with(self.volume.id) + self.snapshots_mock.delete.assert_called_once_with(self.snapshot.id) + self.backups_mock.delete.assert_called_once_with(self.backup.id) + self.assertIsNone(result) + + def test_project_purge_with_dry_run(self): + arglist = [ + '--dry-run', + '--project', self.project.id, + ] + verifylist = [ + ('dry_run', True), + ('keep_project', False), + ('auth_project', False), + ('project', self.project.id), + ('project_domain', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.projects_mock.get.assert_called_once_with(self.project.id) + self.projects_mock.delete.assert_not_called() + self.servers_mock.list.assert_called_once_with( + search_opts={'tenant_id': self.project.id}) + self.images_mock.list.assert_called_once_with( + owner=self.project.id) + volume_search_opts = {'project_id': self.project.id} + self.volumes_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.snapshots_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.backups_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.servers_mock.delete.assert_not_called() + self.images_mock.delete.assert_not_called() + self.volumes_mock.force_delete.assert_not_called() + self.snapshots_mock.delete.assert_not_called() + self.backups_mock.delete.assert_not_called() + self.assertIsNone(result) + + def test_project_purge_with_keep_project(self): + arglist = [ + '--keep-project', + '--project', self.project.id, + ] + verifylist = [ + ('dry_run', False), + ('keep_project', True), + ('auth_project', False), + ('project', self.project.id), + ('project_domain', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.projects_mock.get.assert_called_once_with(self.project.id) + self.projects_mock.delete.assert_not_called() + self.servers_mock.list.assert_called_once_with( + search_opts={'tenant_id': self.project.id}) + self.images_mock.list.assert_called_once_with( + owner=self.project.id) + volume_search_opts = {'project_id': self.project.id} + self.volumes_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.snapshots_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.backups_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.servers_mock.delete.assert_called_once_with(self.server.id) + self.images_mock.delete.assert_called_once_with(self.image.id) + self.volumes_mock.force_delete.assert_called_once_with(self.volume.id) + self.snapshots_mock.delete.assert_called_once_with(self.snapshot.id) + self.backups_mock.delete.assert_called_once_with(self.backup.id) + self.assertIsNone(result) + + def test_project_purge_with_auth_project(self): + self.app.client_manager.auth_ref = mock.Mock() + self.app.client_manager.auth_ref.project_id = self.project.id + arglist = [ + '--auth-project', + ] + verifylist = [ + ('dry_run', False), + ('keep_project', False), + ('auth_project', True), + ('project', None), + ('project_domain', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.projects_mock.get.assert_not_called() + self.projects_mock.delete.assert_called_once_with(self.project.id) + self.servers_mock.list.assert_called_once_with( + search_opts={'tenant_id': self.project.id}) + self.images_mock.list.assert_called_once_with( + owner=self.project.id) + volume_search_opts = {'project_id': self.project.id} + self.volumes_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.snapshots_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.backups_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.servers_mock.delete.assert_called_once_with(self.server.id) + self.images_mock.delete.assert_called_once_with(self.image.id) + self.volumes_mock.force_delete.assert_called_once_with(self.volume.id) + self.snapshots_mock.delete.assert_called_once_with(self.snapshot.id) + self.backups_mock.delete.assert_called_once_with(self.backup.id) + self.assertIsNone(result) + + @mock.patch.object(project_purge.LOG, 'error') + def test_project_purge_with_exception(self, mock_error): + self.servers_mock.delete.side_effect = exceptions.CommandError() + arglist = [ + '--project', self.project.id, + ] + verifylist = [ + ('dry_run', False), + ('keep_project', False), + ('auth_project', False), + ('project', self.project.id), + ('project_domain', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.projects_mock.get.assert_called_once_with(self.project.id) + self.projects_mock.delete.assert_called_once_with(self.project.id) + self.servers_mock.list.assert_called_once_with( + search_opts={'tenant_id': self.project.id}) + self.images_mock.list.assert_called_once_with( + owner=self.project.id) + volume_search_opts = {'project_id': self.project.id} + self.volumes_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.snapshots_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.backups_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.servers_mock.delete.assert_called_once_with(self.server.id) + self.images_mock.delete.assert_called_once_with(self.image.id) + self.volumes_mock.force_delete.assert_called_once_with(self.volume.id) + self.snapshots_mock.delete.assert_called_once_with(self.snapshot.id) + self.backups_mock.delete.assert_called_once_with(self.backup.id) + mock_error.assert_called_with("1 of 1 servers failed to delete.") + self.assertIsNone(result) + + def test_project_purge_with_force_delete_backup(self): + self.backups_mock.delete.side_effect = [exceptions.CommandError, None] + arglist = [ + '--project', self.project.id, + ] + verifylist = [ + ('dry_run', False), + ('keep_project', False), + ('auth_project', False), + ('project', self.project.id), + ('project_domain', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.projects_mock.get.assert_called_once_with(self.project.id) + self.projects_mock.delete.assert_called_once_with(self.project.id) + self.servers_mock.list.assert_called_once_with( + search_opts={'tenant_id': self.project.id}) + self.images_mock.list.assert_called_once_with( + owner=self.project.id) + volume_search_opts = {'project_id': self.project.id} + self.volumes_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.snapshots_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.backups_mock.list.assert_called_once_with( + search_opts=volume_search_opts) + self.servers_mock.delete.assert_called_once_with(self.server.id) + self.images_mock.delete.assert_called_once_with(self.image.id) + self.volumes_mock.force_delete.assert_called_once_with(self.volume.id) + self.snapshots_mock.delete.assert_called_once_with(self.snapshot.id) + self.assertEqual(2, self.backups_mock.delete.call_count) + self.backups_mock.delete.assert_called_with(self.backup.id, force=True) + self.assertIsNone(result) diff --git a/releasenotes/notes/bug-1584596-5b3109487b451bec.yaml b/releasenotes/notes/bug-1584596-5b3109487b451bec.yaml new file mode 100644 index 0000000000..9d45713d02 --- /dev/null +++ b/releasenotes/notes/bug-1584596-5b3109487b451bec.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + Add command ``openstack project purge`` to clean a project's resources. + [Bug `1584596 `_] diff --git a/setup.cfg b/setup.cfg index 99201b9e34..86f315c139 100644 --- a/setup.cfg +++ b/setup.cfg @@ -47,6 +47,7 @@ openstack.common = extension_list = openstackclient.common.extension:ListExtension extension_show = openstackclient.common.extension:ShowExtension limits_show = openstackclient.common.limits:ShowLimits + project_purge = openstackclient.common.project_purge:ProjectPurge quota_list = openstackclient.common.quota:ListQuota quota_set = openstackclient.common.quota:SetQuota quota_show = openstackclient.common.quota:ShowQuota