From c9ac6e1671df689c3ba4a4d55f8740bd8f2e8f0e Mon Sep 17 00:00:00 2001 From: john-griffith Date: 2012年1月30日 11:16:42 -0700 Subject: [PATCH] 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 --- Authors | 1 + nova/exception.py | 12 + nova/tests/test_SolidFireSanISCSIDriver.py | 180 ++++++++++++++ nova/volume/san.py | 266 ++++++++++++++++++++- 4 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 nova/tests/test_SolidFireSanISCSIDriver.py diff --git a/Authors b/Authors index 91c6bcfcba7b..90de68183257 100644 --- a/Authors +++ b/Authors @@ -76,6 +76,7 @@ Joel Moore Johannes Erdfelt John Dewey John Garbutt +John Griffith John Tran Jonathan Bryce Jordan Rinke diff --git a/nova/exception.py b/nova/exception.py index 25cbf18b3d41..c93e2de11282 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 000000000000..f6b2e8aeb421 --- /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 0c88e9d75718..25309768e1ad 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)

AltStyle によって変換されたページ (->オリジナル) /