Merge lp:~justin-fathomdb/nova/justinsb-hpsan2 into lp:~hudson-openstack/nova/trunk

Proposed by justinsb
Status: Merged
Approved by: Jay Pipes
Approved revision: 707
Merge reported by: justinsb
Merged at revision: not available
Proposed branch: lp:~justin-fathomdb/nova/justinsb-hpsan2
Merge into: lp:~hudson-openstack/nova/trunk
Prerequisite: lp:~justin-fathomdb/nova/justinsb-metadata3
Diff against target: 741 lines (+514/-60)
5 files modified
nova/db/sqlalchemy/migrate_repo/versions/006_add_provider_data_to_volumes.py (+72/-0)
nova/db/sqlalchemy/models.py (+3/-0)
nova/volume/driver.py (+152/-27)
nova/volume/manager.py (+6/-2)
nova/volume/san.py (+281/-31)
To merge this branch: bzr merge lp:~justin-fathomdb/nova/justinsb-hpsan2
Reviewer Review Type Date Requested Status
Todd Willey (community) Approve
Devin Carlen (community) Approve
Jay Pipes (community) Approve
Review via email: mp+50869@code.launchpad.net

This proposal supersedes a proposal from 2011-02-22.

Description of the change

Support HP/LeftHand SANs. We control the SAN by SSHing and issuing CLIQ commands. Also improved the way iSCSI volumes are mounted: try to store the iSCSI connection info in the volume entity, in preference to doing discovery. Also CHAP authentication support.

CHAP support is necessary to avoid the attach-volume command require an export on the SAN device. If we had to do that, the attach command would have to hit both the volume controller and the compute controller, and that would be complex.

To post a comment you must log in.
Revision history for this message
Jay Pipes (jaypipes) wrote : Posted in a previous version of this proposal

Hi! As I said on IRC, not much I can review as far as the volume stuff goes, so here is some general feedback :)

1 === added file 'nova/db/sqlalchemy/migrate_repo/versions/003_cactus.py'

As you mentioned on IRC, I think it would be best to name this something like 003_add_provider_columns.py instead of 003_cactus.py, to make the actual migration file more descriptive of its contents...

74 + # Create tables
75 + for table in (
76 + #certificates, consoles, console_pools, instance_actions
77 + ):
78 + try:
79 + table.create()
80 + except Exception:
81 + logging.info(repr(table))
82 + logging.exception('Exception while creating table')
83 + raise
84 +
85 + # Alter column types
86 + # auth_tokens.c.user_id.alter(type=String(length=255,
87 + # convert_unicode=False,
88 + # assert_unicode=None,
89 + # unicode_error=None,
90 + # _warn_on_bytestring=False))

I recognize you probably commented that stuff out thinking that we are only using a single 003_cactus.py migration file, but if that migration file is renamed to something more descriptive (and we can have >1 migration file per release), obviously I think you should remove all that commented out code :)

Missed i18n in these locations:

139 + LOG.warn("ISCSI provider_location not stored, using discovery")
175 + LOG.debug("ISCSI Discovery: Found %s" % (location))
552 + raise exception.Error("local_path not supported")

For 175 above, please use the following instead:

LOG.debug(_("ISCSI Discovery: Found %s"), location)

Not sure if you meant to i18n this or not :)

457 + message = ("Unexpected number of virtual ips for cluster %s. "
458 + "Result=%s" %
459 + (cluster_name, ElementTree.tostring(cluster_xml)))

But if so, should be:

message = _("Unexpected number of virtual ips for cluster %(cluster)s. "
            "Result=%(result)s" % {'cluster': cluster_name,
                           'result': ElementTree.tostring(cluster_xml)})

Other than those little nits above, the only other advice I could give would be to maybe sprinkle a few LOG.debug()s around HpSanISCSIDriver methods and maybe add a bit of docstring documentation to HpSanISCSIDriver that gives the rough flow of the commands executed against the volume controller?

Cheers, and thanks for a great contribution!
jay

review: Needs Fixing
Revision history for this message
Todd Willey (xtoddx) wrote : Posted in a previous version of this proposal

If we're going to do something special with the return value of driver.create_volume and driver.update_volume, those should be documented as such, probably in nova/volume/driver.py#VolumeDriver (since all the others inherit from that).

I'm not well versed in iscsi, but I'd hope there would be a way to get more specific matches in _do_iscsi_discovery, maybe by making sure a field is surrounded by spaces, etc. The line I'm referencing is "if FLAGS.iscsi_ip_prefix in target and volume_name in target:". I'd hate for a target on 10.1.2.30 to match when we're looking for 10.1.2.3 just because one is a substring of the other.

It would also be great to document what fields should be in iscsi_properties, since this is a class that is inherited other places. A central point would be better than having to look it up in _run_iscsiadmin, discover_volume, etc, getting just a few of the fields each time.

I also see in HpSanISCSIDriver you're using CHAP, but in ISCSIDriver._do_iscsi_discovery you have a note that discovery doesn't work with CHAP. Can you let me know what I'm missing?

Also, I think it is strange for the volume layer to have knowledge of the database schema, and have to know column names and have a variable it returns called 'db_update'. Maybe just renaming it 'state_changes' or something would assuage my fear, and make me feel that if there was a schema change that would be okay because we don't actually have to pass the return value to database calls.

