Coverage for manila/share/drivers/zadara/common.py: 74%
197 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
1# Copyright (c) 2020 Zadara Storage, Inc.
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.
16import json
17import re
19from oslo_config import cfg
20from oslo_log import log as logging
21from oslo_utils import netutils
22import requests
24LOG = logging.getLogger(__name__)
26# Number of seconds the repsonse for the request sent to
27# vpsa is expected. Else the request will be timed out.
28# Setting it to 300 seconds initially.
29vpsa_timeout = 300
32# Common exception class for all the exceptions that
33# are used to redirect to the driver specific exceptions.
34class CommonException(Exception):
35 def __init__(self):
36 pass
38 class UnknownCmd(Exception):
39 def __init__(self, cmd):
40 self.cmd = cmd
42 class BadHTTPResponseStatus(Exception):
43 def __init__(self, status):
44 self.status = status
46 class FailedCmdWithDump(Exception):
47 def __init__(self, status, data):
48 self.status = status
49 self.data = data
51 class SessionRequestException(Exception):
52 def __init__(self, msg):
53 self.msg = msg
55 class ZadaraInvalidAccessKey(Exception):
56 pass
59exception = CommonException()
62zadara_opts = [
63 cfg.HostAddressOpt('zadara_vpsa_host',
64 default=None,
65 help='VPSA - Management Host name or IP address'),
66 cfg.PortOpt('zadara_vpsa_port',
67 default=None,
68 help='VPSA - Port number'),
69 cfg.BoolOpt('zadara_vpsa_use_ssl',
70 default=False,
71 help='VPSA - Use SSL connection'),
72 cfg.BoolOpt('zadara_ssl_cert_verify',
73 default=True,
74 help='If set to True the http client will validate the SSL '
75 'certificate of the VPSA endpoint.'),
76 cfg.StrOpt('zadara_access_key',
77 default=None,
78 help='VPSA access key',
79 secret=True),
80 cfg.StrOpt('zadara_vpsa_poolname',
81 default=None,
82 help='VPSA - Storage Pool assigned for volumes'),
83 cfg.BoolOpt('zadara_vol_encrypt',
84 default=False,
85 help='VPSA - Default encryption policy for volumes. '
86 'If the option is neither configured nor provided '
87 'as metadata, the VPSA will inherit the default value.'),
88 cfg.BoolOpt('zadara_gen3_vol_dedupe',
89 default=False,
90 help='VPSA - Enable deduplication for volumes. '
91 'If the option is neither configured nor provided '
92 'as metadata, the VPSA will inherit the default value.'),
93 cfg.BoolOpt('zadara_gen3_vol_compress',
94 default=False,
95 help='VPSA - Enable compression for volumes. '
96 'If the option is neither configured nor provided '
97 'as metadata, the VPSA will inherit the default value.'),
98 cfg.BoolOpt('zadara_default_snap_policy',
99 default=False,
100 help="VPSA - Attach snapshot policy for volumes. "
101 "If the option is neither configured nor provided "
102 "as metadata, the VPSA will inherit the default value.")]
105# Class used to connect and execute the commands on
106# Zadara Virtual Private Storage Array (VPSA).
107class ZadaraVPSAConnection(object):
108 """Executes driver commands on VPSA."""
110 def __init__(self, conf, driver_ssl_cert_path, block):
111 self.conf = conf
112 self.access_key = conf.zadara_access_key
113 if not self.access_key: 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 raise exception.ZadaraInvalidAccessKey()
115 self.driver_ssl_cert_path = driver_ssl_cert_path
116 # Choose the volume type of either block or file-type
117 # that will help to filter volumes.
118 self.vol_type_str = 'showonlyblock' if block else 'showonlyfile'
120 def _generate_vpsa_cmd(self, cmd, **kwargs):
121 """Generate command to be sent to VPSA."""
123 # Dictionary of applicable VPSA commands in the following format:
124 # 'command': (method, API_URL, {optional parameters})
125 vpsa_commands = {
126 # Volume operations
127 'create_volume': ('POST',
128 '/api/volumes.json',
129 {'name': kwargs.get('name'),
130 'capacity': kwargs.get('size'),
131 'pool': self.conf.zadara_vpsa_poolname,
132 'block': 'YES'
133 if self.vol_type_str == 'showonlyblock'
134 else 'NO',
135 'thin': 'YES',
136 'crypt': 'YES'
137 if self.conf.zadara_vol_encrypt else 'NO',
138 'compress': 'YES'
139 if self.conf.zadara_gen3_vol_compress else 'NO',
140 'dedupe': 'YES'
141 if self.conf.zadara_gen3_vol_dedupe else 'NO',
142 'attachpolicies': 'NO'
143 if not self.conf.zadara_default_snap_policy
144 else 'YES'}),
145 'delete_volume': ('DELETE',
146 '/api/volumes/%s.json' % kwargs.get('vpsa_vol'),
147 {'force': 'YES'}),
148 'expand_volume': ('POST',
149 '/api/volumes/%s/expand.json'
150 % kwargs.get('vpsa_vol'),
151 {'capacity': kwargs.get('size')}),
152 'rename_volume': ('POST',
153 '/api/volumes/%s/rename.json'
154 % kwargs.get('vpsa_vol'),
155 {'new_name': kwargs.get('new_name')}),
156 # Snapshot operations
157 # Snapshot request is triggered for a single volume though the
158 # API call implies that snapshot is triggered for CG (legacy API).
159 'create_snapshot': ('POST',
160 '/api/consistency_groups/%s/snapshots.json'
161 % kwargs.get('cg_name'),
162 {'display_name': kwargs.get('snap_name')}),
163 'delete_snapshot': ('DELETE',
164 '/api/snapshots/%s.json'
165 % kwargs.get('snap_id'),
166 {}),
167 'rename_snapshot': ('POST',
168 '/api/snapshots/%s/rename.json'
169 % kwargs.get('snap_id'),
170 {'newname': kwargs.get('new_name')}),
171 'create_clone_from_snap': ('POST',
172 '/api/consistency_groups/%s/clone.json'
173 % kwargs.get('cg_name'),
174 {'name': kwargs.get('name'),
175 'snapshot': kwargs.get('snap_id')}),
176 'create_clone': ('POST',
177 '/api/consistency_groups/%s/clone.json'
178 % kwargs.get('cg_name'),
179 {'name': kwargs.get('name')}),
180 # Server operations
181 'create_server': ('POST',
182 '/api/servers.json',
183 {'iqn': kwargs.get('iqn'),
184 'iscsi': kwargs.get('iscsi_ip'),
185 'display_name': kwargs.get('iqn')
186 if kwargs.get('iqn')
187 else kwargs.get('iscsi_ip')}),
188 # Attach/Detach operations
189 'attach_volume': ('POST',
190 '/api/servers/%s/volumes.json'
191 % kwargs.get('vpsa_srv'),
192 {'volume_name[]': kwargs.get('vpsa_vol'),
193 'access_type': kwargs.get('share_proto'),
194 'readonly': kwargs.get('read_only'),
195 'force': 'YES'}),
196 'detach_volume': ('POST',
197 '/api/volumes/%s/detach.json'
198 % kwargs.get('vpsa_vol'),
199 {'server_name[]': kwargs.get('vpsa_srv'),
200 'force': 'YES'}),
201 # Update volume comment
202 'update_volume': ('POST',
203 '/api/volumes/%s/update_comment.json'
204 % kwargs.get('vpsa_vol'),
205 {'new_comment': kwargs.get('new_comment')}),
207 # Get operations
208 'list_volumes': ('GET',
209 '/api/volumes.json?%s=YES' % self.vol_type_str,
210 {}),
211 'get_volume': ('GET',
212 '/api/volumes/%s.json' % kwargs.get('vpsa_vol'),
213 {}),
214 'get_volume_by_name': ('GET',
215 '/api/volumes.json?display_name=%s'
216 % kwargs.get('display_name'),
217 {}),
218 'get_pool': ('GET',
219 '/api/pools/%s.json' % kwargs.get('pool_name'),
220 {}),
221 'list_controllers': ('GET',
222 '/api/vcontrollers.json',
223 {}),
224 'list_servers': ('GET',
225 '/api/servers.json',
226 {}),
227 'list_vol_snapshots': ('GET',
228 '/api/consistency_groups/%s/snapshots.json'
229 % kwargs.get('cg_name'),
230 {}),
231 'list_vol_attachments': ('GET',
232 '/api/volumes/%s/servers.json'
233 % kwargs.get('vpsa_vol'),
234 {}),
235 'list_snapshots': ('GET',
236 '/api/snapshots.json',
237 {}),
238 # Put operations
239 'change_export_name': ('PUT',
240 '/api/volumes/%s/export_name.json'
241 % kwargs.get('vpsa_vol'),
242 {'exportname': kwargs.get('exportname')})}
243 try:
244 method, url, params = vpsa_commands[cmd]
245 # Populate the metadata for the volume creation
246 metadata = kwargs.get('metadata')
247 if metadata: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 for key, value in metadata.items():
249 params[key] = value
250 except KeyError:
251 raise exception.UnknownCmd(cmd=cmd)
253 if method == 'GET':
254 params = dict(page=1, start=0, limit=0)
255 body = None
257 elif method in ['DELETE', 'POST', 'PUT']: 257 ↛ 262line 257 didn't jump to line 262 because the condition on line 257 was always true
258 body = params
259 params = None
261 else:
262 msg = ('Method %(method)s is not defined' % {'method': method})
263 LOG.error(msg)
264 raise AssertionError(msg)
266 # 'access_key' was generated using username and password
267 # or it was taken from the input file
268 headers = {'X-Access-Key': self.access_key}
270 return method, url, params, body, headers
272 def send_cmd(self, cmd, **kwargs):
273 """Send command to VPSA Controller."""
275 if not self.access_key: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 raise exception.ZadaraInvalidAccessKey()
278 method, url, params, body, headers = self._generate_vpsa_cmd(cmd,
279 **kwargs)
280 LOG.debug('Invoking %(cmd)s using %(method)s request.',
281 {'cmd': cmd, 'method': method})
283 host = self._get_target_host(self.conf.zadara_vpsa_host)
284 port = int(self.conf.zadara_vpsa_port)
286 protocol = "https" if self.conf.zadara_vpsa_use_ssl else "http"
287 if protocol == "https": 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true
288 if not self.conf.zadara_ssl_cert_verify:
289 verify = False
290 else:
291 verify = (self.driver_ssl_cert_path
292 if self.driver_ssl_cert_path else True)
293 else:
294 verify = False
296 if port: 296 ↛ 299line 296 didn't jump to line 299 because the condition on line 296 was always true
297 api_url = "%s://%s:%d%s" % (protocol, host, port, url)
298 else:
299 api_url = "%s://%s%s" % (protocol, host, url)
301 try:
302 with requests.Session() as session:
303 session.headers.update(headers)
304 response = session.request(method, api_url, params=params,
305 data=body, headers=headers,
306 verify=verify, timeout=vpsa_timeout)
307 except requests.exceptions.RequestException as e:
308 msg = ('Exception: %s') % e
309 raise exception.SessionRequestException(msg=msg)
311 if response.status_code != 200: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true
312 raise exception.BadHTTPResponseStatus(
313 status=response.status_code)
315 data = response.content
316 json_data = json.loads(data)
317 response = json_data['response']
318 status = int(response['status'])
319 if status == 5: 319 ↛ 321line 319 didn't jump to line 321 because the condition on line 319 was never true
320 # Invalid Credentials
321 raise exception.ZadaraInvalidAccessKey()
323 if status != 0: 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true
324 raise exception.FailedCmdWithDump(status=status, data=data)
326 if method in ['POST', 'DELETE']:
327 LOG.debug('Operation completed with status code %(status)s',
328 {'status': status})
329 return response
331 def _get_target_host(self, vpsa_host):
332 """Helper for target host formatting."""
333 return netutils.escape_ipv6(vpsa_host)
335 def _get_active_controller_details(self):
336 """Return details of VPSA's active controller."""
337 data = self.send_cmd('list_controllers')
338 ctrl = None
339 vcontrollers = data.get('vcontrollers', [])
340 for controller in vcontrollers:
341 if controller['state'] == 'active': 341 ↛ 340line 341 didn't jump to line 340 because the condition on line 341 was always true
342 ctrl = controller
343 break
345 if ctrl is not None:
346 target_ip = (ctrl['iscsi_ipv6'] if
347 ctrl['iscsi_ipv6'] else
348 ctrl['iscsi_ip'])
349 return dict(target=ctrl['target'],
350 ip=target_ip,
351 chap_user=ctrl['vpsa_chap_user'],
352 chap_passwd=ctrl['vpsa_chap_secret'])
353 return None
355 def _check_access_key_validity(self):
356 """Check VPSA access key"""
357 if not self.access_key:
358 raise exception.ZadaraInvalidAccessKey()
359 active_ctrl = self._get_active_controller_details()
360 if active_ctrl is None:
361 raise exception.ZadaraInvalidAccessKey()
363 def _get_vpsa_volume(self, name):
364 """Returns a single vpsa volume based on the display name"""
365 volume = None
366 display_name = name
367 if re.search(r"\s", name): 367 ↛ 368line 367 didn't jump to line 368 because the condition on line 367 was never true
368 display_name = re.split(r"\s", name)[0]
369 data = self.send_cmd('get_volume_by_name',
370 display_name=display_name)
371 if data['status'] != 0: 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true
372 return None
373 volumes = data['volumes']
375 for vol in volumes:
376 if vol['display_name'] == name: 376 ↛ 375line 376 didn't jump to line 375 because the condition on line 376 was always true
377 volume = vol
378 break
379 return volume
381 def _get_vpsa_volume_by_id(self, vpsa_vol):
382 """Returns a single vpsa volume based on name"""
383 data = self.send_cmd('get_volume', vpsa_vol=vpsa_vol)
384 return data['volume']
386 def _get_volume_cg_name(self, name):
387 """Return name of the consistency group for the volume.
389 cg-name is a volume uniqe identifier (legacy attribute)
390 and not consistency group as it may imply.
391 """
392 volume = self._get_vpsa_volume(name)
393 if volume is not None:
394 return volume['cg_name']
396 return None
398 def _get_all_vpsa_snapshots(self):
399 """Returns snapshots from all vpsa volumes"""
400 data = self.send_cmd('list_snapshots')
401 return data['snapshots']
403 def _get_all_vpsa_volumes(self):
404 """Returns all vpsa block volumes from the configured pool"""
405 data = self.send_cmd('list_volumes')
406 # FIXME: Work around to filter volumes belonging to given pool
407 # Remove this when we have the API fixed to filter based
408 # on pools. This API today does not have virtual_capacity field
409 volumes = []
411 for volume in data['volumes']:
412 if volume['pool_name'] == self.conf.zadara_vpsa_poolname: 412 ↛ 411line 412 didn't jump to line 411 because the condition on line 412 was always true
413 volumes.append(volume)
415 return volumes
417 def _get_server_name(self, initiator, share):
418 """Return VPSA's name for server object.
420 'share' will be true to search for filesystem volumes
421 """
422 data = self.send_cmd('list_servers')
423 servers = data.get('servers', [])
424 for server in servers:
425 if share: 425 ↛ 429line 425 didn't jump to line 429 because the condition on line 425 was always true
426 if server['iscsi_ip'] == initiator: 426 ↛ 427line 426 didn't jump to line 427 because the condition on line 426 was never true
427 return server['name']
428 else:
429 if server['iqn'] == initiator:
430 return server['name']
431 return None
433 def _create_vpsa_server(self, iqn=None, iscsi_ip=None):
434 """Create server object within VPSA (if doesn't exist)."""
435 initiator = iscsi_ip if iscsi_ip else iqn
436 share = True if iscsi_ip else False
437 vpsa_srv = self._get_server_name(initiator, share)
438 if not vpsa_srv: 438 ↛ 443line 438 didn't jump to line 443 because the condition on line 438 was always true
439 data = self.send_cmd('create_server', iqn=iqn, iscsi_ip=iscsi_ip)
440 if data['status'] != 0: 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true
441 return None
442 vpsa_srv = data['server_name']
443 return vpsa_srv
445 def _get_servers_attached_to_volume(self, vpsa_vol):
446 """Return all servers attached to volume."""
447 servers = vpsa_vol.get('server_ext_names')
448 list_servers = []
449 if servers:
450 list_servers = servers.split(',')
451 return list_servers
453 def _detach_vpsa_volume(self, vpsa_vol, vpsa_srv=None):
454 """Detach volume from all attached servers."""
455 if vpsa_srv: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 list_servers_ids = [vpsa_srv]
457 else:
458 list_servers_ids = self._get_servers_attached_to_volume(vpsa_vol)
460 for server_id in list_servers_ids:
461 # Detach volume from server
462 self.send_cmd('detach_volume', vpsa_srv=server_id,
463 vpsa_vol=vpsa_vol['name'])
465 def _get_volume_snapshots(self, cg_name):
466 """Get snapshots in the consistency group"""
467 data = self.send_cmd('list_vol_snapshots', cg_name=cg_name)
468 snapshots = data.get('snapshots', [])
469 return snapshots
471 def _get_snap_id(self, cg_name, snap_name):
472 """Return snapshot ID for particular volume."""
473 snapshots = self._get_volume_snapshots(cg_name)
474 for snap_vol in snapshots:
475 if snap_vol['display_name'] == snap_name:
476 return snap_vol['name']
478 return None
480 def _get_pool_capacity(self, pool_name):
481 """Return pool's total and available capacities."""
482 data = self.send_cmd('get_pool', pool_name=pool_name)
483 pool = data.get('pool')
484 if pool is not None: 484 ↛ 494line 484 didn't jump to line 494 because the condition on line 484 was always true
485 total = int(pool['capacity'])
486 free = int(pool['available_capacity'])
487 provisioned = int(pool['provisioned_capacity'])
488 LOG.debug('Pool %(name)s: %(total)sGB total, %(free)sGB free, '
489 '%(provisioned)sGB provisioned',
490 {'name': pool_name, 'total': total,
491 'free': free, 'provisioned': provisioned})
492 return total, free, provisioned
494 return 'unknown', 'unknown', 'unknown'