diff --git a/manila/api/common.py b/manila/api/common.py index 605243157f..f14bb55830 100644 --- a/manila/api/common.py +++ b/manila/api/common.py @@ -614,7 +614,7 @@ def validate_subnet_create(context, share_network_id, data, return share_network, existing_subnets -def _check_metadata_properties(metadata=None): +def check_metadata_properties(metadata=None): if not metadata: metadata = {} diff --git a/manila/api/openstack/api_version_request.py b/manila/api/openstack/api_version_request.py index 26b5b0d95a..12c13ef888 100644 --- a/manila/api/openstack/api_version_request.py +++ b/manila/api/openstack/api_version_request.py @@ -194,6 +194,7 @@ REST_API_VERSION_HISTORY = """ promote API. * 2.76 - Added 'default_ad_site' field in security service object. * 2.77 - Added support for share transfer between different projects. + * 2.78 - Added Share Network Subnet Metadata to Metadata API. """ @@ -201,7 +202,7 @@ REST_API_VERSION_HISTORY = """ # 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.77" +_MAX_API_VERSION = "2.78" DEFAULT_API_VERSION = _MIN_API_VERSION diff --git a/manila/api/openstack/rest_api_version_history.rst b/manila/api/openstack/rest_api_version_history.rst index f333f02b53..03aad792dc 100644 --- a/manila/api/openstack/rest_api_version_history.rst +++ b/manila/api/openstack/rest_api_version_history.rst @@ -422,3 +422,8 @@ ____ 2.77 ---- Added support for share transfer between different projects. + +2.78 +---- + Added Metadata API methods (GET, PUT, POST, DELETE) + to Share Network Subnets. diff --git a/manila/api/v1/share_metadata.py b/manila/api/v1/share_metadata.py index 589a4470aa..0d08a33712 100644 --- a/manila/api/v1/share_metadata.py +++ b/manila/api/v1/share_metadata.py @@ -134,7 +134,7 @@ class ShareMetadataController(object): _metadata = orig_meta.copy() _metadata.update(metadata_copy) - api_common._check_metadata_properties(_metadata) + api_common.check_metadata_properties(_metadata) db.share_metadata_update(context, share['id'], _metadata, delete) diff --git a/manila/api/v2/metadata.py b/manila/api/v2/metadata.py index 78e0056a64..40afd306dc 100644 --- a/manila/api/v2/metadata.py +++ b/manila/api/v2/metadata.py @@ -27,36 +27,43 @@ class MetadataController(object): resource_get = { "share": "share_get", "share_snapshot": "share_snapshot_get", + "share_network_subnet": "share_network_subnet_get", } resource_metadata_get = { "share": "share_metadata_get", "share_snapshot": "share_snapshot_metadata_get", + "share_network_subnet": "share_network_subnet_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", } resource_metadata_update = { "share": "share_metadata_update", "share_snapshot": "share_snapshot_metadata_update", + "share_network_subnet": "share_network_subnet_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", } resource_metadata_delete = { "share": "share_metadata_delete", "share_snapshot": "share_snapshot_metadata_delete", + "share_network_subnet": "share_network_subnet_metadata_delete", } resource_policy_get = { 'share': 'get', 'share_snapshot': 'get_snapshot', + 'share_network_subnet': 'show', } def __init__(self): @@ -65,7 +72,7 @@ class MetadataController(object): def _get_resource(self, context, resource_id, for_modification=False, parent_id=None): - if self.resource_name in ['share']: + if self.resource_name in ['share', 'share_network_subnet']: # we would allow retrieving some "public" resources # across project namespaces excpet share snaphots, # project_only=True is hard coded @@ -114,7 +121,7 @@ class MetadataController(object): context = req.environ['manila.context'] try: metadata = body['metadata'] - common._check_metadata_properties(metadata) + common.check_metadata_properties(metadata) except (KeyError, TypeError): msg = _("Malformed request body") raise exc.HTTPBadRequest(explanation=msg) @@ -140,7 +147,7 @@ class MetadataController(object): context = req.environ['manila.context'] try: meta_item = body['metadata'] - common._check_metadata_properties(meta_item) + common.check_metadata_properties(meta_item) except (TypeError, KeyError): expl = _('Malformed request body') raise exc.HTTPBadRequest(explanation=expl) @@ -171,7 +178,7 @@ class MetadataController(object): context = req.environ['manila.context'] try: metadata = body['metadata'] - common._check_metadata_properties(metadata) + common.check_metadata_properties(metadata) except (TypeError, KeyError): expl = _('Malformed request body') raise exc.HTTPBadRequest(explanation=expl) diff --git a/manila/api/v2/router.py b/manila/api/v2/router.py index 0dfe82f13e..fcc74f9932 100644 --- a/manila/api/v2/router.py +++ b/manila/api/v2/router.py @@ -403,6 +403,48 @@ class APIRouter(manila.api.openstack.APIRouter): action="index", conditions={"method": ["GET"]}) + for path_prefix in ['/{project_id}', '']: + # project_id is optional + mapper.connect("subnets_metadata", + "%s/share-networks/{share_network_id}" + "/subnets/{resource_id}/metadata" % path_prefix, + controller=self.resources["share_network_subnets"], + action="create_metadata", + conditions={"method": ["POST"]}) + mapper.connect("subnets_metadata", + "%s/share-networks/{share_network_id}" + "/subnets/{resource_id}/metadata" % path_prefix, + controller=self.resources["share_network_subnets"], + action="update_all_metadata", + conditions={"method": ["PUT"]}) + mapper.connect("subnets_metadata", + "%s/share-networks/{share_network_id}" + "/subnets/{resource_id}" + "/metadata/{key}" % path_prefix, + controller=self.resources["share_network_subnets"], + action="update_metadata_item", + conditions={"method": ["POST"]}) + mapper.connect("subnets_metadata", + "%s/share-networks/{share_network_id}" + "/subnets/{resource_id}/metadata" % path_prefix, + controller=self.resources["share_network_subnets"], + action="index_metadata", + conditions={"method": ["GET"]}) + mapper.connect("subnets_metadata", + "%s/share-networks/{share_network_id}" + "/subnets/{resource_id}" + "/metadata/{key}" % path_prefix, + controller=self.resources["share_network_subnets"], + action="show_metadata", + conditions={"method": ["GET"]}) + mapper.connect("subnets_metadata", + "%s/share-networks/{share_network_id}" + "/subnets/{resource_id}" + "/metadata/{key}" % path_prefix, + controller=self.resources["share_network_subnets"], + action="delete_metadata", + conditions={"method": ["DELETE"]}) + self.resources["share_servers"] = share_servers.create_resource() mapper.resource("share_server", "share-servers", diff --git a/manila/api/v2/share_network_subnets.py b/manila/api/v2/share_network_subnets.py index ae403f8ded..19aba3e980 100644 --- a/manila/api/v2/share_network_subnets.py +++ b/manila/api/v2/share_network_subnets.py @@ -21,8 +21,10 @@ from oslo_log import log import webob from webob import exc +from manila.api import common as api_common from manila.api.openstack import api_version_request as api_version from manila.api.openstack import wsgi +from manila.api.v2 import metadata as metadata_controller from manila.api.views import share_network_subnets as subnet_views from manila.db import api as db_api from manila import exception @@ -33,7 +35,8 @@ from manila.share import rpcapi as share_rpcapi LOG = log.getLogger(__name__) -class ShareNetworkSubnetController(wsgi.Controller): +class ShareNetworkSubnetController(wsgi.Controller, + metadata_controller.MetadataController): """The Share Network Subnet API controller for the OpenStack API.""" resource_name = 'share_network_subnet' @@ -116,6 +119,12 @@ class ShareNetworkSubnetController(wsgi.Controller): msg = _("Share Network Subnet is missing from the request body.") raise exc.HTTPBadRequest(explanation=msg) data = body['share-network-subnet'] + + if req.api_version_request >= api_version.APIVersionRequest("2.78"): + api_common.check_metadata_properties(data.get('metadata')) + else: + data.pop('metadata', None) + data['share_network_id'] = share_network_id multiple_subnet_support = (req.api_version_request >= api_version.APIVersionRequest("2.70")) @@ -181,6 +190,49 @@ class ShareNetworkSubnetController(wsgi.Controller): return self._view_builder.build_share_network_subnet( req, share_network_subnet) + @wsgi.Controller.api_version("2.78") + @wsgi.Controller.authorize("get_metadata") + def index_metadata(self, req, share_network_id, resource_id): + """Returns the list of metadata for a given share network subnet.""" + return self._index_metadata(req, resource_id, + parent_id=share_network_id) + + @wsgi.Controller.api_version("2.78") + @wsgi.Controller.authorize("update_metadata") + def create_metadata(self, req, share_network_id, resource_id, body): + """Create metadata for a given share network subnet.""" + return self._create_metadata(req, resource_id, body, + parent_id=share_network_id) + + @wsgi.Controller.api_version("2.78") + @wsgi.Controller.authorize("update_metadata") + def update_all_metadata(self, req, share_network_id, resource_id, body): + """Update entire metadata for a given share network subnet.""" + return self._update_all_metadata(req, resource_id, body, + parent_id=share_network_id) + + @wsgi.Controller.api_version("2.78") + @wsgi.Controller.authorize("update_metadata") + def update_metadata_item(self, req, share_network_id, resource_id, body, + key): + """Update metadata item for a given share network subnet.""" + return self._update_metadata_item(req, resource_id, body, key, + parent_id=share_network_id) + + @wsgi.Controller.api_version("2.78") + @wsgi.Controller.authorize("get_metadata") + def show_metadata(self, req, share_network_id, resource_id, key): + """Show metadata for a given share network subnet.""" + return self._show_metadata(req, resource_id, key, + parent_id=share_network_id) + + @wsgi.Controller.api_version("2.78") + @wsgi.Controller.authorize("delete_metadata") + def delete_metadata(self, req, share_network_id, resource_id, key): + """Delete metadata for a given share network subnet.""" + return self._delete_metadata(req, resource_id, key, + parent_id=share_network_id) + def create_resource(): return wsgi.Resource(ShareNetworkSubnetController()) diff --git a/manila/api/views/share_network_subnets.py b/manila/api/views/share_network_subnets.py index 342ff2bec0..25d071ed53 100644 --- a/manila/api/views/share_network_subnets.py +++ b/manila/api/views/share_network_subnets.py @@ -20,6 +20,9 @@ class ViewBuilder(common.ViewBuilder): """Model a server API response as a python dictionary.""" _collection_name = 'share_network_subnets' + _detail_version_modifiers = [ + "add_metadata" + ] def build_share_network_subnet(self, request, share_network_subnet): return { @@ -51,3 +54,7 @@ class ViewBuilder(common.ViewBuilder): } self.update_versioned_resource_dict(request, sns, share_network_subnet) return sns + + @common.ViewBuilder.versioned_method("2.78") + def add_metadata(self, context, share_network_subnet_dict, sns): + share_network_subnet_dict['metadata'] = sns.get('subnet_metadata') diff --git a/manila/api/views/share_networks.py b/manila/api/views/share_networks.py index 2c8f5c9afd..bb2a5c4719 100644 --- a/manila/api/views/share_networks.py +++ b/manila/api/views/share_networks.py @@ -23,7 +23,8 @@ class ViewBuilder(common.ViewBuilder): _detail_version_modifiers = ["add_gateway", "add_mtu", "add_nova_net_id", "add_subnets", "add_status_and_sec_service_update_fields", - "add_network_allocation_update_support_field"] + "add_network_allocation_update_support_field", + "add_subnet_with_metadata"] def build_share_network(self, request, share_network): """View of a share network.""" @@ -104,7 +105,7 @@ class ViewBuilder(common.ViewBuilder): self.update_versioned_resource_dict(request, sn, share_network) return sn - @common.ViewBuilder.versioned_method("2.51") + @common.ViewBuilder.versioned_method("2.51", "2.77") def add_subnets(self, context, network_dict, network): subnets = [{ 'id': sns.get('id'), @@ -152,3 +153,28 @@ class ViewBuilder(common.ViewBuilder): self, context, network_dict, network): network_dict['network_allocation_update_support'] = network.get( 'network_allocation_update_support') + + @common.ViewBuilder.versioned_method("2.78") + def add_subnet_with_metadata(self, context, network_dict, network): + subnets = [{ + 'id': sns.get('id'), + 'availability_zone': sns.get('availability_zone'), + 'created_at': sns.get('created_at'), + 'updated_at': sns.get('updated_at'), + 'segmentation_id': sns.get('segmentation_id'), + 'neutron_net_id': sns.get('neutron_net_id'), + 'neutron_subnet_id': sns.get('neutron_subnet_id'), + 'ip_version': sns.get('ip_version'), + 'cidr': sns.get('cidr'), + 'network_type': sns.get('network_type'), + 'mtu': sns.get('mtu'), + 'gateway': sns.get('gateway'), + 'metadata': sns.get('subnet_metadata'), + } for sns in network.get('share_network_subnets')] + + network_dict['share_network_subnets'] = subnets + attr_to_remove = [ + 'neutron_net_id', 'neutron_subnet_id', 'network_type', + 'segmentation_id', 'cidr', 'ip_version', 'gateway', 'mtu'] + for attr in attr_to_remove: + network_dict.pop(attr) diff --git a/manila/db/api.py b/manila/db/api.py index a5ded26fe3..1424bba0ce 100644 --- a/manila/db/api.py +++ b/manila/db/api.py @@ -1072,10 +1072,11 @@ def share_network_subnet_update(context, network_subnet_id, values): return IMPL.share_network_subnet_update(context, network_subnet_id, values) -def share_network_subnet_get(context, network_subnet_id, session=None): +def share_network_subnet_get(context, network_subnet_id, session=None, + parent_id=None): """Get requested share network subnet DB record.""" return IMPL.share_network_subnet_get(context, network_subnet_id, - session=session) + session=session, parent_id=parent_id) def share_network_subnet_get_all_with_same_az(context, network_subnet_id, @@ -1118,8 +1119,47 @@ def share_network_subnet_get_all_by_share_server_id(context, share_server_id): return IMPL.share_network_subnet_get_all_by_share_server_id( context, share_server_id) +#################### -################## + +def share_network_subnet_metadata_get(context, share_network_subnet_id, + **kwargs): + """Get all metadata for a share network subnet.""" + return IMPL.share_network_subnet_metadata_get(context, + share_network_subnet_id, + **kwargs) + + +def share_network_subnet_metadata_get_item(context, share_network_subnet_id, + key): + """Get metadata item for a share network subnet.""" + return IMPL.share_network_subnet_metadata_get_item(context, + share_network_subnet_id, + key) + + +def share_network_subnet_metadata_delete(context, share_network_subnet_id, + key): + """Delete the given metadata item.""" + IMPL.share_network_subnet_metadata_delete(context, share_network_subnet_id, + key) + + +def share_network_subnet_metadata_update(context, share_network_subnet_id, + metadata, delete): + """Update metadata if it exists, otherwise create it.""" + return IMPL.share_network_subnet_metadata_update(context, + share_network_subnet_id, + metadata, delete) + + +def share_network_subnet_metadata_update_item(context, share_network_subnet_id, + metadata): + """Update metadata item if it exists, otherwise create it.""" + return IMPL.share_network_subnet_metadata_update_item( + context, share_network_subnet_id, metadata) + +################### def network_allocation_create(context, values): diff --git a/manila/db/migrations/alembic/versions/ac0620cbe74d_add_share_network_subnet_metadata.py b/manila/db/migrations/alembic/versions/ac0620cbe74d_add_share_network_subnet_metadata.py new file mode 100644 index 0000000000..d7b7d5cb75 --- /dev/null +++ b/manila/db/migrations/alembic/versions/ac0620cbe74d_add_share_network_subnet_metadata.py @@ -0,0 +1,67 @@ +# 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. + +"""Add share network subnet metadata + +Revision ID: ac0620cbe74d +Revises: 1e2d600bf972 +Create Date: 2023-01-07 14:13:25.525968 + +""" + +# revision identifiers, used by Alembic. +revision = 'ac0620cbe74d' +down_revision = '1e2d600bf972' + +from alembic import op +from oslo_log import log +import sqlalchemy as sql + +LOG = log.getLogger(__name__) + +share_network_subnet_metadata_table_name = 'share_network_subnet_metadata' + + +def upgrade(): + context = op.get_context() + mysql_dl = context.bind.dialect.name == 'mysql' + datetime_type = (sql.dialects.mysql.DATETIME(fsp=6) + if mysql_dl else sql.DateTime) + try: + op.create_table( + share_network_subnet_metadata_table_name, + sql.Column('deleted', sql.String(36), default='False'), + sql.Column('created_at', datetime_type), + sql.Column('updated_at', datetime_type), + sql.Column('deleted_at', datetime_type), + sql.Column('share_network_subnet_id', sql.String(36), + sql.ForeignKey('share_network_subnets.id'), + nullable=False), + sql.Column('key', sql.String(255), nullable=False), + sql.Column('value', sql.String(1023), nullable=False), + sql.Column('id', sql.Integer, primary_key=True, nullable=False), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + except Exception: + LOG.error("Table |%s| not created!", + share_network_subnet_metadata_table_name) + raise + + +def downgrade(): + try: + op.drop_table(share_network_subnet_metadata_table_name) + except Exception: + LOG.error("Table |%s| not dropped!", + share_network_subnet_metadata_table_name) + raise diff --git a/manila/db/sqlalchemy/api.py b/manila/db/sqlalchemy/api.py index 3d60db9a61..316a13a630 100644 --- a/manila/db/sqlalchemy/api.py +++ b/manila/db/sqlalchemy/api.py @@ -201,6 +201,20 @@ def require_share_snapshot_exists(f): return wrapper +def require_share_network_subnet_exists(f): + """Decorator to require the specified share network subnet to exist. + + Requires the wrapped function to use context and share_network_subnet_id + as their first two arguments. + """ + @wraps(f) + def wrapper(context, share_network_subnet_id, *args, **kwargs): + share_network_subnet_get(context, share_network_subnet_id) + return f(context, share_network_subnet_id, *args, **kwargs) + wrapper.__name__ = f.__name__ + return wrapper + + def require_share_instance_exists(f): """Decorator to require the specified share instance to exist. @@ -4611,12 +4625,16 @@ def _network_subnet_get_query(context, session=None): if session is None: session = get_session() return (model_query(context, models.ShareNetworkSubnet, session=session). - options(joinedload('share_servers'), joinedload('share_network'))) + options(joinedload('share_servers'), + joinedload('share_network'), + joinedload('share_network_subnet_metadata'))) @require_context def share_network_subnet_create(context, values): values = ensure_model_dict_has_id(values) + values['share_network_subnet_metadata'] = _metadata_refs( + values.pop('metadata', {}), models.ShareNetworkSubnetMetadata) network_subnet_ref = models.ShareNetworkSubnet() network_subnet_ref.update(values) @@ -4635,6 +4653,8 @@ def share_network_subnet_delete(context, network_subnet_id): network_subnet_ref = share_network_subnet_get(context, network_subnet_id, session=session) + session.query(models.ShareNetworkSubnetMetadata).filter_by( + share_network_subnet_id=network_subnet_id).soft_delete() network_subnet_ref.soft_delete(session=session, update_status=True) @@ -4652,9 +4672,13 @@ def share_network_subnet_update(context, network_subnet_id, values): @require_context -def share_network_subnet_get(context, network_subnet_id, session=None): +def share_network_subnet_get(context, network_subnet_id, session=None, + parent_id=None): + kwargs = {'id': network_subnet_id} + if parent_id: + kwargs['share_network_id'] = parent_id result = (_network_subnet_get_query(context, session) - .filter_by(id=network_subnet_id) + .filter_by(**kwargs) .first()) if result is None: raise exception.ShareNetworkSubnetNotFound( @@ -4740,10 +4764,126 @@ def share_network_subnet_get_all_by_share_server_id(context, share_server_id): return result - ################### +@require_context +@require_share_network_subnet_exists +def share_network_subnet_metadata_get(context, share_network_subnet_id): + session = get_session() + return _share_network_subnet_metadata_get(context, share_network_subnet_id, + session=session) + + +@require_context +@require_share_network_subnet_exists +def share_network_subnet_metadata_delete(context, share_network_subnet_id, + key): + session = get_session() + meta_ref = _share_network_subnet_metadata_get_item( + context, share_network_subnet_id, key, session=session) + meta_ref.soft_delete(session=session) + + +@require_context +@require_share_network_subnet_exists +def share_network_subnet_metadata_update(context, share_network_subnet_id, + metadata, delete): + session = get_session() + return _share_network_subnet_metadata_update( + context, share_network_subnet_id, metadata, delete, session=session) + + +def share_network_subnet_metadata_update_item(context, share_network_subnet_id, + item): + session = get_session() + return _share_network_subnet_metadata_update( + context, share_network_subnet_id, item, delete=False, session=session) + + +def share_network_subnet_metadata_get_item(context, share_network_subnet_id, + key): + + session = get_session() + row = _share_network_subnet_metadata_get_item( + context, share_network_subnet_id, key, session=session) + + result = {row['key']: row['value']} + return result + + +def _share_network_subnet_metadata_get_query(context, share_network_subnet_id, + session=None): + session = session or get_session() + return (model_query(context, models.ShareNetworkSubnetMetadata, + session=session, + read_deleted="no"). + filter_by(share_network_subnet_id=share_network_subnet_id). + options(joinedload('share_network_subnet'))) + + +def _share_network_subnet_metadata_get(context, share_network_subnet_id, + session=None): + session = session or get_session() + rows = _share_network_subnet_metadata_get_query( + context, share_network_subnet_id, session=session).all() + + result = {} + for row in rows: + result[row['key']] = row['value'] + return result + + +def _share_network_subnet_metadata_get_item(context, share_network_subnet_id, + key, session=None): + session = session or get_session() + result = (_share_network_subnet_metadata_get_query( + context, share_network_subnet_id, session=session). + filter_by(key=key).first()) + if not result: + raise exception.MetadataItemNotFound + return result + + +@oslo_db_api.wrap_db_retry(max_retries=5, retry_on_deadlock=True) +def _share_network_subnet_metadata_update(context, share_network_subnet_id, + metadata, delete, session=None): + session = session or get_session() + delete = strutils.bool_from_string(delete) + with session.begin(): + if delete: + original_metadata = _share_network_subnet_metadata_get( + context, share_network_subnet_id, session=session) + for meta_key, meta_value in original_metadata.items(): + if meta_key not in metadata: + meta_ref = _share_network_subnet_metadata_get_item( + context, share_network_subnet_id, meta_key, + session=session) + meta_ref.soft_delete(session=session) + meta_ref = None + # Now update all existing items with new values, or create new meta + # objects. + for meta_key, meta_value in metadata.items(): + + # update the value whether it exists or not. + item = {"value": meta_value} + meta_ref = _share_network_subnet_metadata_get_query( + context, share_network_subnet_id, + session=session).filter_by( + key=meta_key).first() + if not meta_ref: + meta_ref = models.ShareNetworkSubnetMetadata() + item.update( + {"key": meta_key, + "share_network_subnet_id": share_network_subnet_id}) + meta_ref.update(item) + meta_ref.save(session=session) + + return metadata + +################################# + + def _server_get_query(context, session=None): if session is None: session = get_session() diff --git a/manila/db/sqlalchemy/models.py b/manila/db/sqlalchemy/models.py index d37aaa37e2..5cf7fda4f9 100644 --- a/manila/db/sqlalchemy/models.py +++ b/manila/db/sqlalchemy/models.py @@ -1001,7 +1001,7 @@ class ShareNetwork(BASE, ManilaBase): class ShareNetworkSubnet(BASE, ManilaBase): """Represents a share network subnet used by some resources.""" - _extra_keys = ['availability_zone'] + _extra_keys = ['availability_zone', 'subnet_metadata'] __tablename__ = 'share_network_subnets' id = Column(String(36), primary_key=True, nullable=False) @@ -1056,6 +1056,35 @@ class ShareNetworkSubnet(BASE, ManilaBase): def share_network_name(self): return self.share_network['name'] + @property + def subnet_metadata(self): + metadata_dict = {} + metadata_list = ( + self.share_network_subnet_metadata) # pylint: disable=no-member + for meta in metadata_list: + metadata_dict[meta['key']] = meta['value'] + return metadata_dict + + +class ShareNetworkSubnetMetadata(BASE, ManilaBase): + """Represents a metadata key/value pair for a subnet.""" + __tablename__ = 'share_network_subnet_metadata' + id = Column(Integer, primary_key=True) + key = Column(String(255), nullable=False) + value = Column(String(1023), nullable=False) + deleted = Column(String(36), default='False') + share_network_subnet_id = Column(String(36), ForeignKey( + 'share_network_subnets.id'), nullable=False) + + share_network_subnet = orm.relationship( + ShareNetworkSubnet, + backref=orm.backref('share_network_subnet_metadata', lazy='immediate'), + foreign_keys=share_network_subnet_id, + primaryjoin='and_(' + 'ShareNetworkSubnetMetadata.share_network_subnet_id == ' + 'ShareNetworkSubnet.id,' + 'ShareNetworkSubnetMetadata.deleted == "False")') + class ShareServer(BASE, ManilaBase): """Represents share server used by share.""" diff --git a/manila/policies/share_network_subnet.py b/manila/policies/share_network_subnet.py index df281ff9bc..18c90f15a5 100644 --- a/manila/policies/share_network_subnet.py +++ b/manila/policies/share_network_subnet.py @@ -48,6 +48,24 @@ deprecated_subnet_index = policy.DeprecatedRule( deprecated_reason=DEPRECATED_REASON, deprecated_since=versionutils.deprecated.WALLABY ) +deprecated_update_subnet_metadata = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'update_metadata', + check_str=base.RULE_DEFAULT, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='ANTELOPE' +) +deprecated_delete_subnet_metadata = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'delete_metadata', + check_str=base.RULE_DEFAULT, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='ANTELOPE' +) +deprecated_get_subnet_metadata = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'get_metadata', + check_str=base.RULE_DEFAULT, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='ANTELOPE' +) share_network_subnet_policies = [ @@ -105,6 +123,63 @@ share_network_subnet_policies = [ ], deprecated_rule=deprecated_subnet_index ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'update_metadata', + check_str=base.ADMIN_OR_PROJECT_MEMBER, + scope_types=['system', 'project'], + description="Update share network subnet metadata.", + operations=[ + { + 'method': 'PUT', + 'path': '/share-networks/{share_network_id}/subnets/' + '{share_network_subnet_id}/metadata', + }, + { + 'method': 'POST', + 'path': '/share-networks/{share_network_id}/subnets/' + '{share_network_subnet_id}/metadata/{key}', + }, + { + 'method': 'POST', + 'path': '/share-networks/{share_network_id}/subnets/' + '{share_network_subnet_id}/metadata', + }, + ], + deprecated_rule=deprecated_update_subnet_metadata + ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'delete_metadata', + check_str=base.ADMIN_OR_PROJECT_MEMBER, + scope_types=['system', 'project'], + description="Delete share network subnet metadata.", + operations=[ + { + 'method': 'DELETE', + 'path': '/share-networks/{share_network_id}/subnets/' + '{share_network_subnet_id}/metadata/{key}', + } + ], + deprecated_rule=deprecated_delete_subnet_metadata + ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'get_metadata', + check_str=base.ADMIN_OR_PROJECT_READER, + scope_types=['system', 'project'], + description="Get share network subnet metadata.", + operations=[ + { + 'method': 'GET', + 'path': '/share-networks/{share_network_id}/subnets/' + '{share_network_subnet_id}/metadata', + }, + { + 'method': 'GET', + 'path': '/share-networks/{share_network_id}/subnets/' + '{share_network_subnet_id}/metadata/{key}', + } + ], + deprecated_rule=deprecated_get_subnet_metadata + ), ] diff --git a/manila/share/api.py b/manila/share/api.py index 2fecb525f2..204d2d0d9e 100644 --- a/manila/share/api.py +++ b/manila/share/api.py @@ -223,7 +223,7 @@ class API(base.Base): az_request_multiple_subnet_support_map=None): """Create new share.""" - api_common._check_metadata_properties(metadata) + api_common.check_metadata_properties(metadata) if snapshot_id is not None: snapshot = self.get_snapshot(context, snapshot_id) @@ -1449,7 +1449,7 @@ class API(base.Base): force=False, metadata=None): policy.check_policy(context, 'share', 'create_snapshot', share) if metadata: - api_common._check_metadata_properties(metadata) + api_common.check_metadata_properties(metadata) if ((not force) and (share['status'] != constants.STATUS_AVAILABLE)): msg = _("Source share status must be " @@ -2231,7 +2231,7 @@ class API(base.Base): msg = _("Invalid share access level: %s.") % access_level raise exception.InvalidShareAccess(reason=msg) - api_common._check_metadata_properties(metadata) + api_common.check_metadata_properties(metadata) access_exists = self.db.share_access_check_for_existing_access( ctx, share['id'], access_type, access_to) @@ -2413,7 +2413,7 @@ class API(base.Base): def update_share_access_metadata(self, context, access_id, metadata): """Updates share access metadata.""" try: - api_common._check_metadata_properties(metadata) + api_common.check_metadata_properties(metadata) except exception.InvalidMetadata: raise exception.InvalidMetadata() except exception.InvalidMetadataSize: diff --git a/manila/share/manager.py b/manila/share/manager.py index 89c98ffe33..6f3735b9fe 100644 --- a/manila/share/manager.py +++ b/manila/share/manager.py @@ -4209,6 +4209,7 @@ class ShareManager(manager.SchedulerDependentManager): 'admin_network_allocations': admin_network_allocations, 'backend_details': share_server.get('backend_details'), 'network_type': share_network_subnet['network_type'], + 'subnet_metadata': share_network_subnet['subnet_metadata'] }) return network_info diff --git a/manila/tests/api/v2/test_share_network_subnets.py b/manila/tests/api/v2/test_share_network_subnets.py index 2d7be05e76..cd3b6a9dc4 100644 --- a/manila/tests/api/v2/test_share_network_subnets.py +++ b/manila/tests/api/v2/test_share_network_subnets.py @@ -62,8 +62,10 @@ class ShareNetworkSubnetControllerTest(test.TestCase): mock.Mock(return_value=fake_az)) self.share_network = db_utils.create_share_network( name='fake_network', id='fake_sn_id') + self.subnet_metadata = {'fake_key': 'fake_value'} self.subnet = db_utils.create_share_network_subnet( - share_network_id=self.share_network['id']) + share_network_id=self.share_network['id'], + metadata=self.subnet_metadata) self.share_server = db_utils.create_share_server( share_network_subnets=[self.subnet]) self.share = db_utils.create_share() @@ -212,27 +214,35 @@ class ShareNetworkSubnetControllerTest(test.TestCase): self.mock_policy_check.assert_called_once_with( context, self.resource_name, 'delete') - def _setup_create_test_request_body(self): + def _setup_create_test_request_body(self, metadata=False): body = { 'share_network_id': self.share_network['id'], 'availability_zone': fake_az['name'], 'neutron_net_id': 'fake_nn_id', 'neutron_subnet_id': 'fake_nsn_id' } + if metadata: + body['metadata'] = self.subnet_metadata return body @ddt.data({'version': "2.51", 'has_share_servers': False}, {'version': "2.70", 'has_share_servers': False}, - {'version': "2.70", 'has_share_servers': True}) + {'version': "2.70", 'has_share_servers': True}, + {'version': "2.78", 'has_share_servers': False}) @ddt.unpack def test_subnet_create(self, version, has_share_servers): req = fakes.HTTPRequest.blank('/subnets', version=version) multiple_subnet_support = (req.api_version_request >= api_version.APIVersionRequest("2.70")) + metadata_support = (req.api_version_request >= + api_version.APIVersionRequest("2.78")) + context = req.environ['manila.context'] body = { - 'share-network-subnet': self._setup_create_test_request_body() + 'share-network-subnet': self._setup_create_test_request_body( + metadata=metadata_support) } + sn_id = body['share-network-subnet']['share_network_id'] expected_subnet = copy.deepcopy(self.subnet) if has_share_servers: @@ -251,6 +261,8 @@ class ShareNetworkSubnetControllerTest(test.TestCase): mock_share_network_subnet_get = self.mock_object( db_api, 'share_network_subnet_get', mock.Mock(return_value=expected_subnet)) + mock_check_metadata_properties = self.mock_object( + common, 'check_metadata_properties') fake_data = body['share-network-subnet'] fake_data['share_network_id'] = self.share_network['id'] @@ -273,6 +285,8 @@ class ShareNetworkSubnetControllerTest(test.TestCase): 'mtu': expected_subnet.get('mtu'), 'gateway': expected_subnet.get('gateway') } + if metadata_support: + view_subnet['metadata'] = self.subnet_metadata self.assertEqual(view_subnet, res['share_network_subnet']) mock_share_network_subnet_get.assert_called_once_with( context, expected_subnet['id']) @@ -285,6 +299,8 @@ class ShareNetworkSubnetControllerTest(test.TestCase): else: mock_subnet_create.assert_called_once_with( context, fake_data) + self.assertEqual(metadata_support, + mock_check_metadata_properties.called) @ddt.data({'exception1': exception.ServiceIsDown(service='fake_srv'), 'exc_raise': exc.HTTPInternalServerError}, @@ -475,3 +491,87 @@ class ShareNetworkSubnetControllerTest(test.TestCase): req, self.share_network['id']) mock_sn_get.assert_called_once_with(context, self.share_network['id']) + + def test_index_metadata(self): + req = fakes.HTTPRequest.blank('/subnets/', version="2.78") + mock_index = self.mock_object( + self.controller, '_index_metadata', + mock.Mock(return_value='fake_metadata')) + + result = self.controller.index_metadata(req, self.share_network['id'], + self.subnet['id']) + + self.assertEqual('fake_metadata', result) + mock_index.assert_called_once_with(req, self.subnet['id'], + parent_id=self.share_network['id']) + + def test_create_metadata(self): + req = fakes.HTTPRequest.blank('/subnets/', version="2.78") + mock_index = self.mock_object( + self.controller, '_create_metadata', + mock.Mock(return_value='fake_metadata')) + + body = 'fake_metadata_body' + result = self.controller.create_metadata(req, self.share_network['id'], + self.subnet['id'], body) + + self.assertEqual('fake_metadata', result) + mock_index.assert_called_once_with(req, self.subnet['id'], body, + parent_id=self.share_network['id']) + + def test_update_all_metadata(self): + req = fakes.HTTPRequest.blank('/subnets/', version="2.78") + mock_index = self.mock_object( + self.controller, '_update_all_metadata', + mock.Mock(return_value='fake_metadata')) + + body = 'fake_metadata_body' + result = self.controller.update_all_metadata( + req, self.share_network['id'], self.subnet['id'], body) + + self.assertEqual('fake_metadata', result) + mock_index.assert_called_once_with(req, self.subnet['id'], body, + parent_id=self.share_network['id']) + + def test_update_metadata_item(self): + req = fakes.HTTPRequest.blank('/subnets/', version="2.78") + mock_index = self.mock_object( + self.controller, '_update_metadata_item', + mock.Mock(return_value='fake_metadata')) + + body = 'fake_metadata_body' + key = 'fake_key' + result = self.controller.update_metadata_item( + req, self.share_network['id'], self.subnet['id'], body, key) + + self.assertEqual('fake_metadata', result) + mock_index.assert_called_once_with(req, self.subnet['id'], body, key, + parent_id=self.share_network['id']) + + def test_show_metadata(self): + req = fakes.HTTPRequest.blank('/subnets/', version="2.78") + mock_index = self.mock_object( + self.controller, '_show_metadata', + mock.Mock(return_value='fake_metadata')) + + key = 'fake_key' + result = self.controller.show_metadata( + req, self.share_network['id'], self.subnet['id'], key) + + self.assertEqual('fake_metadata', result) + mock_index.assert_called_once_with(req, self.subnet['id'], key, + parent_id=self.share_network['id']) + + def test_delete_metadata(self): + req = fakes.HTTPRequest.blank('/subnets/', version="2.78") + mock_index = self.mock_object( + self.controller, '_delete_metadata', + mock.Mock(return_value='fake_metadata')) + + key = 'fake_key' + result = self.controller.delete_metadata( + req, self.share_network['id'], self.subnet['id'], key) + + self.assertEqual('fake_metadata', result) + mock_index.assert_called_once_with(req, self.subnet['id'], key, + parent_id=self.share_network['id']) diff --git a/manila/tests/api/views/test_share_network_subnets.py b/manila/tests/api/views/test_share_network_subnets.py index 6f9fe3953e..9a26819134 100644 --- a/manila/tests/api/views/test_share_network_subnets.py +++ b/manila/tests/api/views/test_share_network_subnets.py @@ -16,6 +16,7 @@ import ddt +from manila.api.openstack import api_version_request as api_version from manila.api.views import share_network_subnets from manila import test from manila.tests.api import fakes @@ -31,11 +32,13 @@ class ViewBuilderTestCase(test.TestCase): self.share_network = db_utils.create_share_network( name='fake_network', id='fake_sn_id') - def _validate_is_detail_return(self, result): + def _validate_is_detail_return(self, result, metadata_support=False): expected_keys = ['id', 'created_at', 'updated_at', 'neutron_net_id', 'neutron_subnet_id', 'network_type', 'cidr', 'segmentation_id', 'ip_version', 'share_network_id', 'availability_zone', 'gateway', 'mtu'] + if metadata_support: + expected_keys.append('metadata') for key in expected_keys: self.assertIn(key, result) @@ -58,13 +61,19 @@ class ViewBuilderTestCase(test.TestCase): result['share_network_subnet']['availability_zone']) self._validate_is_detail_return(result['share_network_subnet']) - def test_build_share_network_subnets(self): - req = fakes.HTTPRequest.blank('/subnets', version='2.51') + @ddt.data("2.51", "2.78") + def test_build_share_network_subnets(self, microversion): + metadata_support = (api_version.APIVersionRequest(microversion) >= + api_version.APIVersionRequest('2.78')) + + req = fakes.HTTPRequest.blank('/subnets', version=microversion) share_network = db_utils.create_share_network( name='fake_network', id='fake_sn_id_1') + + expected_metadata = {'fake_key': 'fake_value'} subnet = db_utils.create_share_network_subnet( - share_network_id=share_network['id']) + share_network_id=share_network['id'], metadata=expected_metadata) result = self.builder.build_share_network_subnets(req, [subnet]) @@ -72,4 +81,7 @@ class ViewBuilderTestCase(test.TestCase): self.assertEqual(1, len(result['share_network_subnets'])) subnet_list = result['share_network_subnets'] for subnet in subnet_list: - self._validate_is_detail_return(subnet) + self._validate_is_detail_return(subnet, + metadata_support=metadata_support) + if metadata_support: + self.assertEqual(expected_metadata, subnet['metadata']) diff --git a/manila/tests/api/views/test_share_networks.py b/manila/tests/api/views/test_share_networks.py index 98e6583fbb..d3ebbd9af9 100644 --- a/manila/tests/api/views/test_share_networks.py +++ b/manila/tests/api/views/test_share_networks.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +import copy import ddt import itertools @@ -145,6 +146,9 @@ class ViewBuilderTestCase(test.TestCase): network_allocation_update_support = ( api_version.APIVersionRequest(microversion) >= api_version.APIVersionRequest('2.69')) + subnet_metadata_support = ( + api_version.APIVersionRequest(microversion) >= + api_version.APIVersionRequest('2.78')) req = fakes.HTTPRequest.blank('/share-networks', version=microversion) expected_networks_list = [] @@ -158,8 +162,30 @@ class ViewBuilderTestCase(test.TestCase): 'description': share_network.get('description'), } if subnets_support: - share_network.update({'share_network_subnets': []}) - expected_data.update({'share_network_subnets': []}) + expected_subnet = { + 'id': 'fake_subnet_id', + 'availability_zone': 'fake_az', + 'created_at': share_network.get('created_at'), + 'updated_at': share_network.get('updated_at'), + 'segmentation_id': share_network.get('segmentation_id'), + 'neutron_net_id': share_network.get('neutron_net_id'), + 'neutron_subnet_id': share_network.get( + 'neutron_subnet_id'), + 'ip_version': share_network.get('ip_version'), + 'cidr': share_network.get('cidr'), + 'network_type': share_network.get('network_type'), + 'mtu': share_network.get('mtu'), + 'gateway': share_network.get('gateway'), + } + subnet = expected_subnet + if subnet_metadata_support: + subnet = copy.deepcopy(expected_subnet) + expected_subnet['metadata'] = {'fake_key': 'fake_value'} + subnet['subnet_metadata'] = expected_subnet['metadata'] + + expected_data.update( + {'share_network_subnets': [expected_subnet]}) + share_network.update({'share_network_subnets': [subnet]}) else: if default_net_info_support: network_data = { diff --git a/manila/tests/db/migrations/alembic/migrations_data_checks.py b/manila/tests/db/migrations/alembic/migrations_data_checks.py index e547f2c97a..4d210f7db6 100644 --- a/manila/tests/db/migrations/alembic/migrations_data_checks.py +++ b/manila/tests/db/migrations/alembic/migrations_data_checks.py @@ -3253,3 +3253,58 @@ class AddSnapshotMetadata(BaseMigrationChecks): def check_downgrade(self, engine): self.test_case.assertRaises(sa_exc.NoSuchTableError, utils.load_table, self.new_table_name, engine) + + +@map_to_migration('ac0620cbe74d') +class AddSubnetMetadata(BaseMigrationChecks): + share_subnet_id = uuidutils.generate_uuid() + new_table_name = 'share_network_subnet_metadata' + + def setup_upgrade_data(self, engine): + # Setup Share network. + share_network_data = { + 'id': uuidutils.generate_uuid(), + 'user_id': 'fake', + 'project_id': 'fake' + } + network_table = utils.load_table('share_networks', engine) + engine.execute(network_table.insert(share_network_data)) + + # Setup share network subnet. + share_network_subnet_data = { + 'id': self.share_subnet_id, + 'share_network_id': share_network_data['id'] + } + network_table = utils.load_table('share_network_subnets', engine) + engine.execute(network_table.insert(share_network_subnet_data)) + + def check_upgrade(self, engine, data): + data = { + 'id': 1, + 'key': 't' * 255, + 'value': 'v' * 1023, + 'share_network_subnet_id': self.share_subnet_id, + 'deleted': 'False', + } + + new_table = utils.load_table(self.new_table_name, engine) + engine.execute(new_table.insert(data)) + + item = engine.execute( + new_table.select().where(new_table.c.id == data['id'])).first() + self.test_case.assertTrue(hasattr(item, 'id')) + self.test_case.assertEqual(data['id'], item['id']) + self.test_case.assertTrue(hasattr(item, 'key')) + self.test_case.assertEqual(data['key'], item['key']) + self.test_case.assertTrue(hasattr(item, 'value')) + self.test_case.assertEqual(data['value'], item['value']) + self.test_case.assertTrue(hasattr(item, 'share_network_subnet_id')) + self.test_case.assertEqual(self.share_subnet_id, + item['share_network_subnet_id']) + self.test_case.assertTrue(hasattr(item, 'deleted')) + self.test_case.assertEqual('False', item['deleted']) + + def check_downgrade(self, engine): + self.test_case.assertRaises(sa_exc.NoSuchTableError, + utils.load_table, + self.new_table_name, engine) diff --git a/manila/tests/db/sqlalchemy/test_api.py b/manila/tests/db/sqlalchemy/test_api.py index 8caf6ede90..3648ed4a2e 100644 --- a/manila/tests/db/sqlalchemy/test_api.py +++ b/manila/tests/db/sqlalchemy/test_api.py @@ -3149,6 +3149,64 @@ class ShareNetworkSubnetDatabaseAPITestCase(BaseDatabaseAPITestCase): db_api.share_network_subnet_get_all_by_share_server_id, self.fake_context, 'share_server_id') + def test_share_network_subnet_metadata_get(self): + metadata = {'a': 'b', 'c': 'd'} + + subnet_1 = db_api.share_network_subnet_create( + self.fake_context, self.subnet_dict) + db_api.share_network_subnet_metadata_update( + self.fake_context, share_network_subnet_id=subnet_1['id'], + metadata=metadata, delete=False) + self.assertEqual( + metadata, db_api.share_network_subnet_metadata_get( + self.fake_context, share_network_subnet_id=subnet_1['id'])) + + def test_share_network_subnet_metadata_get_item(self): + metadata = {'a': 'b', 'c': 'd'} + key = 'a' + shouldbe = {'a': 'b'} + subnet_1 = db_api.share_network_subnet_create( + self.fake_context, self.subnet_dict) + db_api.share_network_subnet_metadata_update( + self.fake_context, share_network_subnet_id=subnet_1['id'], + metadata=metadata, delete=False) + self.assertEqual( + shouldbe, db_api.share_network_subnet_metadata_get_item( + self.fake_context, share_network_subnet_id=subnet_1['id'], + key=key)) + + def test_share_network_subnet_metadata_update(self): + metadata1 = {'a': '1', 'c': '2'} + metadata2 = {'a': '3', 'd': '5'} + should_be = {'a': '3', 'c': '2', 'd': '5'} + subnet_1 = db_api.share_network_subnet_create( + self.fake_context, self.subnet_dict) + db_api.share_network_subnet_metadata_update( + self.fake_context, share_network_subnet_id=subnet_1['id'], + metadata=metadata1, delete=False) + db_api.share_network_subnet_metadata_update( + self.fake_context, share_network_subnet_id=subnet_1['id'], + metadata=metadata2, delete=False) + self.assertEqual( + should_be, db_api.share_network_subnet_metadata_get( + self.fake_context, share_network_subnet_id=subnet_1['id'])) + + def test_share_network_subnet_metadata_delete(self): + key = 'a' + metadata = {'a': '1', 'c': '2'} + should_be = {'c': '2'} + subnet_1 = db_api.share_network_subnet_create( + self.fake_context, self.subnet_dict) + db_api.share_network_subnet_metadata_update( + self.fake_context, share_network_subnet_id=subnet_1['id'], + metadata=metadata, delete=False) + db_api.share_network_subnet_metadata_delete( + self.fake_context, share_network_subnet_id=subnet_1['id'], + key=key) + self.assertEqual( + should_be, db_api.share_network_subnet_metadata_get( + self.fake_context, share_network_subnet_id=subnet_1['id'])) + @ddt.ddt class SecurityServiceDatabaseAPITestCase(BaseDatabaseAPITestCase): diff --git a/manila/tests/share/test_manager.py b/manila/tests/share/test_manager.py index 0cbc81101e..1bb8056ee1 100644 --- a/manila/tests/share/test_manager.py +++ b/manila/tests/share/test_manager.py @@ -3861,7 +3861,8 @@ class ShareManagerTestCase(test.TestCase): cidr='fake_cidr', neutron_net_id='fake_neutron_net_id', neutron_subnet_id='fake_neutron_subnet_id', - network_type='fake_network_type') + network_type='fake_network_type', + subnet_metadata={'fake_key': 'fake_value'}) expected = [dict( server_id=fake_share_server['id'], segmentation_id=fake_share_network_subnet['segmentation_id'], @@ -3874,7 +3875,8 @@ class ShareManagerTestCase(test.TestCase): admin_network_allocations=( fake_network_allocations_get_for_share_server(label='admin')), backend_details=fake_share_server['backend_details'], - network_type=fake_share_network_subnet['network_type'])] + network_type=fake_share_network_subnet['network_type'], + subnet_metadata=fake_share_network_subnet['subnet_metadata'])] network_info = self.share_manager._form_server_setup_info( self.context, fake_share_server, fake_share_network, diff --git a/releasenotes/notes/add_share_network_subnet_metadata-ddee482d93030fc3.yaml b/releasenotes/notes/add_share_network_subnet_metadata-ddee482d93030fc3.yaml new file mode 100644 index 0000000000..95203c7c63 --- /dev/null +++ b/releasenotes/notes/add_share_network_subnet_metadata-ddee482d93030fc3.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Adds share network subnet metadata capabilities including + create, update all, update single, show and delete metadata.