Metadata for Share Export Location

This patch adds support for Share Export Locations in the
Metadata Controller.

Co-Authored-By: Ashley Rodriguez <ashrod98@gmail.com>
Partially-implements: bp metadata-for-share-resources
Change-Id: Icf096a5cbc650f02eca68d714c876eb854499b9b
This commit is contained in:
Clifford 2023-07-03 22:51:02 +00:00 committed by ashrod98
parent d643c2601d
commit 2016062027
15 changed files with 471 additions and 29 deletions

View File

@ -204,13 +204,14 @@ REST_API_VERSION_HISTORY = """
* 2.84 - Added mount_point_name to shares.
* 2.85 - Added backup_type field to share backups.
* 2.86 - Add ensure share API.
* 2.87 - Added Share export location metadata API
"""
# The minimum and maximum versions of the API supported
# The default api version request is defined to be the
# minimum version of the API supported.
_MIN_API_VERSION = "2.0"
_MAX_API_VERSION = "2.86"
_MAX_API_VERSION = "2.87"
DEFAULT_API_VERSION = _MIN_API_VERSION

View File

@ -462,3 +462,8 @@ user documentation.
2.86
----
Added ensure shares API.
2.87
----
Added Metadata API methods (GET, PUT, POST, DELETE)
to Share Export Locations.

View File

