summaryrefslogtreecommitdiffstats
path: root/nova
diff options
context:
space:
mode:
authorjohn-griffith <john.griffith@solidfire.com>2012-01-30 11:16:42 -0700
committerjohn-griffith <john.griffith@solidfire.com>2012-01-30 11:19:52 -0700
commitc9ac6e1671df689c3ba4a4d55f8740bd8f2e8f0e (patch)
tree75b0c9aaea1d8e40557c78a48f9b339c8c74d98b /nova
parentc10443704226341086d3d1cd8850508d28201fc3 (diff)
Implementation of new Nova Volume driver for SolidFire ISCSI SAN
* Adds new SolidFire driver that subclasses nova.volume.san.SanISCSIDriver * Adds unit tests for new driver * Adds new exception subclasses in nova.exception * Adds John Griffith to Authors Implements solidfire-san-iscsidriver Change-Id: I4dc7508ba08f5333cde74d4cfeaae3939c5d2b02
Diffstat (limited to 'nova')
-rw-r--r--nova/exception.py12
-rw-r--r--nova/tests/test_SolidFireSanISCSIDriver.py180
-rw-r--r--nova/volume/san.py266
3 files changed, 455 insertions, 3 deletions
diff --git a/nova/exception.py b/nova/exception.py
index 25cbf18b3..c93e2de11 100644
--- a/nova/exception.py
+++ b/nova/exception.py
@@ -219,6 +219,10 @@ class InvalidKeypair(Invalid):
message = _("Keypair data is invalid")
+class SfJsonEncodeFailure(NovaException):
+ message = _("Failed to load data into json format")
+
+
class InvalidRequest(Invalid):
message = _("The request is invalid.")
@@ -398,6 +402,10 @@ class VolumeNotFound(NotFound):
message = _("Volume %(volume_id)s could not be found.")
+class SfAccountNotFound(NotFound):
+ message = _("Unable to locate account %(account_name) on Solidfire device")
+
+
class VolumeNotFoundForInstance(VolumeNotFound):
message = _("Volume not found for instance %(instance_id)s.")
@@ -934,3 +942,7 @@ class AggregateHostConflict(Duplicate):
class AggregateHostExists(Duplicate):
message = _("Aggregate %(aggregate_id)s already has host %(host)s.")
+
+
+class DuplicateSfVolumeNames(Duplicate):
+ message = _("Detected more than one volume with name %(vol_name)")
diff --git a/nova/tests/test_SolidFireSanISCSIDriver.py b/nova/tests/test_SolidFireSanISCSIDriver.py
new file mode 100644
index 000000000..f6b2e8aeb
--- /dev/null
+++ b/nova/tests/test_SolidFireSanISCSIDriver.py
@@ -0,0 +1,180 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 OpenStack LLC.
+# 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 nova import exception
+from nova import log as logging
+from nova.volume.san import SolidFireSanISCSIDriver as SFID
+from nova import test
+
+LOG = logging.getLogger('nova.tests.test_solidfire')
+
+
+class SolidFireVolumeTestCase(test.TestCase):
+ def setUp(self):
+ super(SolidFireVolumeTestCase, self).setUp()
+ self.executes = []
+ self.account_not_found = False
+
+ def tearDown(self):
+ pass
+
+ def fake_issue_api_request(obj, method, params):
+ if method is 'GetClusterInfo':
+ LOG.info('Called Fake GetClusterInfo...')
+ results = {'result': {'clusterInfo':
+ {'name': 'fake-cluster',
+ 'mvip': '1.1.1.1',
+ 'svip': '1.1.1.1',
+ 'uniqueID': 'unqid',
+ 'repCount': 2,
+ 'attributes': {}}}}
+ return results
+
+ elif method is 'AddAccount':
+ LOG.info('Called Fake AddAccount...')
+ return {'result': {'accountID': 25}, 'id': 1}
+
+ elif method is 'GetAccountByName':
+ LOG.info('Called Fake GetAccountByName...')
+ results = {'result': {'account': {
+ 'accountID': 25,
+ 'username': params['username'],
+ 'status': 'active',
+ 'initiatorSecret': '123456789012',
+ 'targetSecret': '123456789012',
+ 'attributes': {},
+ 'volumes': [6, 7, 20]}},
+ "id": 1}
+ return results
+
+ elif method is 'CreateVolume':
+ LOG.info('Called Fake CreateVolume...')
+ return {'result': {'volumeID': 5}, 'id': 1}
+
+ elif method is 'DeleteVolume':
+ LOG.info('Called Fake DeleteVolume...')
+ return {'result': {}, 'id': 1}
+
+ elif method is 'ListVolumesForAccount':
+ LOG.info('Called Fake ListVolumesForAccount...')
+ result = {'result': {'volumes': [{
+ 'volumeID': '5',
+ 'name': 'test_volume',
+ 'accountID': 25,
+ 'sliceCount': 1,
+ 'totalSize': 1048576 * 1024,
+ 'enable512e': False,
+ 'access': "readWrite",
+ 'status': "active",
+ 'attributes':None,
+ 'qos':None}]}}
+ return result
+
+ else:
+ LOG.error('Crap, unimplemented API call in Fake:%s' % method)
+
+ def fake_issue_api_request_fails(obj, method, params):
+ return {'error': {
+ 'code': 000,
+ 'name': 'DummyError',
+ 'message': 'This is a fake error response'},
+ 'id': 1}
+
+ def test_create_volume(self):
+ SFID._issue_api_request = self.fake_issue_api_request
+ testvol = {'project_id': 'testprjid',
+ 'name': 'testvol',
+ 'size': 1}
+ sfv = SFID()
+ model_update = sfv.create_volume(testvol)
+
+ def test_create_volume_fails(self):
+ SFID._issue_api_request = self.fake_issue_api_request_fails
+ testvol = {'project_id': 'testprjid',
+ 'name': 'testvol',
+ 'size': 1}
+ sfv = SFID()
+ try:
+ sfv.create_volume(testvol)
+ self.fail("Should have thrown Error")
+ except:
+ pass
+
+ def test_create_sfaccount(self):
+ sfv = SFID()
+ SFID._issue_api_request = self.fake_issue_api_request
+ account = sfv._create_sfaccount('project-id')
+ self.assertNotEqual(account, None)
+
+ def test_create_sfaccount_fails(self):
+ sfv = SFID()
+ SFID._issue_api_request = self.fake_issue_api_request_fails
+ account = sfv._create_sfaccount('project-id')
+ self.assertEqual(account, None)
+
+ def test_get_sfaccount_by_name(self):
+ sfv = SFID()
+ SFID._issue_api_request = self.fake_issue_api_request
+ account = sfv._get_sfaccount_by_name('some-name')
+ self.assertNotEqual(account, None)
+
+ def test_get_sfaccount_by_name_fails(self):
+ sfv = SFID()
+ SFID._issue_api_request = self.fake_issue_api_request_fails
+ account = sfv._get_sfaccount_by_name('some-name')
+ self.assertEqual(account, None)
+
+ def test_delete_volume(self):
+ SFID._issue_api_request = self.fake_issue_api_request
+ testvol = {'project_id': 'testprjid',
+ 'name': 'test_volume',
+ 'size': 1}
+ sfv = SFID()
+ model_update = sfv.delete_volume(testvol)
+
+ def test_delete_volume_fails_no_volume(self):
+ SFID._issue_api_request = self.fake_issue_api_request
+ testvol = {'project_id': 'testprjid',
+ 'name': 'no-name',
+ 'size': 1}
+ sfv = SFID()
+ try:
+ model_update = sfv.delete_volume(testvol)
+ self.fail("Should have thrown Error")
+ except:
+ pass
+
+ def test_delete_volume_fails_account_lookup(self):
+ SFID._issue_api_request = self.fake_issue_api_request
+ testvol = {'project_id': 'testprjid',
+ 'name': 'no-name',
+ 'size': 1}
+ sfv = SFID()
+ self.assertRaises(exception.DuplicateSfVolumeNames,
+ sfv.delete_volume,
+ testvol)
+
+ def test_get_cluster_info(self):
+ SFID._issue_api_request = self.fake_issue_api_request
+ sfv = SFID()
+ sfv._get_cluster_info()
+
+ def test_get_cluster_info_fail(self):
+ SFID._issue_api_request = self.fake_issue_api_request_fails
+ sfv = SFID()
+ self.assertRaises(exception.ApiError,
+ sfv._get_cluster_info)
diff --git a/nova/volume/san.py b/nova/volume/san.py
index 0c88e9d75..25309768e 100644
--- a/nova/volume/san.py
+++ b/nova/volume/san.py
@@ -21,12 +21,19 @@ The unique thing about a SAN is that we don't expect that we can run the volume
controller on the SAN hardware. We expect to access it over SSH or some API.
"""
+import base64
+import httplib
+import json
import os
import paramiko
-
+import random
+import socket
+import string
+import uuid
from xml.etree import ElementTree
from nova.common import cfg
+
from nova import exception
from nova import flags
from nova import log as logging
@@ -34,6 +41,7 @@ from nova import utils
from nova.utils import ssh_execute
from nova.volume.driver import ISCSIDriver
+
LOG = logging.getLogger("nova.volume.driver")
san_opts = [
@@ -72,7 +80,7 @@ FLAGS.add_options(san_opts)
class SanISCSIDriver(ISCSIDriver):
- """ Base class for SAN-style storage volumes
+ """Base class for SAN-style storage volumes
A SAN-style storage value is 'different' because the volume controller
probably won't run on it, so we need to access is over SSH or another
@@ -151,7 +159,7 @@ class SanISCSIDriver(ISCSIDriver):
def _collect_lines(data):
- """ Split lines from data into an array, trimming them """
+ """Split lines from data into an array, trimming them """
matches = []
for line in data.splitlines():
match = line.strip()
@@ -645,3 +653,255 @@ class HpSanISCSIDriver(SanISCSIDriver):
cliq_args['volumeName'] = volume['name']
self._cliq_run_xml("unassignVolume", cliq_args)
+
+
+class SolidFireSanISCSIDriver(SanISCSIDriver):
+
+ def _issue_api_request(self, method_name, params):
+ """All API requests to SolidFire device go through this method
+
+ Simple json-rpc web based API calls.
+ each call takes a set of paramaters (dict)
+ and returns results in a dict as well.
+ """
+
+ host = FLAGS.san_ip
+ # For now 443 is the only port our server accepts requests on
+ port = 443
+
+ # NOTE(john-griffith): Probably don't need this, but the idea is
+ # we provide a request_id so we can correlate
+ # responses with requests
+ request_id = int(uuid.uuid4()) # just generate a random number
+
+ cluster_admin = FLAGS.san_login
+ cluster_password = FLAGS.san_password
+
+ command = {'method': method_name,
+ 'id': request_id}
+
+ if params is not None:
+ command['params'] = params
+
+ payload = json.dumps(command, ensure_ascii=False)
+ payload.encode('utf-8')
+ # we use json-rpc, webserver needs to see json-rpc in header
+ header = {'Content-Type': 'application/json-rpc; charset=utf-8'}
+
+ if cluster_password is not None:
+ # base64.encodestring includes a newline character
+ # in the result, make sure we strip it off
+ auth_key = base64.encodestring('%s:%s' % (cluster_admin,
+ cluster_password))[:-1]
+ header['Authorization'] = 'Basic %s' % auth_key
+
+ LOG.debug(_("Payload for SolidFire API call: %s" % payload))
+ connection = httplib.HTTPSConnection(host, port)
+ connection.request('POST', '/json-rpc/1.0', payload, header)
+ response = connection.getresponse()
+ data = {}
+
+ if response.status != 200:
+ connection.close()
+ msg = _("Error in SolidFire API response, status was: %s"
+ % response.status)
+ raise exception.ApiError(msg)
+
+ else:
+ data = response.read()
+ try:
+ data = json.loads(data)
+
+ except (TypeError, ValueError), exc:
+ connection.close()
+ msg = _("Call to json.loads() raised an exception: %s" % exc)
+ raise exception.SfJsonEncodeFailure(msg)
+
+ connection.close()
+
+ LOG.debug(_("Results of SolidFire API call: %s" % data))
+ return data
+
+ def _get_volumes_by_sfaccount(self, account_id):
+ params = {'accountID': account_id}
+ data = self._issue_api_request('ListVolumesForAccount', params)
+ if 'result' in data:
+ return data['result']['volumes']
+
+ def _get_sfaccount_by_name(self, sf_account_name):
+ sfaccount = None
+ params = {'username': sf_account_name}
+ data = self._issue_api_request('GetAccountByName', params)
+ if 'result' in data and 'account' in data['result']:
+ LOG.debug(_('Found solidfire account: %s' % sf_account_name))
+ sfaccount = data['result']['account']
+ return sfaccount
+
+ def _create_sfaccount(self, nova_project_id):
+ """Create account on SolidFire device if it doesn't already exist.
+
+ We're first going to check if the account already exits, if it does
+ just return it. If not, then create it.
+ """
+
+ sf_account_name = socket.gethostname() + '-' + nova_project_id
+ sfaccount = self._get_sfaccount_by_name(sf_account_name)
+ if sfaccount is None:
+ LOG.debug(_('solidfire account: %s does not exist, create it...'
+ % sf_account_name))
+ chap_secret = self._generate_random_string(12)
+ params = {'username': sf_account_name,
+ 'initiatorSecret': chap_secret,
+ 'targetSecret': chap_secret,
+ 'attributes': {}}
+ data = self._issue_api_request('AddAccount', params)
+ if 'result' in data:
+ sfaccount = self._get_sfaccount_by_name(sf_account_name)
+
+ return sfaccount
+
+ def _get_cluster_info(self):
+ params = {}
+ data = self._issue_api_request('GetClusterInfo', params)
+ if 'result' not in data:
+ msg = _("Error in SolidFire API response data was: %s"
+ % data)
+ raise exception.ApiError(msg)
+
+ return data['result']
+
+ def _do_export(self, volume):
+ """Gets the associated account, retrieves CHAP info and updates."""
+
+ sfaccount_name = '%s-%s' % (socket.gethostname(), volume['project_id'])
+ sfaccount = self._get_sfaccount_by_name(sfaccount_name)
+
+ model_update = {}
+ model_update['provider_auth'] = ('CHAP %s %s'
+ % (sfaccount['username'], sfaccount['targetSecret']))
+
+ return model_update
+
+ def _generate_random_string(self, length):
+ """Generates random_string to use for CHAP password."""
+
+ char_set = string.ascii_uppercase + string.digits
+ return ''.join(random.sample(char_set, length))
+
+ def create_volume(self, volume):
+ """Create volume on SolidFire device.
+
+ The account is where CHAP settings are derived from, volume is
+ created and exported. Note that the new volume is immediately ready
+ for use.
+
+ One caveat here is that an existing user account must be specified
+ in the API call to create a new volume. We use a set algorithm to
+ determine account info based on passed in nova volume object. First
+ we check to see if the account already exists (and use it), or if it
+ does not already exist, we'll go ahead and create it.
+
+ For now, we're just using very basic settings, QOS is
+ turned off, 512 byte emulation is off etc. Will be
+ looking at extensions for these things later, or
+ this module can be hacked to suit needs.
+ """
+
+ LOG.debug(_("Enter SolidFire create_volume..."))
+ GB = 1048576 * 1024
+ slice_count = 1
+ enable_emulation = False
+ attributes = {}
+
+ cluster_info = self._get_cluster_info()
+ iscsi_portal = cluster_info['clusterInfo']['svip'] + ':3260'
+ sfaccount = self._create_sfaccount(volume['project_id'])
+ account_id = sfaccount['accountID']
+ account_name = sfaccount['username']
+ chap_secret = sfaccount['targetSecret']
+
+ params = {'name': volume['name'],
+ 'accountID': account_id,
+ 'sliceCount': slice_count,
+ 'totalSize': volume['size'] * GB,
+ 'enable512e': enable_emulation,
+ 'attributes': attributes}
+
+ data = self._issue_api_request('CreateVolume', params)
+ if 'result' not in data:
+ msg = _("Error in SolidFire API response data was: %s"
+ % data)
+ raise exception.ApiError(msg)
+ if 'volumeID' not in data['result']:
+ msg = _("Error in SolidFire API response data was: %s"
+ % data)
+ raise exception.ApiError(msg)
+ volume_id = data['result']['volumeID']
+
+ volume_list = self._get_volumes_by_sfaccount(account_id)
+ iqn = None
+ for v in volume_list:
+ if v['volumeID'] == volume_id:
+ iqn = 'iqn.2010-01.com.solidfire:' + v['iqn']
+ break
+
+ model_update = {}
+ model_update['provider_location'] = ('%s %s' % (iscsi_portal, iqn))
+ model_update['provider_auth'] = ('CHAP %s %s'
+ % (account_name, chap_secret))
+
+ LOG.debug(_("Leaving SolidFire create_volume"))
+ return model_update
+
+ def delete_volume(self, volume):
+ """Delete SolidFire Volume from device.
+
+ SolidFire allows multipe volumes with same name,
+ volumeID is what's guaranteed unique.
+
+ What we'll do here is check volumes based on account. this
+ should work because nova will increment it's volume_id
+ so we should always get the correct volume. This assumes
+ that nova does not assign duplicate ID's.
+ """
+
+ LOG.debug(_("Enter SolidFire delete_volume..."))
+ sf_account_name = socket.gethostname() + '-' + volume['project_id']
+ sfaccount = self._get_sfaccount_by_name(sf_account_name)
+ if sfaccount is None:
+ raise exception.SfAccountNotFound(account_name=sf_account_name)
+
+ params = {'accountID': sfaccount['accountID']}
+ data = self._issue_api_request('ListVolumesForAccount', params)
+ if 'result' not in data:
+ msg = _("Error in SolidFire API response, data was: %s"
+ % data)
+ raise exception.ApiError(msg)
+
+ found_count = 0
+ volid = -1
+ for v in data['result']['volumes']:
+ if v['name'] == volume['name']:
+ found_count += 1
+ volid = v['volumeID']
+
+ if found_count != 1:
+ LOG.debug(_("Deleting volumeID: %s " % volid))
+ raise exception.DuplicateSfVolumeNames(vol_name=volume['name'])
+
+ params = {'volumeID': volid}
+ data = self._issue_api_request('DeleteVolume', params)
+ if 'result' not in data:
+ msg = _("Error in SolidFire API response, data was: %s"
+ % data)
+ raise exception.ApiError(msg)
+
+ LOG.debug(_("Leaving SolidFire delete_volume"))
+
+ def ensure_export(self, context, volume):
+ LOG.debug(_("Executing SolidFire ensure_export..."))
+ return self._do_export(volume)
+
+ def create_export(self, context, volume):
+ LOG.debug(_("Executing SolidFire create_export..."))
+ return self._do_export(volume)