diff --git a/doc/source/cli/data/glance.csv b/doc/source/cli/data/glance.csv index 1481610ec1..c2c3248943 100644 --- a/doc/source/cli/data/glance.csv +++ b/doc/source/cli/data/glance.csv @@ -1,7 +1,7 @@ -cache-clear,,"Clear all images from cache, queue or both." -cache-delete,,Delete image from cache/caching queue. -cache-list,,Get cache state. -cache-queue,,Queue image(s) for caching. +cache-clear,cached image list,"Clear all images from cache, queue or both." +cache-delete,cached image delete,Delete image from cache/caching queue. +cache-list,cached image list,Get cache state. +cache-queue,cached image queue,Queue image(s) for caching. explain,WONTFIX,Describe a specific model. image-create,image create,Create a new image. image-create-via-import, image create --import,"EXPERIMENTAL: Create a new image via image import using glance-direct import method. Missing support for web-download, copy-image and glance-download import methods. The OSC command is also missing support for importing image to specified store as well as all stores (--store, --stores, --all-stores) and skip or stop processing if import fails to one of the store (--allow-failure)" diff --git a/openstackclient/image/v2/cache.py b/openstackclient/image/v2/cache.py new file mode 100644 index 0000000000..ebb4e5b206 --- /dev/null +++ b/openstackclient/image/v2/cache.py @@ -0,0 +1,218 @@ +# Copyright 2023 Red Hat. +# 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 copy +import datetime +import logging + +from osc_lib.command import command +from osc_lib import exceptions +from osc_lib import utils + +from openstackclient.i18n import _ + + +LOG = logging.getLogger(__name__) + + +def _format_image_cache(cached_images): + """Format image cache to make it more consistent with OSC operations.""" + + image_list = [] + for item in cached_images: + if item == "cached_images": + for image in cached_images[item]: + image_obj = copy.deepcopy(image) + image_obj['state'] = 'cached' + image_obj[ + 'last_accessed' + ] = datetime.datetime.utcfromtimestamp( + image['last_accessed'] + ).isoformat() + image_obj[ + 'last_modified' + ] = datetime.datetime.utcfromtimestamp( + image['last_modified'] + ).isoformat() + image_list.append(image_obj) + elif item == "queued_images": + for image in cached_images[item]: + image = {'image_id': image} + image.update( + { + 'state': 'queued', + 'last_accessed': 'N/A', + 'last_modified': 'N/A', + 'size': 'N/A', + 'hits': 'N/A', + } + ) + image_list.append(image) + return image_list + + +class ListCachedImage(command.Lister): + _description = _("Get Cache State") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + return parser + + def take_action(self, parsed_args): + image_client = self.app.client_manager.image + + # List of Cache data received + data = _format_image_cache(dict(image_client.get_image_cache())) + columns = [ + 'image_id', + 'state', + 'last_accessed', + 'last_modified', + 'size', + 'hits', + ] + column_headers = [ + "ID", + "State", + "Last Accessed (UTC)", + "Last Modified (UTC)", + "Size", + "Hits", + ] + + return ( + column_headers, + ( + utils.get_dict_properties( + image, + columns, + ) + for image in data + ), + ) + + +class QueueCachedImage(command.Command): + _description = _("Queue image(s) for caching.") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + "images", + metavar="", + nargs="+", + help=_("Image to display (name or ID)"), + ) + return parser + + def take_action(self, parsed_args): + image_client = self.app.client_manager.image + + failures = 0 + for image in parsed_args.images: + try: + image_obj = image_client.find_image( + image, + ignore_missing=False, + ) + image_client.queue_image(image_obj.id) + except Exception as e: + failures += 1 + msg = _( + "Failed to queue image with name or " + "ID '%(image)s': %(e)s" + ) + LOG.error(msg, {'image': image, 'e': e}) + + if failures > 0: + total = len(parsed_args.images) + msg = _("Failed to queue %(failures)s of %(total)s images") % { + 'failures': failures, + 'total': total, + } + raise exceptions.CommandError(msg) + + +class DeleteCachedImage(command.Command): + _description = _("Delete image(s) from cache") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + "images", + metavar="", + nargs="+", + help=_("Image(s) to delete (name or ID)"), + ) + return parser + + def take_action(self, parsed_args): + failures = 0 + image_client = self.app.client_manager.image + for image in parsed_args.images: + try: + image_obj = image_client.find_image( + image, + ignore_missing=False, + ) + image_client.cache_delete_image(image_obj.id) + except Exception as e: + failures += 1 + msg = _( + "Failed to delete image with name or " + "ID '%(image)s': %(e)s" + ) + LOG.error(msg, {'image': image, 'e': e}) + + if failures > 0: + total = len(parsed_args.images) + msg = _("Failed to delete %(failures)s of %(total)s images.") % { + 'failures': failures, + 'total': total, + } + raise exceptions.CommandError(msg) + + +class ClearCachedImage(command.Command): + _description = _("Clear all images from cache, queue or both") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + "--cache", + action="store_const", + const="cache", + dest="target", + help=_("Clears all the cached images"), + ) + parser.add_argument( + "--queue", + action="store_const", + const="queue", + dest="target", + help=_("Clears all the queued images"), + ) + return parser + + def take_action(self, parsed_args): + image_client = self.app.client_manager.image + + target = parsed_args.target + try: + image_client.clear_cache(target) + except Exception: + msg = _("Failed to clear image cache") + LOG.error(msg) + raise exceptions.CommandError(msg) diff --git a/openstackclient/tests/unit/image/v2/fakes.py b/openstackclient/tests/unit/image/v2/fakes.py index 1fcfb50e9d..45af0f47cb 100644 --- a/openstackclient/tests/unit/image/v2/fakes.py +++ b/openstackclient/tests/unit/image/v2/fakes.py @@ -17,6 +17,7 @@ from unittest import mock import uuid from openstack.image.v2 import _proxy +from openstack.image.v2 import cache from openstack.image.v2 import image from openstack.image.v2 import member from openstack.image.v2 import metadef_namespace @@ -238,6 +239,28 @@ def create_tasks(attrs=None, count=2): return tasks +def create_cache(attrs=None): + attrs = attrs or {} + cache_info = { + 'cached_images': [ + { + 'hits': 0, + 'image_id': '1a56983c-f71f-490b-a7ac-6b321a18935a', + 'last_accessed': 1671699579.444378, + 'last_modified': 1671699579.444378, + 'size': 0, + }, + ], + 'queued_images': [ + '3a4560a1-e585-443e-9b39-553b46ec92d1', + '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + ], + } + cache_info.update(attrs) + + return cache.Cache(**cache_info) + + def create_one_metadef_namespace(attrs=None): """Create a fake MetadefNamespace member. diff --git a/openstackclient/tests/unit/image/v2/test_cache.py b/openstackclient/tests/unit/image/v2/test_cache.py new file mode 100644 index 0000000000..abb0b37327 --- /dev/null +++ b/openstackclient/tests/unit/image/v2/test_cache.py @@ -0,0 +1,214 @@ +# Copyright 2023 Red Hat. +# 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 unittest.mock import call + +from openstack import exceptions as sdk_exceptions +from osc_lib import exceptions + +from openstackclient.image.v2 import cache +from openstackclient.tests.unit.image.v2 import fakes + + +class TestCacheList(fakes.TestImagev2): + _cache = fakes.create_cache() + columns = [ + "ID", + "State", + "Last Accessed (UTC)", + "Last Modified (UTC)", + "Size", + "Hits", + ] + + cache_list = cache._format_image_cache(dict(fakes.create_cache())) + datalist = ( + ( + image['image_id'], + image['state'], + image['last_accessed'], + image['last_modified'], + image['size'], + image['hits'], + ) + for image in cache_list + ) + + def setUp(self): + super().setUp() + + # Get the command object to test + self.image_client.get_image_cache.return_value = self._cache + self.cmd = cache.ListCachedImage(self.app, None) + + def test_image_cache_list(self): + arglist = [] + parsed_args = self.check_parser(self.cmd, arglist, []) + columns, data = self.cmd.take_action(parsed_args) + + self.image_client.get_image_cache.assert_called() + self.assertEqual(self.columns, columns) + self.assertEqual(tuple(self.datalist), tuple(data)) + + +class TestQueueCache(fakes.TestImagev2): + def setUp(self): + super().setUp() + + self.image_client.queue_image.return_value = None + self.cmd = cache.QueueCachedImage(self.app, None) + + def test_cache_queue(self): + images = fakes.create_images(count=1) + arglist = [ + images[0].id, + ] + + verifylist = [ + ('images', [images[0].id]), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.image_client.find_image.side_effect = images + + self.cmd.take_action(parsed_args) + + self.image_client.queue_image.assert_called_once_with(images[0].id) + + def test_cache_queue_multiple_images(self): + images = fakes.create_images(count=3) + arglist = [i.id for i in images] + + verifylist = [ + ('images', arglist), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.image_client.find_image.side_effect = images + + self.cmd.take_action(parsed_args) + calls = [call(i.id) for i in images] + self.image_client.queue_image.assert_has_calls(calls) + + +class TestCacheDelete(fakes.TestImagev2): + def setUp(self): + super().setUp() + + self.image_client.cache_delete_image.return_value = None + self.cmd = cache.DeleteCachedImage(self.app, None) + + def test_cache_delete(self): + images = fakes.create_images(count=1) + arglist = [ + images[0].id, + ] + + verifylist = [ + ('images', [images[0].id]), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.image_client.find_image.side_effect = images + + self.cmd.take_action(parsed_args) + + self.image_client.find_image.assert_called_once_with( + images[0].id, ignore_missing=False + ) + self.image_client.cache_delete_image.assert_called_once_with( + images[0].id + ) + + def test_cache_delete_multiple_images(self): + images = fakes.create_images(count=3) + arglist = [i.id for i in images] + + verifylist = [ + ('images', arglist), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.image_client.find_image.side_effect = images + + self.cmd.take_action(parsed_args) + calls = [call(i.id) for i in images] + self.image_client.cache_delete_image.assert_has_calls(calls) + + def test_cache_delete_multiple_images_exception(self): + images = fakes.create_images(count=2) + arglist = [ + images[0].id, + images[1].id, + 'x-y-x', + ] + verifylist = [ + ('images', arglist), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + ret_find = [images[0], images[1], sdk_exceptions.ResourceNotFound()] + + self.image_client.find_image.side_effect = ret_find + + self.assertRaises( + exceptions.CommandError, self.cmd.take_action, parsed_args + ) + calls = [call(i.id) for i in images] + self.image_client.cache_delete_image.assert_has_calls(calls) + + +class TestCacheClear(fakes.TestImagev2): + def setUp(self): + super().setUp() + + self.image_client.clear_cache.return_value = None + self.cmd = cache.ClearCachedImage(self.app, None) + + def test_cache_clear_no_option(self): + arglist = [] + + verifylist = [('target', None)] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.cmd.take_action(parsed_args) + + self.assertIsNone( + self.image_client.clear_cache.assert_called_with(None) + ) + + def test_cache_clear_queue_option(self): + arglist = ['--queue'] + + verifylist = [('target', 'queue')] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.cmd.take_action(parsed_args) + + self.image_client.clear_cache.assert_called_once_with('queue') + + def test_cache_clear_cache_option(self): + arglist = ['--cache'] + + verifylist = [('target', 'cache')] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.cmd.take_action(parsed_args) + + self.image_client.clear_cache.assert_called_once_with('cache') diff --git a/releasenotes/notes/add-cache-commands-a6f046348a3a0b1f.yaml b/releasenotes/notes/add-cache-commands-a6f046348a3a0b1f.yaml new file mode 100644 index 0000000000..81243cfa99 --- /dev/null +++ b/releasenotes/notes/add-cache-commands-a6f046348a3a0b1f.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Add commands for the image Cache API, to list, queue, + delete and clear images in the cache. diff --git a/setup.cfg b/setup.cfg index de13507562..dc770b0061 100644 --- a/setup.cfg +++ b/setup.cfg @@ -399,6 +399,12 @@ openstack.image.v2 = image_metadef_resource_type_list = openstackclient.image.v2.metadef_resource_types:ListMetadefResourceTypes + cached_image_list = openstackclient.image.v2.cache:ListCachedImage + cached_image_queue = openstackclient.image.v2.cache:QueueCachedImage + cached_image_delete = openstackclient.image.v2.cache:DeleteCachedImage + cached_image_clear = openstackclient.image.v2.cache:ClearCachedImage + + openstack.network.v2 = address_group_create = openstackclient.network.v2.address_group:CreateAddressGroup address_group_delete = openstackclient.network.v2.address_group:DeleteAddressGroup