Browse Source

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
tags/3.12.0
Steve Martinelli 2 years ago
parent
commit
227d4c64ef

+ 42
- 0
doc/source/command-objects/project-purge.rst View File

@@ -0,0 +1,42 @@
1
+=============
2
+project purge
3
+=============
4
+
5
+Clean resources associated with a specific project.
6
+
7
+Block Storage v1, v2; Compute v2; Image v1, v2
8
+
9
+project purge
10
+-------------
11
+
12
+Clean resources associated with a project
13
+
14
+.. program:: project purge
15
+.. code:: bash
16
+
17
+    openstack project purge
18
+        [--dry-run]
19
+        [--keep-project]
20
+        [--auth-project | --project <project>]
21
+        [--project-domain <project-domain>]
22
+
23
+.. option:: --dry-run
24
+
25
+    List a project's resources
26
+
27
+.. option:: --keep-project
28
+
29
+    Clean project resources, but don't delete the project.
30
+
31
+.. option:: --auth-project
32
+
33
+    Delete resources of the project used to authenticate
34
+
35
+.. option:: --project <project>
36
+
37
+    Project to clean (name or ID)
38
+
39
+.. option:: --project-domain <project-domain>
40
+
41
+    Domain the project belongs to (name or ID). This can be
42
+    used in case collisions between project names exist.

+ 1
- 0
doc/source/commands.rst View File

@@ -251,6 +251,7 @@ Those actions with an opposite action are noted in parens if applicable.
251 251
   live server migration if possible
252 252
 * ``pause`` (``unpause``) - stop one or more servers and leave them in memory
253 253
 * ``query`` - Query resources by Elasticsearch query string or json format DSL.
254
+* ``purge`` - clean resources associated with a specific project
254 255
 * ``reboot`` - forcibly reboot a server
255 256
 * ``rebuild`` - rebuild a server using (most of) the same arguments as in the original create
256 257
 * ``remove`` (``add``) - remove an object from a group of objects

+ 168
- 0
openstackclient/common/project_purge.py View File

