diff --git a/nova/conf/__init__.py b/nova/conf/__init__.py index 18e935830b..6155281379 100644 --- a/nova/conf/__init__.py +++ b/nova/conf/__init__.py @@ -40,6 +40,7 @@ from nova.conf import ironic from nova.conf import key_manager from nova.conf import keystone from nova.conf import libvirt +from nova.conf import manila from nova.conf import mks from nova.conf import netconf from nova.conf import neutron @@ -82,6 +83,7 @@ devices.register_opts(CONF) ephemeral_storage.register_opts(CONF) glance.register_opts(CONF) guestfs.register_opts(CONF) +manila.register_opts(CONF) mks.register_opts(CONF) imagecache.register_opts(CONF) ironic.register_opts(CONF) diff --git a/nova/conf/manila.py b/nova/conf/manila.py new file mode 100644 index 0000000000..e691c75205 --- /dev/null +++ b/nova/conf/manila.py @@ -0,0 +1,58 @@ +# 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 keystoneauth1 import loading as ks_loading +from oslo_config import cfg + +from nova.conf import utils as confutils + +DEFAULT_SERVICE_TYPE = 'shared-file-system' + +manila_group = cfg.OptGroup( + 'manila', + title='Manila Options', + help="Configuration options for the share-file-system service") + +manila_opts = [ + cfg.IntOpt('share_apply_policy_timeout', + default=10, + help=""" +Timeout period for share policy application. + +Maximum duration to await a response from the Manila service for the +application of a share policy before experiencing a timeout. +0 means do not wait (0s). + +Possible values: + +* A positive integer or 0 (default value is 10). +"""), +] + + +def register_opts(conf): + conf.register_group(manila_group) + conf.register_opts(manila_opts, group=manila_group) + ks_loading.register_session_conf_options(conf, manila_group.name) + ks_loading.register_auth_conf_options(conf, manila_group.name) + + confutils.register_ksa_opts(conf, manila_group, DEFAULT_SERVICE_TYPE) + + +def list_opts(): + return { + manila_group.name: ( + manila_opts + + ks_loading.get_session_conf_options() + + ks_loading.get_auth_common_conf_options() + + ks_loading.get_auth_plugin_conf_options('v3password')) + } diff --git a/nova/context.py b/nova/context.py index dc42b38b5b..b993e65d5f 100644 --- a/nova/context.py +++ b/nova/context.py @@ -115,7 +115,7 @@ class RequestContext(context.RequestContext): self.service_catalog = [s for s in service_catalog if s.get('type') in ('image', 'block-storage', 'volumev3', 'key-manager', 'placement', 'network', - 'accelerator')] + 'accelerator', 'sharev2')] else: # if list is empty or none self.service_catalog = [] diff --git a/nova/exception.py b/nova/exception.py index e59b8e3feb..dd02658d97 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -143,6 +143,10 @@ class CinderConnectionFailed(NovaException): msg_fmt = _("Connection to cinder host failed: %(reason)s") +class ManilaConnectionFailed(NovaException): + msg_fmt = _("Connection to manila service failed: %(reason)s") + + class UnsupportedCinderAPIVersion(NovaException): msg_fmt = _('Nova does not support Cinder API version %(version)s') @@ -704,6 +708,14 @@ class ShareNotFound(NotFound): msg_fmt = _("Share %(share_id)s could not be found.") +class ShareMappingAlreadyExists(NotFound): + msg_fmt = _("Share %(share_id)s already associated to this server.") + + +class ShareProtocolUnknown(NotFound): + msg_fmt = _("Share protocol %(share_proto)s is unknown.") + + class ShareUmountError(NovaException): msg_fmt = _("Share id %(share_id)s umount error " "from server %(server_id)s.\n" @@ -716,6 +728,23 @@ class ShareMountError(NovaException): "Reason: %(reason)s.") +class ShareAccessNotFound(NotFound): + msg_fmt = _("Share access from Manila could not be found for " + "share id %(share_id)s.") + + +class ShareAccessGrantError(NovaException): + msg_fmt = _("Share access could not be granted to " + "share id %(share_id)s.\n" + "Reason: %(reason)s.") + + +class ShareAccessRemovalError(NovaException): + msg_fmt = _("Share access could not be removed from " + "share id %(share_id)s.\n" + "Reason: %(reason)s.") + + class VolumeTypeNotFound(NotFound): msg_fmt = _("Volume type %(id_or_name)s could not be found.") diff --git a/nova/share/__init__.py b/nova/share/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nova/share/manila.py b/nova/share/manila.py new file mode 100644 index 0000000000..e0e8b55aa2 --- /dev/null +++ b/nova/share/manila.py @@ -0,0 +1,331 @@ +# 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. + +""" +Handles all requests relating to shares + manila. +""" + +from dataclasses import dataclass +import functools +from typing import Optional + +from openstack import exceptions as sdk_exc +from oslo_log import log as logging + +import nova.conf +from nova import exception +from nova import utils + +CONF = nova.conf.CONF +LOG = logging.getLogger(__name__) +MIN_SHARE_FILE_SYSTEM_MICROVERSION = "2.82" + + +def manilaclient(context): + """Constructs a manila client object for making API requests. + + :return: An openstack.proxy.Proxy object for the specified service_type. + :raise: ConfGroupForServiceTypeNotFound If no conf group name could be + found for the specified service_type. + :raise: ServiceUnavailable if the service is down + """ + + return utils.get_sdk_adapter( + "shared-file-system", + check_service=True, + shared_file_system_api_version=MIN_SHARE_FILE_SYSTEM_MICROVERSION, + global_request_id=context.global_id + ) + + +@dataclass(frozen=True) +class Share(): + id: str + size: int + availability_zone: Optional[str] + created_at: str + status: str + name: Optional[str] + description: Optional[str] + project_id: str + snapshot_id: Optional[str] + share_network_id: Optional[str] + share_proto: str + export_location: str + metadata: dict + share_type: Optional[str] + is_public: bool + + @classmethod + def from_manila_share(cls, manila_share, export_location): + return cls( + id=manila_share.id, + size=manila_share.size, + availability_zone=manila_share.availability_zone, + created_at=manila_share.created_at, + status=manila_share.status, + name=manila_share.name, + description=manila_share.description, + project_id=manila_share.project_id, + snapshot_id=manila_share.snapshot_id, + share_network_id=manila_share.share_network_id, + share_proto=manila_share.share_protocol, + export_location=export_location, + metadata=manila_share.metadata, + share_type=manila_share.share_type, + is_public=manila_share.is_public, + ) + + +@dataclass(frozen=True) +class Access(): + id: str + access_level: str + state: str + access_type: str + access_to: str + access_key: Optional[str] + + @classmethod + def from_manila_access(cls, manila_access): + return cls( + id=manila_access.id, + access_level=manila_access.access_level, + state=manila_access.state, + access_type=manila_access.access_type, + access_to=manila_access.access_to, + access_key= getattr(manila_access, 'access_key', None) + ) + + @classmethod + def from_dict(cls, manila_access): + return cls( + id=manila_access['id'], + access_level=manila_access['access_level'], + state=manila_access['state'], + access_type=manila_access['access_type'], + access_to=manila_access['access_to'], + access_key=manila_access['access_key'], + ) + + +def translate_sdk_exception(method): + """Transforms a manila exception but keeps its traceback intact.""" + @functools.wraps(method) + def wrapper(self, *args, **kwargs): + try: + res = method(self, *args, **kwargs) + except (exception.ServiceUnavailable, + exception.ConfGroupForServiceTypeNotFound) as exc: + raise exception.ManilaConnectionFailed(reason=str(exc)) from exc + except (sdk_exc.BadRequestException) as exc: + raise exception.InvalidInput(reason=str(exc)) from exc + except (sdk_exc.ForbiddenException) as exc: + raise exception.Forbidden(str(exc)) from exc + return res + return wrapper + + +def translate_share_exception(method): + """Transforms the exception for the share but keeps its traceback intact. + """ + + def wrapper(self, *args, **kwargs): + try: + res = method(self, *args, **kwargs) + except (sdk_exc.ResourceNotFound) as exc: + raise exception.ShareNotFound( + share_id=args[1], reason=exc) from exc + except (sdk_exc.BadRequestException) as exc: + raise exception.ShareNotFound( + share_id=args[1], reason=exc) from exc + return res + return translate_sdk_exception(wrapper) + + +def translate_allow_exception(method): + """Transforms the exception for allow but keeps its traceback intact. + """ + + def wrapper(self, *args, **kwargs): + try: + res = method(self, *args, **kwargs) + except (sdk_exc.BadRequestException) as exc: + raise exception.ShareAccessGrantError( + share_id=args[1], reason=exc) from exc + except (sdk_exc.ResourceNotFound) as exc: + raise exception.ShareNotFound( + share_id=args[1], reason=exc) from exc + return res + return translate_sdk_exception(wrapper) + + +def translate_deny_exception(method): + """Transforms the exception for deny but keeps its traceback intact. + """ + + def wrapper(self, *args, **kwargs): + try: + res = method(self, *args, **kwargs) + except (sdk_exc.BadRequestException) as exc: + raise exception.ShareAccessRemovalError( + share_id=args[1], reason=exc) from exc + except (sdk_exc.ResourceNotFound) as exc: + raise exception.ShareNotFound( + share_id=args[1], reason=exc) from exc + return res + return translate_sdk_exception(wrapper) + + +class API(object): + """API for interacting with the share manager.""" + + @translate_share_exception + def get(self, context, share_id): + """Get the details about a share given its ID. + + :param share_id: the id of the share to get + :raises: ShareNotFound if the share_id specified is not available. + :returns: Share object. + """ + + def filter_export_locations(export_locations): + # Return the preferred path otherwise choose the first one + paths = [] + for export_location in export_locations: + if export_location.is_preferred: + return export_location.path + else: + paths.append(export_location.path) + return paths[0] + + client = manilaclient(context) + LOG.debug("Get share id:'%s' data from manila", share_id) + share = client.get_share(share_id) + export_locations = client.export_locations(share.id) + export_location = filter_export_locations(export_locations) + + return Share.from_manila_share(share, export_location) + + @translate_share_exception + def get_access( + self, + context, + share_id, + access_type, + access_to, + ): + """Get share access + + :param share_id: the id of the share to get + :param access_type: the type of access ("ip", "cert", "user") + :param access_to: ip:cidr or cert:cn or user:group or user name + :raises: ShareNotFound if the share_id specified is not available. + :returns: Access object or None if there is no access granted to this + share. + """ + + LOG.debug("Get share access id for share id:'%s'", + share_id) + access_list = manilaclient(context).access_rules(share_id) + + for access in access_list: + if ( + access.access_type == access_type and + access.access_to == access_to + ): + return Access.from_manila_access(access) + return None + + @translate_allow_exception + def allow( + self, + context, + share_id, + access_type, + access_to, + access_level, + ): + """Allow share access + + :param share_id: the id of the share + :param access_type: the type of access ("ip", "cert", "user") + :param access_to: ip:cidr or cert:cn or user:group or user name + :param access_level: "ro" for read only or "rw" for read/write + :raises: ShareNotFound if the share_id specified is not available. + :raises: BadRequest if the share already exists. + :raises: ShareAccessGrantError if the answer from manila allow API is + not the one expected. + """ + + def check_manila_access_response(access): + if not ( + isinstance(access, Access) and + access.access_type == access_type and + access.access_to == access_to and + access.access_level == access_level + ): + raise exception.ShareAccessGrantError(share_id=share_id) + + LOG.debug("Allow host access to share id:'%s'", + share_id) + + access = manilaclient(context).create_access_rule( + share_id, + access_type=access_type, + access_to=access_to, + access_level=access_level, + lock_visibility=True, + lock_deletion=True, + lock_reason="Lock by nova", + ) + + access = Access.from_manila_access(access) + check_manila_access_response(access) + return access + + @translate_deny_exception + def deny( + self, + context, + share_id, + access_type, + access_to, + ): + """Deny share access + :param share_id: the id of the share + :param access_type: the type of access ("ip", "cert", "user") + :param access_to: ip:cidr or cert:cn or user:group or user name + :raises: ShareAccessNotFound if the access_id specified is not + available. + :raises: ShareAccessRemovalError if the manila deny API does not + respond with a status code 202. + """ + + client = manilaclient(context) + + access = self.get_access( + context, + share_id, + access_type, + access_to, + ) + + if access: + LOG.debug("Deny host access to share id:'%s'", share_id) + resp = client.delete_access_rule(access.id, share_id) + if resp.status_code != 202: + raise exception.ShareAccessRemovalError( + share_id=share_id, reason=resp.reason + ) + else: + raise exception.ShareAccessNotFound(share_id=share_id) diff --git a/nova/tests/fixtures/__init__.py b/nova/tests/fixtures/__init__.py index ff3dc92f02..9a9b890a8b 100644 --- a/nova/tests/fixtures/__init__.py +++ b/nova/tests/fixtures/__init__.py @@ -22,6 +22,7 @@ from .glance import GlanceFixture # noqa: F401, H304 from .libvirt import LibvirtFixture # noqa: F401, H304 from .libvirt_imagebackend import \ LibvirtImageBackendFixture # noqa: F401, H304 +from .manila import ManilaFixture # noqa: F401, H304 from .neutron import NeutronFixture # noqa: F401, H304 from .notifications import NotificationFixture # noqa: F401, H304 from .nova import * # noqa: F401, F403, H303, H304 diff --git a/nova/tests/fixtures/manila.py b/nova/tests/fixtures/manila.py new file mode 100644 index 0000000000..d7fca52d04 --- /dev/null +++ b/nova/tests/fixtures/manila.py @@ -0,0 +1,111 @@ +# 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. + +import fixtures +import nova +from oslo_config import cfg +from oslo_log import log as logging + + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + + +class ManilaShare(): + def __init__(self, share_id, proto="NFS"): + self.id = share_id + self.size = 1 + self.availability_zone = "nova" + self.created_at = "2015-09-18T10:25:24.000000" + self.status = "available" + self.name = "share_London" + self.description = "My custom share London" + self.project_id = "6a6a9c9eee154e9cb8cec487b98d36ab" + self.snapshot_id = None + self.share_network_id = "713df749-aac0-4a54-af52-10f6c991e80c" + self.share_protocol = proto + self.metadata = {"project": "my_app", "aim": "doc"} + self.share_type = "25747776-08e5-494f-ab40-a64b9d20d8f7" + self.volume_type = "default" + self.is_public = True + + +class ManilaAccess(): + def __init__(self, access_type="ip"): + self.access_level = "rw" + self.state = "active" + self.id = "507bf114-36f2-4f56-8cf4-857985ca87c1" + if access_type == "ip": + self.access_type = "ip" + self.access_to = "192.168.0.1" + self.access_key = None + elif access_type == "cephx": + self.access_type = "cephx" + self.access_to = "nova" + self.access_key = "mykey" + + +class ManilaFixture(fixtures.Fixture): + """Fixture that mocks Manila APIs used by nova/share/manila.py""" + + def setUp(self): + super().setUp() + self.share_access = set() + self.mock_get = self.useFixture(fixtures.MockPatch( + 'nova.share.manila.API.get', + side_effect=self.fake_get)).mock + self.mock_get_access = self.useFixture(fixtures.MockPatch( + 'nova.share.manila.API.get_access', + side_effect=self.fake_get_access)).mock + self.mock_allow = self.useFixture(fixtures.MockPatch( + 'nova.share.manila.API.allow', + side_effect=self.fake_allow)).mock + self.mock_deny = self.useFixture(fixtures.MockPatch( + 'nova.share.manila.API.deny', + side_effect=self.fake_deny)).mock + + def fake_get(self, context, share_id): + manila_share = ManilaShare(share_id) + export_location = "10.0.0.50:/mnt/foo" + return nova.share.manila.Share.from_manila_share( + manila_share, export_location + ) + + def fake_get_cephfs(self, context, share_id): + manila_share = ManilaShare(share_id, "CEPHFS") + export_location = "10.0.0.50:/mnt/foo" + return nova.share.manila.Share.from_manila_share( + manila_share, export_location + ) + + def fake_get_access(self, context, share_id, access_type, access_to): + if share_id not in self.share_access: + return None + else: + access = ManilaAccess() + return nova.share.manila.Access.from_manila_access(access) + + def fake_get_access_cephfs( + self, context, share_id, access_type, access_to + ): + access = ManilaAccess(access_type="cephx") + return access + + def fake_allow( + self, context, share_id, access_type, access_to, access_level + ): + self.share_access.add(share_id) + self.fake_get_access(context, share_id, access_type, access_to) + + def fake_deny(self, context, share_id, access_type, access_to): + self.share_access.discard(share_id) + return 202 diff --git a/nova/tests/unit/share/__init__.py b/nova/tests/unit/share/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nova/tests/unit/test_manila.py b/nova/tests/unit/test_manila.py new file mode 100644 index 0000000000..7dc9ad890d --- /dev/null +++ b/nova/tests/unit/test_manila.py @@ -0,0 +1,432 @@ +# 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 requests import Response + +import fixtures +import nova.conf +from nova import context as nova_context +from nova import exception +from nova.share import manila +from nova import test + +from openstack import exceptions as sdk_exc +from openstack.shared_file_system.v2 import ( + share_access_rule as sdk_share_access_rule +) +from openstack.shared_file_system.v2 import ( + share_export_locations as sdk_share_export_locations +) +from openstack.shared_file_system.v2 import share as sdk_share + +from openstack import utils +from unittest import mock + +from nova.tests.unit.api.openstack import fakes + +CONF = nova.conf.CONF + + +def stub_share(share_id): + share = sdk_share.Share() + share.id = share_id + share.size = 1 + share.availability_zone = "nova" + share.created_at = "2015-09-18T10:25:24.000000" + share.status = "available" + share.name = "share_London" + share.description = "My custom share London" + share.project_id = "16e1ab15c35a457e9c2b2aa189f544e1" + share.snapshot_id = None + share.share_network_id = "713df749-aac0-4a54-af52-10f6c991e80c" + share.share_protocol = "NFS" + share.metadata = { + "project": "my_app", + "aim": "doc" + } + share.share_type = "25747776-08e5-494f-ab40-a64b9d20d8f7" + share.is_public = True + share.share_server_id = "e268f4aa-d571-43dd-9ab3-f49ad06ffaef" + share.host = "manila2@generic1#GENERIC1" + + share.location = utils.Munch( + { + "cloud": "envvars", + "region_name": "RegionOne", + "zone": "manila-zone-0", + "project": utils.Munch( + { + "id": "bce4fcc3bd0d4c598f610cb45ec5c5ba", + "name": "demo", + "domain_id": "default", + "domain_name": None, + } + ), + } + ) + return share + + +def stub_export_locations(): + export_locations = [] + export_location = sdk_share_export_locations.ShareExportLocation() + export_location.id = "b6bd76ce-12a2-42a9-a30a-8a43b503867d" + export_location.path = ( + "10.0.0.3:/shares/share-e1c2d35e-fe67-4028-ad7a-45f668732b1d" + ) + export_location.is_preferred = True + export_location.share_instance_id = ( + "e1c2d35e-fe67-4028-ad7a-45f668732b1d" + ) + export_location.is_admin = True + export_location.share_instance_id = "e1c2d35e-fe67-4028-ad7a-45f668732b1d" + export_location.location = utils.Munch( + { + "cloud": "envvars", + "region_name": "RegionOne", + "zone": None, + "project": utils.Munch( + { + "id": "bce4fcc3bd0d4c598f610cb45ec5c5ba", + "name": "demo", + "domain_id": "default", + "domain_name": None, + } + ), + } + ) + + export_locations.append(export_location) + for item in export_locations: + yield item + + +def stub_access_list(): + access_list = [] + access_list.append(stub_access()) + for access in access_list: + yield access + + +def stub_access(): + access = sdk_share_access_rule.ShareAccessRule() + access.id = "a25b2df3-90bd-4add-afa6-5f0dbbd50452" + access.access_level = "rw" + access.access_to = "0.0.0.0/0" + access.access_type = "ip" + access.state = "active" + access.access_key = None + access.created_at = "2023-07-21T15:20:01.812350" + access.updated_at = "2023-07-21T15:20:01.812350" + access.metadata = {} + access.location = utils.Munch( + { + "cloud": "envvars", + "region_name": "RegionOne", + "zone": None, + "project": utils.Munch( + { + "id": "bce4fcc3bd0d4c598f610cb45ec5c5ba", + "name": "demo", + "domain_id": "default", + "domain_name": None, + } + ), + } + ) + return access + + +class BaseManilaTestCase(object): + project_id = fakes.FAKE_PROJECT_ID + + def setUp(self): + + super(BaseManilaTestCase, self).setUp() + + self.mock_get_confgrp = self.useFixture(fixtures.MockPatch( + 'nova.utils._get_conf_group')).mock + + self.mock_get_auth_sess = self.useFixture(fixtures.MockPatch( + 'nova.utils._get_auth_and_session')).mock + self.mock_get_auth_sess.return_value = (None, mock.sentinel.session) + + self.service_type = 'shared-file-system' + self.mock_connection = self.useFixture( + fixtures.MockPatch( + "nova.utils.connection.Connection", side_effect=self.fake_conn + ) + ).mock + + # We need to stub the CONF global in nova.utils to assert that the + # Connection constructor picks it up. + self.mock_conf = self.useFixture(fixtures.MockPatch( + 'nova.utils.CONF')).mock + + self.api = manila.API() + + self.context = nova_context.RequestContext( + user_id="fake_user", project_id=self.project_id + ) + + def fake_conn(self, *args, **kwargs): + class FakeConnection(object): + def __init__(self): + self.shared_file_system = FakeConnectionShareV2Proxy() + + class FakeConnectionShareV2Proxy(object): + def __init__(self): + pass + + def get_share(self, share_id): + if share_id == 'nonexisting': + raise sdk_exc.ResourceNotFound + return stub_share(share_id) + + def export_locations(self, share_id): + return stub_export_locations() + + def access_rules(self, share_id): + if share_id == 'nonexisting': + raise sdk_exc.ResourceNotFound + if share_id == 'nonexisting2': + raise sdk_exc.ResourceNotFound + if share_id == '4567': + return [] + return stub_access_list() + + def create_access_rule(self, share_id, **kwargs): + if share_id == '2345': + raise sdk_exc.BadRequestException + if share_id == 'nonexisting': + raise sdk_exc.ResourceNotFound + return stub_access() + + def delete_access_rule(self, access_id, share_id): + if share_id == 'nonexisting': + raise sdk_exc.ResourceNotFound + res = Response() + res.status_code = 202 + res.reason = "Internal error" + if share_id == '2345': + res.status_code = 500 + return res + + return FakeConnection() + + def create_client(self, context): + return manila.manilaclient(context) + + def test_client(self): + client = self.create_client(self.context) + self.assertTrue(hasattr(client, 'get_share')) + self.assertTrue(hasattr(client, 'export_locations')) + self.assertTrue(hasattr(client, 'access_rules')) + self.assertTrue(hasattr(client, 'create_access_rule')) + self.assertTrue(hasattr(client, 'delete_access_rule')) + + +class ManilaTestCase(BaseManilaTestCase, test.NoDBTestCase): + def test_get_fails_non_existing_share(self): + """Tests that we fail if trying to get an + non existing share. + """ + exc = self.assertRaises( + exception.ShareNotFound, self.api.get, self.context, "nonexisting" + ) + + self.assertIn("Share nonexisting could not be found.", exc.message) + + def test_get_share(self): + """Tests that we manage to get a share. + """ + share = self.api.get(self.context, '1234') + self.assertIsInstance(share, manila.Share) + self.assertEqual('1234', share.id) + self.assertEqual(1, share.size) + self.assertEqual('nova', share.availability_zone) + self.assertEqual('2015-09-18T10:25:24.000000', + share.created_at) + self.assertEqual('available', share.status) + self.assertEqual('share_London', share.name) + self.assertEqual('My custom share London', + share.description) + self.assertEqual('16e1ab15c35a457e9c2b2aa189f544e1', + share.project_id) + self.assertIsNone(share.snapshot_id) + self.assertEqual( + '713df749-aac0-4a54-af52-10f6c991e80c', + share.share_network_id) + self.assertEqual('NFS', share.share_proto) + self.assertEqual(share.export_location, + "10.0.0.3:/shares/" + "share-e1c2d35e-fe67-4028-ad7a-45f668732b1d" + ) + self.assertEqual({"project": "my_app", "aim": "doc"}, + share.metadata) + self.assertEqual( + '25747776-08e5-494f-ab40-a64b9d20d8f7', + share.share_type) + self.assertTrue(share.is_public) + + def test_get_access_fails_non_existing_share(self): + """Tests that we fail if trying to get an access on a + non existing share. + """ + exc = self.assertRaises( + exception.ShareNotFound, + self.api.get_access, + self.context, + "nonexisting", + "ip", + "0.0.0.0/0", + ) + + self.assertIn("Share nonexisting could not be found.", exc.message) + + exc = self.assertRaises( + exception.ShareNotFound, + self.api.get_access, + self.context, + "nonexisting2", + "ip", + "0.0.0.0/0", + ) + + self.assertIn("Share nonexisting2 could not be found.", exc.message) + + def test_get_access(self): + """Tests that we manage to get an access id based on access_type and + access_to parameters. + """ + access = self.api.get_access(self.context, '1234', 'ip', '0.0.0.0/0') + + self.assertEqual('a25b2df3-90bd-4add-afa6-5f0dbbd50452', access.id) + self.assertEqual('rw', access.access_level) + self.assertEqual('active', access.state) + self.assertEqual('ip', access.access_type) + self.assertEqual('0.0.0.0/0', access.access_to) + self.assertIsNone(access.access_key) + + def test_get_access_not_existing(self): + """Tests that we get None if the access id does not exist. + """ + access = self.api.get_access( + self.context, "1234", "ip", "192.168.0.1/32" + ) + + self.assertIsNone(access) + + def test_allow_access_fails_non_existing_share(self): + """Tests that we fail if trying to allow an + non existing share. + """ + exc = self.assertRaises( + exception.ShareNotFound, + self.api.allow, + self.context, + "nonexisting", + "ip", + "0.0.0.0/0", + "rw", + ) + + self.assertIn("Share nonexisting could not be found.", exc.message) + + def test_allow_access(self): + """Tests that we manage to allow access to a share. + """ + access = self.api.allow(self.context, '1234', 'ip', '0.0.0.0/0', 'rw') + self.assertEqual('a25b2df3-90bd-4add-afa6-5f0dbbd50452', access.id) + self.assertEqual('rw', access.access_level) + self.assertEqual('active', access.state) + self.assertEqual('ip', access.access_type) + self.assertEqual('0.0.0.0/0', access.access_to) + self.assertIsNone(access.access_key) + + def test_allow_access_fails_already_exists(self): + """Tests that we have an exception is the share already exists. + """ + exc = self.assertRaises( + exception.ShareAccessGrantError, + self.api.allow, + self.context, + '2345', + 'ip', + '0.0.0.0/0', + 'rw' + ) + + self.assertIn( + 'Share access could not be granted to share', + exc.message) + + def test_deny_access_fails_non_existing_share(self): + """Tests that we fail if trying to deny an + non existing share. + """ + exc = self.assertRaises( + exception.ShareNotFound, + self.api.deny, + self.context, + "nonexisting", + "ip", + "0.0.0.0/0", + ) + + self.assertIn("Share nonexisting could not be found.", exc.message) + + def test_deny_access(self): + """Tests that we manage to deny access to a share. + """ + self.api.deny( + self.context, + '1234', + 'ip', + '0.0.0.0/0' + ) + + def test_deny_access_fails_id_missing(self): + """Tests that we fail if something wrong happens calling deny method. + """ + exc = self.assertRaises(exception.ShareAccessRemovalError, + self.api.deny, + self.context, + '2345', + 'ip', + '0.0.0.0/0' + ) + + self.assertIn( + 'Share access could not be removed from', + exc.message) + self.assertEqual( + 500, + exc.code) + + def test_deny_access_fails_access_not_found(self): + """Tests that we fail if access is missing. + """ + exc = self.assertRaises(exception.ShareAccessNotFound, + self.api.deny, + self.context, + '4567', + 'ip', + '0.0.0.0/0' + ) + + self.assertIn( + 'Share access from Manila could not be found', + exc.message) + self.assertEqual( + 404, + exc.code) diff --git a/nova/utils.py b/nova/utils.py index f9c029bf44..b85cf0cdf7 100644 --- a/nova/utils.py +++ b/nova/utils.py @@ -965,7 +965,9 @@ def get_ksa_adapter(service_type, ksa_auth=None, ksa_session=None, min_version=min_version, max_version=max_version, raise_exc=False) -def get_sdk_adapter(service_type, check_service=False, conf_group=None): +def get_sdk_adapter( + service_type, check_service=False, conf_group=None, **kwargs +): """Construct an openstacksdk-brokered Adapter for a given service type. We expect to find a conf group whose name corresponds to the service_type's @@ -978,6 +980,9 @@ def get_sdk_adapter(service_type, check_service=False, conf_group=None): service is alive, raising ServiceUnavailable if it is not. :param conf_group: String name of the conf group to use, otherwise the name of the service_type will be used. + :param kwargs: Additional arguments to pass to the Adapter constructor. + Mainly used to pass microversion to a specific service, + e.g. shared_file_system_api_version="2.82". :return: An openstack.proxy.Proxy object for the specified service_type. :raise: ConfGroupForServiceTypeNotFound If no conf group name could be found for the specified service_type. @@ -988,12 +993,16 @@ def get_sdk_adapter(service_type, check_service=False, conf_group=None): try: conn = connection.Connection( session=sess, oslo_conf=CONF, service_types={service_type}, - strict_proxies=check_service) + strict_proxies=check_service, **kwargs) except sdk_exc.ServiceDiscoveryException as e: raise exception.ServiceUnavailable( _("The %(service_type)s service is unavailable: %(error)s") % {'service_type': service_type, 'error': str(e)}) - return getattr(conn, service_type) + # The replace('-', '_') below is to handle service names that use + # hyphens and SDK attributes that use underscores. + # e.g. service name --> sdk attribute + # 'shared-file-system' --> 'shared_file_system' + return getattr(conn, service_type.replace('-', '_')) def get_endpoint(ksa_adapter): diff --git a/requirements.txt b/requirements.txt index 36d5026fec..34e14bdb37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -62,5 +62,5 @@ retrying>=1.3.3 # Apache-2.0 os-service-types>=1.7.0 # Apache-2.0 python-dateutil>=2.7.0 # BSD futurist>=1.8.0 # Apache-2.0 -openstacksdk>=0.35.0 # Apache-2.0 +openstacksdk>=4.0.0 # Apache-2.0 PyYAML>=5.1 # MIT