Coverage for manila/share/drivers/quobyte/quobyte.py: 97%

140 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2026-02-18 22:19 +0000

1# Copyright (c) 2015 Quobyte 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 

16""" 

17Quobyte driver. 

18 

19Manila shares are directly mapped to Quobyte volumes. The access to the 

20shares is provided by the Quobyte NFS proxy (a Ganesha NFS server). 

21""" 

22 

23import math 

24 

25from oslo_config import cfg 

26from oslo_log import log 

27from oslo_utils import units 

28 

29from manila.common import constants 

30from manila import exception 

31from manila.i18n import _ 

32from manila.share import driver 

33from manila.share.drivers.quobyte import jsonrpc 

34 

35LOG = log.getLogger(__name__) 

36 

37quobyte_manila_share_opts = [ 

38 cfg.StrOpt('quobyte_api_url', 

39 help='URL of the Quobyte API server (http or https)'), 

40 cfg.StrOpt('quobyte_api_ca', 

41 help='The X.509 CA file to verify the server cert.'), 

42 cfg.BoolOpt('quobyte_delete_shares', 

43 default=False, 

44 help='Actually deletes shares (vs. unexport)'), 

45 cfg.StrOpt('quobyte_api_username', 

46 default='admin', 

47 help='Username for Quobyte API server.'), 

48 cfg.StrOpt('quobyte_api_password', 

49 default='quobyte', 

50 secret=True, 

51 help='Password for Quobyte API server'), 

52 cfg.StrOpt('quobyte_volume_configuration', 

53 default='BASE', 

54 help='Name of volume configuration used for new shares.'), 

55 cfg.StrOpt('quobyte_default_volume_user', 

56 default='root', 

57 help='Default owning user for new volumes.'), 

58 cfg.StrOpt('quobyte_default_volume_group', 

59 default='root', 

60 help='Default owning group for new volumes.'), 

61 cfg.StrOpt('quobyte_export_path', 

62 default='/quobyte', 

63 help='Export path for shares of this bacckend. This needs ' 

64 'to match the quobyte-nfs services "Pseudo" option.'), 

65] 

66 

67CONF = cfg.CONF 

68CONF.register_opts(quobyte_manila_share_opts) 

69 

70 

71class QuobyteShareDriver(driver.ExecuteMixin, driver.ShareDriver,): 

72 """Map share commands to Quobyte volumes. 

73 

74 Version history: 

75 1.0 - Initial driver. 

76 1.0.1 - Adds ensure_share() implementation. 

77 1.1 - Adds extend_share() and shrink_share() implementation. 

78 1.2 - Adds update_access() implementation and related methods 

79 1.2.1 - Improved capacity calculation 

80 1.2.2 - Minor optimizations 

81 1.2.3 - Updated RPC layer for improved stability 

82 1.2.4 - Fixed handling updated QB API error codes 

83 1.2.5 - Fixed two quota handling bugs 

84 1.2.6 - Fixed volume resize and jsonrpc code style bugs 

85 1.2.7 - Add quobyte_export_path option 

86 """ 

87 

88 DRIVER_VERSION = '1.2.7' 

89 

90 def __init__(self, *args, **kwargs): 

91 super(QuobyteShareDriver, self).__init__(False, *args, **kwargs) 

92 self.configuration.append_config_values(quobyte_manila_share_opts) 

93 self.backend_name = (self.configuration.safe_get('share_backend_name') 

94 or CONF.share_backend_name or 'Quobyte') 

95 

96 def _fetch_existing_access(self, context, share): 

97 volume_uuid = self._resolve_volume_name(share['name'], 

98 share['project_id']) 

99 result = self.rpc.call('getConfiguration', {}) 

100 if result is None: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 raise exception.QBException( 

102 "Could not retrieve Quobyte configuration data!") 

103 tenant_configs = result['tenant_configuration'] 

104 qb_access_list = [] 

105 for tc in tenant_configs: 

106 for va in tc['volume_access']: 

107 if va['volume_uuid'] == volume_uuid: 

108 a_level = constants.ACCESS_LEVEL_RW 

109 if va['read_only']: 

110 a_level = constants.ACCESS_LEVEL_RO 

111 qb_access_list.append({ 

112 'access_to': va['restrict_to_network'], 

113 'access_level': a_level, 

114 'access_type': 'ip' 

115 }) 