@@ -0,0 +1,168 @@
1
+#   Copyright 2012 OpenStack Foundation
2
+#
3
+#   Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+#   not use this file except in compliance with the License. You may obtain
5
+#   a copy of the License at
6
+#
7
+#        http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+#   Unless required by applicable law or agreed to in writing, software
10
+#   distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+#   WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+#   License for the specific language governing permissions and limitations
13
+#   under the License.
14
+#
15
+
16
+import logging
17
+
18
+from osc_lib.command import command
19
+from osc_lib import utils
20
+
21
+from openstackclient.i18n import _
22
+from openstackclient.identity import common as identity_common
23
+
24
+
25
+LOG = logging.getLogger(__name__)
26
+
27
+
28
+class ProjectPurge(command.Command):
29
+    _description = _("Clean resources associated with a project")
30
+
31
+    def get_parser(self, prog_name):
32
+        parser = super(ProjectPurge, self).get_parser(prog_name)
33
+        parser.add_argument(
34
+            '--dry-run',
35
+            action='store_true',
36
+            help=_("List a project's resources"),
37
+        )
38
+        parser.add_argument(
39
+            '--keep-project',
40
+            action='store_true',
41
+            help=_("Clean project resources, but don't delete the project"),
42
+        )
43
+        project_group = parser.add_mutually_exclusive_group(required=True)
44
+        project_group.add_argument(
45
+            '--auth-project',
46
+            action='store_true',
47
+            help=_('Delete resources of the project used to authenticate'),
48
+        )
49
+        project_group.add_argument(
50
+            '--project',
51
+            metavar='<project>',
52
+            help=_('Project to clean (name or ID)'),
53
+        )
54
+        identity_common.add_project_domain_option_to_parser(parser)
55
+        return parser
56
+
57
+    def take_action(self, parsed_args):
58
+        identity_client = self.app.client_manager.identity
59
+
60
+        if parsed_args.auth_project:
61
+            project_id = self.app.client_manager.auth_ref.project_id
62
+        elif parsed_args.project:
63
+            try:
64
+                project_id = identity_common.find_project(
65
+                    identity_client,
66
+                    parsed_args.project,
67
+                    parsed_args.project_domain,
68
+                ).id
69
+            except AttributeError:  # using v2 auth and supplying a domain
70
+                project_id = utils.find_resource(
71
+                    identity_client.tenants,
72
+                    parsed_args.project,
73
+                ).id
74
+
75
+        # delete all non-identity resources
76
+        self.delete_resources(parsed_args.dry_run, project_id)
77
+
78
+        # clean up the project
79
+        if not parsed_args.keep_project:
80
+            LOG.warning(_('Deleting project: %s'), project_id)
81
+            if not parsed_args.dry_run:
82
+                identity_client.projects.delete(project_id)
83
+
84
+    def delete_resources(self, dry_run, project_id):
85
+        # servers
86
+        try:
87
+            compute_client = self.app.client_manager.compute
88
+            search_opts = {'tenant_id': project_id}
89
+            data = compute_client.servers.list(search_opts=search_opts)
90
+            self.delete_objects(
91
+                compute_client.servers.delete, data, 'server', dry_run)
92
+        except Exception:
93
+            pass
94
+
95
+        # images
96
+        try:
97
+            image_client = self.app.client_manager.image
98
+            data = image_client.images.list(owner=project_id)
99
+            self.delete_objects(
100
+                image_client.images.delete, data, 'image', dry_run)
101
+        except Exception:
102
+            pass
103
+
104
+        # volumes, snapshots, backups
105
+        volume_client = self.app.client_manager.volume
106
+        search_opts = {'project_id': project_id}
107
+        try:
108
+            data = volume_client.volume_snapshots.list(search_opts=search_opts)
109
+            self.delete_objects(
110
+                self.delete_one_volume_snapshot,
111
+                data,
112
+                'volume snapshot',
113
+                dry_run)
114
+        except Exception:
115
+            pass
116
+        try:
117
+            data = volume_client.backups.list(search_opts=search_opts)
118
+            self.delete_objects(
119
+                self.delete_one_volume_backup,
120
+                data,
121
+                'volume backup',
122
+                dry_run)
123
+        except Exception:
124
+            pass
125
+        try:
126
+            data = volume_client.volumes.list(search_opts=search_opts)
127
+            self.delete_objects(
128
+                volume_client.volumes.force_delete, data, 'volume', dry_run)
129
+        except Exception:
130
+            pass
131
+
132
+    def delete_objects(self, func_delete, data, resource, dry_run):
133
+        result = 0
134
+        for i in data:
135
+            LOG.warning(_('Deleting %(resource)s : %(id)s') %
136
+                        {'resource': resource, 'id': i.id})
137
+            if not dry_run:
138
+                try:
139
+                    func_delete(i.id)
140
+                except Exception as e:
141
+                    result += 1
142
+                    LOG.error(_("Failed to delete %(resource)s with "
143
+                                "ID '%(id)s': %(e)s")
144
+                              % {'resource': resource, 'id': i.id, 'e': e})
145
+        if result > 0:
146
+            total = len(data)
147
+            msg = (_("%(result)s of %(total)s %(resource)ss failed "
148
+                   "to delete.") %
149
+                   {'result': result,
150
+                    'total': total,
151
+                    'resource': resource})
152
+            LOG.error(msg)
153
+
154
+    def delete_one_volume_snapshot(self, snapshot_id):
155
+        volume_client = self.app.client_manager.volume
156
+        try:
157
+            volume_client.volume_snapshots.delete(snapshot_id)
158
+        except Exception:
159
+            # Only volume v2 support deleting by force
160
+            volume_client.volume_snapshots.delete(snapshot_id, force=True)
161
+
162
+    def delete_one_volume_backup(self, backup_id):
163
+        volume_client = self.app.client_manager.volume
164
+        try:
165
+            volume_client.backups.delete(backup_id)
166
+        except Exception:
167
+            # Only volume v2 support deleting by force
168
+            volume_client.backups.delete(backup_id, force=True)

