Add support for Cache API

This change provides support for the Cache API changes and
deprecation path for glance-cache-manage command.

Change-Id: I6fca9bbe6bc0bd9b14d8dba685405838131160af
This commit is contained in:
Erno Kuvaja 2021-07-09 10:41:22 +01:00 committed by Abhishek Kekane
parent 63bb03a145
commit 62f4f67d1d
6 changed files with 491 additions and 1 deletions

View File

@ -13,6 +13,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import datetime
import errno import errno
import functools import functools
import hashlib import hashlib
@ -175,13 +176,54 @@ def pretty_choice_list(l):
def has_version(client, version): def has_version(client, version):
versions = client.get('/versions')[1].get('versions') versions = client.get('/versions')[1].get('versions')
supported = ['SUPPORTED', 'CURRENT'] supported = ['SUPPORTED', 'CURRENT', 'EXPERIMENTAL']
for version_struct in versions: for version_struct in versions:
if version_struct['id'] == version: if version_struct['id'] == version:
return version_struct['status'] in supported return version_struct['status'] in supported
return False 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): def print_dict_list(objects, fields):
pt = prettytable.PrettyTable([f for f in fields], caching=False) pt = prettytable.PrettyTable([f for f in fields], caching=False)
pt.align = 'l' pt.align = 'l'

View File

@ -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)

View File

@ -114,6 +114,7 @@ class ShellV2Test(testtools.TestCase):
utils.print_dict = mock.Mock() utils.print_dict = mock.Mock()
utils.save_image = mock.Mock() utils.save_image = mock.Mock()
utils.print_dict_list = 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): def assert_exits_with_msg(self, func, func_args, err_msg=None):
with mock.patch.object(utils, 'exit') as mocked_utils_exit: with mock.patch.object(utils, 'exit') as mocked_utils_exit:
@ -3180,3 +3181,178 @@ class ShellV2Test(testtools.TestCase):
['name'], ['name'],
field_settings={ field_settings={
'description': {'align': 'l', 'max_width': 50}}) '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.')

62
glanceclient/v2/cache.py Normal file
View File

@ -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

View File

@ -16,6 +16,7 @@
from glanceclient.common import http from glanceclient.common import http
from glanceclient.common import utils from glanceclient.common import utils
from glanceclient.v2 import cache
from glanceclient.v2 import image_members from glanceclient.v2 import image_members
from glanceclient.v2 import image_tags from glanceclient.v2 import image_tags
from glanceclient.v2 import images from glanceclient.v2 import images
@ -39,6 +40,7 @@ class Client(object):
""" """
def __init__(self, endpoint=None, **kwargs): def __init__(self, endpoint=None, **kwargs):
self.endpoint_provided = endpoint is not None
endpoint, self.version = utils.endpoint_version_from_url(endpoint, 2.0) endpoint, self.version = utils.endpoint_version_from_url(endpoint, 2.0)
self.http_client = http.get_http_client(endpoint=endpoint, **kwargs) self.http_client = http.get_http_client(endpoint=endpoint, **kwargs)
self.schemas = schemas.Controller(self.http_client) self.schemas = schemas.Controller(self.http_client)
@ -69,3 +71,5 @@ class Client(object):
metadefs.NamespaceController(self.http_client, self.schemas)) metadefs.NamespaceController(self.http_client, self.schemas))
self.versions = versions.VersionController(self.http_client) self.versions = versions.VersionController(self.http_client)
self.cache = cache.Controller(self.http_client)

View File

@ -23,6 +23,7 @@ from glanceclient._i18n import _
from glanceclient.common import progressbar from glanceclient.common import progressbar
from glanceclient.common import utils from glanceclient.common import utils
from glanceclient import exc from glanceclient import exc
from glanceclient.v2 import cache
from glanceclient.v2 import image_members from glanceclient.v2 import image_members
from glanceclient.v2 import image_schema from glanceclient.v2 import image_schema
from glanceclient.v2 import images from glanceclient.v2 import images
@ -1479,6 +1480,76 @@ def do_md_tag_list(gc, args):
utils.print_list(tags, columns, field_settings=column_settings) 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', @utils.arg('--sort-key', default='status',
choices=tasks.SORT_KEY_VALUES, choices=tasks.SORT_KEY_VALUES,
help=_('Sort task list by specified field.')) help=_('Sort task list by specified field.'))