main
svalouch 5 years ago
commit 27de066f27

147
.gitignore vendored

@ -0,0 +1,147 @@
### Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
### VIM
# Swap
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
*~
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~

@ -0,0 +1,31 @@
BSD 3-Clause License
Copyright (c) 2019, Andreas Gonschorek (agonschorek) <simplezfs@andreasgonschorek.de>
Copyright (c) 2019, Stefan Valouch (svalouch) <svalouch@valouch.com>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

@ -0,0 +1,50 @@
################
Python-SimpleZFS
################
A thin wrapper around ZFS from the `ZFS on Linux <https://zfsonlinux.org/>`_ project.
The library aims at providing a simple, low-level interface for working with ZFS, either by wrapping the ``zfs(8)`` and ``zpool(8)`` CLI utilities or by accessing the native python API.
It does not provide a high-level interface, however, and does not aim to. It also tries to keep as little state as possible.
Two interface classes make up the API, ``ZFS`` and ``ZPool``, which are wrappers around the functionality of the CLI tools of the same name. They come with two implementations:
* The CLI implementation wraps the executables
* The Native implementation uses the native API released with ZoL 0.8.
In this early stage, the native implementation has not been written.
Usage
*****
One can either get a concrete implementation by calling ``ZFSCli``/``ZFSNative`` or ``ZPoolCli``/``ZPoolNative``, or more conveniently use the functions ``get_zfs(implementation_name)`` or ``get_zpool(implementation_name)``.
First, get an instance:
.. code-block:: python-shell
>>> from simplezfs import get_zfs
>>> zfs = get_zfs('cli') # or "native" for the native API
>>> zfs
<simplezfs.zfs_cli.ZFSCli object at 0x7ffbca7fb9e8>
>>>
>>> for ds in zfs.list_datasets():
... print(ds.name)
...
tank
tank/system
tank/system/rootfs
Compatibility
*************
The library is written with `Python` 3.6 or higher in mind, which was in a stable release in a few of the major Linux distributions we care about (Debian Buster, Ubuntu 18.04 LTS, RHEL 8, Gentoo).
On the ZoL_ side, the code is developed mostly on version ``0.8``, and takes some validation values from that release. The library doesn't make a lot of assumptions, the code should work on ``0.7``, too. If you spot an incompatibility, please let us know via the issue tracker.
Testing
*******
An extensive set of tests are in the ``tests/`` subfolder, it can be run using ``pytest`` from the source of the repository. At this time, only the validation functions and the ZFS Cli API are tested, the tests are non-destructive and won't run the actual commands but instead mock away the ``subprocess`` invocations and supply dummy commands to run (usually ``/bin/true``) should the code be changed in a way that isn't caught by the test framework. Nevertheless, keep in mind that if commands are run for whatever reason, they most likely result in unrecoverable data loss.
It is planned to add a separate set of `destructive` tests that need to be specially activated for testing if the code works when run against an actual Linux system. This can't be done using most of the CI providers, as the nature of ZFS requires having a operating system with loaded modules that may be destroyed during the test run.
.. _ZoL: https://zfsonlinux.org/

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

@ -0,0 +1,90 @@
###
API
###
Enumerations
************
.. autoclass:: simplezfs.types.DatasetType
:members:
.. autoclass:: simplezfs.types.PropertySource
:members:
.. autoclass:: simplezfs.types.ZPoolHealth
:members:
Types
*****
.. autoclass:: simplezfs.types.Dataset
:members:
.. autoclass:: simplezfs.types.Property
:members:
Interfaces
**********
ZFS
===
.. autofunction:: simplezfs.zfs.get_zfs
.. autoclass:: simplezfs.ZFS
:members:
ZPool
=====
.. autofunction:: simplezfs.zpool.get_zpool
.. autoclass:: simplezfs.ZPool
:members:
Implementations
***************
.. autoclass:: simplezfs.zfs_cli.ZFSCli
:members:
.. autoclass:: simplezfs.zfs_native.ZFSNative
:members:
.. autoclass:: simplezfs.zpool_cli.ZPoolCli
:members:
.. autoclass:: simplezfs.zpool_native.ZPoolNative
:members:
Validation functions
********************
A set of validation functions exist to validate names and other data. All of them raise a
:class:`simplezfs.exceptions.ValidationError` as a result of a failed validation and return nothing if everything is okay.
.. autofunction:: simplezfs.validation.validate_dataset_name
.. autofunction:: simplezfs.validation.validate_dataset_path
.. autofunction:: simplezfs.validation.validate_native_property_name
.. autofunction:: simplezfs.validation.validate_metadata_property_name
.. autofunction:: simplezfs.validation.validate_pool_name
.. autofunction:: simplezfs.validation.validate_property_value
Exceptions
**********
.. autoexception:: simplezfs.exceptions.ZFSException
.. autoexception:: simplezfs.exceptions.DatasetNotFound
.. autoexception:: simplezfs.exceptions.PermissionError
.. autoexception:: simplezfs.exceptions.PoolNotFound
.. autoexception:: simplezfs.exceptions.PropertyNotFound
.. autoexception:: simplezfs.exceptions.ValidationError

@ -0,0 +1,57 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# http://www.sphinx-doc.org/en/master/config
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'Python-SimpleZFS'
copyright = '2019, Andreas Gonschorek, Stefan Valouch'
author = 'Andreas Gonschorek, Stefan Valouch'
# The full version, including alpha/beta/rc tags
release = '0.0.1'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx_autodoc_typehints',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']

