diff --git a/src/simplezfs/exceptions.py b/src/simplezfs/exceptions.py index 4aafeae..1dac9f6 100644 --- a/src/simplezfs/exceptions.py +++ b/src/simplezfs/exceptions.py @@ -3,6 +3,8 @@ Exceptions ''' +from typing import Optional + class ZFSException(Exception): ''' @@ -46,3 +48,20 @@ class ValidationError(ZFSException): Indicates that a value failed validation. ''' pass + + +class PEHelperException(ZFSException): + ''' + Indicates a problem when running the PE helper. + ''' + def __init__(self, message: str, returncode: Optional[int], stdout: Optional[str] = None, stderr: Optional[str] = None) -> None: + ''' + :param message: The message to carry. + :param returncode: The programs return code. + :param stdout: The programs standard output, if any. + :param stderr: The programs standard error, if any. + ''' + super().__init__(message) + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr diff --git a/src/simplezfs/zfs.py b/src/simplezfs/zfs.py index 9938bf4..0dd80c2 100644 --- a/src/simplezfs/zfs.py +++ b/src/simplezfs/zfs.py @@ -6,10 +6,12 @@ ZFS frontend API import logging import os import stat +import subprocess from typing import Dict, List, Optional, Union from .exceptions import ( DatasetNotFound, + PEHelperException, PermissionError, PoolNotFound, PropertyNotFound, @@ -46,14 +48,18 @@ class ZFS: The functions :func:`set_property`, :func:`get_property` and :func:`get_properties` wrap the ZFS get/set functionality. To support so-called `user properties`, which are called `metadata` in this API, a default namespace can be stored using `metadata_namespace` when instantiating the interface or by calling - :func:`set_metadata_namespace` at any time. + :func:`set_metadata_namespace` at any time. :note: Not setting a metadata namespace means that one can't set or get metadata properties, unless the overwrite parameter for the get/set functions is used. + The parameter ``use_pe_helper`` is used to control whether the ``pe_helper`` executable will be used when + performing actions that require elevated permissions. It can be changed at anytime using the ``use_pe_helper`` + property. + :param metadata_namespace: Default namespace :param pe_helper: Privilege escalation (PE) helper to use for actions that require elevated privileges (root). - :param use_pe_helper: Whether to use the PE helper for creating and (u)mounting.# + :param use_pe_helper: Whether to use the PE helper for creating and (u)mounting. :param kwargs: Extra arguments, ignored ''' def __init__(self, *, metadata_namespace: Optional[str] = None, pe_helper: Optional[str] = None, @@ -163,7 +169,7 @@ class ZFS: ''' raise NotImplementedError(f'{self} has not implemented this function') - def set_mountpoint(self, fileset: str, mountpoint: str, *, use_pe_helper: bool = False) -> None: + def set_mountpoint(self, fileset: str, mountpoint: str, *, use_pe_helper: Optional[bool] = None) -> None: ''' Sets or changes the mountpoint property of a fileset. While this can be achieved using the generic function :func:`~ZFS.set_property`, it allows for using the privilege escalation (PE) helper if so desired. @@ -171,6 +177,7 @@ class ZFS: :param fileset: The fileset to modify. :param mountpoint: The new value for the ``mountpoint`` property. :param use_pe_helper: Overwrite the default for using the privilege escalation (PE) helper for this task. + ``None`` (default) uses the default setting. :raises DatasetNotFound: if the fileset could not be found. :raises ValidationError: if validating the parameters failed. ''' @@ -184,6 +191,11 @@ class ZFS: ``metadata`` is set to **True**, the default metadata namespace is prepended or the one in ``overwrite_metadata_namespace`` is used if set to a valid string. + .. note:: + + Use :func:`set_mountpoint` to change the ``mountpoint`` property if your setup requires the use of elevated + permissions (such as Linux). + Example: >>> z = ZFSCli(namespace='foo') @@ -327,6 +339,10 @@ class ZFS: ''' Create a new snapshot from an existing dataset. + .. warning:: + + This action requires the ``pe_helper`` on Linux when not running as `root`. + :param dataset: The dataset to snapshot. :param name: Name of the snapshot (the part after the ``@``) :param properties: Dict of native properties to set. @@ -368,6 +384,10 @@ class ZFS: Create a new fileset. For convenience, a ``mountpoint`` parameter can be given. If not **None**, it will overwrite any `mountpoint` value in the ``properties`` dict. + .. warning:: + + This action requires the ``pe_helper`` on Linux when not running as `root`. + :param name: Name of the new fileset (complete path in the ZFS hierarchy). :ppram mountpoint: Convenience parameter for setting/overwriting the moutpoint property :param properties: Dict of native properties to set. @@ -589,6 +609,23 @@ class ZFS: size: Optional[int] = None, recursive: bool = False, ) -> Dataset: + ''' + Actual implementation of :func:`create_dataset`. + + :param name: The name of the new dataset. This includes the full path, e.g. ``tank/data/newdataset``. + :param dataset_type: Indicates the type of the dataset to be created. + :param properties: A dict containing the properties for this new dataset. These are the native properties. + :param metadata_properties: The metadata properties to set. To use a different namespace than the default (or + when no default is set), use the ``namespace:key`` format for the dict keys. + :param sparse: For volumes, specifies whether a sparse (thin provisioned) or normal (thick provisioned) volume + should be created. + :param size: For volumes, specifies the size in bytes. + t:param recursive: Recursively create the parent fileset. Refer to the ZFS documentation about the `-p` + parameter for ``zfs create``. This does not apply to types other than volumes or filesets. + :raises ValidationError: If validating the parameters failed. + :raises DatasetNotFound: If the dataset can't be found (snapshot, bookmark) or the parent dataset can't be + found (fileset, volume with ``recursive = False``). + ''' raise NotImplementedError(f'{self} has not implemented this function') def destroy_dataset(self, name: str, *, recursive: bool = False) -> None: @@ -610,6 +647,101 @@ class ZFS: ''' raise NotImplementedError(f'{self} has not implemented this function') + def handle_pe_error(self, args: List[str], proc: subprocess.CompletedProcess) -> None: + ''' + Handles errors from the Privilege Escalation (PE) helper. If the returncode is ß, nothing happens, otherwise + an exception is thrown. + + :param args: Arguments passed to the helper (without the helper executable itself). + :param proc: The result of subprocess.run + :raises PEHelperException: In case of error. + ''' + log = logging.getLogger('simplezfs.zfs.pe_helper') + if proc.returncode == 0: + log.info(f'PE helper action {args[0]} was successful') + if len(proc.stdout) > 0: + log.debug(f'PE stdout: {proc.stdout}') + if len(proc.stderr) > 0: + log.warning(f'PE stderr: {proc.stderr}') + else: + if proc.returncode == 1: + log.error('General error in PE executable: Wrong parameters or configuration problem') + msg = 'General error' + elif proc.returncode == 2: + msg = 'Parent directory does not exist or is not a directory' + log.error(msg) + elif proc.returncode == 3: + msg = 'Parent dataset does not exist' + log.error(msg) + elif proc.returncode == 4: + msg = 'Target fileset is not a (grand)child of parent or parent does not exist' + log.error(msg) + elif proc.returncode == 5: + msg = 'Mountpoint is not inside the parent directory or otherwise invalid' + log.error(msg) + elif proc.returncode == 6: + msg = 'Calling the zfs utility failed' + log.error(msg) + else: + msg = f'Unknown / Unhandled error with returncode {proc.returncode}' + log.error(msg) + + # gather output + if len(proc.stdout) > 0: + log.warning(f'PE stdout: {proc.stdout}') + if len(proc.stderr) > 0: + log.warning(f'PE stderr: {proc.stderr}') + + raise PEHelperException(msg, returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr) + + def _execute_pe_helper(self, action: str, name: str, mountpoint: Optional[str] = None): + ''' + Runs the specified action through the PE helper. + + :param action: The action to perform. Valid are: "create", "destroy", "set_mountpoint". + :param name: The name of the dataset to operate on. + :param mountpoint: The mountpoint for create/set_mountpoint actions. + :raises ValidationError: If the parameters are invalid. + :raises PEHelperException: If the PE helper reported an error. + ''' + if not self._pe_helper: + raise ValidationError('PE Helper is not set') + if action not in ('create', 'destroy', 'set_mountpoint'): + raise ValidationError(f'Invalid action') + validate_dataset_path(name) + + if action == 'create': + if mountpoint is None: + raise ValidationError(f'Mountpoint has to be set for action "{action}"') + # TODO validate filesystem path + cmd = [self._pe_helper, 'create', name, mountpoint] + elif action == 'destroy': + cmd = [self._pe_helper, 'destroy', name] + elif action == 'set_mountpoint': + if mountpoint is None: + raise ValidationError(f'Mountpoint has to be set for action "{action}"') + # TODO validate filesystem path + cmd = [self._pe_helper, 'set_mountpoint', name, mountpoint] + else: + raise ValidationError('Invalid action') + + print(f'PE Helper: {cmd}') + + log = logging.getLogger('simplezfs.zfs.pe_helper') + log.debug(f'About to run the following command: {cmd}') + + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8') + if proc.returncode != 0 or len(proc.stderr) > 0: + log.error(f'PE Helper exit code {proc.returncode}') + log.error(f'Stdout: {proc.stdout}') + log.error(f'Stderr: {proc.stderr}') + raise PEHelperException('PE Helper execution error', proc.returncode, proc.stdout, proc.stderr) + else: + log.info(f'PE Helper successful') + log.debug(f'Return code: {proc.returncode}') + log.debug(f'Stdout: {proc.stdout}') + log.debug(f'Stderr: {proc.stderr}') + def get_zfs(api: str = 'cli', metadata_namespace: Optional[str] = None, **kwargs) -> ZFS: ''' diff --git a/src/simplezfs/zfs_cli.py b/src/simplezfs/zfs_cli.py index 758e535..246c361 100644 --- a/src/simplezfs/zfs_cli.py +++ b/src/simplezfs/zfs_cli.py @@ -3,14 +3,14 @@ CLI-based implementation. ''' -from typing import List, Optional, Union +from typing import Dict, List, Optional, Union import logging import os import shutil import subprocess -from .exceptions import DatasetNotFound, PropertyNotFound -from .types import Dataset, Property, PropertySource +from .exceptions import DatasetNotFound, PEHelperException, PropertyNotFound, ValidationError +from .types import Dataset, DatasetType, Property, PropertySource from .validation import ( validate_dataset_path, validate_pool_name, @@ -120,6 +120,28 @@ class ZFSCli(ZFS): res.append(Dataset.from_string(name.strip())) return res + def set_mountpoint(self, fileset: str, mountpoint: str, *, use_pe_helper: Optional[bool] = False) -> None: + real_use_pe_helper = use_pe_helper if use_pe_helper is not None else self.use_pe_helper + ds_type = self.get_property(fileset, 'type') + if ds_type != 'filesystem': + raise ValidationError('Given fileset is not a filesystem, can\'t set mountpoint') + + if not real_use_pe_helper: + self.set_property(fileset, 'mountpoint', mountpoint) + else: + if not self.pe_helper: + raise ValidationError('PE helper should be used, but is not defined') + + args = [self.pe_helper, 'set_mountpoint', fileset, mountpoint] + log.debug(f'set_mountpoint: executing {args}') + proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8') + log.debug(f'set_mountpoint returncode: {proc.returncode}') + log.debug(f'set_mountpoint stdout: {proc.stdout}') + log.debug(f'set_mountpoint stderr: {proc.stderr}') + # TODO log output + if proc.returncode != 0 or len(proc.stderr) > 0: + self.handle_command_error(proc, fileset) + def handle_command_error(self, proc: subprocess.CompletedProcess, dataset: str = None) -> None: ''' Handles errors that occured while running a command. @@ -140,6 +162,8 @@ class ZFSCli(ZFS): raise PropertyNotFound(f'invalid property on dataset {dataset}') else: raise PropertyNotFound('invalid property') + elif 'permission denied' in proc.stderr: + raise PermissionError(proc.stderr) raise Exception(f'Command execution "{" ".join(proc.args)}" failed: {proc.stderr}') def _set_property(self, dataset: str, key: str, value: str, is_metadata: bool) -> None: @@ -209,3 +233,102 @@ class ZFSCli(ZFS): else: res.append(Property(key=prop_name, value=prop_value, source=property_source, namespace=None)) return res + + def _create_dataset( + self, + name: str, + *, + dataset_type: DatasetType, + properties: Dict[str, str] = None, + metadata_properties: Dict[str, str] = None, + sparse: bool = False, + size: Optional[int] = None, + recursive: bool = False, + ) -> Dataset: + + if dataset_type == DatasetType.BOOKMARK: + raise ValidationError('Bookmarks can\'t be created by this function') + + # assemble the options list for properties + prop_args: List[str] = [] + if properties: + for nk, nv in properties.items(): + prop_args += ['-o', f'{nk}={nv}'] + if metadata_properties: + for mk, mv in metadata_properties.items(): + prop_args += ['-o', f'{mk}={mv}'] + + if dataset_type == DatasetType.FILESET: + assert size is None, 'Filesets have no size' + assert sparse is False, 'Filesets cannot be sparse' + + # try on our own first, then depending on settings use the pe helper + args = [self.__exe, 'create'] + if recursive: + args += ['-p'] + + args += prop_args + args += [name] + + log.debug(f'executing: {args}') + proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8') + if proc.returncode != 0 or len(proc.stderr) > 0: + if 'filesystem successfully created, but it may only be mounted by root' in proc.stderr: + if self.use_pe_helper: + # We may not have a mountpoint, but tried to inherit the base from the parent. + # In this case, we need to compute it on our own, for now we simply break. + try: + mp = properties['mountpoint'] + except KeyError: + msg = 'Mountpoint property not set, can\'t run pe_helper' + log.error(msg) + raise PermissionError(msg) + + log.info(f'Fileset {name} was created, using pe_helper to set the mountpoint') + else: + log.info(f'Fileset {name} was created, but could not be mounted due to not running as root') + raise PermissionError(proc.stderr) + + if self.use_pe_helper: + try: + mp = properties['mountpoint'] + except KeyError: + raise ValidationError('Mountpoint not found in properties') + self._execute_pe_helper('create', recursive=recursive, mountpoint=properties['mountpoint'], name=name) + else: + args = [self.__exe, 'create'] + + if recursive: + args += ['-p'] + + args += [name] + print(f'Executing {args}') + + elif dataset_type == DatasetType.VOLUME: + assert size is not None + + args = [self.__exe, 'create'] + if sparse: + args += ['-s'] + if recursive: + args += ['-p'] + # [-b blocksize] is set using properties + + args += prop_args + + args += ['-V', str(size), name] + + print(f'Executing {args}') + + elif dataset_type == DatasetType.SNAPSHOT: + assert size is None, 'Snapshots have no size' + assert sparse is False, 'Snapshots can\'t be sparse' + + args = [self.__exe, 'snapshot', *prop_args, name] + print(f'Executing {args}') + + raise NotImplementedError() + + def create_bookmark(self, snapshot: str, name: str) -> Dataset: + validate_dataset_path(snapshot) + raise NotImplementedError()