116 return qb_access_list 

117 

118 def do_setup(self, context): 

119 """Prepares the backend.""" 

120 self.rpc = jsonrpc.JsonRpc( 

121 url=self.configuration.quobyte_api_url, 

122 ca_file=self.configuration.quobyte_api_ca, 

123 user_credentials=( 

124 self.configuration.quobyte_api_username, 

125 self.configuration.quobyte_api_password)) 

126 

127 try: 

128 self.rpc.call('getInformation', {}) 

129 except Exception as exc: 

130 LOG.error("Could not connect to API: %s", exc) 

131 raise exception.QBException( 

132 _('Could not connect to API: %s') % exc) 

133 

134 def _update_share_stats(self): 

135 total_gb, free_gb = self._get_capacities() 

136 

137 data = dict( 

138 storage_protocol='NFS', 

139 vendor_name='Quobyte', 

140 share_backend_name=self.backend_name, 

141 driver_version=self.DRIVER_VERSION, 

142 total_capacity_gb=total_gb, 

143 free_capacity_gb=free_gb, 

144 reserved_percentage=self.configuration.reserved_share_percentage, 

145 reserved_snapshot_percentage=( 

146 self.configuration.reserved_share_from_snapshot_percentage 

147 or self.configuration.reserved_share_percentage), 

148 reserved_share_extend_percentage=( 

149 self.configuration.reserved_share_extend_percentage 

150 or self.configuration.reserved_share_percentage)) 

151 super(QuobyteShareDriver, self)._update_share_stats(data) 

152 

153 def _get_capacities(self): 

154 result = self.rpc.call('getSystemStatistics', {}) 

155 

156 total = float(result['total_physical_capacity']) 

157 used = float(result['total_physical_usage']) 

158 LOG.info('Read capacity of %(cap)s bytes and ' 

159 'usage of %(use)s bytes from backend. ', 

160 {'cap': total, 'use': used}) 

161 free = total - used 

162 if free < 0: 

163 free = 0 # no space available 

164 free_replicated = free / self._get_qb_replication_factor() 

165 # floor numbers to nine digits (bytes) 

166 total = math.floor((total / units.Gi) * units.G) / units.G 

167 free = math.floor((free_replicated / units.Gi) * units.G) / units.G 

168 

169 return total, free 

170 

171 def _get_qb_replication_factor(self): 

172 result = self.rpc.call('getEffectiveVolumeConfiguration', 

173 {'configuration_name': self. 

174 configuration.quobyte_volume_configuration}) 

175 return int(result['configuration']['volume_metadata_configuration'] 

176 ['replication_factor']) 

177 

178 def check_for_setup_error(self): 

179 pass 

180 

181 def get_network_allocations_number(self): 

182 return 0 

183 

184 def _get_project_name(self, context, project_id): 

185 """Retrieve the project name. 

186 

187 TODO (kaisers): retrieve the project name in order 

188 to store and use in the backend for better usability. 

189 """ 

190 return project_id 

191 

192 def _resize_share(self, share, new_size): 

193 newsize_bytes = new_size * units.Gi 

194 self.rpc.call('setQuota', {"quotas": [ 

195 {"consumer": 

196 [{"type": "VOLUME", 

197 "identifier": self._resolve_volume_name(share["name"], 

198 share['project_id']), 

199 "tenant_id": share["project_id"]}], 

200 "limits": [{"type": "LOGICAL_DISK_SPACE", 

201 "value": newsize_bytes}]} 

202 ]}) 

203 

204 def _resolve_volume_name(self, volume_name, tenant_domain): 

205 """Resolve a volume name to the global volume uuid.""" 

206 result = self.rpc.call('resolveVolumeName', dict( 

207 volume_name=volume_name, 

208 tenant_domain=tenant_domain), [jsonrpc.ERROR_ENOENT, 

209 jsonrpc.ERROR_ENTITY_NOT_FOUND]) 

210 if result: 

211 return result['volume_uuid'] 

212 return None # not found 

213 

214 def _subtract_access_lists(self, list_a, list_b): 

215 """Returns a list of elements in list_a that are not in list_b 

216 

217 :param list_a: Base list of access rules 

218 :param list_b: List of access rules not to be returned 

219 :return: List of elements of list_a not present in 

220 list_b 

221 """ 

