cinder/cinder/volume/drivers/coraid.py

564 lines
21 KiB
Python

# Copyright 2012 Alyseo.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
Desc : Driver to store volumes on Coraid Appliances.
Require : Coraid EtherCloud ESM, Coraid VSX and Coraid SRX.
Author : Jean-Baptiste RANSY <openstack@alyseo.com>
Author : Alex Zasimov <azasimov@mirantis.com>
Author : Nikolay Sobolevsky <nsobolevsky@mirantis.com>
Contrib : Larry Matter <support@coraid.com>
"""
import cookielib
import math
import urllib
import urllib2
from oslo_concurrency import lockutils
from oslo_config import cfg
from oslo_serialization import jsonutils
from oslo_utils import units
import six.moves.urllib.parse as urlparse
from cinder import exception
from cinder.i18n import _
from cinder.openstack.common import log as logging
from cinder.volume import driver
from cinder.volume import volume_types
LOG = logging.getLogger(__name__)
coraid_opts = [
cfg.StrOpt('coraid_esm_address',
default='',
help='IP address of Coraid ESM'),
cfg.StrOpt('coraid_user',
default='admin',
help='User name to connect to Coraid ESM'),
cfg.StrOpt('coraid_group',
default='admin',
help='Name of group on Coraid ESM to which coraid_user belongs'
' (must have admin privilege)'),
cfg.StrOpt('coraid_password',
default='password',
help='Password to connect to Coraid ESM'),
cfg.StrOpt('coraid_repository_key',
default='coraid_repository',
help='Volume Type key name to store ESM Repository Name'),
cfg.StrOpt('coraid_default_repository',
help='ESM Repository Name to use if not specified in '
'Volume Type keys'),
]
CONF = cfg.CONF
CONF.register_opts(coraid_opts)
ESM_SESSION_EXPIRED_STATES = ['GeneralAdminFailure',
'passwordInactivityTimeout',
'passwordAbsoluteTimeout']
class CoraidRESTClient(object):
"""Executes REST RPC requests on Coraid ESM EtherCloud Appliance."""
def __init__(self, esm_url):
self._check_esm_url(esm_url)
self._esm_url = esm_url
self._cookie_jar = cookielib.CookieJar()
self._url_opener = urllib2.build_opener(
urllib2.HTTPCookieProcessor(self._cookie_jar))
def _check_esm_url(self, esm_url):
splitted = urlparse.urlsplit(esm_url)
if splitted.scheme != 'https':
raise ValueError(
_('Invalid ESM url scheme "%s". Supported https only.') %
splitted.scheme)
@lockutils.synchronized('coraid_rpc', 'cinder-', False)
def rpc(self, handle, url_params, data, allow_empty_response=False):
return self._rpc(handle, url_params, data, allow_empty_response)
def _rpc(self, handle, url_params, data, allow_empty_response):
"""Execute REST RPC using url <esm_url>/handle?url_params.
Send JSON encoded data in body of POST request.
Exceptions:
urllib2.URLError
1. Name or service not found (e.reason is socket.gaierror)
2. Socket blocking operation timeout (e.reason is
socket.timeout)
3. Network IO error (e.reason is socket.error)
urllib2.HTTPError
1. HTTP 404, HTTP 500 etc.
CoraidJsonEncodeFailure - bad REST response
"""
# Handle must be simple path, for example:
# /configure
if '?' in handle or '&' in handle:
raise ValueError(_('Invalid REST handle name. Expected path.'))
# Request url includes base ESM url, handle path and optional
# URL params.
rest_url = urlparse.urljoin(self._esm_url, handle)
encoded_url_params = urllib.urlencode(url_params)
if encoded_url_params:
rest_url += '?' + encoded_url_params
if data is None:
json_request = None
else:
json_request = jsonutils.dumps(data)
request = urllib2.Request(rest_url, json_request)
response = self._url_opener.open(request).read()
try:
if not response and allow_empty_response:
reply = {}
else:
reply = jsonutils.loads(response)
except (TypeError, ValueError) as exc:
msg = (_('Call to json.loads() failed: %(ex)s.'
' Response: %(resp)s') %
{'ex': exc, 'resp': response})
raise exception.CoraidJsonEncodeFailure(msg)
return reply
def to_coraid_kb(gb):
return math.ceil(float(gb) * units.Gi / 1000)
def coraid_volume_size(gb):
return '{0}K'.format(to_coraid_kb(gb))
class CoraidAppliance(object):
def __init__(self, rest_client, username, password, group):
self._rest_client = rest_client
self._username = username
self._password = password
self._group = group
self._logined = False
def _login(self):
"""Login into ESM.
Perform login request and return available groups.
:returns: dict -- map with group_name to group_id
"""
ADMIN_GROUP_PREFIX = 'admin group:'
url_params = {'op': 'login',
'username': self._username,
'password': self._password}
reply = self._rest_client.rpc('admin', url_params, 'Login')
if reply['state'] != 'adminSucceed':
raise exception.CoraidESMBadCredentials()
# Read groups map from login reply.
groups_map = {}
for group_info in reply.get('values', []):
full_group_name = group_info['fullPath']
if full_group_name.startswith(ADMIN_GROUP_PREFIX):
group_name = full_group_name[len(ADMIN_GROUP_PREFIX):]
groups_map[group_name] = group_info['groupId']
return groups_map
def _set_effective_group(self, groups_map, group):
"""Set effective group.
Use groups_map returned from _login method.
"""
try:
group_id = groups_map[group]
except KeyError:
raise exception.CoraidESMBadGroup(group_name=group)
url_params = {'op': 'setRbacGroup',
'groupId': group_id}
reply = self._rest_client.rpc('admin', url_params, 'Group')
if reply['state'] != 'adminSucceed':
raise exception.CoraidESMBadCredentials()
self._logined = True
def _ensure_session(self):
if not self._logined:
groups_map = self._login()
self._set_effective_group(groups_map, self._group)
def _relogin(self):
self._logined = False
self._ensure_session()
def rpc(self, handle, url_params, data, allow_empty_response=False):
self._ensure_session()
relogin_attempts = 3
# Do action, relogin if needed and repeat action.
while True:
reply = self._rest_client.rpc(handle, url_params, data,
allow_empty_response)
if self._is_session_expired(reply):
relogin_attempts -= 1
if relogin_attempts <= 0:
raise exception.CoraidESMReloginFailed()
LOG.debug('Session is expired. Relogin on ESM.')
self._relogin()
else:
return reply
def _is_session_expired(self, reply):
return ('state' in reply and
reply['state'] in ESM_SESSION_EXPIRED_STATES and
reply['metaCROp'] == 'reboot')
def _is_bad_config_state(self, reply):
return (not reply or
'configState' not in reply or
reply['configState'] != 'completedSuccessfully')
def configure(self, json_request):
reply = self.rpc('configure', {}, json_request)
if self._is_bad_config_state(reply):
# Calculate error message
if not reply:
reason = _('Reply is empty.')
else:
reason = reply.get('message', _('Error message is empty.'))
raise exception.CoraidESMConfigureError(reason=reason)
return reply
def esm_command(self, request):
request['data'] = jsonutils.dumps(request['data'])
return self.configure([request])
def get_volume_info(self, volume_name):
"""Retrieve volume information for a given volume name."""
url_params = {'shelf': 'cms',
'orchStrRepo': '',
'lv': volume_name}
reply = self.rpc('fetch', url_params, None)
try:
volume_info = reply[0][1]['reply'][0]
except (IndexError, KeyError):
raise exception.VolumeNotFound(volume_id=volume_name)
return {'pool': volume_info['lv']['containingPool'],
'repo': volume_info['repoName'],
'lun': volume_info['lv']['lvStatus']['exportedLun']['lun'],
'shelf': volume_info['lv']['lvStatus']['exportedLun']['shelf']}
def get_volume_repository(self, volume_name):
volume_info = self.get_volume_info(volume_name)
return volume_info['repo']
def get_all_repos(self):
reply = self.rpc('fetch', {'orchStrRepo': ''}, None)
try:
return reply[0][1]['reply']
except (IndexError, KeyError):
return []
def ping(self):
try:
self.rpc('fetch', {}, None, allow_empty_response=True)
except Exception as e:
LOG.debug('Coraid Appliance ping failed: %s', e)
raise exception.CoraidESMNotAvailable(reason=e)
def create_lun(self, repository_name, volume_name, volume_size_in_gb):
request = {'addr': 'cms',
'data': {
'servers': [],
'repoName': repository_name,
'lvName': volume_name,
'size': coraid_volume_size(volume_size_in_gb)},
'op': 'orchStrLun',
'args': 'add'}
esm_result = self.esm_command(request)
LOG.debug('Volume "%(name)s" created with VSX LUN "%(lun)s"' %
{'name': volume_name,
'lun': esm_result['firstParam']})
return esm_result
def delete_lun(self, volume_name):
repository_name = self.get_volume_repository(volume_name)
request = {'addr': 'cms',
'data': {
'repoName': repository_name,
'lvName': volume_name},
'op': 'orchStrLun/verified',
'args': 'delete'}
esm_result = self.esm_command(request)
LOG.debug('Volume "%s" deleted.', volume_name)
return esm_result
def resize_volume(self, volume_name, new_volume_size_in_gb):
LOG.debug('Resize volume "%(name)s" to %(size)s GB.' %
{'name': volume_name,
'size': new_volume_size_in_gb})
repository = self.get_volume_repository(volume_name)
LOG.debug('Repository for volume "%(name)s" found: "%(repo)s"' %
{'name': volume_name,
'repo': repository})
request = {'addr': 'cms',
'data': {
'lvName': volume_name,
'newLvName': volume_name + '-resize',
'size': coraid_volume_size(new_volume_size_in_gb),
'repoName': repository},
'op': 'orchStrLunMods',
'args': 'resize'}
esm_result = self.esm_command(request)
LOG.debug('Volume "%(name)s" resized. New size is %(size)s GB.' %
{'name': volume_name,
'size': new_volume_size_in_gb})
return esm_result
def create_snapshot(self, volume_name, snapshot_name):
volume_repository = self.get_volume_repository(volume_name)
request = {'addr': 'cms',
'data': {
'repoName': volume_repository,
'lvName': volume_name,
'newLvName': snapshot_name},
'op': 'orchStrLunMods',
'args': 'addClSnap'}
esm_result = self.esm_command(request)
return esm_result
def delete_snapshot(self, snapshot_name):
repository_name = self.get_volume_repository(snapshot_name)
request = {'addr': 'cms',
'data': {
'repoName': repository_name,
'lvName': snapshot_name,
# NOTE(novel): technically, the 'newLvName' is not
# required for 'delClSnap' command. However, some
# versions of ESM have a bug that fails validation
# if we don't specify that. Hence, this fake value.
'newLvName': "noop"},
'op': 'orchStrLunMods',
'args': 'delClSnap'}
esm_result = self.esm_command(request)
return esm_result
def create_volume_from_snapshot(self,
snapshot_name,
volume_name,
dest_repository_name):
snapshot_repo = self.get_volume_repository(snapshot_name)
request = {'addr': 'cms',
'data': {
'lvName': snapshot_name,
'repoName': snapshot_repo,
'newLvName': volume_name,
'newRepoName': dest_repository_name},
'op': 'orchStrLunMods',
'args': 'addClone'}
esm_result = self.esm_command(request)
return esm_result
def clone_volume(self,
src_volume_name,
dst_volume_name,
dst_repository_name):
src_volume_info = self.get_volume_info(src_volume_name)
if src_volume_info['repo'] != dst_repository_name:
raise exception.CoraidException(
_('Cannot create clone volume in different repository.'))
request = {'addr': 'cms',
'data': {
'shelfLun': '{0}.{1}'.format(src_volume_info['shelf'],
src_volume_info['lun']),
'lvName': src_volume_name,
'repoName': src_volume_info['repo'],
'newLvName': dst_volume_name,
'newRepoName': dst_repository_name},
'op': 'orchStrLunMods',
'args': 'addClone'}
return self.esm_command(request)
class CoraidDriver(driver.VolumeDriver):
"""This is the Class to set in cinder.conf (volume_driver)."""
VERSION = '1.0.0'
def __init__(self, *args, **kwargs):
super(CoraidDriver, self).__init__(*args, **kwargs)
self.configuration.append_config_values(coraid_opts)
self._stats = {'driver_version': self.VERSION,
'free_capacity_gb': 'unknown',
'reserved_percentage': 0,
'storage_protocol': 'aoe',
'total_capacity_gb': 'unknown',
'vendor_name': 'Coraid'}
backend_name = self.configuration.safe_get('volume_backend_name')
self._stats['volume_backend_name'] = backend_name or 'EtherCloud ESM'
@property
def appliance(self):
# NOTE(nsobolevsky): This is workaround for bug in the ESM appliance.
# If there is a lot of request with the same session/cookie/connection,
# the appliance could corrupt all following request in session.
# For that purpose we just create a new appliance.
esm_url = "https://{0}:8443".format(
self.configuration.coraid_esm_address)
return CoraidAppliance(CoraidRESTClient(esm_url),
self.configuration.coraid_user,
self.configuration.coraid_password,
self.configuration.coraid_group)
def check_for_setup_error(self):
"""Return an error if prerequisites aren't met."""
self.appliance.ping()
def _get_repository(self, volume_type):
"""Get the ESM Repository from the Volume Type.
The ESM Repository is stored into a volume_type_extra_specs key.
"""
volume_type_id = volume_type['id']
repository_key_name = self.configuration.coraid_repository_key
repository = (volume_types.get_volume_type_extra_specs(
volume_type_id, repository_key_name) or
self.configuration.coraid_default_repository)
# if there's no repository still, we cannot move forward
if not repository:
message = ("The Coraid repository not specified neither "
"in Volume Type '%s' key nor in the "
"'coraid_default_repository' config option" %
self.configuration.coraid_repository_key)
raise exception.CoraidException(message=message)
# Remove <in> keyword from repository name if needed
if repository.startswith('<in> '):
return repository[len('<in> '):]
else:
return repository
def create_volume(self, volume):
"""Create a Volume."""
repository = self._get_repository(volume['volume_type'])
self.appliance.create_lun(repository, volume['name'], volume['size'])
def create_cloned_volume(self, volume, src_vref):
dst_volume_repository = self._get_repository(volume['volume_type'])
self.appliance.clone_volume(src_vref['name'],
volume['name'],
dst_volume_repository)
if volume['size'] != src_vref['size']:
self.appliance.resize_volume(volume['name'], volume['size'])
def delete_volume(self, volume):
"""Delete a Volume."""
try:
self.appliance.delete_lun(volume['name'])
except exception.VolumeNotFound:
self.appliance.ping()
def create_snapshot(self, snapshot):
"""Create a Snapshot."""
volume_name = snapshot['volume_name']
snapshot_name = snapshot['name']
self.appliance.create_snapshot(volume_name, snapshot_name)
def delete_snapshot(self, snapshot):
"""Delete a Snapshot."""
snapshot_name = snapshot['name']
self.appliance.delete_snapshot(snapshot_name)
def create_volume_from_snapshot(self, volume, snapshot):
"""Create a Volume from a Snapshot."""
snapshot_name = snapshot['name']
repository = self._get_repository(volume['volume_type'])
self.appliance.create_volume_from_snapshot(snapshot_name,
volume['name'],
repository)
if volume['size'] > snapshot['volume_size']:
self.appliance.resize_volume(volume['name'], volume['size'])
def extend_volume(self, volume, new_size):
"""Extend an existing volume."""
self.appliance.resize_volume(volume['name'], new_size)
def initialize_connection(self, volume, connector):
"""Return connection information."""
volume_info = self.appliance.get_volume_info(volume['name'])
shelf = volume_info['shelf']
lun = volume_info['lun']
LOG.debug('Initialize connection %(shelf)s/%(lun)s for %(name)s' %
{'shelf': shelf,
'lun': lun,
'name': volume['name']})
aoe_properties = {'target_shelf': shelf,
'target_lun': lun}
return {'driver_volume_type': 'aoe',
'data': aoe_properties}
def _get_repository_capabilities(self):
repos_list = map(lambda i: i['profile']['fullName'] + ':' + i['name'],
self.appliance.get_all_repos())
return ' '.join(repos_list)
def update_volume_stats(self):
capabilities = self._get_repository_capabilities()
self._stats[self.configuration.coraid_repository_key] = capabilities
def get_volume_stats(self, refresh=False):
"""Return Volume Stats."""
if refresh:
self.update_volume_stats()
return self._stats
def local_path(self, volume):
pass
def create_export(self, context, volume):
pass
def remove_export(self, context, volume):
pass
def terminate_connection(self, volume, connector, **kwargs):
pass
def ensure_export(self, context, volume):
pass