@ -28,36 +28,42 @@ class MetadataController(object):
"share": "share_get",
"share_snapshot": "share_snapshot_get",
"share_network_subnet": "share_network_subnet_get",
"share_export_location": "export_location_get_by_uuid",
}
resource_metadata_get = {
"share": "share_metadata_get",
"share_snapshot": "share_snapshot_metadata_get",
"share_network_subnet": "share_network_subnet_metadata_get",
"share_export_location": "export_location_metadata_get",
}
resource_metadata_get_item = {
"share": "share_metadata_get_item",
"share_snapshot": "share_snapshot_metadata_get_item",
"share_network_subnet": "share_network_subnet_metadata_get_item",
"share_export_location": "export_location_metadata_get_item",
}
resource_metadata_update = {
"share": "share_metadata_update",
"share_snapshot": "share_snapshot_metadata_update",
"share_network_subnet": "share_network_subnet_metadata_update",
"share_export_location": "export_location_metadata_update",
}
resource_metadata_update_item = {
"share": "share_metadata_update_item",
"share_snapshot": "share_snapshot_metadata_update_item",
"share_network_subnet": "share_network_subnet_metadata_update_item",
"share_export_location": "export_location_metadata_update_item",
}
resource_metadata_delete = {
"share": "share_metadata_delete",
"share_snapshot": "share_snapshot_metadata_delete",
"share_network_subnet": "share_network_subnet_metadata_delete",
"share_export_location": "export_location_metadata_delete",
}
resource_policy_get = {
@ -72,10 +78,13 @@ class MetadataController(object):
def _get_resource(self, context, resource_id,
for_modification=False, parent_id=None):
if self.resource_name in ['share', 'share_network_subnet']:
# we would allow retrieving some "public" resources
# across project namespaces except share snapshots,
# project_only=True is hard coded
if self.resource_name in ['share', 'share_network_subnet',
'share_export_location']:
# some resources don't have a "project_id" field (like
# share_export_location or share_network_subnet),
# and sometimes we want to retrieve "public" resources
# (like shares), so avoid hard coding project_only=True in the
# lookup where necessary
kwargs = {}
else:
kwargs = {'project_only': True}
@ -86,23 +95,25 @@ class MetadataController(object):
kwargs["parent_id"] = parent_id
res = get_res_method(context, resource_id, **kwargs)
get_policy = self.resource_policy_get[self.resource_name]
if res.get('is_public') is False:
authorized = policy.check_policy(context,
self.resource_name,
get_policy,
res,
do_raise=False)
if not authorized:
# Raising NotFound to prevent existence detection
raise exception.NotFound()
elif for_modification:
# a public resource's metadata can be viewed, but not
# modified by non owners
policy.check_policy(context,
self.resource_name,
get_policy,
res)
if self.resource_name not in ["share_export_location"]:
get_policy = self.resource_policy_get[self.resource_name]
# skip policy check for export locations
if res.get('is_public') is False:
authorized = policy.check_policy(context,
self.resource_name,
get_policy,
res,
do_raise=False)
if not authorized:
# Raising NotFound to prevent existence detection
raise exception.NotFound()
elif for_modification:
# a public resource's metadata can be viewed, but not
# modified by non owners
policy.check_policy(context,
self.resource_name,
get_policy,
res)
except exception.NotFound:
msg = _('%s not found.' % self.resource_name.capitalize())
raise exc.HTTPNotFound(explanation=msg)
@ -120,6 +131,7 @@ class MetadataController(object):
@wsgi.response(200)
def _index_metadata(self, req, resource_id, parent_id=None):
"""Lists existing metadata."""
context = req.environ['manila.context']
metadata = self._get_metadata(context, resource_id,
parent_id=parent_id)

View File

@ -259,6 +259,45 @@ class APIRouter(manila.api.openstack.APIRouter):
controller=self.resources["share_export_locations"],
action="show",
conditions={"method": ["GET"]})
mapper.connect("export_locations_metadata",
"%s/shares/{share_id}/export_locations"
"/{resource_id}/metadata" % path_prefix,
controller=self.resources["share_export_locations"],
action="create_metadata",
conditions={"method": ["POST"]})
mapper.connect("export_locations_metadata",
"%s/shares/{share_id}/export_locations"
"/{resource_id}/metadata" % path_prefix,
controller=self.resources["share_export_locations"],
action="update_all_metadata",
conditions={"method": ["PUT"]})
mapper.connect("export_locations_metadata",
"%s/shares/{share_id}/export_locations/"
"{resource_id}/metadata/{key}"
% path_prefix,
controller=self.resources["share_export_locations"],
action="update_metadata_item",
conditions={"method": ["POST"]})
mapper.connect("export_locations_metadata",
"%s/shares/{share_id}/export_locations/"
"{resource_id}/metadata" % path_prefix,
controller=self.resources["share_export_locations"],
action="index_metadata",
conditions={"method": ["GET"]})
mapper.connect("export_locations_metadata",
"%s/shares/{share_id}/export_locations/"
"{resource_id}/metadata/{key}"
% path_prefix,
controller=self.resources["share_export_locations"],
action="show_metadata",
conditions={"method": ["GET"]})
mapper.connect("export_locations_metadata",
"%s/shares/{share_id}/export_locations/"
"{resource_id}/metadata/{key}"
% path_prefix,
controller=self.resources["share_export_locations"],
action="delete_metadata",
conditions={"method": ["DELETE"]})
self.resources["snapshots"] = share_snapshots.create_resource()
mapper.resource("snapshot", "snapshots",

View File

@ -13,23 +13,33 @@
# License for the specific language governing permissions and limitations
# under the License.
from oslo_config import cfg
from oslo_log import log
from webob import exc
from manila.api.openstack import wsgi
from manila.api.v2 import metadata
from manila.api.views import export_locations as export_locations_views
from manila.db import api as db_api
from manila import exception
from manila.i18n import _
from manila import policy
LOG = log.getLogger(__name__)
CONF = cfg.CONF
class ShareExportLocationController(wsgi.Controller):
class ShareExportLocationController(wsgi.Controller,
metadata.MetadataController):
"""The Share Export Locations API controller."""
def __init__(self):
self._view_builder_class = export_locations_views.ViewBuilder
self.resource_name = 'share_export_location'
super(ShareExportLocationController, self).__init__()
self._conf_admin_only_metadata_keys = getattr(
CONF, 'admin_only_el_metadata', []
)
def _verify_share(self, context, share_id):
try:
@ -94,6 +104,96 @@ class ShareExportLocationController(wsgi.Controller):
return self._show(req, share_id, export_location_uuid,
ignore_secondary_replicas=True)
def _validate_metadata_for_update(self, req, share_export_location,
metadata, delete=True):
persistent_keys = set(self._conf_admin_only_metadata_keys)
context = req.environ['manila.context']
if set(metadata).intersection(persistent_keys):
try:
policy.check_policy(
context, 'share_export_location',
'update_admin_only_metadata')
except exception.PolicyNotAuthorized:
msg = _("Cannot set or update admin only metadata.")
LOG.exception(msg)
raise exc.HTTPForbidden(explanation=msg)
persistent_keys = []
current_export_metadata = db_api.export_location_metadata_get(
context, share_export_location)
if delete:
_metadata = metadata
for key in persistent_keys:
if key in current_export_metadata:
_metadata[key] = current_export_metadata[key]
else:
metadata_copy = metadata.copy()
for key in persistent_keys:
metadata_copy.pop(key, None)
_metadata = current_export_metadata.copy()
_metadata.update(metadata_copy)
return _metadata
@wsgi.Controller.api_version("2.87")
@wsgi.Controller.authorize("get_metadata")
def index_metadata(self, req, share_id, resource_id):
"""Returns the list of metadata for a given share export location."""
context = req.environ['manila.context']
self._verify_share(context, share_id)
return self._index_metadata(req, resource_id)
@wsgi.Controller.api_version("2.87")
@wsgi.Controller.authorize("update_metadata")
def create_metadata(self, req, share_id, resource_id, body):
"""Create metadata for a given share export location."""
_metadata = self._validate_metadata_for_update(req, resource_id,
body['metadata'],
delete=False)
body['metadata'] = _metadata
context = req.environ['manila.context']
self._verify_share(context, share_id)
return self._create_metadata(req, resource_id, body)
@wsgi.Controller.api_version("2.87")
@wsgi.Controller.authorize("update_metadata")
def update_all_metadata(self, req, share_id, resource_id, body):
"""Update entire metadata for a given share export location."""
_metadata = self._validate_metadata_for_update(req, resource_id,
body['metadata'])
body['metadata'] = _metadata
context = req.environ['manila.context']
self._verify_share(context, share_id)
return self._update_all_metadata(req, resource_id, body)
@wsgi.Controller.api_version("2.87")
@wsgi.Controller.authorize("update_metadata")
def update_metadata_item(self, req, share_id, resource_id, body, key):
"""Update metadata item for a given share export location."""
_metadata = self._validate_metadata_for_update(req, resource_id,
body['metadata'],
delete=False)
body['metadata'] = _metadata
context = req.environ['manila.context']
self._verify_share(context, share_id)
return self._update_metadata_item(req, resource_id, body, key)
@wsgi.Controller.api_version("2.87")
@wsgi.Controller.authorize("get_metadata")
def show_metadata(self, req, share_id, resource_id, key):
"""Show metadata for a given share export location."""
context = req.environ['manila.context']
self._verify_share(context, share_id)
return self._show_metadata(req, resource_id, key)
@wsgi.Controller.api_version("2.87")
@wsgi.Controller.authorize("delete_metadata")
def delete_metadata(self, req, share_id, resource_id, key):
"""Delete metadata for a given share export location."""
context = req.environ['manila.context']
self._verify_share(context, share_id)
return self._delete_metadata(req, resource_id, key)
def create_resource():
return wsgi.Resource(ShareExportLocationController())

View File

@ -627,9 +627,9 @@ class ShareController(wsgi.Controller,
def _validate_metadata_for_update(self, req, share_id, metadata,
delete=True):
admin_metadata_ignore_keys = set(self._conf_admin_only_metadata_keys)
persistent_keys = set(self._conf_admin_only_metadata_keys)
context = req.environ['manila.context']
if set(metadata).intersection(admin_metadata_ignore_keys):
if set(metadata).intersection(persistent_keys):
try:
policy.check_policy(
context, 'share', 'update_admin_only_metadata')
@ -637,17 +637,17 @@ class ShareController(wsgi.Controller,
msg = _("Cannot set or update admin only metadata.")
LOG.exception(msg)
raise exc.HTTPForbidden(explanation=msg)
admin_metadata_ignore_keys = []
persistent_keys = []
current_share_metadata = db.share_metadata_get(context, share_id)
if delete:
_metadata = metadata
for key in admin_metadata_ignore_keys:
for key in persistent_keys:
if key in current_share_metadata:
_metadata[key] = current_share_metadata[key]
else:
metadata_copy = metadata.copy()
for key in admin_metadata_ignore_keys:
for key in persistent_keys:
metadata_copy.pop(key, None)
_metadata = current_share_metadata.copy()
_metadata.update(metadata_copy)

View File

@ -13,6 +13,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import copy
from oslo_utils import strutils
from manila.api import common
@ -25,6 +27,7 @@ class ViewBuilder(common.ViewBuilder):
_detail_version_modifiers = [
'add_preferred_path_attribute',
'add_metadata_attribute',
]
def _get_export_location_view(self, request, export_location,
@ -87,3 +90,11 @@ class ViewBuilder(common.ViewBuilder):
export_location):
view_dict['preferred'] = strutils.bool_from_string(
export_location['el_metadata'].get('preferred'))
@common.ViewBuilder.versioned_method('2.87')
def add_metadata_attribute(self, context, view_dict,
export_location):
metadata = export_location.get('el_metadata')
meta_copy = copy.copy(metadata)
meta_copy.pop('preferred', None)
view_dict['metadata'] = meta_copy

View File

@ -152,6 +152,10 @@ global_opts = [
help='Whether Manila should update the status of all shares '
'within a backend during ongoing ensure_shares '
'run.'),
cfg.ListOpt('admin_only_el_metadata',
default=constants.AdminOnlyMetadata.EXPORT_LOCATION_KEYS,
help='Metadata keys for export locations that should only be '
'manipulated by administrators.'),
]
CONF.register_opts(global_opts)

View File

@ -362,8 +362,13 @@ class ExtraSpecs(object):
class AdminOnlyMetadata(object):
AFFINITY_KEY = "__affinity_same_host"
ANTI_AFFINITY_KEY = "__affinity_different_host"
PREFERRED_KEY = "preferred"
SCHEDULER_FILTERS = [
AFFINITY_KEY,
ANTI_AFFINITY_KEY,
]
EXPORT_LOCATION_KEYS = [
PREFERRED_KEY,
]

View File

@ -1004,6 +1004,13 @@ def export_location_metadata_get(context, export_location_uuid):
return IMPL.export_location_metadata_get(context, export_location_uuid)
def export_location_metadata_get_item(context, export_location_uuid, key):
"""Get metadata item for a share export location."""
return IMPL.export_location_metadata_get_item(context,
export_location_uuid,
key)
def export_location_metadata_delete(context, export_location_uuid, keys):
"""Delete metadata of an export location."""
return IMPL.export_location_metadata_delete(
@ -1016,6 +1023,14 @@ def export_location_metadata_update(context, export_location_uuid, metadata,
return IMPL.export_location_metadata_update(
context, export_location_uuid, metadata, delete)
def export_location_metadata_update_item(context, export_location_uuid,
metadata):
"""Update metadata item if it exists, otherwise create it."""
return IMPL.export_location_metadata_update_item(context,
export_location_uuid,
metadata)
####################

View File

@ -4698,6 +4698,33 @@ def _export_location_metadata_update(
return metadata
@require_context
@context_manager.reader
def export_location_metadata_get_item(context, export_location_uuid, key):
row = _export_location_metadata_get_item(
context, export_location_uuid, key)
result = {row['key']: row['value']}
return result
@require_context
@context_manager.writer
def export_location_metadata_update_item(context, export_location_uuid,
item):
return _export_location_metadata_update(context, export_location_uuid,
item, delete=False)
def _export_location_metadata_get_item(context, export_location_uuid, key):
result = _export_location_metadata_get_query(
context, export_location_uuid,
).filter_by(key=key).first()
if not result:
raise exception.MetadataItemNotFound()
return result
###################################

View File

@ -34,6 +34,30 @@ deprecated_export_location_show = policy.DeprecatedRule(
deprecated_reason=DEPRECATED_REASON,
deprecated_since=versionutils.deprecated.WALLABY
)
deprecated_update_export_location_metadata = policy.DeprecatedRule(
name=BASE_POLICY_NAME % 'update_metadata',
check_str=base.RULE_DEFAULT,
deprecated_reason=DEPRECATED_REASON,
deprecated_since='2024.2/Dalmatian'
)
deprecated_delete_export_location_metadata = policy.DeprecatedRule(
name=BASE_POLICY_NAME % 'delete_metadata',
check_str=base.RULE_DEFAULT,
deprecated_reason=DEPRECATED_REASON,
deprecated_since='2024.2/Dalmatian'
)
deprecated_get_export_location_metadata = policy.DeprecatedRule(
name=BASE_POLICY_NAME % 'get_metadata',
check_str=base.RULE_DEFAULT,
deprecated_reason=DEPRECATED_REASON,
deprecated_since='2024.2/Dalmatian'
)
deprecated_update_admin_only_metadata = policy.DeprecatedRule(
name=BASE_POLICY_NAME % 'update_admin_only_metadata',
check_str=base.RULE_ADMIN_API,
deprecated_reason=DEPRECATED_REASON,
deprecated_since="2024.2/Dalmatian"
)
share_export_location_policies = [
@ -64,6 +88,79 @@ share_export_location_policies = [
],
deprecated_rule=deprecated_export_location_show
),
policy.DocumentedRuleDefault(
name=BASE_POLICY_NAME % 'update_metadata',
check_str=base.ADMIN_OR_PROJECT_MEMBER,
scope_types=['project'],
description="Update share export location metadata.",
operations=[
{
'method': 'PUT',
'path': ('/shares/{share_id}/export_locations/'
'{export_location_id}/metadata'),
},
{
'method': 'POST',
'path': ('/shares/{share_id}/export_locations/'
'{export_location_id}/metadata/{key}')
},
{
'method': 'POST',
'path': ('/shares/{share_id}/export_locations/'
'{export_location_id}/metadata'),
},
],
deprecated_rule=deprecated_update_export_location_metadata
),
policy.DocumentedRuleDefault(
name=BASE_POLICY_NAME % 'delete_metadata',
check_str=base.ADMIN_OR_PROJECT_MEMBER,
scope_types=['project'],
description="Delete share export location metadata",
operations=[
{
'method': 'DELETE',
'path': ('/shares/{share_id}/export_locations/'
'{export_location_id}/metadata/{key}')
},
],
deprecated_rule=deprecated_delete_export_location_metadata
),
policy.DocumentedRuleDefault(
name=BASE_POLICY_NAME % 'get_metadata',
check_str=base.ADMIN_OR_PROJECT_READER,
scope_types=['project'],
description='Get share export location metadata',
operations=[
{
'method': "GET",
'path': ('/shares/{share_id}/export_locations/'
'{export_location_id}/metadata')
},
{
'method': 'GET',
'path': ('/shares/{share_id}/export_locations/'
'{export_location_id}/metadata/{key}')
},
],
deprecated_rule=deprecated_get_export_location_metadata
),
policy.DocumentedRuleDefault(
name=BASE_POLICY_NAME % 'update_admin_only_metadata',
check_str=base.ADMIN,
scope_types=['project'],
description=(
"Update metadata items that are considered \"admin only\" "
"by the service."),
operations=[
{
'method': 'PUT',
'path': '/shares/{share_id}/export_locations/'
'{export_location_id}/metadata',
}
],
deprecated_rule=deprecated_update_admin_only_metadata
),
]

View File

@ -249,3 +249,98 @@ class ShareExportLocationsAPITest(test.TestCase):
self.share['id'],
index_result['export_locations'][0]['id']
)
def test_validate_metadata_for_update(self):
index_result = self.controller.index(self.req, self.share['id'])
el_id = index_result['export_locations'][0]['id']
metadata = {"foo": "bar", "preferred": "False"}
req = fakes.HTTPRequest.blank(
'/v2/shares/%s/export_locations/%s/metadata' % (
self.share_instance_id, el_id),
version="2.87", use_admin_context=True)
result = self.controller._validate_metadata_for_update(
req, el_id, metadata)
self.assertEqual(metadata, result)
def test_validate_metadata_for_update_invalid(self):
index_result = self.controller.index(self.req, self.share['id'])
el_id = index_result['export_locations'][0]['id']
metadata = {"foo": "bar", "preferred": "False"}
self.mock_policy_check = self.mock_object(
policy, 'check_policy', mock.Mock(
side_effect=exception.PolicyNotAuthorized(
action="update_admin_only_metadata")))
req = fakes.HTTPRequest.blank(
'/v2/shares/%s/export_locations/%s/metadata' % (
self.share_instance_id, el_id),
version="2.87", use_admin_context=False)
self.assertRaises(exc.HTTPForbidden,
self.controller._validate_metadata_for_update,
req, el_id, metadata)
self.mock_policy_check.assert_called_once_with(
req.environ['manila.context'], 'share_export_location',
'update_admin_only_metadata')
def test_create_metadata(self):
index_result = self.controller.index(self.req, self.share['id'])
el_id = index_result['export_locations'][0]['id']
body = {'metadata': {'key1': 'val1', 'key2': 'val2'}}
mock_validate = self.mock_object(
self.controller, '_validate_metadata_for_update',
mock.Mock(return_value=body['metadata']))
mock_create = self.mock_object(
self.controller, '_create_metadata',
mock.Mock(return_value=body))
req = fakes.HTTPRequest.blank(
'/v2/shares/%s/export_locations/%s/metadata' % (
self.share_instance_id, el_id),
version="2.87", use_admin_context=True)
res = self.controller.create_metadata(req, self.share['id'], el_id,
body)
self.assertEqual(body, res)
mock_validate.assert_called_once_with(req, el_id, body['metadata'],
delete=False)
mock_create.assert_called_once_with(req, el_id, body)
def test_update_all_metadata(self):
index_result = self.controller.index(self.req, self.share['id'])
el_id = index_result['export_locations'][0]['id']
body = {'metadata': {'key1': 'val1', 'key2': 'val2'}}
mock_validate = self.mock_object(
self.controller, '_validate_metadata_for_update',
mock.Mock(return_value=body['metadata']))
mock_update = self.mock_object(
self.controller, '_update_all_metadata',
mock.Mock(return_value=body))
req = fakes.HTTPRequest.blank(
'/v2/shares/%s/export_locations/%s/metadata' % (
self.share_instance_id, el_id),
version="2.87", use_admin_context=True)
res = self.controller.update_all_metadata(req, self.share['id'], el_id,
body)
self.assertEqual(body, res)
mock_validate.assert_called_once_with(req, el_id, body['metadata'])
mock_update.assert_called_once_with(req, el_id, body)
def test_delete_metadata(self):
index_result = self.controller.index(self.req, self.share['id'])
el_id = index_result['export_locations'][0]['id']
mock_delete = self.mock_object(
self.controller, '_delete_metadata', mock.Mock())
req = fakes.HTTPRequest.blank(
'/v2/shares/%s/export_locations/%s/metadata/fake_key' % (
self.share_instance_id, el_id),
version="2.87", use_admin_context=True)
self.controller.delete_metadata(req, self.share['id'], el_id,
'fake_key')
mock_delete.assert_called_once_with(req, el_id, 'fake_key')

View File

@ -2481,7 +2481,6 @@ class ShareInstanceExportLocationsMetadataDatabaseAPITestCase(test.TestCase):
self.assertEqual({}, result)
def test_export_location_metadata_update_get(self):
# Write metadata for target export location
export_location_uuid = self._get_export_location_uuid_by_path(
self.initial_locations[0])
@ -2514,6 +2513,29 @@ class ShareInstanceExportLocationsMetadataDatabaseAPITestCase(test.TestCase):
self.assertEqual(updated_metadata, result)
def test_export_location_metadata_get_item(self):
export_location_uuid = self._get_export_location_uuid_by_path(
self.initial_locations[0])
metadata = {'foo_key': 'foo_value', 'bar_key': 'bar_value'}
db_api.export_location_metadata_update(
self.ctxt, export_location_uuid, metadata, False)
result = db_api.export_location_metadata_get_item(
self.ctxt, export_location_uuid, 'foo_key')
self.assertEqual(
{'foo_key': 'foo_value'}, result)
def test_export_location_metadata_get_item_invalid(self):
export_location_uuid = self._get_export_location_uuid_by_path(
self.initial_locations[0])
metadata = {'foo_key': 'foo_value', 'bar_key': 'bar_value'}
db_api.export_location_metadata_update(
self.ctxt, export_location_uuid, metadata, False)
self.assertRaises(exception.MetadataItemNotFound,
db_api.export_location_metadata_get_item,
self.ctxt,
export_location_uuid,
'foo')
@ddt.data(
("k", "v"),
("k" * 256, "v"),

View File

@ -0,0 +1,9 @@
---
features:
- |
Added share export location metadata capabilities including
create, update all, update single, show and delete metadata.
Allows configuration of `admin_only_el_metadata`,
such that keys in this list are able to be manipulated only by
those with admin privileges. By default, this includes
"preferred" key.