Provide HP 3PAR array iSCSI driver

implements blueprint hp3par-volume-driver

We have the driver broken into 2 files:
hp_3par_common.py and
hp_3par_iscsi.py

The reason we do this is because we have a fibre channel driver
that will be submitted shortly after this is committed.   The
fibre channel driver and the iscsi driver share a lot of the same
code that talks to the 3PAR array for provisioning.  So,
it made sense not to have duplicate code.  The fibre channel driver
will be dependent on the fibre channel support I am actively working
on for nova/cinder grizzly release.

The driver uses a 2 mechanisms to talk to the 3PAR array:
1) a python REST client (hp3parclient) that lives in the pypi
   repository here:
   http://pypi.python.org/pypi/hp3parclient

2) SSH.  We had to pull in some of the ssh code from the base san
   driver to help fix an issue with executing commands on the 3PAR
   array.  The 3PAR has the ability to turn on CSV output for command
   results, which makes this easier to parse.  Unfortunately, there
   is no way to turn CSV mode on permanently for all ssh requests.
   So, we have to turn on the CSV output for every single ssh command
   issued.  Since we use ssh as well, we require the san_* options
   to be set.

We use a dual mechianism because the REST API that ships with the 3.1.2
firmware doesn't support all of the capabilities a cinder driver needs
to export volumes.

When a newer version of the firmware comes out that supports host
management on the 3PAR array, then we will get rid of the SSH code.

Change-Id: I9826ba1a36e27a9be05457ee9236a491dbfd0713
This commit is contained in:
Walter A. Boring IV 2012-12-18 14:16:33 -08:00
parent 93bb58732d
commit 027b78a214
6 changed files with 1190 additions and 0 deletions

