Add support for granting and removing access
This commit is contained in:
parent
95cb7c5839
commit
8d410253d0
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ __pycache__
|
||||
lib/*
|
||||
!lib/README.txt
|
||||
*.charm
|
||||
.vscode/settings.json
|
||||
|
@ -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
|
||||
|
30
actions.yaml
30
actions.yaml
@ -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
|
||||
|
80
src/charm.py
80
src/charm.py
@ -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):
|
||||
|
115
src/ganesha.py
115
src/ganesha.py
@ -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 = [
|
||||
|
@ -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")
|
||||
|
@ -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'},
|
||||
])
|
||||
|
Loading…
x
Reference in New Issue
Block a user