Cache management API endpoints
This change adds the new cache API endpoints and their related new policies. Implements-bp: https://blueprints.launchpad.net/glance/+spec/cache-api Change-Id: I69162e19bf095ef11fbac56a1ea2159d1caefba7
This commit is contained in:
parent
4f4fc9b15d
commit
87eae327bf
84
api-ref/source/v2/cache-manage.inc
Normal file
84
api-ref/source/v2/cache-manage.inc
Normal file
@ -0,0 +1,84 @@
|
||||
.. -*- rst -*-
|
||||
|
||||
Cache Manage
|
||||
************
|
||||
|
||||
List and manage the cache.
|
||||
|
||||
|
||||
Query cache status
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. rest_method:: GET /v2/cache/
|
||||
|
||||
Lists all images in cache or queue.
|
||||
*(Since Image API v2.14)*
|
||||
|
||||
Normal response codes: 200
|
||||
|
||||
Error response codes: 400, 401, 403
|
||||
|
||||
|
||||
Request
|
||||
-------
|
||||
|
||||
No request parameters.
|
||||
|
||||
|
||||
Queue image
|
||||
~~~~~~~~~~~
|
||||
|
||||
.. rest_method:: PUT /v2/cache/{image_id}/
|
||||
|
||||
Queues image for caching.
|
||||
*(Since Image API v2.14)*
|
||||
|
||||
Normal response codes: 200
|
||||
|
||||
Error response codes: 400, 401, 403, 404
|
||||
|
||||
|
||||
Request
|
||||
-------
|
||||
|
||||
- image_id: image_id-in-path
|
||||
|
||||
|
||||
Delete image from cache
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. rest_method:: DELETE /v2/cache/{image_id}/
|
||||
|
||||
Deletes a image from cache.
|
||||
*(Since Image API v2.14)*
|
||||
|
||||
Normal response codes: 204
|
||||
|
||||
Error response codes: 400, 401, 403, 404
|
||||
|
||||
|
||||
Request
|
||||
-------
|
||||
|
||||
- image_id: image_id-in-path
|
||||
|
||||
|
||||
Clear images from cache
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. rest_method:: DELETE /v2/cache/
|
||||
|
||||
Clears the cache and its queue.
|
||||
*(Since Image API v2.14)*
|
||||
|
||||
Normal response codes: 204
|
||||
|
||||
Error response codes: 400, 401, 403
|
||||
|
||||
|
||||
Request
|
||||
-------
|
||||
|
||||
.. rest_parameters:: images-parameters.yaml
|
||||
|
||||
- x-image-cache-clear-target: cache-clear-header
|
@ -1,4 +1,12 @@
|
||||
# variables in header
|
||||
cache-clear-header:
|
||||
description: |
|
||||
A keyword indicating 'cache', 'queue' or empty string to indicate the delete
|
||||
API to delete images from cache or queue or delete from both. If this header
|
||||
is missing then all cached and queued images for caching will be deleted.
|
||||
in: header
|
||||
required: false
|
||||
type: string
|
||||
Content-Length:
|
||||
description: |
|
||||
The length of the body in octets (8-bit bytes)
|
||||
|
@ -33,3 +33,4 @@ Image Service API v2 (CURRENT)
|
||||
.. include:: discovery.inc
|
||||
.. include:: tasks.inc
|
||||
.. include:: tasks-schemas.inc
|
||||
.. include:: cache-manage.inc
|
||||
|
@ -1390,8 +1390,8 @@ configuration file, select the appropriate deployment flavor like so::
|
||||
[paste_deploy]
|
||||
flavor = caching
|
||||
|
||||
Enabling the Image Cache Management Middleware
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Enabling the Image Cache Management Middleware (DEPRECATED)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
There is an optional ``cachemanage`` middleware that allows you to
|
||||
directly interact with cache images. Use this flavor in place of the
|
||||
@ -1402,6 +1402,11 @@ can chose: ``cachemanagement``, ``keystone+cachemanagement`` and
|
||||
[paste_deploy]
|
||||
flavor = keystone+cachemanagement
|
||||
|
||||
The new cache management endpoints were introduced in Images API v. 2.13.
|
||||
If cache middleware is configured the new endpoints will be active and
|
||||
there is no need to use the cachemanagement middleware unless the old
|
||||
`glance-cache-manage` tooling is desired to be still used.
|
||||
|
||||
Configuration Options Affecting the Image Cache
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
@ -83,6 +83,8 @@ class VersionNegotiationFilter(wsgi.Middleware):
|
||||
allowed_versions['v2.7'] = 2
|
||||
allowed_versions['v2.9'] = 2
|
||||
allowed_versions['v2.13'] = 2
|
||||
if CONF.image_cache_dir:
|
||||
allowed_versions['v2.14'] = 2
|
||||
if CONF.enabled_backends:
|
||||
allowed_versions['v2.8'] = 2
|
||||
allowed_versions['v2.10'] = 2
|
||||
|
@ -17,6 +17,8 @@
|
||||
Controller for Image Cache Management API
|
||||
"""
|
||||
|
||||
import glance_store
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log as logging
|
||||
import webob.exc
|
||||
|
||||
@ -24,8 +26,14 @@ from glance.api import policy
|
||||
from glance.api.v2 import policy as api_policy
|
||||
from glance.common import exception
|
||||
from glance.common import wsgi
|
||||
import glance.db
|
||||
import glance.gateway
|
||||
from glance.i18n import _
|
||||
from glance import image_cache
|
||||
import glance.notifier
|
||||
|
||||
|
||||
CONF = cfg.CONF
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -34,19 +42,36 @@ class CacheController(object):
|
||||
A controller for managing cached images.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, db_api=None, policy_enforcer=None, notifier=None,
|
||||
store_api=None):
|
||||
if not CONF.image_cache_dir:
|
||||
self.cache = None
|
||||
else:
|
||||
self.cache = image_cache.ImageCache()
|
||||
self.policy = policy.Enforcer()
|
||||
|
||||
def _enforce(self, req):
|
||||
"""Authorize request against 'manage_image_cache' policy"""
|
||||
self.policy = policy_enforcer or policy.Enforcer()
|
||||
self.db_api = db_api or glance.db.get_api()
|
||||
self.notifier = notifier or glance.notifier.Notifier()
|
||||
self.store_api = store_api or glance_store
|
||||
self.gateway = glance.gateway.Gateway(self.db_api, self.store_api,
|
||||
self.notifier, self.policy)
|
||||
|
||||
def _enforce(self, req, image=None, new_policy=None):
|
||||
"""Authorize request against given policy"""
|
||||
if not new_policy:
|
||||
new_policy = 'manage_image_cache'
|
||||
try:
|
||||
api_policy.CacheImageAPIPolicy(
|
||||
req.context, enforcer=self.policy).manage_image_cache()
|
||||
req.context, image=image, enforcer=self.policy,
|
||||
policy_str=new_policy).manage_image_cache()
|
||||
except exception.Forbidden:
|
||||
LOG.debug("User not permitted to manage the image cache")
|
||||
LOG.debug("User not permitted by '%s' policy" % new_policy)
|
||||
raise webob.exc.HTTPForbidden()
|
||||
|
||||
if not CONF.image_cache_dir:
|
||||
msg = _("Caching via API is not supported at this site.")
|
||||
raise webob.exc.HTTPNotFound(explanation=msg)
|
||||
|
||||
def get_cached_images(self, req):
|
||||
"""
|
||||
GET /cached_images
|
||||
@ -114,6 +139,99 @@ class CacheController(object):
|
||||
self._enforce(req)
|
||||
return dict(num_deleted=self.cache.delete_all_queued_images())
|
||||
|
||||
def delete_cache_entry(self, req, image_id):
|
||||
"""
|
||||
DELETE /cache/<IMAGE_ID> - Remove image from cache
|
||||
|
||||
Removes the image from cache or queue.
|
||||
"""
|
||||
image_repo = self.gateway.get_repo(
|
||||
req.context, authorization_layer=False)
|
||||
try:
|
||||
image = image_repo.get(image_id)
|
||||
except exception.NotFound:
|
||||
# We are going to raise this error only if image is
|
||||
# not present in cache or queue list
|
||||
image = None
|
||||
if not self.image_exists_in_cache(image_id):
|
||||
msg = _("Image %s not found.") % image_id
|
||||
LOG.warning(msg)
|
||||
raise webob.exc.HTTPNotFound(explanation=msg)
|
||||
|
||||
self._enforce(req, new_policy='cache_delete', image=image)
|
||||
self.cache.delete_cached_image(image_id)
|
||||
self.cache.delete_queued_image(image_id)
|
||||
|
||||
def image_exists_in_cache(self, image_id):
|
||||
queued_images = self.cache.get_queued_images()
|
||||
if image_id in queued_images:
|
||||
return True
|
||||
|
||||
cached_images = self.cache.get_cached_images()
|
||||
if image_id in [image['image_id'] for image in cached_images]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clear_cache(self, req):
|
||||
"""
|
||||
DELETE /cache - Clear cache and queue
|
||||
|
||||
Removes all images from cache and queue.
|
||||
"""
|
||||
self._enforce(req, new_policy='cache_delete')
|
||||
target = req.headers.get('x-image-cache-clear-target', '').lower()
|
||||
if target == '':
|
||||
res = dict(cache_deleted=self.cache.delete_all_cached_images(),
|
||||
queue_deleted=self.cache.delete_all_queued_images())
|
||||
elif target == 'cache':
|
||||
res = dict(cache_deleted=self.cache.delete_all_cached_images())
|
||||
elif target == 'queue':
|
||||
res = dict(queue_deleted=self.cache.delete_all_queued_images())
|
||||
else:
|
||||
reason = (_("If provided 'x-image-cache-clear-target' must be "
|
||||
"'cache', 'queue' or empty string."))
|
||||
raise webob.exc.HTTPBadRequest(explanation=reason,
|
||||
request=req,
|
||||
content_type='text/plain')
|
||||
return res
|
||||
|
||||
def get_cache_state(self, req):
|
||||
"""
|
||||
GET /cache/ - Get currently cached and queued images
|
||||
|
||||
Returns dict of cached and queued images
|
||||
"""
|
||||
self._enforce(req, new_policy='cache_list')
|
||||
return dict(cached_images=self.cache.get_cached_images(),
|
||||
queued_images=self.cache.get_queued_images())
|
||||
|
||||
def queue_image_from_api(self, req, image_id):
|
||||
"""
|
||||
PUT /cache/<IMAGE_ID>
|
||||
|
||||
Queues an image for caching. We do not check to see if
|
||||
the image is in the registry here. That is done by the
|
||||
prefetcher...
|
||||
"""
|
||||
image_repo = self.gateway.get_repo(
|
||||
req.context, authorization_layer=False)
|
||||
try:
|
||||
image = image_repo.get(image_id)
|
||||
except exception.NotFound:
|
||||
msg = _("Image %s not found.") % image_id
|
||||
LOG.warning(msg)
|
||||
raise webob.exc.HTTPNotFound(explanation=msg)
|
||||
|
||||
self._enforce(req, new_policy='cache_image', image=image)
|
||||
|
||||
if image.status != 'active':
|
||||
msg = _("Only images with status active can be targeted for "
|
||||
"queueing")
|
||||
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||
|
||||
self.cache.queue_image(image_id)
|
||||
|
||||
|
||||
class CachedImageDeserializer(wsgi.JSONRequestDeserializer):
|
||||
pass
|
||||
|
@ -105,14 +105,21 @@ class APIPolicyBase(object):
|
||||
|
||||
|
||||
class CacheImageAPIPolicy(APIPolicyBase):
|
||||
def __init__(self, context, target=None, enforcer=None):
|
||||
def __init__(self, context, image=None, policy_str=None,
|
||||
target=None, enforcer=None):
|
||||
self._context = context
|
||||
self._target = target or {}
|
||||
target = {}
|
||||
self._image = image
|
||||
if self._image:
|
||||
target = policy.ImageTarget(self._image)
|
||||
|
||||
self._target = target
|
||||
self.enforcer = enforcer or policy.Enforcer()
|
||||
self.policy_str = policy_str
|
||||
super(CacheImageAPIPolicy, self).__init__(context, target, enforcer)
|
||||
|
||||
def manage_image_cache(self):
|
||||
self._enforce('manage_image_cache')
|
||||
self._enforce(self.policy_str)
|
||||
|
||||
|
||||
class ImageAPIPolicy(APIPolicyBase):
|
||||
|
@ -13,6 +13,7 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from glance.api.v2 import cached_images
|
||||
from glance.api.v2 import discovery
|
||||
from glance.api.v2 import image_actions
|
||||
from glance.api.v2 import image_data
|
||||
@ -593,4 +594,32 @@ class API(wsgi.Router):
|
||||
action='get_usage',
|
||||
conditions={'method': ['GET']})
|
||||
|
||||
# Cache Management API
|
||||
cache_manage_resource = cached_images.create_resource()
|
||||
mapper.connect('/cache',
|
||||
controller=cache_manage_resource,
|
||||
action='get_cache_state',
|
||||
conditions={'method': ['GET']},
|
||||
body_reject=True)
|
||||
mapper.connect('/cache',
|
||||
controller=cache_manage_resource,
|
||||
action='clear_cache',
|
||||
conditions={'method': ['DELETE']})
|
||||
mapper.connect('/cache',
|
||||
controller=reject_method_resource,
|
||||
action='reject',
|
||||
allowed_methods='GET, DELETE')
|
||||
mapper.connect('/cache/{image_id}',
|
||||
controller=cache_manage_resource,
|
||||
action='delete_cache_entry',
|
||||
conditions={'method': ['DELETE']})
|
||||
mapper.connect('/cache/{image_id}',
|
||||
controller=cache_manage_resource,
|
||||
action='queue_image_from_api',
|
||||
conditions={'method': ['PUT']})
|
||||
mapper.connect('/cache/{image_id}',
|
||||
controller=reject_method_resource,
|
||||
action='reject',
|
||||
allowed_methods='DELETE, PUT')
|
||||
|
||||
super(API, self).__init__(mapper)
|
||||
|
@ -77,6 +77,15 @@ class Controller(object):
|
||||
}
|
||||
|
||||
version_objs = []
|
||||
if CONF.image_cache_dir:
|
||||
version_objs.extend([
|
||||
build_version_object(2.14, 'v2', 'CURRENT'),
|
||||
build_version_object(2.13, 'v2', 'SUPPORTED'),
|
||||
])
|
||||
else:
|
||||
version_objs.extend([
|
||||
build_version_object(2.13, 'v2', 'CURRENT'),
|
||||
])
|
||||
if CONF.enabled_backends:
|
||||
version_objs.extend([
|
||||
build_version_object(2.12, 'v2', 'SUPPORTED'),
|
||||
@ -90,7 +99,6 @@ class Controller(object):
|
||||
build_version_object(2.9, 'v2', 'SUPPORTED'),
|
||||
])
|
||||
version_objs.extend([
|
||||
build_version_object(2.13, 'v2', 'CURRENT'),
|
||||
build_version_object(2.7, 'v2', 'SUPPORTED'),
|
||||
build_version_object(2.6, 'v2', 'SUPPORTED'),
|
||||
build_version_object(2.5, 'v2', 'SUPPORTED'),
|
||||
|
@ -13,6 +13,7 @@
|
||||
import itertools
|
||||
|
||||
from glance.policies import base
|
||||
from glance.policies import cache
|
||||
from glance.policies import image
|
||||
from glance.policies import metadef
|
||||
from glance.policies import tasks
|
||||
@ -24,4 +25,5 @@ def list_rules():
|
||||
image.list_rules(),
|
||||
tasks.list_rules(),
|
||||
metadef.list_rules(),
|
||||
cache.list_rules(),
|
||||
)
|
||||
|
@ -90,6 +90,7 @@ ADMIN_OR_PROJECT_READER_OR_SHARED_MEMBER = (
|
||||
f'role:reader and (project_id:%(project_id)s or {IMAGE_MEMBER_CHECK})'
|
||||
)
|
||||
|
||||
ADMIN = f'role:admin'
|
||||
|
||||
rules = [
|
||||
policy.RuleDefault(name='default', check_str='',
|
||||
|
75
glance/policies/cache.py
Normal file
75
glance/policies/cache.py
Normal file
@ -0,0 +1,75 @@
|
||||
# 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 oslo_log import versionutils
|
||||
from oslo_policy import policy
|
||||
|
||||
from glance.policies import base
|
||||
|
||||
|
||||
DEPRECATED_REASON = """
|
||||
The image API now supports roles.
|
||||
"""
|
||||
|
||||
|
||||
cache_policies = [
|
||||
policy.DocumentedRuleDefault(
|
||||
name="cache_image",
|
||||
check_str=base.ADMIN,
|
||||
scope_types=['project'],
|
||||
description='Queue image for caching',
|
||||
operations=[
|
||||
{'path': '/v2/cache/{image_id}',
|
||||
'method': 'PUT'}
|
||||
],
|
||||
deprecated_rule=policy.DeprecatedRule(
|
||||
name="cache_image", check_str="rule:manage_image_cache",
|
||||
deprecated_reason=DEPRECATED_REASON,
|
||||
deprecated_since=versionutils.deprecated.XENA
|
||||
),
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name="cache_list",
|
||||
check_str=base.ADMIN,
|
||||
scope_types=['project'],
|
||||
description='List cache status',
|
||||
operations=[
|
||||
{'path': '/v2/cache',
|
||||
'method': 'GET'}
|
||||
],
|
||||
deprecated_rule=policy.DeprecatedRule(
|
||||
name="cache_list", check_str="rule:manage_image_cache",
|
||||
deprecated_reason=DEPRECATED_REASON,
|
||||
deprecated_since=versionutils.deprecated.XENA
|
||||
),
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name="cache_delete",
|
||||
check_str=base.ADMIN,
|
||||
scope_types=['project'],
|
||||
description='Delete image(s) from cache and/or queue',
|
||||
operations=[
|
||||
{'path': '/v2/cache',
|
||||
'method': 'DELETE'},
|
||||
{'path': '/v2/cache/{image_id}',
|
||||
'method': 'DELETE'}
|
||||
],
|
||||
deprecated_rule=policy.DeprecatedRule(
|
||||
name="cache_delete", check_str="rule:manage_image_cache",
|
||||
deprecated_reason=DEPRECATED_REASON,
|
||||
deprecated_since=versionutils.deprecated.XENA
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def list_rules():
|
||||
return cache_policies
|
@ -1550,6 +1550,8 @@ class SynchronousAPIBase(test_utils.BaseTestCase):
|
||||
CacheManageFilter.factory
|
||||
[pipeline:glance-api-cachemanagement]
|
||||
pipeline = context cache cachemanage rootapp
|
||||
[pipeline:glance-api-caching]
|
||||
pipeline = context cache rootapp
|
||||
[pipeline:glance-api]
|
||||
pipeline = context rootapp
|
||||
[composite:rootapp]
|
||||
|
@ -30,7 +30,8 @@ class TestApiVersions(functional.FunctionalTest):
|
||||
self.start_servers(**self.__dict__.copy())
|
||||
|
||||
url = 'http://127.0.0.1:%d' % self.api_port
|
||||
versions = {'versions': tv.get_versions_list(url)}
|
||||
versions = {'versions': tv.get_versions_list(url,
|
||||
enabled_cache=True)}
|
||||
|
||||
# Verify version choices returned.
|
||||
path = 'http://%s:%d' % ('127.0.0.1', self.api_port)
|
||||
@ -44,7 +45,8 @@ class TestApiVersions(functional.FunctionalTest):
|
||||
self.start_servers(**self.__dict__.copy())
|
||||
|
||||
url = 'http://127.0.0.1:%d' % self.api_port
|
||||
versions = {'versions': tv.get_versions_list(url)}
|
||||
versions = {'versions': tv.get_versions_list(url,
|
||||
enabled_cache=True)}
|
||||
|
||||
# Verify version choices returned.
|
||||
path = 'http://%s:%d' % ('127.0.0.1', self.api_port)
|
||||
@ -62,7 +64,8 @@ class TestApiVersionsMultistore(functional.MultipleBackendFunctionalTest):
|
||||
|
||||
url = 'http://127.0.0.1:%d' % self.api_port
|
||||
versions = {'versions': tv.get_versions_list(url,
|
||||
enabled_backends=True)}
|
||||
enabled_backends=True,
|
||||
enabled_cache=True)}
|
||||
|
||||
# Verify version choices returned.
|
||||
path = 'http://%s:%d' % ('127.0.0.1', self.api_port)
|
||||
@ -77,7 +80,8 @@ class TestApiVersionsMultistore(functional.MultipleBackendFunctionalTest):
|
||||
|
||||
url = 'http://127.0.0.1:%d' % self.api_port
|
||||
versions = {'versions': tv.get_versions_list(url,
|
||||
enabled_backends=True)}
|
||||
enabled_backends=True,
|
||||
enabled_cache=True)}
|
||||
|
||||
# Verify version choices returned.
|
||||
path = 'http://%s:%d' % ('127.0.0.1', self.api_port)
|
||||
@ -94,7 +98,8 @@ class TestApiPaths(functional.FunctionalTest):
|
||||
self.start_servers(**self.__dict__.copy())
|
||||
|
||||
url = 'http://127.0.0.1:%d' % self.api_port
|
||||
self.versions = {'versions': tv.get_versions_list(url)}
|
||||
self.versions = {'versions': tv.get_versions_list(url,
|
||||
enabled_cache=True)}
|
||||
images = {'images': []}
|
||||
self.images_json = jsonutils.dumps(images)
|
||||
|
||||
|
360
glance/tests/functional/v2/test_cache_api.py
Normal file
360
glance/tests/functional/v2/test_cache_api.py
Normal file
@ -0,0 +1,360 @@
|
||||
# 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.
|
||||
from unittest import mock
|
||||
|
||||
import oslo_policy.policy
|
||||
|
||||
from glance.api import policy
|
||||
from glance.image_cache import prefetcher
|
||||
from glance.tests import functional
|
||||
|
||||
|
||||
class TestImageCache(functional.SynchronousAPIBase):
|
||||
# ToDo(abhishekk): Once system scope is enabled and RBAC is fully
|
||||
# supported, enable these tests for RBAC as well
|
||||
def setUp(self):
|
||||
super(TestImageCache, self).setUp()
|
||||
self.policy = policy.Enforcer(suppress_deprecation_warnings=True)
|
||||
|
||||
def set_policy_rules(self, rules):
|
||||
self.policy.set_rules(
|
||||
oslo_policy.policy.Rules.from_dict(rules),
|
||||
overwrite=True)
|
||||
|
||||
def start_server(self, enable_cache=True):
|
||||
with mock.patch.object(policy, 'Enforcer') as mock_enf:
|
||||
mock_enf.return_value = self.policy
|
||||
super(TestImageCache, self).start_server(enable_cache=enable_cache)
|
||||
|
||||
def load_data(self):
|
||||
output = {}
|
||||
# Create 1 queued image as well for testing
|
||||
path = "/v2/images"
|
||||
data = {
|
||||
'name': 'queued-image',
|
||||
'container_format': 'bare',
|
||||
'disk_format': 'raw'
|
||||
}
|
||||
response = self.api_post(path, json=data)
|
||||
self.assertEqual(201, response.status_code)
|
||||
image_id = response.json['id']
|
||||
output['queued'] = image_id
|
||||
|
||||
for visibility in ['public', 'private', 'community', 'shared']:
|
||||
data = {
|
||||
'name': '%s-image' % visibility,
|
||||
'visibility': visibility,
|
||||
'container_format': 'bare',
|
||||
'disk_format': 'raw'
|
||||
}
|
||||
response = self.api_post(path, json=data)
|
||||
self.assertEqual(201, response.status_code)
|
||||
image_id = response.json['id']
|
||||
# Upload some data to image
|
||||
response = self.api_put(
|
||||
'/v2/images/%s/file' % image_id,
|
||||
headers={'Content-Type': 'application/octet-stream'},
|
||||
data=b'IMAGEDATA')
|
||||
self.assertEqual(204, response.status_code)
|
||||
output[visibility] = image_id
|
||||
|
||||
return output
|
||||
|
||||
def list_cache(self, expected_code=200):
|
||||
path = '/v2/cache'
|
||||
response = self.api_get(path)
|
||||
self.assertEqual(expected_code, response.status_code)
|
||||
if response.status_code == 200:
|
||||
return response.json
|
||||
|
||||
def cache_queue(self, image_id, expected_code=200):
|
||||
# Queue image for prefetching
|
||||
path = '/v2/cache/%s' % image_id
|
||||
response = self.api_put(path)
|
||||
self.assertEqual(expected_code, response.status_code)
|
||||
|
||||
def cache_delete(self, image_id, expected_code=200):
|
||||
path = '/v2/cache/%s' % image_id
|
||||
response = self.api_delete(path)
|
||||
self.assertEqual(expected_code, response.status_code)
|
||||
|
||||
def cache_clear(self, target='', expected_code=200):
|
||||
path = '/v2/cache'
|
||||
headers = {}
|
||||
if target:
|
||||
headers['x-image-cache-clear-target'] = target
|
||||
response = self.api_delete(path, headers=headers)
|
||||
if target not in ('', 'cache', 'queue'):
|
||||
self.assertEqual(expected_code, response.status_code)
|
||||
else:
|
||||
self.assertEqual(expected_code, response.status_code)
|
||||
|
||||
def cache_image(self):
|
||||
# NOTE(abhishekk): Here we are not running periodic job which caches
|
||||
# queued images as precaching is not part of this patch, so to test
|
||||
# all caching operations we are using this way to cache images for us
|
||||
cache_prefetcher = prefetcher.Prefetcher()
|
||||
cache_prefetcher.run()
|
||||
|
||||
def test_cache_api_lifecycle(self):
|
||||
self.start_server(enable_cache=True)
|
||||
images = self.load_data()
|
||||
|
||||
# Ensure that nothing is cached and nothing is queued for caching
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
|
||||
# Try non-existing image to queue for caching
|
||||
self.cache_queue('non-existing-image-id', expected_code=404)
|
||||
|
||||
# Verify that you can not queue non-active image
|
||||
self.cache_queue(images['queued'], expected_code=400)
|
||||
|
||||
# Queue 1 image for caching
|
||||
self.cache_queue(images['public'])
|
||||
# Now verify that we have 1 image queued for caching and 0
|
||||
# cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(1, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
# Verify same image is queued for caching
|
||||
self.assertIn(images['public'], output['queued_images'])
|
||||
|
||||
# Cache the image
|
||||
self.cache_image()
|
||||
# Now verify that we have 0 queued image and 1 cached image
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(1, len(output['cached_images']))
|
||||
# Verify same image is queued for caching
|
||||
self.assertIn(images['public'], output['cached_images'][0]['image_id'])
|
||||
|
||||
# Queue 2nd image for caching
|
||||
self.cache_queue(images['community'])
|
||||
# Now verify that we have 1 image queued for caching and 1
|
||||
# cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(1, len(output['queued_images']))
|
||||
self.assertEqual(1, len(output['cached_images']))
|
||||
# Verify same image is queued for caching
|
||||
self.assertIn(images['community'], output['queued_images'])
|
||||
self.assertIn(images['public'], output['cached_images'][0]['image_id'])
|
||||
|
||||
# Queue 3rd image for caching
|
||||
self.cache_queue(images['private'])
|
||||
# Now verify that we have 2 images queued for caching and 1
|
||||
# cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(2, len(output['queued_images']))
|
||||
self.assertEqual(1, len(output['cached_images']))
|
||||
# Verify same image is queued for caching
|
||||
self.assertIn(images['private'], output['queued_images'])
|
||||
|
||||
# Try to delete non-existing image from cache
|
||||
self.cache_delete('non-existing-image-id', expected_code=404)
|
||||
|
||||
# Delete public image from cache
|
||||
self.cache_delete(images['public'])
|
||||
# Now verify that we have 2 image queued for caching and no
|
||||
# cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(2, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
|
||||
# Verify clearing cache fails with 400 if invalid header is passed
|
||||
self.cache_clear(target='both', expected_code=400)
|
||||
|
||||
# Delete all queued images
|
||||
self.cache_clear(target='queue')
|
||||
# Now verify that we have 0 image queued for caching and 0
|
||||
# cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
|
||||
# Queue and cache image so we have something to clear
|
||||
self.cache_queue(images['public'])
|
||||
# Now verify that we have 1 queued image
|
||||
output = self.list_cache()
|
||||
self.assertEqual(1, len(output['queued_images']))
|
||||
self.cache_image()
|
||||
# Now verify that we have 0 queued image and 1 cached image
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(1, len(output['cached_images']))
|
||||
|
||||
# Delete all cached images
|
||||
self.cache_clear(target='cache')
|
||||
# Now verify that we have 0 image queued for caching and 0
|
||||
# cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
|
||||
# Now we need 2 queued images and 2 cached images in order
|
||||
# to delete both of them together
|
||||
self.cache_queue(images['public'])
|
||||
self.cache_queue(images['private'])
|
||||
# Now verify that we have 2 queued images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(2, len(output['queued_images']))
|
||||
|
||||
self.cache_image()
|
||||
# Now verify that we have 0 queued images and 2 cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(2, len(output['cached_images']))
|
||||
|
||||
self.cache_queue(images['community'])
|
||||
self.cache_queue(images['shared'])
|
||||
# Verify we have 2 queued and 2 cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(2, len(output['queued_images']))
|
||||
self.assertEqual(2, len(output['cached_images']))
|
||||
|
||||
# Now delete all queued and all cached images at once
|
||||
self.cache_clear()
|
||||
# Now verify that we have 0 image queued for caching and 0
|
||||
# cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
|
||||
# Try to cache image again to validate nothing will be cached
|
||||
self.cache_image()
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
|
||||
def test_cache_image_queue_delete(self):
|
||||
# This test verifies that if image is queued for caching
|
||||
# and user deletes the original image, but it is still
|
||||
# present in queued list and deleted with cache-delete API.
|
||||
self.start_server(enable_cache=True)
|
||||
images = self.load_data()
|
||||
|
||||
# Ensure that nothing is cached and nothing is queued for caching
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
|
||||
self.cache_queue(images['public'])
|
||||
# Now verify that we have 1 image queued for caching and 0
|
||||
# cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(1, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
# Verify same image is queued for caching
|
||||
self.assertIn(images['public'], output['queued_images'])
|
||||
|
||||
# Delete image and verify that it is still present
|
||||
# in queued list
|
||||
path = '/v2/images/%s' % images['public']
|
||||
response = self.api_delete(path)
|
||||
self.assertEqual(204, response.status_code)
|
||||
|
||||
output = self.list_cache()
|
||||
self.assertEqual(1, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
self.assertIn(images['public'], output['queued_images'])
|
||||
|
||||
# Deleted the image from queued list
|
||||
self.cache_delete(images['public'])
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
|
||||
def test_cache_image_cache_delete(self):
|
||||
# This test verifies that if image is queued for caching
|
||||
# and user deletes the original image, but it is still
|
||||
# present in queued list and deleted with cache-delete API.
|
||||
self.start_server(enable_cache=True)
|
||||
images = self.load_data()
|
||||
|
||||
# Ensure that nothing is cached and nothing is queued for caching
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
|
||||
self.cache_queue(images['public'])
|
||||
# Now verify that we have 1 image queued for caching and 0
|
||||
# cached images
|
||||
output = self.list_cache()
|
||||
self.assertEqual(1, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
# Verify same image is queued for caching
|
||||
self.assertIn(images['public'], output['queued_images'])
|
||||
|
||||
# Cache the image
|
||||
self.cache_image()
|
||||
# Now verify that we have 0 queued image and 1 cached image
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(1, len(output['cached_images']))
|
||||
# Verify same image is queued for caching
|
||||
self.assertIn(images['public'], output['cached_images'][0]['image_id'])
|
||||
|
||||
# Delete image and verify that it is deleted from
|
||||
# cache as well
|
||||
path = '/v2/images/%s' % images['public']
|
||||
response = self.api_delete(path)
|
||||
self.assertEqual(204, response.status_code)
|
||||
|
||||
output = self.list_cache()
|
||||
self.assertEqual(0, len(output['queued_images']))
|
||||
self.assertEqual(0, len(output['cached_images']))
|
||||
|
||||
def test_cache_api_cache_disabled(self):
|
||||
self.start_server(enable_cache=False)
|
||||
images = self.load_data()
|
||||
# As cache is not enabled each API call should return 404 response
|
||||
self.list_cache(expected_code=404)
|
||||
self.cache_queue(images['public'], expected_code=404)
|
||||
self.cache_delete(images['public'], expected_code=404)
|
||||
self.cache_clear(expected_code=404)
|
||||
self.cache_clear(target='both', expected_code=404)
|
||||
|
||||
# Now disable cache policies and ensure that you will get 403
|
||||
self.set_policy_rules({
|
||||
'cache_list': '!',
|
||||
'cache_delete': '!',
|
||||
'cache_image': '!',
|
||||
'add_image': '',
|
||||
'upload_image': ''
|
||||
})
|
||||
self.list_cache(expected_code=403)
|
||||
self.cache_queue(images['public'], expected_code=403)
|
||||
self.cache_delete(images['public'], expected_code=403)
|
||||
self.cache_clear(expected_code=403)
|
||||
self.cache_clear(target='both', expected_code=403)
|
||||
|
||||
def test_cache_api_not_allowed(self):
|
||||
self.start_server(enable_cache=True)
|
||||
images = self.load_data()
|
||||
# As cache operations are not allowed each API call should return
|
||||
# 403 response
|
||||
self.set_policy_rules({
|
||||
'cache_list': '!',
|
||||
'cache_delete': '!',
|
||||
'cache_image': '!',
|
||||
'add_image': '',
|
||||
'upload_image': ''
|
||||
})
|
||||
self.list_cache(expected_code=403)
|
||||
self.cache_queue(images['public'], expected_code=403)
|
||||
self.cache_delete(images['public'], expected_code=403)
|
||||
self.cache_clear(expected_code=403)
|
||||
self.cache_clear(target='both', expected_code=403)
|
@ -13,30 +13,51 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import testtools
|
||||
from unittest import mock
|
||||
|
||||
import webob
|
||||
|
||||
from glance.api import policy
|
||||
from glance.api.v2 import cached_images
|
||||
from glance.common import exception
|
||||
import glance.gateway
|
||||
from glance import image_cache
|
||||
from glance import notifier
|
||||
import glance.tests.unit.utils as unit_test_utils
|
||||
import glance.tests.utils as test_utils
|
||||
|
||||
|
||||
class FakePolicyEnforcer(policy.Enforcer):
|
||||
def __init__(self):
|
||||
self.default_rule = ''
|
||||
self.policy_path = ''
|
||||
self.policy_file_mtime = None
|
||||
self.policy_file_contents = None
|
||||
UUID4 = '6bbe7cc2-eae7-4c0f-b50d-a7160b0c6a86'
|
||||
|
||||
def enforce(self, context, action, target):
|
||||
return 'pass'
|
||||
|
||||
def check(rule, target, creds, exc=None, *args, **kwargs):
|
||||
return 'pass'
|
||||
class FakeImage(object):
|
||||
def __init__(self, id=None, status='active', container_format='ami',
|
||||
disk_format='ami', locations=None):
|
||||
self.id = id or UUID4
|
||||
self.status = status
|
||||
self.container_format = container_format
|
||||
self.disk_format = disk_format
|
||||
self.locations = locations
|
||||
self.owner = unit_test_utils.TENANT1
|
||||
self.created_at = ''
|
||||
self.updated_at = ''
|
||||
self.min_disk = ''
|
||||
self.min_ram = ''
|
||||
self.protected = False
|
||||
self.checksum = ''
|
||||
self.os_hash_algo = ''
|
||||
self.os_hash_value = ''
|
||||
self.size = 0
|
||||
self.virtual_size = 0
|
||||
self.visibility = 'public'
|
||||
self.os_hidden = False
|
||||
self.name = 'foo'
|
||||
self.tags = []
|
||||
self.extra_properties = {}
|
||||
self.member = self.owner
|
||||
|
||||
def _check(self, context, rule, target, *args, **kwargs):
|
||||
return 'pass'
|
||||
# NOTE(danms): This fixture looks more like the db object than
|
||||
# the proxy model. This needs fixing all through the tests
|
||||
# below.
|
||||
self.image_id = self.id
|
||||
|
||||
|
||||
class FakeCache(image_cache.ImageCache):
|
||||
@ -48,13 +69,14 @@ class FakeCache(image_cache.ImageCache):
|
||||
pass
|
||||
|
||||
def get_cached_images(self):
|
||||
return {'id': 'test'}
|
||||
return [{'image_id': 'test'}]
|
||||
|
||||
def delete_cached_image(self, image_id):
|
||||
self.deleted_images.append(image_id)
|
||||
|
||||
def delete_all_cached_images(self):
|
||||
self.delete_cached_image(self.get_cached_images().get('id'))
|
||||
self.delete_cached_image(
|
||||
self.get_cached_images()[0].get('image_id'))
|
||||
return 1
|
||||
|
||||
def get_queued_images(self):
|
||||
@ -74,72 +96,315 @@ class FakeCache(image_cache.ImageCache):
|
||||
class FakeController(cached_images.CacheController):
|
||||
def __init__(self):
|
||||
self.cache = FakeCache()
|
||||
self.policy = FakePolicyEnforcer()
|
||||
self.db = unit_test_utils.FakeDB(initialize=False)
|
||||
self.policy = unit_test_utils.FakePolicyEnforcer()
|
||||
self.notifier = unit_test_utils.FakeNotifier()
|
||||
self.store = unit_test_utils.FakeStoreAPI()
|
||||
self.gateway = glance.gateway.Gateway(self.db, self.store,
|
||||
self.notifier, self.policy)
|
||||
|
||||
|
||||
class TestController(testtools.TestCase):
|
||||
class TestController(test_utils.BaseTestCase):
|
||||
def test_initialization_without_conf(self):
|
||||
self.assertRaises(exception.BadDriverConfiguration,
|
||||
cached_images.CacheController)
|
||||
# NOTE(abhishekk): Since we are initializing cache driver only
|
||||
# if image_cache_dir is set, here we are checking that cache
|
||||
# object is None when it is not set
|
||||
caching_controller = cached_images.CacheController()
|
||||
self.assertIsNone(caching_controller.cache)
|
||||
|
||||
|
||||
class TestCachedImages(testtools.TestCase):
|
||||
class TestCachedImages(test_utils.BaseTestCase):
|
||||
def setUp(self):
|
||||
super(TestCachedImages, self).setUp()
|
||||
test_controller = FakeController()
|
||||
self.controller = test_controller
|
||||
|
||||
def test_get_cached_images(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
result = self.controller.get_cached_images(req)
|
||||
self.assertEqual({'cached_images': {'id': 'test'}}, result)
|
||||
self.assertEqual({'cached_images': [{'image_id': 'test'}]}, result)
|
||||
|
||||
def test_delete_cached_image(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.controller.delete_cached_image(req, image_id='test')
|
||||
self.assertEqual(['test'], self.controller.cache.deleted_images)
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.controller.delete_cached_image(req, image_id=UUID4)
|
||||
self.assertEqual([UUID4], self.controller.cache.deleted_images)
|
||||
|
||||
def test_delete_cached_images(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertEqual({'num_deleted': 1},
|
||||
self.controller.delete_cached_images(req))
|
||||
self.assertEqual(['test'], self.controller.cache.deleted_images)
|
||||
|
||||
def test_policy_enforce_forbidden(self):
|
||||
def fake_enforce(context, action, target):
|
||||
raise exception.Forbidden()
|
||||
|
||||
self.controller.policy.enforce = fake_enforce
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.get_cached_images, req)
|
||||
|
||||
def test_get_queued_images(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
result = self.controller.get_queued_images(req)
|
||||
self.assertEqual({'queued_images': {'test': 'passed'}}, result)
|
||||
|
||||
def test_queue_image(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.controller.queue_image(req, image_id='test1')
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.controller.queue_image(req, image_id=UUID4)
|
||||
|
||||
def test_delete_queued_image(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.controller.delete_queued_image(req, 'deleted_img')
|
||||
self.assertEqual(['deleted_img'],
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.controller.delete_queued_image(req, UUID4)
|
||||
self.assertEqual([UUID4],
|
||||
self.controller.cache.deleted_images)
|
||||
|
||||
def test_delete_queued_images(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertEqual({'num_deleted': 1},
|
||||
self.controller.delete_queued_images(req))
|
||||
self.assertEqual(['deleted_img'],
|
||||
self.controller.cache.deleted_images)
|
||||
|
||||
|
||||
class TestCachedImagesNegative(test_utils.BaseTestCase):
|
||||
def setUp(self):
|
||||
super(TestCachedImagesNegative, self).setUp()
|
||||
test_controller = FakeController()
|
||||
self.controller = test_controller
|
||||
|
||||
def test_get_cached_images_disabled(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.get_cached_images, req)
|
||||
|
||||
def test_get_cached_images_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"manage_image_cache": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.get_cached_images,
|
||||
req)
|
||||
|
||||
def test_delete_cached_image_disabled(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.delete_cached_image, req,
|
||||
image_id='test')
|
||||
|
||||
def test_delete_cached_image_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"manage_image_cache": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.delete_cached_image,
|
||||
req, image_id=UUID4)
|
||||
|
||||
def test_delete_cached_images_disabled(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.delete_cached_images, req)
|
||||
|
||||
def test_delete_cached_images_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"manage_image_cache": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.delete_cached_images,
|
||||
req)
|
||||
|
||||
def test_get_queued_images_disabled(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.get_queued_images, req)
|
||||
|
||||
def test_get_queued_images_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"manage_image_cache": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.get_queued_images,
|
||||
req)
|
||||
|
||||
def test_queue_image_disabled(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.queue_image,
|
||||
req, image_id='test1')
|
||||
|
||||
def test_queue_image_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"manage_image_cache": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.queue_image,
|
||||
req, image_id=UUID4)
|
||||
|
||||
def test_delete_queued_image_disabled(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.delete_queued_image,
|
||||
req, image_id='test1')
|
||||
|
||||
def test_delete_queued_image_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"manage_image_cache": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.delete_queued_image,
|
||||
req, image_id=UUID4)
|
||||
|
||||
def test_delete_queued_images_disabled(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.delete_queued_images, req)
|
||||
|
||||
def test_delete_queued_images_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"manage_image_cache": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.delete_queued_images,
|
||||
req)
|
||||
|
||||
def test_delete_cache_entry_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"cache_delete": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.delete_cache_entry,
|
||||
req, image_id=UUID4)
|
||||
|
||||
def test_delete_cache_entry_disabled(self):
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.delete_cache_entry,
|
||||
req, image_id=UUID4)
|
||||
|
||||
def test_delete_non_existing_cache_entries(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = unit_test_utils.get_fake_request()
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.delete_cache_entry,
|
||||
req, image_id='non-existing-queued-image')
|
||||
|
||||
def test_clear_cache_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"cache_delete": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.clear_cache,
|
||||
req)
|
||||
|
||||
def test_clear_cache_disabled(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.clear_cache, req)
|
||||
|
||||
def test_cache_clear_invalid_target(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = unit_test_utils.get_fake_request()
|
||||
req.headers.update({'x-image-cache-clear-target': 'invalid'})
|
||||
self.assertRaises(webob.exc.HTTPBadRequest,
|
||||
self.controller.clear_cache,
|
||||
req)
|
||||
|
||||
def test_get_cache_state_disabled(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.get_cache_state, req)
|
||||
|
||||
def test_get_cache_state_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"cache_list": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.get_cache_state,
|
||||
req)
|
||||
|
||||
def test_queue_image_from_api_disabled(self):
|
||||
req = webob.Request.blank('')
|
||||
req.context = 'test'
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.queue_image_from_api,
|
||||
req, image_id='test1')
|
||||
|
||||
def test_queue_image_from_api_forbidden(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
self.controller.policy.rules = {"cache_image": False}
|
||||
req = unit_test_utils.get_fake_request()
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage()
|
||||
self.assertRaises(webob.exc.HTTPForbidden,
|
||||
self.controller.queue_image_from_api,
|
||||
req, image_id=UUID4)
|
||||
|
||||
def test_non_active_image_for_queue_api(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = unit_test_utils.get_fake_request()
|
||||
for status in ('saving', 'queued', 'pending_delete',
|
||||
'deactivated', 'importing', 'uploading'):
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
mock_get.return_value = FakeImage(status=status)
|
||||
self.assertRaises(webob.exc.HTTPBadRequest,
|
||||
self.controller.queue_image_from_api,
|
||||
req, image_id=UUID4)
|
||||
|
||||
def test_queue_api_non_existing_image_(self):
|
||||
self.config(image_cache_dir='fake_cache_directory')
|
||||
req = unit_test_utils.get_fake_request()
|
||||
self.assertRaises(webob.exc.HTTPNotFound,
|
||||
self.controller.queue_image_from_api,
|
||||
req, image_id='non-existing-image-id')
|
||||
|
@ -28,7 +28,8 @@ from glance.tests.unit import base
|
||||
|
||||
# make this public so it doesn't need to be repeated for the
|
||||
# functional tests
|
||||
def get_versions_list(url, enabled_backends=False):
|
||||
def get_versions_list(url, enabled_backends=False,
|
||||
enabled_cache=False):
|
||||
image_versions = [
|
||||
{
|
||||
'id': 'v2.13',
|
||||
@ -36,6 +37,12 @@ def get_versions_list(url, enabled_backends=False):
|
||||
'links': [{'rel': 'self',
|
||||
'href': '%s/v2/' % url}],
|
||||
},
|
||||
{
|
||||
'id': 'v2.9',
|
||||
'status': 'SUPPORTED',
|
||||
'links': [{'rel': 'self',
|
||||
'href': '%s/v2/' % url}],
|
||||
},
|
||||
{
|
||||
'id': 'v2.7',
|
||||
'status': 'SUPPORTED',
|
||||
@ -87,6 +94,12 @@ def get_versions_list(url, enabled_backends=False):
|
||||
]
|
||||
if enabled_backends:
|
||||
image_versions = [
|
||||
{
|
||||
'id': 'v2.13',
|
||||
'status': 'CURRENT',
|
||||
'links': [{'rel': 'self',
|
||||
'href': '%s/v2/' % url}],
|
||||
},
|
||||
{
|
||||
'id': 'v2.12',
|
||||
'status': 'SUPPORTED',
|
||||
@ -117,14 +130,16 @@ def get_versions_list(url, enabled_backends=False):
|
||||
'links': [{'rel': 'self',
|
||||
'href': '%s/v2/' % url}],
|
||||
}
|
||||
] + image_versions
|
||||
else:
|
||||
] + image_versions[2:]
|
||||
|
||||
if enabled_cache:
|
||||
image_versions.insert(0, {
|
||||
'id': 'v2.9',
|
||||
'status': 'SUPPORTED',
|
||||
'id': 'v2.14',
|
||||
'status': 'CURRENT',
|
||||
'links': [{'rel': 'self',
|
||||
'href': '%s/v2/' % url}],
|
||||
})
|
||||
image_versions[1]['status'] = 'SUPPORTED'
|
||||
|
||||
return image_versions
|
||||
|
||||
@ -151,6 +166,14 @@ class VersionsTest(base.IsolatedUnitTest):
|
||||
enabled_backends=True)
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
self.config(image_cache_dir='/tmp/cache')
|
||||
res = versions.Controller().index(req)
|
||||
results = jsonutils.loads(res.body)['versions']
|
||||
expected = get_versions_list('http://127.0.0.1:9292',
|
||||
enabled_backends=True,
|
||||
enabled_cache=True)
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
def test_get_version_list_public_endpoint(self):
|
||||
req = webob.Request.blank('/', base_url='http://127.0.0.1:9292/')
|
||||
req.accept = 'application/json'
|
||||
@ -170,6 +193,14 @@ class VersionsTest(base.IsolatedUnitTest):
|
||||
enabled_backends=True)
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
self.config(image_cache_dir='/tmp/cache')
|
||||
res = versions.Controller().index(req)
|
||||
results = jsonutils.loads(res.body)['versions']
|
||||
expected = get_versions_list('https://example.com:9292',
|
||||
enabled_backends=True,
|
||||
enabled_cache=True)
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
def test_get_version_list_secure_proxy_ssl_header(self):
|
||||
self.config(secure_proxy_ssl_header='HTTP_X_FORWARDED_PROTO')
|
||||
url = 'http://localhost:9292'
|
||||
@ -188,6 +219,14 @@ class VersionsTest(base.IsolatedUnitTest):
|
||||
expected = get_versions_list(url, enabled_backends=True)
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
self.config(image_cache_dir='/tmp/cache')
|
||||
res = versions.Controller().index(req)
|
||||
results = jsonutils.loads(res.body)['versions']
|
||||
expected = get_versions_list(url,
|
||||
enabled_backends=True,
|
||||
enabled_cache=True)
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
def test_get_version_list_secure_proxy_ssl_header_https(self):
|
||||
self.config(secure_proxy_ssl_header='HTTP_X_FORWARDED_PROTO')
|
||||
url = 'http://localhost:9292'
|
||||
@ -208,6 +247,14 @@ class VersionsTest(base.IsolatedUnitTest):
|
||||
expected = get_versions_list(ssl_url, enabled_backends=True)
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
self.config(image_cache_dir='/tmp/cache')
|
||||
res = versions.Controller().index(req)
|
||||
results = jsonutils.loads(res.body)['versions']
|
||||
expected = get_versions_list(ssl_url,
|
||||
enabled_backends=True,
|
||||
enabled_cache=True)
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
def test_get_version_list_for_external_app(self):
|
||||
url = 'http://customhost:9292/app/api'
|
||||
req = webob.Request.blank('/', base_url=url)
|
||||
@ -225,6 +272,13 @@ class VersionsTest(base.IsolatedUnitTest):
|
||||
expected = get_versions_list(url, enabled_backends=True)
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
self.config(image_cache_dir='/tmp/cache')
|
||||
res = versions.Controller().index(req)
|
||||
results = jsonutils.loads(res.body)['versions']
|
||||
expected = get_versions_list(url,
|
||||
enabled_backends=True,
|
||||
enabled_cache=True)
|
||||
|
||||
|
||||
class VersionNegotiationTest(base.IsolatedUnitTest):
|
||||
|
||||
@ -333,15 +387,21 @@ class VersionNegotiationTest(base.IsolatedUnitTest):
|
||||
self.middleware.process_request(request)
|
||||
self.assertEqual('/v2/images', request.path_info)
|
||||
|
||||
# version 2.14 does not exist
|
||||
def test_request_url_v2_14_default_unsupported(self):
|
||||
def test_request_url_v2_14_enabled_supported(self):
|
||||
self.config(image_cache_dir='/tmp/cache')
|
||||
request = webob.Request.blank('/v2.14/images')
|
||||
self.middleware.process_request(request)
|
||||
self.assertEqual('/v2/images', request.path_info)
|
||||
|
||||
# version 2.15 does not exist
|
||||
def test_request_url_v2_15_default_unsupported(self):
|
||||
request = webob.Request.blank('/v2.15/images')
|
||||
resp = self.middleware.process_request(request)
|
||||
self.assertIsInstance(resp, versions.Controller)
|
||||
|
||||
def test_request_url_v2_14_enabled_unsupported(self):
|
||||
self.config(enabled_backends='slow:one,fast:two')
|
||||
request = webob.Request.blank('/v2.14/images')
|
||||
def test_request_url_v2_15_enabled_unsupported(self):
|
||||
self.config(image_cache_dir='/tmp/cache')
|
||||
request = webob.Request.blank('/v2.15/images')
|
||||
resp = self.middleware.process_request(request)
|
||||
self.assertIsInstance(resp, versions.Controller)
|
||||
|
||||
|
123
glance/tests/unit/v2/test_cache_management_api.py
Normal file
123
glance/tests/unit/v2/test_cache_management_api.py
Normal file
@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
from unittest import mock
|
||||
|
||||
from glance.api.v2 import cached_images
|
||||
from glance import notifier
|
||||
import glance.tests.unit.utils as unit_test_utils
|
||||
import glance.tests.utils as test_utils
|
||||
|
||||
|
||||
UUID1 = 'c80a1a6c-bd1f-41c5-90ee-81afedb1d58d'
|
||||
|
||||
|
||||
class FakeImage(object):
|
||||
def __init__(self, id=None, status='active', container_format='ami',
|
||||
disk_format='ami', locations=None):
|
||||
self.id = id or UUID1
|
||||
self.status = status
|
||||
self.container_format = container_format
|
||||
self.disk_format = disk_format
|
||||
self.locations = locations
|
||||
self.owner = unit_test_utils.TENANT1
|
||||
self.created_at = ''
|
||||
self.updated_at = ''
|
||||
self.min_disk = ''
|
||||
self.min_ram = ''
|
||||
self.protected = False
|
||||
self.checksum = ''
|
||||
self.os_hash_algo = ''
|
||||
self.os_hash_value = ''
|
||||
self.size = 0
|
||||
self.virtual_size = 0
|
||||
self.visibility = 'public'
|
||||
self.os_hidden = False
|
||||
self.name = 'foo'
|
||||
self.tags = []
|
||||
self.extra_properties = {}
|
||||
self.member = self.owner
|
||||
|
||||
# NOTE(danms): This fixture looks more like the db object than
|
||||
# the proxy model. This needs fixing all through the tests
|
||||
# below.
|
||||
self.image_id = self.id
|
||||
|
||||
|
||||
class TestCacheManageAPI(test_utils.BaseTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestCacheManageAPI, self).setUp()
|
||||
self.req = unit_test_utils.get_fake_request()
|
||||
|
||||
def _main_test_helper(self, argv, status='active', image_mock=True):
|
||||
with mock.patch.object(notifier.ImageRepoProxy,
|
||||
'get') as mock_get:
|
||||
image = FakeImage(status=status)
|
||||
mock_get.return_value = image
|
||||
with mock.patch.object(cached_images.CacheController,
|
||||
'_enforce') as e:
|
||||
with mock.patch('glance.image_cache.ImageCache') as ic:
|
||||
cc = cached_images.CacheController()
|
||||
cc.cache = ic
|
||||
c_calls = []
|
||||
c_calls += argv[0].split(',')
|
||||
for call in c_calls:
|
||||
mock.patch.object(ic, call)
|
||||
test_call = getattr(cc, argv[1])
|
||||
new_policy = argv[2]
|
||||
args = []
|
||||
if len(argv) == 4:
|
||||
args = argv[3:]
|
||||
test_call(self.req, *args)
|
||||
if image_mock:
|
||||
e.assert_called_once_with(self.req, image=image,
|
||||
new_policy=new_policy)
|
||||
else:
|
||||
e.assert_called_once_with(self.req,
|
||||
new_policy=new_policy)
|
||||
mcs = []
|
||||
for method in ic.method_calls:
|
||||
mcs.append(str(method))
|
||||
for call in c_calls:
|
||||
if args == []:
|
||||
args.append("")
|
||||
elif args[0] and not args[0].endswith("'"):
|
||||
args[0] = "'" + args[0] + "'"
|
||||
self.assertIn("call." + call + "(" + args[0] + ")",
|
||||
mcs)
|
||||
self.assertEqual(len(c_calls), len(mcs))
|
||||
|
||||
def test_delete_cache_entry(self):
|
||||
self._main_test_helper(['delete_cached_image,delete_queued_image',
|
||||
'delete_cache_entry',
|
||||
'cache_delete',
|
||||
UUID1])
|
||||
|
||||
def test_clear_cache(self):
|
||||
self._main_test_helper(
|
||||
['delete_all_cached_images,delete_all_queued_images',
|
||||
'clear_cache',
|
||||
'cache_delete'], image_mock=False)
|
||||
|
||||
def test_get_cache_state(self):
|
||||
self._main_test_helper(['get_cached_images,get_queued_images',
|
||||
'get_cache_state',
|
||||
'cache_list'], image_mock=False)
|
||||
|
||||
def test_queue_image_from_api(self):
|
||||
self._main_test_helper(['queue_image',
|
||||
'queue_image_from_api',
|
||||
'cache_image',
|
||||
UUID1])
|
@ -780,16 +780,44 @@ class TestTasksAPIPolicy(APIPolicyBase):
|
||||
mock.ANY)
|
||||
|
||||
|
||||
class TestCacheImageAPIPolicy(APIPolicyBase):
|
||||
class TestCacheImageAPIPolicy(utils.BaseTestCase):
|
||||
def setUp(self):
|
||||
super(TestCacheImageAPIPolicy, self).setUp()
|
||||
self.enforcer = mock.MagicMock()
|
||||
self.context = mock.MagicMock()
|
||||
self.policy = policy.CacheImageAPIPolicy(
|
||||
self.context, enforcer=self.enforcer)
|
||||
|
||||
def test_manage_image_cache(self):
|
||||
self.policy = policy.CacheImageAPIPolicy(
|
||||
self.context, enforcer=self.enforcer,
|
||||
policy_str='manage_image_cache')
|
||||
self.policy.manage_image_cache()
|
||||
self.enforcer.enforce.assert_called_once_with(self.context,
|
||||
'manage_image_cache',
|
||||
mock.ANY)
|
||||
|
||||
def test_manage_image_cache_with_cache_delete(self):
|
||||
self.policy = policy.CacheImageAPIPolicy(
|
||||
self.context, enforcer=self.enforcer,
|
||||
policy_str='cache_delete')
|
||||
self.policy.manage_image_cache()
|
||||
self.enforcer.enforce.assert_called_once_with(self.context,
|
||||
'cache_delete',
|
||||
mock.ANY)
|
||||
|
||||
def test_manage_image_cache_with_cache_list(self):
|
||||
self.policy = policy.CacheImageAPIPolicy(
|
||||
self.context, enforcer=self.enforcer,
|
||||
policy_str='cache_list')
|
||||
self.policy.manage_image_cache()
|
||||
self.enforcer.enforce.assert_called_once_with(self.context,
|
||||
'cache_list',
|
||||
mock.ANY)
|
||||
|
||||
def test_manage_image_cache_with_cache_image(self):
|
||||
self.policy = policy.CacheImageAPIPolicy(
|
||||
self.context, enforcer=self.enforcer,
|
||||
policy_str='cache_image')
|
||||
self.policy.manage_image_cache()
|
||||
self.enforcer.enforce.assert_called_once_with(self.context,
|
||||
'cache_image',
|
||||
mock.ANY)
|
||||
|
9
releasenotes/notes/cache-api-b806ccfb8c5d9bb6.yaml
Normal file
9
releasenotes/notes/cache-api-b806ccfb8c5d9bb6.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
This release introduces new APIs for cache related operations. This new
|
||||
version of the cache API will help administrators to cache images on
|
||||
dedicated glance nodes as well. For more information, see the
|
||||
``Cache Manage`` section in the `api-ref-guide
|
||||
<https://developer.openstack.org/api-ref/image/v2/index.html#cache-manage>`_.
|
||||
|
Loading…
Reference in New Issue
Block a user