@ -0,0 +1,443 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# Copyright (c) 2012 Hewlett-Packard, Inc.
# 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.
"""
Unit tests for OpenStack Cinder volume driver
"""
import shutil
import tempfile
from hp3parclient import exceptions as hpexceptions
import cinder.flags
from cinder.openstack.common import log as logging
from cinder import test
from cinder.volume.drivers.san.hp import hp_3par_iscsi as hpdriver
FLAGS = cinder.flags.FLAGS
LOG = logging.getLogger(__name__)
HP3PAR_DOMAIN = 'OpenStack',
HP3PAR_CPG = 'OpenStackCPG',
HP3PAR_CPG_SNAP = 'OpenStackCPGSnap'
class FakeHP3ParClient(object):
api_url = None
debug = False
volumes = []
hosts = []
vluns = []
cpgs = [
{'SAGrowth': {'LDLayout': {'diskPatterns': [{'diskType': 2}]},
'incrementMiB': 8192},
'SAUsage': {'rawTotalMiB': 24576,
'rawUsedMiB': 768,
'totalMiB': 8192,
'usedMiB': 256},
'SDGrowth': {'LDLayout': {'RAIDType': 4,
'diskPatterns': [{'diskType': 2}]},
'incrementMiB': 32768},
'SDUsage': {'rawTotalMiB': 49152,
'rawUsedMiB': 1023,
'totalMiB': 36864,
'usedMiB': 768},
'UsrUsage': {'rawTotalMiB': 57344,
'rawUsedMiB': 43349,
'totalMiB': 43008,
'usedMiB': 32512},
'additionalStates': [],
'degradedStates': [],
'domain': HP3PAR_DOMAIN,
'failedStates': [],
'id': 5,
'name': HP3PAR_CPG,
'numFPVVs': 2,
'numTPVVs': 0,
'state': 1,
'uuid': '29c214aa-62b9-41c8-b198-543f6cf24edf'}]
def __init__(self, api_url):
self.api_url = api_url
def debug_rest(self, flag):
self.debug = flag
def login(self, username, password, optional=None):
return None
def logout(self):
return None
def getVolumes(self):
return self.volumes
def getVolume(self, name):
if self.volumes:
for volume in self.volumes:
if volume['name'] == name:
return volume
msg = {'code': 'NON_EXISTENT_HOST',
'desc': "VOLUME '%s' was not found" % name}
raise hpexceptions.HTTPNotFound(msg)
def createVolume(self, name, cpgName, sizeMiB, optional=None):
new_vol = {'additionalStates': [],
'adminSpace': {'freeMiB': 0,
'rawReservedMiB': 384,
'reservedMiB': 128,
'usedMiB': 128},
'baseId': 115,
'comment': optional['comment'],
'copyType': 1,
'creationTime8601': '2012-10-22T16:37:57-07:00',
'creationTimeSec': 1350949077,
'degradedStates': [],
'domain': HP3PAR_DOMAIN,
'failedStates': [],
'id': 115,
'name': name,
'policies': {'caching': True,
'oneHost': False,
'staleSS': True,
'system': False,
'zeroDetect': False},
'provisioningType': 1,
'readOnly': False,
'sizeMiB': sizeMiB,
'snapCPG': optional['snapCPG'],
'snapshotSpace': {'freeMiB': 0,
'rawReservedMiB': 683,
'reservedMiB': 512,
'usedMiB': 512},
'ssSpcAllocLimitPct': 0,
'ssSpcAllocWarningPct': 0,
'state': 1,
'userCPG': cpgName,
'userSpace': {'freeMiB': 0,
'rawReservedMiB': 41984,
'reservedMiB': 31488,
'usedMiB': 31488},
'usrSpcAllocLimitPct': 0,
'usrSpcAllocWarningPct': 0,
'uuid': '1e7daee4-49f4-4d07-9ab8-2b6a4319e243',
'wwn': '50002AC00073383D'}
self.volumes.append(new_vol)
return None
def deleteVolume(self, name):
volume = self.getVolume(name)
self.volumes.remove(volume)
def createSnapshot(self, name, copyOfName, optional=None):
new_snap = {'additionalStates': [],
'adminSpace': {'freeMiB': 0,
'rawReservedMiB': 0,
'reservedMiB': 0,
'usedMiB': 0},
'baseId': 342,
'comment': optional['comment'],
'copyOf': copyOfName,
'copyType': 3,
'creationTime8601': '2012-11-09T15:13:28-08:00',
'creationTimeSec': 1352502808,
'degradedStates': [],
'domain': HP3PAR_DOMAIN,
'expirationTime8601': '2012-11-09T17:13:28-08:00',
'expirationTimeSec': 1352510008,
'failedStates': [],
'id': 343,
'name': name,
'parentId': 342,
'policies': {'caching': True,
'oneHost': False,
'staleSS': True,
'system': False,
'zeroDetect': False},
'provisioningType': 3,
'readOnly': True,
'retentionTime8601': '2012-11-09T16:13:27-08:00',
'retentionTimeSec': 1352506407,
'sizeMiB': 256,
'snapCPG': HP3PAR_CPG_SNAP,
'snapshotSpace': {'freeMiB': 0,
'rawReservedMiB': 0,
'reservedMiB': 0,
'usedMiB': 0},
'ssSpcAllocLimitPct': 0,
'ssSpcAllocWarningPct': 0,
'state': 1,
'userCPG': HP3PAR_CPG,
'userSpace': {'freeMiB': 0,
'rawReservedMiB': 0,
'reservedMiB': 0,
'usedMiB': 0},
'usrSpcAllocLimitPct': 0,
'usrSpcAllocWarningPct': 0,
'uuid': 'd7a40b8f-2511-46a8-9e75-06383c826d19',
'wwn': '50002AC00157383D'}
self.volumes.append(new_snap)
return None
def deleteSnapshot(self, name):
volume = self.getVolume(name)
self.volumes.remove(volume)
def getCPGs(self):
return self.cpgs
def getCPG(self, name):
if self.cpgs:
for cpg in self.cpgs:
if cpg['name'] == name:
return cpg
msg = {'code': 'NON_EXISTENT_HOST',
'desc': "CPG '%s' was not found" % name}
raise hpexceptions.HTTPNotFound(msg)
def createVLUN(self, volumeName, lun, hostname=None,
portPos=None, noVcn=None,
overrideLowerPriority=None):
vlun = {'active': False,
'failedPathInterval': 0,
'failedPathPol': 1,
'hostname': hostname,
'lun': lun,
'multipathing': 1,
'portPos': portPos,
'type': 4,
'volumeName': volumeName,
'volumeWWN': '50002AC00077383D'}
self.vluns.append(vlun)
return None
def deleteVLUN(self, name, lunID, hostname=None, port=None):
vlun = self.getVLUN(name)
self.vluns.remove(vlun)
def getVLUNs(self):
return self.vluns
def getVLUN(self, volumeName):
for vlun in self.vluns:
if vlun['volumeName'] == volumeName:
return vlun
msg = {'code': 'NON_EXISTENT_HOST',
'desc': "VLUN '%s' was not found" % volumeName}
raise hpexceptions.HTTPNotFound(msg)
class TestHP3PARDriver(test.TestCase):
TARGET_IQN = "iqn.2000-05.com.3pardata:21810002ac00383d"
VOLUME_NAME = "volume-d03338a9-9115-48a3-8dfc-35cdfcdc15a7"
SNAPSHOT_NAME = "snapshot-2f823bdc-e36e-4dc8-bd15-de1c7a28ff31"
VOLUME_3PAR_NAME = "osv-0DM4qZEVSKON-DXN-NwVpw"
SNAPSHOT_VOL_NAME = "oss-L4I73ONuTci9Fd4ceij-MQ"
FAKE_HOST = "fakehost"
_hosts = {}
def setUp(self):
self.tempdir = tempfile.mkdtemp()
super(TestHP3PARDriver, self).setUp()
self.flags(
hp3par_username='testUser',
hp3par_password='testPassword',
hp3par_api_url='https://1.1.1.1/api/v1',
hp3par_domain=HP3PAR_DOMAIN,
hp3par_cpg=HP3PAR_CPG,
hp3par_cpg_snap=HP3PAR_CPG_SNAP,
iscsi_ip_address='1.1.1.2',
iscsi_port='1234',
san_ip='2.2.2.2',
san_login='test',
san_password='test'
)
self.stubs.Set(hpdriver.HP3PARISCSIDriver, "_create_client",
self.fake_create_client)
self.stubs.Set(hpdriver.HP3PARISCSIDriver,
"_iscsi_discover_target_iqn",
self.fake_iscsi_discover_target_iqn)
self.stubs.Set(hpdriver.HP3PARISCSIDriver, "_create_3par_iscsi_host",
self.fake_create_3par_iscsi_host)
self.stubs.Set(hpdriver.HP3PARISCSIDriver,
"_iscsi_discover_target_iqn",
self.fake_iscsi_discover_target_iqn)
self.stubs.Set(hpdriver.HP3PARCommon, "_get_3par_host",
self.fake_get_3par_host)
self.stubs.Set(hpdriver.HP3PARCommon, "_delete_3par_host",
self.fake_delete_3par_host)
self.stubs.Set(hpdriver.HP3PARCommon, "_create_3par_vlun",
self.fake_create_3par_vlun)
self.driver = hpdriver.HP3PARISCSIDriver()
self.driver.do_setup(None)
self.volume = {'name': self.VOLUME_NAME,
'display_name': 'Foo Volume',
'size': 1,
'host': self.FAKE_HOST}
user_id = '2689d9a913974c008b1d859013f23607'
project_id = 'fac88235b9d64685a3530f73e490348f'
volume_id = '761fc5e5-5191-4ec7-aeba-33e36de44156'
fake_desc = 'test description name'
self.snapshot = type('snapshot',
(object,),
{'name': self.SNAPSHOT_NAME,
'user_id': user_id,
'project_id': project_id,
'volume_id': volume_id,
'volume_name': self.VOLUME_NAME,
'status': 'creating',
'progress': '0%',
'volume_size': 2,
'display_name': 'fakesnap',
'display_description': fake_desc})()
self.connector = {'ip': '10.0.0.2',
'initiator': 'iqn.1993-08.org.debian:01:222',
'host': 'fakehost'}
target_iqn = 'iqn.2000-05.com.3pardata:21810002ac00383d'
self.properties = {'data':
{'target_discovered': True,
'target_iqn': target_iqn,
'target_lun': 186,
'target_portal': '1.1.1.2:1234'},
'driver_volume_type': 'iscsi'}
def tearDown(self):
shutil.rmtree(self.tempdir)
super(TestHP3PARDriver, self).tearDown()
def fake_create_client(self):
return FakeHP3ParClient(FLAGS.hp3par_api_url)
def fake_iscsi_discover_target_iqn(self, ip_address):
return self.TARGET_IQN
def fake_create_3par_iscsi_host(self, hostname, iscsi_iqn, domain):
host = {'FCPaths': [],
'descriptors': None,
'domain': domain,
'iSCSIPaths': [{'driverVersion': None,
'firmwareVersion': None,
'hostSpeed': 0,
'ipAddr': '10.10.221.59',
'model': None,
'name': iscsi_iqn,
'portPos': {'cardPort': 1, 'node': 1,
'slot': 8},
'vendor': None}],
'id': 11,
'name': hostname}
self._hosts[hostname] = host
def fake_get_3par_host(self, hostname):
if hostname not in self._hosts:
msg = {'code': 'NON_EXISTENT_HOST',
'desc': "HOST '%s' was not found" % hostname}
raise hpexceptions.HTTPNotFound(msg)
else:
return self._hosts[hostname]
def fake_delete_3par_host(self, hostname):
if hostname not in self._hosts:
msg = {'code': 'NON_EXISTENT_HOST',
'desc': "HOST '%s' was not found" % hostname}
raise hpexceptions.HTTPNotFound(msg)
else:
self._hosts[hostname] = None
def fake_create_3par_vlun(self, volume, hostname):
self.driver.client.createVLUN(volume, 19, hostname)
def test_create_volume(self):
self.flags(lock_path=self.tempdir)
model_update = self.driver.create_volume(self.volume)
expected_location = "%s:%s" % (FLAGS.iscsi_ip_address,
FLAGS.iscsi_port)
self.assertEqual(model_update['provider_location'], expected_location)
def test_delete_volume(self):
self.flags(lock_path=self.tempdir)
self.driver.delete_volume(self.volume)
self.assertRaises(hpexceptions.HTTPNotFound,
self.driver.client.getVolume,
self.VOLUME_NAME)
def test_create_snapshot(self):
self.flags(lock_path=self.tempdir)
self.driver.create_snapshot(self.snapshot)
# check to see if the snapshot was created
snap_vol = self.driver.client.getVolume(self.SNAPSHOT_VOL_NAME)
self.assertEqual(snap_vol['name'], self.SNAPSHOT_VOL_NAME)
def test_delete_snapshot(self):
self.flags(lock_path=self.tempdir)
self.driver.delete_snapshot(self.snapshot)
# the snapshot should be deleted now
self.assertRaises(hpexceptions.HTTPNotFound,
self.driver.client.getVolume,
self.SNAPSHOT_VOL_NAME)
def test_create_volume_from_snapshot(self):
self.flags(lock_path=self.tempdir)
self.driver.create_volume_from_snapshot(self.volume, self.snapshot)
snap_vol = self.driver.client.getVolume(self.SNAPSHOT_VOL_NAME)
self.assertEqual(snap_vol['name'], self.SNAPSHOT_VOL_NAME)
def test_initialize_connection(self):
self.flags(lock_path=self.tempdir)
result = self.driver.initialize_connection(self.volume, self.connector)
self.assertEqual(result['driver_volume_type'], 'iscsi')
self.assertEqual(result['data']['target_iqn'],
self.properties['data']['target_iqn'])
self.assertEqual(result['data']['target_portal'],
self.properties['data']['target_portal'])
self.assertEqual(result['data']['target_discovered'],
self.properties['data']['target_discovered'])
# we should have a host and a vlun now.
host = self.fake_get_3par_host(self.FAKE_HOST)
self.assertEquals(self.FAKE_HOST, host['name'])
self.assertEquals(HP3PAR_DOMAIN, host['domain'])
vlun = self.driver.client.getVLUN(self.VOLUME_3PAR_NAME)
self.assertEquals(self.VOLUME_3PAR_NAME, vlun['volumeName'])
self.assertEquals(self.FAKE_HOST, vlun['hostname'])
def test_terminate_connection(self):
self.flags(lock_path=self.tempdir)
self.driver.terminate_connection(self.volume,
self.connector, True)
# vlun should be gone.
self.assertRaises(hpexceptions.HTTPNotFound,
self.driver.client.getVLUN,
self.VOLUME_3PAR_NAME)