+ 314
- 0
openstackclient/tests/unit/common/test_project_purge.py View File

@@ -0,0 +1,314 @@
1
+#   Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+#   not use this file except in compliance with the License. You may obtain
3
+#   a copy of the License at
4
+#
5
+#        http://www.apache.org/licenses/LICENSE-2.0
6
+#
7
+#   Unless required by applicable law or agreed to in writing, software
8
+#   distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+#   WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+#   License for the specific language governing permissions and limitations
11
+#   under the License.
12
+
13
+import mock
14
+
15
+from osc_lib import exceptions
16
+
17
+from openstackclient.common import project_purge
18
+from openstackclient.tests.unit.compute.v2 import fakes as compute_fakes
19
+from openstackclient.tests.unit import fakes
20
+from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes
21
+from openstackclient.tests.unit.image.v2 import fakes as image_fakes
22
+from openstackclient.tests.unit import utils as tests_utils
23
+from openstackclient.tests.unit.volume.v2 import fakes as volume_fakes
24
+
25
+
26
+class TestProjectPurgeInit(tests_utils.TestCommand):
27
+
28
+    def setUp(self):
29
+        super(TestProjectPurgeInit, self).setUp()
30
+        compute_client = compute_fakes.FakeComputev2Client(
31
+            endpoint=fakes.AUTH_URL,
32
+            token=fakes.AUTH_TOKEN,
33
+        )
34
+        self.app.client_manager.compute = compute_client
35
+        self.servers_mock = compute_client.servers
36
+        self.servers_mock.reset_mock()
37
+
38
+        volume_client = volume_fakes.FakeVolumeClient(
39
+            endpoint=fakes.AUTH_URL,
40
+            token=fakes.AUTH_TOKEN,
41
+        )
42
+        self.app.client_manager.volume = volume_client
43
+        self.volumes_mock = volume_client.volumes
44
+        self.volumes_mock.reset_mock()
45
+        self.snapshots_mock = volume_client.volume_snapshots
46
+        self.snapshots_mock.reset_mock()
47
+        self.backups_mock = volume_client.backups
48
+        self.backups_mock.reset_mock()
49
+
50
+        identity_client = identity_fakes.FakeIdentityv3Client(
51
+            endpoint=fakes.AUTH_URL,
52
+            token=fakes.AUTH_TOKEN,
53
+        )
54
+        self.app.client_manager.identity = identity_client
55
+        self.domains_mock = identity_client.domains
56
+        self.domains_mock.reset_mock()
57
+        self.projects_mock = identity_client.projects
58
+        self.projects_mock.reset_mock()
59
+
60
+        image_client = image_fakes.FakeImagev2Client(
61
+            endpoint=fakes.AUTH_URL,
62
+            token=fakes.AUTH_TOKEN,
63
+        )
64
+        self.app.client_manager.image = image_client
65
+        self.images_mock = image_client.images
66
+        self.images_mock.reset_mock()
67
+
68
+
69
+class TestProjectPurge(TestProjectPurgeInit):
70
+
71
+    project = identity_fakes.FakeProject.create_one_project()
72
+    server = compute_fakes.FakeServer.create_one_server()
73
+    image = image_fakes.FakeImage.create_one_image()
74
+    volume = volume_fakes.FakeVolume.create_one_volume()
75
+    backup = volume_fakes.FakeBackup.create_one_backup()
76
+    snapshot = volume_fakes.FakeSnapshot.create_one_snapshot()
77
+
78
+    def setUp(self):
79
+        super(TestProjectPurge, self).setUp()
80
+        self.projects_mock.get.return_value = self.project
81
+        self.projects_mock.delete.return_value = None
82
+        self.images_mock.list.return_value = [self.image]
83
+        self.images_mock.delete.return_value = None
84
+        self.servers_mock.list.return_value = [self.server]
85
+        self.servers_mock.delete.return_value = None
86
+        self.volumes_mock.list.return_value = [self.volume]
87
+        self.volumes_mock.delete.return_value = None
88
+        self.volumes_mock.force_delete.return_value = None
89
+        self.snapshots_mock.list.return_value = [self.snapshot]
90
+        self.snapshots_mock.delete.return_value = None
91
+        self.backups_mock.list.return_value = [self.backup]
92
+        self.backups_mock.delete.return_value = None
93
+
94
+        self.cmd = project_purge.ProjectPurge(self.app, None)
95
+
96
+    def test_project_no_options(self):
97
+        arglist = []
98
+        verifylist = []
99
+
100
+        self.assertRaises(tests_utils.ParserException, self.check_parser,
101
+                          self.cmd, arglist, verifylist)
102
+
103
+    def test_project_purge_with_project(self):
104
+        arglist = [
105
+            '--project', self.project.id,
106
+        ]
107
+        verifylist = [
108
+            ('dry_run', False),
109
+            ('keep_project', False),
110
+            ('auth_project', False),
111
+            ('project', self.project.id),
112
+            ('project_domain', None),
113
+        ]
114
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
115
+
116
+        result = self.cmd.take_action(parsed_args)
117
+        self.projects_mock.get.assert_called_once_with(self.project.id)
118
+        self.projects_mock.delete.assert_called_once_with(self.project.id)
119
+        self.servers_mock.list.assert_called_once_with(
120
+            search_opts={'tenant_id': self.project.id})
121
+        self.images_mock.list.assert_called_once_with(
122
+            owner=self.project.id)
123
+        volume_search_opts = {'project_id': self.project.id}
124
+        self.volumes_mock.list.assert_called_once_with(
125
+            search_opts=volume_search_opts)
126
+        self.snapshots_mock.list.assert_called_once_with(
127
+            search_opts=volume_search_opts)
128
+        self.backups_mock.list.assert_called_once_with(
129
+            search_opts=volume_search_opts)
130
+        self.servers_mock.delete.assert_called_once_with(self.server.id)
131
+        self.images_mock.delete.assert_called_once_with(self.image.id)
132
+        self.volumes_mock.force_delete.assert_called_once_with(self.volume.id)
133
+        self.snapshots_mock.delete.assert_called_once_with(self.snapshot.id)
134
+        self.backups_mock.delete.assert_called_once_with(self.backup.id)
135
+        self.assertIsNone(result)
136
+
137
+    def test_project_purge_with_dry_run(self):
138
+        arglist = [
139
+            '--dry-run',
140
+            '--project', self.project.id,
141
+        ]
142
+        verifylist = [
143
+            ('dry_run', True),
144
+            ('keep_project', False),
145
+            ('auth_project', False),
146
+            ('project', self.project.id),
147
+            ('project_domain', None),
148
+        ]
149
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
150
+
151
+        result = self.cmd.take_action(parsed_args)
152
+        self.projects_mock.get.assert_called_once_with(self.project.id)
153
+        self.projects_mock.delete.assert_not_called()
154
+        self.servers_mock.list.assert_called_once_with(
155
+            search_opts={'tenant_id': self.project.id})
156
+        self.images_mock.list.assert_called_once_with(
157
+            owner=self.project.id)
158
+        volume_search_opts = {'project_id': self.project.id}
159
+        self.volumes_mock.list.assert_called_once_with(
160
+            search_opts=volume_search_opts)
161
+        self.snapshots_mock.list.assert_called_once_with(
162
+            search_opts=volume_search_opts)
163
+        self.backups_mock.list.assert_called_once_with(
164
+            search_opts=volume_search_opts)
165
+        self.servers_mock.delete.assert_not_called()
166
+        self.images_mock.delete.assert_not_called()
167
+        self.volumes_mock.force_delete.assert_not_called()
168
+        self.snapshots_mock.delete.assert_not_called()
169
+        self.backups_mock.delete.assert_not_called()
170
+        self.assertIsNone(result)
171
+
172
+    def test_project_purge_with_keep_project(self):
173
+        arglist = [
174
+            '--keep-project',
175
+            '--project', self.project.id,
176
+        ]
177
+        verifylist = [
178
+            ('dry_run', False),
179
+            ('keep_project', True),
180
+            ('auth_project', False),
181
+            ('project', self.project.id),
182
+            ('project_domain', None),
183
+        ]
184
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
185
+
186
+        result = self.cmd.take_action(parsed_args)
187
+        self.projects_mock.get.assert_called_once_with(self.project.id)
188
+        self.projects_mock.delete.assert_not_called()
189
+        self.servers_mock.list.assert_called_once_with(
190
+            search_opts={'tenant_id': self.project.id})
191
+        self.images_mock.list.assert_called_once_with(
192
+            owner=self.project.id)
193
+        volume_search_opts = {'project_id': self.project.id}
194
+        self.volumes_mock.list.assert_called_once_with(
195
+            search_opts=volume_search_opts)
196
+        self.snapshots_mock.list.assert_called_once_with(
197
+            search_opts=volume_search_opts)
198
+        self.backups_mock.list.assert_called_once_with(
199
+            search_opts=volume_search_opts)
200
+        self.servers_mock.delete.assert_called_once_with(self.server.id)
201
+        self.images_mock.delete.assert_called_once_with(self.image.id)
202
+        self.volumes_mock.force_delete.assert_called_once_with(self.volume.id)
203
+        self.snapshots_mock.delete.assert_called_once_with(self.snapshot.id)
204
+        self.backups_mock.delete.assert_called_once_with(self.backup.id)
205
+        self.assertIsNone(result)
206
+
207
+    def test_project_purge_with_auth_project(self):
208
+        self.app.client_manager.auth_ref = mock.Mock()
209
+        self.app.client_manager.auth_ref.project_id = self.project.id
210
+        arglist = [
211
+            '--auth-project',
212
+        ]
213
+        verifylist = [
214
+            ('dry_run', False),
215
+            ('keep_project', False),
216
+            ('auth_project', True),
217
+            ('project', None),
218
+            ('project_domain', None),
219
+        ]
220
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
221
+
222
+        result = self.cmd.take_action(parsed_args)
223
+        self.projects_mock.get.assert_not_called()
224
+        self.projects_mock.delete.assert_called_once_with(self.project.id)
225
+        self.servers_mock.list.assert_called_once_with(
226
+            search_opts={'tenant_id': self.project.id})
227
+        self.images_mock.list.assert_called_once_with(
228
+            owner=self.project.id)
229
+        volume_search_opts = {'project_id': self.project.id}
230
+        self.volumes_mock.list.assert_called_once_with(
231
+            search_opts=volume_search_opts)
232
+        self.snapshots_mock.list.assert_called_once_with(
233
+            search_opts=volume_search_opts)
234
+        self.backups_mock.list.assert_called_once_with(
235
+            search_opts=volume_search_opts)
236
+        self.servers_mock.delete.assert_called_once_with(self.server.id)
237
+        self.images_mock.delete.assert_called_once_with(self.image.id)
238
+        self.volumes_mock.force_delete.assert_called_once_with(self.volume.id)
239
+        self.snapshots_mock.delete.assert_called_once_with(self.snapshot.id)
240
+        self.backups_mock.delete.assert_called_once_with(self.backup.id)
241
+        self.assertIsNone(result)
242
+
243
+    @mock.patch.object(project_purge.LOG, 'error')
244
+    def test_project_purge_with_exception(self, mock_error):
245
+        self.servers_mock.delete.side_effect = exceptions.CommandError()
246
+        arglist = [
247
+            '--project', self.project.id,
248
+        ]
249
+        verifylist = [
250
+            ('dry_run', False),
251
+            ('keep_project', False),
252
+            ('auth_project', False),
253
+            ('project', self.project.id),
254
+            ('project_domain', None),
255
+        ]
256
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
257
+
258
+        result = self.cmd.take_action(parsed_args)
259
+        self.projects_mock.get.assert_called_once_with(self.project.id)
260
+        self.projects_mock.delete.assert_called_once_with(self.project.id)
261
+        self.servers_mock.list.assert_called_once_with(
262
+            search_opts={'tenant_id': self.project.id})
263
+        self.images_mock.list.assert_called_once_with(
264
+            owner=self.project.id)
265
+        volume_search_opts = {'project_id': self.project.id}
266
+        self.volumes_mock.list.assert_called_once_with(
267
+            search_opts=volume_search_opts)
268
+        self.snapshots_mock.list.assert_called_once_with(
269
+            search_opts=volume_search_opts)
270
+        self.backups_mock.list.assert_called_once_with(
271
+            search_opts=volume_search_opts)
272
+        self.servers_mock.delete.assert_called_once_with(self.server.id)
273
+        self.images_mock.delete.assert_called_once_with(self.image.id)
274
+        self.volumes_mock.force_delete.assert_called_once_with(self.volume.id)
275
+        self.snapshots_mock.delete.assert_called_once_with(self.snapshot.id)
276
+        self.backups_mock.delete.assert_called_once_with(self.backup.id)
277
+        mock_error.assert_called_with("1 of 1 servers failed to delete.")
278
+        self.assertIsNone(result)
279
+
280
+    def test_project_purge_with_force_delete_backup(self):
281
+        self.backups_mock.delete.side_effect = [exceptions.CommandError, None]
282
+        arglist = [
283
+            '--project', self.project.id,
284
+        ]
285
+        verifylist = [
286
+            ('dry_run', False),
287
+            ('keep_project', False),
288
+            ('auth_project', False),
289
+            ('project', self.project.id),
290
+            ('project_domain', None),
291
+        ]
292
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
293
+
294
+        result = self.cmd.take_action(parsed_args)
295
+        self.projects_mock.get.assert_called_once_with(self.project.id)
296
+        self.projects_mock.delete.assert_called_once_with(self.project.id)
297
+        self.servers_mock.list.assert_called_once_with(
298
+            search_opts={'tenant_id': self.project.id})
299
+        self.images_mock.list.assert_called_once_with(
300
+            owner=self.project.id)
301
+        volume_search_opts = {'project_id': self.project.id}
302
+        self.volumes_mock.list.assert_called_once_with(
303
+            search_opts=volume_search_opts)
304
+        self.snapshots_mock.list.assert_called_once_with(
305
+            search_opts=volume_search_opts)
306
+        self.backups_mock.list.assert_called_once_with(
307
+            search_opts=volume_search_opts)
308
+        self.servers_mock.delete.assert_called_once_with(self.server.id)
309
+        self.images_mock.delete.assert_called_once_with(self.image.id)
310
+        self.volumes_mock.force_delete.assert_called_once_with(self.volume.id)
311
+        self.snapshots_mock.delete.assert_called_once_with(self.snapshot.id)
312
+        self.assertEqual(2, self.backups_mock.delete.call_count)
313
+        self.backups_mock.delete.assert_called_with(self.backup.id, force=True)
314
+        self.assertIsNone(result)

+ 5
- 0
releasenotes/notes/bug-1584596-5b3109487b451bec.yaml View File

@@ -0,0 +1,5 @@
1
+---
2
+fixes:
3
+  - |
4
+    Add command ``openstack project purge`` to clean a project's resources.
5
+    [Bug `1584596 <https://bugs.launchpad.net/bugs/1584596>`_]

+ 1
- 0
setup.cfg View File

@@ -47,6 +47,7 @@ openstack.common =
47 47
     extension_list = openstackclient.common.extension:ListExtension
48 48
     extension_show = openstackclient.common.extension:ShowExtension
49 49
     limits_show = openstackclient.common.limits:ShowLimits
50
+    project_purge = openstackclient.common.project_purge:ProjectPurge
50 51
     quota_list = openstackclient.common.quota:ListQuota
51 52
     quota_set = openstackclient.common.quota:SetQuota
52 53
     quota_show = openstackclient.common.quota:ShowQuota

Loading…
Cancel
Save