1
# vim: tabstop=4 shiftwidth=4 softtabstop=4
3
# Copyright 2011 Justin Santa Barbara
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
10
# http://www.apache.org/licenses/LICENSE-2.0
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
18
Drivers for san-stored volumes.
20
The unique thing about a SAN is that we don't expect that we can run the volume
21
controller on the SAN hardware. We expect to access it over SSH or some API.
33
from lxml import etree
35
from cinder import exception
36
from cinder import flags
37
from cinder.openstack.common import log as logging
38
from cinder.openstack.common import cfg
39
from cinder.openstack.common import jsonutils
40
from cinder import utils
41
import cinder.volume.driver
44
LOG = logging.getLogger(__name__)
47
cfg.BoolOpt('san_thin_provision',
49
help='Use thin provisioning for SAN volumes?'),
52
help='IP address of SAN controller'),
53
cfg.StrOpt('san_login',
55
help='Username for SAN controller'),
56
cfg.StrOpt('san_password',
58
help='Password for SAN controller'),
59
cfg.StrOpt('san_private_key',
61
help='Filename of private key to use for SSH authentication'),
62
cfg.StrOpt('san_clustername',
64
help='Cluster name to use for creating volumes'),
65
cfg.IntOpt('san_ssh_port',
67
help='SSH port to use with SAN'),
68
cfg.BoolOpt('san_is_local',
70
help='Execute commands locally instead of over SSH; '
71
'use if the volume service is running on the SAN device'),
72
cfg.StrOpt('san_zfs_volume_base',
74
help='The ZFS path under which to create zvols for volumes.'),
78
FLAGS.register_opts(san_opts)
81
class SanISCSIDriver(cinder.volume.driver.ISCSIDriver):
82
"""Base class for SAN-style storage volumes
84
A SAN-style storage value is 'different' because the volume controller
85
probably won't run on it, so we need to access is over SSH or another
89
def __init__(self, *args, **kwargs):
90
super(SanISCSIDriver, self).__init__(*args, **kwargs)
91
self.run_local = FLAGS.san_is_local
93
def _build_iscsi_target_name(self, volume):
94
return "%s%s" % (FLAGS.iscsi_target_prefix, volume['name'])
96
def _connect_to_ssh(self):
97
ssh = paramiko.SSHClient()
98
#TODO(justinsb): We need a better SSH key policy
99
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
100
if FLAGS.san_password:
101
ssh.connect(FLAGS.san_ip,
102
port=FLAGS.san_ssh_port,
103
username=FLAGS.san_login,
104
password=FLAGS.san_password)
105
elif FLAGS.san_private_key:
106
privatekeyfile = os.path.expanduser(FLAGS.san_private_key)
107
# It sucks that paramiko doesn't support DSA keys
108
privatekey = paramiko.RSAKey.from_private_key_file(privatekeyfile)
109
ssh.connect(FLAGS.san_ip,
110
port=FLAGS.san_ssh_port,
111
username=FLAGS.san_login,
114
msg = _("Specify san_password or san_private_key")
115
raise exception.InvalidInput(reason=msg)
118
def _execute(self, *cmd, **kwargs):
120
return utils.execute(*cmd, **kwargs)
122
check_exit_code = kwargs.pop('check_exit_code', None)
123
command = ' '.join(cmd)
124
return self._run_ssh(command, check_exit_code)
126
def _run_ssh(self, command, check_exit_code=True):
127
#TODO(justinsb): SSH connection caching (?)
128
ssh = self._connect_to_ssh()
130
#TODO(justinsb): Reintroduce the retry hack
131
ret = utils.ssh_execute(ssh, command, check_exit_code=check_exit_code)
137
def ensure_export(self, context, volume):
138
"""Synchronously recreates an export for a logical volume."""
141
def create_export(self, context, volume):
142
"""Exports the volume."""
145
def remove_export(self, context, volume):
146
"""Removes an export for a logical volume."""
149
def check_for_setup_error(self):
150
"""Returns an error if prerequisites aren't met."""
151
if not self.run_local:
152
if not (FLAGS.san_password or FLAGS.san_private_key):
153
raise exception.InvalidInput(
154
reason=_('Specify san_password or san_private_key'))
156
# The san_ip must always be set, because we use it for the target
157
if not (FLAGS.san_ip):
158
raise exception.InvalidInput(reason=_("san_ip must be set"))
161
def _collect_lines(data):
162
"""Split lines from data into an array, trimming them """
164
for line in data.splitlines():
166
matches.append(match)
171
def _get_prefixed_values(data, prefix):
172
"""Collect lines which start with prefix; with trimming"""
174
for line in data.splitlines():
176
if line.startswith(prefix):
177
match = line[len(prefix):]
178
match = match.strip()
179
matches.append(match)
184
class SolarisISCSIDriver(SanISCSIDriver):
185
"""Executes commands relating to Solaris-hosted ISCSI volumes.
187
Basic setup for a Solaris iSCSI server:
189
pkg install storage-server SUNWiscsit
193
svcadm enable -r svc:/network/iscsi/target:default
195
pfexec itadm create-tpg e1000g0 ${MYIP}
197
pfexec itadm create-target -t e1000g0
200
Then grant the user that will be logging on lots of permissions.
201
I'm not sure exactly which though:
203
zfs allow justinsb create,mount,destroy rpool
205
usermod -P'File System Management' justinsb
207
usermod -P'Primary Administrator' justinsb
209
Also make sure you can login using san_login & san_password/san_private_key
211
def __init__(self, *cmd, **kwargs):
212
super(SolarisISCSIDriver, self).__init__(*cmd,
213
execute=self._execute,
216
def _execute(self, *cmd, **kwargs):
219
return super(SolarisISCSIDriver, self)._execute(*new_cmd,
222
def _view_exists(self, luid):
223
(out, _err) = self._execute('/usr/sbin/stmfadm',
224
'list-view', '-l', luid,
225
check_exit_code=False)
226
if "no views found" in out:
229
if "View Entry:" in out:
231
msg = _("Cannot parse list-view output: %s") % out
232
raise exception.VolumeBackendAPIException(data=msg)
234
def _get_target_groups(self):
235
"""Gets list of target groups from host."""
236
(out, _err) = self._execute('/usr/sbin/stmfadm', 'list-tg')
237
matches = _get_prefixed_values(out, 'Target group: ')
238
LOG.debug("target_groups=%s" % matches)
241
def _target_group_exists(self, target_group_name):
242
return target_group_name not in self._get_target_groups()
244
def _get_target_group_members(self, target_group_name):
245
(out, _err) = self._execute('/usr/sbin/stmfadm',
246
'list-tg', '-v', target_group_name)
247
matches = _get_prefixed_values(out, 'Member: ')
248
LOG.debug("members of %s=%s" % (target_group_name, matches))
251
def _is_target_group_member(self, target_group_name, iscsi_target_name):
252
return iscsi_target_name in (
253
self._get_target_group_members(target_group_name))
255
def _get_iscsi_targets(self):
256
(out, _err) = self._execute('/usr/sbin/itadm', 'list-target')
257
matches = _collect_lines(out)
260
if len(matches) != 0:
261
assert 'TARGET NAME' in matches[0]
262
matches = matches[1:]
267
assert len(items) == 3
268
targets.append(items[0])
270
LOG.debug("_get_iscsi_targets=%s" % (targets))
273
def _iscsi_target_exists(self, iscsi_target_name):
274
return iscsi_target_name in self._get_iscsi_targets()
276
def _build_zfs_poolname(self, volume):
277
zfs_poolname = '%s%s' % (FLAGS.san_zfs_volume_base, volume['name'])
280
def create_volume(self, volume):
281
"""Creates a volume."""
282
if int(volume['size']) == 0:
285
sizestr = '%sG' % volume['size']
287
zfs_poolname = self._build_zfs_poolname(volume)
289
# Create a zfs volume
290
cmd = ['/usr/sbin/zfs', 'create']
291
if FLAGS.san_thin_provision:
293
cmd.extend(['-V', sizestr])
294
cmd.append(zfs_poolname)
297
def _get_luid(self, volume):
298
zfs_poolname = self._build_zfs_poolname(volume)
299
zvol_name = '/dev/zvol/rdsk/%s' % zfs_poolname
301
(out, _err) = self._execute('/usr/sbin/sbdadm', 'list-lu')
303
lines = _collect_lines(out)
311
assert 'Found' in lines[0]
312
assert '' == lines[1]
313
assert 'GUID' in lines[2]
314
assert '------------------' in lines[3]
320
assert len(items) == 3
321
if items[2] == zvol_name:
322
luid = items[0].strip()
325
msg = _('LUID not found for %(zfs_poolname)s. '
326
'Output=%(out)s') % locals()
327
raise exception.VolumeBackendAPIException(data=msg)
329
def _is_lu_created(self, volume):
330
luid = self._get_luid(volume)
333
def delete_volume(self, volume):
334
"""Deletes a volume."""
335
zfs_poolname = self._build_zfs_poolname(volume)
336
self._execute('/usr/sbin/zfs', 'destroy', zfs_poolname)
338
def local_path(self, volume):
339
# TODO(justinsb): Is this needed here?
340
escaped_group = FLAGS.volume_group.replace('-', '--')
341
escaped_name = volume['name'].replace('-', '--')
342
return "/dev/mapper/%s-%s" % (escaped_group, escaped_name)
344
def ensure_export(self, context, volume):
345
"""Synchronously recreates an export for a logical volume."""
346
#TODO(justinsb): On bootup, this is called for every volume.
347
# It then runs ~5 SSH commands for each volume,
348
# most of which fetch the same info each time
349
# This makes initial start stupid-slow
350
return self._do_export(volume, force_create=False)
352
def create_export(self, context, volume):
353
return self._do_export(volume, force_create=True)
355
def _do_export(self, volume, force_create):
356
# Create a Logical Unit (LU) backed by the zfs volume
357
zfs_poolname = self._build_zfs_poolname(volume)
359
if force_create or not self._is_lu_created(volume):
360
zvol_name = '/dev/zvol/rdsk/%s' % zfs_poolname
361
self._execute('/usr/sbin/sbdadm', 'create-lu', zvol_name)
363
luid = self._get_luid(volume)
364
iscsi_name = self._build_iscsi_target_name(volume)
365
target_group_name = 'tg-%s' % volume['name']
367
# Create a iSCSI target, mapped to just this volume
368
if force_create or not self._target_group_exists(target_group_name):
369
self._execute('/usr/sbin/stmfadm', 'create-tg', target_group_name)
371
# Yes, we add the initiatior before we create it!
372
# Otherwise, it complains that the target is already active
373
if force_create or not self._is_target_group_member(target_group_name,
375
self._execute('/usr/sbin/stmfadm',
376
'add-tg-member', '-g', target_group_name, iscsi_name)
378
if force_create or not self._iscsi_target_exists(iscsi_name):
379
self._execute('/usr/sbin/itadm', 'create-target', '-n', iscsi_name)
381
if force_create or not self._view_exists(luid):
382
self._execute('/usr/sbin/stmfadm',
383
'add-view', '-t', target_group_name, luid)
385
#TODO(justinsb): Is this always 1? Does it matter?
386
iscsi_portal_interface = '1'
387
iscsi_portal = FLAGS.san_ip + ":3260," + iscsi_portal_interface
390
db_update['provider_location'] = ("%s %s" %
396
def remove_export(self, context, volume):
397
"""Removes an export for a logical volume."""
399
# This is the reverse of _do_export
400
luid = self._get_luid(volume)
401
iscsi_name = self._build_iscsi_target_name(volume)
402
target_group_name = 'tg-%s' % volume['name']
404
if self._view_exists(luid):
405
self._execute('/usr/sbin/stmfadm', 'remove-view', '-l', luid, '-a')
407
if self._iscsi_target_exists(iscsi_name):
408
self._execute('/usr/sbin/stmfadm', 'offline-target', iscsi_name)
409
self._execute('/usr/sbin/itadm', 'delete-target', iscsi_name)
411
# We don't delete the tg-member; we delete the whole tg!
413
if self._target_group_exists(target_group_name):
414
self._execute('/usr/sbin/stmfadm', 'delete-tg', target_group_name)
416
if self._is_lu_created(volume):
417
self._execute('/usr/sbin/sbdadm', 'delete-lu', luid)
420
class HpSanISCSIDriver(SanISCSIDriver):
421
"""Executes commands relating to HP/Lefthand SAN ISCSI volumes.
423
We use the CLIQ interface, over SSH.
425
Rough overview of CLIQ commands used:
427
:createVolume: (creates the volume)
429
:getVolumeInfo: (to discover the IQN etc)
431
:getClusterInfo: (to discover the iSCSI target IP address)
433
:assignVolumeChap: (exports it with CHAP security)
435
The 'trick' here is that the HP SAN enforces security by default, so
436
normally a volume mount would need both to configure the SAN in the volume
437
layer and do the mount on the compute layer. Multi-layer operations are
438
not catered for at the moment in the cinder architecture, so instead we
439
share the volume using CHAP at volume creation time. Then the mount need
440
only use those CHAP credentials, so can take place exclusively in the
444
def _cliq_run(self, verb, cliq_args):
445
"""Runs a CLIQ command over SSH, without doing any result parsing"""
446
cliq_arg_strings = []
447
for k, v in cliq_args.items():
448
cliq_arg_strings.append(" %s=%s" % (k, v))
449
cmd = verb + ''.join(cliq_arg_strings)
451
return self._run_ssh(cmd)
453
def _cliq_run_xml(self, verb, cliq_args, check_cliq_result=True):
454
"""Runs a CLIQ command over SSH, parsing and checking the output"""
455
cliq_args['output'] = 'XML'
456
(out, _err) = self._cliq_run(verb, cliq_args)
458
LOG.debug(_("CLIQ command returned %s"), out)
460
result_xml = etree.fromstring(out)
461
if check_cliq_result:
462
response_node = result_xml.find("response")
463
if response_node is None:
464
msg = (_("Malformed response to CLIQ command "
465
"%(verb)s %(cliq_args)s. Result=%(out)s") %
467
raise exception.VolumeBackendAPIException(data=msg)
469
result_code = response_node.attrib.get("result")
471
if result_code != "0":
472
msg = (_("Error running CLIQ command %(verb)s %(cliq_args)s. "
475
raise exception.VolumeBackendAPIException(data=msg)
479
def _cliq_get_cluster_info(self, cluster_name):
480
"""Queries for info about the cluster (including IP)"""
482
cliq_args['clusterName'] = cluster_name
483
cliq_args['searchDepth'] = '1'
484
cliq_args['verbose'] = '0'
486
result_xml = self._cliq_run_xml("getClusterInfo", cliq_args)
490
def _cliq_get_cluster_vip(self, cluster_name):
491
"""Gets the IP on which a cluster shares iSCSI volumes"""
492
cluster_xml = self._cliq_get_cluster_info(cluster_name)
495
for vip in cluster_xml.findall("response/cluster/vip"):
496
vips.append(vip.attrib.get('ipAddress'))
501
_xml = etree.tostring(cluster_xml)
502
msg = (_("Unexpected number of virtual ips for cluster "
503
" %(cluster_name)s. Result=%(_xml)s") %
505
raise exception.VolumeBackendAPIException(data=msg)
507
def _cliq_get_volume_info(self, volume_name):
508
"""Gets the volume info, including IQN"""
510
cliq_args['volumeName'] = volume_name
511
result_xml = self._cliq_run_xml("getVolumeInfo", cliq_args)
513
# Result looks like this:
514
#<gauche version="1.0">
515
# <response description="Operation succeeded." name="CliqSuccess"
516
# processingTime="87" result="0">
517
# <volume autogrowPages="4" availability="online" blockSize="1024"
518
# bytesWritten="0" checkSum="false" clusterName="Cluster01"
519
# created="2011-02-08T19:56:53Z" deleting="false" description=""
520
# groupName="Group01" initialQuota="536870912" isPrimary="true"
521
# iscsiIqn="iqn.2003-10.com.lefthandnetworks:group01:25366:vol-b"
522
# maxSize="6865387257856" md5="9fa5c8b2cca54b2948a63d833097e1ca"
523
# minReplication="1" name="vol-b" parity="0" replication="2"
524
# reserveQuota="536870912" scratchQuota="4194304"
525
# serialNumber="9fa5c8b2cca54b2948a63d833097e1ca0000000000006316"
526
# size="1073741824" stridePages="32" thinProvision="true">
527
# <status description="OK" value="2"/>
528
# <permission access="rw"
529
# authGroup="api-34281B815713B78-(trimmed)51ADD4B7030853AA7"
530
# chapName="chapusername" chapRequired="true" id="25369"
531
# initiatorSecret="" iqn="" iscsiEnabled="true"
532
# loadBalance="true" targetSecret="supersecret"/>
537
# Flatten the nodes into a dictionary; use prefixes to avoid collisions
538
volume_attributes = {}
540
volume_node = result_xml.find("response/volume")
541
for k, v in volume_node.attrib.items():
542
volume_attributes["volume." + k] = v
544
status_node = volume_node.find("status")
545
if not status_node is None:
546
for k, v in status_node.attrib.items():
547
volume_attributes["status." + k] = v
549
# We only consider the first permission node
550
permission_node = volume_node.find("permission")
551
if not permission_node is None:
552
for k, v in status_node.attrib.items():
553
volume_attributes["permission." + k] = v
555
LOG.debug(_("Volume info: %(volume_name)s => %(volume_attributes)s") %
557
return volume_attributes
559
def create_volume(self, volume):
560
"""Creates a volume."""
562
cliq_args['clusterName'] = FLAGS.san_clustername
563
#TODO(justinsb): Should we default to inheriting thinProvision?
564
cliq_args['thinProvision'] = '1' if FLAGS.san_thin_provision else '0'
565
cliq_args['volumeName'] = volume['name']
566
if int(volume['size']) == 0:
567
cliq_args['size'] = '100MB'
569
cliq_args['size'] = '%sGB' % volume['size']
571
self._cliq_run_xml("createVolume", cliq_args)
573
volume_info = self._cliq_get_volume_info(volume['name'])
574
cluster_name = volume_info['volume.clusterName']
575
iscsi_iqn = volume_info['volume.iscsiIqn']
577
#TODO(justinsb): Is this always 1? Does it matter?
578
cluster_interface = '1'
580
cluster_vip = self._cliq_get_cluster_vip(cluster_name)
581
iscsi_portal = cluster_vip + ":3260," + cluster_interface
585
# NOTE(jdg): LH volumes always at lun 0 ?
586
model_update['provider_location'] = ("%s %s %s" %
593
def create_volume_from_snapshot(self, volume, snapshot):
594
"""Creates a volume from a snapshot."""
595
raise NotImplementedError()
597
def create_snapshot(self, snapshot):
598
"""Creates a snapshot."""
599
raise NotImplementedError()
601
def delete_volume(self, volume):
602
"""Deletes a volume."""
604
cliq_args['volumeName'] = volume['name']
605
cliq_args['prompt'] = 'false' # Don't confirm
607
self._cliq_run_xml("deleteVolume", cliq_args)
609
def local_path(self, volume):
610
# TODO(justinsb): Is this needed here?
611
msg = _("local_path not supported")
612
raise exception.VolumeBackendAPIException(data=msg)
614
def initialize_connection(self, volume, connector):
615
"""Assigns the volume to a server.
617
Assign any created volume to a compute node/host so that it can be
618
used from that host. HP VSA requires a volume to be assigned
621
This driver returns a driver_volume_type of 'iscsi'.
622
The format of the driver data is defined in _get_iscsi_properties.
623
Example return value:
626
'driver_volume_type': 'iscsi'
628
'target_discovered': True,
629
'target_iqn': 'iqn.2010-10.org.openstack:volume-00000001',
630
'target_protal': '127.0.0.1:3260',
637
cliq_args['volumeName'] = volume['name']
638
cliq_args['serverName'] = connector['host']
639
self._cliq_run_xml("assignVolumeToServer", cliq_args)
641
iscsi_properties = self._get_iscsi_properties(volume)
643
'driver_volume_type': 'iscsi',
644
'data': iscsi_properties
647
def terminate_connection(self, volume, connector):
648
"""Unassign the volume from the host."""
650
cliq_args['volumeName'] = volume['name']
651
cliq_args['serverName'] = connector['host']
652
self._cliq_run_xml("unassignVolumeToServer", cliq_args)