# Copyright (c) 2014 Red Hat, Inc. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import os import pipes import re import sys from oslo_log import log from oslo_serialization import jsonutils import six from manila import exception from manila.i18n import _ from manila.share.drivers.ganesha import utils as ganesha_utils from manila import utils LOG = log.getLogger(__name__) IWIDTH = 4 def _conf2json(conf): """Convert Ganesha config to JSON.""" # tokenize config string token_list = [six.StringIO()] state = { 'in_quote': False, 'in_comment': False, 'escape': False, } cbk = [] for char in conf: if state['in_quote']: if not state['escape']: if char == '"': state['in_quote'] = False cbk.append(lambda: token_list.append(six.StringIO())) elif char == '\\': cbk.append(lambda: state.update({'escape': True})) else: if char == "#": state['in_comment'] = True if state['in_comment']: if char == "\n": state['in_comment'] = False else: if char == '"': token_list.append(six.StringIO()) state['in_quote'] = True state['escape'] = False if not state['in_comment']: token_list[-1].write(char) while cbk: cbk.pop(0)() if state['in_quote']: raise RuntimeError("Unterminated quoted string") # jsonify tokens js_token_list = ["{"] for tok in token_list: tok = tok.getvalue() if tok[0] == '"': js_token_list.append(tok) continue for pat, s in [ # add omitted "=" signs to block openings ('([^=\s])\s*{', '\\1={'), # delete trailing semicolons in blocks (';\s*}', '}'), # add omitted semicolons after blocks ('}\s*([^}\s])', '};\\1'), # separate syntactically significant characters ('([;{}=])', ' \\1 ')]: tok = re.sub(pat, s, tok) # map tokens to JSON equivalents for word in tok.split(): if word == "=": word = ":" elif word == ";": word = ',' elif (word in ['{', '}'] or re.search('\A-?[1-9]\d*(\.\d+)?\Z', word)): pass else: word = jsonutils.dumps(word) js_token_list.append(word) js_token_list.append("}") # group quoted strings token_grp_list = [] for tok in js_token_list: if tok[0] == '"': if not (token_grp_list and isinstance(token_grp_list[-1], list)): token_grp_list.append([]) token_grp_list[-1].append(tok) else: token_grp_list.append(tok) # process quoted string groups by joining them js_token_list2 = [] for x in token_grp_list: if isinstance(x, list): x = ''.join(['"'] + [tok[1:-1] for tok in x] + ['"']) js_token_list2.append(x) return ''.join(js_token_list2) def _dump_to_conf(confdict, out=sys.stdout, indent=0): """Output confdict in Ganesha config format.""" if isinstance(confdict, dict): for k, v in confdict.items(): if v is None: continue if isinstance(v, dict): out.write(' ' * (indent * IWIDTH) + k + ' ') out.write("{\n") _dump_to_conf(v, out, indent + 1) out.write(' ' * (indent * IWIDTH) + '}') elif isinstance(v, list): for item in v: out.write(' ' * (indent * IWIDTH) + k + ' ') out.write("{\n") _dump_to_conf(item, out, indent + 1) out.write(' ' * (indent * IWIDTH) + '}\n') else: out.write(' ' * (indent * IWIDTH) + k + ' ') out.write('= ') _dump_to_conf(v, out, indent) out.write(';') out.write('\n') else: dj = jsonutils.dumps(confdict) if confdict == dj[1:-1]: out.write(confdict) else: out.write(dj) def parseconf(conf): """Parse Ganesha config. Both native format and JSON are supported. Convert config to a (nested) dictionary. """ def list_to_dict(l): # Convert a list of key-value pairs stored as tuples to a dict. # For tuples with identical keys, preserve all the values in a # list. e.g., argument [('k', 'v1'), ('k', 'v2')] to function # returns {'k': ['v1', 'v2']}. d = {} for i in l: if isinstance(i, tuple): k, v = i if isinstance(v, list): v = list_to_dict(v) if k in d: d[k] = [d[k]] d[k].append(v) else: d[k] = v return d try: # allow config to be specified in JSON -- # for sake of people who might feel Ganesha config foreign. d = jsonutils.loads(conf) except ValueError: # Customize JSON decoder to convert Ganesha config to a list # of key-value pairs stored as tuples. This allows multiple # occurrences of a config block to be later converted to a # dict key-value pair, with block name being the key and a # list of block contents being the value. l = jsonutils.loads(_conf2json(conf), object_pairs_hook=lambda x: x) d = list_to_dict(l) return d def mkconf(confdict): """Create Ganesha config string from confdict.""" s = six.StringIO() _dump_to_conf(confdict, s) return s.getvalue() class GaneshaManager(object): """Ganesha instrumentation class.""" def __init__(self, execute, tag, **kwargs): self.confrx = re.compile('\.conf\Z') self.ganesha_config_path = kwargs['ganesha_config_path'] self.tag = tag def _execute(*args, **kwargs): msg = kwargs.pop('message', args[0]) makelog = kwargs.pop('makelog', True) try: return execute(*args, **kwargs) except exception.ProcessExecutionError as e: if makelog: LOG.error( ("Error while executing management command on " "Ganesha node %(tag)s: %(msg)s."), {'tag': tag, 'msg': msg}) raise exception.GaneshaCommandFailure( stdout=e.stdout, stderr=e.stderr, exit_code=e.exit_code, cmd=e.cmd) self.execute = _execute self.ganesha_export_dir = kwargs['ganesha_export_dir'] self.execute('mkdir', '-p', self.ganesha_export_dir) self.ganesha_db_path = kwargs['ganesha_db_path'] self.execute('mkdir', '-p', os.path.dirname(self.ganesha_db_path)) self.ganesha_service = kwargs['ganesha_service_name'] # Here we are to make sure that an SQLite database of the # required scheme exists at self.ganesha_db_path. # The following command gets us there -- provided the file # does not yet exist (otherwise it just fails). However, # we don't care about this condition, we just execute the # command unconditionally (ignoring failure). Instead we # directly query the db right after, to check its validity. self.execute("sqlite3", self.ganesha_db_path, 'create table ganesha(key varchar(20) primary key, ' 'value int); insert into ganesha values("exportid", ' '100);', run_as_root=False, check_exit_code=False) self.get_export_id(bump=False) def _getpath(self, name): """Get the path of config file for name.""" return os.path.join(self.ganesha_export_dir, name + ".conf") def _write_file(self, path, data): """Write data to path atomically.""" dirpath, fname = (getattr(os.path, q + "name")(path) for q in ("dir", "base")) tmpf = self.execute('mktemp', '-p', dirpath, "-t", fname + ".XXXXXX")[0][:-1] self.execute( 'sh', '-c', 'echo %s > %s' % (pipes.quote(data), pipes.quote(tmpf)), message='writing ' + tmpf) self.execute('mv', tmpf, path) def _write_conf_file(self, name, data): """Write data to config file for name atomically.""" path = self._getpath(name) self._write_file(path, data) return path def _mkindex(self): """Generate the index file for current exports.""" @utils.synchronized("ganesha-index-" + self.tag, external=True) def _mkindex(): files = filter(lambda f: self.confrx.search(f) and f != "INDEX.conf", self.execute('ls', self.ganesha_export_dir, run_as_root=False)[0].split("\n")) index = "".join(map(lambda f: "%include " + os.path.join( self.ganesha_export_dir, f) + "\n", files)) self._write_conf_file("INDEX", index) _mkindex() def _read_export_file(self, name): """Return the dict of the export identified by name.""" return parseconf(self.execute("cat", self._getpath(name), message='reading export ' + name)[0]) def _check_export_file_exists(self, name): """Check whether export exists.""" try: self.execute('test', '-f', self._getpath(name), makelog=False, run_as_root=False) return True except exception.GaneshaCommandFailure as e: if e.exit_code == 1: return False else: raise def _write_export_file(self, name, confdict): """Write confdict to the export file of name.""" for k, v in ganesha_utils.walk(confdict): # values in the export block template that need to be # filled in by Manila are pre-fixed by '@' if isinstance(v, six.string_types) and v[0] == '@': msg = _("Incomplete export block: value %(val)s of attribute " "%(key)s is a stub.") % {'key': k, 'val': v} raise exception.InvalidParameterValue(err=msg) return self._write_conf_file(name, mkconf(confdict)) def _rm_export_file(self, name): """Remove export file of name.""" self.execute("rm", self._getpath(name)) def _dbus_send_ganesha(self, method, *args, **kwargs): """Send a message to Ganesha via dbus.""" service = kwargs.pop("service", "exportmgr") self.execute("dbus-send", "--print-reply", "--system", "--dest=org.ganesha.nfsd", "/org/ganesha/nfsd/ExportMgr", "org.ganesha.nfsd.%s.%s" % (service, method), *args, message='dbus call %s.%s' % (service, method), **kwargs) def _remove_export_dbus(self, xid): """Remove an export from Ganesha runtime with given export id.""" self._dbus_send_ganesha("RemoveExport", "uint16:%d" % xid) def add_export(self, name, confdict): """Add an export to Ganesha specified by confdict.""" xid = confdict["EXPORT"]["Export_Id"] undos = [] _mkindex_called = False try: path = self._write_export_file(name, confdict) undos.append(lambda: self._rm_export_file(name)) self._dbus_send_ganesha("AddExport", "string:" + path, "string:EXPORT(Export_Id=%d)" % xid) undos.append(lambda: self._remove_export_dbus(xid)) _mkindex_called = True self._mkindex() except Exception: for u in undos: u() if not _mkindex_called: self._mkindex() raise def update_export(self, name, confdict): """Update an export to Ganesha specified by confdict.""" xid = confdict["EXPORT"]["Export_Id"] old_confdict = self._read_export_file(name) path = self._write_export_file(name, confdict) try: self._dbus_send_ganesha("UpdateExport", "string:" + path, "string:EXPORT(Export_Id=%d)" % xid) except Exception: # Revert the export file update. self._write_export_file(name, old_confdict) raise def remove_export(self, name): """Remove an export from Ganesha.""" try: confdict = self._read_export_file(name) self._remove_export_dbus(confdict["EXPORT"]["Export_Id"]) finally: self._rm_export_file(name) self._mkindex() def get_export_id(self, bump=True): """Get a new export id.""" # XXX overflowing the export id (16 bit unsigned integer) # is not handled if bump: bumpcode = 'update ganesha set value = value + 1;' else: bumpcode = '' out = self.execute( "sqlite3", self.ganesha_db_path, bumpcode + 'select * from ganesha where key = "exportid";', run_as_root=False)[0] match = re.search('\Aexportid\|(\d+)$', out) if not match: LOG.error("Invalid export database on " "Ganesha node %(tag)s: %(db)s.", {'tag': self.tag, 'db': self.ganesha_db_path}) raise exception.InvalidSqliteDB() return int(match.groups()[0]) def restart_service(self): """Restart the Ganesha service.""" self.execute("service", self.ganesha_service, "restart") def reset_exports(self): """Delete all export files.""" self.execute('sh', '-c', 'rm -f %s/*.conf' % pipes.quote(self.ganesha_export_dir)) self._mkindex()