Added a new API to expose store info

This patch adds a new API to glance ``GET /v2/info/stores/detail``
to expose the stores specific details about the store like store
type and other specific store properties.This operation
will be admin only and validated by the new policy rule
``stores_info_detail`` which defaults to admin only

Implements: blueprint expose-store-specific-info
Change-Id: I6882fd2381e6ae245fd8c61bf9f4d52df2b216f5
This commit is contained in:
Mridula Joshi 2022-01-12 13:01:15 +00:00 committed by Abhishek Kekane
parent 88c43d4715
commit a34764ecac
15 changed files with 252 additions and 16 deletions

@ -18,3 +18,28 @@ stores:
in: body in: body
required: true required: true
type: array 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 <image-data>`
and :ref:`Interoperable image import <image-import-process>` 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

@ -126,3 +126,39 @@ Response Example
.. literalinclude:: samples/usage-response.json .. literalinclude:: samples/usage-response.json
:language: 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

@ -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": {}
}
]
}

@ -85,6 +85,7 @@ class VersionNegotiationFilter(wsgi.Middleware):
allowed_versions['v2.13'] = 2 allowed_versions['v2.13'] = 2
if CONF.image_cache_dir: if CONF.image_cache_dir:
allowed_versions['v2.14'] = 2 allowed_versions['v2.14'] = 2
allowed_versions['v2.15'] = 2
if CONF.enabled_backends: if CONF.enabled_backends:
allowed_versions['v2.8'] = 2 allowed_versions['v2.8'] = 2
allowed_versions['v2.10'] = 2 allowed_versions['v2.10'] = 2

@ -15,10 +15,15 @@
import copy import copy
import glance_store as g_store
from oslo_config import cfg from oslo_config import cfg
from oslo_log import log as logging
import oslo_serialization.jsonutils as json import oslo_serialization.jsonutils as json
import webob.exc 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 from glance.common import wsgi
import glance.db import glance.db
from glance.i18n import _ from glance.i18n import _
@ -26,8 +31,13 @@ from glance.quota import keystone as ks_quota
CONF = cfg.CONF CONF = cfg.CONF
LOG = logging.getLogger(__name__)
class InfoController(object): class InfoController(object):
def __init__(self, policy_enforcer=None):
self.policy = policy_enforcer or policy.Enforcer()
def get_image_import(self, req): def get_image_import(self, req):
# TODO(jokke): All the rest of the boundaries should be implemented. # TODO(jokke): All the rest of the boundaries should be implemented.
import_methods = { import_methods = {
@ -67,6 +77,30 @@ class InfoController(object):
return {'stores': backends} 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): def get_usage(self, req):
project_usage = ks_quota.get_usage(req.context) project_usage = ks_quota.get_usage(req.context)
return {'usage': return {'usage':

@ -122,6 +122,17 @@ class CacheImageAPIPolicy(APIPolicyBase):
self._enforce(self.policy_str) 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): class ImageAPIPolicy(APIPolicyBase):
def __init__(self, context, image, enforcer=None): def __init__(self, context, image, enforcer=None):
"""Image API policy module. """Image API policy module.

@ -593,6 +593,15 @@ class API(wsgi.Router):
controller=info_resource, controller=info_resource,
action='get_usage', action='get_usage',
conditions={'method': ['GET']}) 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 Management API
cache_manage_resource = cached_images.create_resource() cache_manage_resource = cached_images.create_resource()

@ -79,15 +79,16 @@ class Controller(object):
version_objs = [] version_objs = []
if CONF.image_cache_dir: if CONF.image_cache_dir:
version_objs.extend([ version_objs.extend([
build_version_object(2.14, 'v2', 'CURRENT'), build_version_object(2.15, 'v2', 'CURRENT'),
build_version_object(2.13, 'v2', 'SUPPORTED'), build_version_object(2.14, 'v2', 'SUPPORTED'),
]) ])
else: else:
version_objs.extend([ version_objs.extend([
build_version_object(2.13, 'v2', 'CURRENT'), build_version_object(2.15, 'v2', 'CURRENT'),
]) ])
if CONF.enabled_backends: if CONF.enabled_backends:
version_objs.extend([ version_objs.extend([
build_version_object(2.13, 'v2', 'SUPPORTED'),
build_version_object(2.12, 'v2', 'SUPPORTED'), build_version_object(2.12, 'v2', 'SUPPORTED'),
build_version_object(2.11, 'v2', 'SUPPORTED'), build_version_object(2.11, 'v2', 'SUPPORTED'),
build_version_object('2.10', 'v2', 'SUPPORTED'), build_version_object('2.10', 'v2', 'SUPPORTED'),

@ -14,6 +14,7 @@ import itertools
from glance.policies import base from glance.policies import base
from glance.policies import cache from glance.policies import cache
from glance.policies import discovery
from glance.policies import image from glance.policies import image
from glance.policies import metadef from glance.policies import metadef
from glance.policies import tasks from glance.policies import tasks
@ -26,4 +27,5 @@ def list_rules():
tasks.list_rules(), tasks.list_rules(),
metadef.list_rules(), metadef.list_rules(),
cache.list_rules(), cache.list_rules(),
discovery.list_rules(),
) )

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

@ -17,6 +17,7 @@ import os
from unittest import mock from unittest import mock
import glance_store as store import glance_store as store
from glance_store._drivers import rbd as rbd_store
from glance_store import location from glance_store import location
from oslo_concurrency import lockutils from oslo_concurrency import lockutils
from oslo_config import cfg from oslo_config import cfg
@ -70,9 +71,13 @@ class MultiStoreClearingUnitTest(test_utils.BaseTestCase):
:param passing_config: making store driver passes basic configurations. :param passing_config: making store driver passes basic configurations.
:returns: the number of how many store drivers been loaded. :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', self.config(enabled_backends={'fast': 'file', 'cheap': 'file',
'readonly_store': 'http', 'readonly_store': 'http',
'fast-cinder': 'cinder'}) 'fast-cinder': 'cinder',
'fast-rbd': 'rbd'})
store.register_store_opts(CONF) store.register_store_opts(CONF)
self.config(default_backend='fast', self.config(default_backend='fast',
@ -82,6 +87,8 @@ class MultiStoreClearingUnitTest(test_utils.BaseTestCase):
group='fast') group='fast')
self.config(filesystem_store_datadir=self.test_dir2, self.config(filesystem_store_datadir=self.test_dir2,
group='cheap') 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) store.create_multi_stores(CONF)

@ -32,7 +32,7 @@ def get_versions_list(url, enabled_backends=False,
enabled_cache=False): enabled_cache=False):
image_versions = [ image_versions = [
{ {
'id': 'v2.13', 'id': 'v2.15',
'status': 'CURRENT', 'status': 'CURRENT',
'links': [{'rel': 'self', 'links': [{'rel': 'self',
'href': '%s/v2/' % url}], 'href': '%s/v2/' % url}],
@ -95,11 +95,17 @@ def get_versions_list(url, enabled_backends=False,
if enabled_backends: if enabled_backends:
image_versions = [ image_versions = [
{ {
'id': 'v2.13', 'id': 'v2.15',
'status': 'CURRENT', 'status': 'CURRENT',
'links': [{'rel': 'self', 'links': [{'rel': 'self',
'href': '%s/v2/' % url}], 'href': '%s/v2/' % url}],
}, },
{
'id': 'v2.13',
'status': 'SUPPORTED',
'links': [{'rel': 'self',
'href': '%s/v2/' % url}],
},
{ {
'id': 'v2.12', 'id': 'v2.12',
'status': 'SUPPORTED', 'status': 'SUPPORTED',
@ -133,13 +139,12 @@ def get_versions_list(url, enabled_backends=False,
] + image_versions[2:] ] + image_versions[2:]
if enabled_cache: if enabled_cache:
image_versions.insert(0, { image_versions.insert(1, {
'id': 'v2.14', 'id': 'v2.14',
'status': 'CURRENT', 'status': 'SUPPORTED',
'links': [{'rel': 'self', 'links': [{'rel': 'self',
'href': '%s/v2/' % url}], 'href': '%s/v2/' % url}],
}) })
image_versions[1]['status'] = 'SUPPORTED'
return image_versions return image_versions
@ -393,15 +398,20 @@ class VersionNegotiationTest(base.IsolatedUnitTest):
self.middleware.process_request(request) self.middleware.process_request(request)
self.assertEqual('/v2/images', request.path_info) self.assertEqual('/v2/images', request.path_info)
# version 2.15 does not exist def test_request_url_v2_15_enabled_supported(self):
def test_request_url_v2_15_default_unsupported(self):
request = webob.Request.blank('/v2.15/images') 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) resp = self.middleware.process_request(request)
self.assertIsInstance(resp, versions.Controller) self.assertIsInstance(resp, versions.Controller)
def test_request_url_v2_15_enabled_unsupported(self): def test_request_url_v2_16_enabled_unsupported(self):
self.config(image_cache_dir='/tmp/cache') self.config(enabled_backends='slow:one,fast:two')
request = webob.Request.blank('/v2.15/images') request = webob.Request.blank('/v2.16/images')
resp = self.middleware.process_request(request) resp = self.middleware.process_request(request)
self.assertIsInstance(resp, versions.Controller) self.assertIsInstance(resp, versions.Controller)

@ -39,7 +39,8 @@ class TestInfoControllers(base.MultiStoreClearingUnitTest):
req) req)
def test_get_stores(self): 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() req = unit_test_utils.get_fake_request()
output = self.controller.get_stores(req) output = self.controller.get_stores(req)
self.assertIn('stores', output) self.assertIn('stores', output)
@ -48,7 +49,8 @@ class TestInfoControllers(base.MultiStoreClearingUnitTest):
self.assertIn(stores['id'], available_stores) self.assertIn(stores['id'], available_stores)
def test_get_stores_read_only_store(self): 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() req = unit_test_utils.get_fake_request()
output = self.controller.get_stores(req) output = self.controller.get_stores(req)
self.assertIn('stores', output) self.assertIn('stores', output)
@ -72,3 +74,28 @@ class TestInfoControllers(base.MultiStoreClearingUnitTest):
self.assertEqual(2, len(output['stores'])) self.assertEqual(2, len(output['stores']))
for stores in output["stores"]: for stores in output["stores"]:
self.assertFalse(stores["id"].startswith("os_glance_")) 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)

@ -821,3 +821,18 @@ class TestCacheImageAPIPolicy(utils.BaseTestCase):
self.enforcer.enforce.assert_called_once_with(self.context, self.enforcer.enforce.assert_called_once_with(self.context,
'cache_image', 'cache_image',
mock.ANY) 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)

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