Attach Manila shares via virtiofs (manila abstraction)
This patch is inspired by /nova/volume/cinder.py, it is an abstraction to the manila service. Manila is the OpenStack Shared Filesystems service. These series of patches implement changes required in Nova to allow the shares provided by Manila to be associated with and attached to instances using virtiofs. Implements: blueprint libvirt-virtiofs-attach-manila-shares Depends-On: https://review.opendev.org/c/openstack/openstacksdk/+/889519 Change-Id: I44ab37ec2c15fcfc351c42216660bda39461b163
This commit is contained in:
parent
9a1872d30e
commit
0f9001f06e
@ -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)
|
||||
|
58
nova/conf/manila.py
Normal file
58
nova/conf/manila.py
Normal file
@ -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'))
|
||||
}
|
@ -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 = []
|
||||
|
@ -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.")
|
||||
|
||||
|
0
nova/share/__init__.py
Normal file
0
nova/share/__init__.py
Normal file
331
nova/share/manila.py
Normal file
331
nova/share/manila.py
Normal file
@ -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)
|
1
nova/tests/fixtures/__init__.py
vendored
1
nova/tests/fixtures/__init__.py
vendored
@ -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
|
||||
|
111
nova/tests/fixtures/manila.py
vendored
Normal file
111
nova/tests/fixtures/manila.py
vendored
Normal file
@ -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
|
0
nova/tests/unit/share/__init__.py
Normal file
0
nova/tests/unit/share/__init__.py
Normal file
432
nova/tests/unit/test_manila.py
Normal file
432
nova/tests/unit/test_manila.py
Normal file
@ -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)
|
@ -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):
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user