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
« 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.
16"""
17Quobyte driver.
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"""
23import math
25from oslo_config import cfg
26from oslo_log import log
27from oslo_utils import units
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
35LOG = log.getLogger(__name__)
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]
67CONF = cfg.CONF
68CONF.register_opts(quobyte_manila_share_opts)
71class QuobyteShareDriver(driver.ExecuteMixin, driver.ShareDriver,):
72 """Map share commands to Quobyte volumes.
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 """
88 DRIVER_VERSION = '1.2.7'
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')
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
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))
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)
134 def _update_share_stats(self):
135 total_gb, free_gb = self._get_capacities()
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)
153 def _get_capacities(self):
154 result = self.rpc.call('getSystemStatistics', {})
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
169 return total, free
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'])
178 def check_for_setup_error(self):
179 pass
181 def get_network_allocations_number(self):
182 return 0
184 def _get_project_name(self, context, project_id):
185 """Retrieve the project name.
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
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 ]})
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
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
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)]
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'))
237 volume_uuid = self._resolve_volume_name(share['name'],
238 share['project_id'])
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']
254 result = self.rpc.call('exportVolume', dict(
255 volume_uuid=volume_uuid,
256 protocol='NFS'))
258 self._resize_share(share, share['size'])
260 return self._build_share_export_string(result)
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
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 })
280 def ensure_share(self, context, share, share_server=None):
281 """Invoked to ensure that share is exported.
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 """
293 volume_uuid = self._resolve_volume_name(share['name'],
294 share['project_id'])
296 LOG.debug("Ensuring Quobyte share %s", share['name'])
298 if not volume_uuid:
299 raise (exception.ShareResourceNotFound(
300 share_id=share['id']))
302 result = self.rpc.call('exportVolume', dict(
303 volume_uuid=volume_uuid,
304 protocol='NFS'))
306 return self._build_share_export_string(result)
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'))
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)
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"]}
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
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)
345 def extend_share(self, ext_share, ext_size, share_server=None):
346 """Uses _resize_share to extend a share.
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)
354 def shrink_share(self, shrink_share, shrink_size, share_server=None):
355 """Uses _resize_share to shrink a share.
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.
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)
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.
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.
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'.
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)
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)
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)