@ -0,0 +1,484 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# Copyright (c) 2012 Hewlett-Packard, Inc.
# All Rights Reserved.
#
# Copyright 2012 OpenStack LLC
#
# 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.
#
"""
Volume driver common utilities for HP 3PAR Storage array
This driver requires 3.1.2 firmware on the 3PAR array.
The driver uses both the REST service and the SSH
command line to correctly operate. Since the
ssh credentials and the REST credentials can be different
we need to have settings for both.
This driver requires the use of the san_ip, san_login,
san_password settings for ssh connections into the 3PAR
array. It also requires the setting of
hp3par_api_url, hp3par_username, hp3par_password
for credentials to talk to the REST service on the 3PAR
array.
"""
import base64
import json
import paramiko
import pprint
from random import randint
import uuid
from eventlet import greenthread
from hp3parclient import exceptions as hpexceptions
from cinder import exception
from cinder import flags
from cinder.openstack.common import cfg
from cinder.openstack.common import lockutils
from cinder.openstack.common import log as logging
from cinder import utils
LOG = logging.getLogger(__name__)
hp3par_opts = [
cfg.StrOpt('hp3par_api_url',
default='',
help="3PAR WSAPI Server Url like "
"https://<3par ip>:8080/api/v1"),
cfg.StrOpt('hp3par_username',
default='',
help="3PAR Super user username"),
cfg.StrOpt('hp3par_password',
default='',
help="3PAR Super user password"),
cfg.StrOpt('hp3par_domain',
default="OpenStack",
help="The 3par domain name to use"),
cfg.StrOpt('hp3par_cpg',
default="OpenStack",
help="The CPG to use for volume creation"),
cfg.StrOpt('hp3par_cpg_snap',
default="",
help="The CPG to use for Snapshots for volumes. "
"If empty hp3par_cpg will be used"),
cfg.StrOpt('hp3par_snapshot_retention',
default="",
help="The time in hours to retain a snapshot. "
"You can't delete it before this expires."),
cfg.StrOpt('hp3par_snapshot_expiration',
default="",
help="The time in hours when a snapshot expires "
" and is deleted. This must be larger than expiration"),
cfg.BoolOpt('hp3par_debug',
default=False,
help="Enable HTTP debugging to 3PAR")
]
FLAGS = flags.FLAGS
FLAGS.register_opts(hp3par_opts)
class HP3PARCommon():
def __init__(self):
self.sshpool = None
def check_flags(self, FLAGS, required_flags):
for flag in required_flags:
if not getattr(FLAGS, flag, None):
raise exception.InvalidInput(reason=_('%s is not set') % flag)
def _get_3par_vol_name(self, name):
"""
Converts the openstack volume name from
volume-ecffc30f-98cb-4cf5-85ee-d7309cc17cd2
to
osv-7P.DD5jLTPWF7tcwnMF80g
We convert the 128 bits of the uuid into a 24character long
base64 encoded string to ensure we don't exceed the maximum
allowed 31 character name limit on 3Par
We strip the padding '=' and replace + with .
and / with -
"""
name = name.replace("volume-", "")
volume_name = self._encode_name(name)
return "osv-%s" % volume_name
def _get_3par_snap_name(self, name):
name = name.replace("snapshot-", "")
snapshot_name = self._encode_name(name)
return "oss-%s" % snapshot_name
def _encode_name(self, name):
uuid_str = name.replace("-", "")
vol_uuid = uuid.UUID('urn:uuid:%s' % uuid_str)
vol_encoded = base64.b64encode(vol_uuid.bytes)
# 3par doesn't allow +, nor /
vol_encoded = vol_encoded.replace('+', '.')
vol_encoded = vol_encoded.replace('/', '-')
#strip off the == as 3par doesn't like those.
vol_encoded = vol_encoded.replace('=', '')
return vol_encoded
def _capacity_from_size(self, vol_size):
# because 3PAR volume sizes are in
# Mebibytes, Gigibytes, not Megabytes.
MB = 1000L
MiB = 1.048576
if int(vol_size) == 0:
capacity = MB # default: 1GB
else:
capacity = vol_size * MB
capacity = int(round(capacity / MiB))
return capacity
def _cli_run(self, verb, cli_args):
"""Runs a CLI command over SSH, without doing any result parsing"""
cli_arg_strings = []
if cli_args:
for k, v in cli_args.items():
if k == '':
cli_arg_strings.append(" %s" % k)
else:
cli_arg_strings.append(" %s=%s" % (k, v))
cmd = verb + ''.join(cli_arg_strings)
LOG.debug("SSH CMD = %s " % cmd)
(stdout, stderr) = self._run_ssh(cmd, False)
# we have to strip out the input and exit lines
tmp = stdout.split("\r\n")
out = tmp[5:len(tmp) - 2]
return out
def _ssh_execute(self, ssh, cmd,
check_exit_code=True):
"""
We have to do this in order to get CSV output
from the CLI command. We first have to issue
a command to tell the CLI that we want the output
to be formatted in CSV, then we issue the real
command
"""
LOG.debug(_('Running cmd (SSH): %s'), cmd)
channel = ssh.invoke_shell()
stdin_stream = channel.makefile('wb')
stdout_stream = channel.makefile('rb')
stderr_stream = channel.makefile('rb')
stdin_stream.write('''setclienv csvtable 1
%s
exit
''' % cmd)
#stdin.write('process_input would go here')
#stdin.flush()
# NOTE(justinsb): This seems suspicious...
# ...other SSH clients have buffering issues with this approach
stdout = stdout_stream.read()
stderr = stderr_stream.read()
stdin_stream.close()
stdout_stream.close()
stderr_stream.close()
exit_status = channel.recv_exit_status()
# exit_status == -1 if no exit code was returned
if exit_status != -1:
LOG.debug(_('Result was %s') % exit_status)
if check_exit_code and exit_status != 0:
raise exception.ProcessExecutionError(exit_code=exit_status,
stdout=stdout,
stderr=stderr,
cmd=cmd)
channel.close()
return (stdout, stderr)
def _run_ssh(self, command, check_exit=True, attempts=1):
if not self.sshpool:
self.sshpool = utils.SSHPool(FLAGS.san_ip,
FLAGS.san_ssh_port,
FLAGS.ssh_conn_timeout,
FLAGS.san_login,
password=FLAGS.san_password,
privatekey=FLAGS.san_private_key,
min_size=FLAGS.ssh_min_pool_conn,
max_size=FLAGS.ssh_max_pool_conn)
try:
total_attempts = attempts
with self.sshpool.item() as ssh:
while attempts > 0:
attempts -= 1
try:
return self._ssh_execute(ssh, command,
check_exit_code=check_exit)
except Exception as e:
LOG.error(e)
greenthread.sleep(randint(20, 500) / 100.0)
raise paramiko.SSHException(_("SSH Command failed after "
"'%(total_attempts)r' attempts"
": '%(command)s'"), locals())
except Exception as e:
LOG.error(_("Error running ssh command: %s") % command)
raise e
def _delete_3par_host(self, hostname):
self._cli_run('removehost %s' % hostname, None)
def _create_3par_vlun(self, volume, hostname):
self._cli_run('createvlun %s auto %s' % (volume, hostname), None)
def _safe_hostname(self, hostname):
"""
We have to use a safe hostname length
for 3PAR host names
"""
try:
index = hostname.index('.')
except ValueError:
# couldn't find it
index = len(hostname)
#we'll just chop this off for now.
if index > 23:
index = 23
return hostname[:index]
def _get_3par_host(self, hostname):
out = self._cli_run('showhost -verbose %s' % (hostname), None)
LOG.debug("OUTPUT = \n%s" % (pprint.pformat(out)))
host = {'id': None, 'name': None,
'domain': None,
'descriptors': {},
'iSCSIPaths': [],
'FCPaths': []}
if out:
err = out[0]
if err == 'no hosts listed':
msg = {'code': 'NON_EXISTENT_HOST',
'desc': "HOST '%s' was not found" % hostname}
raise hpexceptions.HTTPNotFound(msg)
# start parsing the lines after the header line
for line in out[1:]:
if line == '':
break
tmp = line.split(',')
paths = {}
LOG.debug("line = %s" % (pprint.pformat(tmp)))
host['id'] = tmp[0]
host['name'] = tmp[1]
portPos = tmp[4]
LOG.debug("portPos = %s" % (pprint.pformat(portPos)))
if portPos == '---':
portPos = None
else:
port = portPos.split(':')
portPos = {'node': int(port[0]), 'slot': int(port[1]),
'cardPort': int(port[2])}
paths['portPos'] = portPos
# If FC entry
if tmp[5] == 'n/a':
paths['wwn'] = tmp[3]
host['FCPaths'].append(paths)
# else iSCSI entry
else:
paths['name'] = tmp[3]
paths['ipAddr'] = tmp[5]
host['iSCSIPaths'].append(paths)
# find the offset to the description stuff
offset = 0
for line in out:
if line[:15] == '---------- Host':
break
else:
offset += 1
info = out[offset + 2]
tmp = info.split(':')
host['domain'] = tmp[1]
info = out[offset + 4]
tmp = info.split(':')
host['descriptors']['location'] = tmp[1]
info = out[offset + 5]
tmp = info.split(':')
host['descriptors']['ipAddr'] = tmp[1]
info = out[offset + 6]
tmp = info.split(':')
host['descriptors']['os'] = tmp[1]
info = out[offset + 7]
tmp = info.split(':')
host['descriptors']['model'] = tmp[1]
info = out[offset + 8]
tmp = info.split(':')
host['descriptors']['contact'] = tmp[1]
info = out[offset + 9]
tmp = info.split(':')
host['descriptors']['comment'] = tmp[1]
return host
def create_vlun(self, volume, host, client):
"""
In order to export a volume on a 3PAR box, we have to
create a VLUN.
"""
volume_name = self._get_3par_vol_name(volume['name'])
self._create_3par_vlun(volume_name, host['name'])
return client.getVLUN(volume_name)
def delete_vlun(self, volume, connector, client):
hostname = self._safe_hostname(connector['host'])
volume_name = self._get_3par_vol_name(volume['name'])
vlun = client.getVLUN(volume_name)
client.deleteVLUN(volume_name, vlun['lun'], hostname)
self._delete_3par_host(hostname)
@lockutils.synchronized('3par', 'cinder-', True)
def create_volume(self, volume, client, FLAGS):
""" Create a new volume """
LOG.debug("CREATE VOLUME (%s : %s %s)" %
(volume['display_name'], volume['name'],
self._get_3par_vol_name(volume['name'])))
try:
comments = {'name': volume['name'],
'display_name': volume['display_name'],
'type': 'OpenStack'}
extras = {'comment': json.dumps(comments),
'snapCPG': FLAGS.hp3par_cpg_snap}
if not FLAGS.hp3par_cpg_snap:
extras['snapCPG'] = FLAGS.hp3par_cpg
capacity = self._capacity_from_size(volume['size'])
volume_name = self._get_3par_vol_name(volume['name'])
client.createVolume(volume_name, FLAGS.hp3par_cpg,
capacity, extras)
except hpexceptions.HTTPConflict:
raise exception.Duplicate(_("Volume (%s) already exists on array")
% volume_name)
except hpexceptions.HTTPBadRequest as ex:
LOG.error(str(ex))
raise exception.Invalid(ex.get_description())
except Exception as ex:
LOG.error(str(ex))
raise exception.CinderException(ex.get_description())
@lockutils.synchronized('3par', 'cinder-', True)
def delete_volume(self, volume, client):
""" Delete a volume """
try:
volume_name = self._get_3par_vol_name(volume['name'])
client.deleteVolume(volume_name)
except hpexceptions.HTTPNotFound as ex:
LOG.error(str(ex))
raise exception.NotFound(ex.get_description())
except hpexceptions.HTTPForbidden as ex:
LOG.error(str(ex))
raise exception.NotAuthorized(ex.get_description())
except Exception as ex:
LOG.error(str(ex))
raise exception.CinderException(ex.get_description())
@lockutils.synchronized('3par', 'cinder-', True)
def create_volume_from_snapshot(self, volume, snapshot, client):
"""
Creates a volume from a snapshot.
TODO: support using the size from the user.
"""
LOG.debug("Create Volume from Snapshot\n%s\n%s" %
(pprint.pformat(volume['display_name']),
pprint.pformat(snapshot.display_name)))
try:
snap_name = self._get_3par_snap_name(snapshot.name)
vol_name = self._get_3par_vol_name(volume['name'])
extra = {'name': snapshot.display_name,
'description': snapshot.display_description}
optional = {'comment': json.dumps(extra),
'readOnly': False}
client.createSnapshot(vol_name, snap_name, optional)
except hpexceptions.HTTPForbidden as ex:
raise exception.NotAuthorized()
except hpexceptions.HTTPNotFound as ex:
raise exception.NotFound()
@lockutils.synchronized('3par', 'cinder-', True)
def create_snapshot(self, snapshot, client, FLAGS):
"""Creates a snapshot."""
LOG.debug("Create Snapshot\n%s" % pprint.pformat(snapshot))
try:
snap_name = self._get_3par_snap_name(snapshot.name)
vol_name = self._get_3par_vol_name(snapshot.volume_name)
extra = {'name': snapshot.display_name,
'vol_name': snapshot.volume_name,
'description': snapshot.display_description}
optional = {'comment': json.dumps(extra),
'readOnly': True}
if FLAGS.hp3par_snapshot_expiration:
optional['expirationHours'] = FLAGS.hp3par_snapshot_expiration
if FLAGS.hp3par_snapshot_retention:
optional['retentionHours'] = FLAGS.hp3par_snapshot_retention
client.createSnapshot(snap_name, vol_name, optional)
except hpexceptions.HTTPForbidden:
raise exception.NotAuthorized()
except hpexceptions.HTTPNotFound:
raise exception.NotFound()
@lockutils.synchronized('3par', 'cinder-', True)
def delete_snapshot(self, snapshot, client):
"""Driver entry point for deleting a snapshot."""
LOG.debug("Delete Snapshot\n%s" % pprint.pformat(snapshot))
try:
snap_name = self._get_3par_snap_name(snapshot.name)
client.deleteVolume(snap_name)
except hpexceptions.HTTPForbidden:
raise exception.NotAuthorized()
except hpexceptions.HTTPNotFound:
raise exception.NotFound()

