ganesha: dynamically update access of share

You can dynamically update access lists of exports with Ganesha
version >= 2.4. Make ganesha library use this feature in a new helper
class, GaneshaNASHelper2, to cleanly implement share access rules
changes without undesired interruptions.

When updating a share's access rules, the new helper class differs
from the older GaneshaNASHelper class as follows:
* Looks for an existing export and edits its client access list;
  creates a new export if it can't find one; and removes an export if
  the access list ends up empty. Rather than awkwardly create or
  remove an export per addition or removal of an access rule.
* Issues DBUS UpdateAccess command to dynamically update an export.

Implements: bp ganesha-dynamic-update-export

Co-Authored-By: Csaba Henk <chenk@redhat.com>

Change-Id: I01ec100c0afe28a84e9afa8e0660d299e4b3d160
This commit is contained in:
Ramana Raja 2017-01-09 14:03:33 +05:30
parent acecb8b5b9
commit a8e522961c
7 changed files with 443 additions and 30 deletions

View File

@ -33,7 +33,18 @@ Supported operations
Requirements
------------
`NFS-Ganesha <https://github.com/nfs-ganesha/nfs-ganesha/wiki>`__ 2.1 or newer.
- Preferred:
`NFS-Ganesha <https://github.com/nfs-ganesha/nfs-ganesha/wiki>`_ v2.4 or
later, which allows dynamic update of access rules. And use manila's
``ganesha.GaneshaNASHelper2`` class as described later in
:ref:`ganesha_using_library`.
- For use with limitations documented in :ref:`ganesha_known_issues`:
`NFS-Ganesha <https://github.com/nfs-ganesha/nfs-ganesha/wiki>`_ v2.1 to
v2.3. And use manila's ``ganesha.GaneshaNASHelper`` class as described later
in :ref:`ganesha_using_library`.
NFS-Ganesha configuration
-------------------------
@ -82,19 +93,30 @@ These are:
- `ganesha_export_template_dir` = directory from where Ganesha loads
export customizations (cf. "Customizing Ganesha exports").
.. _ganesha_using_library:
Using Ganesha Library in drivers
--------------------------------
A driver that wants to use the Ganesha Library has to inherit
from ``driver.GaneshaMixin``.
The driver has to contain a subclass of ``ganesha.GaneshaNASHelper``,
The driver has to contain a subclass of ``ganesha.GaneshaNASHelper2``,
instantiate it along with the driver instance and delegate
``allow_access`` and ``deny_access`` methods to it (when appropriate,
ie. when ``access_proto`` is NFS).
``update_access`` method to it (when appropriate, i.e., when ``access_proto``
is NFS).
.. note::
You can also subclass ``ganesha.GaneshaNASHelper``. It works with
NFS-Ganesha v2.1 to v2.3 that doesn't support dynamic update of exports.
To update access rules without having to restart NFS-Ganesha server, the
class manipulates exports created per share access rule (rather than per
share) introducing limitations documented in :ref:`ganesha_known_issues`.
In the following we explain what has to be implemented by the
``ganesha.GaneshaNASHelper`` subclass (to which we refer as "helper
``ganesha.GaneshaNASHelper2`` subclass (to which we refer as "helper
class").
Ganesha exports are described by so-called *Ganesha export blocks*
@ -107,7 +129,7 @@ subblock*. The helper class has to implement the ``_fsal_hook``
method which returns the FSAL subblock (in Python represented as
a dict with string keys and values). It has one mandatory key,
``Name``, to which the value should be the name of the FSAL
(eg.: ``{"Name": "GLUSTER"}``). Further content of it is
(eg.: ``{"Name": "CEPH"}``). Further content of it is
optional and FSAL specific.
Customizing Ganesha exports
@ -190,6 +212,9 @@ Known Restrictions
Known Issues
------------
Following issues concern only users of `ganesha.GaneshaNASHelper` class that
works with NFS-Ganesha v2.1 to v2.3.
- The export location for shares of a driver that uses the Ganesha Library
will be of the format ``<ganesha-server>:/share-<share-id>``. However,
this is incomplete information, because it pertains only to NFSv3

View File

@ -141,6 +141,9 @@ dbus-addexport: RegExpFilter, dbus-send, root, dbus-send, --print-reply, --syste
# manila/share/drivers/ganesha/manager.py:
dbus-removeexport: RegExpFilter, dbus-send, root, dbus-send, --print-reply, --system, --dest=org\.ganesha\.nfsd, /org/ganesha/nfsd/ExportMgr, org\.ganesha\.nfsd\.exportmgr\.(Add|Remove)Export, .*
# manila/share/drivers/ganesha/manager.py:
dbus-updateexport: RegExpFilter, dbus-send, root, dbus-send, --print-reply, --system, --dest=org\.ganesha\.nfsd, /org/ganesha/nfsd/ExportMgr, org\.ganesha\.nfsd\.exportmgr\.UpdateExport, .*, .*
# manila/share/drivers/ganesha/manager.py:
rmconf: RegExpFilter, sh, root, sh, -c, rm -f /.*/\*\.conf$

View File

@ -50,13 +50,13 @@ class NASHelperBase(object):
"""Initializes protocol-specific NAS drivers."""
@abc.abstractmethod
def update_access(self, base_path, share, add_rules, delete_rules,
recovery=False):
def update_access(self, context, share, access_rules, add_rules,
delete_rules, share_server=None):
"""Update access rules of share."""
class GaneshaNASHelper(NASHelperBase):
"""Execute commands relating to Shares."""
"""Perform share access changes using Ganesha version < 2.4."""
supported_access_types = ('ip', )
supported_access_levels = (constants.ACCESS_LEVEL_RW, )
@ -146,15 +146,96 @@ class GaneshaNASHelper(NASHelperBase):
"""Deny access to the share."""
self.ganesha.remove_export("%s--%s" % (share['name'], access['id']))
def update_access(self, base_path, share, add_rules, delete_rules,
recovery=False):
def update_access(self, context, share, access_rules, add_rules,
delete_rules, share_server=None):
"""Update access rules of share."""
if recovery:
if not (add_rules or delete_rules):
add_rules = access_rules
self.ganesha.reset_exports()
self.ganesha.restart_service()
for rule in add_rules:
self._allow_access(base_path, share, rule)
self._allow_access('/', share, rule)
for rule in delete_rules:
self._deny_access(base_path, share, rule)
self._deny_access('/', share, rule)
class GaneshaNASHelper2(GaneshaNASHelper):
"""Perform share access changes using Ganesha version >= 2.4."""
def _get_export_path(self, share):
"""Subclass this to return export path."""
raise NotImplementedError()
def _get_export_pseudo_path(self, share):
"""Subclass this to return export pseudo path."""
raise NotImplementedError()
def update_access(self, context, share, access_rules, add_rules,
delete_rules, share_server=None):
"""Update access rules of share.
Creates an export per share. Modifies access rules of shares by
dynamically updating exports via DBUS.
"""
confdict = {}
existing_access_rules = []
if self.ganesha._check_export_file_exists(share['name']):
confdict = self.ganesha._read_export_file(share['name'])
existing_access_rules = confdict["EXPORT"]["CLIENT"]
if not isinstance(existing_access_rules, list):
existing_access_rules = [existing_access_rules]
else:
if not access_rules:
LOG.warning("Trying to remove export file '%s' but it's "
"already gone",
self.ganesha._getpath(share['name']))
return
wanted_rw_clients, wanted_ro_clients = [], []
for rule in access_rules:
if rule['access_level'] == 'rw':
wanted_rw_clients.append(rule['access_to'])
elif rule['access_level'] == 'ro':
wanted_ro_clients.append(rule['access_to'])
if access_rules:
# Add or Update export.
clients = []
if wanted_ro_clients:
clients.append({
'Access_Type': 'ro',
'Clients': ','.join(wanted_ro_clients)
})
if wanted_rw_clients:
clients.append({
'Access_Type': 'rw',
'Clients': ','.join(wanted_rw_clients)
})
if existing_access_rules:
# Update existing export.
ganesha_utils.patch(confdict, {
'EXPORT': {
'CLIENT': clients
}
})
self.ganesha.update_export(share['name'], confdict)
else:
# Add new export.
ganesha_utils.patch(confdict, self.export_template, {
'EXPORT': {
'Export_Id': self.ganesha.get_export_id(),
'Path': self._get_export_path(share),
'Pseudo': self._get_export_pseudo_path(share),
'Tag': share['name'],
'CLIENT': clients,
'FSAL': self._fsal_hook(None, share, None)
}
})
self.ganesha.add_export(share['name'], confdict)
else:
# No clients have access to the share. Remove export.
self.ganesha.remove_export(share['name'])

View File

@ -130,12 +130,19 @@ def _dump_to_conf(confdict, out=sys.stdout, indent=0):
for k, v in confdict.items():
if v is None:
continue
out.write(' ' * (indent * IWIDTH) + k + ' ')
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(';')
@ -152,14 +159,39 @@ 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:
d = jsonutils.loads(_conf2json(conf))
# 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
@ -251,6 +283,18 @@ class GaneshaManager(object):
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):
@ -300,6 +344,20 @@ class GaneshaManager(object):
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:

View File

@ -15,6 +15,7 @@
import re
import ddt
import mock
from oslo_serialization import jsonutils
import six
@ -32,18 +33,27 @@ test_ganesha_cnf = """EXPORT {
Export_Id = 101;
CLIENT {
Clients = ip1;
Access_Level = ro;
}
CLIENT {
Clients = ip2;
Access_Level = rw;
}
}"""
test_dict_unicode = {
u'EXPORT': {
u'Export_Id': 101,
u'CLIENT': {u'Clients': u"ip1"}
u'CLIENT': [
{u'Clients': u"ip1", u'Access_Level': u'ro'},
{u'Clients': u"ip2", u'Access_Level': u'rw'}]
}
}
test_dict_str = {
'EXPORT': {
'Export_Id': 101,
'CLIENT': {'Clients': "ip1"}
'CLIENT': [
{'Clients': 'ip1', 'Access_Level': 'ro'},
{'Clients': 'ip2', 'Access_Level': 'rw'}]
}
}
@ -61,6 +71,11 @@ class GaneshaConfigTests(test.TestCase):
ref_ganesha_cnf = """EXPORT {
CLIENT {
Clients = ip1;
Access_Level = ro;
}
CLIENT {
Clients = ip2;
Access_Level = rw;
}
Export_Id = 101;
}"""
@ -103,8 +118,14 @@ class GaneshaConfigTests(test.TestCase):
Clients = ip1;
}
}"""
result_dict_unicode = {
u'EXPORT': {
u'CLIENT': {u'Clients': u'ip1'},
u'Export_Id': 101
}
}
ret = manager._conf2json(test_ganesha_cnf_with_comment)
self.assertEqual(test_dict_unicode, jsonutils.loads(ret))
self.assertEqual(result_dict_unicode, jsonutils.loads(ret))
def test_parseconf_ganesha_cnf_input(self):
ret = manager.parseconf(test_ganesha_cnf)
@ -126,6 +147,7 @@ class GaneshaConfigTests(test.TestCase):
ganesha_cnf))
@ddt.ddt
class GaneshaManagerTestCase(test.TestCase):
"""Tests GaneshaManager."""
@ -283,6 +305,41 @@ class GaneshaManagerTestCase(test.TestCase):
manager.parseconf.assert_called_once_with(test_ganesha_cnf)
self.assertEqual(test_dict_unicode, ret)
def test_check_export_file_exists(self):
self.mock_object(self._manager, '_getpath',
mock.Mock(return_value=test_path))
self.mock_object(self._manager, 'execute',
mock.Mock(return_value=(test_ganesha_cnf,)))
ret = self._manager._check_export_file_exists(test_name)
self._manager._getpath.assert_called_once_with(test_name)
self._manager.execute.assert_called_once_with(
'test', '-f', test_path, makelog=False, run_as_root=False)
self.assertTrue(ret)
@ddt.data(1, 4)
def test_check_export_file_exists_error(self, exit_code):
self.mock_object(self._manager, '_getpath',
mock.Mock(return_value=test_path))
self.mock_object(
self._manager, 'execute',
mock.Mock(side_effect=exception.GaneshaCommandFailure(
exit_code=exit_code))
)
if exit_code == 1:
ret = self._manager._check_export_file_exists(test_name)
self.assertFalse(ret)
else:
self.assertRaises(exception.GaneshaCommandFailure,
self._manager._check_export_file_exists,
test_name)
self._manager._getpath.assert_called_once_with(test_name)
self._manager.execute.assert_called_once_with(
'test', '-f', test_path, makelog=False, run_as_root=False)
def test_write_export_file(self):
self.mock_object(manager, 'mkconf',
mock.Mock(return_value=test_ganesha_cnf))
@ -416,6 +473,54 @@ class GaneshaManagerTestCase(test.TestCase):
self._manager._mkindex.assert_called_once_with()
self.assertFalse(self._manager._remove_export_dbus.called)
def test_update_export(self):
confdict = {
'EXPORT': {
'Export_Id': 101,
'CLIENT': {'Clients': 'ip1', 'Access_Level': 'ro'},
}
}
self.mock_object(self._manager, '_read_export_file',
mock.Mock(return_value=test_dict_unicode))
self.mock_object(self._manager, '_write_export_file',
mock.Mock(return_value=test_path))
self.mock_object(self._manager, '_dbus_send_ganesha')
self._manager.update_export(test_name, confdict)
self._manager._read_export_file.assert_called_once_with(test_name)
self._manager._write_export_file.assert_called_once_with(test_name,
confdict)
self._manager._dbus_send_ganesha.assert_called_once_with(
'UpdateExport', 'string:' + test_path,
'string:EXPORT(Export_Id=101)')
def test_update_export_error(self):
confdict = {
'EXPORT': {
'Export_Id': 101,
'CLIENT': {'Clients': 'ip1', 'Access_Level': 'ro'},
}
}
self.mock_object(self._manager, '_read_export_file',
mock.Mock(return_value=test_dict_unicode))
self.mock_object(self._manager, '_write_export_file',
mock.Mock(return_value=test_path))
self.mock_object(
self._manager, '_dbus_send_ganesha',
mock.Mock(side_effect=exception.GaneshaCommandFailure))
self.assertRaises(exception.GaneshaCommandFailure,
self._manager.update_export, test_name, confdict)
self._manager._read_export_file.assert_called_once_with(test_name)
self._manager._write_export_file.assert_has_calls([
mock.call(test_name, confdict),
mock.call(test_name, test_dict_unicode)])
self._manager._dbus_send_ganesha.assert_called_once_with(
'UpdateExport', 'string:' + test_path,
'string:EXPORT(Export_Id=101)')
def test_remove_export(self):
self.mock_object(self._manager, '_read_export_file',
mock.Mock(return_value=test_dict_unicode))

View File

@ -21,6 +21,7 @@ import ddt
import mock
from oslo_config import cfg
from manila import context
from manila import exception
from manila.share import configuration as config
from manila.share.drivers import ganesha
@ -61,6 +62,7 @@ class GaneshaNASHelperTestCase(test.TestCase):
CONF.set_default('ganesha_export_template_dir',
'/fakedir2/faketempl.d')
CONF.set_default('ganesha_service_name', 'ganesha.fakeservice')
self._context = context.get_admin_context()
self._execute = mock.Mock(return_value=('', ''))
self.fake_conf = config.Configuration(None)
self.fake_conf_dir_path = '/fakedir0/exports.d'
@ -256,17 +258,16 @@ class GaneshaNASHelperTestCase(test.TestCase):
'fakename--fakeaccid')
self.assertIsNone(ret)
@ddt.data({}, {'recovery': False})
def test_update_access_for_allow(self, kwargs):
def test_update_access_for_allow(self):
self.mock_object(self._helper, '_allow_access')
self.mock_object(self._helper, '_deny_access')
self._helper.update_access(
'/some/path', 'aShare', add_rules=["example.com"], delete_rules=[],
**kwargs)
self._context, self.share, access_rules=[self.access],
add_rules=[self.access], delete_rules=[])
self._helper._allow_access.assert_called_once_with(
'/some/path', 'aShare', 'example.com')
'/', self.share, self.access)
self.assertFalse(self._helper._deny_access.called)
self.assertFalse(self._helper.ganesha.reset_exports.called)
@ -277,10 +278,11 @@ class GaneshaNASHelperTestCase(test.TestCase):
self.mock_object(self._helper, '_deny_access')
self._helper.update_access(
'/some/path', 'aShare', [], delete_rules=["example.com"])
self._context, self.share, access_rules=[],
add_rules=[], delete_rules=[self.access])
self._helper._deny_access.assert_called_once_with(
'/some/path', 'aShare', 'example.com')
'/', self.share, self.access)
self.assertFalse(self._helper._allow_access.called)
self.assertFalse(self._helper.ganesha.reset_exports.called)
@ -291,12 +293,143 @@ class GaneshaNASHelperTestCase(test.TestCase):
self.mock_object(self._helper, '_deny_access')
self._helper.update_access(
'/some/path', 'aShare', add_rules=["example.com"], delete_rules=[],
recovery=True)
self._context, self.share, access_rules=[self.access],
add_rules=[], delete_rules=[])
self._helper._allow_access.assert_called_once_with(
'/some/path', 'aShare', 'example.com')
'/', self.share, self.access)
self.assertFalse(self._helper._deny_access.called)
self.assertTrue(self._helper.ganesha.reset_exports.called)
self.assertTrue(self._helper.ganesha.restart_service.called)
@ddt.ddt
class GaneshaNASHelper2TestCase(test.TestCase):
"""Tests GaneshaNASHelper2."""
def setUp(self):
super(GaneshaNASHelper2TestCase, self).setUp()
CONF.set_default('ganesha_config_path', '/fakedir0/fakeconfig')
CONF.set_default('ganesha_db_path', '/fakedir1/fake.db')
CONF.set_default('ganesha_export_dir', '/fakedir0/export.d')
CONF.set_default('ganesha_export_template_dir',
'/fakedir2/faketempl.d')
CONF.set_default('ganesha_service_name', 'ganesha.fakeservice')
self._context = context.get_admin_context()
self._execute = mock.Mock(return_value=('', ''))
self.fake_conf = config.Configuration(None)
self.fake_conf_dir_path = '/fakedir0/exports.d'
self._helper = ganesha.GaneshaNASHelper2(
self._execute, self.fake_conf, tag='faketag')
self._helper.ganesha = mock.Mock()
self._helper.export_template = {}
self.share = fake_share.fake_share()
self.rule1 = fake_share.fake_access(access_level='ro')
self.rule2 = fake_share.fake_access(access_level='rw',
access_to='10.0.0.2')
def test_update_access_add_export(self):
mock_gh = self._helper.ganesha
self.mock_object(mock_gh, '_check_export_file_exists',
mock.Mock(return_value=False))
self.mock_object(mock_gh, 'get_export_id',
mock.Mock(return_value=100))
self.mock_object(self._helper, '_get_export_path',
mock.Mock(return_value='/fakepath'))
self.mock_object(self._helper, '_get_export_pseudo_path',
mock.Mock(return_value='/fakepath'))
self.mock_object(self._helper, '_fsal_hook',
mock.Mock(return_value={'Name': 'fake'}))
result_confdict = {
'EXPORT': {
'Export_Id': 100,
'Path': '/fakepath',
'Pseudo': '/fakepath',
'Tag': 'fakename',
'CLIENT': [{
'Access_Type': 'ro',
'Clients': '10.0.0.1'}],
'FSAL': {'Name': 'fake'}
}
}
self._helper.update_access(
self._context, self.share, access_rules=[self.rule1],
add_rules=[], delete_rules=[])
mock_gh._check_export_file_exists.assert_called_once_with('fakename')
mock_gh.get_export_id.assert_called_once_with()
self._helper._get_export_path.assert_called_once_with(self.share)
(self._helper._get_export_pseudo_path.assert_called_once_with(
self.share))
self._helper._fsal_hook.assert_called_once_with(
None, self.share, None)
mock_gh.add_export.assert_called_once_with(
'fakename', result_confdict)
self.assertFalse(mock_gh.update_export.called)
self.assertFalse(mock_gh.remove_export.called)
@ddt.data({'Access_Type': 'ro', 'Clients': '10.0.0.1'},
[{'Access_Type': 'ro', 'Clients': '10.0.0.1'}])
def test_update_access_update_export(self, client):
mock_gh = self._helper.ganesha
self.mock_object(mock_gh, '_check_export_file_exists',
mock.Mock(return_value=True))
self.mock_object(
mock_gh, '_read_export_file',
mock.Mock(return_value={'EXPORT': {'CLIENT': client}})
)
result_confdict = {
'EXPORT': {
'CLIENT': [
{'Access_Type': 'ro', 'Clients': '10.0.0.1'},
{'Access_Type': 'rw', 'Clients': '10.0.0.2'}]
}
}
self._helper.update_access(
self._context, self.share, access_rules=[self.rule1, self.rule2],
add_rules=[self.rule2], delete_rules=[])
mock_gh._check_export_file_exists.assert_called_once_with('fakename')
mock_gh.update_export.assert_called_once_with('fakename',
result_confdict)
self.assertFalse(mock_gh.add_export.called)
self.assertFalse(mock_gh.remove_export.called)
def test_update_access_remove_export(self):
mock_gh = self._helper.ganesha
self.mock_object(mock_gh, '_check_export_file_exists',
mock.Mock(return_value=True))
client = {'Access_Type': 'ro', 'Clients': '10.0.0.1'}
self.mock_object(
mock_gh, '_read_export_file',
mock.Mock(return_value={'EXPORT': {'CLIENT': client}})
)
self._helper.update_access(
self._context, self.share, access_rules=[],
add_rules=[], delete_rules=[self.rule1])
mock_gh._check_export_file_exists.assert_called_once_with('fakename')
mock_gh.remove_export.assert_called_once_with('fakename')
self.assertFalse(mock_gh.add_export.called)
self.assertFalse(mock_gh.update_export.called)
def test_update_access_export_file_already_removed(self):
mock_gh = self._helper.ganesha
self.mock_object(mock_gh, '_check_export_file_exists',
mock.Mock(return_value=False))
self.mock_object(ganesha.LOG, 'warning')
self._helper.update_access(
self._context, self.share, access_rules=[],
add_rules=[], delete_rules=[self.rule1])
mock_gh._check_export_file_exists.assert_called_once_with('fakename')
ganesha.LOG.warning.assert_called_once_with(mock.ANY, mock.ANY)
self.assertFalse(mock_gh.add_export.called)
self.assertFalse(mock_gh.update_export.called)
self.assertFalse(mock_gh.remove_export.called)

View File

@ -0,0 +1,8 @@
---
features:
- The new class `ganesha.GaneshaNASHelper2` in the ganesha library uses
dynamic update of export feature of NFS-Ganesha versions v2.4 or newer
to modify access rules of a share in a clean way. It modifies exports
created per share rather than per share access rule (as with
`ganesha.GaneshaNASHelper`) that introduced limitations and unintuitive
end user experience.