diff options
-rw-r--r-- | .stestr.conf | 2 | ||||
-rw-r--r-- | .zuul.yaml | 1 | ||||
-rw-r--r-- | glanceclient/common/utils.py | 44 | ||||
-rw-r--r-- | glanceclient/tests/unit/v2/test_cache.py | 135 | ||||
-rw-r--r-- | glanceclient/tests/unit/v2/test_info.py | 37 | ||||
-rw-r--r-- | glanceclient/tests/unit/v2/test_shell_v2.py | 264 | ||||
-rw-r--r-- | glanceclient/v2/cache.py | 62 | ||||
-rw-r--r-- | glanceclient/v2/client.py | 7 | ||||
-rw-r--r-- | glanceclient/v2/images.py | 7 | ||||
-rw-r--r-- | glanceclient/v2/info.py | 23 | ||||
-rw-r--r-- | glanceclient/v2/metadefs.py | 11 | ||||
-rw-r--r-- | glanceclient/v2/shell.py | 94 | ||||
-rw-r--r-- | releasenotes/notes/3.6.0_Release-04d3b5017747290b.yaml | 51 | ||||
-rw-r--r-- | releasenotes/source/index.rst | 1 | ||||
-rw-r--r-- | releasenotes/source/yoga.rst | 6 | ||||
-rw-r--r-- | setup.cfg | 1 | ||||
-rwxr-xr-x | tools/with_venv.sh | 2 | ||||
-rw-r--r-- | tox.ini | 14 |
18 files changed, 743 insertions, 19 deletions
diff --git a/.stestr.conf b/.stestr.conf index 44d7432..a0b3fc8 100644 --- a/.stestr.conf +++ b/.stestr.conf @@ -1,3 +1,3 @@ [DEFAULT] -test_path=./glanceclient/tests/unit +test_path=${OS_TEST_PATH:-./glanceclient/tests/unit} top_path=./ @@ -33,7 +33,6 @@ - ^.*\.rst$ - ^(test-|)requirements.txt$ - ^setup.cfg$ - - ^tox.ini$ - job: name: glanceclient-tox-keystone-tips-base diff --git a/glanceclient/common/utils.py b/glanceclient/common/utils.py index 1691264..fd0243c 100644 --- a/glanceclient/common/utils.py +++ b/glanceclient/common/utils.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +import datetime import errno import functools import hashlib @@ -175,13 +176,54 @@ def pretty_choice_list(l): def has_version(client, version): versions = client.get('/versions')[1].get('versions') - supported = ['SUPPORTED', 'CURRENT'] + supported = ['SUPPORTED', 'CURRENT', 'EXPERIMENTAL'] for version_struct in versions: if version_struct['id'] == version: return version_struct['status'] in supported return False +def print_cached_images(cached_images): + cache_pt = prettytable.PrettyTable(("ID", + "State", + "Last Accessed (UTC)", + "Last Modified (UTC)", + "Size", + "Hits")) + for item in cached_images: + state = "queued" + last_accessed = "N/A" + last_modified = "N/A" + size = "N/A" + hits = "N/A" + if item == 'cached_images': + state = "cached" + for image in cached_images[item]: + last_accessed = image['last_accessed'] + if last_accessed == 0: + last_accessed = "N/A" + else: + last_accessed = datetime.datetime.utcfromtimestamp( + last_accessed).isoformat() + + cache_pt.add_row((image['image_id'], state, + last_accessed, + datetime.datetime.utcfromtimestamp( + image['last_modified']).isoformat(), + image['size'], + image['hits'])) + else: + for image in cached_images[item]: + cache_pt.add_row((image, + state, + last_accessed, + last_modified, + size, + hits)) + + print(cache_pt.get_string()) + + def print_dict_list(objects, fields): pt = prettytable.PrettyTable([f for f in fields], caching=False) pt.align = 'l' diff --git a/glanceclient/tests/unit/v2/test_cache.py b/glanceclient/tests/unit/v2/test_cache.py new file mode 100644 index 0000000..a5a908f --- /dev/null +++ b/glanceclient/tests/unit/v2/test_cache.py @@ -0,0 +1,135 @@ +# Copyright 2021 Red Hat Inc. +# 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 testtools +from unittest import mock + +from glanceclient.common import utils as common_utils +from glanceclient import exc +from glanceclient.tests import utils +from glanceclient.v2 import cache + + +data_fixtures = { + '/v2/cache': { + 'GET': ( + {}, + { + 'cached_images': [ + { + 'id': 'b0aa672a-bc26-4fcb-8be1-f53ca361943d', + 'Last Accessed (UTC)': '2021-08-09T07:08:20.214543', + 'Last Modified (UTC)': '2021-08-09T07:08:20.214543', + 'Size': 13267968, + 'Hits': 0 + }, + { + 'id': 'df601a47-7251-4d20-84ae-07de335af424', + 'Last Accessed (UTC)': '2021-08-09T07:08:20.214543', + 'Last Modified (UTC)': '2021-08-09T07:08:20.214543', + 'Size': 13267968, + 'Hits': 0 + }, + ], + 'queued_images': [ + '3a4560a1-e585-443e-9b39-553b46ec92d1', + '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810' + ], + }, + ), + 'DELETE': ( + {}, + '', + ), + }, + '/v2/cache/3a4560a1-e585-443e-9b39-553b46ec92d1': { + 'PUT': ( + {}, + '', + ), + 'DELETE': ( + {}, + '', + ), + }, +} + + +class TestCacheController(testtools.TestCase): + def setUp(self): + super(TestCacheController, self).setUp() + self.api = utils.FakeAPI(data_fixtures) + self.controller = cache.Controller(self.api) + + @mock.patch.object(common_utils, 'has_version') + def test_list_cached(self, mock_has_version): + mock_has_version.return_value = True + images = self.controller.list() + # Verify that we have 2 cached and 2 queued images + self.assertEqual(2, len(images['cached_images'])) + self.assertEqual(2, len(images['queued_images'])) + + @mock.patch.object(common_utils, 'has_version') + def test_list_cached_empty_response(self, mock_has_version): + dummy_fixtures = { + '/v2/cache': { + 'GET': ( + {}, + { + 'cached_images': [], + 'queued_images': [], + }, + ), + } + } + dummy_api = utils.FakeAPI(dummy_fixtures) + dummy_controller = cache.Controller(dummy_api) + mock_has_version.return_value = True + images = dummy_controller.list() + # Verify that we have 0 cached and 0 queued images + self.assertEqual(0, len(images['cached_images'])) + self.assertEqual(0, len(images['queued_images'])) + + @mock.patch.object(common_utils, 'has_version') + def test_queue_image(self, mock_has_version): + mock_has_version.return_value = True + image_id = '3a4560a1-e585-443e-9b39-553b46ec92d1' + self.controller.queue(image_id) + expect = [('PUT', '/v2/cache/%s' % image_id, + {}, None)] + self.assertEqual(expect, self.api.calls) + + @mock.patch.object(common_utils, 'has_version') + def test_cache_clear_with_header(self, mock_has_version): + mock_has_version.return_value = True + self.controller.clear("cache") + expect = [('DELETE', '/v2/cache', + {'x-image-cache-clear-target': 'cache'}, None)] + self.assertEqual(expect, self.api.calls) + + @mock.patch.object(common_utils, 'has_version') + def test_cache_delete(self, mock_has_version): + mock_has_version.return_value = True + image_id = '3a4560a1-e585-443e-9b39-553b46ec92d1' + self.controller.delete(image_id) + expect = [('DELETE', '/v2/cache/%s' % image_id, + {}, None)] + self.assertEqual(expect, self.api.calls) + + @mock.patch.object(common_utils, 'has_version') + def test_cache_not_supported(self, mock_has_version): + mock_has_version.return_value = False + self.assertRaises(exc.HTTPNotImplemented, + self.controller.list) diff --git a/glanceclient/tests/unit/v2/test_info.py b/glanceclient/tests/unit/v2/test_info.py new file mode 100644 index 0000000..645c15c --- /dev/null +++ b/glanceclient/tests/unit/v2/test_info.py @@ -0,0 +1,37 @@ +# Copyright 2022 Red Hat, Inc. +# +# 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 testtools +from unittest import mock + +from glanceclient.v2 import info + + +class TestController(testtools.TestCase): + def setUp(self): + super(TestController, self).setUp() + self.fake_client = mock.MagicMock() + self.info_controller = info.Controller(self.fake_client, None) + + def test_get_usage(self): + fake_usage = { + 'usage': { + 'quota1': {'limit': 10, 'usage': 0}, + 'quota2': {'limit': 20, 'usage': 5}, + } + } + self.fake_client.get.return_value = (mock.MagicMock(), fake_usage) + usage = self.info_controller.get_usage() + self.assertEqual(fake_usage['usage'], usage) + self.fake_client.get.assert_called_once_with('/v2/info/usage') diff --git a/glanceclient/tests/unit/v2/test_shell_v2.py b/glanceclient/tests/unit/v2/test_shell_v2.py index 83c4727..9d38416 100644 --- a/glanceclient/tests/unit/v2/test_shell_v2.py +++ b/glanceclient/tests/unit/v2/test_shell_v2.py @@ -114,6 +114,7 @@ class ShellV2Test(testtools.TestCase): utils.print_dict = mock.Mock() utils.save_image = mock.Mock() utils.print_dict_list = mock.Mock() + utils.print_cached_images = mock.Mock() def assert_exits_with_msg(self, func, func_args, err_msg=None): with mock.patch.object(utils, 'exit') as mocked_utils_exit: @@ -150,8 +151,44 @@ class ShellV2Test(testtools.TestCase): ] } + stores_info_detail_response = { + "stores": [ + { + "default": "true", + "id": "ceph1", + "type": "rbd", + "description": "RBD backend for glance.", + "properties": { + "pool": "pool1", + "chunk_size": "4" + } + }, + { + "id": "file2", + "type": "file", + "description": "Filesystem backend for glance.", + "properties": {} + }, + { + "id": "file1", + "type": "file", + "description": "Filesystem backend for gkance.", + "properties": {} + }, + { + "id": "ceph2", + "type": "rbd", + "description": "RBD backend for glance.", + "properties": { + "pool": "pool2", + "chunk_size": "4" + } + } + ] + } + def test_do_stores_info(self): - args = [] + args = self._make_args({'detail': False}) with mock.patch.object(self.gc.images, 'get_stores_info') as mocked_list: mocked_list.return_value = self.stores_info_response @@ -166,7 +203,7 @@ class ShellV2Test(testtools.TestCase): def test_neg_stores_info( self, mock_stdin, mock_utils_exit): expected_msg = ('Multi Backend support is not enabled') - args = [] + args = self._make_args({'detail': False}) mock_utils_exit.side_effect = self._mock_utils_exit with mock.patch.object(self.gc.images, 'get_stores_info') as mocked_info: @@ -178,6 +215,18 @@ class ShellV2Test(testtools.TestCase): pass mock_utils_exit.assert_called_once_with(expected_msg) + def test_do_stores_info_with_detail(self): + args = self._make_args({'detail': True}) + with mock.patch.object(self.gc.images, + 'get_stores_info_detail') as mocked_list: + mocked_list.return_value = self.stores_info_detail_response + + test_shell.do_stores_info(self.gc, args) + + mocked_list.assert_called_once_with() + utils.print_dict.assert_called_once_with( + self.stores_info_detail_response) + @mock.patch('sys.stderr') def test_image_create_missing_disk_format(self, __): e = self.assertRaises(exc.CommandError, self._run_command, @@ -614,6 +663,16 @@ class ShellV2Test(testtools.TestCase): mock_exit.assert_called_once_with( 'Server does not support image tasks API (v2.12)') + def test_usage(self): + with mock.patch.object(self.gc.info, 'get_usage') as mock_usage: + mock_usage.return_value = {'quota1': {'limit': 10, 'usage': 0}, + 'quota2': {'limit': 20, 'usage': 5}} + test_shell.do_usage(self.gc, []) + utils.print_dict_list.assert_called_once_with( + [{'quota': 'quota1', 'limit': 10, 'usage': 0}, + {'quota': 'quota2', 'limit': 20, 'usage': 5}], + ['Quota', 'Limit', 'Usage']) + @mock.patch('sys.stdin', autospec=True) def test_do_image_create_no_user_props(self, mock_stdin): args = self._make_args({'name': 'IMG-01', 'disk_format': 'vhd', @@ -3084,7 +3143,8 @@ class ShellV2Test(testtools.TestCase): def test_do_md_tag_create_multiple(self): args = self._make_args({'namespace': 'MyNamespace', 'delim': ',', - 'names': 'MyTag1, MyTag2'}) + 'names': 'MyTag1, MyTag2', + 'append': False}) with mock.patch.object( self.gc.metadefs_tag, 'create_multiple') as mocked_create_tags: expect_tags = [{'tags': [{'name': 'MyTag1'}, {'name': 'MyTag2'}]}] @@ -3094,9 +3154,205 @@ class ShellV2Test(testtools.TestCase): test_shell.do_md_tag_create_multiple(self.gc, args) mocked_create_tags.assert_called_once_with( - 'MyNamespace', tags=['MyTag1', 'MyTag2']) + 'MyNamespace', tags=['MyTag1', 'MyTag2'], append=False) utils.print_list.assert_called_once_with( expect_tags, ['name'], field_settings={ 'description': {'align': 'l', 'max_width': 50}}) + + def test_do_md_tag_create_multiple_with_append(self): + args = self._make_args({'namespace': 'MyNamespace', + 'delim': ',', + 'names': 'MyTag1, MyTag2', + 'append': True}) + with mock.patch.object( + self.gc.metadefs_tag, 'create_multiple') as mocked_create_tags: + expect_tags = [{'tags': [{'name': 'MyTag1'}, {'name': 'MyTag2'}]}] + + mocked_create_tags.return_value = expect_tags + + test_shell.do_md_tag_create_multiple(self.gc, args) + + mocked_create_tags.assert_called_once_with( + 'MyNamespace', tags=['MyTag1', 'MyTag2'], append=True) + utils.print_list.assert_called_once_with( + expect_tags, + ['name'], + field_settings={ + 'description': {'align': 'l', 'max_width': 50}}) + + def _test_do_cache_list(self, supported=True): + args = self._make_args({}) + expected_output = { + "cached_images": [ + { + "image_id": "pass", + "last_accessed": 0, + "last_modified": 0, + "size": "fake_size", + "hits": "fake_hits", + } + ], + "queued_images": ['fake_image'] + } + + with mock.patch.object(self.gc.cache, 'list') as mocked_cache_list: + if supported: + mocked_cache_list.return_value = expected_output + else: + mocked_cache_list.side_effect = exc.HTTPNotImplemented + test_shell.do_cache_list(self.gc, args) + mocked_cache_list.assert_called() + if supported: + utils.print_cached_images.assert_called_once_with( + expected_output) + + def test_do_cache_list(self): + self._test_do_cache_list() + + def test_do_cache_list_unsupported(self): + self.assertRaises(exc.HTTPNotImplemented, + self._test_do_cache_list, supported=False) + + def test_do_cache_list_endpoint_not_provided(self): + args = self._make_args({}) + self.gc.endpoint_provided = False + with mock.patch('glanceclient.common.utils.exit') as mock_exit: + test_shell.do_cache_list(self.gc, args) + mock_exit.assert_called_once_with( + 'Direct server endpoint needs to be provided. Do ' + 'not use loadbalanced or catalog endpoints.') + + def _test_cache_queue(self, supported=True, forbidden=False,): + args = argparse.Namespace(id=['image1']) + with mock.patch.object(self.gc.cache, 'queue') as mocked_cache_queue: + if supported: + mocked_cache_queue.return_value = None + else: + mocked_cache_queue.side_effect = exc.HTTPNotImplemented + if forbidden: + mocked_cache_queue.side_effect = exc.HTTPForbidden + + test_shell.do_cache_queue(self.gc, args) + if supported: + mocked_cache_queue.assert_called_once_with('image1') + + def test_do_cache_queue(self): + self._test_cache_queue() + + def test_do_cache_queue_unsupported(self): + with mock.patch( + 'glanceclient.common.utils.print_err') as mock_print_err: + self._test_cache_queue(supported=False) + mock_print_err.assert_called_once_with( + "'HTTP HTTPNotImplemented': Unable to queue image " + "'image1' for caching.") + + def test_do_cache_queue_forbidden(self): + with mock.patch( + 'glanceclient.common.utils.print_err') as mock_print_err: + self._test_cache_queue(forbidden=True) + mock_print_err.assert_called_once_with( + "You are not permitted to queue the image 'image1' for " + "caching.") + + def test_do_cache_queue_endpoint_not_provided(self): + args = argparse.Namespace(id=['image1']) + self.gc.endpoint_provided = False + with mock.patch('glanceclient.common.utils.exit') as mock_exit: + test_shell.do_cache_queue(self.gc, args) + mock_exit.assert_called_once_with( + 'Direct server endpoint needs to be provided. Do ' + 'not use loadbalanced or catalog endpoints.') + + def _test_cache_delete(self, supported=True, forbidden=False,): + args = argparse.Namespace(id=['image1']) + with mock.patch.object(self.gc.cache, 'delete') as mocked_cache_delete: + if supported: + mocked_cache_delete.return_value = None + else: + mocked_cache_delete.side_effect = exc.HTTPNotImplemented + if forbidden: + mocked_cache_delete.side_effect = exc.HTTPForbidden + + test_shell.do_cache_delete(self.gc, args) + if supported: + mocked_cache_delete.assert_called_once_with('image1') + + def test_do_cache_delete(self): + self._test_cache_delete() + + def test_do_cache_delete_unsupported(self): + with mock.patch( + 'glanceclient.common.utils.print_err') as mock_print_err: + self._test_cache_delete(supported=False) + mock_print_err.assert_called_once_with( + "'HTTP HTTPNotImplemented': Unable to delete image " + "'image1' from cache.") + + def test_do_cache_delete_forbidden(self): + with mock.patch( + 'glanceclient.common.utils.print_err') as mock_print_err: + self._test_cache_delete(forbidden=True) + mock_print_err.assert_called_once_with( + "You are not permitted to " + "delete the image 'image1' from cache.") + + def test_do_cache_delete_endpoint_not_provided(self): + args = argparse.Namespace(id=['image1']) + self.gc.endpoint_provided = False + with mock.patch('glanceclient.common.utils.exit') as mock_exit: + test_shell.do_cache_delete(self.gc, args) + mock_exit.assert_called_once_with( + 'Direct server endpoint needs to be provided. Do ' + 'not use loadbalanced or catalog endpoints.') + + def _test_cache_clear(self, target='both', supported=True, + forbidden=False,): + args = self._make_args({'target': target}) + with mock.patch.object(self.gc.cache, 'clear') as mocked_cache_clear: + if supported: + mocked_cache_clear.return_value = None + else: + mocked_cache_clear.side_effect = exc.HTTPNotImplemented + if forbidden: + mocked_cache_clear.side_effect = exc.HTTPForbidden + + test_shell.do_cache_clear(self.gc, args) + if supported: + mocked_cache_clear.mocked_cache_clear(target) + + def test_do_cache_clear_all(self): + self._test_cache_clear() + + def test_do_cache_clear_queued_only(self): + self._test_cache_clear(target='queue') + + def test_do_cache_clear_cached_only(self): + self._test_cache_clear(target='cache') + + def test_do_cache_clear_unsupported(self): + with mock.patch( + 'glanceclient.common.utils.print_err') as mock_print_err: + self._test_cache_clear(supported=False) + mock_print_err.assert_called_once_with( + "'HTTP HTTPNotImplemented': Unable to delete image(s) " + "from cache.") + + def test_do_cache_clear_forbidden(self): + with mock.patch( + 'glanceclient.common.utils.print_err') as mock_print_err: + self._test_cache_clear(forbidden=True) + mock_print_err.assert_called_once_with( + "You are not permitted to " + "delete image(s) from cache.") + + def test_do_cache_clear_endpoint_not_provided(self): + args = self._make_args({'target': 'both'}) + self.gc.endpoint_provided = False + with mock.patch('glanceclient.common.utils.exit') as mock_exit: + test_shell.do_cache_clear(self.gc, args) + mock_exit.assert_called_once_with( + 'Direct server endpoint needs to be provided. Do ' + 'not use loadbalanced or catalog endpoints.') diff --git a/glanceclient/v2/cache.py b/glanceclient/v2/cache.py new file mode 100644 index 0000000..7631c4a --- /dev/null +++ b/glanceclient/v2/cache.py @@ -0,0 +1,62 @@ +# Copyright 2021 OpenStack Foundation +# 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 glanceclient.common import utils +from glanceclient import exc + +TARGET_VALUES = ('both', 'cache', 'queue') + + +class Controller(object): + def __init__(self, http_client): + self.http_client = http_client + + def is_supported(self, version): + if utils.has_version(self.http_client, version): + return True + else: + raise exc.HTTPNotImplemented( + 'Glance does not support image caching API (v2.14)') + + @utils.add_req_id_to_object() + def list(self): + if self.is_supported('v2.14'): + url = '/v2/cache' + resp, body = self.http_client.get(url) + return body, resp + + @utils.add_req_id_to_object() + def delete(self, image_id): + if self.is_supported('v2.14'): + resp, body = self.http_client.delete('/v2/cache/%s' % + image_id) + return body, resp + + @utils.add_req_id_to_object() + def clear(self, target): + if self.is_supported('v2.14'): + url = '/v2/cache' + headers = {} + if target != "both": + headers = {'x-image-cache-clear-target': target} + resp, body = self.http_client.delete(url, headers=headers) + return body, resp + + @utils.add_req_id_to_object() + def queue(self, image_id): + if self.is_supported('v2.14'): + url = '/v2/cache/%s' % image_id + resp, body = self.http_client.put(url) + return body, resp diff --git a/glanceclient/v2/client.py b/glanceclient/v2/client.py index 279be63..8b8bd61 100644 --- a/glanceclient/v2/client.py +++ b/glanceclient/v2/client.py @@ -16,9 +16,11 @@ from glanceclient.common import http from glanceclient.common import utils +from glanceclient.v2 import cache from glanceclient.v2 import image_members from glanceclient.v2 import image_tags from glanceclient.v2 import images +from glanceclient.v2 import info from glanceclient.v2 import metadefs from glanceclient.v2 import schemas from glanceclient.v2 import tasks @@ -38,6 +40,7 @@ class Client(object): """ def __init__(self, endpoint=None, **kwargs): + self.endpoint_provided = endpoint is not None endpoint, self.version = utils.endpoint_version_from_url(endpoint, 2.0) self.http_client = http.get_http_client(endpoint=endpoint, **kwargs) self.schemas = schemas.Controller(self.http_client) @@ -48,6 +51,8 @@ class Client(object): self.image_members = image_members.Controller(self.http_client, self.schemas) + self.info = info.Controller(self.http_client, self.schemas) + self.tasks = tasks.Controller(self.http_client, self.schemas) self.metadefs_resource_type = ( @@ -66,3 +71,5 @@ class Client(object): metadefs.NamespaceController(self.http_client, self.schemas)) self.versions = versions.VersionController(self.http_client) + + self.cache = cache.Controller(self.http_client) diff --git a/glanceclient/v2/images.py b/glanceclient/v2/images.py index b412c42..eeb5ee1 100644 --- a/glanceclient/v2/images.py +++ b/glanceclient/v2/images.py @@ -323,6 +323,13 @@ class Controller(object): return body, resp @utils.add_req_id_to_object() + def get_stores_info_detail(self): + """Get available stores info from discovery endpoint.""" + url = '/v2/info/stores/detail' + resp, body = self.http_client.get(url) + return body, resp + + @utils.add_req_id_to_object() def delete_from_store(self, store_id, image_id): """Delete image data from specific store.""" url = ('/v2/stores/%(store)s/%(image)s' % {'store': store_id, diff --git a/glanceclient/v2/info.py b/glanceclient/v2/info.py new file mode 100644 index 0000000..1c40567 --- /dev/null +++ b/glanceclient/v2/info.py @@ -0,0 +1,23 @@ +# Copyright 2022 Red Hat, Inc. +# +# 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. + + +class Controller: + def __init__(self, http_client, schema_client): + self.http_client = http_client + self.schema_client = schema_client + + def get_usage(self, **kwargs): + resp, body = self.http_client.get('/v2/info/usage') + return body['usage'] diff --git a/glanceclient/v2/metadefs.py b/glanceclient/v2/metadefs.py index 1b641ac..a98a9fe 100644 --- a/glanceclient/v2/metadefs.py +++ b/glanceclient/v2/metadefs.py @@ -490,9 +490,8 @@ class TagController(object): """Create the list of tags. :param namespace: Name of a namespace to which the Tags belong. - :param kwargs: list of tags. + :param kwargs: list of tags, optional parameter append. """ - tag_names = kwargs.pop('tags', []) md_tag_list = [] @@ -502,11 +501,15 @@ class TagController(object): except (warlock.InvalidOperation) as e: raise TypeError(encodeutils.exception_to_unicode(e)) tags = {'tags': md_tag_list} + headers = {} url = '/v2/metadefs/namespaces/%(namespace)s/tags' % { - 'namespace': namespace} + 'namespace': namespace} - resp, body = self.http_client.post(url, data=tags) + append = kwargs.pop('append', False) + if append: + headers['X-Openstack-Append'] = True + resp, body = self.http_client.post(url, headers=headers, data=tags) body.pop('self', None) for tag in body['tags']: yield self.model(tag), resp diff --git a/glanceclient/v2/shell.py b/glanceclient/v2/shell.py index 5f83bd2..be627f5 100644 --- a/glanceclient/v2/shell.py +++ b/glanceclient/v2/shell.py @@ -23,6 +23,7 @@ from glanceclient._i18n import _ from glanceclient.common import progressbar from glanceclient.common import utils from glanceclient import exc +from glanceclient.v2 import cache from glanceclient.v2 import image_members from glanceclient.v2 import image_schema from glanceclient.v2 import images @@ -486,6 +487,15 @@ def do_image_tasks(gc, args): utils.exit('Server does not support image tasks API (v2.12)') +def do_usage(gc, args): + """Get quota usage information.""" + columns = ['Quota', 'Limit', 'Usage'] + usage = gc.info.get_usage() + utils.print_dict_list( + [dict(v, quota=k) for k, v in usage.items()], + columns) + + @utils.arg('--image-id', metavar='<IMAGE_ID>', required=True, help=_('Image to display members of.')) def do_member_list(gc, args): @@ -574,11 +584,15 @@ def do_import_info(gc, args): else: utils.print_dict(import_info) - +@utils.arg('--detail', default=False, action='store_true', + help='Shows details of stores. Admin only.') def do_stores_info(gc, args): """Print available backends from Glance.""" try: - stores_info = gc.images.get_stores_info() + if args.detail: + stores_info = gc.images.get_stores_info_detail() + else: + stores_info = gc.images.get_stores_info() except exc.HTTPNotFound: utils.exit('Multi Backend support is not enabled') else: @@ -1383,10 +1397,12 @@ def do_md_tag_create(gc, args): @utils.arg('--delim', metavar='<DELIM>', required=False, help=_('The delimiter used to separate the names' ' (if none is provided then the default is a comma).')) +@utils.arg('--append', default=False, action='store_true', required=False, + help=_('Append the new tags to the existing ones instead of' + 'overwriting them')) def do_md_tag_create_multiple(gc, args): """Create new metadata definitions tags inside a namespace.""" delim = args.delim or ',' - tags = [] names_list = args.names.split(delim) for name in names_list: @@ -1398,7 +1414,7 @@ def do_md_tag_create_multiple(gc, args): utils.exit('Please supply at least one tag name. For example: ' '--names Tag1') - fields = {'tags': tags} + fields = {'tags': tags, 'append': args.append} new_tags = gc.metadefs_tag.create_multiple(args.namespace, **fields) columns = ['name'] column_settings = { @@ -1464,6 +1480,76 @@ def do_md_tag_list(gc, args): utils.print_list(tags, columns, field_settings=column_settings) +@utils.arg('--target', default='both', + choices=cache.TARGET_VALUES, + help=_('Specify which target you want to clear')) +def do_cache_clear(gc, args): + """Clear all images from cache, queue or both""" + if not gc.endpoint_provided: + utils.exit("Direct server endpoint needs to be provided. Do not use " + "loadbalanced or catalog endpoints.") + try: + gc.cache.clear(args.target) + except exc.HTTPForbidden: + msg = _("You are not permitted to delete image(s) " + "from cache.") + utils.print_err(msg) + except exc.HTTPException as e: + msg = _("'%s': Unable to delete image(s) from cache." % e) + utils.print_err(msg) + + +@utils.arg('id', metavar='<IMAGE_ID>', nargs='+', + help=_('ID of image(s) to delete from cache/queue.')) +def do_cache_delete(gc, args): + """Delete image from cache/caching queue.""" + if not gc.endpoint_provided: + utils.exit("Direct server endpoint needs to be provided. Do not use " + "loadbalanced or catalog endpoints.") + + for args_id in args.id: + try: + gc.cache.delete(args_id) + except exc.HTTPForbidden: + msg = _("You are not permitted to delete the image '%s' " + "from cache." % args_id) + utils.print_err(msg) + except exc.HTTPException as e: + msg = _("'%s': Unable to delete image '%s' from cache." + % (e, args_id)) + utils.print_err(msg) + + +def do_cache_list(gc, args): + """Get cache state.""" + if not gc.endpoint_provided: + utils.exit("Direct server endpoint needs to be provided. Do not use " + "loadbalanced or catalog endpoints.") + cached_images = gc.cache.list() + utils.print_cached_images(cached_images) + + +@utils.arg('id', metavar='<IMAGE_ID>', nargs='+', + help=_('ID of image(s) to queue for caching.')) +def do_cache_queue(gc, args): + """Queue image(s) for caching.""" + if not gc.endpoint_provided: + utils.exit("Direct server endpoint needs to be provided. Do not use " + "loadbalanced or catalog endpoints.") + + for args_id in args.id: + try: + gc.cache.queue(args_id) + except exc.HTTPForbidden: + msg = _("You are not permitted to queue the image '%s' " + "for caching." % args_id) + utils.print_err(msg) + except exc.HTTPException as e: + msg = _("'%s': Unable to queue image '%s' for caching." + % (e, args_id)) + utils.print_err(msg) + + @utils.arg('--sort-key', default='status', choices=tasks.SORT_KEY_VALUES, help=_('Sort task list by specified field.')) diff --git a/releasenotes/notes/3.6.0_Release-04d3b5017747290b.yaml b/releasenotes/notes/3.6.0_Release-04d3b5017747290b.yaml new file mode 100644 index 0000000..4ba1dd3 --- /dev/null +++ b/releasenotes/notes/3.6.0_Release-04d3b5017747290b.yaml @@ -0,0 +1,51 @@ +--- +prelude: | + This version of python-glanceclient introduces new commands ``usage`` + to fetch quota related usage information and ``cache-clear``, + ``cache-delete``, ``cache-list`` and ``cache-queue`` for cache related + operations. + +features: + - | + Adds support for ``usage`` command which will report the usage + of unified limits configured. The following commands have been added to + the command line interface: + + * ``usage`` - Get quota usage information. + + - | + Client support has been added for the new caching functionality + introduced into Glance in this cycle. This feature is only available in + the Images API version 2 when the caching middleware is enabled in the + Glance service that the client is contacting. The following commands have + been added to the command line interface: + + * ``cache-clear`` - Delete all the images from cache and queued for caching + * ``cache-delete`` - Delete the specified image from cache or from the queued + list + * ``cache-list`` - List all the images which are cached or being queued for + caching + * ``cache-queue`` - Queue specified image(s) for caching. + + - | + Client side support has been added to fetch store specific configuration + information. With sufficient permissions, this will display additional + information about the stores. + + - | + Client side support has been added to provide the facility of appending + the tags while creating new multiple tags rather than overwriting the + existing tags. + +upgrade: + - | + The following Command Line Interface call now takes ``--detail`` option: + + * | ``glance stores-info`` + | The value for ``--detail`` option could be True or False. + + - | + The following Command Line Interface call now takes ``--append`` option: + + * | ``glance md-tag-create-multiple`` + | The value for ``--append`` option could be True or False. diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst index 8cd670d..56571c3 100644 --- a/releasenotes/source/index.rst +++ b/releasenotes/source/index.rst @@ -6,6 +6,7 @@ glanceclient Release Notes :maxdepth: 1 unreleased + yoga xena wallaby victoria diff --git a/releasenotes/source/yoga.rst b/releasenotes/source/yoga.rst new file mode 100644 index 0000000..7cd5e90 --- /dev/null +++ b/releasenotes/source/yoga.rst @@ -0,0 +1,6 @@ +========================= +Yoga Series Release Notes +========================= + +.. release-notes:: + :branch: stable/yoga @@ -23,6 +23,7 @@ classifier = Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 [files] packages = diff --git a/tools/with_venv.sh b/tools/with_venv.sh index e6e44f5..1b09ad7 100755 --- a/tools/with_venv.sh +++ b/tools/with_venv.sh @@ -4,7 +4,7 @@ command -v tox > /dev/null 2>&1 if [ $? -ne 0 ]; then echo 'This script requires "tox" to run.' echo 'You can install it with "pip install tox".' - exit 1; + exit 1; fi tox -evenv -- $@ @@ -1,5 +1,5 @@ [tox] -envlist = py38,pep8 +envlist = py39,pep8 minversion = 2.0 skipsdist = True @@ -8,8 +8,12 @@ usedevelop = True setenv = OS_STDOUT_NOCAPTURE=False OS_STDERR_NOCAPTURE=False +# Nowadays, TOX_CONSTRAINTS_FILE should be used, but some older scripts might +# still be using UPPER_CONSTRAINTS_FILE, so we check both variables and use the +# first one that is defined. If none of them is defined, we fallback to the +# default value. deps = - -c{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -c{env:TOX_CONSTRAINTS_FILE:{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master}} -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt commands = stestr run --slowest {posargs} @@ -55,8 +59,12 @@ commands = [testenv:releasenotes] basepython = python3 +# Nowadays, TOX_CONSTRAINTS_FILE should be used, but some older scripts might +# still be using UPPER_CONSTRAINTS_FILE, so we check both variables and use the +# first one that is defined. If none of them is defined, we fallback to the +# default value. deps = - -c{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -c{env:TOX_CONSTRAINTS_FILE:{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master}} -r{toxinidir}/doc/requirements.txt commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html |