From: Walter A. Boring IV Date: Tue, 18 Dec 2012 22:16:33 +0000 (-0800) Subject: Provide HP 3PAR array iSCSI driver X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=027b78a214e1f7ab7bbda342a024cb13dae8944d;p=openstack-build%2Fcinder-build.git 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 --- diff --git a/cinder/tests/test_hp3par_iscsi.py b/cinder/tests/test_hp3par_iscsi.py new file mode 100644 index 000000000..637038e0a --- /dev/null +++ b/cinder/tests/test_hp3par_iscsi.py @@ -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) diff --git a/cinder/volume/drivers/san/hp/__init__.py b/cinder/volume/drivers/san/hp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cinder/volume/drivers/san/hp/hp_3par_common.py b/cinder/volume/drivers/san/hp/hp_3par_common.py new file mode 100644 index 000000000..177037f5e --- /dev/null +++ b/cinder/volume/drivers/san/hp/hp_3par_common.py @@ -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() diff --git a/cinder/volume/drivers/san/hp/hp_3par_iscsi.py b/cinder/volume/drivers/san/hp/hp_3par_iscsi.py new file mode 100644 index 000000000..041ca637c --- /dev/null +++ b/cinder/volume/drivers/san/hp/hp_3par_iscsi.py @@ -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 diff --git a/etc/cinder/cinder.conf.sample b/etc/cinder/cinder.conf.sample index f3d3e3c98..c2eb8b6e7 100644 --- a/etc/cinder/cinder.conf.sample +++ b/etc/cinder/cinder.conf.sample @@ -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= +#### (StrOpt) 3PAR WSAPI Server Url like https://<3par ip>:8080/api/v1 + +# hp3par_username= +#### (StrOpt) 3PAR username + +# hp3par_password= +#### (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= +#### (StrOpt) The CPG to use for snapshots for volumes. + +# hp3par_snapshot_retention= +#### (StrOpt) The time in hours to retain a snapshot + +# hp3par_snapshot_expiration= +#### (StrOpt) The time in ours when a snapshot expires + +# hp3par_debug=False +#### (BoolOpt) Enable REST debugging output + + # Total option count: 219 diff --git a/tools/test-requires b/tools/test-requires index 9f78c1a30..5cac87c74 100644 --- a/tools/test-requires +++ b/tools/test-requires @@ -11,3 +11,4 @@ pep8==1.3.3 pylint==0.25.2 sphinx>=1.1.2 MySQL-python +hp3parclient>=1.0.0