diff --git a/api-ref/source/v2/discovery-parameters.yaml b/api-ref/source/v2/discovery-parameters.yaml index b4b4d3d6ee..205d6378e0 100644 --- a/api-ref/source/v2/discovery-parameters.yaml +++ b/api-ref/source/v2/discovery-parameters.yaml @@ -18,3 +18,28 @@ stores: in: body required: true type: array +stores-detail: + description: | + A list of store objects, where each store object may contain the + following fields: + + ``id`` + Operator-defined identifier for the store. + ``type`` + Specify the type of store. + ``description`` + Operator-supplied description of this store. + ``default`` (optional) + Only present on the default store. This is the store where image + data is placed if you do not indicate a specific store when supplying + data to the Image Service. (See the :ref:`Image data ` + and :ref:`Interoperable image import ` sections + for more information.) + ``read-only`` (optional) + Included only when the store is read only. + ``properties`` + Contains store specific properties + in: body + required: true + type: array + diff --git a/api-ref/source/v2/discovery.inc b/api-ref/source/v2/discovery.inc index 1344280249..e1401bd837 100644 --- a/api-ref/source/v2/discovery.inc +++ b/api-ref/source/v2/discovery.inc @@ -126,3 +126,39 @@ Response Example .. literalinclude:: samples/usage-response.json :language: json + +List stores detail +~~~~~~~~~~~~~~~~~~ + +.. rest_method:: GET /v2/info/stores/detail + +Lists all the backend stores, with detail, accessible to admins, +for non-admin user API will return bad request. + +Normal response codes: 200 + +Error response codes: 403, 404 + + +Request +------- + +There are no request parameters. + +This call does not allow a request body. + + +Response Parameters +------------------- + +.. rest_parameters:: discovery-parameters.yaml + + - stores: stores-detail + + +Response Example +---------------- + +.. literalinclude:: samples/stores-list-detail-response.json + :language: json + diff --git a/api-ref/source/v2/samples/stores-list-detail-response.json b/api-ref/source/v2/samples/stores-list-detail-response.json new file mode 100644 index 0000000000..f377518f56 --- /dev/null +++ b/api-ref/source/v2/samples/stores-list-detail-response.json @@ -0,0 +1,19 @@ +{ + "stores": [ + { + "id":"reliable", + "type": "rbd", + "description": "More expensive store with data redundancy", + "default": true, + "properties": { + "pool": "pool1" + } + }, + { + "id":"cheap", + "type": "file", + "description": "Less expensive store for seldom-used images", + "properties": {} + } + ] +} diff --git a/glance/api/middleware/version_negotiation.py b/glance/api/middleware/version_negotiation.py index b989093ec3..d5ab5e2d11 100644 --- a/glance/api/middleware/version_negotiation.py +++ b/glance/api/middleware/version_negotiation.py @@ -85,6 +85,7 @@ class VersionNegotiationFilter(wsgi.Middleware): allowed_versions['v2.13'] = 2 if CONF.image_cache_dir: allowed_versions['v2.14'] = 2 + allowed_versions['v2.15'] = 2 if CONF.enabled_backends: allowed_versions['v2.8'] = 2 allowed_versions['v2.10'] = 2 diff --git a/glance/api/v2/discovery.py b/glance/api/v2/discovery.py index e8c2caf751..c0aa477dd5 100644 --- a/glance/api/v2/discovery.py +++ b/glance/api/v2/discovery.py @@ -15,10 +15,15 @@ import copy +import glance_store as g_store from oslo_config import cfg +from oslo_log import log as logging import oslo_serialization.jsonutils as json import webob.exc +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 from glance.i18n import _ @@ -26,8 +31,13 @@ from glance.quota import keystone as ks_quota CONF = cfg.CONF +LOG = logging.getLogger(__name__) + class InfoController(object): + def __init__(self, policy_enforcer=None): + self.policy = policy_enforcer or policy.Enforcer() + def get_image_import(self, req): # TODO(jokke): All the rest of the boundaries should be implemented. import_methods = { @@ -67,6 +77,30 @@ class InfoController(object): return {'stores': backends} + def get_stores_detail(self, req): + enabled_backends = CONF.enabled_backends + stores = self.get_stores(req).get('stores') + try: + api_policy.DiscoveryAPIPolicy( + req.context, + enforcer=self.policy).stores_info_detail() + for store in stores: + store['type'] = enabled_backends[store['id']] + store['properties'] = {} + if store['type'] == 'rbd': + store_detail = g_store.get_store_from_store_identifier( + store['id']) + store['properties'] = {'chunk_size': + store_detail.chunk_size, + 'pool': store_detail.pool, + 'thin_provisioning': + store_detail.thin_provisioning} + except exception.Forbidden as e: + LOG.debug("User not permitted to view details") + raise webob.exc.HTTPForbidden(explanation=e.msg) + + return {'stores': stores} + def get_usage(self, req): project_usage = ks_quota.get_usage(req.context) return {'usage': diff --git a/glance/api/v2/policy.py b/glance/api/v2/policy.py index 9edf55329b..8ad4046478 100644 --- a/glance/api/v2/policy.py +++ b/glance/api/v2/policy.py @@ -122,6 +122,17 @@ class CacheImageAPIPolicy(APIPolicyBase): self._enforce(self.policy_str) +class DiscoveryAPIPolicy(APIPolicyBase): + def __init__(self, context, target=None, enforcer=None): + self._context = context + self._target = target or {} + self.enforcer = enforcer or policy.Enforcer() + super(DiscoveryAPIPolicy, self).__init__(context, target, enforcer) + + def stores_info_detail(self): + self._enforce('stores_info_detail') + + class ImageAPIPolicy(APIPolicyBase): def __init__(self, context, image, enforcer=None): """Image API policy module. diff --git a/glance/api/v2/router.py b/glance/api/v2/router.py index d7e881af2a..d8ebdf398a 100644 --- a/glance/api/v2/router.py +++ b/glance/api/v2/router.py @@ -593,6 +593,15 @@ class API(wsgi.Router): controller=info_resource, action='get_usage', conditions={'method': ['GET']}) + mapper.connect('/info/stores/detail', + controller=info_resource, + action='get_stores_detail', + conditions={'method': ['GET']}, + body_reject=True) + mapper.connect('/info/stores/detail', + controller=reject_method_resource, + action='reject', + allowed_methods='GET') # Cache Management API cache_manage_resource = cached_images.create_resource() diff --git a/glance/api/versions.py b/glance/api/versions.py index 18f77e3191..15b0487517 100644 --- a/glance/api/versions.py +++ b/glance/api/versions.py @@ -79,15 +79,16 @@ 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'), + build_version_object(2.15, 'v2', 'CURRENT'), + build_version_object(2.14, 'v2', 'SUPPORTED'), ]) else: version_objs.extend([ - build_version_object(2.13, 'v2', 'CURRENT'), + build_version_object(2.15, 'v2', 'CURRENT'), ]) if CONF.enabled_backends: version_objs.extend([ + build_version_object(2.13, 'v2', 'SUPPORTED'), build_version_object(2.12, 'v2', 'SUPPORTED'), build_version_object(2.11, 'v2', 'SUPPORTED'), build_version_object('2.10', 'v2', 'SUPPORTED'), diff --git a/glance/policies/__init__.py b/glance/policies/__init__.py index a885bb3fc4..55ceda7c68 100644 --- a/glance/policies/__init__.py +++ b/glance/policies/__init__.py @@ -14,6 +14,7 @@ import itertools from glance.policies import base from glance.policies import cache +from glance.policies import discovery from glance.policies import image from glance.policies import metadef from glance.policies import tasks @@ -26,4 +27,5 @@ def list_rules(): tasks.list_rules(), metadef.list_rules(), cache.list_rules(), + discovery.list_rules(), ) diff --git a/glance/policies/discovery.py b/glance/policies/discovery.py new file mode 100644 index 0000000000..3273a48ecb --- /dev/null +++ b/glance/policies/discovery.py @@ -0,0 +1,33 @@ +# 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 oslo_policy import policy + + +discovery_policies = [ + policy.DocumentedRuleDefault( + name="stores_info_detail", + check_str='role:admin', + scope_types=['system', 'project'], + description='Expose store specific information', + operations=[ + {'path': '/v2/info/stores/detail', + 'method': 'GET'} + ] + ), +] + + +def list_rules(): + return discovery_policies diff --git a/glance/tests/unit/base.py b/glance/tests/unit/base.py index d859dbea47..2cdd8fa552 100644 --- a/glance/tests/unit/base.py +++ b/glance/tests/unit/base.py @@ -17,6 +17,7 @@ import os from unittest import mock import glance_store as store +from glance_store._drivers import rbd as rbd_store from glance_store import location from oslo_concurrency import lockutils from oslo_config import cfg @@ -70,9 +71,13 @@ class MultiStoreClearingUnitTest(test_utils.BaseTestCase): :param passing_config: making store driver passes basic configurations. :returns: the number of how many store drivers been loaded. """ + rbd_store.rados = mock.MagicMock() + rbd_store.rbd = mock.MagicMock() + rbd_store.Store._set_url_prefix = mock.MagicMock() self.config(enabled_backends={'fast': 'file', 'cheap': 'file', 'readonly_store': 'http', - 'fast-cinder': 'cinder'}) + 'fast-cinder': 'cinder', + 'fast-rbd': 'rbd'}) store.register_store_opts(CONF) self.config(default_backend='fast', @@ -82,6 +87,8 @@ class MultiStoreClearingUnitTest(test_utils.BaseTestCase): group='fast') self.config(filesystem_store_datadir=self.test_dir2, group='cheap') + self.config(rbd_store_chunk_size=8688388, rbd_store_pool='images', + rbd_thin_provisioning=False, group='fast-rbd') store.create_multi_stores(CONF) diff --git a/glance/tests/unit/test_versions.py b/glance/tests/unit/test_versions.py index b33b3dbc3d..4acda1fa86 100644 --- a/glance/tests/unit/test_versions.py +++ b/glance/tests/unit/test_versions.py @@ -32,7 +32,7 @@ def get_versions_list(url, enabled_backends=False, enabled_cache=False): image_versions = [ { - 'id': 'v2.13', + 'id': 'v2.15', 'status': 'CURRENT', 'links': [{'rel': 'self', 'href': '%s/v2/' % url}], @@ -95,11 +95,17 @@ def get_versions_list(url, enabled_backends=False, if enabled_backends: image_versions = [ { - 'id': 'v2.13', + 'id': 'v2.15', 'status': 'CURRENT', 'links': [{'rel': 'self', 'href': '%s/v2/' % url}], }, + { + 'id': 'v2.13', + 'status': 'SUPPORTED', + 'links': [{'rel': 'self', + 'href': '%s/v2/' % url}], + }, { 'id': 'v2.12', 'status': 'SUPPORTED', @@ -133,13 +139,12 @@ def get_versions_list(url, enabled_backends=False, ] + image_versions[2:] if enabled_cache: - image_versions.insert(0, { + image_versions.insert(1, { 'id': 'v2.14', - 'status': 'CURRENT', + 'status': 'SUPPORTED', 'links': [{'rel': 'self', 'href': '%s/v2/' % url}], }) - image_versions[1]['status'] = 'SUPPORTED' return image_versions @@ -393,15 +398,20 @@ class VersionNegotiationTest(base.IsolatedUnitTest): 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): + def test_request_url_v2_15_enabled_supported(self): request = webob.Request.blank('/v2.15/images') + self.middleware.process_request(request) + self.assertEqual('/v2/images', request.path_info) + + # version 2.16 does not exist + def test_request_url_v2_16_default_unsupported(self): + request = webob.Request.blank('/v2.16/images') resp = self.middleware.process_request(request) self.assertIsInstance(resp, versions.Controller) - def test_request_url_v2_15_enabled_unsupported(self): - self.config(image_cache_dir='/tmp/cache') - request = webob.Request.blank('/v2.15/images') + def test_request_url_v2_16_enabled_unsupported(self): + self.config(enabled_backends='slow:one,fast:two') + request = webob.Request.blank('/v2.16/images') resp = self.middleware.process_request(request) self.assertIsInstance(resp, versions.Controller) diff --git a/glance/tests/unit/v2/test_discovery_stores.py b/glance/tests/unit/v2/test_discovery_stores.py index 0f1fad05d2..111064767c 100644 --- a/glance/tests/unit/v2/test_discovery_stores.py +++ b/glance/tests/unit/v2/test_discovery_stores.py @@ -39,7 +39,8 @@ class TestInfoControllers(base.MultiStoreClearingUnitTest): req) def test_get_stores(self): - available_stores = ['cheap', 'fast', 'readonly_store', 'fast-cinder'] + available_stores = ['cheap', 'fast', 'readonly_store', 'fast-cinder', + 'fast-rbd'] req = unit_test_utils.get_fake_request() output = self.controller.get_stores(req) self.assertIn('stores', output) @@ -48,7 +49,8 @@ class TestInfoControllers(base.MultiStoreClearingUnitTest): self.assertIn(stores['id'], available_stores) def test_get_stores_read_only_store(self): - available_stores = ['cheap', 'fast', 'readonly_store', 'fast-cinder'] + available_stores = ['cheap', 'fast', 'readonly_store', 'fast-cinder', + 'fast-rbd'] req = unit_test_utils.get_fake_request() output = self.controller.get_stores(req) self.assertIn('stores', output) @@ -72,3 +74,28 @@ class TestInfoControllers(base.MultiStoreClearingUnitTest): self.assertEqual(2, len(output['stores'])) for stores in output["stores"]: self.assertFalse(stores["id"].startswith("os_glance_")) + + def test_get_stores_detail(self): + available_stores = ['cheap', 'fast', 'readonly_store', 'fast-cinder', + 'fast-rbd'] + available_store_type = ['file', 'file', 'http', 'cinder', 'rbd'] + req = unit_test_utils.get_fake_request(roles=['admin']) + output = self.controller.get_stores_detail(req) + self.assertIn('stores', output) + for stores in output['stores']: + self.assertIn('id', stores) + self.assertIn(stores['id'], available_stores) + self.assertIn(stores['type'], available_store_type) + self.assertIsNotNone(stores['properties']) + if stores['id'] == 'fast-rbd': + self.assertIn('chunk_size', stores['properties']) + self.assertIn('pool', stores['properties']) + self.assertIn('thin_provisioning', stores['properties']) + else: + self.assertEqual({}, stores['properties']) + + def test_get_stores_detail_non_admin(self): + req = unit_test_utils.get_fake_request() + self.assertRaises(webob.exc.HTTPForbidden, + self.controller.get_stores_detail, + req) diff --git a/glance/tests/unit/v2/test_v2_policy.py b/glance/tests/unit/v2/test_v2_policy.py index 0b2c2bb995..ae04c7de84 100644 --- a/glance/tests/unit/v2/test_v2_policy.py +++ b/glance/tests/unit/v2/test_v2_policy.py @@ -821,3 +821,18 @@ class TestCacheImageAPIPolicy(utils.BaseTestCase): self.enforcer.enforce.assert_called_once_with(self.context, 'cache_image', mock.ANY) + + +class TestDiscoveryAPIPolicy(APIPolicyBase): + def setUp(self): + super(TestDiscoveryAPIPolicy, self).setUp() + self.enforcer = mock.MagicMock() + self.context = mock.MagicMock() + self.policy = policy.DiscoveryAPIPolicy( + self.context, enforcer=self.enforcer) + + def test_stores_info_detail(self): + self.policy.stores_info_detail() + self.enforcer.enforce.assert_called_once_with(self.context, + 'stores_info_detail', + mock.ANY) diff --git a/releasenotes/notes/added-store-detail-api-215810aa85dfbb99.yaml b/releasenotes/notes/added-store-detail-api-215810aa85dfbb99.yaml new file mode 100644 index 0000000000..ef67e48cc8 --- /dev/null +++ b/releasenotes/notes/added-store-detail-api-215810aa85dfbb99.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + This release brings additional functionality to the stores API. + The stores detail API helps in providing the store specific + information.