@ -0,0 +1,310 @@
###################
Guide to python-zfs
###################
Overview
********
The interfaces are similar to calling the zfs toolset on the command line. That is, there are not state holding classes
representing filesets or pools, each call must include the entire dataset path or pool name. There is also no way to
collect actions and run them at the end, each action is carried out immediately.
There are, however, two implementations of the functionality, using the ``cli`` tools and another one using the
`libzfs_core` ``native`` library. We'll focus on the ``cli`` version here.
Most of the functions raise a :class:`:: simplezfs.simplezfs.exceptions.ValidationError` with some helpful text if any of the data turns
out not to be valid. For example, including invalid or reserved strings in the dataset name raises this exception.
Let's take a look at the two interfaces **ZFS** and **ZPool**...
.. warning:: All of the commands here attempt to modify something in the pool or dataset given as parameters. If run
with high enough permission (usually ``root``, but there's ``zfs allow`` that can delegate to lower-
privileged users, too) these commands can and will **delete** data! Always run these against pools, disks
and datasets that bear no important data! You have been warned.
The ZFS interface
*****************
The :class:`:: simplezfs.simplezfs.zfs.ZFS` is the interface that corresponds to the ``zfs(8)`` program. It holds very little state,
and it is recommended to get an instance through the function :func:`~zfs.zfs.get_zfs`. It selects the desired
implementation and passes the required parameters. At the very least, it requires the ``api``-parameter, which is a
string that selects the actual implementation, either ``cli`` or ``native``.
All our examples use the ``cli`` implementation for simplicity.
.. code-block:: pycon
>>> from zfs import get_zfs
>>> zfs = get_zfs('cli')
>>> zfs
<zfs.zfs_cli.ZFSCli object at 0x7f9f00faa9b0>
For the remainder of this guide, we're going to assume that the variable ``zfs`` always holds a
:class:`:: simplezfs.simplezfs.zfs_cli.ZFSCli` object.
Viewing data
============
To get an overview over the interface, we'll dive in and inspect our running system. Information is returned in the
form if :class:`:: simplezfs.simplezfs.types.Dataset` instances, which is a named tuple containing a set of fields. For simplicity,
we'll output only a few of its fields to not clobber the screen, so don't be alarmed if there seems to be information
missing: we just omitted the boring parts.
Listing datasets
----------------
By default when listing datasets, all of them are returned regardless of their type. That means that it includes
* volumes
* filesets
* snapshots
* bookmars
.. code-block:: pycon
>>> zfs.list_datasets()
<Dataset pool/system/root>
<Dataset pool/system/root@pre-distupgrade>
<Dataset tank/vol>
<Dataset tank/vol@test>
This is often unneccessary, and it allows to limit both by ``type`` and by including only datasets that are children
of another, and both at the same time:
.. code-block:: pycon
>>> zfs.list_datasets(type=DatasetType.SNAPSHOT)
<Dataset pool/root@pre-distupgrade>
<Dataset tank/vol@test>
>>> zfs.list_datasets(parent='pool/system')
<Dataset pool/root>
<Dataset pool/root@pre-distupgrade>
>>> zfs.list_datasets(parent='pool/system', type=DatasetType.SNAPSHOT)
<Dataset pool/root@pre-distupgrade>
Creating something new
======================
There are functions for creating the four different types of datasets with nice interfaces:
* :func:`~zfs.ZFS.create_fileset` for ordinary filesets, the most commonly used parameter is ``mountpoint`` for
telling it where it should be mounted.
* :func:`~zfs.ZFS.create_volume` creates volumes, or ZVols, this features a parameter ``thin`` for creating thin-
provisioned or sparse volumes.
* :func:`~zfs.ZFS.create_snapshot` creates a snapshot on a volume or fileset.
* :func:`~zfs.ZFS.create_bookmark` creates a bookmark (on recent versions of ZFS).
These essentially call :func:`~zfs.ZFS.create_dataset`, which can be called directly, but its interface is not as
nice as the special purpose create functions.
Filesets
--------
Creating a fileset requires the dataset path, like this:
.. code-block:: pycon
>>> zfs.create_fileset('pool/test', mountpoint='/tmp/test')
<Dataset pool/test>
:todo: add create_dataset
Volumes
-------
Volumes are created similar to filesets, this example creates a thin-provisioned sparse volume:
.. code-block:: pycon
>>> zfs.create_volume('pool/vol', thin=True)
<Dataset pool/vol>
:todo: add create_dataset
Snapshots
---------
Snapshots are, like bookmarks, created on an existing fileset or volume, hence the first parameter to the function is
the dataset that is our base, and the second parameter is the name of the snapshot.
.. code-block:: pycon
>>> zfs.create_snapshot('pool/test', 'pre-distupgrade')
<Dataset pool/test@pre-distupgrade>
Bookmarks
---------
Like snapshots above, bookmarks are created on an existing fileset or volume.
.. code-block:: pycon
>>> zfs.create_bookmark('pool/test', 'book-20190723')
<Dataset pool/test#book-20190723>
Destroying things
=================
After creating some datasets of various kinds and playing around with some of their properties, it's time to clean up.
We'll use the ``destroy_*`` family of methods.
.. warning:: Bear in mind that things happening here are final and cannot be undone. When playing around, always make
sure not to run this on pools containing important data!
Filesets
--------
Volumes
-------
Snapshots
---------
Bookmarks
---------
Properties
==========
Properties are one of the many cool and useful features of ZFS. They control its behaviour (like ``compression``) or
return information about the internal states (like ``creation`` time).
.. note:: The python library does not validate the names of native properties, as these are subject to change with the
ZFS version and it would mean that the library needs an update every time a new ZFS version changes some of
these. Thus, it relies on validating the input for syntax based on the ZFS documentation of the ZFS on Linux
(ZoL) project and ZFS telling it that it did not like a name.
A word on metadata/user properties
----------------------------------
The API allows to get and set properties, for both ``native`` properties (the ones defined by ZFS, exposing information
or altering how it works) and ``user`` properties that we call **metadata properties** in the API.
When working with metadata properties, you need to supply a ``namespace`` to distinguish it from a native property.
This works by separating the namespace and the property name using a ``:`` character, so a property ``myprop``
in the namespace ``com.company.department`` becomes ``com.company.department:myprop`` in the ZFS property system. This
is done automatically for you if you supply a ``metadata_namespace`` when creating the ZFS instance and can be
overwritten when working with the get and set functions. It is also possible not to define the namespace and passing
it to the functions every time.
When you want to get or set a metadata property, set ``metadata`` to **True** when calling
:func:`~zfs.ZFS.get_property` or :func:`~zfs.ZFS.set_property`. This will cause it to automatically prepend the
namespace given on instantiation or to prepend the one given in the ``overwrite_metadata_namespace`` when calling the
functions. The name of the property **must not** include the namespace, though it may contain ``:`` characters on its
own, properties of the form ``zfs:is:cool`` are valid afterall. ``:`` characters are never valid in the context of
native properties, and this is the reason why there is a separate switch to turn on metadata properties when using
these functions.
Error handling
--------------
If a property name is not valid or the value exceeds certain bounds, a :class:`:: simplezfs.simplezfs.exceptions.ValidationError` is
raised. This includes specifying a namespace in the property name if ``metadata`` is **False**, or exceeding the
length allowed for a metadata property (8192 - 1 bytes).
Though not an error for the ``zfs(8)`` utility, getting a non-existing metadata property also raises the above
exception to indicate that the property does not exist.
Getting a property
------------------
Getting properties is fairly straight-forward, especially for native properties:
.. code-block:: pycon
>>> zfs.get_property('tank/system/root', 'mountpoint')
Property(key='mountpoint', value='/', source='local', namespace=None)
For **metadata** properties, one needs to enable their usage by setting ``metadata`` to True. With a globally saved
namespace, it looks like this:
.. code-block:: pycon
>>> zfs = get_zfs('cli', metadata_namespace='com.company')
>>> zfs.get_property('tank/system/root', 'do_backup', metdata=True)
Property(key='do_backup', value='true', source='local', namespace='com.company')
If you don't specify a namespace when calling :func:`~zfs.zfs.get_zfs` or if you want to use a different namespace for
one call, specify the desired namespace in ``overwrite_metadata_namespace`` like so:
.. code-block:: pycon
>>> zfs.get_property('tank/system/root', 'requires', metadata=True, overwrite_metadata_namespace='user')
Property(key='requires', value='coffee', source='local', namespace='user')
This is the equivalent of calling ``zfs get user:requires tank/system/root`` on the shell.
Asking it to get a native property that does not exist results in an error:
.. code-block:: pycon
>>> zfs.get_property('tank/system/root', 'notexisting', metadata=False)
zfs.exceptions.PropertyNotFound: invalid property on dataset tank/test
Setting a property
------------------
The interface for setting both native and metadata properties works exactly like the get interface shown earlier,
though it obviously needs a value to set. We won't go into ZFS delegation system (``zfs allow``) and assume the
following is run using **root** privileges.
.. code-block:: pycon
>>> zfs.set_property('tank/service/backup', 'mountpoint', ''/backup')
Setting a metadata property works like this (again, like above):
.. code-block:: pycon
>>> zfs.set_property('tank/system/root', 'requires', 'tea', metadata=True, overwrite_metadata_namespace='user')
Listing properties
------------------
:todo: ``zfs.get_properties``
The ZPool interface
*******************
The :class:`:: simplezfs.simplezfs.zfs.ZPool` is the interface that corresponds to the ``zpool(8)`` program. It holds very little state,
and it is recommended to get an instance through the function :func:`~simplezfs.zpool.get_zpool`. It selects the desired
implementation and passes the required parameters. At the very least, it requires the ``api``-parameter, which is a
string that selects the actual implementation, either ``cli`` or ``native``.
All our examples use the ``cli`` implementation for simplicity.
.. code-block:: pycon
>>> from simplezfs import get_zpool
>>> zpool = get_zpool('cli')
>>> zpool
<zfs.zpool_cli.ZPoolCli object at 0x7f67d5254940>
For the remainder of this guide, we're going to assume that the variable ``zpool`` always holds a
:class:`:: simplezfs.simplezfs.zpool_cli.ZPoolCli` object.
Error handling
**************
We kept the most important part for last: handling errors. The module defines its own hierarchy with
:class:`:: simplezfs.simplezfs.exceptions.ZFSError` as toplevel exception. Various specific exceptions are based on ot. When working
with :class:`:: simplezfs.simplezfs.zfs.ZFS`, the three most common ones are:
* :class:`:: simplezfs.simplezfs.exceptions.ValidationError` which indicates that a name (e.g. dataset name) was invalid.
* :class:`:: simplezfs.simplezfs.exceptions.DatasetNotFound` is, like FileNotFound in standard python, indicating that the dataset the
module was instructed to work on (e.g. get/set properties, destroy) was not present.
* :class:`:: simplezfs.simplezfs.exceptions.PermissionError` is raised when the current users permissions are not sufficient to perform
the requested operation. While some actions can be delegated using ``zfs allow``, linux, for example, doesn't allow
non-root users to mount filesystems, which means that a non-root user may create filesets with a valid mountpoint
property, but it won't be mounted.
Examples
========
.. code-block:: pycon
>>> zfs.list_dataset(parent=':pool/name/invalid')
zfs.exceptions.ValidationError: malformed name
.. code-block:: pycon
>>> zfs.list_datasets(parent='pool/not/existing')
zfs.exceptions.DatasetNotFound: Dataset "pool/not/existing" not found

@ -0,0 +1,31 @@
##############################
Python-SimpleZFS documentation
##############################
The module implements a simple and straight-forward (hopefully) API for interacting with ZFS. It consists of two main
classes :class:`~simplezfs.zfs.ZFS` and :class:`~simplezfs.zpool.ZPool` that can be thought of as wrappers around the
ZFS command line utilities ``zfs(8)`` and ``zpool(8)``. This module provides two implementations:
* The ``cli``-API wrapps the command line utilities
* And the ``native``-API uses ``libzfs_core``.
At the time of writing, the ``native``-API has not been implemented.
.. toctree::
:maxdepth: 2
:caption: Contents:
quickstart
security
guide
properties_metadata
testing
api
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

@ -0,0 +1,7 @@
#######################
Properties and Metadata
#######################
ZFS features a property system using ``zfs(8) get/set``, which is used to read information about datasets and change their behaviour. Additionally, it allows users to attach arbitrary properties on their own to datasets. ZFS calls these "`user properties`", distinguishing them from its own "`native properties`" by requirim a ``:`` character. This library calls the user properties **metadata** properties.

@ -0,0 +1,54 @@
##########
Quickstart
##########
Installation
************
From the repo
=============
You can pull the code from the repository at URL using pip:
.. code-block:: bash
pip install git+ssh://URL/python-simplezfs
Interfaces
**********
For both ``zfs(8)`` and ``zpool(8)`` there exist two interfaces:
* **cli** calls the ``zfs`` and ``zpool`` binaries in subprocesses
* **native** uses ``libzfs_core`` to achieve the same goals
There exist two functions :func:`~simplezfs.zfs.get_zfs` and :func:`~simplezfs.zpool.get_zpool` that take the implementation as
first argument and return the appropriate implementation. The main documentation about how it works are in the api
documentation for the interface classes :class:`~simplezfs.zfs.ZFS` and :class:`~simplezfs.zpool.ZPool`.
This guide focuses on the ``cli`` variants.
It is not strictly neccessary to get and pass around an instance every time, as the classes hold very little state.
They need to do some initialization however, such as finding the binaries (and thus hitting the local filesystem).
For the rest of the guide, we'll assume that the following has been run and we have a :class:`~simplezfs.zfs.ZFS` instance
in ``zfs``:
.. code-block:: pycon
>>> from simplezfs import get_zfs
>>> zfs = get_zfs('cli')
>>> zfs
<zfs.zfs_cli.ZFSCli object at 0x7f9f00faa9b0>
As well as a :class:`~simplezfs.zpool.ZPool` instance in ``zpool`` after running the following:
.. code-block:: pycon
>>> from simplezfs import get_zpool
>>> zpool = get_zpool('cli')
>>> zpool
<zfs.zpool_cli.ZPoolCli object at 0x7f67d5254940>
To be continued

@ -0,0 +1,2 @@
Sphinx>=2.0
sphinx-autodoc-typehints

@ -0,0 +1,36 @@
########
Security
########
The authors of this library tried their best to make sure that nothing bad happens, but they are only human. Thus:
.. warning:: Use this library at your own risk.
Running as root
***************
The library goes to great length to make sure everything passed to the operating system is safe. Due to the nature of
operating systems, many tasks can only be carried out with elevated privileges, commonly running with the privileges
of the user ``root``. This means that care must be taken when using it with elevated privileges!
Thankfully, ZFS allows to delegate permission to some extend, allowing a user or a group on the local system to carry
out administrative tasks. One exception is mounting, which is handled in the next paragraph.
It is suggested to take a look at the ``zfs(8)`` manpage, especially the part that covers ``zfs allow``.
.. _the_mount_problem:
The mount problem
*****************
On Linux, only root is allowed to manipulate the global namespace. This means that no amount of ``zfs allow`` will
allow any other user to mount a fileset. They can be created with the ``mountpoint`` property set, but can't be
mounted. One workaround is to specify ``legacy``, and usr ``/etc/fstab`` to mount it, the other is to install and use
a special privilege escalation helper.
There are two places where it is needed to have **root** privileges: Setting or changing the ``mountpoint`` property
of a fileset. For this, a helper program is provided as an example. It is intended to be edited by the administrator
before use.
Installed **setuid root**, it allows the caller to mount a fileset if it is below a hardcoded (by the administrator
of the system) dataset and targets a hardcoded subtree of the local filesystem hierarchy. The path to that helper has
to be passed to the create function for filesets.

@ -0,0 +1,194 @@
#######
Testing
#######
``python-zfs`` uses **pytest** for testing. The test code can be found in the ``tests``-subdirectory of the source
tree.
Preparation
===========
Usually, when running the test suite locally in your shell, it is advised to use a virtual environment. Which one
to use is up to the reader. The authors use the ``venv`` module. The requirements can be found in the file
``requirements_develop.txt`` or by installing the module with tests.
.. code-block:: shell-session
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install -e .[tests]
Running the tests
=================
Then run the tests using pytest: ``pytest -v --cov``. The test suite will be expanded in the future.
Creating local pools and datasets
=================================
Pool with most vdev types
-------------------------
To have a pool with most of the supported vdev types, create a pool from a set of files. The following sequence
creates a set of 64MB files (the minimum size ZFS accepts). The new pool is not really usable for storing data,
mostly because it is based on sparse files and ZFS does not like that. But it should be usable for most of the actions
that one wants to perform on it for testing purposes.
.. code-block:: shell
mkdir /tmp/z
for i in {1..12}; do truncate -s 64M /tmp/z/vol$i; done
sudo zpool create testpool \
raidz /tmp/z/vol1 /tmp/z/vol2 /tmp/z/vol3 \
raidz /tmp/z/vol4 /tmp/z/vol5 /tmp/z/vol6 \
log mirror /tmp/z/vol7 /tmp/z/vol8 \
cache /tmp/z/vol9 /tmp/z/vol10 \
spare /tmp/z/vol11 /tmp/z/vol12
The new pool should now look like this:
.. code-block:: none
pool: testpool
state: ONLINE
scan: none requested
config:
NAME STATE READ WRITE CKSUM
testpool ONLINE 0 0 0
raidz1-0 ONLINE 0 0 0
/tmp/z/vol1 ONLINE 0 0 0
/tmp/z/vol2 ONLINE 0 0 0
/tmp/z/vol3 ONLINE 0 0 0
raidz1-1 ONLINE 0 0 0
/tmp/z/vol4 ONLINE 0 0 0
/tmp/z/vol5 ONLINE 0 0 0
/tmp/z/vol6 ONLINE 0 0 0
logs
mirror-2 ONLINE 0 0 0
/tmp/z/vol7 ONLINE 0 0 0
/tmp/z/vol8 ONLINE 0 0 0
cache
/tmp/z/vol9 ONLINE 0 0 0
/tmp/z/vol10 ONLINE 0 0 0
spares
/tmp/z/vol11 AVAIL
/tmp/z/vol12 AVAIL
errors: No known data errors
For reference, when getting a listing of the pools content, the output should look like this (converted to json using
``json.dumps()`` and pretty-printed for readability:
.. code-block:: json
{
"testpool": {
"drives": [
{
"type": "raidz1",
"health": "ONLINE",
"size": 184549376,
"alloc": 88064,
"free": 184461312,
"frag": 0,
"cap": 0,
"members": [
{
"name": "/tmp/z/vol1",
"health": "ONLINE"
},
{
"name": "/tmp/z/vol2",
"health": "ONLINE"
},
{
"name": "/tmp/z/vol3",
"health": "ONLINE"
}
]
},
{
"type": "raidz1",
"health": "ONLINE",
"size": 184549376,
"alloc": 152576,
"free": 184396800,
"frag": 0,
"cap": 0,
"members": [
{
"name": "/tmp/z/vol4",
"health": "ONLINE"
},
{
"name": "/tmp/z/vol5",
"health": "ONLINE"
},
{
"name": "/tmp/z/vol6",
"health": "ONLINE"
}
]
}
],
"log": [
{
"type": "mirror",
"size": 50331648,
"alloc": 0,
"free": 50331648,
"frag": 0,
"cap": 0,
"members": [
{
"name": "/tmp/z/vol7",
"health": "ONLINE"
},
{
"name": "/tmp/z/vol8",
"health": "ONLINE"
}
]
}
],
"cache": [
{
"type": "none",
"members": [
{
"name": "/tmp/z/vol9",
"health": "ONLINE"
},
{
"name": "/tmp/z/vol10",
"health": "ONLINE"
}
]
}
],
"spare": [
{
"type": "none",
"members": [
{
"name": "/tmp/z/vol11",
"health": "AVAIL"
},
{
"name": "/tmp/z/vol12",
"health": "AVAIL"
}
]
}
],
"size": 369098752,
"alloc": 240640,
"free": 368858112,
"chkpoint": null,
"expandsz": null,
"frag": 0,
"cap": 0,
"dedup": 1,
"health": "ONLINE",
"altroot": null
}
}

@ -0,0 +1 @@
pydantic

@ -0,0 +1,4 @@
flake8
mypy
pytest
pytest-cov

@ -0,0 +1,25 @@
[flake8]
ignore = E501,E402
max-line-length = 120
exclude = .git,.tox,build,_build,env,venv,__pycache__
[tool:pytest]
testpaths = tests
python_files =
test_*.py
*_test.py
tests.py
addopts =
-ra
--strict
--tb=short
# potentially dangerous!
# --doctest-modules
# --doctest-glob=\*.rst
[coverage:run]
omit =
venv/*
tests/*

@ -0,0 +1,47 @@
from setuptools import setup
with open('README.rst', 'r') as fh:
long_description = fh.read()
setup(
name='simplezfs',
version='0.0.1',
author='Andreas Gonschorek, Stefan Valouch',
description='Simple, low-level ZFS API',
long_description=long_description,
packages=['simplezfs'],
package_data={'simplezfs': ['py.typed']},
package_dir={'': 'src'},
include_package_data=True,
zip_safe=False,
license='BSD-3-Clause',
url='https://github.com/svalouch/python-simplezfs',
platforms='any',
python_requires='>=3.6',
install_requires=[
'pydantic',
],
extras_require={
'tests': [
'mypy',
'pytest',
'pytest-cov',
],
'docs': [
'Sphinx>=2.0',
],
},
classifiers=[
'Development Status :: 2 - Pre-Alpha',
'License :: OSI Approved :: BSD License',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
],
)

@ -0,0 +1,16 @@
'''
Python ZFS API.
'''
__version__ = '0.0.1'
from .zfs import ZFS, get_zfs
from .zpool import ZPool, get_zpool
__all__ = [
'ZFS',
'get_zfs',
'ZPool',
'get_zpool',
]

@ -0,0 +1,48 @@
'''
Exceptions
'''
class ZFSException(Exception):
'''
Base for the exception tree used by this library.
:note: Some functions throw ordinary python base exceptions as well.
'''
pass
class DatasetNotFound(ZFSException):
'''
A dataset was not found.
'''
pass
class PermissionError(ZFSException):
'''
Permissions are not sufficient to carry out the task.
'''
pass
class PoolNotFound(ZFSException):
'''
A pool was not found.
'''
pass
class PropertyNotFound(ZFSException):
'''
A property was not found.
'''
pass
class ValidationError(ZFSException):
'''
Indicates that a value failed validation.
'''
pass

@ -0,0 +1,181 @@
'''
Type declarations
'''
from enum import Enum, unique
from typing import NamedTuple, Optional
@unique
class DatasetType(str, Enum):
'''
Enumeration of dataset types that ZFS supports.
'''
#: Dataset is an ordinary fileset
FILESET = 'fileset'
#: Dataset is a ZVOL
VOLUME = 'volume'
#: Dataset is a snapshot
SNAPSHOT = 'snapshot'
#: Dataset is a bookmark
BOOKMARK = 'bookmark'
@staticmethod
def from_string(value: str) -> 'DatasetType':
'''
Helper to convert a string to an instance.
:param value: The string to converts.
:returns: The enum value
:raises ValueError: If the supplied value is not found in the enumeration.
'''
if not isinstance(value, str):
raise ValueError('only string types allowed')
val = value.lower()
if val == 'fileset':
return DatasetType.FILESET
elif val == 'volume':
return DatasetType.VOLUME
elif val == 'snapshot':
return DatasetType.SNAPSHOT
elif val == 'bookmark':
return DatasetType.BOOKMARK
else:
raise ValueError(f'Value {value} is not a valid DatasetType')
class Dataset(NamedTuple):
'''
Container describing a single dataset.
'''
#: Name of the dataset (excluding the path)
name: str
#: Full path to and including the dataset itself
full_path: str
#: Pool name
pool: str
#: Parent dataset, or None for the topmost dataset (pool)
parent: Optional[str]
#: Dataset type
type: DatasetType
@unique
class PropertySource(str, Enum):
'''
Enumeration of the valid property sources in ZFS.
'''
#: Property is at default
DEFAULT = 'default'
#: Property was inerited
INHERITED = 'inherited'
#: Property is temporary
TEMPORARY = 'temporary'
#: Property is set to the value that it had due to the sender of a "zfs send/receive" operation having it set this way.
RECEIVED = 'received'
#: Property is set on the dataset in question
NONE = 'none'
@staticmethod
def from_string(value: str) -> 'PropertySource':
'''
Helper to convert a string to an instance.
:param value: The string to convert.
:returns: The enum value.
:raises ValueError: If the supplied value is not found in the enumeration.
'''
if not isinstance(value, str):
raise ValueError('only string types allowed')
val = value.lower()
if val == 'default':
return PropertySource.DEFAULT
elif val == 'inherited':
return PropertySource.INHERITED
elif val == 'temporary':
return PropertySource.TEMPORARY
elif val == 'received':
return PropertySource.RECEIVED
elif val == 'none':
return PropertySource.NONE
else:
raise ValueError(f'Value {value} is not a valid PropertySource')
class Property(NamedTuple):
'''
Container for a single ZFS property.
'''
#: Key or name of the property (excluding namespace for non-native properties)
key: str
#: Value of the property
value: str
#: Source of the property value
source: PropertySource = PropertySource.NONE
#: Namespace name of the property, None for native properties
namespace: Optional[str] = None
class VDevType(str, Enum):
'''
Type of a vdev. vdevs are either one of two storages (disk or file) or one of the special parts (mirror, raidz*,
spare, log, dedup, special and cache).
When reading zpool output, it is not always clear what type a vdev is when it comes to "disk" vs. "file", so a
special type "DISK_OR_FILE" is used. This type is invalid when setting values.
'''
DISK = 'disk'
FILE = 'file'
DISK_OR_FILE = 'disk_or_file'
MIRROR = 'mirror'
RAIDZ1 = 'raidz1'
RAIDZ2 = 'raidz2'
RAIDZ3 = 'raidz3'
SPARE = 'spare'
LOG = 'log'
DEDUP = 'dedup'
SPECIAL = 'special'
CACHE = 'cache'
class ZPoolHealth(str, Enum):
'''
'''
ONLINE = 'ONLINE'
DEGRADED = 'DEGRADED'
FAULTED = 'FAULTED'
OFFLINE = 'OFFLINE'
UNAVAIL = 'UNAVAIL'
REMOVED = 'REMOVED'
#: This one is used for spares
AVAIL = 'AVAIL'
@staticmethod
def from_string(value: str) -> 'ZPoolHealth':
'''
Helper to convert a string to an instance.
:param value: The string to convert.
:returns: The enum value.
:raises ValueError: If the supplied value is not found in the enumeration.
'''
if not isinstance(value, str):
raise ValueError('only string types are allowed')
val = value.lower()
if val == 'online':
return ZPoolHealth.ONLINE
elif val == 'degraded':
return ZPoolHealth.DEGRADED
elif val == 'faulted':
return ZPoolHealth.FAULTED
elif val == 'offline':
return ZPoolHealth.OFFLINE
elif val == 'unavail':
return ZPoolHealth.UNAVAIL
elif val == 'removed':
return ZPoolHealth.REMOVED
elif val == 'avail':
return ZPoolHealth.AVAIL
else:
raise ValueError(f'Value {value} is not a valid ZPoolHealth')

@ -0,0 +1,174 @@
'''
Functions for validating data.
'''
import re
from .exceptions import ValidationError
#: Maximum length of a dataset (from zfs(8)) in bytes
MAXNAMELEN: int = 256
#: Maximum length of a native property name (assumed, TODO check in source)
NATIVE_PROPERTY_NAME_LEN_MAX: int = MAXNAMELEN
#: Maximum length of a metadata property name (assumed, TODO check in source)
METADATA_PROPERTY_NAME_LEN_MAX: int = MAXNAMELEN
#: Maximum length of a metadata property value in bytes
METADATA_PROPERTY_VALUE_LEN_MAX: int = 8192
#: Regular expression for validating dataset names, handling both the name itself as well as snapshot or bookmark names.
DATASET_NAME_RE = re.compile(r'^(?P<dataset>[a-zA-Z0-9_\-.:]+)(?P<detail>(@|#)[a-zA-Z0-9_\-.:]+)?$')
#: Regular expression for validating a native property name
NATIVE_PROPERTY_NAME_RE = re.compile(r'^[a-z]([a-z0-9]+)?$')
#: Regular expression for validating the syntax of a user property (metadata property in python-zfs)
METADATA_PROPERTY_NAME_RE = re.compile(r'^([a-z0-9_.]([a-z0-9_.\-]+)?)?:([a-z0-9:_.\-]+)$')
def validate_pool_name(name: str) -> None:
'''
Validates a pool name.
:raises ValidationError: Indicates validation failed
'''
try:
b_name = name.encode('utf-8')
except UnicodeEncodeError as err:
raise ValidationError(f'unicode encoding error: {err}') from err
if len(b_name) < 1:
raise ValidationError('too short')
# TODO find maximum pool name length
# The pool name must begin with a letter, and can only contain alphanumeric characters as well as underscore
# ("_"), dash ("-"), colon (":"), space (" "), and period (".").
if not re.match(r'^[a-z]([a-z0-9\-_: .]+)?$', name):
raise ValidationError('malformed name')
# The pool names mirror, raidz, spare and log are reserved,
if name in ['mirror', 'raidz', 'spare', 'log']:
raise ValidationError('reserved name')
# as are names beginning with mirror, raidz, spare,
for word in ['mirror', 'raidz', 'spare']:
if name.startswith(word):
raise ValidationError(f'starts with invalid token {word}')
# and the pattern c[0-9].
if re.match(r'^c[0-9]', name):
raise ValidationError('begins with reserved sequence c[0-9]')
def validate_dataset_name(name: str, *, strict: bool = False) -> None:
'''
Validates a dataset name. By default (``strict`` set to **False**) a snapshot (``name@snapshotname``) or bookmark
(``name#bookmarkname``) postfix are allowed. Otherwise, these names are rejected. To validate a complete path, see
:func:`validate_dataset_path`.
Example:
>>> from zfs.validation import validate_dataset_name
>>> validate_dataset_name('swap')
>>> validate_dataset_name('backup', strict=True)
>>> validate_dataset_name('backup@20190525', strict=True)
zfs.validation.ValidationError: snapshot or bookmark identifier are not allowed in strict mode
>>> validate_dataset_name('')
zfs.validation.ValidationError: name is too short
>>> validate_dataset_name('pool/system')
zfs.validation.ValidationError: name contains disallowed characters
>>> validate_dataset_name('a' * 1024) # really long name
zfs.validation.ValidationError: length > 255
:param name: The name to validate
:param strict: Whether to allow (``False``) or disallow (``True``) snapshot and bookmark names.
:raises ValidationError: Indicates validation failed
'''
try:
b_name = name.encode('utf-8')
except UnicodeEncodeError as err:
raise ValidationError(f'unicode encoding error: {err}') from err
if len(b_name) < 1:
raise ValidationError('name is too short')
if len(b_name) > MAXNAMELEN - 1:
raise ValidationError(f'length > {MAXNAMELEN - 1}')
match = DATASET_NAME_RE.match(name)
if not match:
raise ValidationError('name contains disallowed characters')
elif strict and match.group('detail') is not None:
raise ValidationError('snapshot or bookmark identifier are not allowed in strict mode')
def validate_dataset_path(path: str) -> None:
'''
Validates a path of datasets. While :func:`validate_dataset_name` validates only a single entry in a path to a
dataset, this function validates the whole path beginning with the pool.
:raises ValidationError: Indicates validation failed
'''
try:
b_name = path.encode('utf-8')
except UnicodeEncodeError as err:
raise ValidationError(f'unicode encoding error: {err}') from err
if len(b_name) < 3: # a/a is the smallest path
raise ValidationError('path is too short')
if '/' not in path:
raise ValidationError('Not a path')
if path.startswith('/'):
raise ValidationError('zfs dataset paths are never absolute')
tokens = path.split('/')
# the first token is the pool name
validate_pool_name(tokens[0])
# second token to second-to-last token are normal datasets that must not be snapshots or bookmarks
for dataset in tokens[1:-1]:
validate_dataset_name(dataset, strict=True)
# last token is the actual dataset, this may be a snapshot or bookmark
validate_dataset_name(tokens[-1])
def validate_native_property_name(name: str) -> None:
'''
Validates the name of a native property. Length and syntax is checked.
:note: No check is performed to match the name against the actual names the target ZFS version supports.
:raises ValidationError: Indicates that the validation failed.
'''
try:
b_name = name.encode('utf-8')
except UnicodeEncodeError as err:
raise ValidationError(f'unicode encoding error: {err}') from err
if len(b_name) < 1:
raise ValidationError('name is too short')
if len(b_name) > NATIVE_PROPERTY_NAME_LEN_MAX - 1:
raise ValidationError(f'length > {NATIVE_PROPERTY_NAME_LEN_MAX - 1}')
if not NATIVE_PROPERTY_NAME_RE.match(name):
raise ValidationError('property name does not match')
def validate_metadata_property_name(name: str) -> None:
'''
Validate the name of a metadata property (user property in ZFS manual).
:raises ValidationError: Indicates that the validation failed.
'''
try:
b_name = name.encode('utf-8')
except UnicodeEncodeError as err:
raise ValidationError(f'unicode encoding error: {err}') from err
if len(b_name) < 1:
raise ValidationError('name is too short')
if len(b_name) > METADATA_PROPERTY_NAME_LEN_MAX - 1:
raise ValidationError(f'length > {METADATA_PROPERTY_NAME_LEN_MAX - 1}')
if not METADATA_PROPERTY_NAME_RE.match(name):
raise ValidationError('property name does not match')
def validate_property_value(value: str) -> None:
'''
Validates the value of a property. This works for both native properties, where the driver will tell us if the
value was good or not, as well metadata (or user) properties where the only limit is its length.
:param value: The value to validate.
:raises ValidationError: Indicates that the validation failed.
'''
try:
b_value = value.encode('utf-8')
except UnicodeEncodeError as err:
raise ValidationError(f'unicode encoding error: {err}') from err
if len(b_value) > METADATA_PROPERTY_VALUE_LEN_MAX - 1:
raise ValidationError(f'length > {METADATA_PROPERTY_VALUE_LEN_MAX - 1}')

@ -0,0 +1,550 @@
'''
ZFS frontend API
'''
from typing import Dict, List, Optional, Union
from .exceptions import (
DatasetNotFound,
PermissionError,
PoolNotFound,
PropertyNotFound,
ValidationError,
)
from .types import Dataset, DatasetType, Property
from .validation import (
validate_dataset_path,
validate_metadata_property_name,
validate_native_property_name,
validate_pool_name,
validate_property_value,
)
class ZFS:
'''
ZFS interface class. This API generally covers only the zfs(8) tool, for zpool(8) please see :class:`~ZPool`.
**ZFS implementation**
There are two ways how the API actually communicates with the ZFS filesystem:
* Using the CLI tools (:class:`~zfs.zfs_cli.ZFSCli`)
* Using the native API (:class:`~zfs.zfs_native.ZFSNative`)
You can select the API by either creating an instance of one of them or using :func:`~zfs.zfs.get_zfs`.
**Properties and Metadata**
The functions :func:`set_property`, :func:`get_property` and :func:`get_properties` wraps the ZFS get/set
functionality. To support so-alled `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.
: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.
:param api: API to use, either ``cli`` for zfs(8) or ``native`` for the `libzfs_core` api.
:param metadata_namespace: Default namespace
:param kwargs: Extra arguments TODO
'''
def __init__(self, *, metadata_namespace: Optional[str] = None, **kwargs) -> None:
self.metadata_namespace = metadata_namespace
@property
def metadata_namespace(self) -> Optional[str]:
'''
Returns the metadata namespace, which may be None if not set.
'''
return self._metadata_namespace
@metadata_namespace.setter
def metadata_namespace(self, namespace: str) -> None:
'''
Sets a new metadata namespace
:todo: validate!
'''
self._metadata_namespace = namespace
def dataset_exists(self, name: str ) -> bool:
'''
Checks is a dataset exists. This is done by querying for its `type` property.
:param name: Name of the dataset to check for.
:return: Whether the dataset exists.
'''
try:
return self.get_property(name, 'type') is not None
except (DatasetNotFound, PermissionError, PoolNotFound):
return