All in all a very nice contribution. ☺

Revision history for this message
Jay Pipes (jaypipes) wrote : Posted in a previous version of this proposal

Thanks for fixing up the few nits I brought up Justin. Approved from me from a style/overview perspective. I trust Todd and some others will more thoroughly review the call sequences to the volume drivers.

Cheers!
jay

review: Approve
Revision history for this message
justinsb (justin-fathomdb) wrote : Posted in a previous version of this proposal

I documented the return value in VolumeDriver create_volume & create_export.

I changed the name of the model state change Dictionary from db_update to model_update, based on your suggestion. The volume drivers are tightly coupled to the model definitions, so I don't think we're violating any separation of concerns. However, the naming "DB" was violating the separation, so I renamed it. Thanks for pointing this out.

The two new properties are 'provider_auth' and 'provider_location'. They're intended to be opaque properties for the driver's own use, but the natural assumption is that _location would be data as to the location and _auth info about authentication. I could use a dictionary instead, but I tried to pick two sensible fields that are hopefully fairly generic across drivers. Using a dictionary is problematic in terms of how it gets serialized efficiently. I documented the default iSCSI use of provider_location and provider_auth on ISCSIDriver.

The iSCSI discovery code is inherited, though the indentation level changed (I'm probably not blameless on the original code though!) I agree that it's fragile, so I'd like to move away from it. HP SANs and Solaris 'SANs' no longer use it; I can fix OpenISCSI next but that's a behavioural change on code that is potentially in active use so probably belongs in its own patch. There's a note that discovery is deprecated, but this code path is there until I can fix it for OpeniSCSI, but it is the same code as before. If you think I should clean it up in this patch I can do that, but I think it's best not to.

The CHAP authentication issue with discovery will go away when discovery is removed. HP SANs don't use discovery, and they're also the only ones using CHAP right now. If CHAP is supported with discovery (?), we'd definitely have to pass some extra parameters which aren't being passed right now, and I don't see the point of making this chaneg if I'm going to remove this code anyway :-)

I documented the current iscsi_properties (though I really don't know how to format this so it doesn't look terrible)

Revision history for this message
justinsb (justin-fathomdb) wrote : Posted in a previous version of this proposal

Fixed up code. Can't merge (or even run unit tests) until metadata patch merges though, because of numbering issues on the DB migrations.

Revision history for this message
Jay Pipes (jaypipes) wrote : Posted in a previous version of this proposal

> Fixed up code. Can't merge (or even run unit tests) until metadata patch
> merges though, because of numbering issues on the DB migrations.

Hi! There's a little-known feature of Launchpad's merge proposal system that allows you to set a "prerequisite branch". Edit the merge proposal and set the pre-requisite branch to your metadata branch, and Launchpad will:

* Not show any changes from the metadata branch in this merge proposal diff (this is so you can, say, merge your local metadata branch into this hpsan branch and not have the metadata changes show up in the merge diff here)
* Not allow someone to set this to Approved before the metadata branch is merged into trunk.

Cheers!
jay

Revision history for this message
justinsb (justin-fathomdb) wrote :

Added metadata as prerequisite branch. But can we get that merged please? I think it's good to go...

Revision history for this message
Todd Willey (xtoddx) wrote :

Looks pretty good. I'd suggest one more pass on the docstrings. Pep-257 outlines the format for multi-line docstrings, which is essentially that a short sentence goes on the first line, then you write the rest, and you end with a blank line.

You can look at nova/db/api.py to see how the flags in the database layer are documented in RST, which should be a good way to document the properties here as well.

Revision history for this message
Jay Pipes (jaypipes) wrote :

lgtm3

review: Approve
Revision history for this message
Devin Carlen (devcamcar) wrote :

lgtm

review: Approve
Revision history for this message
FUJITA Tomonori (fujita-tomonori-deactivatedaccount) wrote :

I'm still learning how the volume stuff works but here are some comments about iSCSI stuff:

- Avoiding discovery session sounds better. You can directly insert the target info into open-iscsi db and let open-iscsi log in it.

- iSCSI supports the mutual authentication (the initiator can verify the target too) so it might be better to make 'provider_auth' capable to handle it.

- If you will not remove the discovery session, 'provider_auth' needs to handle discovery session CHAP auth too.

Revision history for this message
justinsb (justin-fathomdb) wrote :

Todd: Fixed docstrings so that they look better and comply with PEP 257. Is there an easy preview tool and PEP 257 checker?

FUJITA: The goal is to remove discovery. It's not done in this patch, but it's coming for OpeniSCSI. So I don't want to invest time in supporting CHAP at the moment for discovery, because that is hopefully going away, and CHAP isn't used with OpenISCSI at the moment anyway.

I do agree that mutual authentication would be nice, but I'd like to keep this patch as focused as is possible. It would be great if you want to add support!

Revision history for this message
Todd Willey (xtoddx) wrote :

Lets get this done!

review: Approve
Revision history for this message
OpenStack Infra (hudson-openstack) wrote :

No proposals found for merge of lp:~justin-fathomdb/nova/justinsb-metadata3 into lp:nova.

Revision history for this message
justinsb (justin-fathomdb) wrote :

