458f098318
This is part of the effort to gather accurate information about Cinder drivers. This driver is missing the CI WIKI info. Change-Id: I3e71a6b2a04414c8324184e196a1d6fdc95adcc8
1630 lines
68 KiB
Python
1630 lines
68 KiB
Python
# Copyright (c) 2014 X-IO.
|
|
# 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.
|
|
|
|
from lxml import etree
|
|
from oslo_config import cfg
|
|
from oslo_log import log as logging
|
|
from oslo_serialization import base64
|
|
from oslo_service import loopingcall
|
|
from six.moves import urllib
|
|
|
|
from cinder import context
|
|
from cinder import exception
|
|
from cinder.i18n import _LE, _LI, _LW
|
|
from cinder import interface
|
|
from cinder.volume import driver
|
|
from cinder.volume.drivers.san import san
|
|
from cinder.volume import qos_specs
|
|
from cinder.volume import volume_types
|
|
from cinder.zonemanager import utils as fczm_utils
|
|
|
|
XIO_OPTS = [
|
|
cfg.IntOpt('ise_storage_pool', default=1,
|
|
help='Default storage pool for volumes.'),
|
|
cfg.IntOpt('ise_raid', default=1,
|
|
help='Raid level for ISE volumes.'),
|
|
cfg.IntOpt('ise_connection_retries', default=5,
|
|
help='Number of retries (per port) when establishing '
|
|
'connection to ISE management port.'),
|
|
cfg.IntOpt('ise_retry_interval', default=1,
|
|
help='Interval (secs) between retries.'),
|
|
cfg.IntOpt('ise_completion_retries', default=30,
|
|
help='Number on retries to get completion status after '
|
|
'issuing a command to ISE.'),
|
|
]
|
|
|
|
|
|
CONF = cfg.CONF
|
|
CONF.register_opts(XIO_OPTS)
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
OPERATIONAL_STATUS = 'OPERATIONAL'
|
|
PREPARED_STATUS = 'PREPARED'
|
|
INVALID_STATUS = 'VALID'
|
|
NOTFOUND_STATUS = 'NOT FOUND'
|
|
|
|
|
|
# Raise exception for X-IO driver
|
|
def RaiseXIODriverException():
|
|
raise exception.XIODriverException()
|
|
|
|
|
|
class XIOISEDriver(driver.VolumeDriver):
|
|
|
|
VERSION = '1.1.4'
|
|
|
|
# Version Changes
|
|
# 1.0.0 Base driver
|
|
# 1.1.0 QoS, affinity, retype and thin support
|
|
# 1.1.1 Fix retry loop (Bug 1429283)
|
|
# 1.1.2 Fix host object deletion (Bug 1433450).
|
|
# 1.1.3 Wait for volume/snapshot to be deleted.
|
|
# 1.1.4 Force target_lun to be int (Bug 1549048)
|
|
|
|
# ThirdPartySystems wiki page
|
|
CI_WIKI_NAME = "X-IO_technologies_CI"
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(XIOISEDriver, self).__init__()
|
|
LOG.debug("XIOISEDriver __init__ called.")
|
|
self.configuration = kwargs.get('configuration', None)
|
|
self.ise_primary_ip = ''
|
|
self.ise_secondary_ip = ''
|
|
self.newquery = 1
|
|
self.ise_globalid = None
|
|
self._vol_stats = {}
|
|
|
|
def do_setup(self, context):
|
|
LOG.debug("XIOISEDriver do_setup called.")
|
|
self._get_ise_globalid()
|
|
|
|
def check_for_setup_error(self):
|
|
LOG.debug("XIOISEDriver check_for_setup_error called.")
|
|
# The san_ip must always be set
|
|
if self.configuration.san_ip == "":
|
|
LOG.error(_LE("san ip must be configured!"))
|
|
RaiseXIODriverException()
|
|
# The san_login must always be set
|
|
if self.configuration.san_login == "":
|
|
LOG.error(_LE("san_login must be configured!"))
|
|
RaiseXIODriverException()
|
|
# The san_password must always be set
|
|
if self.configuration.san_password == "":
|
|
LOG.error(_LE("san_password must be configured!"))
|
|
RaiseXIODriverException()
|
|
return
|
|
|
|
def _get_version(self):
|
|
"""Return driver version."""
|
|
return self.VERSION
|
|
|
|
def _send_query(self):
|
|
"""Do initial query to populate ISE global id."""
|
|
body = ''
|
|
url = '/query'
|
|
resp = self._connect('GET', url, body)
|
|
status = resp['status']
|
|
if status != 200:
|
|
# unsuccessful - this is fatal as we need the global id
|
|
# to build REST requests.
|
|
LOG.error(_LE("Array query failed - No response (%d)!"), status)
|
|
RaiseXIODriverException()
|
|
# Successfully fetched QUERY info. Parse out globalid along with
|
|
# ipaddress for Controller 1 and Controller 2. We assign primary
|
|
# ipaddress to use based on controller rank
|
|
xml_tree = etree.fromstring(resp['content'])
|
|
# first check that the ISE is running a supported FW version
|
|
support = {}
|
|
support['thin'] = False
|
|
support['clones'] = False
|
|
support['thin-clones'] = False
|
|
self.configuration.ise_affinity = False
|
|
self.configuration.ise_qos = False
|
|
capabilities = xml_tree.find('capabilities')
|
|
if capabilities is None:
|
|
LOG.error(_LE("Array query failed. No capabilities in response!"))
|
|
RaiseXIODriverException()
|
|
for node in capabilities:
|
|
if node.tag != 'capability':
|
|
continue
|
|
capability = node
|
|
if capability.attrib['value'] == '49003':
|
|
self.configuration.ise_affinity = True
|
|
elif capability.attrib['value'] == '49004':
|
|
self.configuration.ise_qos = True
|
|
elif capability.attrib['value'] == '49005':
|
|
support['thin'] = True
|
|
elif capability.attrib['value'] == '49006':
|
|
support['clones'] = True
|
|
elif capability.attrib['value'] == '49007':
|
|
support['thin-clones'] = True
|
|
# Make sure ISE support necessary features
|
|
if not support['clones']:
|
|
LOG.error(_LE("ISE FW version is not compatible with OpenStack!"))
|
|
RaiseXIODriverException()
|
|
# set up thin provisioning support
|
|
self.configuration.san_thin_provision = support['thin-clones']
|
|
# Fill in global id, primary and secondary ip addresses
|
|
globalid = xml_tree.find('globalid')
|
|
if globalid is None:
|
|
LOG.error(_LE("Array query failed. No global id in XML response!"))
|
|
RaiseXIODriverException()
|
|
self.ise_globalid = globalid.text
|
|
controllers = xml_tree.find('controllers')
|
|
if controllers is None:
|
|
LOG.error(_LE("Array query failed. No controllers in response!"))
|
|
RaiseXIODriverException()
|
|
for node in controllers:
|
|
if node.tag != 'controller':
|
|
continue
|
|
# found a controller node
|
|
controller = node
|
|
ipaddress = controller.find('ipaddress')
|
|
ranktag = controller.find('rank')
|
|
if ipaddress is None:
|
|
continue
|
|
# found an ipaddress tag
|
|
# make sure rank tag is present
|
|
if ranktag is None:
|
|
continue
|
|
rank = ranktag.attrib['value']
|
|
# make sure rank value is present
|
|
if rank is None:
|
|
continue
|
|
if rank == '1':
|
|
# rank 1 means primary (xo)
|
|
self.ise_primary_ip = ipaddress.text
|
|
LOG.debug('Setting primary IP to: %s.',
|
|
self.ise_primary_ip)
|
|
elif rank == '0':
|
|
# rank 0 means secondary (nxo)
|
|
self.ise_secondary_ip = ipaddress.text
|
|
LOG.debug('Setting secondary IP to: %s.',
|
|
self.ise_secondary_ip)
|
|
# clear out new query request flag on successful fetch of QUERY info.
|
|
self.newquery = 0
|
|
return support
|
|
|
|
def _get_ise_globalid(self):
|
|
"""Return ISE globalid."""
|
|
if self.ise_globalid is None or self.newquery == 1:
|
|
# this call will populate globalid
|
|
self._send_query()
|
|
if self.ise_globalid is None:
|
|
LOG.error(_LE("ISE globalid not set!"))
|
|
RaiseXIODriverException()
|
|
return self.ise_globalid
|
|
|
|
def _get_ise_primary_ip(self):
|
|
"""Return Primary IP address to REST API."""
|
|
if self.ise_primary_ip == '':
|
|
# Primary IP is set to ISE IP passed in from cinder.conf
|
|
self.ise_primary_ip = self.configuration.san_ip
|
|
if self.ise_primary_ip == '':
|
|
# No IP - fatal.
|
|
LOG.error(_LE("Primary IP must be set!"))
|
|
RaiseXIODriverException()
|
|
return self.ise_primary_ip
|
|
|
|
def _get_ise_secondary_ip(self):
|
|
"""Return Secondary IP address to REST API."""
|
|
if self.ise_secondary_ip != '':
|
|
return self.ise_secondary_ip
|
|
|
|
def _get_uri_prefix(self):
|
|
"""Returns prefix in form of http(s)://1.2.3.4"""
|
|
prefix = ''
|
|
# figure out if http or https should be used
|
|
if self.configuration.driver_use_ssl:
|
|
prefix = 'https://'
|
|
else:
|
|
prefix = 'http://'
|
|
# add the IP address
|
|
prefix += self._get_ise_primary_ip()
|
|
return prefix
|
|
|
|
def _opener(self, method, url, body, header):
|
|
"""Wrapper to handle connection"""
|
|
response = {}
|
|
response['status'] = 0
|
|
response['content'] = ''
|
|
response['location'] = ''
|
|
# send the request
|
|
req = urllib.request.Request(url, body, header)
|
|
# Override method to allow GET, PUT, POST, DELETE
|
|
req.get_method = lambda: method
|
|
try:
|
|
resp = urllib.request.urlopen(req)
|
|
except urllib.error.HTTPError as err:
|
|
# HTTP error. Return HTTP status and content and let caller
|
|
# handle retries.
|
|
response['status'] = err.code
|
|
response['content'] = err.read()
|
|
except urllib.error.URLError as err:
|
|
# Connection failure. Return a status of 0 to indicate error.
|
|
response['status'] = 0
|
|
else:
|
|
# Successful. Return status code, content,
|
|
# and location header, if present.
|
|
response['status'] = resp.getcode()
|
|
response['content'] = resp.read()
|
|
response['location'] = \
|
|
resp.info().getheader('Content-Location', '')
|
|
return response
|
|
|
|
def _help_call_method(self, args, retry_count):
|
|
"""Helper function used for prepare clone and delete REST calls."""
|
|
# This function calls request method and URL and checks the response.
|
|
# Certain cases allows for retries, while success and fatal status
|
|
# will fall out and tell parent to break out of loop.
|
|
# initialize remaining to one less than retries
|
|
remaining = retry_count
|
|
resp = self._send_cmd(args['method'], args['url'], args['arglist'])
|
|
status = resp['status']
|
|
if (status == 400):
|
|
reason = ''
|
|
if 'content' in resp:
|
|
reason = etree.fromstring(resp['content'])
|
|
if reason is not None:
|
|
reason = reason.text.upper()
|
|
if INVALID_STATUS in reason:
|
|
# Request failed with an invalid state. This can be because
|
|
# source volume is in a temporary unavailable state.
|
|
LOG.debug('REST call failed with invalid state: '
|
|
'%(method)s - %(status)d - %(reason)s',
|
|
{'method': args['method'],
|
|
'status': status, 'reason': reason})
|
|
# Let parent check retry eligibility based on remaining retries
|
|
remaining -= 1
|
|
else:
|
|
# Fatal error. Set remaining to 0 to make caller exit loop.
|
|
remaining = 0
|
|
else:
|
|
# set remaining to 0 to make caller exit loop
|
|
# original waiter will handle the difference between success and
|
|
# fatal error based on resp['status'].
|
|
remaining = 0
|
|
return (remaining, resp)
|
|
|
|
def _help_call_opener(self, args, retry_count):
|
|
"""Helper function to call _opener."""
|
|
# This function calls _opener func and checks the response.
|
|
# If response is 0 it will decrement the remaining retry count.
|
|
# On successful connection it will set remaining to 0 to signal
|
|
# parent to break out of loop.
|
|
remaining = retry_count
|
|
response = self._opener(args['method'], args['url'],
|
|
args['body'], args['header'])
|
|
if response['status'] != 0:
|
|
# We are done
|
|
remaining = 0
|
|
else:
|
|
# Let parent check retry eligibility based on remaining retries.
|
|
remaining -= 1
|
|
# Return remaining and response
|
|
return (remaining, response)
|
|
|
|
def _help_wait_for_status(self, args, retry_count):
|
|
"""Helper function to wait for specified volume status"""
|
|
# This function calls _get_volume_info and checks the response.
|
|
# If the status strings do not match the specified status it will
|
|
# return the remaining retry count decremented by one.
|
|
# On successful match it will set remaining to 0 to signal
|
|
# parent to break out of loop.
|
|
remaining = retry_count
|
|
info = self._get_volume_info(args['name'])
|
|
status = args['status_string']
|
|
if (status in info['string'] or status in info['details']):
|
|
remaining = 0
|
|
else:
|
|
# Let parent check retry eligibility based on remaining retries.
|
|
remaining -= 1
|
|
# return remaining and volume info
|
|
return (remaining, info)
|
|
|
|
def _wait_for_completion(self, help_func, args, retry_count):
|
|
"""Helper function to wait for completion of passed function"""
|
|
# Helper call loop function.
|
|
def _call_loop(loop_args):
|
|
remaining = loop_args['retries']
|
|
args = loop_args['args']
|
|
LOG.debug("In call loop (%(remaining)d) %(args)s",
|
|
{'remaining': remaining, 'args': args})
|
|
(remaining, response) = loop_args['func'](args, remaining)
|
|
if remaining == 0:
|
|
# We are done - let our caller handle response
|
|
raise loopingcall.LoopingCallDone(response)
|
|
loop_args['retries'] = remaining
|
|
|
|
# Setup retries, interval and call wait function.
|
|
loop_args = {}
|
|
loop_args['retries'] = retry_count
|
|
loop_args['func'] = help_func
|
|
loop_args['args'] = args
|
|
interval = self.configuration.ise_retry_interval
|
|
timer = loopingcall.FixedIntervalLoopingCall(_call_loop, loop_args)
|
|
return timer.start(interval).wait()
|
|
|
|
def _connect(self, method, uri, body=''):
|
|
"""Set up URL and HTML and call _opener to make request"""
|
|
url = ''
|
|
# see if we need to add prefix
|
|
# this call will force primary ip to be filled in as well
|
|
prefix = self._get_uri_prefix()
|
|
if prefix not in uri:
|
|
url = prefix
|
|
url += uri
|
|
# set up headers for XML and Auth
|
|
header = {'Content-Type': 'application/xml; charset=utf-8'}
|
|
auth_key = ('%s:%s'
|
|
% (self.configuration.san_login,
|
|
self.configuration.san_password))
|
|
auth_key = base64.encode_as_text(auth_key)
|
|
header['Authorization'] = 'Basic %s' % auth_key
|
|
# We allow 5 retries on each IP address. If connection to primary
|
|
# fails, secondary will be tried. If connection to secondary is
|
|
# successful, the request flag for a new QUERY will be set. The QUERY
|
|
# will be sent on next connection attempt to figure out which
|
|
# controller is primary in case it has changed.
|
|
LOG.debug("Connect: %(method)s %(url)s %(body)s",
|
|
{'method': method, 'url': url, 'body': body})
|
|
using_secondary = 0
|
|
response = {}
|
|
response['status'] = 0
|
|
response['location'] = ''
|
|
response['content'] = ''
|
|
primary_ip = self._get_ise_primary_ip()
|
|
secondary_ip = self._get_ise_secondary_ip()
|
|
# This will first try connecting to primary IP and then secondary IP.
|
|
args = {}
|
|
args['method'] = method
|
|
args['url'] = url
|
|
args['body'] = body
|
|
args['header'] = header
|
|
retries = self.configuration.ise_connection_retries
|
|
while True:
|
|
response = self._wait_for_completion(self._help_call_opener,
|
|
args, retries)
|
|
if response['status'] != 0:
|
|
# Connection succeeded. Request new query on next connection
|
|
# attempt if we used secondary ip to sort out who should be
|
|
# primary going forward
|
|
self.newquery = using_secondary
|
|
return response
|
|
# connection failed - check if we have any retries left
|
|
if using_secondary == 0:
|
|
# connection on primary ip failed
|
|
# try secondary ip
|
|
if secondary_ip is '':
|
|
# if secondary is not setup yet, then assert
|
|
# connection on primary and secondary ip failed
|
|
LOG.error(_LE("Connection to %s failed and no secondary!"),
|
|
primary_ip)
|
|
RaiseXIODriverException()
|
|
# swap primary for secondary ip in URL
|
|
url = url.replace(primary_ip, secondary_ip)
|
|
LOG.debug('Trying secondary IP URL: %s', url)
|
|
using_secondary = 1
|
|
continue
|
|
# connection failed on both IPs - break out of the loop
|
|
break
|
|
# connection on primary and secondary ip failed
|
|
LOG.error(_LE("Could not connect to %(primary)s or %(secondary)s!"),
|
|
{'primary': primary_ip, 'secondary': secondary_ip})
|
|
RaiseXIODriverException()
|
|
|
|
def _param_string(self, params):
|
|
"""Turn (name, value) pairs into single param string"""
|
|
param_str = []
|
|
for name, value in params.items():
|
|
if value != '':
|
|
param_str.append("%s=%s" % (name, value))
|
|
return '&'.join(param_str)
|
|
|
|
def _send_cmd(self, method, url, params=None):
|
|
"""Prepare HTTP request and call _connect"""
|
|
params = params or {}
|
|
# Add params to appropriate field based on method
|
|
if method in ('GET', 'PUT'):
|
|
if params:
|
|
url += '?' + self._param_string(params)
|
|
body = ''
|
|
elif method == 'POST':
|
|
body = self._param_string(params)
|
|
else:
|
|
# method like 'DELETE'
|
|
body = ''
|
|
# ISE REST API is mostly synchronous but has some asynchronous
|
|
# streaks. Add retries to work around design of ISE REST API that
|
|
# does not allow certain operations to be in process concurrently.
|
|
# This is only an issue if lots of CREATE/DELETE/SNAPSHOT/CLONE ops
|
|
# are issued in short order.
|
|
return self._connect(method, url, body)
|
|
|
|
def find_target_chap(self):
|
|
"""Return target CHAP settings"""
|
|
chap = {}
|
|
chap['chap_user'] = ''
|
|
chap['chap_passwd'] = ''
|
|
url = '/storage/arrays/%s/ionetworks' % (self._get_ise_globalid())
|
|
resp = self._send_cmd('GET', url)
|
|
status = resp['status']
|
|
if status != 200:
|
|
LOG.warning(_LW("IOnetworks GET failed (%d)"), status)
|
|
return chap
|
|
# Got a good response. Parse out CHAP info. First check if CHAP is
|
|
# enabled and if so parse out username and password.
|
|
root = etree.fromstring(resp['content'])
|
|
for element in root.iter():
|
|
if element.tag != 'chap':
|
|
continue
|
|
chapin = element.find('chapin')
|
|
if chapin is None:
|
|
continue
|
|
if chapin.attrib['value'] != '1':
|
|
continue
|
|
# CHAP is enabled. Store username / pw
|
|
chap_user = chapin.find('username')
|
|
if chap_user is not None:
|
|
chap['chap_user'] = chap_user.text
|
|
chap_passwd = chapin.find('password')
|
|
if chap_passwd is not None:
|
|
chap['chap_passwd'] = chap_passwd.text
|
|
break
|
|
return chap
|
|
|
|
def find_target_iqn(self, iscsi_ip):
|
|
"""Find Target IQN string"""
|
|
url = '/storage/arrays/%s/controllers' % (self._get_ise_globalid())
|
|
resp = self._send_cmd('GET', url)
|
|
status = resp['status']
|
|
if status != 200:
|
|
# Not good. Throw an exception.
|
|
LOG.error(_LE("Controller GET failed (%d)"), status)
|
|
RaiseXIODriverException()
|
|
# Good response. Parse out IQN that matches iscsi_ip_address
|
|
# passed in from cinder.conf. IQN is 'hidden' in globalid field.
|
|
root = etree.fromstring(resp['content'])
|
|
for element in root.iter():
|
|
if element.tag != 'ioport':
|
|
continue
|
|
ipaddrs = element.find('ipaddresses')
|
|
if ipaddrs is None:
|
|
continue
|
|
for ipaddr in ipaddrs.iter():
|
|
# Look for match with iscsi_ip_address
|
|
if ipaddr is None or ipaddr.text != iscsi_ip:
|
|
continue
|
|
endpoint = element.find('endpoint')
|
|
if endpoint is None:
|
|
continue
|
|
global_id = endpoint.find('globalid')
|
|
if global_id is None:
|
|
continue
|
|
target_iqn = global_id.text
|
|
if target_iqn != '':
|
|
return target_iqn
|
|
# Did not find a matching IQN. Upsetting.
|
|
LOG.error(_LE("Failed to get IQN!"))
|
|
RaiseXIODriverException()
|
|
|
|
def find_target_wwns(self):
|
|
"""Return target WWN"""
|
|
# Let's look for WWNs
|
|
target_wwns = []
|
|
target = ''
|
|
url = '/storage/arrays/%s/controllers' % (self._get_ise_globalid())
|
|
resp = self._send_cmd('GET', url)
|
|
status = resp['status']
|
|
if status != 200:
|
|
# Not good. Throw an exception.
|
|
LOG.error(_LE("Controller GET failed (%d)"), status)
|
|
RaiseXIODriverException()
|
|
# Good response. Parse out globalid (WWN) of endpoint that matches
|
|
# protocol and type (array).
|
|
controllers = etree.fromstring(resp['content'])
|
|
for controller in controllers.iter():
|
|
if controller.tag != 'controller':
|
|
continue
|
|
fcports = controller.find('fcports')
|
|
if fcports is None:
|
|
continue
|
|
for fcport in fcports:
|
|
if fcport.tag != 'fcport':
|
|
continue
|
|
wwn_tag = fcport.find('wwn')
|
|
if wwn_tag is None:
|
|
continue
|
|
target = wwn_tag.text
|
|
target_wwns.append(target)
|
|
return target_wwns
|
|
|
|
def _find_target_lun(self, location):
|
|
"""Return LUN for allocation specified in location string"""
|
|
resp = self._send_cmd('GET', location)
|
|
status = resp['status']
|
|
if status != 200:
|
|
# Not good. Throw an exception.
|
|
LOG.error(_LE("Failed to get allocation information (%d)!"),
|
|
status)
|
|
RaiseXIODriverException()
|
|
# Good response. Parse out LUN.
|
|
xml_tree = etree.fromstring(resp['content'])
|
|
allocation = xml_tree.find('allocation')
|
|
if allocation is not None:
|
|
luntag = allocation.find('lun')
|
|
if luntag is not None:
|
|
return luntag.text
|
|
# Did not find LUN. Throw an exception.
|
|
LOG.error(_LE("Failed to get LUN information!"))
|
|
RaiseXIODriverException()
|
|
|
|
def _get_volume_info(self, vol_name):
|
|
"""Return status of ISE volume"""
|
|
vol_info = {}
|
|
vol_info['value'] = ''
|
|
vol_info['string'] = NOTFOUND_STATUS
|
|
vol_info['details'] = ''
|
|
vol_info['location'] = ''
|
|
vol_info['size'] = ''
|
|
# Attempt to collect status value, string and details. Also pick up
|
|
# location string from response. Location is used in REST calls
|
|
# DELETE/SNAPSHOT/CLONE.
|
|
# We ask for specific volume, so response should only contain one
|
|
# volume entry.
|
|
url = '/storage/arrays/%s/volumes' % (self._get_ise_globalid())
|
|
resp = self._send_cmd('GET', url, {'name': vol_name})
|
|
if resp['status'] != 200:
|
|
LOG.warning(_LW("Could not get status for %(name)s (%(status)d)."),
|
|
{'name': vol_name, 'status': resp['status']})
|
|
return vol_info
|
|
# Good response. Parse down to Volume tag in list of one.
|
|
root = etree.fromstring(resp['content'])
|
|
volume_node = root.find('volume')
|
|
if volume_node is None:
|
|
LOG.warning(_LW("No volume node in XML content."))
|
|
return vol_info
|
|
# Location can be found as an attribute in the volume node tag.
|
|
vol_info['location'] = volume_node.attrib['self']
|
|
# Find status tag
|
|
status = volume_node.find('status')
|
|
if status is None:
|
|
LOG.warning(_LW("No status payload for volume %s."), vol_name)
|
|
return vol_info
|
|
# Fill in value and string from status tag attributes.
|
|
vol_info['value'] = status.attrib['value']
|
|
vol_info['string'] = status.attrib['string'].upper()
|
|
# Detailed status has it's own list of tags.
|
|
details = status.find('details')
|
|
if details is not None:
|
|
detail = details.find('detail')
|
|
if detail is not None:
|
|
vol_info['details'] = detail.text.upper()
|
|
# Get volume size
|
|
size_tag = volume_node.find('size')
|
|
if size_tag is not None:
|
|
vol_info['size'] = size_tag.text
|
|
# Return value, string, details and location.
|
|
return vol_info
|
|
|
|
def _alloc_location(self, volume, hostname, delete=0):
|
|
"""Find location string for allocation. Also delete alloc per reqst"""
|
|
location = ''
|
|
url = '/storage/arrays/%s/allocations' % (self._get_ise_globalid())
|
|
resp = self._send_cmd('GET', url, {'name': volume['name'],
|
|
'hostname': hostname})
|
|
if resp['status'] != 200:
|
|
LOG.error(_LE("Could not GET allocation information (%d)!"),
|
|
resp['status'])
|
|
RaiseXIODriverException()
|
|
# Good response. Find the allocation based on volume name.
|
|
allocation_tree = etree.fromstring(resp['content'])
|
|
for allocation in allocation_tree.iter():
|
|
if allocation.tag != 'allocation':
|
|
continue
|
|
# verify volume name match
|
|
volume_tag = allocation.find('volume')
|
|
if volume_tag is None:
|
|
continue
|
|
volumename_tag = volume_tag.find('volumename')
|
|
if volumename_tag is None:
|
|
continue
|
|
volumename = volumename_tag.text
|
|
if volumename != volume['name']:
|
|
continue
|
|
# verified volume name match
|
|
# find endpoints list
|
|
endpoints = allocation.find('endpoints')
|
|
if endpoints is None:
|
|
continue
|
|
# Found endpoints list. Found matching host if hostname specified,
|
|
# otherwise any host is a go. This is used by the caller to
|
|
# delete all allocations (presentations) to a volume.
|
|
for endpoint in endpoints.iter():
|
|
if hostname != '':
|
|
hname_tag = endpoint.find('hostname')
|
|
if hname_tag is None:
|
|
continue
|
|
if hname_tag.text.upper() != hostname.upper():
|
|
continue
|
|
# Found hostname match. Location string is an attribute in
|
|
# allocation tag.
|
|
location = allocation.attrib['self']
|
|
# Delete allocation if requested.
|
|
if delete == 1:
|
|
self._send_cmd('DELETE', location)
|
|
location = ''
|
|
break
|
|
else:
|
|
return location
|
|
return location
|
|
|
|
def _present_volume(self, volume, hostname, lun):
|
|
"""Present volume to host at specified LUN"""
|
|
# Set up params with volume name, host name and target lun, if
|
|
# specified.
|
|
target_lun = lun
|
|
params = {'volumename': volume['name'],
|
|
'hostname': hostname}
|
|
# Fill in LUN if specified.
|
|
if target_lun != '':
|
|
params['lun'] = target_lun
|
|
# Issue POST call to allocation.
|
|
url = '/storage/arrays/%s/allocations' % (self._get_ise_globalid())
|
|
resp = self._send_cmd('POST', url, params)
|
|
status = resp['status']
|
|
if status == 201:
|
|
LOG.info(_LI("Volume %s presented."), volume['name'])
|
|
elif status == 409:
|
|
LOG.warning(_LW("Volume %(name)s already presented (%(status)d)!"),
|
|
{'name': volume['name'], 'status': status})
|
|
else:
|
|
LOG.error(_LE("Failed to present volume %(name)s (%(status)d)!"),
|
|
{'name': volume['name'], 'status': status})
|
|
RaiseXIODriverException()
|
|
# Fetch LUN. In theory the LUN should be what caller requested.
|
|
# We try to use shortcut as location comes back in Location header.
|
|
# Make sure shortcut of using location header worked, if not ask
|
|
# for it explicitly.
|
|
location = resp['location']
|
|
if location == '':
|
|
location = self._alloc_location(volume, hostname)
|
|
# Find target LUN
|
|
if location != '':
|
|
target_lun = self._find_target_lun(location)
|
|
# Success. Return target LUN.
|
|
LOG.debug("Volume %(volume)s presented: %(host)s %(lun)s",
|
|
{'volume': volume['name'], 'host': hostname,
|
|
'lun': target_lun})
|
|
return target_lun
|
|
|
|
def find_allocations(self, hostname):
|
|
"""Find allocations for specified host"""
|
|
alloc_cnt = 0
|
|
url = '/storage/arrays/%s/allocations' % (self._get_ise_globalid())
|
|
resp = self._send_cmd('GET', url, {'hostname': hostname})
|
|
status = resp['status']
|
|
if status != 200:
|
|
LOG.error(_LE("Failed to get allocation information: "
|
|
"%(host)s (%(status)d)!"),
|
|
{'host': hostname, 'status': status})
|
|
RaiseXIODriverException()
|
|
# Good response. Count the number of allocations.
|
|
allocation_tree = etree.fromstring(resp['content'])
|
|
for allocation in allocation_tree.iter():
|
|
if allocation.tag != 'allocation':
|
|
continue
|
|
alloc_cnt += 1
|
|
return alloc_cnt
|
|
|
|
def _find_host(self, endpoints):
|
|
"""Check if host entry exists on ISE based on endpoint (IQN, WWNs)"""
|
|
# FC host might have more than one endpoint. ISCSI has only one.
|
|
# Check if endpoints is a list, if so use first entry in list for
|
|
# host search.
|
|
if type(endpoints) is list:
|
|
for endpoint in endpoints:
|
|
ep = endpoint
|
|
break
|
|
else:
|
|
ep = endpoints
|
|
# Got single end point. Now make REST API call to fetch all hosts
|
|
LOG.debug("find_host: Looking for host %s.", ep)
|
|
host = {}
|
|
host['name'] = ''
|
|
host['type'] = ''
|
|
host['locator'] = ''
|
|
params = {}
|
|
url = '/storage/arrays/%s/hosts' % (self._get_ise_globalid())
|
|
resp = self._send_cmd('GET', url, params)
|
|
status = resp['status']
|
|
if resp['status'] != 200:
|
|
LOG.error(_LE("Could not find any hosts (%s)"), status)
|
|
RaiseXIODriverException()
|
|
# Good response. Try to match up a host based on end point string.
|
|
host_tree = etree.fromstring(resp['content'])
|
|
for host_node in host_tree.iter():
|
|
if host_node.tag != 'host':
|
|
continue
|
|
# Found a host tag. Check if end point matches.
|
|
endpoints_node = host_node.find('endpoints')
|
|
if endpoints_node is None:
|
|
continue
|
|
for endpoint_node in endpoints_node.iter():
|
|
if endpoint_node.tag != 'endpoint':
|
|
continue
|
|
gid = endpoint_node.find('globalid')
|
|
if gid is None:
|
|
continue
|
|
if gid.text.upper() != ep.upper():
|
|
continue
|
|
# We have a match. Fill in host name, type and locator
|
|
host['locator'] = host_node.attrib['self']
|
|
type_tag = host_node.find('type')
|
|
if type_tag is not None:
|
|
host['type'] = type_tag.text
|
|
name_tag = host_node.find('name')
|
|
if name_tag is not None:
|
|
host['name'] = name_tag.text
|
|
break
|
|
# This will be filled in or '' based on findings above.
|
|
return host
|
|
|
|
def _create_host(self, hostname, endpoints):
|
|
"""Create host entry on ISE for connector"""
|
|
# Create endpoint list for REST call.
|
|
endpoint_str = ''
|
|
if type(endpoints) is list:
|
|
ep_str = []
|
|
ec = 0
|
|
for endpoint in endpoints:
|
|
if ec == 0:
|
|
ep_str.append("%s" % (endpoint))
|
|
else:
|
|
ep_str.append("endpoint=%s" % (endpoint))
|
|
ec += 1
|
|
endpoint_str = '&'.join(ep_str)
|
|
else:
|
|
endpoint_str = endpoints
|
|
# Log host creation.
|
|
LOG.debug("Create host %(host)s; %(endpoint)s",
|
|
{'host': hostname, 'endpoint': endpoint_str})
|
|
# Issue REST call to create host entry of OpenStack type.
|
|
params = {'name': hostname, 'endpoint': endpoint_str,
|
|
'os': 'openstack'}
|
|
url = '/storage/arrays/%s/hosts' % (self._get_ise_globalid())
|
|
resp = self._send_cmd('POST', url, params)
|
|
status = resp['status']
|
|
if status != 201 and status != 409:
|
|
LOG.error(_LE("POST for host create failed (%s)!"), status)
|
|
RaiseXIODriverException()
|
|
# Successfully created host entry. Return host name.
|
|
return hostname
|
|
|
|
def _create_clone(self, volume, clone, clone_type):
|
|
"""Create clone worker function"""
|
|
# This function is called for both snapshot and clone
|
|
# clone_type specifies what type is being processed
|
|
# Creating snapshots and clones is a two step process on current ISE
|
|
# FW. First snapshot/clone is prepared and then created.
|
|
volume_name = ''
|
|
if clone_type == 'snapshot':
|
|
volume_name = volume['volume_name']
|
|
elif clone_type == 'clone':
|
|
volume_name = volume['name']
|
|
args = {}
|
|
# Make sure source volume is ready. This is another case where
|
|
# we have to work around asynchronous behavior in ISE REST API.
|
|
args['name'] = volume_name
|
|
args['status_string'] = OPERATIONAL_STATUS
|
|
retries = self.configuration.ise_completion_retries
|
|
vol_info = self._wait_for_completion(self._help_wait_for_status,
|
|
args, retries)
|
|
if vol_info['value'] == '0':
|
|
LOG.debug('Source volume %s ready.', volume_name)
|
|
else:
|
|
LOG.error(_LE("Source volume %s not ready!"), volume_name)
|
|
RaiseXIODriverException()
|
|
# Prepare snapshot
|
|
# get extra_specs and qos specs from source volume
|
|
# these functions fill in default values for entries used below
|
|
ctxt = context.get_admin_context()
|
|
type_id = volume['volume_type_id']
|
|
extra_specs = self._get_extra_specs(ctxt, type_id)
|
|
LOG.debug("Volume %(volume_name)s extra_specs %(extra_specs)s",
|
|
{'volume_name': volume['name'], 'extra_specs': extra_specs})
|
|
qos = self._get_qos_specs(ctxt, type_id)
|
|
# Wait until snapshot/clone is prepared.
|
|
args['method'] = 'POST'
|
|
args['url'] = vol_info['location']
|
|
args['status'] = 202
|
|
args['arglist'] = {'name': clone['name'],
|
|
'type': clone_type,
|
|
'affinity': extra_specs['affinity'],
|
|
'IOPSmin': qos['minIOPS'],
|
|
'IOPSmax': qos['maxIOPS'],
|
|
'IOPSburst': qos['burstIOPS']}
|
|
retries = self.configuration.ise_completion_retries
|
|
resp = self._wait_for_completion(self._help_call_method,
|
|
args, retries)
|
|
if resp['status'] != 202:
|
|
# clone prepare failed - bummer
|
|
LOG.error(_LE("Prepare clone failed for %s."), clone['name'])
|
|
RaiseXIODriverException()
|
|
# clone prepare request accepted
|
|
# make sure not to continue until clone prepared
|
|
args['name'] = clone['name']
|
|
args['status_string'] = PREPARED_STATUS
|
|
retries = self.configuration.ise_completion_retries
|
|
clone_info = self._wait_for_completion(self._help_wait_for_status,
|
|
args, retries)
|
|
if PREPARED_STATUS in clone_info['details']:
|
|
LOG.debug('Clone %s prepared.', clone['name'])
|
|
else:
|
|
LOG.error(_LE("Clone %s not in prepared state!"), clone['name'])
|
|
RaiseXIODriverException()
|
|
# Clone prepared, now commit the create
|
|
resp = self._send_cmd('PUT', clone_info['location'],
|
|
{clone_type: 'true'})
|
|
if resp['status'] != 201:
|
|
LOG.error(_LE("Commit clone failed: %(name)s (%(status)d)!"),
|
|
{'name': clone['name'], 'status': resp['status']})
|
|
RaiseXIODriverException()
|
|
# Clone create request accepted. Make sure not to return until clone
|
|
# operational.
|
|
args['name'] = clone['name']
|
|
args['status_string'] = OPERATIONAL_STATUS
|
|
retries = self.configuration.ise_completion_retries
|
|
clone_info = self._wait_for_completion(self._help_wait_for_status,
|
|
args, retries)
|
|
if OPERATIONAL_STATUS in clone_info['string']:
|
|
LOG.info(_LI("Clone %s created."), clone['name'])
|
|
else:
|
|
LOG.error(_LE("Commit failed for %s!"), clone['name'])
|
|
RaiseXIODriverException()
|
|
return
|
|
|
|
def _fill_in_available_capacity(self, node, pool):
|
|
"""Fill in free capacity info for pool."""
|
|
available = node.find('available')
|
|
if available is None:
|
|
pool['free_capacity_gb'] = 0
|
|
return pool
|
|
pool['free_capacity_gb'] = int(available.get('total'))
|
|
# Fill in separate RAID level cap
|
|
byred = available.find('byredundancy')
|
|
if byred is None:
|
|
return pool
|
|
raid = byred.find('raid-0')
|
|
if raid is not None:
|
|
pool['free_capacity_gb_raid_0'] = int(raid.text)
|
|
raid = byred.find('raid-1')
|
|
if raid is not None:
|
|
pool['free_capacity_gb_raid_1'] = int(raid.text)
|
|
raid = byred.find('raid-5')
|
|
if raid is not None:
|
|
pool['free_capacity_gb_raid_5'] = int(raid.text)
|
|
raid = byred.find('raid-6')
|
|
if raid is not None:
|
|
pool['free_capacity_gb_raid_6'] = int(raid.text)
|
|
return pool
|
|
|
|
def _fill_in_used_capacity(self, node, pool):
|
|
"""Fill in used capacity info for pool."""
|
|
used = node.find('used')
|
|
if used is None:
|
|
pool['allocated_capacity_gb'] = 0
|
|
return pool
|
|
pool['allocated_capacity_gb'] = int(used.get('total'))
|
|
# Fill in separate RAID level cap
|
|
byred = used.find('byredundancy')
|
|
if byred is None:
|
|
return pool
|
|
raid = byred.find('raid-0')
|
|
if raid is not None:
|
|
pool['allocated_capacity_gb_raid_0'] = int(raid.text)
|
|
raid = byred.find('raid-1')
|
|
if raid is not None:
|
|
pool['allocated_capacity_gb_raid_1'] = int(raid.text)
|
|
raid = byred.find('raid-5')
|
|
if raid is not None:
|
|
pool['allocated_capacity_gb_raid_5'] = int(raid.text)
|
|
raid = byred.find('raid-6')
|
|
if raid is not None:
|
|
pool['allocated_capacity_gb_raid_6'] = int(raid.text)
|
|
return pool
|
|
|
|
def _get_pools(self):
|
|
"""Return information about all pools on ISE"""
|
|
pools = []
|
|
pool = {}
|
|
vol_cnt = 0
|
|
url = '/storage/pools'
|
|
resp = self._send_cmd('GET', url)
|
|
status = resp['status']
|
|
if status != 200:
|
|
# Request failed. Return what we have, which isn't much.
|
|
LOG.warning(_LW("Could not get pool information (%s)!"), status)
|
|
return (pools, vol_cnt)
|
|
# Parse out available (free) and used. Add them up to get total.
|
|
xml_tree = etree.fromstring(resp['content'])
|
|
for child in xml_tree:
|
|
if child.tag != 'pool':
|
|
continue
|
|
# Fill in ise pool name
|
|
tag = child.find('name')
|
|
if tag is not None:
|
|
pool['pool_ise_name'] = tag.text
|
|
# Fill in globalid
|
|
tag = child.find('globalid')
|
|
if tag is not None:
|
|
pool['globalid'] = tag.text
|
|
# Fill in pool name
|
|
tag = child.find('id')
|
|
if tag is not None:
|
|
pool['pool_name'] = tag.text
|
|
# Fill in pool status
|
|
tag = child.find('status')
|
|
if tag is not None:
|
|
pool['status'] = tag.attrib['string']
|
|
details = tag.find('details')
|
|
if details is not None:
|
|
detail = details.find('detail')
|
|
if detail is not None:
|
|
pool['status_details'] = detail.text
|
|
# Fill in available capacity
|
|
pool = self._fill_in_available_capacity(child, pool)
|
|
# Fill in allocated capacity
|
|
pool = self._fill_in_used_capacity(child, pool)
|
|
# Fill in media health and type
|
|
media = child.find('media')
|
|
if media is not None:
|
|
medium = media.find('medium')
|
|
if medium is not None:
|
|
health = medium.find('health')
|
|
if health is not None:
|
|
pool['health'] = int(health.text)
|
|
tier = medium.find('tier')
|
|
if tier is not None:
|
|
pool['media'] = tier.attrib['string']
|
|
cap = child.find('IOPSmincap')
|
|
if cap is not None:
|
|
pool['minIOPS_capacity'] = cap.text
|
|
cap = child.find('IOPSmaxcap')
|
|
if cap is not None:
|
|
pool['maxIOPS_capacity'] = cap.text
|
|
cap = child.find('IOPSburstcap')
|
|
if cap is not None:
|
|
pool['burstIOPS_capacity'] = cap.text
|
|
pool['total_capacity_gb'] = (int(pool['free_capacity_gb'] +
|
|
pool['allocated_capacity_gb']))
|
|
pool['QoS_support'] = self.configuration.ise_qos
|
|
pool['reserved_percentage'] = 0
|
|
pools.append(pool)
|
|
# count volumes
|
|
volumes = child.find('volumes')
|
|
if volumes is not None:
|
|
vol_cnt += len(volumes)
|
|
return (pools, vol_cnt)
|
|
|
|
def _update_volume_stats(self):
|
|
"""Update storage information"""
|
|
self._send_query()
|
|
data = {}
|
|
data["vendor_name"] = 'X-IO'
|
|
data["driver_version"] = self._get_version()
|
|
if self.configuration.volume_backend_name:
|
|
backend_name = self.configuration.volume_backend_name
|
|
else:
|
|
backend_name = self.__class__.__name__
|
|
data["volume_backend_name"] = backend_name
|
|
data['reserved_percentage'] = 0
|
|
# Get total and free capacity.
|
|
(pools, vol_cnt) = self._get_pools()
|
|
total_cap = 0
|
|
free_cap = 0
|
|
# fill in global capability support
|
|
# capacity
|
|
for pool in pools:
|
|
total_cap += int(pool['total_capacity_gb'])
|
|
free_cap += int(pool['free_capacity_gb'])
|
|
data['total_capacity_gb'] = int(total_cap)
|
|
data['free_capacity_gb'] = int(free_cap)
|
|
# QoS
|
|
data['QoS_support'] = self.configuration.ise_qos
|
|
# Volume affinity
|
|
data['affinity'] = self.configuration.ise_affinity
|
|
# Thin provisioning
|
|
data['thin'] = self.configuration.san_thin_provision
|
|
data['pools'] = pools
|
|
data['active_volumes'] = int(vol_cnt)
|
|
return data
|
|
|
|
def get_volume_stats(self, refresh=False):
|
|
"""Get volume stats."""
|
|
if refresh:
|
|
self._vol_stats = self._update_volume_stats()
|
|
LOG.debug("ISE get_volume_stats (total, free): %(total)s, %(free)s",
|
|
{'total': self._vol_stats['total_capacity_gb'],
|
|
'free': self._vol_stats['free_capacity_gb']})
|
|
return self._vol_stats
|
|
|
|
def _get_extra_specs(self, ctxt, type_id):
|
|
"""Get extra specs from volume type."""
|
|
specs = {}
|
|
specs['affinity'] = ''
|
|
specs['alloctype'] = ''
|
|
specs['pool'] = self.configuration.ise_storage_pool
|
|
specs['raid'] = self.configuration.ise_raid
|
|
if type_id is not None:
|
|
volume_type = volume_types.get_volume_type(ctxt, type_id)
|
|
extra_specs = volume_type.get('extra_specs')
|
|
# Parse out RAID, pool and affinity values
|
|
for key, value in extra_specs.items():
|
|
subkey = ''
|
|
if ':' in key:
|
|
fields = key.split(':')
|
|
key = fields[0]
|
|
subkey = fields[1]
|
|
if key.upper() == 'Feature'.upper():
|
|
if subkey.upper() == 'Raid'.upper():
|
|
specs['raid'] = value
|
|
elif subkey.upper() == 'Pool'.upper():
|
|
specs['pool'] = value
|
|
elif key.upper() == 'Affinity'.upper():
|
|
# Only fill this in if ISE FW supports volume affinity
|
|
if self.configuration.ise_affinity:
|
|
if subkey.upper() == 'Type'.upper():
|
|
specs['affinity'] = value
|
|
elif key.upper() == 'Alloc'.upper():
|
|
# Only fill this in if ISE FW supports thin provisioning
|
|
if self.configuration.san_thin_provision:
|
|
if subkey.upper() == 'Type'.upper():
|
|
specs['alloctype'] = value
|
|
return specs
|
|
|
|
def _get_qos_specs(self, ctxt, type_id):
|
|
"""Get QoS specs from volume type."""
|
|
specs = {}
|
|
specs['minIOPS'] = ''
|
|
specs['maxIOPS'] = ''
|
|
specs['burstIOPS'] = ''
|
|
if type_id is not None:
|
|
volume_type = volume_types.get_volume_type(ctxt, type_id)
|
|
qos_specs_id = volume_type.get('qos_specs_id')
|
|
if qos_specs_id is not None:
|
|
kvs = qos_specs.get_qos_specs(ctxt, qos_specs_id)['specs']
|
|
else:
|
|
kvs = volume_type.get('extra_specs')
|
|
# Parse out min, max and burst values
|
|
for key, value in kvs.items():
|
|
if ':' in key:
|
|
fields = key.split(':')
|
|
key = fields[1]
|
|
if key.upper() == 'minIOPS'.upper():
|
|
specs['minIOPS'] = value
|
|
elif key.upper() == 'maxIOPS'.upper():
|
|
specs['maxIOPS'] = value
|
|
elif key.upper() == 'burstIOPS'.upper():
|
|
specs['burstIOPS'] = value
|
|
return specs
|
|
|
|
def create_volume(self, volume):
|
|
"""Create requested volume"""
|
|
LOG.debug("X-IO create_volume called.")
|
|
# get extra_specs and qos based on volume type
|
|
# these functions fill in default values for entries used below
|
|
ctxt = context.get_admin_context()
|
|
type_id = volume['volume_type_id']
|
|
extra_specs = self._get_extra_specs(ctxt, type_id)
|
|
LOG.debug("Volume %(volume_name)s extra_specs %(extra_specs)s",
|
|
{'volume_name': volume['name'], 'extra_specs': extra_specs})
|
|
qos = self._get_qos_specs(ctxt, type_id)
|
|
# Make create call
|
|
url = '/storage/arrays/%s/volumes' % (self._get_ise_globalid())
|
|
resp = self._send_cmd('POST', url,
|
|
{'name': volume['name'],
|
|
'size': volume['size'],
|
|
'pool': extra_specs['pool'],
|
|
'redundancy': extra_specs['raid'],
|
|
'affinity': extra_specs['affinity'],
|
|
'alloctype': extra_specs['alloctype'],
|
|
'IOPSmin': qos['minIOPS'],
|
|
'IOPSmax': qos['maxIOPS'],
|
|
'IOPSburst': qos['burstIOPS']})
|
|
if resp['status'] != 201:
|
|
LOG.error(_LE("Failed to create volume: %(name)s (%(status)s)"),
|
|
{'name': volume['name'], 'status': resp['status']})
|
|
RaiseXIODriverException()
|
|
# Good response. Make sure volume is in operational state before
|
|
# returning. Volume creation completes asynchronously.
|
|
args = {}
|
|
args['name'] = volume['name']
|
|
args['status_string'] = OPERATIONAL_STATUS
|
|
retries = self.configuration.ise_completion_retries
|
|
vol_info = self._wait_for_completion(self._help_wait_for_status,
|
|
args, retries)
|
|
if OPERATIONAL_STATUS in vol_info['string']:
|
|
# Ready.
|
|
LOG.info(_LI("Volume %s created"), volume['name'])
|
|
else:
|
|
LOG.error(_LE("Failed to create volume %s."), volume['name'])
|
|
RaiseXIODriverException()
|
|
return
|
|
|
|
def create_cloned_volume(self, volume, src_vref):
|
|
"""Create clone"""
|
|
LOG.debug("X-IO create_cloned_volume called.")
|
|
self._create_clone(src_vref, volume, 'clone')
|
|
|
|
def create_snapshot(self, snapshot):
|
|
"""Create snapshot"""
|
|
LOG.debug("X-IO create_snapshot called.")
|
|
# Creating a snapshot uses same interface as clone operation on
|
|
# ISE. Clone type ('snapshot' or 'clone') tells the ISE what kind
|
|
# of operation is requested.
|
|
self._create_clone(snapshot, snapshot, 'snapshot')
|
|
|
|
def create_volume_from_snapshot(self, volume, snapshot):
|
|
"""Create volume from snapshot"""
|
|
LOG.debug("X-IO create_volume_from_snapshot called.")
|
|
# ISE snapshots are just like a volume so this is a clone operation.
|
|
self._create_clone(snapshot, volume, 'clone')
|
|
|
|
def _delete_volume(self, volume):
|
|
"""Delete specified volume"""
|
|
# First unpresent volume from all hosts.
|
|
self._alloc_location(volume, '', 1)
|
|
# Get volume status. Location string for volume comes back
|
|
# in response. Used for DELETE call below.
|
|
vol_info = self._get_volume_info(volume['name'])
|
|
if vol_info['location'] == '':
|
|
LOG.warning(_LW("%s not found!"), volume['name'])
|
|
return
|
|
# Make DELETE call.
|
|
args = {}
|
|
args['method'] = 'DELETE'
|
|
args['url'] = vol_info['location']
|
|
args['arglist'] = {}
|
|
args['status'] = 204
|
|
retries = self.configuration.ise_completion_retries
|
|
resp = self._wait_for_completion(self._help_call_method, args, retries)
|
|
if resp['status'] != 204:
|
|
LOG.warning(_LW("DELETE call failed for %s!"), volume['name'])
|
|
return
|
|
# DELETE call successful, now wait for completion.
|
|
# We do that by waiting for the REST call to return Volume Not Found.
|
|
args['method'] = ''
|
|
args['url'] = ''
|
|
args['name'] = volume['name']
|
|
args['status_string'] = NOTFOUND_STATUS
|
|
retries = self.configuration.ise_completion_retries
|
|
vol_info = self._wait_for_completion(self._help_wait_for_status,
|
|
args, retries)
|
|
if NOTFOUND_STATUS in vol_info['string']:
|
|
# Volume no longer present on the backend.
|
|
LOG.info(_LI("Successfully deleted %s."), volume['name'])
|
|
return
|
|
# If we come here it means the volume is still present
|
|
# on the backend.
|
|
LOG.error(_LE("Timed out deleting %s!"), volume['name'])
|
|
return
|
|
|
|
def delete_volume(self, volume):
|
|
"""Delete specified volume"""
|
|
LOG.debug("X-IO delete_volume called.")
|
|
self._delete_volume(volume)
|
|
|
|
def delete_snapshot(self, snapshot):
|
|
"""Delete snapshot"""
|
|
LOG.debug("X-IO delete_snapshot called.")
|
|
# Delete snapshot and delete volume is identical to ISE.
|
|
self._delete_volume(snapshot)
|
|
|
|
def _modify_volume(self, volume, new_attributes):
|
|
# Get volume status. Location string for volume comes back
|
|
# in response. Used for PUT call below.
|
|
vol_info = self._get_volume_info(volume['name'])
|
|
if vol_info['location'] == '':
|
|
LOG.error(_LE("modify volume: %s does not exist!"), volume['name'])
|
|
RaiseXIODriverException()
|
|
# Make modify volume REST call using PUT.
|
|
# Location from above is used as identifier.
|
|
resp = self._send_cmd('PUT', vol_info['location'], new_attributes)
|
|
status = resp['status']
|
|
if status == 201:
|
|
LOG.debug("Volume %s modified.", volume['name'])
|
|
return True
|
|
LOG.error(_LE("Modify volume PUT failed: %(name)s (%(status)d)."),
|
|
{'name': volume['name'], 'status': status})
|
|
RaiseXIODriverException()
|
|
|
|
def extend_volume(self, volume, new_size):
|
|
"""Extend volume to new size."""
|
|
LOG.debug("extend_volume called")
|
|
ret = self._modify_volume(volume, {'size': new_size})
|
|
if ret is True:
|
|
LOG.info(_LI("volume %(name)s extended to %(size)d."),
|
|
{'name': volume['name'], 'size': new_size})
|
|
return
|
|
|
|
def retype(self, ctxt, volume, new_type, diff, host):
|
|
"""Convert the volume to be of the new type."""
|
|
LOG.debug("X-IO retype called")
|
|
qos = self._get_qos_specs(ctxt, new_type['id'])
|
|
ret = self._modify_volume(volume, {'IOPSmin': qos['minIOPS'],
|
|
'IOPSmax': qos['maxIOPS'],
|
|
'IOPSburst': qos['burstIOPS']})
|
|
if ret is True:
|
|
LOG.info(_LI("Volume %s retyped."), volume['name'])
|
|
return True
|
|
|
|
def manage_existing(self, volume, ise_volume_ref):
|
|
"""Convert an existing ISE volume to a Cinder volume."""
|
|
LOG.debug("X-IO manage_existing called")
|
|
if 'source-name' not in ise_volume_ref:
|
|
LOG.error(_LE("manage_existing: No source-name in ref!"))
|
|
RaiseXIODriverException()
|
|
# copy the source-name to 'name' for modify volume use
|
|
ise_volume_ref['name'] = ise_volume_ref['source-name']
|
|
ctxt = context.get_admin_context()
|
|
qos = self._get_qos_specs(ctxt, volume['volume_type_id'])
|
|
ret = self._modify_volume(ise_volume_ref,
|
|
{'name': volume['name'],
|
|
'IOPSmin': qos['minIOPS'],
|
|
'IOPSmax': qos['maxIOPS'],
|
|
'IOPSburst': qos['burstIOPS']})
|
|
if ret is True:
|
|
LOG.info(_LI("Volume %s converted."), ise_volume_ref['name'])
|
|
return ret
|
|
|
|
def manage_existing_get_size(self, volume, ise_volume_ref):
|
|
"""Get size of an existing ISE volume."""
|
|
LOG.debug("X-IO manage_existing_get_size called")
|
|
if 'source-name' not in ise_volume_ref:
|
|
LOG.error(_LE("manage_existing_get_size: No source-name in ref!"))
|
|
RaiseXIODriverException()
|
|
ref_name = ise_volume_ref['source-name']
|
|
# get volume status including size
|
|
vol_info = self._get_volume_info(ref_name)
|
|
if vol_info['location'] == '':
|
|
LOG.error(_LE("manage_existing_get_size: %s does not exist!"),
|
|
ref_name)
|
|
RaiseXIODriverException()
|
|
return int(vol_info['size'])
|
|
|
|
def unmanage(self, volume):
|
|
"""Remove Cinder management from ISE volume"""
|
|
LOG.debug("X-IO unmanage called")
|
|
vol_info = self._get_volume_info(volume['name'])
|
|
if vol_info['location'] == '':
|
|
LOG.error(_LE("unmanage: Volume %s does not exist!"),
|
|
volume['name'])
|
|
RaiseXIODriverException()
|
|
# This is a noop. ISE does not store any Cinder specific information.
|
|
|
|
def ise_present(self, volume, hostname_in, endpoints):
|
|
"""Set up presentation for volume and specified connector"""
|
|
LOG.debug("X-IO ise_present called.")
|
|
# Create host entry on ISE if necessary.
|
|
# Check to see if host entry already exists.
|
|
# Create if not found
|
|
host = self._find_host(endpoints)
|
|
if host['name'] == '':
|
|
# host not found, so create new host entry
|
|
# Use host name if filled in. If blank, ISE will make up a name.
|
|
self._create_host(hostname_in, endpoints)
|
|
host = self._find_host(endpoints)
|
|
if host['name'] == '':
|
|
# host still not found, this is fatal.
|
|
LOG.error(_LE("Host could not be found!"))
|
|
RaiseXIODriverException()
|
|
elif host['type'].upper() != 'OPENSTACK':
|
|
# Make sure host type is marked as OpenStack host
|
|
params = {'os': 'openstack'}
|
|
resp = self._send_cmd('PUT', host['locator'], params)
|
|
status = resp['status']
|
|
if status != 201 and status != 409:
|
|
LOG.error(_LE("Host PUT failed (%s)."), status)
|
|
RaiseXIODriverException()
|
|
# We have a host object.
|
|
target_lun = ''
|
|
# Present volume to host.
|
|
target_lun = self._present_volume(volume, host['name'], target_lun)
|
|
# Fill in target information.
|
|
data = {}
|
|
data['target_lun'] = int(target_lun)
|
|
data['volume_id'] = volume['id']
|
|
return data
|
|
|
|
def ise_unpresent(self, volume, endpoints):
|
|
"""Delete presentation between volume and connector"""
|
|
LOG.debug("X-IO ise_unpresent called.")
|
|
# Delete allocation uses host name. Go find it based on endpoints.
|
|
host = self._find_host(endpoints)
|
|
if host['name'] != '':
|
|
# Delete allocation based on hostname and volume.
|
|
self._alloc_location(volume, host['name'], 1)
|
|
return host['name']
|
|
|
|
def create_export(self, context, volume):
|
|
LOG.debug("X-IO create_export called.")
|
|
|
|
def ensure_export(self, context, volume):
|
|
LOG.debug("X-IO ensure_export called.")
|
|
|
|
def remove_export(self, context, volume):
|
|
LOG.debug("X-IO remove_export called.")
|
|
|
|
def local_path(self, volume):
|
|
LOG.debug("X-IO local_path called.")
|
|
|
|
def delete_host(self, endpoints):
|
|
"""Delete ISE host object"""
|
|
host = self._find_host(endpoints)
|
|
if host['locator'] != '':
|
|
# Delete host
|
|
self._send_cmd('DELETE', host['locator'])
|
|
LOG.debug("X-IO: host %s deleted", host['name'])
|
|
|
|
|
|
# Protocol specific classes for entry. They are wrappers around base class
|
|
# above and every external API resuslts in a call to common function in base
|
|
# class.
|
|
@interface.volumedriver
|
|
class XIOISEISCSIDriver(driver.ISCSIDriver):
|
|
|
|
"""Requires ISE Running FW version 3.1.0 or higher"""
|
|
|
|
# ThirdPartySystems wiki page
|
|
CI_WIKI_NAME = 'X-IO_technologies_CI'
|
|
VERSION = XIOISEDriver.VERSION
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(XIOISEISCSIDriver, self).__init__(*args, **kwargs)
|
|
self.configuration.append_config_values(XIO_OPTS)
|
|
self.configuration.append_config_values(san.san_opts)
|
|
|
|
# The iscsi_ip_address must always be set.
|
|
if self.configuration.iscsi_ip_address == '':
|
|
LOG.error(_LE("iscsi_ip_address must be set!"))
|
|
RaiseXIODriverException()
|
|
# Setup common driver
|
|
self.driver = XIOISEDriver(configuration=self.configuration)
|
|
|
|
def do_setup(self, context):
|
|
return self.driver.do_setup(context)
|
|
|
|
def check_for_setup_error(self):
|
|
return self.driver.check_for_setup_error()
|
|
|
|
def local_path(self, volume):
|
|
return self.driver.local_path(volume)
|
|
|
|
def get_volume_stats(self, refresh=False):
|
|
data = self.driver.get_volume_stats(refresh)
|
|
data["storage_protocol"] = 'iSCSI'
|
|
return data
|
|
|
|
def create_volume(self, volume):
|
|
self.driver.create_volume(volume)
|
|
# Volume created successfully. Fill in CHAP information.
|
|
model_update = {}
|
|
chap = self.driver.find_target_chap()
|
|
if chap['chap_user'] != '':
|
|
model_update['provider_auth'] = 'CHAP %s %s' % \
|
|
(chap['chap_user'], chap['chap_passwd'])
|
|
else:
|
|
model_update['provider_auth'] = ''
|
|
return model_update
|
|
|
|
def create_cloned_volume(self, volume, src_vref):
|
|
return self.driver.create_cloned_volume(volume, src_vref)
|
|
|
|
def create_volume_from_snapshot(self, volume, snapshot):
|
|
return self.driver.create_volume_from_snapshot(volume, snapshot)
|
|
|
|
def delete_volume(self, volume):
|
|
return self.driver.delete_volume(volume)
|
|
|
|
def extend_volume(self, volume, new_size):
|
|
return self.driver.extend_volume(volume, new_size)
|
|
|
|
def retype(self, ctxt, volume, new_type, diff, host):
|
|
return self.driver.retype(ctxt, volume, new_type, diff, host)
|
|
|
|
def manage_existing(self, volume, ise_volume_ref):
|
|
ret = self.driver.manage_existing(volume, ise_volume_ref)
|
|
if ret is True:
|
|
# Volume converted successfully. Fill in CHAP information.
|
|
model_update = {}
|
|
chap = {}
|
|
chap = self.driver.find_target_chap()
|
|
if chap['chap_user'] != '':
|
|
model_update['provider_auth'] = 'CHAP %s %s' % \
|
|
(chap['chap_user'], chap['chap_passwd'])
|
|
else:
|
|
model_update['provider_auth'] = ''
|
|
return model_update
|
|
|
|
def manage_existing_get_size(self, volume, ise_volume_ref):
|
|
return self.driver.manage_existing_get_size(volume, ise_volume_ref)
|
|
|
|
def unmanage(self, volume):
|
|
return self.driver.unmanage(volume)
|
|
|
|
def initialize_connection(self, volume, connector):
|
|
hostname = ''
|
|
if 'host' in connector:
|
|
hostname = connector['host']
|
|
data = self.driver.ise_present(volume, hostname,
|
|
connector['initiator'])
|
|
# find IP for target
|
|
data['target_portal'] = \
|
|
'%s:3260' % (self.configuration.iscsi_ip_address)
|
|
# set IQN for target
|
|
data['target_discovered'] = False
|
|
data['target_iqn'] = \
|
|
self.driver.find_target_iqn(self.configuration.iscsi_ip_address)
|
|
# Fill in authentication method (CHAP)
|
|
if 'provider_auth' in volume:
|
|
auth = volume['provider_auth']
|
|
if auth:
|
|
(auth_method, auth_username, auth_secret) = auth.split()
|
|
data['auth_method'] = auth_method
|
|
data['auth_username'] = auth_username
|
|
data['auth_password'] = auth_secret
|
|
return {'driver_volume_type': 'iscsi',
|
|
'data': data}
|
|
|
|
def terminate_connection(self, volume, connector, **kwargs):
|
|
hostname = self.driver.ise_unpresent(volume, connector['initiator'])
|
|
alloc_cnt = 0
|
|
if hostname != '':
|
|
alloc_cnt = self.driver.find_allocations(hostname)
|
|
if alloc_cnt == 0:
|
|
# delete host object
|
|
self.driver.delete_host(connector['initiator'])
|
|
|
|
def create_snapshot(self, snapshot):
|
|
return self.driver.create_snapshot(snapshot)
|
|
|
|
def delete_snapshot(self, snapshot):
|
|
return self.driver.delete_snapshot(snapshot)
|
|
|
|
def create_export(self, context, volume, connector):
|
|
return self.driver.create_export(context, volume)
|
|
|
|
def ensure_export(self, context, volume):
|
|
return self.driver.ensure_export(context, volume)
|
|
|
|
def remove_export(self, context, volume):
|
|
return self.driver.remove_export(context, volume)
|
|
|
|
|
|
@interface.volumedriver
|
|
class XIOISEFCDriver(driver.FibreChannelDriver):
|
|
|
|
"""Requires ISE Running FW version 2.8.0 or higher"""
|
|
|
|
# ThirdPartySystems wiki page
|
|
CI_WIKI_NAME = 'X-IO_technologies_CI'
|
|
VERSION = XIOISEDriver.VERSION
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(XIOISEFCDriver, self).__init__(*args, **kwargs)
|
|
self.configuration.append_config_values(XIO_OPTS)
|
|
self.configuration.append_config_values(san.san_opts)
|
|
self.driver = XIOISEDriver(configuration=self.configuration)
|
|
|
|
def do_setup(self, context):
|
|
return self.driver.do_setup(context)
|
|
|
|
def check_for_setup_error(self):
|
|
return self.driver.check_for_setup_error()
|
|
|
|
def local_path(self, volume):
|
|
return self.driver.local_path(volume)
|
|
|
|
def get_volume_stats(self, refresh=False):
|
|
data = self.driver.get_volume_stats(refresh)
|
|
data["storage_protocol"] = 'fibre_channel'
|
|
return data
|
|
|
|
def create_volume(self, volume):
|
|
return self.driver.create_volume(volume)
|
|
|
|
def create_cloned_volume(self, volume, src_vref):
|
|
return self.driver.create_cloned_volume(volume, src_vref)
|
|
|
|
def create_volume_from_snapshot(self, volume, snapshot):
|
|
return self.driver.create_volume_from_snapshot(volume, snapshot)
|
|
|
|
def delete_volume(self, volume):
|
|
return self.driver.delete_volume(volume)
|
|
|
|
def extend_volume(self, volume, new_size):
|
|
return self.driver.extend_volume(volume, new_size)
|
|
|
|
def retype(self, ctxt, volume, new_type, diff, host):
|
|
return self.driver.retype(ctxt, volume, new_type, diff, host)
|
|
|
|
def manage_existing(self, volume, ise_volume_ref):
|
|
return self.driver.manage_existing(volume, ise_volume_ref)
|
|
|
|
def manage_existing_get_size(self, volume, ise_volume_ref):
|
|
return self.driver.manage_existing_get_size(volume, ise_volume_ref)
|
|
|
|
def unmanage(self, volume):
|
|
return self.driver.unmanage(volume)
|
|
|
|
@fczm_utils.AddFCZone
|
|
def initialize_connection(self, volume, connector):
|
|
hostname = ''
|
|
if 'host' in connector:
|
|
hostname = connector['host']
|
|
data = self.driver.ise_present(volume, hostname, connector['wwpns'])
|
|
data['target_discovered'] = True
|
|
# set wwns for target
|
|
target_wwns = self.driver.find_target_wwns()
|
|
data['target_wwn'] = target_wwns
|
|
# build target initiator map
|
|
target_map = {}
|
|
for initiator in connector['wwpns']:
|
|
target_map[initiator] = target_wwns
|
|
data['initiator_target_map'] = target_map
|
|
return {'driver_volume_type': 'fibre_channel',
|
|
'data': data}
|
|
|
|
@fczm_utils.RemoveFCZone
|
|
def terminate_connection(self, volume, connector, **kwargs):
|
|
# now we are ready to tell ISE to delete presentations
|
|
hostname = self.driver.ise_unpresent(volume, connector['wwpns'])
|
|
# set target_wwn and initiator_target_map only if host
|
|
# has no more presentations
|
|
data = {}
|
|
alloc_cnt = 0
|
|
if hostname != '':
|
|
alloc_cnt = self.driver.find_allocations(hostname)
|
|
if alloc_cnt == 0:
|
|
target_wwns = self.driver.find_target_wwns()
|
|
data['target_wwn'] = target_wwns
|
|
# build target initiator map
|
|
target_map = {}
|
|
for initiator in connector['wwpns']:
|
|
target_map[initiator] = target_wwns
|
|
data['initiator_target_map'] = target_map
|
|
# delete host object
|
|
self.driver.delete_host(connector['wwpns'])
|
|
|
|
return {'driver_volume_type': 'fibre_channel',
|
|
'data': data}
|
|
|
|
def create_snapshot(self, snapshot):
|
|
return self.driver.create_snapshot(snapshot)
|
|
|
|
def delete_snapshot(self, snapshot):
|
|
return self.driver.delete_snapshot(snapshot)
|
|
|
|
def create_export(self, context, volume, connector):
|
|
return self.driver.create_export(context, volume)
|
|
|
|
def ensure_export(self, context, volume):
|
|
return self.driver.ensure_export(context, volume)
|
|
|
|
def remove_export(self, context, volume):
|
|
return self.driver.remove_export(context, volume)
|