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

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. 

15 

16import json 

17import re 

18 

19from oslo_config import cfg 

20from oslo_log import log as logging 

21from oslo_utils import netutils 

22import requests 

23 

24LOG = logging.getLogger(__name__) 

25 

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 

30 

31 

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 

37 

38 class UnknownCmd(Exception): 

39 def __init__(self, cmd): 

40 self.cmd = cmd 

41 

42 class BadHTTPResponseStatus(Exception): 

43 def __init__(self, status): 

44 self.status = status 

45 

46 class FailedCmdWithDump(Exception): 

47 def __init__(self, status, data): 

48 self.status = status 

49 self.data = data 

50 

51 class SessionRequestException(Exception): 

52 def __init__(self, msg): 

53 self.msg = msg 

54 

55 class ZadaraInvalidAccessKey(Exception): 

56 pass 

57 

58 

59exception = CommonException() 

60 

61 

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.")] 

103 

104 

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.""" 

109 

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' 

119 

120 def _generate_vpsa_cmd(self, cmd, **kwargs): 

121 """Generate command to be sent to VPSA.""" 

122 

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')}), 

206 

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) 

252 

253 if method == 'GET': 

254 params = dict(page=1, start=0, limit=0) 

255 body = None 

256 

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 

260 

261 else: 

262 msg = ('Method %(method)s is not defined' % {'method': method}) 

263 LOG.error(msg) 

264 raise AssertionError(msg) 

265 

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} 

269 

270 return method, url, params, body, headers 

271 

272 def send_cmd(self, cmd, **kwargs): 

273 """Send command to VPSA Controller.""" 

274 

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() 

277 

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}) 

282 

283 host = self._get_target_host(self.conf.zadara_vpsa_host) 

284 port = int(self.conf.zadara_vpsa_port) 

285 

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 

295 

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) 

300 

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) 

310 

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) 

314 

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() 

322 

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) 

325 

326 if method in ['POST', 'DELETE']: 

327 LOG.debug('Operation completed with status code %(status)s', 

328 {'status': status}) 

329 return response 

330 

331 def _get_target_host(self, vpsa_host): 

332 """Helper for target host formatting.""" 

333 return netutils.escape_ipv6(vpsa_host) 

334 

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 

344 

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 

354 

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() 

362 

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'] 

374 

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 

380 

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'] 

385 

386 def _get_volume_cg_name(self, name): 

387 """Return name of the consistency group for the volume. 

388 

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'] 

395 

396 return None 

397 

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'] 

402 

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 = [] 

410 

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) 

414 

415 return volumes 

416 

417 def _get_server_name(self, initiator, share): 

418 """Return VPSA's name for server object. 

419 

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 

432 

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 

444 

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 

452 

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) 

459 

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']) 

464 

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 

470 

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'] 

477 

478 return None 

479 

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 

493 

494 return 'unknown', 'unknown', 'unknown'