Add support for granting and removing access

This commit is contained in:
Chris MacNaughton 2022-02-02 10:00:50 +01:00
parent 95cb7c5839
commit 8d410253d0
7 changed files with 209 additions and 60 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ __pycache__
lib/*
!lib/README.txt
*.charm
.vscode/settings.json

View File

@ -2,13 +2,13 @@
## Description
TODO: Describe your charm in a few paragraphs of Markdown
CephNFS is a charm designed to enable management of NFS shares backed
by CephFS. It supports Ceph Pacific and above.
## Usage
TODO: Provide high-level usage, such as required config or relations
## Relations
TODO: Provide any relations which are provided or required by your charm

View File

@ -22,6 +22,36 @@ create-share:
Name of the share that will be exported.
type: string
default:
grant-access:
description: |
Grant the specified client access to a share.
params:
name:
description: Name of the share
type: string
default:
client:
description: IP address or network to change access for
type: string
default:
mode:
description: Access mode to grant
type: string
default: "RW"
revoke-access:
description: |
Revoke the specified client's access to a share.
params:
name:
description: Name of the share
type: string
default:
client:
description: IP address or network to change access for
type: string
default:
delete-share:
description: |
Delete a CephFS Backed NFS export. Note that this does not delete

View File

@ -192,6 +192,14 @@ class CephNfsCharm(
self.on.delete_share_action,
self.delete_share_action
)
self.framework.observe(
self.on.grant_access_action,
self.grant_access_action
)
self.framework.observe(
self.on.revoke_access_action,
self.revoke_access_action
)
def config_get(self, key, default=None):
"""Retrieve config option.
@ -304,24 +312,25 @@ class CephNfsCharm(
'--id', self.client_name,
'put', 'ganesha-export-index', '/dev/null'
]
try:
logging.debug("Creating ganesha-export-index in Ceph")
subprocess.check_call(cmd)
counter = tempfile.NamedTemporaryFile('w+')
counter.write('1000')
counter.seek(0)
logging.debug("Creating ganesha-export-counter in Ceph")
cmd = [
'rados', '-p', self.pool_name,
'-c', self.CEPH_CONF,
'--id', self.client_name,
'put', 'ganesha-export-counter', counter.name
]
subprocess.check_call(cmd)
self.peers.initialised_pool()
except subprocess.CalledProcessError:
logging.error("Failed to setup ganesha index object")
event.defer()
if not self.peers.pool_initialised:
try:
logging.debug("Creating ganesha-export-index in Ceph")
subprocess.check_call(cmd)
counter = tempfile.NamedTemporaryFile('w+')
counter.write('1000')
counter.seek(0)
logging.debug("Creating ganesha-export-counter in Ceph")
cmd = [
'rados', '-p', self.pool_name,
'-c', self.CEPH_CONF,
'--id', self.client_name,
'put', 'ganesha-export-counter', counter.name
]
subprocess.check_call(cmd)
self.peers.initialised_pool()
except subprocess.CalledProcessError:
logging.error("Failed to setup ganesha index object")
event.defer()
def on_pool_initialised(self, event):
try:
@ -378,6 +387,41 @@ class CephNfsCharm(
"message": "Share deleted",
})
def grant_access_action(self, event):
if not self.model.unit.is_leader():
event.fail("Share creation needs to be run from the application leader")
return
client = GaneshaNfs(self.client_name, self.pool_name)
name = event.params.get('name')
address = event.params.get('client')
mode = event.params.get('mode')
if mode not in ['r', 'rw']:
event.fail('Mode must be either r (read) or rw (read/write)')
res = client.grant_access(name, address, mode)
if res is not None:
event.fail(res)
return
self.peers.trigger_reload()
event.set_results({
"message": "Acess granted",
})
def revoke_access_action(self, event):
if not self.model.unit.is_leader():
event.fail("Share creation needs to be run from the application leader")
return
client = GaneshaNfs(self.client_name, self.pool_name)
name = event.params.get('name')
address = event.params.get('client')
res = client.revoke_access(name, address)
if res is not None:
event.fail(res)
return
self.peers.trigger_reload()
event.set_results({
"message": "Acess revoked",
})
@ops_openstack.core.charm_class
class CephNFSCharmOcto(CephNfsCharm):

View File

@ -14,35 +14,6 @@ logger = logging.getLogger(__name__)
# TODO: Add ACL with kerberos
GANESHA_EXPORT_TEMPLATE = """
EXPORT {{
# Each EXPORT must have a unique Export_Id.
Export_Id = {id};
# The directory in the exported file system this export
# is rooted on.
Path = '{path}';
# FSAL, Ganesha's module component
FSAL {{
# FSAL name
Name = "Ceph";
User_Id = "{user_id}";
Secret_Access_Key = "{secret_key}";
}}
# Path of export in the NFSv4 pseudo filesystem
Pseudo = '{path}';
SecType = "sys";
CLIENT {{
Access_Type = "rw";
Clients = {clients};
}}
# User id squashing, one of None, Root, All
Squash = "None";
}}
"""
class Export(object):
@ -53,9 +24,13 @@ class Export(object):
def __init__(self, export_options: Optional[Dict] = None):
if export_options is None:
export_options = {}
if isinstance(export_options, Export):
raise RuntimeError('export_options must be a dictionary')
self.export_options = export_options
if self.path:
self.name = self.path.split('/')[-2]
if not isinstance(self.export_options['EXPORT']['CLIENT'], list):
self.export_options['EXPORT']['CLIENT'] = [self.export_options['EXPORT']['CLIENT']]
def from_export(export: str) -> 'Export':
return Export(export_options=manager.parseconf(export))
@ -68,16 +43,50 @@ class Export(object):
return self.export_options['EXPORT']
@property
def clients(self):
return self.export['CLIENT']
def clients(self) -> List[Dict[str, str]]:
return self.export_options['EXPORT']['CLIENT']
@property
def export_id(self):
return self.export['Export_Id']
def clients_by_mode(self):
clients_by_mode = {'r': [], 'rw': []}
for client in self.clients:
if client['Access_Type'].lower() == 'r':
clients_by_mode['r'] += [s.strip() for s in client['Clients'].split(',')]
elif client['Access_Type'].lower() == 'rw':
clients_by_mode['rw'] += [s.strip() for s in client['Clients'].split(',')]
else:
raise RuntimeError("Invalid access type")
return clients_by_mode
@property
def path(self):
return self.export['Path']
def export_id(self) -> int:
return int(self.export_options['EXPORT']['Export_Id'])
@property
def path(self) -> str:
return self.export_options['EXPORT']['Path']
def add_client(self, client: str, mode: str):
if mode not in ['r', 'rw']:
return 'Mode must be either r (read) or rw (read/write)'
clients_by_mode = self.clients_by_mode
if client not in clients_by_mode[mode.lower()]:
clients_by_mode[mode.lower()].append(client)
self.export_options['EXPORT']['CLIENT'] = []
for (mode, clients) in clients_by_mode.items():
if clients:
self.export_options['EXPORT']['CLIENT'].append(
{'Access_Type': mode, 'Clients': ', '.join(clients)})
def remove_client(self, client: str):
clients_by_mode = self.clients_by_mode
for (mode, clients) in clients_by_mode.items():
clients_by_mode[mode] = [old_client for old_client in clients if old_client != client]
self.export_options['EXPORT']['CLIENT'] = []
for (mode, clients) in clients_by_mode.items():
if clients:
self.export_options['EXPORT']['CLIENT'].append(
{'Access_Type': mode, 'Clients': ', '.join(clients)})
class GaneshaNfs(object):
@ -176,15 +185,39 @@ class GaneshaNfs(object):
logging.debug("Removing export file from RADOS")
self._rados_rm('ganesha-export-{}'.format(share.export_id))
def get_share(self, id):
pass
def grant_access(self, name: str, client: str, mode: str) -> Optional[str]:
share = self.get_share(name)
if share is None:
return 'Share does not exist'
share.add_client(client, mode)
export_template = share.to_export()
logging.debug("Export template::\n{}".format(export_template))
tmp_file = self._tmpfile(export_template)
self._rados_put('ganesha-export-{}'.format(share.export_id), tmp_file.name)
self._ganesha_update_export(share.export_id, tmp_file.name)
def revoke_access(self, name: str, client: str):
share = self.get_share(name)
if share is None:
return 'Share does not exist'
share.remove_client(client)
export_template = share.to_export()
logging.debug("Export template::\n{}".format(export_template))
tmp_file = self._tmpfile(export_template)
self._rados_put('ganesha-export-{}'.format(share.export_id), tmp_file.name)
self._ganesha_update_export(share.export_id, tmp_file.name)
def get_share(self, name: str) -> Optional[Export]:
share = [share for share in self.list_shares() if share.name == name]
if share:
return share[0]
def update_share(self, id):
pass
def _ganesha_add_export(self, export_path: str, tmp_path: str):
"""Add a configured NFS export to Ganesha"""
return self._dbus_send(
self._dbus_send(
'ExportMgr', 'AddExport',
'string:{}'.format(tmp_path), 'string:EXPORT(Path={})'.format(export_path))
@ -195,6 +228,12 @@ class GaneshaNfs(object):
'RemoveExport',
"uint16:{}".format(share_id))
def _ganesha_update_export(self, share_id: int, tmp_path: str):
"""Update a configured NFS export in Ganesha"""
self._dbus_send(
'ExportMgr', 'UpdateExport',
'string:{}'.format(tmp_path), 'string:EXPORT(Export_Id={})'.format(share_id))
def _dbus_send(self, section: str, action: str, *args):
"""Send a command to Ganesha via Dbus"""
cmd = [

View File

@ -79,6 +79,7 @@ class NfsGaneshaTest(unittest.TestCase):
zaza.utilities.generic.run_via_ssh(
unit_name=unit_name,
cmd=ssh_cmd)
self.mounts_share = True
def _install_dependencies(self, unit: str):
logging.debug("About to install nfs-common on {}".format(unit))
@ -112,7 +113,6 @@ class NfsGaneshaTest(unittest.TestCase):
export_path = share['path']
ip = share['ip']
logging.info("Mounting share on ubuntu units")
self.mounts_share = True
self._mount_share('ubuntu/0', ip, export_path)
self._mount_share('ubuntu/1', ip, export_path)
logging.info("writing to the share on ubuntu/0")

View File

@ -38,5 +38,40 @@ 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, {'Access_Type': 'rw', 'Clients': '0.0.0.0'})
self.assertEqual(export.clients, [{'Access_Type': 'rw', 'Clients': '0.0.0.0'}])
self.assertEqual(export.name, 'test_ganesha_share')
def test_add_client(self):
export = ganesha.Export.from_export(EXAMPLE_EXPORT)
export.add_client('10.0.0.0/8', 'rw')
self.assertEqual(
export.clients,
[{'Access_Type': 'rw', 'Clients': '0.0.0.0, 10.0.0.0/8'}])
# adding again shouldn't duplicate export
export.add_client('10.0.0.0/8', 'rw')
self.assertEqual(
export.clients,
[{'Access_Type': 'rw', 'Clients': '0.0.0.0, 10.0.0.0/8'}])
export.add_client('192.168.0.0/16', 'r')
self.assertEqual(
export.clients,
[{'Access_Type': 'r', 'Clients': '192.168.0.0/16'},
{'Access_Type': 'rw', 'Clients': '0.0.0.0, 10.0.0.0/8'},
])
def test_remove_client(self):
export = ganesha.Export.from_export(EXAMPLE_EXPORT)
export.add_client('10.0.0.0/8', 'rw')
export.add_client('192.168.0.0/16', 'r')
self.assertEqual(
export.clients,
[{'Access_Type': 'r', 'Clients': '192.168.0.0/16'},
{'Access_Type': 'rw', 'Clients': '0.0.0.0, 10.0.0.0/8'},
])
export.remove_client('0.0.0.0')
self.assertEqual(
export.clients,
[{'Access_Type': 'r', 'Clients': '192.168.0.0/16'},
{'Access_Type': 'rw', 'Clients': '10.0.0.0/8'},
])