1# Copyright (c) 2012 - 2014 EMC Corporation. 2# All Rights Reserved. 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); you may 5# not use this file except in compliance with the License. You may obtain 6# a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13# License for the specific language governing permissions and limitations 14# under the License. 15""" 16Driver for Dell EMC XtremIO Storage. 17supported XtremIO version 2.4 and up 18 19.. code-block:: none 20 21 1.0.0 - initial release 22 1.0.1 - enable volume extend 23 1.0.2 - added FC support, improved error handling 24 1.0.3 - update logging level, add translation 25 1.0.4 - support for FC zones 26 1.0.5 - add support for XtremIO 4.0 27 1.0.6 - add support for iSCSI multipath, CA validation, consistency groups, 28 R/O snapshots, CHAP discovery authentication 29 1.0.7 - cache glance images on the array 30 1.0.8 - support for volume retype, CG fixes 31 1.0.9 - performance improvements, support force detach, support for X2 32 1.0.10 - option to clean unused IGs 33""" 34 35import json 36import math 37import random 38import requests 39import string 40 41from oslo_config import cfg 42from oslo_log import log as logging 43from oslo_utils import strutils 44from oslo_utils import units 45import six 46from six.moves import http_client 47 48from cinder import context 49from cinder import exception 50from cinder.i18n import _ 51from cinder import interface 52from cinder.objects import fields 53from cinder import utils 54from cinder.volume import configuration 55from cinder.volume import driver 56from cinder.volume.drivers.san import san 57from cinder.volume import utils as vutils 58from cinder.zonemanager import utils as fczm_utils 59 60 61LOG = logging.getLogger(__name__) 62 63CONF = cfg.CONF 64DEFAULT_PROVISIONING_FACTOR = 20.0 65XTREMIO_OPTS = [ 66 cfg.StrOpt('xtremio_cluster_name', 67 default='', 68 help='XMS cluster id in multi-cluster environment'), 69 cfg.IntOpt('xtremio_array_busy_retry_count', 70 default=5, 71 help='Number of retries in case array is busy'), 72 cfg.IntOpt('xtremio_array_busy_retry_interval', 73 default=5, 74 help='Interval between retries in case array is busy'), 75 cfg.IntOpt('xtremio_volumes_per_glance_cache', 76 default=100, 77 help='Number of volumes created from each cached glance image'), 78 cfg.BoolOpt('xtremio_clean_unused_ig', 79 default=False, 80 help='Should the driver remove initiator groups with no ' 81 'volumes after the last connection was terminated. ' 82 'Since the behavior till now was to leave ' 83 'the IG be, we default to False (not deleting IGs ' 84 'without connected volumes); setting this parameter ' 85 'to True will remove any IG after terminating its ' 86 'connection to the last volume.')] 87 88CONF.register_opts(XTREMIO_OPTS, group=configuration.SHARED_CONF_GROUP) 89 90RANDOM = random.Random() 91OBJ_NOT_FOUND_ERR = 'obj_not_found' 92VOL_NOT_UNIQUE_ERR = 'vol_obj_name_not_unique' 93VOL_OBJ_NOT_FOUND_ERR = 'vol_obj_not_found' 94ALREADY_MAPPED_ERR = 'already_mapped' 95SYSTEM_BUSY = 'system_is_busy' 96TOO_MANY_OBJECTS = 'too_many_objs' 97TOO_MANY_SNAPSHOTS_PER_VOL = 'too_many_snapshots_per_vol' 98 99 100XTREMIO_OID_NAME = 1 101XTREMIO_OID_INDEX = 2 102 103 104class XtremIOClient(object): 105 def __init__(self, configuration, cluster_id): 106 self.configuration = configuration 107 self.cluster_id = cluster_id 108 self.verify = (self.configuration. 109 safe_get('driver_ssl_cert_verify') or False) 110 if self.verify: 111 verify_path = (self.configuration. 112 safe_get('driver_ssl_cert_path') or None) 113 if verify_path: 114 self.verify = verify_path 115 116 def get_base_url(self, ver): 117 if ver == 'v1': 118 return 'https://%s/api/json/types' % self.configuration.san_ip 119 elif ver == 'v2': 120 return 'https://%s/api/json/v2/types' % self.configuration.san_ip 121 122 def req(self, object_type='volumes', method='GET', data=None, 123 name=None, idx=None, ver='v1'): 124 @utils.retry(exception.XtremIOArrayBusy, 125 self.configuration.xtremio_array_busy_retry_count, 126 self.configuration.xtremio_array_busy_retry_interval, 1) 127 def _do_req(object_type, method, data, name, idx, ver): 128 if not data: 129 data = {} 130 if name and idx: 131 msg = _("can't handle both name and index in req") 132 LOG.error(msg) 133 raise exception.VolumeDriverException(message=msg) 134 135 url = '%s/%s' % (self.get_base_url(ver), object_type) 136 params = {} 137 key = None 138 if name: 139 params['name'] = name 140 key = name 141 elif idx: 142 url = '%s/%d' % (url, idx) 143 key = str(idx) 144 if method in ('GET', 'DELETE'): 145 params.update(data) 146 self.update_url(params, self.cluster_id) 147 if method != 'GET': 148 self.update_data(data, self.cluster_id) 149 # data may include chap password 150 LOG.debug('data: %s', strutils.mask_password(data)) 151 LOG.debug('%(type)s %(url)s', {'type': method, 'url': url}) 152 try: 153 response = requests.request( 154 method, url, params=params, data=json.dumps(data), 155 verify=self.verify, auth=(self.configuration.san_login, 156 self.configuration.san_password)) 157 except requests.exceptions.RequestException as exc: 158 msg = (_('Exception: %s') % six.text_type(exc)) 159 raise exception.VolumeDriverException(message=msg) 160 161 if (http_client.OK <= response.status_code < 162 http_client.MULTIPLE_CHOICES): 163 if method in ('GET', 'POST'): 164 return response.json() 165 else: 166 return '' 167 168 self.handle_errors(response, key, object_type) 169 return _do_req(object_type, method, data, name, idx, ver) 170 171 def handle_errors(self, response, key, object_type): 172 if response.status_code == http_client.BAD_REQUEST: 173 error = response.json() 174 err_msg = error.get('message') 175 if err_msg.endswith(OBJ_NOT_FOUND_ERR): 176 LOG.warning("object %(key)s of " 177 "type %(typ)s not found, %(err_msg)s", 178 {'key': key, 'typ': object_type, 179 'err_msg': err_msg, }) 180 raise exception.NotFound() 181 elif err_msg == VOL_NOT_UNIQUE_ERR: 182 LOG.error("can't create 2 volumes with the same name, %s", 183 err_msg) 184 msg = _('Volume by this name already exists') 185 raise exception.VolumeBackendAPIException(data=msg) 186 elif err_msg == VOL_OBJ_NOT_FOUND_ERR: 187 LOG.error("Can't find volume to map %(key)s, %(msg)s", 188 {'key': key, 'msg': err_msg, }) 189 raise exception.VolumeNotFound(volume_id=key) 190 elif ALREADY_MAPPED_ERR in err_msg: 191 raise exception.XtremIOAlreadyMappedError() 192 elif err_msg == SYSTEM_BUSY: 193 raise exception.XtremIOArrayBusy() 194 elif err_msg in (TOO_MANY_OBJECTS, TOO_MANY_SNAPSHOTS_PER_VOL): 195 raise exception.XtremIOSnapshotsLimitExceeded() 196 msg = _('Bad response from XMS, %s') % response.text 197 LOG.error(msg) 198 raise exception.VolumeBackendAPIException(message=msg) 199 200 def update_url(self, data, cluster_id): 201 return 202 203 def update_data(self, data, cluster_id): 204 return 205 206 def get_cluster(self): 207 return self.req('clusters', idx=1)['content'] 208 209 def create_snapshot(self, src, dest, ro=False): 210 """Create a snapshot of a volume on the array. 211 212 XtreamIO array snapshots are also volumes. 213 214 :src: name of the source volume to be cloned 215 :dest: name for the new snapshot 216 :ro: new snapshot type ro/regular. only applicable to Client4 217 """ 218 raise NotImplementedError() 219 220 def get_extra_capabilities(self): 221 return {} 222 223 def get_initiator(self, port_address): 224 raise NotImplementedError() 225 226 def add_vol_to_cg(self, vol_id, cg_id): 227 pass 228 229 def get_initiators_igs(self, port_addresses): 230 ig_indexes = set() 231 for port_address in port_addresses: 232 initiator = self.get_initiator(port_address) 233 ig_indexes.add(initiator['ig-id'][XTREMIO_OID_INDEX]) 234 235 return list(ig_indexes) 236 237 def get_fc_up_ports(self): 238 targets = [self.req('targets', name=target['name'])['content'] 239 for target in self.req('targets')['targets']] 240 return [target for target in targets 241 if target['port-type'] == 'fc' and 242 target["port-state"] == 'up'] 243 244 245class XtremIOClient3(XtremIOClient): 246 def __init__(self, configuration, cluster_id): 247 super(XtremIOClient3, self).__init__(configuration, cluster_id) 248 self._portals = [] 249 250 def find_lunmap(self, ig_name, vol_name): 251 try: 252 lun_mappings = self.req('lun-maps')['lun-maps'] 253 except exception.NotFound: 254 raise (exception.VolumeDriverException 255 (_("can't find lun-map, ig:%(ig)s vol:%(vol)s") % 256 {'ig': ig_name, 'vol': vol_name})) 257 258 for lm_link in lun_mappings: 259 idx = lm_link['href'].split('/')[-1] 260 # NOTE(geguileo): There can be races so mapped elements retrieved 261 # in the listing may no longer exist. 262 try: 263 lm = self.req('lun-maps', idx=int(idx))['content'] 264 except exception.NotFound: 265 continue 266 if lm['ig-name'] == ig_name and lm['vol-name'] == vol_name: 267 return lm 268 269 return None 270 271 def num_of_mapped_volumes(self, initiator): 272 cnt = 0 273 for lm_link in self.req('lun-maps')['lun-maps']: 274 idx = lm_link['href'].split('/')[-1] 275 # NOTE(geguileo): There can be races so mapped elements retrieved 276 # in the listing may no longer exist. 277 try: 278 lm = self.req('lun-maps', idx=int(idx))['content'] 279 except exception.NotFound: 280 continue 281 if lm['ig-name'] == initiator: 282 cnt += 1 283 return cnt 284 285 def get_iscsi_portals(self): 286 if self._portals: 287 return self._portals 288 289 iscsi_portals = [t['name'] for t in self.req('iscsi-portals') 290 ['iscsi-portals']] 291 for portal_name in iscsi_portals: 292 try: 293 self._portals.append(self.req('iscsi-portals', 294 name=portal_name)['content']) 295 except exception.NotFound: 296 raise (exception.VolumeBackendAPIException 297 (data=_("iscsi portal, %s, not found") % portal_name)) 298 299 return self._portals 300 301 def create_snapshot(self, src, dest, ro=False): 302 data = {'snap-vol-name': dest, 'ancestor-vol-id': src} 303 304 self.req('snapshots', 'POST', data) 305 306 def get_initiator(self, port_address): 307 try: 308 return self.req('initiators', 'GET', name=port_address)['content'] 309 except exception.NotFound: 310 pass 311 312 313class XtremIOClient4(XtremIOClient): 314 def __init__(self, configuration, cluster_id): 315 super(XtremIOClient4, self).__init__(configuration, cluster_id) 316 self._cluster_name = None 317 318 def req(self, object_type='volumes', method='GET', data=None, 319 name=None, idx=None, ver='v2'): 320 return super(XtremIOClient4, self).req(object_type, method, data, 321 name, idx, ver) 322 323 def get_extra_capabilities(self): 324 return {'consistencygroup_support': True} 325 326 def find_lunmap(self, ig_name, vol_name): 327 try: 328 return (self.req('lun-maps', 329 data={'full': 1, 330 'filter': ['vol-name:eq:%s' % vol_name, 331 'ig-name:eq:%s' % ig_name]}) 332 ['lun-maps'][0]) 333 except (KeyError, IndexError): 334 raise exception.VolumeNotFound(volume_id=vol_name) 335 336 def num_of_mapped_volumes(self, initiator): 337 return len(self.req('lun-maps', 338 data={'filter': 'ig-name:eq:%s' % initiator}) 339 ['lun-maps']) 340 341 def update_url(self, data, cluster_id): 342 if cluster_id: 343 data['cluster-name'] = cluster_id 344 345 def update_data(self, data, cluster_id): 346 if cluster_id: 347 data['cluster-id'] = cluster_id 348 349 def get_iscsi_portals(self): 350 return self.req('iscsi-portals', 351 data={'full': 1})['iscsi-portals'] 352 353 def get_cluster(self): 354 if not self.cluster_id: 355 self.cluster_id = self.req('clusters')['clusters'][0]['name'] 356 357 return self.req('clusters', name=self.cluster_id)['content'] 358 359 def create_snapshot(self, src, dest, ro=False): 360 data = {'snapshot-set-name': dest, 'snap-suffix': dest, 361 'volume-list': [src], 362 'snapshot-type': 'readonly' if ro else 'regular'} 363 364 res = self.req('snapshots', 'POST', data, ver='v2') 365 typ, idx = res['links'][0]['href'].split('/')[-2:] 366 367 # rename the snapshot 368 data = {'name': dest} 369 try: 370 self.req(typ, 'PUT', data, idx=int(idx)) 371 except exception.VolumeBackendAPIException: 372 # reverting 373 LOG.error('Failed to rename the created snapshot, reverting.') 374 self.req(typ, 'DELETE', idx=int(idx)) 375 raise 376 377 def add_vol_to_cg(self, vol_id, cg_id): 378 add_data = {'vol-id': vol_id, 'cg-id': cg_id} 379 self.req('consistency-group-volumes', 'POST', add_data, ver='v2') 380 381 def get_initiator(self, port_address): 382 inits = self.req('initiators', 383 data={'filter': 'port-address:eq:' + port_address, 384 'full': 1})['initiators'] 385 if len(inits) == 1: 386 return inits[0] 387 else: 388 pass 389 390 def get_fc_up_ports(self): 391 return self.req('targets', 392 data={'full': 1, 393 'filter': ['port-type:eq:fc', 394 'port-state:eq:up'], 395 'prop': 'port-address'})["targets"] 396 397 398class XtremIOClient42(XtremIOClient4): 399 def get_initiators_igs(self, port_addresses): 400 init_filter = ','.join('port-address:eq:{}'.format(port_address) for 401 port_address in port_addresses) 402 initiators = self.req('initiators', 403 data={'filter': init_filter, 404 'full': 1, 'prop': 'ig-id'})['initiators'] 405 return list(set(ig_id['ig-id'][XTREMIO_OID_INDEX] 406 for ig_id in initiators)) 407 408 409class XtremIOVolumeDriver(san.SanDriver): 410 """Executes commands relating to Volumes.""" 411 412 VERSION = '1.0.10' 413 414 # ThirdPartySystems wiki 415 CI_WIKI_NAME = "EMC_XIO_CI" 416 417 driver_name = 'XtremIO' 418 MIN_XMS_VERSION = [3, 0, 0] 419 420 def __init__(self, *args, **kwargs): 421 super(XtremIOVolumeDriver, self).__init__(*args, **kwargs) 422 self.configuration.append_config_values(XTREMIO_OPTS) 423 self.protocol = None 424 self.backend_name = (self.configuration.safe_get('volume_backend_name') 425 or self.driver_name) 426 self.cluster_id = (self.configuration.safe_get('xtremio_cluster_name') 427 or '') 428 self.provisioning_factor = vutils.get_max_over_subscription_ratio( 429 self.configuration.max_over_subscription_ratio, 430 supports_auto=False) 431 432 self.clean_ig = (self.configuration.safe_get('xtremio_clean_unused_ig') 433 or False) 434 self._stats = {} 435 self.client = XtremIOClient3(self.configuration, self.cluster_id) 436 437 def _obj_from_result(self, res): 438 typ, idx = res['links'][0]['href'].split('/')[-2:] 439 return self.client.req(typ, idx=int(idx))['content'] 440 441 def check_for_setup_error(self): 442 try: 443 name = self.client.req('clusters')['clusters'][0]['name'] 444 cluster = self.client.req('clusters', name=name)['content'] 445 version_text = cluster['sys-sw-version'] 446 except exception.NotFound: 447 msg = _("XtremIO not initialized correctly, no clusters found") 448 raise (exception.VolumeBackendAPIException 449 (data=msg)) 450 ver = [int(n) for n in version_text.split('-')[0].split('.')] 451 if ver < self.MIN_XMS_VERSION: 452 msg = (_('Invalid XtremIO version %(cur)s,' 453 ' version %(min)s or up is required') % 454 {'min': self.MIN_XMS_VERSION, 455 'cur': ver}) 456 LOG.error(msg) 457 raise exception.VolumeBackendAPIException(data=msg) 458 else: 459 LOG.info('XtremIO Cluster version %s', version_text) 460 client_ver = '3' 461 if ver[0] >= 4: 462 # get XMS version 463 xms = self.client.req('xms', idx=1)['content'] 464 xms_version = tuple([int(i) for i in 465 xms['sw-version'].split('-')[0].split('.')]) 466 LOG.info('XtremIO XMS version %s', version_text) 467 if xms_version >= (4, 2): 468 self.client = XtremIOClient42(self.configuration, 469 self.cluster_id) 470 client_ver = '4.2' 471 else: 472 self.client = XtremIOClient4(self.configuration, 473 self.cluster_id) 474 client_ver = '4' 475 LOG.info('Using XtremIO Client %s', client_ver) 476 477 def create_volume(self, volume): 478 """Creates a volume.""" 479 data = {'vol-name': volume['id'], 480 'vol-size': str(volume['size']) + 'g' 481 } 482 self.client.req('volumes', 'POST', data) 483 484 # Add the volume to a cg in case volume requested a cgid or group_id. 485 # If both cg_id and group_id exists in a volume. group_id will take 486 # place. 487 488 consistency_group = volume.get('consistencygroup_id') 489 490 # if cg_id and group_id are both exists, we gives priority to group_id. 491 if volume.get('group_id'): 492 consistency_group = volume.get('group_id') 493 494 if consistency_group: 495 self.client.add_vol_to_cg(volume['id'], 496 consistency_group) 497 498 def create_volume_from_snapshot(self, volume, snapshot): 499 """Creates a volume from a snapshot.""" 500 if snapshot.get('cgsnapshot_id'): 501 # get array snapshot id from CG snapshot 502 snap_by_anc = self._get_snapset_ancestors(snapshot.cgsnapshot) 503 snapshot_id = snap_by_anc[snapshot['volume_id']] 504 else: 505 snapshot_id = snapshot['id'] 506 507 try: 508 self.client.create_snapshot(snapshot_id, volume['id']) 509 except exception.XtremIOSnapshotsLimitExceeded as e: 510 raise exception.CinderException(e.message) 511 512 # extend the snapped volume if requested size is larger then original 513 if volume['size'] > snapshot['volume_size']: 514 try: 515 self.extend_volume(volume, volume['size']) 516 except Exception: 517 LOG.error('failed to extend volume %s, ' 518 'reverting volume from snapshot operation', 519 volume['id']) 520 # remove the volume in case resize failed 521 self.delete_volume(volume) 522 raise 523 524 # add new volume to consistency group 525 if (volume.get('consistencygroup_id') and 526 self.client is XtremIOClient4): 527 self.client.add_vol_to_cg(volume['id'], 528 snapshot['consistencygroup_id']) 529 530 def create_cloned_volume(self, volume, src_vref): 531 """Creates a clone of the specified volume.""" 532 vol = self.client.req('volumes', name=src_vref['id'])['content'] 533 ctxt = context.get_admin_context() 534 cache = self.db.image_volume_cache_get_by_volume_id(ctxt, 535 src_vref['id']) 536 limit = self.configuration.safe_get('xtremio_volumes_per_glance_cache') 537 if cache and limit and limit > 0 and limit <= vol['num-of-dest-snaps']: 538 raise exception.CinderException('Exceeded the configured limit of ' 539 '%d snapshots per volume' % limit) 540 try: 541 self.client.create_snapshot(src_vref['id'], volume['id']) 542 except exception.XtremIOSnapshotsLimitExceeded as e: 543 raise exception.CinderException(e.message) 544 545 # extend the snapped volume if requested size is larger then original 546 if volume['size'] > src_vref['size']: 547 try: 548 self.extend_volume(volume, volume['size']) 549 except Exception: 550 LOG.error('failed to extend volume %s, ' 551 'reverting clone operation', volume['id']) 552 # remove the volume in case resize failed 553 self.delete_volume(volume) 554 raise 555 556 if volume.get('consistencygroup_id') and self.client is XtremIOClient4: 557 self.client.add_vol_to_cg(volume['id'], 558 volume['consistencygroup_id']) 559 560 def delete_volume(self, volume): 561 """Deletes a volume.""" 562 try: 563 self.client.req('volumes', 'DELETE', name=volume.name_id) 564 except exception.NotFound: 565 LOG.info("volume %s doesn't exist", volume.name_id) 566 567 def create_snapshot(self, snapshot): 568 """Creates a snapshot.""" 569 self.client.create_snapshot(snapshot.volume_id, snapshot.id, True) 570 571 def delete_snapshot(self, snapshot): 572 """Deletes a snapshot.""" 573 try: 574 self.client.req('volumes', 'DELETE', name=snapshot.id) 575 except exception.NotFound: 576 LOG.info("snapshot %s doesn't exist", snapshot.id) 577 578 def update_migrated_volume(self, ctxt, volume, new_volume, 579 original_volume_status): 580 # as the volume name is used to id the volume we need to rename it 581 name_id = None 582 provider_location = None 583 current_name = new_volume['id'] 584 original_name = volume['id'] 585 try: 586 data = {'name': original_name} 587 self.client.req('volumes', 'PUT', data, name=current_name) 588 except exception.VolumeBackendAPIException: 589 LOG.error('Unable to rename the logical volume ' 590 'for volume: %s', original_name) 591 # If the rename fails, _name_id should be set to the new 592 # volume id and provider_location should be set to the 593 # one from the new volume as well. 594 name_id = new_volume['_name_id'] or new_volume['id'] 595 provider_location = new_volume['provider_location'] 596 597 return {'_name_id': name_id, 'provider_location': provider_location} 598 599 def _update_volume_stats(self): 600 sys = self.client.get_cluster() 601 physical_space = int(sys["ud-ssd-space"]) / units.Mi 602 used_physical_space = int(sys["ud-ssd-space-in-use"]) / units.Mi 603 free_physical = physical_space - used_physical_space 604 actual_prov = int(sys["vol-size"]) / units.Mi 605 self._stats = {'volume_backend_name': self.backend_name, 606 'vendor_name': 'Dell EMC', 607 'driver_version': self.VERSION, 608 'storage_protocol': self.protocol, 609 'total_capacity_gb': physical_space, 610 'free_capacity_gb': (free_physical * 611 self.provisioning_factor), 612 'provisioned_capacity_gb': actual_prov, 613 'max_over_subscription_ratio': self.provisioning_factor, 614 'thin_provisioning_support': True, 615 'thick_provisioning_support': False, 616 'reserved_percentage': 617 self.configuration.reserved_percentage, 618 'QoS_support': False, 619 'multiattach': False, 620 } 621 self._stats.update(self.client.get_extra_capabilities()) 622 623 def get_volume_stats(self, refresh=False): 624 """Get volume stats. 625 626 If 'refresh' is True, run update the stats first. 627 """ 628 if refresh: 629 self._update_volume_stats() 630 return self._stats 631 632 def manage_existing(self, volume, existing_ref, is_snapshot=False): 633 """Manages an existing LV.""" 634 lv_name = existing_ref['source-name'] 635 # Attempt to locate the volume. 636 try: 637 vol_obj = self.client.req('volumes', name=lv_name)['content'] 638 if ( 639 is_snapshot and 640 (not vol_obj['ancestor-vol-id'] or 641 vol_obj['ancestor-vol-id'][XTREMIO_OID_NAME] != 642 volume.volume_id)): 643 kwargs = {'existing_ref': lv_name, 644 'reason': 'Not a snapshot of vol %s' % 645 volume.volume_id} 646 raise exception.ManageExistingInvalidReference(**kwargs) 647 except exception.NotFound: 648 kwargs = {'existing_ref': lv_name, 649 'reason': 'Specified logical %s does not exist.' % 650 'snapshot' if is_snapshot else 'volume'} 651 raise exception.ManageExistingInvalidReference(**kwargs) 652 653 # Attempt to rename the LV to match the OpenStack internal name. 654 self.client.req('volumes', 'PUT', data={'vol-name': volume['id']}, 655 idx=vol_obj['index']) 656 657 def manage_existing_get_size(self, volume, existing_ref, 658 is_snapshot=False): 659 """Return size of an existing LV for manage_existing.""" 660 # Check that the reference is valid 661 if 'source-name' not in existing_ref: 662 reason = _('Reference must contain source-name element.') 663 raise exception.ManageExistingInvalidReference( 664 existing_ref=existing_ref, reason=reason) 665 lv_name = existing_ref['source-name'] 666 # Attempt to locate the volume. 667 try: 668 vol_obj = self.client.req('volumes', name=lv_name)['content'] 669 except exception.NotFound: 670 kwargs = {'existing_ref': lv_name, 671 'reason': 'Specified logical %s does not exist.' % 672 'snapshot' if is_snapshot else 'volume'} 673 raise exception.ManageExistingInvalidReference(**kwargs) 674 # LV size is returned in gigabytes. Attempt to parse size as a float 675 # and round up to the next integer. 676 lv_size = int(math.ceil(float(vol_obj['vol-size']) / units.Mi)) 677 678 return lv_size 679 680 def unmanage(self, volume, is_snapshot=False): 681 """Removes the specified volume from Cinder management.""" 682 # trying to rename the volume to [cinder name]-unmanged 683 try: 684 self.client.req('volumes', 'PUT', name=volume['id'], 685 data={'vol-name': volume['name'] + '-unmanged'}) 686 except exception.NotFound: 687 LOG.info("%(typ)s with the name %(name)s wasn't found, " 688 "can't unmanage", 689 {'typ': 'Snapshot' if is_snapshot else 'Volume', 690 'name': volume['id']}) 691 raise exception.VolumeNotFound(volume_id=volume['id']) 692 693 def manage_existing_snapshot(self, snapshot, existing_ref): 694 self.manage_existing(snapshot, existing_ref, True) 695 696 def manage_existing_snapshot_get_size(self, snapshot, existing_ref): 697 return self.manage_existing_get_size(snapshot, existing_ref, True) 698 699 def unmanage_snapshot(self, snapshot): 700 self.unmanage(snapshot, True) 701 702 def extend_volume(self, volume, new_size): 703 """Extend an existing volume's size.""" 704 data = {'vol-size': six.text_type(new_size) + 'g'} 705 try: 706 self.client.req('volumes', 'PUT', data, name=volume['id']) 707 except exception.NotFound: 708 msg = _("can't find the volume to extend") 709 raise exception.VolumeDriverException(message=msg) 710 711 def check_for_export(self, context, volume_id): 712 """Make sure volume is exported.""" 713 pass 714 715 def terminate_connection(self, volume, connector, **kwargs): 716 """Disallow connection from connector""" 717 tg_index = '1' 718 719 if not connector: 720 vol = self.client.req('volumes', name=volume.id)['content'] 721 # foce detach, unmap all IGs from volume 722 IG_OID = 0 723 ig_indexes = [lun_map[IG_OID][XTREMIO_OID_INDEX] for 724 lun_map in vol['lun-mapping-list']] 725 LOG.info('Force detach volume %(vol)s from luns %(luns)s.', 726 {'vol': vol['name'], 'luns': ig_indexes}) 727 else: 728 vol = self.client.req('volumes', name=volume.id, 729 data={'prop': 'index'})['content'] 730 ig_indexes = self._get_ig_indexes_from_initiators(connector) 731 732 for ig_idx in ig_indexes: 733 lm_name = '%s_%s_%s' % (six.text_type(vol['index']), 734 six.text_type(ig_idx), 735 tg_index) 736 LOG.debug('Removing lun map %s.', lm_name) 737 try: 738 self.client.req('lun-maps', 'DELETE', name=lm_name) 739 except exception.NotFound: 740 LOG.warning("terminate_connection: lun map not found") 741 742 if self.clean_ig: 743 for idx in ig_indexes: 744 try: 745 ig = self.client.req('initiator-groups', 'GET', 746 {'prop': 'num-of-vols'}, 747 idx=idx)['content'] 748 if ig['num-of-vols'] == 0: 749 self.client.req('initiator-groups', 'DELETE', idx=idx) 750 except (exception.NotFound, 751 exception.VolumeBackendAPIException): 752 LOG.warning('Failed to clean IG %d without mappings', idx) 753 754 def _get_password(self): 755 return ''.join(RANDOM.choice 756 (string.ascii_uppercase + string.digits) 757 for _ in range(12)) 758 759 def create_lun_map(self, volume, ig, lun_num=None): 760 try: 761 data = {'ig-id': ig, 'vol-id': volume['id']} 762 if lun_num: 763 data['lun'] = lun_num 764 res = self.client.req('lun-maps', 'POST', data) 765 766 lunmap = self._obj_from_result(res) 767 LOG.info('Created lun-map:\n%s', lunmap) 768 except exception.XtremIOAlreadyMappedError: 769 LOG.info('Volume already mapped, retrieving %(ig)s, %(vol)s', 770 {'ig': ig, 'vol': volume['id']}) 771 lunmap = self.client.find_lunmap(ig, volume['id']) 772 return lunmap 773 774 def _get_ig_name(self, connector): 775 raise NotImplementedError() 776 777 def _get_ig_indexes_from_initiators(self, connector): 778 initiator_names = self._get_initiator_names(connector) 779 return self.client.get_initiators_igs(initiator_names) 780 781 def _get_initiator_names(self, connector): 782 raise NotImplementedError() 783 784 def create_consistencygroup(self, context, group): 785 """Creates a consistency group. 786 787 :param context: the context 788 :param group: the group object to be created 789 :returns: dict -- modelUpdate = {'status': 'available'} 790 :raises: VolumeBackendAPIException 791 """ 792 create_data = {'consistency-group-name': group['id']} 793 self.client.req('consistency-groups', 'POST', data=create_data, 794 ver='v2') 795 return {'status': fields.ConsistencyGroupStatus.AVAILABLE} 796 797 def delete_consistencygroup(self, context, group, volumes): 798 """Deletes a consistency group.""" 799 self.client.req('consistency-groups', 'DELETE', name=group['id'], 800 ver='v2') 801 802 volumes_model_update = [] 803 804 for volume in volumes: 805 self.delete_volume(volume) 806 807 update_item = {'id': volume['id'], 808 'status': 'deleted'} 809 810 volumes_model_update.append(update_item) 811 812 model_update = {'status': group['status']} 813 814 return model_update, volumes_model_update 815 816 def _get_snapset_ancestors(self, snapset_name): 817 snapset = self.client.req('snapshot-sets', 818 name=snapset_name)['content'] 819 volume_ids = [s[XTREMIO_OID_INDEX] for s in snapset['vol-list']] 820 return {v['ancestor-vol-id'][XTREMIO_OID_NAME]: v['name'] for v 821 in self.client.req('volumes', 822 data={'full': 1, 823 'props': 824 'ancestor-vol-id'})['volumes'] 825 if v['index'] in volume_ids} 826 827 def create_consistencygroup_from_src(self, context, group, volumes, 828 cgsnapshot=None, snapshots=None, 829 source_cg=None, source_vols=None): 830 """Creates a consistencygroup from source. 831 832 :param context: the context of the caller. 833 :param group: the dictionary of the consistency group to be created. 834 :param volumes: a list of volume dictionaries in the group. 835 :param cgsnapshot: the dictionary of the cgsnapshot as source. 836 :param snapshots: a list of snapshot dictionaries in the cgsnapshot. 837 :param source_cg: the dictionary of a consistency group as source. 838 :param source_vols: a list of volume dictionaries in the source_cg. 839 :returns: model_update, volumes_model_update 840 """ 841 if not (cgsnapshot and snapshots and not source_cg or 842 source_cg and source_vols and not cgsnapshot): 843 msg = _("create_consistencygroup_from_src only supports a " 844 "cgsnapshot source or a consistency group source. " 845 "Multiple sources cannot be used.") 846 raise exception.InvalidInput(msg) 847 848 if cgsnapshot: 849 snap_name = self._get_cgsnap_name(cgsnapshot) 850 snap_by_anc = self._get_snapset_ancestors(snap_name) 851 for volume, snapshot in zip(volumes, snapshots): 852 real_snap = snap_by_anc[snapshot['volume_id']] 853 self.create_volume_from_snapshot( 854 volume, 855 {'id': real_snap, 856 'volume_size': snapshot['volume_size']}) 857 858 elif source_cg: 859 data = {'consistency-group-id': source_cg['id'], 860 'snapshot-set-name': group['id']} 861 self.client.req('snapshots', 'POST', data, ver='v2') 862 snap_by_anc = self._get_snapset_ancestors(group['id']) 863 for volume, src_vol in zip(volumes, source_vols): 864 snap_vol_name = snap_by_anc[src_vol['id']] 865 self.client.req('volumes', 'PUT', {'name': volume['id']}, 866 name=snap_vol_name) 867 868 create_data = {'consistency-group-name': group['id'], 869 'vol-list': [v['id'] for v in volumes]} 870 self.client.req('consistency-groups', 'POST', data=create_data, 871 ver='v2') 872 873 return None, None 874 875 def update_consistencygroup(self, context, group, 876 add_volumes=None, remove_volumes=None): 877 """Updates a consistency group. 878 879 :param context: the context of the caller. 880 :param group: the dictionary of the consistency group to be updated. 881 :param add_volumes: a list of volume dictionaries to be added. 882 :param remove_volumes: a list of volume dictionaries to be removed. 883 :returns: model_update, add_volumes_update, remove_volumes_update 884 """ 885 add_volumes = add_volumes if add_volumes else [] 886 remove_volumes = remove_volumes if remove_volumes else [] 887 for vol in add_volumes: 888 add_data = {'vol-id': vol['id'], 'cg-id': group['id']} 889 self.client.req('consistency-group-volumes', 'POST', add_data, 890 ver='v2') 891 for vol in remove_volumes: 892 remove_data = {'vol-id': vol['id'], 'cg-id': group['id']} 893 self.client.req('consistency-group-volumes', 'DELETE', remove_data, 894 name=group['id'], ver='v2') 895 return None, None, None 896 897 def _get_cgsnap_name(self, cgsnapshot): 898 899 group_id = cgsnapshot.get('group_id') 900 if group_id is None: 901 group_id = cgsnapshot.get('consistencygroup_id') 902 903 return '%(cg)s%(snap)s' % {'cg': group_id 904 .replace('-', ''), 905 'snap': cgsnapshot['id'].replace('-', '')} 906 907 def create_cgsnapshot(self, context, cgsnapshot, snapshots): 908 """Creates a cgsnapshot.""" 909 910 group_id = cgsnapshot.get('group_id') 911 if group_id is None: 912 group_id = cgsnapshot.get('consistencygroup_id') 913 914 data = {'consistency-group-id': group_id, 915 'snapshot-set-name': self._get_cgsnap_name(cgsnapshot)} 916 self.client.req('snapshots', 'POST', data, ver='v2') 917 918 return None, None 919 920 def delete_cgsnapshot(self, context, cgsnapshot, snapshots): 921 """Deletes a cgsnapshot.""" 922 self.client.req('snapshot-sets', 'DELETE', 923 name=self._get_cgsnap_name(cgsnapshot), ver='v2') 924 return None, None 925 926 def create_group(self, context, group): 927 """Creates a group. 928 929 :param context: the context of the caller. 930 :param group: the group object. 931 :returns: model_update 932 """ 933 934 # the driver treats a group as a CG internally. 935 # We proxy the calls to the CG api. 936 return self.create_consistencygroup(context, group) 937 938 def delete_group(self, context, group, volumes): 939 """Deletes a group. 940 941 :param context: the context of the caller. 942 :param group: the group object. 943 :param volumes: a list of volume objects in the group. 944 :returns: model_update, volumes_model_update 945 """ 946 947 # the driver treats a group as a CG internally. 948 # We proxy the calls to the CG api. 949 return self.delete_consistencygroup(context, group, volumes) 950 951 def update_group(self, context, group, 952 add_volumes=None, remove_volumes=None): 953 """Updates a group. 954 955 :param context: the context of the caller. 956 :param group: the group object. 957 :param add_volumes: a list of volume objects to be added. 958 :param remove_volumes: a list of volume objects to be removed. 959 :returns: model_update, add_volumes_update, remove_volumes_update 960 """ 961 962 # the driver treats a group as a CG internally. 963 # We proxy the calls to the CG api. 964 return self.update_consistencygroup(context, group, add_volumes, 965 remove_volumes) 966 967 def create_group_from_src(self, context, group, volumes, 968 group_snapshot=None, snapshots=None, 969 source_group=None, source_vols=None): 970 """Creates a group from source. 971 972 :param context: the context of the caller. 973 :param group: the Group object to be created. 974 :param volumes: a list of Volume objects in the group. 975 :param group_snapshot: the GroupSnapshot object as source. 976 :param snapshots: a list of snapshot objects in group_snapshot. 977 :param source_group: the Group object as source. 978 :param source_vols: a list of volume objects in the source_group. 979 :returns: model_update, volumes_model_update 980 """ 981 982 # the driver treats a group as a CG internally. 983 # We proxy the calls to the CG api. 984 return self.create_consistencygroup_from_src(context, group, volumes, 985 group_snapshot, snapshots, 986 source_group, source_vols) 987 988 def create_group_snapshot(self, context, group_snapshot, snapshots): 989 """Creates a group_snapshot. 990 991 :param context: the context of the caller. 992 :param group_snapshot: the GroupSnapshot object to be created. 993 :param snapshots: a list of Snapshot objects in the group_snapshot. 994 :returns: model_update, snapshots_model_update 995 """ 996 997 # the driver treats a group as a CG internally. 998 # We proxy the calls to the CG api. 999 return self.create_cgsnapshot(context, group_snapshot, snapshots) 1000 1001 def delete_group_snapshot(self, context, group_snapshot, snapshots): 1002 """Deletes a group_snapshot. 1003 1004 :param context: the context of the caller. 1005 :param group_snapshot: the GroupSnapshot object to be deleted. 1006 :param snapshots: a list of snapshot objects in the group_snapshot. 1007 :returns: model_update, snapshots_model_update 1008 """ 1009 1010 # the driver treats a group as a CG internally. 1011 # We proxy the calls to the CG api. 1012 return self.delete_cgsnapshot(context, group_snapshot, snapshots) 1013 1014 def _get_ig(self, name): 1015 try: 1016 return self.client.req('initiator-groups', 'GET', 1017 name=name)['content'] 1018 except exception.NotFound: 1019 pass 1020 1021 def _create_ig(self, name): 1022 # create an initiator group to hold the initiator 1023 data = {'ig-name': name} 1024 self.client.req('initiator-groups', 'POST', data) 1025 try: 1026 return self.client.req('initiator-groups', name=name)['content'] 1027 except exception.NotFound: 1028 raise (exception.VolumeBackendAPIException 1029 (data=_("Failed to create IG, %s") % name)) 1030 1031 1032@interface.volumedriver 1033class XtremIOISCSIDriver(XtremIOVolumeDriver, driver.ISCSIDriver): 1034 """Executes commands relating to ISCSI volumes. 1035 1036 We make use of model provider properties as follows: 1037 1038 ``provider_location`` 1039 if present, contains the iSCSI target information in the same 1040 format as an ietadm discovery 1041 i.e. '<ip>:<port>,<portal> <target IQN>' 1042 1043 ``provider_auth`` 1044 if present, contains a space-separated triple: 1045 '<auth method> <auth username> <auth password>'. 1046 `CHAP` is the only auth_method in use at the moment. 1047 """ 1048 driver_name = 'XtremIO_ISCSI' 1049 1050 def __init__(self, *args, **kwargs): 1051 super(XtremIOISCSIDriver, self).__init__(*args, **kwargs) 1052 self.protocol = 'iSCSI' 1053 1054 def _add_auth(self, data, login_chap, discovery_chap): 1055 login_passwd, discovery_passwd = None, None 1056 if login_chap: 1057 data['initiator-authentication-user-name'] = 'chap_user' 1058 login_passwd = self._get_password() 1059 data['initiator-authentication-password'] = login_passwd 1060 if discovery_chap: 1061 data['initiator-discovery-user-name'] = 'chap_user' 1062 discovery_passwd = self._get_password() 1063 data['initiator-discovery-password'] = discovery_passwd 1064 return login_passwd, discovery_passwd 1065 1066 def _create_initiator(self, connector, login_chap, discovery_chap): 1067 initiator = self._get_initiator_names(connector)[0] 1068 # create an initiator 1069 data = {'initiator-name': initiator, 1070 'ig-id': initiator, 1071 'port-address': initiator} 1072 l, d = self._add_auth(data, login_chap, discovery_chap) 1073 self.client.req('initiators', 'POST', data) 1074 return l, d 1075 1076 def initialize_connection(self, volume, connector): 1077 try: 1078 sys = self.client.get_cluster() 1079 except exception.NotFound: 1080 msg = _("XtremIO not initialized correctly, no clusters found") 1081 raise exception.VolumeBackendAPIException(data=msg) 1082 login_chap = (sys.get('chap-authentication-mode', 'disabled') != 1083 'disabled') 1084 discovery_chap = (sys.get('chap-discovery-mode', 'disabled') != 1085 'disabled') 1086 initiator_name = self._get_initiator_names(connector)[0] 1087 initiator = self.client.get_initiator(initiator_name) 1088 if initiator: 1089 login_passwd = initiator['chap-authentication-initiator-password'] 1090 discovery_passwd = initiator['chap-discovery-initiator-password'] 1091 ig = self._get_ig(initiator['ig-id'][XTREMIO_OID_NAME]) 1092 else: 1093 ig = self._get_ig(self._get_ig_name(connector)) 1094 if not ig: 1095 ig = self._create_ig(self._get_ig_name(connector)) 1096 (login_passwd, 1097 discovery_passwd) = self._create_initiator(connector, 1098 login_chap, 1099 discovery_chap) 1100 # if CHAP was enabled after the initiator was created 1101 if login_chap and not login_passwd: 1102 LOG.info('Initiator has no password while using chap, adding it.') 1103 data = {} 1104 (login_passwd, 1105 d_passwd) = self._add_auth(data, login_chap, discovery_chap and 1106 not discovery_passwd) 1107 discovery_passwd = (discovery_passwd if discovery_passwd 1108 else d_passwd) 1109 self.client.req('initiators', 'PUT', data, idx=initiator['index']) 1110 1111 # lun mappping 1112 lunmap = self.create_lun_map(volume, ig['ig-id'][XTREMIO_OID_NAME]) 1113 1114 properties = self._get_iscsi_properties(lunmap) 1115 1116 if login_chap: 1117 properties['auth_method'] = 'CHAP' 1118 properties['auth_username'] = 'chap_user' 1119 properties['auth_password'] = login_passwd 1120 if discovery_chap: 1121 properties['discovery_auth_method'] = 'CHAP' 1122 properties['discovery_auth_username'] = 'chap_user' 1123 properties['discovery_auth_password'] = discovery_passwd 1124 LOG.debug('init conn params:\n%s', 1125 strutils.mask_dict_password(properties)) 1126 return { 1127 'driver_volume_type': 'iscsi', 1128 'data': properties 1129 } 1130 1131 def _get_iscsi_properties(self, lunmap): 1132 """Gets iscsi configuration. 1133 1134 :target_discovered: boolean indicating whether discovery was used 1135 :target_iqn: the IQN of the iSCSI target 1136 :target_portal: the portal of the iSCSI target 1137 :target_lun: the lun of the iSCSI target 1138 :volume_id: the id of the volume (currently used by xen) 1139 :auth_method:, :auth_username:, :auth_password: 1140 the authentication details. Right now, either auth_method is not 1141 present meaning no authentication, or auth_method == `CHAP` 1142 meaning use CHAP with the specified credentials. 1143 multiple connection return 1144 :target_iqns, :target_portals, :target_luns, which contain lists of 1145 multiple values. The main portal information is also returned in 1146 :target_iqn, :target_portal, :target_lun for backward compatibility. 1147 """ 1148 portals = self.client.get_iscsi_portals() 1149 if not portals: 1150 msg = _("XtremIO not configured correctly, no iscsi portals found") 1151 LOG.error(msg) 1152 raise exception.VolumeDriverException(message=msg) 1153 portal = RANDOM.choice(portals) 1154 portal_addr = ('%(ip)s:%(port)d' % 1155 {'ip': portal['ip-addr'].split('/')[0], 1156 'port': portal['ip-port']}) 1157 1158 tg_portals = ['%(ip)s:%(port)d' % {'ip': p['ip-addr'].split('/')[0], 1159 'port': p['ip-port']} 1160 for p in portals] 1161 properties = {'target_discovered': False, 1162 'target_iqn': portal['port-address'], 1163 'target_lun': lunmap['lun'], 1164 'target_portal': portal_addr, 1165 'target_iqns': [p['port-address'] for p in portals], 1166 'target_portals': tg_portals, 1167 'target_luns': [lunmap['lun']] * len(portals)} 1168 return properties 1169 1170 def _get_initiator_names(self, connector): 1171 return [connector['initiator']] 1172 1173 def _get_ig_name(self, connector): 1174 return connector['initiator'] 1175 1176 1177@interface.volumedriver 1178class XtremIOFCDriver(XtremIOVolumeDriver, 1179 driver.FibreChannelDriver): 1180 1181 def __init__(self, *args, **kwargs): 1182 super(XtremIOFCDriver, self).__init__(*args, **kwargs) 1183 self.protocol = 'FC' 1184 self._targets = None 1185 1186 def get_targets(self): 1187 if not self._targets: 1188 try: 1189 targets = self.client.get_fc_up_ports() 1190 self._targets = [target['port-address'].replace(':', '') 1191 for target in targets] 1192 except exception.NotFound: 1193 raise (exception.VolumeBackendAPIException 1194 (data=_("Failed to get targets"))) 1195 return self._targets 1196 1197 def _get_free_lun(self, igs): 1198 luns = [] 1199 for ig in igs: 1200 luns.extend(lm['lun'] for lm in 1201 self.client.req('lun-maps', 1202 data={'full': 1, 'prop': 'lun', 1203 'filter': 'ig-name:eq:%s' % ig}) 1204 ['lun-maps']) 1205 uniq_luns = set(luns + [0]) 1206 seq = range(len(uniq_luns) + 1) 1207 return min(set(seq) - uniq_luns) 1208 1209 @fczm_utils.add_fc_zone 1210 def initialize_connection(self, volume, connector): 1211 wwpns = self._get_initiator_names(connector) 1212 ig_name = self._get_ig_name(connector) 1213 i_t_map = {} 1214 found = [] 1215 new = [] 1216 for wwpn in wwpns: 1217 init = self.client.get_initiator(wwpn) 1218 if init: 1219 found.append(init) 1220 else: 1221 new.append(wwpn) 1222 i_t_map[wwpn.replace(':', '')] = self.get_targets() 1223 # get or create initiator group 1224 if new: 1225 ig = self._get_ig(ig_name) 1226 if not ig: 1227 ig = self._create_ig(ig_name) 1228 for wwpn in new: 1229 data = {'initiator-name': wwpn, 'ig-id': ig_name, 1230 'port-address': wwpn} 1231 self.client.req('initiators', 'POST', data) 1232 igs = list(set([i['ig-id'][XTREMIO_OID_NAME] for i in found])) 1233 if new and ig['ig-id'][XTREMIO_OID_NAME] not in igs: 1234 igs.append(ig['ig-id'][XTREMIO_OID_NAME]) 1235 1236 if len(igs) > 1: 1237 lun_num = self._get_free_lun(igs) 1238 else: 1239 lun_num = None 1240 for ig in igs: 1241 lunmap = self.create_lun_map(volume, ig, lun_num) 1242 lun_num = lunmap['lun'] 1243 return {'driver_volume_type': 'fibre_channel', 1244 'data': { 1245 'target_discovered': False, 1246 'target_lun': lun_num, 1247 'target_wwn': self.get_targets(), 1248 'initiator_target_map': i_t_map}} 1249 1250 @fczm_utils.remove_fc_zone 1251 def terminate_connection(self, volume, connector, **kwargs): 1252 (super(XtremIOFCDriver, self) 1253 .terminate_connection(volume, connector, **kwargs)) 1254 has_volumes = (not connector 1255 or self.client. 1256 num_of_mapped_volumes(self._get_ig_name(connector)) > 0) 1257 1258 if has_volumes: 1259 data = {} 1260 else: 1261 i_t_map = {} 1262 for initiator in self._get_initiator_names(connector): 1263 i_t_map[initiator.replace(':', '')] = self.get_targets() 1264 data = {'target_wwn': self.get_targets(), 1265 'initiator_target_map': i_t_map} 1266 1267 return {'driver_volume_type': 'fibre_channel', 1268 'data': data} 1269 1270 def _get_initiator_names(self, connector): 1271 return [wwpn if ':' in wwpn else 1272 ':'.join(wwpn[i:i + 2] for i in range(0, len(wwpn), 2)) 1273 for wwpn in connector['wwpns']] 1274 1275 def _get_ig_name(self, connector): 1276 return connector['host'] 1277