Ah - I accidentally pushed trunk to the (already merged) justinsb-metadata3 branch, which I think is the cause of the above error from Hudson. Nonetheless, it looks like this branch did merge clean. I'm just double-checking that, but I put it back to 'merged' to stop mergebot getting really confused!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== added file 'nova/db/sqlalchemy/migrate_repo/versions/006_add_provider_data_to_volumes.py'
--- nova/db/sqlalchemy/migrate_repo/versions/006_add_provider_data_to_volumes.py 1970-01-01 00:00:00 +0000
+++ nova/db/sqlalchemy/migrate_repo/versions/006_add_provider_data_to_volumes.py 2011-02-24 06:50:14 +0000
@@ -0,0 +1,72 @@
1# vim: tabstop=4 shiftwidth=4 softtabstop=4
2
3# Copyright 2011 Justin Santa Barbara.
4# All Rights Reserved.
5#
6# Licensed under the Apache License, Version 2.0 (the "License"); you may
7# not use this file except in compliance with the License. You may obtain
8# a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15# License for the specific language governing permissions and limitations
16# under the License.
17
18from sqlalchemy import *
19from migrate import *
20
21from nova import log as logging
22
23
24meta = MetaData()
25
26
27# Table stub-definitions
28# Just for the ForeignKey and column creation to succeed, these are not the
29# actual definitions of instances or services.
30#
31volumes = Table('volumes', meta,
32 Column('id', Integer(), primary_key=True, nullable=False),
33 )
34
35
36#
37# New Tables
38#
39# None
40
41#
42# Tables to alter
43#
44# None
45
46#
47# Columns to add to existing tables
48#
49
50volumes_provider_location = Column('provider_location',
51 String(length=256,
52 convert_unicode=False,
53 assert_unicode=None,
54 unicode_error=None,
55 _warn_on_bytestring=False))
56
57volumes_provider_auth = Column('provider_auth',
58 String(length=256,
59 convert_unicode=False,
60 assert_unicode=None,
61 unicode_error=None,
62 _warn_on_bytestring=False))
63
64
65def upgrade(migrate_engine):
66 # Upgrade operations go here. Don't create your own engine;
67 # bind migrate_engine to your metadata
68 meta.bind = migrate_engine
69
70 # Add columns to existing tables
71 volumes.create_column(volumes_provider_location)
72 volumes.create_column(volumes_provider_auth)
073
=== modified file 'nova/db/sqlalchemy/models.py'
--- nova/db/sqlalchemy/models.py 2011-02-17 23:00:18 +0000
+++ nova/db/sqlalchemy/models.py 2011-02-24 06:50:14 +0000
@@ -243,6 +243,9 @@
243 display_name = Column(String(255))243 display_name = Column(String(255))
244 display_description = Column(String(255))244 display_description = Column(String(255))
245245
246 provider_location = Column(String(255))
247 provider_auth = Column(String(255))
248
246249
247class Quota(BASE, NovaBase):250class Quota(BASE, NovaBase):
248 """Represents quota overrides for a project."""251 """Represents quota overrides for a project."""
249252
=== modified file 'nova/volume/driver.py'
--- nova/volume/driver.py 2011-02-04 17:04:55 +0000
+++ nova/volume/driver.py 2011-02-24 06:50:14 +0000
@@ -21,6 +21,7 @@
21"""21"""
2222
23import time23import time
24import os
2425
25from nova import exception26from nova import exception
26from nova import flags27from nova import flags
@@ -36,6 +37,8 @@
36 'Which device to export the volumes on')37 'Which device to export the volumes on')
37flags.DEFINE_string('num_shell_tries', 3,38flags.DEFINE_string('num_shell_tries', 3,
38 'number of times to attempt to run flakey shell commands')39 'number of times to attempt to run flakey shell commands')
40flags.DEFINE_string('num_iscsi_scan_tries', 3,
41 'number of times to rescan iSCSI target to find volume')
39flags.DEFINE_integer('num_shelves',42flags.DEFINE_integer('num_shelves',
40 100,43 100,
41 'Number of vblade shelves')44 'Number of vblade shelves')
@@ -88,7 +91,8 @@
88 % FLAGS.volume_group)91 % FLAGS.volume_group)
8992
90 def create_volume(self, volume):93 def create_volume(self, volume):
91 """Creates a logical volume."""94 """Creates a logical volume. Can optionally return a Dictionary of
95 changes to the volume object to be persisted."""
92 if int(volume['size']) == 0:96 if int(volume['size']) == 0:
93 sizestr = '100M'97 sizestr = '100M'
94 else:98 else:
@@ -123,7 +127,8 @@
123 raise NotImplementedError()127 raise NotImplementedError()
124128
125 def create_export(self, context, volume):129 def create_export(self, context, volume):
126 """Exports the volume."""130 """Exports the volume. Can optionally return a Dictionary of changes
131 to the volume object to be persisted."""
127 raise NotImplementedError()132 raise NotImplementedError()
128133
129 def remove_export(self, context, volume):134 def remove_export(self, context, volume):
@@ -222,7 +227,18 @@
222227
223228
224class ISCSIDriver(VolumeDriver):229class ISCSIDriver(VolumeDriver):
225 """Executes commands relating to ISCSI volumes."""230 """Executes commands relating to ISCSI volumes.
231
232 We make use of model provider properties as follows:
233
234 :provider_location: if present, contains the iSCSI target information
235 in the same format as an ietadm discovery
236 i.e. '<ip>:<port>,<portal> <target IQN>'
237
238 :provider_auth: if present, contains a space-separated triple:
239 '<auth method> <auth username> <auth password>'.
240 `CHAP` is the only auth_method in use at the moment.
241 """
226242
227 def ensure_export(self, context, volume):243 def ensure_export(self, context, volume):
228 """Synchronously recreates an export for a logical volume."""244 """Synchronously recreates an export for a logical volume."""
@@ -294,40 +310,149 @@
294 self._execute("sudo ietadm --op delete --tid=%s" %310 self._execute("sudo ietadm --op delete --tid=%s" %
295 iscsi_target)311 iscsi_target)
296312
297 def _get_name_and_portal(self, volume):313 def _do_iscsi_discovery(self, volume):
298 """Gets iscsi name and portal from volume name and host."""314 #TODO(justinsb): Deprecate discovery and use stored info
315 #NOTE(justinsb): Discovery won't work with CHAP-secured targets (?)
316 LOG.warn(_("ISCSI provider_location not stored, using discovery"))
317
299 volume_name = volume['name']318 volume_name = volume['name']
300 host = volume['host']319
301 (out, _err) = self._execute("sudo iscsiadm -m discovery -t "320 (out, _err) = self._execute("sudo iscsiadm -m discovery -t "
302 "sendtargets -p %s" % host)321 "sendtargets -p %s" % (volume['host']))
303 for target in out.splitlines():322 for target in out.splitlines():
304 if FLAGS.iscsi_ip_prefix in target and volume_name in target:323 if FLAGS.iscsi_ip_prefix in target and volume_name in target:
305 (location, _sep, iscsi_name) = target.partition(" ")324 return target
306 break325 return None
307 iscsi_portal = location.split(",")[0]326
308 return (iscsi_name, iscsi_portal)327 def _get_iscsi_properties(self, volume):
328 """Gets iscsi configuration
329
330 We ideally get saved information in the volume entity, but fall back
331 to discovery if need be. Discovery may be completely removed in future
332 The properties are:
333
334 :target_discovered: boolean indicating whether discovery was used
335
336 :target_iqn: the IQN of the iSCSI target
337
338 :target_portal: the portal of the iSCSI target
339
340 :auth_method:, :auth_username:, :auth_password:
341
342 the authentication details. Right now, either auth_method is not
343 present meaning no authentication, or auth_method == `CHAP`
344 meaning use CHAP with the specified credentials.
345 """
346
347 properties = {}
348
349 location = volume['provider_location']
350
351 if location:
352 # provider_location is the same format as iSCSI discovery output
353 properties['target_discovered'] = False
354 else:
355 location = self._do_iscsi_discovery(volume)
356
357 if not location:
358 raise exception.Error(_("Could not find iSCSI export "
359 " for volume %s") %
360 (volume['name']))
361
362 LOG.debug(_("ISCSI Discovery: Found %s") % (location))
363 properties['target_discovered'] = True
364
365 (iscsi_target, _sep, iscsi_name) = location.partition(" ")
366
367 iscsi_portal = iscsi_target.split(",")[0]
368
369 properties['target_iqn'] = iscsi_name
370 properties['target_portal'] = iscsi_portal
371
372 auth = volume['provider_auth']
373
374 if auth:
375 (auth_method, auth_username, auth_secret) = auth.split()
376
377 properties['auth_method'] = auth_method
378 properties['auth_username'] = auth_username
379 properties['auth_password'] = auth_secret
380
381 return properties
382
383 def _run_iscsiadm(self, iscsi_properties, iscsi_command):
384 command = ("sudo iscsiadm -m node -T %s -p %s %s" %
385 (iscsi_properties['target_iqn'],
386 iscsi_properties['target_portal'],
387 iscsi_command))
388 (out, err) = self._execute(command)
389 LOG.debug("iscsiadm %s: stdout=%s stderr=%s" %
390 (iscsi_command, out, err))
391 return (out, err)
392
393 def _iscsiadm_update(self, iscsi_properties, property_key, property_value):
394 iscsi_command = ("--op update -n %s -v %s" %
395 (property_key, property_value))
396 return self._run_iscsiadm(iscsi_properties, iscsi_command)
309397
310 def discover_volume(self, volume):398 def discover_volume(self, volume):
311 """Discover volume on a remote host."""399 """Discover volume on a remote host."""
312 iscsi_name, iscsi_portal = self._get_name_and_portal(volume)400 iscsi_properties = self._get_iscsi_properties(volume)
313 self._execute("sudo iscsiadm -m node -T %s -p %s --login" %401
314 (iscsi_name, iscsi_portal))402 if not iscsi_properties['target_discovered']:
315 self._execute("sudo iscsiadm -m node -T %s -p %s --op update "403 self._run_iscsiadm(iscsi_properties, "--op new")
316 "-n node.startup -v automatic" %404
317 (iscsi_name, iscsi_portal))405 if iscsi_properties.get('auth_method'):
318 return "/dev/disk/by-path/ip-%s-iscsi-%s-lun-0" % (iscsi_portal,406 self._iscsiadm_update(iscsi_properties,
319 iscsi_name)407 "node.session.auth.authmethod",
408 iscsi_properties['auth_method'])
409 self._iscsiadm_update(iscsi_properties,
410 "node.session.auth.username",
411 iscsi_properties['auth_username'])
412 self._iscsiadm_update(iscsi_properties,
413 "node.session.auth.password",
414 iscsi_properties['auth_password'])
415
416 self._run_iscsiadm(iscsi_properties, "--login")
417
418 self._iscsiadm_update(iscsi_properties, "node.startup", "automatic")
419
420 mount_device = ("/dev/disk/by-path/ip-%s-iscsi-%s-lun-0" %
421 (iscsi_properties['target_portal'],
422 iscsi_properties['target_iqn']))
423
424 # The /dev/disk/by-path/... node is not always present immediately
425 # TODO(justinsb): This retry-with-delay is a pattern, move to utils?
426 tries = 0
427 while not os.path.exists(mount_device):
428 if tries >= FLAGS.num_iscsi_scan_tries:
429 raise exception.Error(_("iSCSI device not found at %s") %
430 (mount_device))
431
432 LOG.warn(_("ISCSI volume not yet found at: %(mount_device)s. "
433 "Will rescan & retry. Try number: %(tries)s") %
434 locals())
435
436 # The rescan isn't documented as being necessary(?), but it helps
437 self._run_iscsiadm(iscsi_properties, "--rescan")
438
439 tries = tries + 1
440 if not os.path.exists(mount_device):
441 time.sleep(tries ** 2)
442
443 if tries != 0:
444 LOG.debug(_("Found iSCSI node %(mount_device)s "
445 "(after %(tries)s rescans)") %
446 locals())
447
448 return mount_device
320449
321 def undiscover_volume(self, volume):450 def undiscover_volume(self, volume):
322 """Undiscover volume on a remote host."""451 """Undiscover volume on a remote host."""
323 iscsi_name, iscsi_portal = self._get_name_and_portal(volume)452 iscsi_properties = self._get_iscsi_properties(volume)
324 self._execute("sudo iscsiadm -m node -T %s -p %s --op update "453 self._iscsiadm_update(iscsi_properties, "node.startup", "manual")
325 "-n node.startup -v manual" %454 self._run_iscsiadm(iscsi_properties, "--logout")
326 (iscsi_name, iscsi_portal))455 self._run_iscsiadm(iscsi_properties, "--op delete")
327 self._execute("sudo iscsiadm -m node -T %s -p %s --logout " %
328 (iscsi_name, iscsi_portal))
329 self._execute("sudo iscsiadm -m node --op delete "
330 "--targetname %s" % iscsi_name)
331456
332457
333class FakeISCSIDriver(ISCSIDriver):458class FakeISCSIDriver(ISCSIDriver):
334459
=== modified file 'nova/volume/manager.py'
--- nova/volume/manager.py 2011-02-15 18:19:52 +0000
+++ nova/volume/manager.py 2011-02-24 06:50:14 +0000
@@ -107,10 +107,14 @@
107 vol_size = volume_ref['size']107 vol_size = volume_ref['size']
108 LOG.debug(_("volume %(vol_name)s: creating lv of"108 LOG.debug(_("volume %(vol_name)s: creating lv of"
109 " size %(vol_size)sG") % locals())109 " size %(vol_size)sG") % locals())
110 self.driver.create_volume(volume_ref)110 model_update = self.driver.create_volume(volume_ref)
111 if model_update:
112 self.db.volume_update(context, volume_ref['id'], model_update)
111113
112 LOG.debug(_("volume %s: creating export"), volume_ref['name'])114 LOG.debug(_("volume %s: creating export"), volume_ref['name'])
113 self.driver.create_export(context, volume_ref)115 model_update = self.driver.create_export(context, volume_ref)
116 if model_update:
117 self.db.volume_update(context, volume_ref['id'], model_update)
114 except Exception:118 except Exception:
115 self.db.volume_update(context,119 self.db.volume_update(context,
116 volume_ref['id'], {'status': 'error'})120 volume_ref['id'], {'status': 'error'})
117121
=== modified file 'nova/volume/san.py'
--- nova/volume/san.py 2011-02-09 19:25:18 +0000
+++ nova/volume/san.py 2011-02-24 06:50:14 +0000
@@ -16,13 +16,16 @@
16# under the License.16# under the License.
17"""17"""
18Drivers for san-stored volumes.18Drivers for san-stored volumes.
19
19The unique thing about a SAN is that we don't expect that we can run the volume20The unique thing about a SAN is that we don't expect that we can run the volume
20 controller on the SAN hardware. We expect to access it over SSH or some API.21controller on the SAN hardware. We expect to access it over SSH or some API.
21"""22"""
2223
23import os24import os
24import paramiko25import paramiko
2526
27from xml.etree import ElementTree
28
26from nova import exception29from nova import exception
27from nova import flags30from nova import flags
28from nova import log as logging31from nova import log as logging
@@ -41,37 +44,19 @@
41 'Password for SAN controller')44 'Password for SAN controller')
42flags.DEFINE_string('san_privatekey', '',45flags.DEFINE_string('san_privatekey', '',
43 'Filename of private key to use for SSH authentication')46 'Filename of private key to use for SSH authentication')
47flags.DEFINE_string('san_clustername', '',
48 'Cluster name to use for creating volumes')
49flags.DEFINE_integer('san_ssh_port', 22,
50 'SSH port to use with SAN')
4451
4552
46class SanISCSIDriver(ISCSIDriver):53class SanISCSIDriver(ISCSIDriver):
47 """ Base class for SAN-style storage volumes54 """ Base class for SAN-style storage volumes
48 (storage providers we access over SSH)"""55
49 #Override because SAN ip != host ip56 A SAN-style storage value is 'different' because the volume controller
50 def _get_name_and_portal(self, volume):57 probably won't run on it, so we need to access is over SSH or another
51 """Gets iscsi name and portal from volume name and host."""58 remote protocol.
52 volume_name = volume['name']59 """
53
54 # TODO(justinsb): store in volume, remerge with generic iSCSI code
55 host = FLAGS.san_ip
56
57 (out, _err) = self._execute("sudo iscsiadm -m discovery -t "
58 "sendtargets -p %s" % host)
59
60 location = None
61 find_iscsi_name = self._build_iscsi_target_name(volume)
62 for target in out.splitlines():
63 if find_iscsi_name in target:
64 (location, _sep, iscsi_name) = target.partition(" ")
65 break
66 if not location:
67 raise exception.Error(_("Could not find iSCSI export "
68 " for volume %s") %
69 volume_name)
70
71 iscsi_portal = location.split(",")[0]
72 LOG.debug("iscsi_name=%s, iscsi_portal=%s" %
73 (iscsi_name, iscsi_portal))
74 return (iscsi_name, iscsi_portal)
7560
76 def _build_iscsi_target_name(self, volume):61 def _build_iscsi_target_name(self, volume):
77 return "%s%s" % (FLAGS.iscsi_target_prefix, volume['name'])62 return "%s%s" % (FLAGS.iscsi_target_prefix, volume['name'])
@@ -85,6 +70,7 @@
85 ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())70 ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
86 if FLAGS.san_password:71 if FLAGS.san_password:
87 ssh.connect(FLAGS.san_ip,72 ssh.connect(FLAGS.san_ip,
73 port=FLAGS.san_ssh_port,
88 username=FLAGS.san_login,74 username=FLAGS.san_login,
89 password=FLAGS.san_password)75 password=FLAGS.san_password)
90 elif FLAGS.san_privatekey:76 elif FLAGS.san_privatekey:
@@ -92,10 +78,11 @@
92 # It sucks that paramiko doesn't support DSA keys78 # It sucks that paramiko doesn't support DSA keys
93 privatekey = paramiko.RSAKey.from_private_key_file(privatekeyfile)79 privatekey = paramiko.RSAKey.from_private_key_file(privatekeyfile)
94 ssh.connect(FLAGS.san_ip,80 ssh.connect(FLAGS.san_ip,
81 port=FLAGS.san_ssh_port,
95 username=FLAGS.san_login,82 username=FLAGS.san_login,
96 pkey=privatekey)83 pkey=privatekey)
97 else:84 else:
98 raise exception.Error("Specify san_password or san_privatekey")85 raise exception.Error(_("Specify san_password or san_privatekey"))
99 return ssh86 return ssh
10087
101 def _run_ssh(self, command, check_exit_code=True):88 def _run_ssh(self, command, check_exit_code=True):
@@ -124,10 +111,10 @@
124 def check_for_setup_error(self):111 def check_for_setup_error(self):
125 """Returns an error if prerequisites aren't met"""112 """Returns an error if prerequisites aren't met"""
126 if not (FLAGS.san_password or FLAGS.san_privatekey):113 if not (FLAGS.san_password or FLAGS.san_privatekey):
127 raise exception.Error("Specify san_password or san_privatekey")114 raise exception.Error(_("Specify san_password or san_privatekey"))
128115
129 if not (FLAGS.san_ip):116 if not (FLAGS.san_ip):
130 raise exception.Error("san_ip must be set")117 raise exception.Error(_("san_ip must be set"))
131118
132119
133def _collect_lines(data):120def _collect_lines(data):
@@ -155,17 +142,27 @@
155142
156class SolarisISCSIDriver(SanISCSIDriver):143class SolarisISCSIDriver(SanISCSIDriver):
157 """Executes commands relating to Solaris-hosted ISCSI volumes.144 """Executes commands relating to Solaris-hosted ISCSI volumes.
145
158 Basic setup for a Solaris iSCSI server:146 Basic setup for a Solaris iSCSI server:
147
159 pkg install storage-server SUNWiscsit148 pkg install storage-server SUNWiscsit
149
160 svcadm enable stmf150 svcadm enable stmf
151
161 svcadm enable -r svc:/network/iscsi/target:default152 svcadm enable -r svc:/network/iscsi/target:default
153
162 pfexec itadm create-tpg e1000g0 ${MYIP}154 pfexec itadm create-tpg e1000g0 ${MYIP}
155
163 pfexec itadm create-target -t e1000g0156 pfexec itadm create-target -t e1000g0
164157
158
165 Then grant the user that will be logging on lots of permissions.159 Then grant the user that will be logging on lots of permissions.
166 I'm not sure exactly which though:160 I'm not sure exactly which though:
161
167 zfs allow justinsb create,mount,destroy rpool162 zfs allow justinsb create,mount,destroy rpool
163
168 usermod -P'File System Management' justinsb164 usermod -P'File System Management' justinsb
165
169 usermod -P'Primary Administrator' justinsb166 usermod -P'Primary Administrator' justinsb
170167
171 Also make sure you can login using san_login & san_password/san_privatekey168 Also make sure you can login using san_login & san_password/san_privatekey
@@ -306,6 +303,17 @@
306 self._run_ssh("pfexec /usr/sbin/stmfadm add-view -t %s %s" %303 self._run_ssh("pfexec /usr/sbin/stmfadm add-view -t %s %s" %
307 (target_group_name, luid))304 (target_group_name, luid))
308305
306 #TODO(justinsb): Is this always 1? Does it matter?
307 iscsi_portal_interface = '1'
308 iscsi_portal = FLAGS.san_ip + ":3260," + iscsi_portal_interface
309
310 db_update = {}
311 db_update['provider_location'] = ("%s %s" %
312 (iscsi_portal,
313 iscsi_name))
314
315 return db_update
316
309 def remove_export(self, context, volume):317 def remove_export(self, context, volume):
310 """Removes an export for a logical volume."""318 """Removes an export for a logical volume."""
311319
@@ -333,3 +341,245 @@
333 if self._is_lu_created(volume):341 if self._is_lu_created(volume):
334 self._run_ssh("pfexec /usr/sbin/sbdadm delete-lu %s" %342 self._run_ssh("pfexec /usr/sbin/sbdadm delete-lu %s" %
335 (luid))343 (luid))
344
345
346class HpSanISCSIDriver(SanISCSIDriver):
347 """Executes commands relating to HP/Lefthand SAN ISCSI volumes.
348
349 We use the CLIQ interface, over SSH.
350
351 Rough overview of CLIQ commands used:
352
353 :createVolume: (creates the volume)
354
355 :getVolumeInfo: (to discover the IQN etc)
356
357 :getClusterInfo: (to discover the iSCSI target IP address)
358
359 :assignVolumeChap: (exports it with CHAP security)
360
361 The 'trick' here is that the HP SAN enforces security by default, so
362 normally a volume mount would need both to configure the SAN in the volume
363 layer and do the mount on the compute layer. Multi-layer operations are
364 not catered for at the moment in the nova architecture, so instead we
365 share the volume using CHAP at volume creation time. Then the mount need
366 only use those CHAP credentials, so can take place exclusively in the
367 compute layer.
368 """
369
370 def _cliq_run(self, verb, cliq_args):
371 """Runs a CLIQ command over SSH, without doing any result parsing"""
372 cliq_arg_strings = []
373 for k, v in cliq_args.items():
374 cliq_arg_strings.append(" %s=%s" % (k, v))
375 cmd = verb + ''.join(cliq_arg_strings)
376
377 return self._run_ssh(cmd)
378
379 def _cliq_run_xml(self, verb, cliq_args, check_cliq_result=True):
380 """Runs a CLIQ command over SSH, parsing and checking the output"""
381 cliq_args['output'] = 'XML'
382 (out, _err) = self._cliq_run(verb, cliq_args)
383
384 LOG.debug(_("CLIQ command returned %s"), out)
385
386 result_xml = ElementTree.fromstring(out)
387 if check_cliq_result:
388 response_node = result_xml.find("response")
389 if response_node is None:
390 msg = (_("Malformed response to CLIQ command "
391 "%(verb)s %(cliq_args)s. Result=%(out)s") %
392 locals())
393 raise exception.Error(msg)
394
395 result_code = response_node.attrib.get("result")
396
397 if result_code != "0":
398 msg = (_("Error running CLIQ command %(verb)s %(cliq_args)s. "
399 " Result=%(out)s") %
400 locals())
401 raise exception.Error(msg)
402
403 return result_xml
404
405 def _cliq_get_cluster_info(self, cluster_name):
406 """Queries for info about the cluster (including IP)"""
407 cliq_args = {}
408 cliq_args['clusterName'] = cluster_name
409 cliq_args['searchDepth'] = '1'
410 cliq_args['verbose'] = '0'
411
412 result_xml = self._cliq_run_xml("getClusterInfo", cliq_args)
413
414 return result_xml
415
416 def _cliq_get_cluster_vip(self, cluster_name):
417 """Gets the IP on which a cluster shares iSCSI volumes"""
418 cluster_xml = self._cliq_get_cluster_info(cluster_name)
419
420 vips = []
421 for vip in cluster_xml.findall("response/cluster/vip"):
422 vips.append(vip.attrib.get('ipAddress'))
423
424 if len(vips) == 1:
425 return vips[0]
426
427 _xml = ElementTree.tostring(cluster_xml)
428 msg = (_("Unexpected number of virtual ips for cluster "
429 " %(cluster_name)s. Result=%(_xml)s") %
430 locals())
431 raise exception.Error(msg)
432
433 def _cliq_get_volume_info(self, volume_name):
434 """Gets the volume info, including IQN"""
435 cliq_args = {}
436 cliq_args['volumeName'] = volume_name
437 result_xml = self._cliq_run_xml("getVolumeInfo", cliq_args)
438
439 # Result looks like this:
440 #<gauche version="1.0">
441 # <response description="Operation succeeded." name="CliqSuccess"
442 # processingTime="87" result="0">
443 # <volume autogrowPages="4" availability="online" blockSize="1024"
444 # bytesWritten="0" checkSum="false" clusterName="Cluster01"
445 # created="2011-02-08T19:56:53Z" deleting="false" description=""
446 # groupName="Group01" initialQuota="536870912" isPrimary="true"
447 # iscsiIqn="iqn.2003-10.com.lefthandnetworks:group01:25366:vol-b"
448 # maxSize="6865387257856" md5="9fa5c8b2cca54b2948a63d833097e1ca"
449 # minReplication="1" name="vol-b" parity="0" replication="2"
450 # reserveQuota="536870912" scratchQuota="4194304"
451 # serialNumber="9fa5c8b2cca54b2948a63d833097e1ca0000000000006316"
452 # size="1073741824" stridePages="32" thinProvision="true">
453 # <status description="OK" value="2"/>
454 # <permission access="rw"
455 # authGroup="api-34281B815713B78-(trimmed)51ADD4B7030853AA7"
456 # chapName="chapusername" chapRequired="true" id="25369"
457 # initiatorSecret="" iqn="" iscsiEnabled="true"
458 # loadBalance="true" targetSecret="supersecret"/>
459 # </volume>
460 # </response>
461 #</gauche>
462
463 # Flatten the nodes into a dictionary; use prefixes to avoid collisions
464 volume_attributes = {}
465
466 volume_node = result_xml.find("response/volume")
467 for k, v in volume_node.attrib.items():
468 volume_attributes["volume." + k] = v
469
470 status_node = volume_node.find("status")
471 if not status_node is None:
472 for k, v in status_node.attrib.items():
473 volume_attributes["status." + k] = v
474
475 # We only consider the first permission node
476 permission_node = volume_node.find("permission")
477 if not permission_node is None:
478 for k, v in status_node.attrib.items():
479 volume_attributes["permission." + k] = v
480
481 LOG.debug(_("Volume info: %(volume_name)s => %(volume_attributes)s") %
482 locals())
483 return volume_attributes
484
485 def create_volume(self, volume):
486 """Creates a volume."""
487 cliq_args = {}
488 cliq_args['clusterName'] = FLAGS.san_clustername
489 #TODO(justinsb): Should we default to inheriting thinProvision?
490 cliq_args['thinProvision'] = '1' if FLAGS.san_thin_provision else '0'
491 cliq_args['volumeName'] = volume['name']
492 if int(volume['size']) == 0:
493 cliq_args['size'] = '100MB'
494 else:
495 cliq_args['size'] = '%sGB' % volume['size']
496
497 self._cliq_run_xml("createVolume", cliq_args)
498
499 volume_info = self._cliq_get_volume_info(volume['name'])
500 cluster_name = volume_info['volume.clusterName']
501 iscsi_iqn = volume_info['volume.iscsiIqn']
502
503 #TODO(justinsb): Is this always 1? Does it matter?
504 cluster_interface = '1'
505
506 cluster_vip = self._cliq_get_cluster_vip(cluster_name)
507 iscsi_portal = cluster_vip + ":3260," + cluster_interface
508
509 model_update = {}
510 model_update['provider_location'] = ("%s %s" %
511 (iscsi_portal,
512 iscsi_iqn))
513
514 return model_update
515
516 def delete_volume(self, volume):
517 """Deletes a volume."""
518 cliq_args = {}
519 cliq_args['volumeName'] = volume['name']
520 cliq_args['prompt'] = 'false' # Don't confirm
521
522 self._cliq_run_xml("deleteVolume", cliq_args)
523
524 def local_path(self, volume):
525 # TODO(justinsb): Is this needed here?
526 raise exception.Error(_("local_path not supported"))
527
528 def ensure_export(self, context, volume):
529 """Synchronously recreates an export for a logical volume."""
530 return self._do_export(context, volume, force_create=False)
531
532 def create_export(self, context, volume):
533 return self._do_export(context, volume, force_create=True)
534
535 def _do_export(self, context, volume, force_create):
536 """Supports ensure_export and create_export"""
537 volume_info = self._cliq_get_volume_info(volume['name'])
538
539 is_shared = 'permission.authGroup' in volume_info
540
541 model_update = {}
542
543 should_export = False
544
545 if force_create or not is_shared:
546 should_export = True
547 # Check that we have a project_id
548 project_id = volume['project_id']
549 if not project_id:
550 project_id = context.project_id
551
552 if project_id:
553 #TODO(justinsb): Use a real per-project password here
554 chap_username = 'proj_' + project_id
555 # HP/Lefthand requires that the password be >= 12 characters
556 chap_password = 'project_secret_' + project_id
557 else:
558 msg = (_("Could not determine project for volume %s, "
559 "can't export") %
560 (volume['name']))
561 if force_create:
562 raise exception.Error(msg)
563 else:
564 LOG.warn(msg)
565 should_export = False
566
567 if should_export:
568 cliq_args = {}
569 cliq_args['volumeName'] = volume['name']
570 cliq_args['chapName'] = chap_username
571 cliq_args['targetSecret'] = chap_password
572
573 self._cliq_run_xml("assignVolumeChap", cliq_args)
574
575 model_update['provider_auth'] = ("CHAP %s %s" %
576 (chap_username, chap_password))
577
578 return model_update
579
580 def remove_export(self, context, volume):
581 """Removes an export for a logical volume."""
582 cliq_args = {}
583 cliq_args['volumeName'] = volume['name']
584
585 self._cliq_run_xml("unassignVolume", cliq_args)