222 sub_tuples_list = [{"to": s.get('access_to'), 

223 "type": s.get('access_type'), 

224 "level": s.get('access_level')} 

225 for s in list_b] 

226 return [r for r in list_a if ( 

227 {"to": r.get("access_to"), 

228 "type": r.get("access_type"), 

229 "level": r.get("access_level")} not in sub_tuples_list)] 

230 

231 def create_share(self, context, share, share_server=None): 

232 """Create or export a volume that is usable as a Manila share.""" 

233 if share['share_proto'] != 'NFS': 

234 raise exception.QBException( 

235 _('Quobyte driver only supports NFS shares')) 

236 

237 volume_uuid = self._resolve_volume_name(share['name'], 

238 share['project_id']) 

239 

240 if not volume_uuid: 240 ↛ 254line 240 didn't jump to line 254 because the condition on line 240 was always true

241 # create tenant, expect ERROR_GARBAGE_ARGS if it already exists 

242 self.rpc.call('setTenant', 

243 dict(tenant=dict(tenant_id=share['project_id'])), 

244 expected_errors=[jsonrpc.ERROR_GARBAGE_ARGS]) 

245 result = self.rpc.call('createVolume', dict( 

246 name=share['name'], 

247 tenant_domain=share['project_id'], 

248 root_user_id=self.configuration.quobyte_default_volume_user, 

249 root_group_id=self.configuration.quobyte_default_volume_group, 

250 configuration_name=(self.configuration. 

251 quobyte_volume_configuration))) 

252 volume_uuid = result['volume_uuid'] 

253 

254 result = self.rpc.call('exportVolume', dict( 

255 volume_uuid=volume_uuid, 

256 protocol='NFS')) 

257 

258 self._resize_share(share, share['size']) 

259 

260 return self._build_share_export_string(result) 

261 

262 def delete_share(self, context, share, share_server=None): 

263 """Delete the corresponding Quobyte volume.""" 

264 volume_uuid = self._resolve_volume_name(share['name'], 

265 share['project_id']) 

266 if not volume_uuid: 

267 LOG.warning("No volume found for " 

268 "share %(project_id)s/%(name)s", 

269 {"project_id": share['project_id'], 

270 "name": share['name']}) 

271 return 

272 

273 if self.configuration.quobyte_delete_shares: 

274 self.rpc.call('deleteVolume', {'volume_uuid': volume_uuid}) 

275 else: 

276 self.rpc.call('exportVolume', {"volume_uuid": volume_uuid, 

277 "remove_export": True, 

278 }) 

279 

280 def ensure_share(self, context, share, share_server=None): 

281 """Invoked to ensure that share is exported. 

282 

283 :param context: The `context.RequestContext` object for the request 

284 :param share: Share instance that will be checked. 

285 :param share_server: Data structure with share server information. 

286 Not used by this driver. 

287 :returns: IP:<nfs_export_path> of share 

288 :raises: 

289 :ShareResourceNotFound: If the share instance cannot be found in 

290 the backend 

291 """ 

292 

293 volume_uuid = self._resolve_volume_name(share['name'], 

294 share['project_id']) 

295 

296 LOG.debug("Ensuring Quobyte share %s", share['name']) 

297 

298 if not volume_uuid: 

299 raise (exception.ShareResourceNotFound( 

300 share_id=share['id'])) 

301 

302 result = self.rpc.call('exportVolume', dict( 

303 volume_uuid=volume_uuid, 

304 protocol='NFS')) 

305 

306 return self._build_share_export_string(result) 

307 

308 def _allow_access(self, context, share, access, share_server=None): 

309 """Allow access to a share.""" 

310 if access['access_type'] != 'ip': 

311 raise exception.InvalidShareAccess( 

312 _('Quobyte driver only supports ip access control')) 

313 

314 volume_uuid = self._resolve_volume_name(share['name'], 

315 share['project_id']) 

316 ro = access['access_level'] == (constants.ACCESS_LEVEL_RO) 

317 call_params = { 

318 "volume_uuid": volume_uuid, 

319 "read_only": ro, 

320 "add_allow_ip": access['access_to']} 

321 self.rpc.call('exportVolume', call_params) 

322 

