From bbc463f6794812d1777deaa4a23aa74bc37725f6 Mon Sep 17 00:00:00 2001 From: Chris MacNaughton Date: Wed, 26 Jan 2022 17:07:55 +0100 Subject: [PATCH] Migrate to a more fully-formed export parser --- src/ganesha.py | 67 +++++-------- src/manager.py | 200 +++++++++++++++++++++++++++++++++++++ tox.ini | 2 +- unit_tests/test_ganesha.py | 3 +- 4 files changed, 227 insertions(+), 45 deletions(-) create mode 100644 src/manager.py diff --git a/src/ganesha.py b/src/ganesha.py index e352fd4..906d2a8 100644 --- a/src/ganesha.py +++ b/src/ganesha.py @@ -4,8 +4,9 @@ import json import logging +import manager import subprocess -from typing import List, Optional +from typing import Dict, List, Optional import tempfile import uuid @@ -13,7 +14,7 @@ logger = logging.getLogger(__name__) # TODO: Add ACL with kerberos -GANESHA_EXPORT_TEMPLATE = """## This export is managed by the CephNFS charm ## +GANESHA_EXPORT_TEMPLATE = """ EXPORT {{ # Each EXPORT must have a unique Export_Id. Export_Id = {id}; @@ -46,52 +47,34 @@ EXPORT {{ class Export(object): """Object that encodes and decodes Ganesha export blocks""" - def __init__(self, export_id: int, path: str, - user_id: str, access_key: str, clients: List[str], - name: Optional[str] = None): - self.export_id = export_id - self.path = path - self.user_id = user_id - self.access_key = access_key - self.clients = clients - if '0.0.0.0/0' in self.clients: - self.clients[self.clients.index('0.0.0.0/0')] = '0.0.0.0' + def __init__(self, export_options: Optional[Dict] = None): + if export_options is None: + export_options = {} + self.export_options = export_options if self.path: self.name = self.path.split('/')[-2] def from_export(export: str) -> 'Export': - if not export.startswith('## This export is managed by the CephNFS charm ##'): - raise RuntimeError('This export is not managed by the CephNFS charm.') - clients = [] - strip_chars = " ;'\"" - for line in [line.strip() for line in export.splitlines()]: - if line.startswith('Export_Id'): - export_id = int(line.split('=', 1)[1].strip(strip_chars)) - if line.startswith('Path'): - path = line.split('=', 1)[1].strip(strip_chars) - if line.startswith('User_Id'): - user_id = line.split('=', 1)[1].strip(strip_chars) - if line.startswith('Secret_Access_Key'): - access_key = line.split('=', 1)[1].strip(strip_chars) - if line.startswith('Clients'): - clients = line.split('=', 1)[1].strip(strip_chars) - clients = clients.split(', ') - return Export( - export_id=export_id, - path=path, - user_id=user_id, - access_key=access_key, - clients=clients - ) + return Export(export_options=manager.parseconf(export)) def to_export(self) -> str: - return GANESHA_EXPORT_TEMPLATE.format( - id=self.export_id, - path=self.path, - user_id=self.user_id, - secret_key=self.access_key, - clients=', '.join(self.clients) - ) + return manager.mkconf(self.export_options) + + @property + def export(self): + return self.export_options['EXPORT'] + + @property + def clients(self): + return self.export['CLIENT'] + + @property + def export_id(self): + return self.export['Export_Id'] + + @property + def path(self): + return self.export['Path'] class GaneshaNfs(object): diff --git a/src/manager.py b/src/manager.py new file mode 100644 index 0000000..fd625ed --- /dev/null +++ b/src/manager.py @@ -0,0 +1,200 @@ +# 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. +# +# The contents of this file were copied, almost straight, from +# https://github.com/openstack/manila/blob/a3aaea91494665a25bdccebf69d9e85e8475983d/manila/share/drivers/ganesha/manager.py#L205 +# +# The key differences is the lack of other Ganesha control code +# and the removal of oslo's JSON helpers. + + +import io +import json +import re +import sys + + +IWIDTH = 4 + + +def _conf2json(conf): + """Convert Ganesha config to JSON.""" + + # tokenize config string + token_list = [io.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(io.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(io.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 + (r'([^=\s])\s*{', '\\1={'), + # delete trailing semicolons in blocks + (r';\s*}', '}'), + # add omitted semicolons after blocks + (r'}\s*([^}\s])', '};\\1'), + # separate syntactically significant characters + (r'([;{}=])', ' \\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(r'\A-?[1-9]\d*(\.\d+)?\Z', word)): + pass + else: + word = json.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') + # The 'CLIENTS' Ganesha string option is an exception in that it's + # string value can't be enclosed within quotes as can be done for + # other string options in a valid Ganesha conf file. + elif k.upper() == 'CLIENTS': + out.write(' ' * (indent * IWIDTH) + k + ' = ' + v + ';') + else: + out.write(' ' * (indent * IWIDTH) + k + ' ') + out.write('= ') + _dump_to_conf(v, out, indent) + out.write(';') + out.write('\n') + else: + dj = json.dumps(confdict) + 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(src_list): + # 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']}. + dst_dict = {} + for i in src_list: + if isinstance(i, tuple): + k, v = i + if isinstance(v, list): + v = list_to_dict(v) + if k in dst_dict: + dst_dict[k] = [dst_dict[k]] + dst_dict[k].append(v) + else: + dst_dict[k] = v + return dst_dict + + try: + # allow config to be specified in JSON -- + # for sake of people who might feel Ganesha config foreign. + d = json.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. + li = json.loads(_conf2json(conf), object_pairs_hook=lambda x: x) + d = list_to_dict(li) + return d + + +def mkconf(confdict): + """Create Ganesha config string from confdict.""" + s = io.StringIO() + _dump_to_conf(confdict, s) + return s.getvalue() diff --git a/tox.ini b/tox.ini index 775ea57..52928f3 100644 --- a/tox.ini +++ b/tox.ini @@ -130,4 +130,4 @@ commands = [flake8] # Ignore E902 because the unit_tests directory is missing in the built charm. -ignore = E402,E226,E902 +ignore = E402,E226,E902,W504 diff --git a/unit_tests/test_ganesha.py b/unit_tests/test_ganesha.py index 39c625a..19bdb4d 100644 --- a/unit_tests/test_ganesha.py +++ b/unit_tests/test_ganesha.py @@ -38,6 +38,5 @@ class ExportTest(unittest.TestCase): def test_parser(self): export = ganesha.Export.from_export(EXAMPLE_EXPORT) self.assertEqual(export.export_id, 1000) - self.assertEqual(export.clients, ['0.0.0.0']) - self.assertEqual(export.to_export(), EXAMPLE_EXPORT) + self.assertEqual(export.clients, {'Access_Type': 'rw', 'Clients': '0.0.0.0'}) self.assertEqual(export.name, 'test_ganesha_share')