Coverage for manila/share/drivers/glusterfs/common.py: 98%
214 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 Red Hat, 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"""Common GlussterFS routines."""
19import re
21from defusedxml import ElementTree as etree
22from oslo_config import cfg
23from oslo_log import log
25from manila import exception
26from manila.i18n import _
27from manila.privsep import os as privsep_os
28from manila.share.drivers.ganesha import utils as ganesha_utils
29from manila import utils
31LOG = log.getLogger(__name__)
34glusterfs_common_opts = [
35 cfg.StrOpt('glusterfs_server_password',
36 secret=True,
37 help='Remote GlusterFS server node\'s login password. '
38 'This is not required if '
39 '\'glusterfs_path_to_private_key\' is '
40 'configured.'),
41 cfg.StrOpt('glusterfs_path_to_private_key',
42 help='Path of Manila host\'s private SSH key file.'),
43]
46CONF = cfg.CONF
47CONF.register_opts(glusterfs_common_opts)
50def _check_volume_presence(f):
52 def wrapper(self, *args, **kwargs):
53 if not self.components.get('volume'):
54 raise exception.GlusterfsException(
55 _("Gluster address does not have a volume component."))
56 return f(self, *args, **kwargs)
58 return wrapper
61def volxml_get(xmlout, *paths, **kwargs):
62 """Attempt to extract a value by a set of Xpaths from XML."""
63 for path in paths:
64 value = xmlout.find(path)
65 if value is not None:
66 break
67 if value is None:
68 if 'default' in kwargs:
69 return kwargs['default']
70 raise exception.InvalidShare(
71 _("Volume query response XML has no value for any of "
72 "the following Xpaths: %s") % ", ".join(paths))
73 return value.text
76class GlusterManager(object):
77 """Interface with a GlusterFS volume."""
79 scheme = re.compile(r'\A(?:(?P<user>[^:@/]+)@)?'
80 r'(?P<host>[^:@/]+)'
81 r'(?::/(?P<volume>[^/]+)(?P<path>/.*)?)?\Z')
83 # See this about GlusterFS' convention for Boolean interpretation
84 # of strings:
85 # https://github.com/gluster/glusterfs/blob/v3.7.8/
86 # libglusterfs/src/common-utils.c#L1680-L1708
87 GLUSTERFS_TRUE_VALUES = ('ON', 'YES', 'TRUE', 'ENABLE', '1')
88 GLUSTERFS_FALSE_VALUES = ('OFF', 'NO', 'FALSE', 'DISABLE', '0')
90 @classmethod
91 def parse(cls, address):
92 """Parse address string into component dict."""
93 m = cls.scheme.search(address)
94 if not m:
95 raise exception.GlusterfsException(
96 _('Invalid gluster address %s.') % address)
97 return m.groupdict()
99 def __getattr__(self, attr):
100 if attr in self.components:
101 return self.components[attr]
102 raise AttributeError("'%(typ)s' object has no attribute '%(attr)s'" %
103 {'typ': type(self).__name__, 'attr': attr})
105 def __init__(self, address, execf=None, path_to_private_key=None,
106 remote_server_password=None, requires={}):
107 """Initialize a GlusterManager instance.
109 :param address: the Gluster URI (either string of
110 [<user>@]<host>[:/<volume>[/<path>]] format or
111 component dict with "user", "host", "volume",
112 "path" keys).
113 :param execf: executor function for management commands.
114 :param path_to_private_key: path to private ssh key of remote server.
115 :param remote_server_password: ssh password for remote server.
116 :param requires: a dict mapping some of the component names to
117 either True or False; having it specified,
118 respectively, the presence or absence of the
119 given component in the uri will be enforced.
120 """
122 if isinstance(address, dict):
123 tmp_addr = ""
124 if address.get('user') is not None:
125 tmp_addr = address.get('user') + '@'
126 if address.get('host') is not None:
127 tmp_addr += address.get('host')
128 if address.get('volume') is not None:
129 tmp_addr += ':/' + address.get('volume')
130 if address.get('path') is not None:
131 tmp_addr += address.get('path')
132 self.components = self.parse(tmp_addr)
133 # Verify that the original dictionary matches the parsed
134 # dictionary. This will flag typos such as {'volume': 'vol/err'}
135 # in the original dictionary as errors. Additionally,
136 # extra keys will need to be flagged as an error.
137 sanitized_address = {key: None for key in self.scheme.groupindex}
138 sanitized_address.update(address)
139 if sanitized_address != self.components:
140 raise exception.GlusterfsException(
141 _('Invalid gluster address %s.') % address)
142 else:
143 self.components = self.parse(address)
145 for k, v in requires.items():
146 if v is None:
147 continue
148 if (self.components.get(k) is not None) != v:
149 raise exception.GlusterfsException(
150 _('Invalid gluster address %s.') % address)
152 self.path_to_private_key = path_to_private_key
153 self.remote_server_password = remote_server_password
154 if execf:
155 self.gluster_call = self.make_gluster_call(execf)
157 @property
158 def host_access(self):
159 return '@'.join(filter(None, (self.user, self.host)))
161 def _build_uri(self, base):
162 u = base
163 for sep, comp in ((':/', 'volume'), ('', 'path')): 163 ↛ 167line 163 didn't jump to line 167 because the loop on line 163 didn't complete
164 if self.components[comp] is None:
165 break
166 u = sep.join((u, self.components[comp]))
167 return u
169 @property
170 def qualified(self):
171 return self._build_uri(self.host_access)
173 @property
174 def export(self):
175 if self.volume: 175 ↛ exitline 175 didn't return from function 'export' because the condition on line 175 was always true
176 return self._build_uri(self.host)
178 def make_gluster_call(self, execf):
179 """Execute a Gluster command locally or remotely."""
180 if self.user:
181 gluster_execf = ganesha_utils.SSHExecutor(
182 self.host, 22, None, self.user,
183 password=self.remote_server_password,
184 privatekey=self.path_to_private_key)
185 else:
186 gluster_execf = ganesha_utils.RootExecutor(execf)
188 def _gluster_call(*args, **kwargs):
189 logmsg = kwargs.pop('log', None)
190 error_policy = kwargs.pop('error_policy', 'coerce')
191 if (error_policy not in ('raw', 'coerce', 'suppress') and
192 not isinstance(error_policy[0], int)):
193 raise TypeError(_("undefined error_policy %s") %
194 repr(error_policy))
196 try:
197 return gluster_execf(*(('gluster',) + args), **kwargs)
198 except exception.ProcessExecutionError as exc:
199 if error_policy == 'raw':
200 raise
201 elif error_policy == 'coerce':
202 pass
203 elif (error_policy == 'suppress' or
204 exc.exit_code in error_policy):
205 return
206 if logmsg: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true
207 LOG.error("%s: GlusterFS instrumentation failed.",
208 logmsg)
209 raise exception.GlusterfsException(
210 _("GlusterFS management command '%(cmd)s' failed "
211 "with details as follows:\n%(details)s.") % {
212 'cmd': ' '.join(args),
213 'details': exc})
215 return _gluster_call
217 def xml_response_check(self, xmlout, command, countpath=None):
218 """Sanity check for GlusterFS XML response."""
219 commandstr = ' '.join(command)
220 ret = {}
221 for e in 'opRet', 'opErrno':
222 ret[e] = int(volxml_get(xmlout, e))
223 if ret == {'opRet': -1, 'opErrno': 0}:
224 raise exception.GlusterfsException(_(
225 'GlusterFS command %(command)s on volume %(volume)s failed'
226 ) % {'volume': self.volume, 'command': command})
227 if list(ret.values()) != [0, 0]:
228 errdct = {'volume': self.volume, 'command': commandstr,
229 'opErrstr': volxml_get(xmlout, 'opErrstr', default=None)}
230 errdct.update(ret)
231 raise exception.InvalidShare(_(
232 'GlusterFS command %(command)s on volume %(volume)s got '
233 'unexpected response: '
234 'opRet=%(opRet)s, opErrno=%(opErrno)s, opErrstr=%(opErrstr)s'
235 ) % errdct)
236 if not countpath:
237 return
238 count = volxml_get(xmlout, countpath)
239 if count != '1':
240 raise exception.InvalidShare(
241 _('GlusterFS command %(command)s on volume %(volume)s got '
242 'ambiguous response: '
243 '%(count)s records') % {
244 'volume': self.volume, 'command': commandstr,
245 'count': count})
247 def _get_vol_option_via_info(self, option):
248 """Get the value of an option set on a GlusterFS volume via volinfo."""
249 args = ('--xml', 'volume', 'info', self.volume)
250 out, err = self.gluster_call(*args, log=("retrieving volume info"))
252 if not out:
253 raise exception.GlusterfsException(
254 'gluster volume info %s: no data received' %
255 self.volume
256 )
258 volxml = etree.fromstring(out)
259 self.xml_response_check(volxml, args[1:], './volInfo/volumes/count')
260 for e in volxml.findall(".//option"):
261 o, v = (volxml_get(e, a) for a in ('name', 'value'))
262 if o == option: 262 ↛ 260line 262 didn't jump to line 260 because the condition on line 262 was always true
263 return v
265 @_check_volume_presence
266 def _get_vol_user_option(self, useropt):
267 """Get the value of an user option set on a GlusterFS volume."""
268 option = '.'.join(('user', useropt))
269 return self._get_vol_option_via_info(option)
271 @_check_volume_presence
272 def _get_vol_regular_option(self, option):
273 """Get the value of a regular option set on a GlusterFS volume."""
274 args = ('--xml', 'volume', 'get', self.volume, option)
276 out, err = self.gluster_call(*args, check_exit_code=False)
278 if not out:
279 # all input is valid, but the option has not been set
280 # (nb. some options do come by a null value, but some
281 # don't even have that, see eg. cluster.nufa)
282 return
284 try:
285 optxml = etree.fromstring(out)
286 except Exception:
287 # non-xml output indicates that GlusterFS backend does not support
288 # 'vol get', we fall back to 'vol info' based retrieval (glusterfs
289 # < 3.7).
290 return self._get_vol_option_via_info(option)
292 self.xml_response_check(optxml, args[1:], './volGetopts/count')
293 # the Xpath has changed from first to second as of GlusterFS
294 # 3.7.14 (see http://review.gluster.org/14931).
295 return volxml_get(optxml, './volGetopts/Value',
296 './volGetopts/Opt/Value')
298 def get_vol_option(self, option, boolean=False):
299 """Get the value of an option set on a GlusterFS volume."""
300 useropt = re.sub(r'\Auser\.', '', option)
301 if option == useropt:
302 value = self._get_vol_regular_option(option)
303 else:
304 value = self._get_vol_user_option(useropt)
305 if not boolean or value is None:
306 return value
307 if value.upper() in self.GLUSTERFS_TRUE_VALUES:
308 return True
309 if value.upper() in self.GLUSTERFS_FALSE_VALUES:
310 return False
311 raise exception.GlusterfsException(_(
312 "GlusterFS volume option on volume %(volume)s: "
313 "%(option)s=%(value)s cannot be interpreted as Boolean") % {
314 'volume': self.volume, 'option': option, 'value': value})
316 @_check_volume_presence
317 @utils.retry(retry_param=exception.GlusterfsException)
318 def set_vol_option(self, option, value, ignore_failure=False):
319 value = {True: self.GLUSTERFS_TRUE_VALUES[0],
320 False: self.GLUSTERFS_FALSE_VALUES[0]}.get(value, value)
321 if value is None:
322 args = ('reset', (option,))
323 else:
324 args = ('set', (option, value))
325 policy = (1,) if ignore_failure else 'coerce'
326 self.gluster_call(
327 'volume', args[0], self.volume, *args[1], error_policy=policy)
329 def get_gluster_version(self):
330 """Retrieve GlusterFS version.
332 :returns: version (as tuple of strings, example: ('3', '6', '0beta2'))
333 """
334 out, err = self.gluster_call('--version',
335 log=("GlusterFS version query"))
336 try:
337 owords = out.split()
338 if owords[0] != 'glusterfs':
339 raise RuntimeError
340 vers = owords[1].split('.')
341 # provoke an exception if vers does not start with two numerals
342 int(vers[0])
343 int(vers[1])
344 except Exception:
345 raise exception.GlusterfsException(
346 _("Cannot parse version info obtained from server "
347 "%(server)s, version info: %(info)s") %
348 {'server': self.host, 'info': out})
349 return vers
351 def check_gluster_version(self, minvers):
352 """Retrieve and check GlusterFS version.
354 :param minvers: minimum version to require
355 (given as tuple of integers, example: (3, 6))
356 """
357 vers = self.get_gluster_version()
358 if numreduct(vers) < minvers:
359 raise exception.GlusterfsException(_(
360 "Unsupported GlusterFS version %(version)s on server "
361 "%(server)s, minimum requirement: %(minvers)s") % {
362 'server': self.host,
363 'version': '.'.join(vers),
364 'minvers': '.'.join(str(c) for c in minvers)})
367def numreduct(vers):
368 """The numeric reduct of a tuple of strings.
370 That is, applying an integer conversion map on the longest
371 initial segment of vers which consists of numerals.
372 """
373 numvers = []
374 for c in vers:
375 try:
376 numvers.append(int(c))
377 except ValueError:
378 break
379 return tuple(numvers)
382def _mount_gluster_vol(execute, gluster_export, mount_path, ensure=False):
383 """Mount a GlusterFS volume at the specified mount path.
385 :param execute: command execution function
386 :param gluster_export: GlusterFS export to mount
387 :param mount_path: path to mount at
388 :param ensure: boolean to allow remounting a volume with a warning
389 """
390 execute('mkdir', '-p', mount_path)
391 try:
392 privsep_os.mount(gluster_export, mount_path, mount_type='glusterfs')
393 except exception.ProcessExecutionError as exc:
394 if ensure and 'already mounted' in exc.stderr:
395 LOG.warning("%s is already mounted.", gluster_export)
396 else:
397 raise exception.GlusterfsException(
398 'Unable to mount Gluster volume'
399 )
402def _umount_gluster_vol(mount_path):
403 """Unmount a GlusterFS volume at the specified mount path.
405 :param mount_path: path where volume is mounted
406 """
408 try:
409 privsep_os.umount(mount_path)
410 except exception.ProcessExecutionError as exc:
411 msg = (_("Unable to unmount gluster volume. "
412 "mount_dir: %(mount_path)s, Error: %(error)s") %
413 {'mount_path': mount_path, 'error': exc.stderr})
414 LOG.error(msg)
415 raise exception.GlusterfsException(msg)
418def _restart_gluster_vol(gluster_mgr):
419 """Restart a GlusterFS volume through its manager.
421 :param gluster_mgr: GlusterManager instance
422 """
424 # TODO(csaba): '--mode=script' ensures that the Gluster CLI runs in
425 # script mode. This seems unnecessary as the Gluster CLI is
426 # expected to run in non-interactive mode when the stdin is not
427 # a terminal, as is the case below. But on testing, found the
428 # behaviour of Gluster-CLI to be the contrary. Need to investigate
429 # this odd-behaviour of Gluster-CLI.
430 gluster_mgr.gluster_call(
431 'volume', 'stop', gluster_mgr.volume, '--mode=script',
432 log=("stopping GlusterFS volume %s") % gluster_mgr.volume)
434 gluster_mgr.gluster_call(
435 'volume', 'start', gluster_mgr.volume,
436 log=("starting GlusterFS volume %s") % gluster_mgr.volume)