323 def _build_share_export_string(self, rpc_result): 

324 return '%(nfs_server_ip)s:%(qb_exp_path)s%(nfs_export_path)s' % { 

325 "nfs_server_ip": rpc_result["nfs_server_ip"], 

326 "qb_exp_path": self.configuration.quobyte_export_path, 

327 "nfs_export_path": rpc_result["nfs_export_path"]} 

328 

329 def _deny_access(self, context, share, access, share_server=None): 

330 """Remove white-list ip from a share.""" 

331 if access['access_type'] != 'ip': 

332 LOG.debug('Quobyte driver only supports ip access control. ' 

333 'Ignoring deny access call for %s , %s', 

334 share['name'], 

335 self._get_project_name(context, share['project_id'])) 

336 return 

337 

338 volume_uuid = self._resolve_volume_name(share['name'], 

339 share['project_id']) 

340 call_params = { 

341 "volume_uuid": volume_uuid, 

342 "remove_allow_ip": access['access_to']} 

343 self.rpc.call('exportVolume', call_params) 

344 

345 def extend_share(self, ext_share, ext_size, share_server=None): 

346 """Uses _resize_share to extend a share. 

347 

348 :param ext_share: Share model. 

349 :param ext_size: New size of share (new_size > share['size']). 

350 :param share_server: Currently not used. 

351 """ 

352 self._resize_share(share=ext_share, new_size=ext_size) 

353 

354 def shrink_share(self, shrink_share, shrink_size, share_server=None): 

355 """Uses _resize_share to shrink a share. 

356 

357 Quobyte uses soft quotas. If a shares current size is bigger than 

358 the new shrunken size no data is lost. Data can be continuously read 

359 from the share but new writes receive out of disk space replies. 

360 

361 :param shrink_share: Share model. 

362 :param shrink_size: New size of share (new_size < share['size']). 

363 :param share_server: Currently not used. 

364 """ 

365 self._resize_share(share=shrink_share, new_size=shrink_size) 

366 

367 def update_access(self, context, share, access_rules, add_rules, 

368 delete_rules, update_rules, share_server=None): 

369 """Update access rules for given share. 

370 

371 Two different cases are supported in here: 

372 1. Recovery after error - 'access_rules' contains all access_rules, 

373 'add_rules' and 'delete_rules' are empty. Driver should apply all 

374 access rules for given share. 

375 

376 2. Adding/Deleting of several access rules - 'access_rules' contains 

377 all access_rules, 'add_rules' and 'delete_rules' contain rules which 

378 should be added/deleted. Driver can ignore rules in 'access_rules' and 

379 apply only rules from 'add_rules' and 'delete_rules'. 

380 

381 :param context: Current context 

382 :param share: Share model with share data. 

383 :param access_rules: All access rules for given share 

384 :param add_rules: Empty List or List of access rules which should be 

385 added. access_rules already contains these rules. 

386 :param delete_rules: Empty List or List of access rules which should be 

387 removed. access_rules doesn't contain these rules. 

388 :param update_rules: Empty List or List of access rules which should be 

389 updated. access_rules already contains these rules. 

390 :param share_server: None or Share server model 

391 :raises If all of the *_rules params are None the method raises an 

392 InvalidShareAccess exception 

393 """ 

394 if (add_rules or delete_rules): 

395 # Handling access rule update 

396 for d_rule in delete_rules: 

397 self._deny_access(context, share, d_rule) 

398 for a_rule in add_rules: 

399 self._allow_access(context, share, a_rule) 

400 else: 

401 if not access_rules: 

402 LOG.warning("No access rules provided in update_access.") 

403 else: 

404 # Handling access rule recovery 

405 existing_rules = self._fetch_existing_access(context, share) 

406 

407 missing_rules = self._subtract_access_lists(access_rules, 

408 existing_rules) 

409 for a_rule in missing_rules: 

410 LOG.debug("Adding rule %s in recovery.", 

411 str(a_rule)) 

412 self._allow_access(context, share, a_rule) 

413 

414 superfluous_rules = self._subtract_access_lists(existing_rules, 

415 access_rules) 

416 for d_rule in superfluous_rules: 

417 LOG.debug("Removing rule %s in recovery.", 

418 str(d_rule)) 

419 self._deny_access(context, share, d_rule)