From 119d2fae2567285b9149b2c737d7d4452b59288c Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Tue, 9 Jun 2020 11:35:46 +0200 Subject: [PATCH] project cleanup New implementation of the project cleanup based on the sdk.project_cleanup. It is implemented as an additional OSC operation and will ideally obsolete the `openstack project purge` giving flexibility to extend services support, parallelization, filters, etc. Change-Id: Ie08877f182379f73e5ec5ad4daaf84b3092c829c --- .../cli/command-objects/project-cleanup.rst | 12 ++ doc/source/cli/commands.rst | 1 + openstackclient/common/project_cleanup.py | 140 ++++++++++++++ .../tests/unit/common/test_project_cleanup.py | 183 ++++++++++++++++++ .../add-project-cleanup-beb08c9df3c95b24.yaml | 6 + setup.cfg | 1 + 6 files changed, 343 insertions(+) create mode 100644 doc/source/cli/command-objects/project-cleanup.rst create mode 100644 openstackclient/common/project_cleanup.py create mode 100644 openstackclient/tests/unit/common/test_project_cleanup.py create mode 100644 releasenotes/notes/add-project-cleanup-beb08c9df3c95b24.yaml diff --git a/doc/source/cli/command-objects/project-cleanup.rst b/doc/source/cli/command-objects/project-cleanup.rst new file mode 100644 index 000000000..e76e53894 --- /dev/null +++ b/doc/source/cli/command-objects/project-cleanup.rst @@ -0,0 +1,12 @@ +=============== +project cleanup +=============== + +Clean resources associated with a specific project based on OpenStackSDK +implementation + +Block Storage v2, v3; Compute v2; Network v2; DNS v2; Orchestrate v1 + + +.. autoprogram-cliff:: openstack.common + :command: project cleanup diff --git a/doc/source/cli/commands.rst b/doc/source/cli/commands.rst index 0dfac00bd..94a0b5a63 100644 --- a/doc/source/cli/commands.rst +++ b/doc/source/cli/commands.rst @@ -270,6 +270,7 @@ Those actions with an opposite action are noted in parens if applicable. * ``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 +* ``cleanup`` - flexible 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_cleanup.py b/openstackclient/common/project_cleanup.py new file mode 100644 index 000000000..f25363549 --- /dev/null +++ b/openstackclient/common/project_cleanup.py @@ -0,0 +1,140 @@ +# Copyright 2020 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 getpass +import logging +import os +import queue + +from cliff.formatters import table +from osc_lib.command import command + +from openstackclient.i18n import _ +from openstackclient.identity import common as identity_common + + +LOG = logging.getLogger(__name__) + + +def ask_user_yesno(msg, default=True): + """Ask user Y/N question + + :param str msg: question text + :param bool default: default value + :return bool: User choice + """ + while True: + answer = getpass._raw_input( + '{} [{}]: '.format(msg, 'y/N' if not default else 'Y/n')) + if answer in ('y', 'Y', 'yes'): + return True + elif answer in ('n', 'N', 'no'): + return False + + +class ProjectCleanup(command.Command): + _description = _("Clean resources associated with a project") + + def get_parser(self, prog_name): + parser = super(ProjectCleanup, self).get_parser(prog_name) + parser.add_argument( + '--dry-run', + action='store_true', + help=_("List a project's resources") + ) + 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)') + ) + parser.add_argument( + '--created-before', + metavar='', + help=_('Drop resources created before the given time') + ) + parser.add_argument( + '--updated-before', + metavar='', + help=_('Drop resources updated before the given time') + ) + identity_common.add_project_domain_option_to_parser(parser) + return parser + + def take_action(self, parsed_args): + sdk = self.app.client_manager.sdk_connection + + if parsed_args.auth_project: + project_connect = sdk + elif parsed_args.project: + project = sdk.identity.find_project( + name_or_id=parsed_args.project, + ignore_missing=False) + project_connect = sdk.connect_as_project(project) + + if project_connect: + status_queue = queue.Queue() + parsed_args.max_width = int(os.environ.get('CLIFF_MAX_TERM_WIDTH', + 0)) + parsed_args.fit_width = bool(int(os.environ.get('CLIFF_FIT_WIDTH', + 0))) + parsed_args.print_empty = False + table_fmt = table.TableFormatter() + + self.log.info('Searching resources...') + + filters = {} + if parsed_args.created_before: + filters['created_at'] = parsed_args.created_before + + if parsed_args.updated_before: + filters['updated_at'] = parsed_args.updated_before + + project_connect.project_cleanup(dry_run=True, + status_queue=status_queue, + filters=filters) + + data = [] + while not status_queue.empty(): + resource = status_queue.get_nowait() + data.append( + (type(resource).__name__, resource.id, resource.name)) + status_queue.task_done() + status_queue.join() + table_fmt.emit_list( + ('Type', 'ID', 'Name'), + data, + self.app.stdout, + parsed_args + ) + + if parsed_args.dry_run: + return + + confirm = ask_user_yesno( + _("These resources will be deleted. Are you sure"), + default=False) + + if confirm: + self.log.warning(_('Deleting resources')) + + project_connect.project_cleanup(dry_run=False, + status_queue=status_queue, + filters=filters) diff --git a/openstackclient/tests/unit/common/test_project_cleanup.py b/openstackclient/tests/unit/common/test_project_cleanup.py new file mode 100644 index 000000000..d235aeb06 --- /dev/null +++ b/openstackclient/tests/unit/common/test_project_cleanup.py @@ -0,0 +1,183 @@ +# 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 io import StringIO +from unittest import mock + +from openstackclient.common import project_cleanup +from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes +from openstackclient.tests.unit import utils as tests_utils + + +class TestProjectCleanupBase(tests_utils.TestCommand): + + def setUp(self): + super(TestProjectCleanupBase, self).setUp() + + self.app.client_manager.sdk_connection = mock.Mock() + + +class TestProjectCleanup(TestProjectCleanupBase): + + project = identity_fakes.FakeProject.create_one_project() + + def setUp(self): + super(TestProjectCleanup, self).setUp() + self.cmd = project_cleanup.ProjectCleanup(self.app, None) + + self.project_cleanup_mock = mock.Mock() + self.sdk_connect_as_project_mock = \ + mock.Mock(return_value=self.app.client_manager.sdk_connection) + self.app.client_manager.sdk_connection.project_cleanup = \ + self.project_cleanup_mock + self.app.client_manager.sdk_connection.identity.find_project = \ + mock.Mock(return_value=self.project) + self.app.client_manager.sdk_connection.connect_as_project = \ + self.sdk_connect_as_project_mock + + def test_project_no_options(self): + arglist = [] + verifylist = [] + + self.assertRaises(tests_utils.ParserException, self.check_parser, + self.cmd, arglist, verifylist) + + def test_project_cleanup_with_filters(self): + arglist = [ + '--project', self.project.id, + '--created-before', '2200-01-01', + '--updated-before', '2200-01-02' + ] + verifylist = [ + ('dry_run', False), + ('auth_project', False), + ('project', self.project.id), + ('created_before', '2200-01-01'), + ('updated_before', '2200-01-02') + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = None + + with mock.patch('sys.stdin', StringIO('y')): + result = self.cmd.take_action(parsed_args) + + self.sdk_connect_as_project_mock.assert_called_with( + self.project) + filters = { + 'created_at': '2200-01-01', + 'updated_at': '2200-01-02' + } + + calls = [ + mock.call(dry_run=True, status_queue=mock.ANY, filters=filters), + mock.call(dry_run=False, status_queue=mock.ANY, filters=filters) + ] + self.project_cleanup_mock.assert_has_calls(calls) + + self.assertIsNone(result) + + def test_project_cleanup_with_project(self): + arglist = [ + '--project', self.project.id, + ] + verifylist = [ + ('dry_run', False), + ('auth_project', False), + ('project', self.project.id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = None + + with mock.patch('sys.stdin', StringIO('y')): + result = self.cmd.take_action(parsed_args) + + self.sdk_connect_as_project_mock.assert_called_with( + self.project) + calls = [ + mock.call(dry_run=True, status_queue=mock.ANY, filters={}), + mock.call(dry_run=False, status_queue=mock.ANY, filters={}) + ] + self.project_cleanup_mock.assert_has_calls(calls) + + self.assertIsNone(result) + + def test_project_cleanup_with_project_abort(self): + arglist = [ + '--project', self.project.id, + ] + verifylist = [ + ('dry_run', False), + ('auth_project', False), + ('project', self.project.id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = None + + with mock.patch('sys.stdin', StringIO('n')): + result = self.cmd.take_action(parsed_args) + + self.sdk_connect_as_project_mock.assert_called_with( + self.project) + calls = [ + mock.call(dry_run=True, status_queue=mock.ANY, filters={}), + ] + self.project_cleanup_mock.assert_has_calls(calls) + + self.assertIsNone(result) + + def test_project_cleanup_with_dry_run(self): + arglist = [ + '--dry-run', + '--project', self.project.id, + ] + verifylist = [ + ('dry_run', True), + ('auth_project', False), + ('project', self.project.id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = None + + result = self.cmd.take_action(parsed_args) + + self.sdk_connect_as_project_mock.assert_called_with( + self.project) + self.project_cleanup_mock.assert_called_once_with( + dry_run=True, status_queue=mock.ANY, filters={}) + + self.assertIsNone(result) + + def test_project_cleanup_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), + ('auth_project', True), + ('project', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = None + + with mock.patch('sys.stdin', StringIO('y')): + result = self.cmd.take_action(parsed_args) + + self.sdk_connect_as_project_mock.assert_not_called() + calls = [ + mock.call(dry_run=True, status_queue=mock.ANY, filters={}), + mock.call(dry_run=False, status_queue=mock.ANY, filters={}) + ] + self.project_cleanup_mock.assert_has_calls(calls) + + self.assertIsNone(result) diff --git a/releasenotes/notes/add-project-cleanup-beb08c9df3c95b24.yaml b/releasenotes/notes/add-project-cleanup-beb08c9df3c95b24.yaml new file mode 100644 index 000000000..58d4223d2 --- /dev/null +++ b/releasenotes/notes/add-project-cleanup-beb08c9df3c95b24.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Add support for project cleanup based on the OpenStackSDK with + create/update time filters. In the long run this will replace + `openstack project purge` command. diff --git a/setup.cfg b/setup.cfg index 48384897a..9deb0f531 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,6 +45,7 @@ openstack.common = extension_list = openstackclient.common.extension:ListExtension extension_show = openstackclient.common.extension:ShowExtension limits_show = openstackclient.common.limits:ShowLimits + project_cleanup = openstackclient.common.project_cleanup:ProjectCleanup project_purge = openstackclient.common.project_purge:ProjectPurge quota_list = openstackclient.common.quota:ListQuota quota_set = openstackclient.common.quota:SetQuota