summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.stestr.conf2
-rw-r--r--.zuul.yaml3
-rw-r--r--doc/source/cli/glance.rst4
-rw-r--r--glanceclient/common/utils.py44
-rw-r--r--glanceclient/shell.py6
-rw-r--r--glanceclient/tests/unit/test_shell.py11
-rw-r--r--glanceclient/tests/unit/v2/test_cache.py135
-rw-r--r--glanceclient/tests/unit/v2/test_info.py37
-rw-r--r--glanceclient/tests/unit/v2/test_shell_v2.py270
-rw-r--r--glanceclient/v2/cache.py62
-rw-r--r--glanceclient/v2/client.py7
-rw-r--r--glanceclient/v2/images.py7
-rw-r--r--glanceclient/v2/info.py23
-rw-r--r--glanceclient/v2/metadefs.py11
-rw-r--r--glanceclient/v2/shell.py94
-rw-r--r--lower-constraints.txt72
-rw-r--r--releasenotes/notes/3.6.0_Release-04d3b5017747290b.yaml51
-rw-r--r--releasenotes/source/index.rst1
-rw-r--r--releasenotes/source/yoga.rst6
-rw-r--r--setup.cfg1
-rw-r--r--tox.ini21
21 files changed, 768 insertions, 100 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=./
diff --git a/.zuul.yaml b/.zuul.yaml
index 285e19e..08733b8 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -32,9 +32,7 @@
- ^releasenotes/.*$
- ^.*\.rst$
- ^(test-|)requirements.txt$
- - ^lower-constraints.txt$
- ^setup.cfg$
- - ^tox.ini$
- job:
name: glanceclient-tox-keystone-tips-base
@@ -81,7 +79,6 @@
- check-requirements
- lib-forward-testing-python3
- openstack-cover-jobs
- - openstack-lower-constraints-jobs
- openstack-python3-yoga-jobs
- publish-openstack-docs-pti
- release-notes-jobs-python3
diff --git a/doc/source/cli/glance.rst b/doc/source/cli/glance.rst
index 27215ef..256a969 100644
--- a/doc/source/cli/glance.rst
+++ b/doc/source/cli/glance.rst
@@ -69,6 +69,10 @@ See available images::
glance image-list
+To get a verbose output including more fields in the image list response::
+
+ glance --verbose image-list
+
Create new image::
glance image-create --name foo --disk-format=qcow2 \
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/shell.py b/glanceclient/shell.py
index 3a32bfb..4a505a5 100644
--- a/glanceclient/shell.py
+++ b/glanceclient/shell.py
@@ -224,8 +224,12 @@ class OpenStackImagesShell(object):
help=argparse.SUPPRESS,
)
self.subcommands[command] = subparser
+ required_args = subparser.add_argument_group('Required arguments')
for (args, kwargs) in arguments:
- subparser.add_argument(*args, **kwargs)
+ if kwargs.get('required', False):
+ required_args.add_argument(*args, **kwargs)
+ else:
+ subparser.add_argument(*args, **kwargs)
subparser.set_defaults(func=callback)
def _add_bash_completion_subparser(self, subparsers):
diff --git a/glanceclient/tests/unit/test_shell.py b/glanceclient/tests/unit/test_shell.py
index 129b127..6b9472e 100644
--- a/glanceclient/tests/unit/test_shell.py
+++ b/glanceclient/tests/unit/test_shell.py
@@ -600,6 +600,17 @@ class ShellTest(testutils.TestCase):
self.assertEqual(glance_logger.getEffectiveLevel(), logging.DEBUG)
conf.assert_called_with(level=logging.DEBUG)
+ def test_subcommand_help(self):
+ # Ensure that main works with sub command help
+ stdout, stderr = self.shell('help stores-delete')
+
+ expected = 'usage: glance stores-delete --store <STORE_ID> ' \
+ '<IMAGE_ID>\n\nDelete image from specific store.' \
+ '\n\nPositional arguments:\n <IMAGE_ID> ' \
+ 'ID of image to update.\n\nRequired arguments:\n ' \
+ '--store <STORE_ID> Store to delete image from.\n'
+ self.assertEqual(expected, stdout)
+
class ShellTestWithKeystoneV3Auth(ShellTest):
# auth environment to use
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..c24a1c9 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,
@@ -319,6 +368,12 @@ class ShellV2Test(testtools.TestCase):
{}, ['ID', 'Name', 'Disk_format', 'Container_format',
'Size', 'Status', 'Owner'])
+ def test_do_image_list_verbose_cmd(self):
+ self._run_command('--os-image-api-version 2 --verbose image-list')
+ utils.print_list.assert_called_once_with(
+ mock.ANY, ['ID', 'Name', 'Disk_format', 'Container_format',
+ 'Size', 'Status', 'Owner'])
+
def test_do_image_list_with_include_stores_true(self):
input = {
'limit': None,
@@ -614,6 +669,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 +3149,29 @@ 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'}]}]
+
+ 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=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'}]}]
@@ -3094,9 +3181,184 @@ 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=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/lower-constraints.txt b/lower-constraints.txt
deleted file mode 100644
index 52d4904..0000000
--- a/lower-constraints.txt
+++ /dev/null
@@ -1,72 +0,0 @@
-alabaster==0.7.10
-appdirs==1.3.0
-asn1crypto==0.23.0
-Babel==2.3.4
-cffi==1.14.0
-cliff==2.8.0
-cmd2==0.8.0
-coverage==4.0
-cryptography==2.7
-ddt==1.2.1
-debtcollector==1.2.0
-docutils==0.11
-dulwich==0.15.0
-extras==1.0.0
-fasteners==0.7.0
-fixtures==3.0.0
-future==0.16.0
-idna==2.6
-imagesize==0.7.1
-iso8601==0.1.11
-Jinja2==2.10
-jsonpatch==1.16
-jsonpointer==1.13
-jsonschema==2.6.0
-keystoneauth1==3.6.2
-linecache2==1.0.0
-MarkupSafe==1.0
-mccabe==0.6.0
-monotonic==0.6
-msgpack-python==0.4.0
-netaddr==0.7.18
-netifaces==0.10.4
-ordereddict==1.1
-os-client-config==1.28.0
-os-testr==1.0.0
-oslo.concurrency==3.25.0
-oslo.config==5.2.0
-oslo.context==2.19.2
-oslo.i18n==3.15.3
-oslo.log==3.36.0
-oslo.serialization==2.18.0
-oslo.utils==3.33.0
-paramiko==2.0.0
-pbr==2.0.0
-prettytable==0.7.1
-pyasn1==0.1.8
-pycparser==2.18
-Pygments==2.2.0
-pyinotify==0.9.6
-pyOpenSSL==17.1.0
-pyparsing==2.1.0
-pyperclip==1.5.27
-python-dateutil==2.5.3
-python-mimeparse==1.6.0
-python-subunit==1.0.0
-pytz==2013.6
-PyYAML==3.13
-requests-mock==1.2.0
-requests==2.14.2
-requestsexceptions==1.2.0
-rfc3986==0.3.1
-snowballstemmer==1.2.1
-stestr==2.0.0
-stevedore==1.20.0
-tempest==17.1.0
-testscenarios==0.4
-testtools==2.2.0
-traceback2==1.4.0
-unittest2==1.1.0
-urllib3==1.21.1
-warlock==1.2.0
-wrapt==1.7.0
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
diff --git a/setup.cfg b/setup.cfg
index a246af5..eea6b51 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -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/tox.ini b/tox.ini
index 7d12799..3f1fbb1 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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
@@ -70,10 +78,3 @@ exclude = .venv*,.tox,dist,*egg,build,.git,doc,*lib/python*,.update-venv
[hacking]
import_exceptions = glanceclient._i18n
-
-[testenv:lower-constraints]
-basepython = python3
-deps =
- -c{toxinidir}/lower-constraints.txt
- -r{toxinidir}/test-requirements.txt
- -r{toxinidir}/requirements.txt