@ -0,0 +1,232 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# Copyright (c) 2012 Hewlett-Packard, Inc.
# All Rights Reserved.
#
# Copyright 2012 OpenStack LLC
#
# 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.
#
"""
Volume driver for HP 3PAR Storage array
This driver requires 3.1.2 firmware on
the 3Par array
"""
from hp3parclient import client
from hp3parclient import exceptions as hpexceptions
from cinder import exception
from cinder import flags
from cinder.openstack.common import lockutils
from cinder.openstack.common import log as logging
import cinder.volume.driver
from cinder.volume.drivers.san.hp.hp_3par_common import HP3PARCommon
LOG = logging.getLogger(__name__)
FLAGS = flags.FLAGS
class HP3PARISCSIDriver(cinder.volume.driver.ISCSIDriver):
def __init__(self, *args, **kwargs):
super(HP3PARISCSIDriver, self).__init__(*args, **kwargs)
self.client = None
self.common = None
def _init_common(self):
return HP3PARCommon()
def _check_flags(self):
"""Sanity check to ensure we have required options set."""
required_flags = ['hp3par_api_url', 'hp3par_username',
'hp3par_password', 'iscsi_ip_address',
'iscsi_port', 'san_ip', 'san_login',
'san_password']
self.common.check_flags(FLAGS, required_flags)
def _create_client(self):
return client.HP3ParClient(FLAGS.hp3par_api_url)
def do_setup(self, context):
self.common = self._init_common()
self._check_flags()
self.client = self._create_client()
if FLAGS.hp3par_debug:
self.client.debug_rest(True)
try:
LOG.debug("Connecting to 3PAR")
self.client.login(FLAGS.hp3par_username, FLAGS.hp3par_password)
except hpexceptions.HTTPUnauthorized as ex:
LOG.warning("Failed to connect to 3PAR (%s) because %s" %
(FLAGS.hp3par_api_url, str(ex)))
msg = _("Login to 3PAR array invalid")
raise exception.InvalidInput(reason=msg)
# make sure the CPG exists
try:
self.client.getCPG(FLAGS.hp3par_cpg)
except hpexceptions.HTTPNotFound as ex:
err = _("CPG (%s) doesn't exist on array") % FLAGS.hp3par_cpg
LOG.error(err)
raise exception.InvalidInput(reason=err)
def check_for_setup_error(self):
"""Returns an error if prerequisites aren't met."""
self._check_flags()
@lockutils.synchronized('3par-vol', 'cinder-', True)
def create_volume(self, volume):
""" Create a new volume """
self.common.create_volume(volume, self.client, FLAGS)
return {'provider_location': "%s:%s" %
(FLAGS.iscsi_ip_address, FLAGS.iscsi_port)}
@lockutils.synchronized('3par-vol', 'cinder-', True)
def delete_volume(self, volume):
""" Delete a volume """
self.common.delete_volume(volume, self.client)
@lockutils.synchronized('3par-vol', 'cinder-', True)
def create_volume_from_snapshot(self, volume, snapshot):
"""
Creates a volume from a snapshot.
TODO: support using the size from the user.
"""
self.common.create_volume_from_snapshot(volume, snapshot, self.client)
@lockutils.synchronized('3par-snap', 'cinder-', True)
def create_snapshot(self, snapshot):
"""Creates a snapshot."""
self.common.create_snapshot(snapshot, self.client, FLAGS)
@lockutils.synchronized('3par-snap', 'cinder-', True)
def delete_snapshot(self, snapshot):
"""Driver entry point for deleting a snapshot."""
self.common.delete_snapshot(snapshot, self.client)
@lockutils.synchronized('3par-attach', 'cinder-', True)
def initialize_connection(self, volume, connector):
"""Assigns the volume to a server.
Assign any created volume to a compute node/host so that it can be
used from that host.
This driver returns a driver_volume_type of 'iscsi'.
The format of the driver data is defined in _get_iscsi_properties.
Example return value:
{
'driver_volume_type': 'iscsi'
'data': {
'target_discovered': True,
'target_iqn': 'iqn.2010-10.org.openstack:volume-00000001',
'target_protal': '127.0.0.1:3260',
'volume_id': 1,
}
}
Steps to export a volume on 3PAR
* Get the 3PAR iSCSI iqn
* Create a host on the 3par
* create vlun on the 3par
"""
# get the target_iqn on the 3par interface.
target_iqn = self._iscsi_discover_target_iqn(FLAGS.iscsi_ip_address)
# we have to make sure we have a host
host = self._create_host(volume, connector)
# now that we have a host, create the VLUN
vlun = self.common.create_vlun(volume, host, self.client)
info = {'driver_volume_type': 'iscsi',
'data': {'target_portal': "%s:%s" %
(FLAGS.iscsi_ip_address, FLAGS.iscsi_port),
'target_iqn': target_iqn,
'target_lun': vlun['lun'],
'target_discovered': True
}
}
return info
@lockutils.synchronized('3par-attach', 'cinder-', True)
def terminate_connection(self, volume, connector, force):
"""
Driver entry point to unattach a volume from an instance.
"""
self.common.delete_vlun(volume, connector, self.client)
def _iscsi_discover_target_iqn(self, remote_ip):
result = self.common._cli_run('showport -ids', None)
iqn = None
if result:
# first line is header
result = result[1:]
for line in result:
info = line.split(",")
if info and len(info) > 2:
if info[1] == remote_ip:
iqn = info[2]
return iqn
def _create_3par_iscsi_host(self, hostname, iscsi_iqn, domain):
cmd = 'createhost -iscsi -persona 1 -domain %s %s %s' % \
(domain, hostname, iscsi_iqn)
self.common._cli_run(cmd, None)
def _modify_3par_iscsi_host(self, hostname, iscsi_iqn):
# when using -add, you can not send the persona or domain options
self.common._cli_run('createhost -iscsi -add %s %s'
% (hostname, iscsi_iqn), None)
def _create_host(self, volume, connector):
"""
This is a 3PAR host entry for exporting volumes
via active VLUNs
"""
# make sure we don't have the host already
host = None
hostname = self.common._safe_hostname(connector['host'])
try:
host = self.common._get_3par_host(hostname)
if not host['iSCSIPaths']:
self._modify_3par_iscsi_host(hostname, connector['initiator'])
host = self.common._get_3par_host(hostname)
except hpexceptions.HTTPNotFound:
# host doesn't exist, we have to create it
self._create_3par_iscsi_host(hostname, connector['initiator'],
FLAGS.hp3par_domain)
host = self.common._get_3par_host(hostname)
return host
@lockutils.synchronized('3par-exp', 'cinder-', True)
def create_export(self, context, volume):
pass
@lockutils.synchronized('3par-exp', 'cinder-', True)
def ensure_export(self, context, volume):
"""Exports the volume."""
pass
@lockutils.synchronized('3par-exp', 'cinder-', True)
def remove_export(self, context, volume):
"""Removes an export for a logical volume."""
pass

@ -826,4 +826,34 @@
#### (BoolOpt) Don't halt on deletion of non-existing volumes
######## defined in cinder.volume.drivers.san.hp.hp_3par_common ########
# hp3par_api_url=<None>
#### (StrOpt) 3PAR WSAPI Server Url like https://<3par ip>:8080/api/v1
# hp3par_username=<None>
#### (StrOpt) 3PAR username
# hp3par_password=<None>
#### (StrOpt) 3PAR password
# hp3par_domain=OpenStack
#### (StrOpt) The 3par domain to use
# hp3par_cpg=OpenStack
#### (StrOpt) The CPG to use for volume creation
# hp3par_cpg_snap=<None>
#### (StrOpt) The CPG to use for snapshots for volumes.
# hp3par_snapshot_retention=<None>
#### (StrOpt) The time in hours to retain a snapshot
# hp3par_snapshot_expiration=<None>
#### (StrOpt) The time in ours when a snapshot expires
# hp3par_debug=False
#### (BoolOpt) Enable REST debugging output
# Total option count: 219

@ -11,3 +11,4 @@ pep8==1.3.3
pylint==0.25.2
sphinx>=1.1.2
MySQL-python
hp3parclient>=1.0.0