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

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. 

15 

16"""Common GlussterFS routines.""" 

17 

18 

19import re 

20 

21from defusedxml import ElementTree as etree 

22from oslo_config import cfg 

23from oslo_log import log 

24 

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 

30 

31LOG = log.getLogger(__name__) 

32 

33 

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] 

44 

45 

46CONF = cfg.CONF 

47CONF.register_opts(glusterfs_common_opts) 

48 

49 

50def _check_volume_presence(f): 

51 

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) 

57 

58 return wrapper 

59 

60 

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 

74 

75 

76class GlusterManager(object): 

77 """Interface with a GlusterFS volume.""" 

78 

79 scheme = re.compile(r'\A(?:(?P<user>[^:@/]+)@)?' 

80 r'(?P<host>[^:@/]+)' 

81 r'(?::/(?P<volume>[^/]+)(?P<path>/.*)?)?\Z') 

82 

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

89 

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

98 

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

104 

105 def __init__(self, address, execf=None, path_to_private_key=None, 

106 remote_server_password=None, requires={}): 

107 """Initialize a GlusterManager instance. 

108 

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

121 

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) 

144 

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) 

151 

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) 

156 

157 @property 

158 def host_access(self): 

159 return '@'.join(filter(None, (self.user, self.host))) 

160 

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 

168 

169 @property 

170 def qualified(self): 

171 return self._build_uri(self.host_access) 

172 

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) 

177 

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) 

187 

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

195 

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

214 

215 return _gluster_call 

216 

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

246 

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

251 

252 if not out: 

253 raise exception.GlusterfsException( 

254 'gluster volume info %s: no data received' % 

255 self.volume 

256 ) 

257 

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 

264 

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) 

270 

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) 

275 

276 out, err = self.gluster_call(*args, check_exit_code=False) 

277 

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 

283 

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) 

291 

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

297 

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

315 

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) 

328 

329 def get_gluster_version(self): 

330 """Retrieve GlusterFS version. 

331 

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 

350 

351 def check_gluster_version(self, minvers): 

352 """Retrieve and check GlusterFS version. 

353 

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

365 

366 

367def numreduct(vers): 

368 """The numeric reduct of a tuple of strings. 

369 

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) 

380 

381 

382def _mount_gluster_vol(execute, gluster_export, mount_path, ensure=False): 

383 """Mount a GlusterFS volume at the specified mount path. 

384 

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 ) 

400 

401 

402def _umount_gluster_vol(mount_path): 

403 """Unmount a GlusterFS volume at the specified mount path. 

404 

405 :param mount_path: path where volume is mounted 

406 """ 

407 

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) 

416 

417 

418def _restart_gluster_vol(gluster_mgr): 

419 """Restart a GlusterFS volume through its manager. 

420 

421 :param gluster_mgr: GlusterManager instance 

422 """ 

423 

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) 

433 

434 gluster_mgr.gluster_call( 

435 'volume', 'start', gluster_mgr.volume, 

436 log=("starting GlusterFS volume %s") % gluster_mgr.volume)