From 07df04f9f768e472d99972acc0cbe8f5d7b68f46 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Mon, 5 Aug 2019 14:59:36 +0200 Subject: [PATCH 001/135] new release cycle --- .gitignore | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9a6d7ce..fe1f1c0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ pip-wheel-metadata/ *.ipynb notes.md *.yaml +dist/ diff --git a/setup.py b/setup.py index a66cb9f..9113e0e 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # Package version -__version__ = '0.0.1' +__version__ = '0.0.2' # List all versions of Python which are supported confirmed_python_versions = [ From b79bd67e9a41d4e348c39740f125134ad2bb846e Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 27 Jun 2020 16:13:26 +0200 Subject: [PATCH 002/135] remove debug output --- src/abgleich/cli/cleanup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/abgleich/cli/cleanup.py b/src/abgleich/cli/cleanup.py index af849fc..c5ad1f8 100644 --- a/src/abgleich/cli/cleanup.py +++ b/src/abgleich/cli/cleanup.py @@ -68,7 +68,6 @@ def cleanup(configfile): name, snapshot_name ]) - print(datasets[0]) print(tabulate( table, From 90baa4c97c65e3d82e36019493c2092d30491aac Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 10:56:28 +0200 Subject: [PATCH 003/135] comparison fix --- src/abgleich/zfs/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/__init__.py b/src/abgleich/zfs/__init__.py index 5b035d8..90fc3d0 100644 --- a/src/abgleich/zfs/__init__.py +++ b/src/abgleich/zfs/__init__.py @@ -118,7 +118,9 @@ def get_backup_ops(tree_a, prefix_a, tree_b, prefix_b, ignore): dataset_in_a = name in subdict_a.keys() dataset_in_b = name in subdict_b.keys() if not dataset_in_a and dataset_in_b: - raise ValueError('no source dataset "%s" - only remote' % name) + # raise ValueError('no source dataset "%s" - only remote' % name) + print('no source dataset "%s" - only remote' % name) + continue if dataset_in_a and not dataset_in_b and len(subdict_a[name]['SNAPSHOTS']) == 0: raise ValueError('no snapshots in dataset "%s" - can not send' % name) if dataset_in_a and not dataset_in_b: From 60718f125285a620d29fa9bcbe92cd085384fe26 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 11:00:55 +0200 Subject: [PATCH 004/135] fix dev install --- makefile | 3 ++- setup.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/makefile b/makefile index 6a4418d..06763c8 100644 --- a/makefile +++ b/makefile @@ -20,4 +20,5 @@ release: gpg --detach-sign -a dist/abgleich*.tar.gz install: - pip install -v -e . + pip install -vU pip setuptools + pip install -v -e .[dev] diff --git a/setup.py b/setup.py index 9113e0e..4a96788 100644 --- a/setup.py +++ b/setup.py @@ -83,7 +83,7 @@ ], extras_require = {'dev': [ # 'pytest', - 'python-language-server', + 'python-language-server[all]', 'setuptools', # 'Sphinx', # 'sphinx_rtd_theme', From 416495f578cefc60416bff7b33573076dd8423b4 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 11:05:09 +0200 Subject: [PATCH 005/135] python 3.8 compatible --- setup.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 4a96788..87121c6 100644 --- a/setup.py +++ b/setup.py @@ -43,10 +43,12 @@ __version__ = '0.0.2' # List all versions of Python which are supported +python_minor_min = 5 +python_minor_max = 8 confirmed_python_versions = [ - ('Programming Language :: Python :: %s' % x) - for x in '3.5 3.6 3.7'.split(' ') - ] + 'Programming Language :: Python :: 3.{MINOR:d}'.format(MINOR = minor) + for minor in range(python_minor_min, python_minor_max + 1) + ] # Fetch readme file with open(os.path.join(os.path.dirname(__file__), 'README.md')) as f: From 6864e3588a2d2869cdab87c4c2e6762927b1474a Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 11:05:53 +0200 Subject: [PATCH 006/135] python_requires --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 87121c6..d072ba5 100644 --- a/setup.py +++ b/setup.py @@ -77,6 +77,7 @@ ], scripts = [], include_package_data = True, + python_requires = '>=3.{MINOR:d}'.format(MINOR = python_minor_min), setup_requires = [], install_requires = [ 'click', From 740c342b2a48bd8d3e6893d1864476058eba472c Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 11:07:46 +0200 Subject: [PATCH 007/135] change log --- CHANGES.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..f01c352 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,9 @@ +# Changes + +## 0.0.2 (2020-XX-XX) + +- FEATURE: Python 3.8 support added + +## 0.0.1 (2019-08-05) + +- Initial release. From 8317a6b7a824922f6ca07724b9a673685cfe455c Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 11:08:59 +0200 Subject: [PATCH 008/135] black dev dependency --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index d072ba5..e76563f 100644 --- a/setup.py +++ b/setup.py @@ -85,6 +85,7 @@ 'pyyaml', ], extras_require = {'dev': [ + 'black', # 'pytest', 'python-language-server[all]', 'setuptools', From b235028a71628d7d9a3b41a05d3969e309e420f8 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 11:09:34 +0200 Subject: [PATCH 009/135] updated copyright --- setup.py | 2 +- src/abgleich/__init__.py | 2 +- src/abgleich/cli/__init__.py | 2 +- src/abgleich/cli/_main_.py | 2 +- src/abgleich/cli/backup.py | 2 +- src/abgleich/cli/cleanup.py | 2 +- src/abgleich/cli/compare.py | 2 +- src/abgleich/cli/snap.py | 2 +- src/abgleich/cli/tree.py | 2 +- src/abgleich/cmd.py | 2 +- src/abgleich/io.py | 2 +- src/abgleich/zfs/__init__.py | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/setup.py b/setup.py index e76563f..596b633 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup.py: Used for package distribution - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/__init__.py b/src/abgleich/__init__.py index 6a06ef7..97275ca 100644 --- a/src/abgleich/__init__.py +++ b/src/abgleich/__init__.py @@ -8,7 +8,7 @@ src/abgleich/__init__.py: Package root - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/cli/__init__.py b/src/abgleich/cli/__init__.py index 24c39e0..f96b87e 100644 --- a/src/abgleich/cli/__init__.py +++ b/src/abgleich/cli/__init__.py @@ -8,7 +8,7 @@ src/abgleich/cli/__init__.py: CLI package root - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/cli/_main_.py b/src/abgleich/cli/_main_.py index 373a3af..2243d5a 100644 --- a/src/abgleich/cli/_main_.py +++ b/src/abgleich/cli/_main_.py @@ -8,7 +8,7 @@ src/abgleich/cli/_main_.py: CLI auto-detection - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/cli/backup.py b/src/abgleich/cli/backup.py index 3177ade..ea07fd1 100644 --- a/src/abgleich/cli/backup.py +++ b/src/abgleich/cli/backup.py @@ -8,7 +8,7 @@ src/abgleich/cli/backup.py: backup command entry point - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/cli/cleanup.py b/src/abgleich/cli/cleanup.py index c5ad1f8..30d1be7 100644 --- a/src/abgleich/cli/cleanup.py +++ b/src/abgleich/cli/cleanup.py @@ -8,7 +8,7 @@ src/abgleich/cli/cleanup.py: cleanup command entry point - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/cli/compare.py b/src/abgleich/cli/compare.py index 44fe8a8..6bab52a 100644 --- a/src/abgleich/cli/compare.py +++ b/src/abgleich/cli/compare.py @@ -8,7 +8,7 @@ src/abgleich/cli/compare.py: compare command entry point - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/cli/snap.py b/src/abgleich/cli/snap.py index a983865..b3dba9d 100644 --- a/src/abgleich/cli/snap.py +++ b/src/abgleich/cli/snap.py @@ -8,7 +8,7 @@ src/abgleich/cli/snap.py: snap command entry point - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/cli/tree.py b/src/abgleich/cli/tree.py index db6cbaf..5019beb 100644 --- a/src/abgleich/cli/tree.py +++ b/src/abgleich/cli/tree.py @@ -8,7 +8,7 @@ src/abgleich/cli/tree.py: tree command entry point - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/cmd.py b/src/abgleich/cmd.py index 38544f4..3652760 100644 --- a/src/abgleich/cmd.py +++ b/src/abgleich/cmd.py @@ -8,7 +8,7 @@ src/abgleich/cmd.py: Subprocess wrappers - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/io.py b/src/abgleich/io.py index cc2db54..23c667c 100644 --- a/src/abgleich/io.py +++ b/src/abgleich/io.py @@ -8,7 +8,7 @@ src/abgleich/io.py: Command line IO - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License diff --git a/src/abgleich/zfs/__init__.py b/src/abgleich/zfs/__init__.py index 90fc3d0..75730a0 100644 --- a/src/abgleich/zfs/__init__.py +++ b/src/abgleich/zfs/__init__.py @@ -8,7 +8,7 @@ src/abgleich/zfs/__init__.py: ZFS package root - Copyright (C) 2019 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License From 35052bf54cf4e80ff283022e3834c2128bbc6d51 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 12:17:44 +0200 Subject: [PATCH 010/135] dropped python 3.5 support --- CHANGES.md | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index f01c352..024c65e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## 0.0.2 (2020-XX-XX) - FEATURE: Python 3.8 support added +- Dropped Python 3.5 support ## 0.0.1 (2019-08-05) diff --git a/setup.py b/setup.py index 596b633..fab3f6f 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ __version__ = '0.0.2' # List all versions of Python which are supported -python_minor_min = 5 +python_minor_min = 6 python_minor_max = 8 confirmed_python_versions = [ 'Programming Language :: Python :: 3.{MINOR:d}'.format(MINOR = minor) From 255e7b5490cc03fabbd565dc0d211b111c5b132d Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 12:18:25 +0200 Subject: [PATCH 011/135] black --- setup.py | 146 ++++++++++++++++++++++++++----------------------------- 1 file changed, 70 insertions(+), 76 deletions(-) diff --git a/setup.py b/setup.py index fab3f6f..040de24 100644 --- a/setup.py +++ b/setup.py @@ -30,9 +30,9 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ from setuptools import ( - find_packages, - setup, - ) + find_packages, + setup, +) import os # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -40,89 +40,83 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # Package version -__version__ = '0.0.2' +__version__ = "0.0.2" # List all versions of Python which are supported python_minor_min = 6 python_minor_max = 8 confirmed_python_versions = [ - 'Programming Language :: Python :: 3.{MINOR:d}'.format(MINOR = minor) + "Programming Language :: Python :: 3.{MINOR:d}".format(MINOR=minor) for minor in range(python_minor_min, python_minor_max + 1) - ] +] # Fetch readme file -with open(os.path.join(os.path.dirname(__file__), 'README.md')) as f: - long_description = f.read() +with open(os.path.join(os.path.dirname(__file__), "README.md")) as f: + long_description = f.read() # Define source directory (path) -SRC_DIR = 'src' +SRC_DIR = "src" # Install package setup( - name = 'abgleich', - packages = find_packages(SRC_DIR), - package_dir = {'': SRC_DIR}, - version = __version__, - description = 'zfs sync tool', - long_description = long_description, - long_description_content_type = 'text/markdown', - author = 'Sebastian M. Ernst', - author_email = 'ernst@pleiszenburg.de', - url = 'https://github.com/pleiszenburg/abgleich', - download_url = 'https://github.com/pleiszenburg/abgleich/archive/v%s.tar.gz' % __version__, - license = 'LGPLv2', - keywords = [ - 'zfs', - 'ssh', - ], - scripts = [], - include_package_data = True, - python_requires = '>=3.{MINOR:d}'.format(MINOR = python_minor_min), - setup_requires = [], - install_requires = [ - 'click', - 'tabulate', - 'pyyaml', - ], - extras_require = {'dev': [ - 'black', - # 'pytest', - 'python-language-server[all]', - 'setuptools', - # 'Sphinx', - # 'sphinx_rtd_theme', - 'twine', - 'wheel', - ]}, - zip_safe = False, - entry_points = { - 'console_scripts': [ - 'abgleich = abgleich.cli:cli', - ], - }, - classifiers = [ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'Intended Audience :: Education', - 'Intended Audience :: Information Technology', - 'Intended Audience :: Science/Research', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)', - 'Operating System :: MacOS', - 'Operating System :: POSIX :: BSD', - 'Operating System :: POSIX :: Linux', - 'Programming Language :: Python :: 3' - ] + confirmed_python_versions + [ - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: Implementation :: CPython', - 'Topic :: Scientific/Engineering', - 'Topic :: System', - 'Topic :: System :: Archiving', - 'Topic :: System :: Archiving :: Backup', - 'Topic :: System :: Archiving :: Mirroring', - 'Topic :: System :: Filesystems', - 'Topic :: System :: Systems Administration', - 'Topic :: Utilities' - ] - ) + name="abgleich", + packages=find_packages(SRC_DIR), + package_dir={"": SRC_DIR}, + version=__version__, + description="zfs sync tool", + long_description=long_description, + long_description_content_type="text/markdown", + author="Sebastian M. Ernst", + author_email="ernst@pleiszenburg.de", + url="https://github.com/pleiszenburg/abgleich", + download_url="https://github.com/pleiszenburg/abgleich/archive/v%s.tar.gz" + % __version__, + license="LGPLv2", + keywords=["zfs", "ssh",], + scripts=[], + include_package_data=True, + python_requires=">=3.{MINOR:d}".format(MINOR=python_minor_min), + setup_requires=[], + install_requires=["click", "tabulate", "pyyaml",], + extras_require={ + "dev": [ + "black", + # 'pytest', + "python-language-server[all]", + "setuptools", + # 'Sphinx', + # 'sphinx_rtd_theme', + "twine", + "wheel", + ] + }, + zip_safe=False, + entry_points={"console_scripts": ["abgleich = abgleich.cli:cli",],}, + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)", + "Operating System :: MacOS", + "Operating System :: POSIX :: BSD", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + ] + + confirmed_python_versions + + [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Scientific/Engineering", + "Topic :: System", + "Topic :: System :: Archiving", + "Topic :: System :: Archiving :: Backup", + "Topic :: System :: Archiving :: Mirroring", + "Topic :: System :: Filesystems", + "Topic :: System :: Systems Administration", + "Topic :: Utilities", + ], +) From ac0a293b7d6c83bdb617da871ebd95a0c27e796f Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 12:19:10 +0200 Subject: [PATCH 012/135] black --- src/abgleich/cli/_main_.py | 21 +- src/abgleich/cli/backup.py | 102 +++--- src/abgleich/cli/cleanup.py | 82 +++-- src/abgleich/cli/compare.py | 68 ++-- src/abgleich/cli/snap.py | 70 ++--- src/abgleich/cli/tree.py | 46 ++- src/abgleich/cmd.py | 120 +++---- src/abgleich/io.py | 76 ++--- src/abgleich/zfs/__init__.py | 585 ++++++++++++++++++----------------- 9 files changed, 584 insertions(+), 586 deletions(-) diff --git a/src/abgleich/cli/_main_.py b/src/abgleich/cli/_main_.py index 2243d5a..e423bf3 100644 --- a/src/abgleich/cli/_main_.py +++ b/src/abgleich/cli/_main_.py @@ -38,19 +38,20 @@ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _add_commands(ctx): - """auto-detects sub-commands""" - for cmd in ( - item[:-3] if item.lower().endswith('.py') else item[:] - for item in os.listdir(os.path.dirname(__file__)) - if not item.startswith('_') - ): - ctx.add_command(getattr(importlib.import_module( - 'abgleich.cli.%s' % cmd - ), cmd)) + """auto-detects sub-commands""" + for cmd in ( + item[:-3] if item.lower().endswith(".py") else item[:] + for item in os.listdir(os.path.dirname(__file__)) + if not item.startswith("_") + ): + ctx.add_command(getattr(importlib.import_module("abgleich.cli.%s" % cmd), cmd)) + @click.group() def cli(): - """abgleich, zfs sync tool""" + """abgleich, zfs sync tool""" + _add_commands(cli) diff --git a/src/abgleich/cli/backup.py b/src/abgleich/cli/backup.py index ea07fd1..3a9209b 100644 --- a/src/abgleich/cli/backup.py +++ b/src/abgleich/cli/backup.py @@ -36,62 +36,60 @@ from ..io import colorize from ..zfs import ( - get_backup_ops, - get_tree, - push_snapshot, - push_snapshot_incremental, - ) + get_backup_ops, + get_tree, + push_snapshot, + push_snapshot_incremental, +) # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -@click.command(short_help = 'backup a dataset tree into another') -@click.argument('configfile', type = click.File('r', encoding = 'utf-8')) + +@click.command(short_help="backup a dataset tree into another") +@click.argument("configfile", type=click.File("r", encoding="utf-8")) def backup(configfile): - config = yaml.load(configfile.read(), Loader = CLoader) - - datasets_local = get_tree() - datasets_remote = get_tree(config['host']) - ops = get_backup_ops( - datasets_local, - config['prefix_local'], - datasets_remote, - config['prefix_remote'], - config['ignore'] - ) - - table = [] - for op in ops: - row = op.copy() - row[0] = colorize(row[0], 'green' if 'incremental' in row[0] else 'blue') - table.append(row) - - print(tabulate( - table, - headers = ['OP', 'PARAM'], - tablefmt = 'github' - )) - - click.confirm('Do you want to continue?', abort = True) - - for op, param in ops: - if op == 'push_snapshot': - push_snapshot( - config['host'], - config['prefix_local'] + param[0], - param[1], - config['prefix_remote'] + param[0], - # debug = True - ) - elif op == 'push_snapshot_incremental': - push_snapshot_incremental( - config['host'], - config['prefix_local'] + param[0], - param[1], param[2], - config['prefix_remote'] + param[0], - # debug = True - ) - else: - raise ValueError('unknown operation') + config = yaml.load(configfile.read(), Loader=CLoader) + + datasets_local = get_tree() + datasets_remote = get_tree(config["host"]) + ops = get_backup_ops( + datasets_local, + config["prefix_local"], + datasets_remote, + config["prefix_remote"], + config["ignore"], + ) + + table = [] + for op in ops: + row = op.copy() + row[0] = colorize(row[0], "green" if "incremental" in row[0] else "blue") + table.append(row) + + print(tabulate(table, headers=["OP", "PARAM"], tablefmt="github")) + + click.confirm("Do you want to continue?", abort=True) + + for op, param in ops: + if op == "push_snapshot": + push_snapshot( + config["host"], + config["prefix_local"] + param[0], + param[1], + config["prefix_remote"] + param[0], + # debug = True + ) + elif op == "push_snapshot_incremental": + push_snapshot_incremental( + config["host"], + config["prefix_local"] + param[0], + param[1], + param[2], + config["prefix_remote"] + param[0], + # debug = True + ) + else: + raise ValueError("unknown operation") diff --git a/src/abgleich/cli/cleanup.py b/src/abgleich/cli/cleanup.py index 30d1be7..ecfacb0 100644 --- a/src/abgleich/cli/cleanup.py +++ b/src/abgleich/cli/cleanup.py @@ -36,56 +36,46 @@ from ..io import colorize, humanize_size from ..zfs import ( - get_tree, - get_cleanup_tasks, - delete_snapshot, - ) + get_tree, + get_cleanup_tasks, + delete_snapshot, +) # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -@click.command(short_help = 'cleanup older snapshots') -@click.argument('configfile', type = click.File('r', encoding = 'utf-8')) + +@click.command(short_help="cleanup older snapshots") +@click.argument("configfile", type=click.File("r", encoding="utf-8")) def cleanup(configfile): - config = yaml.load(configfile.read(), Loader = CLoader) - - cols = ['NAME', 'DELETE SNAPSHOT'] - col_align = ('left', 'left') - datasets = get_tree() - cleanup_tasks = get_cleanup_tasks( - datasets, - config['prefix_local'], - config['ignore'], - config['keep_snapshots'] - ) - space_before = int(datasets[0]['AVAIL']) - - table = [] - for name, snapshot_name in cleanup_tasks: - table.append([ - name, - snapshot_name - ]) - - print(tabulate( - table, - headers = cols, - tablefmt = 'github', - colalign = col_align - )) - print('%s available' % humanize_size(space_before, add_color = True)) - - click.confirm('Do you want to continue?', abort = True) - - for name, snapshot_name in cleanup_tasks: - delete_snapshot( - config['prefix_local'] + name, - snapshot_name, - # debug = True - ) - - space_after = int(get_tree()[0]['AVAIL']) - print('%s available' % humanize_size(space_before, add_color = True)) - print('%s freed' % humanize_size(space_before - space_before, add_color = True)) + config = yaml.load(configfile.read(), Loader=CLoader) + + cols = ["NAME", "DELETE SNAPSHOT"] + col_align = ("left", "left") + datasets = get_tree() + cleanup_tasks = get_cleanup_tasks( + datasets, config["prefix_local"], config["ignore"], config["keep_snapshots"] + ) + space_before = int(datasets[0]["AVAIL"]) + + table = [] + for name, snapshot_name in cleanup_tasks: + table.append([name, snapshot_name]) + + print(tabulate(table, headers=cols, tablefmt="github", colalign=col_align)) + print("%s available" % humanize_size(space_before, add_color=True)) + + click.confirm("Do you want to continue?", abort=True) + + for name, snapshot_name in cleanup_tasks: + delete_snapshot( + config["prefix_local"] + name, + snapshot_name, + # debug = True + ) + + space_after = int(get_tree()[0]["AVAIL"]) + print("%s available" % humanize_size(space_before, add_color=True)) + print("%s freed" % humanize_size(space_before - space_before, add_color=True)) diff --git a/src/abgleich/cli/compare.py b/src/abgleich/cli/compare.py index 6bab52a..2bc141c 100644 --- a/src/abgleich/cli/compare.py +++ b/src/abgleich/cli/compare.py @@ -35,45 +35,41 @@ from yaml import CLoader from ..io import colorize -from ..zfs import ( - compare_trees, - get_tree - ) +from ..zfs import compare_trees, get_tree # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -@click.command(short_help = 'compare dataset trees') -@click.argument('configfile', type = click.File('r', encoding = 'utf-8')) + +@click.command(short_help="compare dataset trees") +@click.argument("configfile", type=click.File("r", encoding="utf-8")) def compare(configfile): - config = yaml.load(configfile.read(), Loader = CLoader) - datasets_local = get_tree() - datasets_remote = get_tree(config['host']) - diff = compare_trees( - datasets_local, - config['prefix_local'], - datasets_remote, - config['prefix_remote'] - ) - table = [] - for element in diff: - element = ['' if item == False else item for item in element] - element = ['X' if item == True else item for item in element] - element = ['- ' + item.split('@')[1] if '@' in item else item for item in element] - if element[1:] == ['X', '']: - element[1] = colorize(element[1], 'red') - elif element[1:] == ['X', 'X']: - element[1], element[2] = colorize(element[1], 'green'), colorize(element[2], 'green') - elif element[1:] == ['', 'X']: - element[2] = colorize(element[2], 'blue') - if not element[0].startswith('- '): - element[0] = colorize(element[0], 'white') - else: - element[0] = colorize(element[0], 'grey') - table.append(element) - print(tabulate( - table, - headers = ['NAME', 'LOCAL', 'REMOTE'], - tablefmt = 'github' - )) + config = yaml.load(configfile.read(), Loader=CLoader) + datasets_local = get_tree() + datasets_remote = get_tree(config["host"]) + diff = compare_trees( + datasets_local, config["prefix_local"], datasets_remote, config["prefix_remote"] + ) + table = [] + for element in diff: + element = ["" if item == False else item for item in element] + element = ["X" if item == True else item for item in element] + element = [ + "- " + item.split("@")[1] if "@" in item else item for item in element + ] + if element[1:] == ["X", ""]: + element[1] = colorize(element[1], "red") + elif element[1:] == ["X", "X"]: + element[1], element[2] = ( + colorize(element[1], "green"), + colorize(element[2], "green"), + ) + elif element[1:] == ["", "X"]: + element[2] = colorize(element[2], "blue") + if not element[0].startswith("- "): + element[0] = colorize(element[0], "white") + else: + element[0] = colorize(element[0], "grey") + table.append(element) + print(tabulate(table, headers=["NAME", "LOCAL", "REMOTE"], tablefmt="github")) diff --git a/src/abgleich/cli/snap.py b/src/abgleich/cli/snap.py index b3dba9d..e8ba8ca 100644 --- a/src/abgleich/cli/snap.py +++ b/src/abgleich/cli/snap.py @@ -36,50 +36,40 @@ from ..io import colorize, humanize_size from ..zfs import ( - create_snapshot, - get_tree, - get_snapshot_tasks, - ) + create_snapshot, + get_tree, + get_snapshot_tasks, +) # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -@click.command(short_help = 'create snapshots of changed datasets for backups') -@click.argument('configfile', type = click.File('r', encoding = 'utf-8')) + +@click.command(short_help="create snapshots of changed datasets for backups") +@click.argument("configfile", type=click.File("r", encoding="utf-8")) def snap(configfile): - config = yaml.load(configfile.read(), Loader = CLoader) - - cols = ['NAME', 'written', 'FUTURE SNAPSHOT'] - col_align = ('left', 'right') - datasets = get_tree() - snapshot_tasks = get_snapshot_tasks( - datasets, - config['prefix_local'], - config['ignore'] - ) - - table = [] - for name, written, snapshot_name in snapshot_tasks: - table.append([ - name, - humanize_size(written, add_color = True), - snapshot_name - ]) - - print(tabulate( - table, - headers = cols, - tablefmt = 'github', - colalign = col_align - )) - - click.confirm('Do you want to continue?', abort = True) - - for name, _, snapshot_name in snapshot_tasks: - create_snapshot( - config['prefix_local'] + name, - snapshot_name, - # debug = True - ) + config = yaml.load(configfile.read(), Loader=CLoader) + + cols = ["NAME", "written", "FUTURE SNAPSHOT"] + col_align = ("left", "right") + datasets = get_tree() + snapshot_tasks = get_snapshot_tasks( + datasets, config["prefix_local"], config["ignore"] + ) + + table = [] + for name, written, snapshot_name in snapshot_tasks: + table.append([name, humanize_size(written, add_color=True), snapshot_name]) + + print(tabulate(table, headers=cols, tablefmt="github", colalign=col_align)) + + click.confirm("Do you want to continue?", abort=True) + + for name, _, snapshot_name in snapshot_tasks: + create_snapshot( + config["prefix_local"] + name, + snapshot_name, + # debug = True + ) diff --git a/src/abgleich/cli/tree.py b/src/abgleich/cli/tree.py index 5019beb..e394acc 100644 --- a/src/abgleich/cli/tree.py +++ b/src/abgleich/cli/tree.py @@ -39,28 +39,26 @@ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -@click.command(short_help = 'show dataset tree') -@click.argument('host', default = 'localhost', type = str) + +@click.command(short_help="show dataset tree") +@click.argument("host", default="localhost", type=str) def tree(host): - cols = ['NAME', 'USED', 'REFER', 'compressratio'] - col_align = ('left', 'right', 'right', 'decimal') - size_cols = ['USED', 'REFER'] - datasets = get_tree(host if host != 'localhost' else None) - table = [] - for dataset in datasets: - table.append([dataset[col] for col in cols]) - for snapshot in dataset['SNAPSHOTS']: - table.append(['- ' + snapshot['NAME']] + [snapshot[col]for col in cols[1:]]) - for row in table: - for col in [1, 2]: - row[col] = humanize_size(int(row[col]), add_color = True) - if not row[0].startswith('- '): - row[0] = colorize(row[0], 'white') - else: - row[0] = colorize(row[0], 'grey') - print(tabulate( - table, - headers = cols, - tablefmt = 'github', - colalign = col_align - )) + cols = ["NAME", "USED", "REFER", "compressratio"] + col_align = ("left", "right", "right", "decimal") + size_cols = ["USED", "REFER"] + datasets = get_tree(host if host != "localhost" else None) + table = [] + for dataset in datasets: + table.append([dataset[col] for col in cols]) + for snapshot in dataset["SNAPSHOTS"]: + table.append( + ["- " + snapshot["NAME"]] + [snapshot[col] for col in cols[1:]] + ) + for row in table: + for col in [1, 2]: + row[col] = humanize_size(int(row[col]), add_color=True) + if not row[0].startswith("- "): + row[0] = colorize(row[0], "white") + else: + row[0] = colorize(row[0], "grey") + print(tabulate(table, headers=cols, tablefmt="github", colalign=col_align)) diff --git a/src/abgleich/cmd.py b/src/abgleich/cmd.py index 3652760..04d85e7 100644 --- a/src/abgleich/cmd.py +++ b/src/abgleich/cmd.py @@ -35,59 +35,71 @@ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -def run_command(cmd_list, debug = False): - if debug: - print_commands(cmd_list) - return - proc = subprocess.Popen(cmd_list, stdout = subprocess.PIPE, stderr = subprocess.PIPE) - outs, errs = proc.communicate() - status_value = not bool(proc.returncode) - output, errors = outs.decode('utf-8'), errs.decode('utf-8') - if len(errors.strip()) != 0 or not status_value: - print(output) - print(errors) - raise - return output - -def run_chain_command(cmd_list_1, cmd_list_2, debug = False): - if debug: - print_commands(cmd_list_1, cmd_list_2) - return - proc_1 = subprocess.Popen( - cmd_list_1, stdout = subprocess.PIPE, stderr = subprocess.PIPE - ) - proc_2 = subprocess.Popen( - cmd_list_2, stdin = proc_1.stdout, stdout = subprocess.PIPE, stderr = subprocess.PIPE - ) - outs_2, errs_2 = proc_2.communicate() - status_value_2 = not bool(proc_2.returncode) - _, errs_1 = proc_1.communicate() - status_value_1 = not bool(proc_1.returncode) - output_2, errors_2 = outs_2.decode('utf-8'), errs_2.decode('utf-8') - errors_1 = errs_1.decode('utf-8') - if any([ - len(errors_1.strip()) != 0, - not status_value_1, - len(errors_2.strip()) != 0, - not status_value_2 - ]): - print(errors_1) - print(output_2) - print(errors_2) - raise - return output_2 + +def run_command(cmd_list, debug=False): + if debug: + print_commands(cmd_list) + return + proc = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + outs, errs = proc.communicate() + status_value = not bool(proc.returncode) + output, errors = outs.decode("utf-8"), errs.decode("utf-8") + if len(errors.strip()) != 0 or not status_value: + print(output) + print(errors) + raise + return output + + +def run_chain_command(cmd_list_1, cmd_list_2, debug=False): + if debug: + print_commands(cmd_list_1, cmd_list_2) + return + proc_1 = subprocess.Popen( + cmd_list_1, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + proc_2 = subprocess.Popen( + cmd_list_2, stdin=proc_1.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + outs_2, errs_2 = proc_2.communicate() + status_value_2 = not bool(proc_2.returncode) + _, errs_1 = proc_1.communicate() + status_value_1 = not bool(proc_1.returncode) + output_2, errors_2 = outs_2.decode("utf-8"), errs_2.decode("utf-8") + errors_1 = errs_1.decode("utf-8") + if any( + [ + len(errors_1.strip()) != 0, + not status_value_1, + len(errors_2.strip()) != 0, + not status_value_2, + ] + ): + print(errors_1) + print(output_2) + print(errors_2) + raise + return output_2 + def print_commands(*args): - commands = [' '.join(cmd_list) for cmd_list in args] - print('#> ' + ' | '.join(commands)) - -def ssh_command(host, cmd_list, compression = False): - return get_ssh_prefix(compression) + [ - host, ' '.join([item.replace(' ', '\\ ') for item in cmd_list]) - ] - -def get_ssh_prefix(compression = False): - return [ - 'ssh', '-T', '-c', 'aes256-gcm@openssh.com', '-o', - 'Compression=yes' if compression else 'Compression=no' - ] + commands = [" ".join(cmd_list) for cmd_list in args] + print("#> " + " | ".join(commands)) + + +def ssh_command(host, cmd_list, compression=False): + return get_ssh_prefix(compression) + [ + host, + " ".join([item.replace(" ", "\\ ") for item in cmd_list]), + ] + + +def get_ssh_prefix(compression=False): + return [ + "ssh", + "-T", + "-c", + "aes256-gcm@openssh.com", + "-o", + "Compression=yes" if compression else "Compression=no", + ] diff --git a/src/abgleich/io.py b/src/abgleich/io.py index 23c667c..6569170 100644 --- a/src/abgleich/io.py +++ b/src/abgleich/io.py @@ -31,47 +31,49 @@ # https://en.wikipedia.org/wiki/ANSI_escape_code c = { - 'RESET': '\033[0;0m', - 'BOLD': '\033[;1m', - 'REVERSE': '\033[;7m', - 'GREY': '\033[1;30m', - 'RED': '\033[1;31m', - 'GREEN': '\033[1;32m', - 'YELLOW': '\033[1;33m', - 'BLUE': '\033[1;34m', - 'MAGENTA': '\033[1;35m', - 'CYAN': '\033[1;36m', - 'WHITE': '\033[1;37m' - } + "RESET": "\033[0;0m", + "BOLD": "\033[;1m", + "REVERSE": "\033[;7m", + "GREY": "\033[1;30m", + "RED": "\033[1;31m", + "GREEN": "\033[1;32m", + "YELLOW": "\033[1;33m", + "BLUE": "\033[1;34m", + "MAGENTA": "\033[1;35m", + "CYAN": "\033[1;36m", + "WHITE": "\033[1;37m", +} # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def colorize(text, col): - return c.get(col.upper(), c['GREY']) + text + c['RESET'] - -def humanize_size(size, add_color = False): - - suffix = 'B' - - for unit, color in ( - ('', 'cyan'), - ('Ki', 'green'), - ('Mi', 'yellow'), - ('Gi', 'red'), - ('Ti', 'magenta'), - ('Pi', 'white'), - ('Ei', 'white'), - ('Zi', 'white'), - ('Yi', 'white') - ): - if abs(size) < 1024.0: - text = '%3.1f %s%s' % (size, unit, suffix) - if add_color: - text = colorize(text, color) - return text - size /= 1024.0 - - raise ValueError('"size" too large') + return c.get(col.upper(), c["GREY"]) + text + c["RESET"] + + +def humanize_size(size, add_color=False): + + suffix = "B" + + for unit, color in ( + ("", "cyan"), + ("Ki", "green"), + ("Mi", "yellow"), + ("Gi", "red"), + ("Ti", "magenta"), + ("Pi", "white"), + ("Ei", "white"), + ("Zi", "white"), + ("Yi", "white"), + ): + if abs(size) < 1024.0: + text = "%3.1f %s%s" % (size, unit, suffix) + if add_color: + text = colorize(text, color) + return text + size /= 1024.0 + + raise ValueError('"size" too large') diff --git a/src/abgleich/zfs/__init__.py b/src/abgleich/zfs/__init__.py index 75730a0..89541e3 100644 --- a/src/abgleich/zfs/__init__.py +++ b/src/abgleich/zfs/__init__.py @@ -32,328 +32,339 @@ import datetime from ..cmd import ( - run_chain_command, - run_command, - ssh_command, - ) + run_chain_command, + run_command, + ssh_command, +) # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def compare_trees(tree_a, prefix_a, tree_b, prefix_b): - assert not prefix_a.endswith('/') - assert not prefix_b.endswith('/') - prefix_a += '/' - prefix_b += '/' - subdict_a = { - '/' + dataset['NAME'][len(prefix_a):]: dataset - for dataset in tree_a - if dataset['NAME'].startswith(prefix_a) # or dataset['NAME'] == prefix_a[:-1] - } - subdict_b = { - '/' + dataset['NAME'][len(prefix_b):]: dataset - for dataset in tree_b - if dataset['NAME'].startswith(prefix_b) # or dataset['NAME'] == prefix_b[:-1] - } - tree_names = list(sorted(subdict_a.keys() | subdict_b.keys())) - res = list() - for name in tree_names: - res.append([name, name in subdict_a.keys(), name in subdict_b.keys()]) - res.extend(__merge_snapshots__( - name, - subdict_a[name]['SNAPSHOTS'] if name in subdict_a else list(), - subdict_b[name]['SNAPSHOTS'] if name in subdict_b else list() - )) - return res + assert not prefix_a.endswith("/") + assert not prefix_b.endswith("/") + prefix_a += "/" + prefix_b += "/" + subdict_a = { + "/" + dataset["NAME"][len(prefix_a) :]: dataset + for dataset in tree_a + if dataset["NAME"].startswith(prefix_a) # or dataset['NAME'] == prefix_a[:-1] + } + subdict_b = { + "/" + dataset["NAME"][len(prefix_b) :]: dataset + for dataset in tree_b + if dataset["NAME"].startswith(prefix_b) # or dataset['NAME'] == prefix_b[:-1] + } + tree_names = list(sorted(subdict_a.keys() | subdict_b.keys())) + res = list() + for name in tree_names: + res.append([name, name in subdict_a.keys(), name in subdict_b.keys()]) + res.extend( + __merge_snapshots__( + name, + subdict_a[name]["SNAPSHOTS"] if name in subdict_a else list(), + subdict_b[name]["SNAPSHOTS"] if name in subdict_b else list(), + ) + ) + return res + def __merge_snapshots__(dataset_name, snap_a, snap_b): - if len(snap_a) == 0 and len(snap_b) == 0: - return list() - names_a = [snapshot['NAME'] for snapshot in snap_a] - names_b = [snapshot['NAME'] for snapshot in snap_b] - if len(names_a) == 0 and len(names_b) > 0: - return [[dataset_name + '@' + name, False, True] for name in names_b] - if len(names_b) == 0 and len(names_a) > 0: - return [[dataset_name + '@' + name, True, False] for name in names_a] - creations_a = {snapshot['creation']: snapshot for snapshot in snap_a} - creations_b = {snapshot['creation']: snapshot for snapshot in snap_b} - creations = list(sorted(creations_a.keys() | creations_b.keys())) - ret = list() - for creation in creations: - in_a = creation in creations_a.keys() - in_b = creation in creations_b.keys() - if in_a: - name = creations_a[creation]['NAME'] - elif in_b: - name = creations_b[creation]['NAME'] - else: - raise ValueError('this should not happen') - if in_a and in_b: - if creations_a[creation]['NAME'] != creations_b[creation]['NAME']: - raise ValueError('snapshot name mismatch for equal creation times') - ret.append([dataset_name + '@' + name, in_a, in_b]) - return ret + if len(snap_a) == 0 and len(snap_b) == 0: + return list() + names_a = [snapshot["NAME"] for snapshot in snap_a] + names_b = [snapshot["NAME"] for snapshot in snap_b] + if len(names_a) == 0 and len(names_b) > 0: + return [[dataset_name + "@" + name, False, True] for name in names_b] + if len(names_b) == 0 and len(names_a) > 0: + return [[dataset_name + "@" + name, True, False] for name in names_a] + creations_a = {snapshot["creation"]: snapshot for snapshot in snap_a} + creations_b = {snapshot["creation"]: snapshot for snapshot in snap_b} + creations = list(sorted(creations_a.keys() | creations_b.keys())) + ret = list() + for creation in creations: + in_a = creation in creations_a.keys() + in_b = creation in creations_b.keys() + if in_a: + name = creations_a[creation]["NAME"] + elif in_b: + name = creations_b[creation]["NAME"] + else: + raise ValueError("this should not happen") + if in_a and in_b: + if creations_a[creation]["NAME"] != creations_b[creation]["NAME"]: + raise ValueError("snapshot name mismatch for equal creation times") + ret.append([dataset_name + "@" + name, in_a, in_b]) + return ret + def get_backup_ops(tree_a, prefix_a, tree_b, prefix_b, ignore): - assert not prefix_a.endswith('/') - assert not prefix_b.endswith('/') - prefix_a += '/' - prefix_b += '/' - subdict_a = { - '/' + dataset['NAME'][len(prefix_a):]: dataset - for dataset in tree_a - if dataset['NAME'].startswith(prefix_a) - } - subdict_b = { - '/' + dataset['NAME'][len(prefix_b):]: dataset - for dataset in tree_b - if dataset['NAME'].startswith(prefix_b) - } - tree_names = list(sorted(subdict_a.keys() | subdict_b.keys())) - res = list() - for name in tree_names: - if name in ignore: - continue - dataset_in_a = name in subdict_a.keys() - dataset_in_b = name in subdict_b.keys() - if not dataset_in_a and dataset_in_b: - # raise ValueError('no source dataset "%s" - only remote' % name) - print('no source dataset "%s" - only remote' % name) - continue - if dataset_in_a and not dataset_in_b and len(subdict_a[name]['SNAPSHOTS']) == 0: - raise ValueError('no snapshots in dataset "%s" - can not send' % name) - if dataset_in_a and not dataset_in_b: - res.append([ - 'push_snapshot', - (name, subdict_a[name]['SNAPSHOTS'][0]['NAME']) - ]) - for snapshot_1, snapshot_2 in zip( - subdict_a[name]['SNAPSHOTS'][:-1], - subdict_a[name]['SNAPSHOTS'][1:] - ): - res.append([ - 'push_snapshot_incremental', - (name, snapshot_1['NAME'], snapshot_2['NAME']) - ]) - continue - last_remote_shapshot = subdict_b[name]['SNAPSHOTS'][-1]['NAME'] - source_index = None - for index, source_snapshot in enumerate(subdict_a[name]['SNAPSHOTS']): - if source_snapshot['NAME'] == last_remote_shapshot: - source_index = index - break - if source_index is None: - raise ValueError('no common snapshots in dataset "%s" - can not send incremental' % name) - for snapshot_1, snapshot_2 in zip( - subdict_a[name]['SNAPSHOTS'][source_index:-1], - subdict_a[name]['SNAPSHOTS'][(source_index + 1):] - ): - res.append([ - 'push_snapshot_incremental', - (name, snapshot_1['NAME'], snapshot_2['NAME']) - ]) - - return res + assert not prefix_a.endswith("/") + assert not prefix_b.endswith("/") + prefix_a += "/" + prefix_b += "/" + subdict_a = { + "/" + dataset["NAME"][len(prefix_a) :]: dataset + for dataset in tree_a + if dataset["NAME"].startswith(prefix_a) + } + subdict_b = { + "/" + dataset["NAME"][len(prefix_b) :]: dataset + for dataset in tree_b + if dataset["NAME"].startswith(prefix_b) + } + tree_names = list(sorted(subdict_a.keys() | subdict_b.keys())) + res = list() + for name in tree_names: + if name in ignore: + continue + dataset_in_a = name in subdict_a.keys() + dataset_in_b = name in subdict_b.keys() + if not dataset_in_a and dataset_in_b: + # raise ValueError('no source dataset "%s" - only remote' % name) + print('no source dataset "%s" - only remote' % name) + continue + if dataset_in_a and not dataset_in_b and len(subdict_a[name]["SNAPSHOTS"]) == 0: + raise ValueError('no snapshots in dataset "%s" - can not send' % name) + if dataset_in_a and not dataset_in_b: + res.append( + ["push_snapshot", (name, subdict_a[name]["SNAPSHOTS"][0]["NAME"])] + ) + for snapshot_1, snapshot_2 in zip( + subdict_a[name]["SNAPSHOTS"][:-1], subdict_a[name]["SNAPSHOTS"][1:] + ): + res.append( + [ + "push_snapshot_incremental", + (name, snapshot_1["NAME"], snapshot_2["NAME"]), + ] + ) + continue + last_remote_shapshot = subdict_b[name]["SNAPSHOTS"][-1]["NAME"] + source_index = None + for index, source_snapshot in enumerate(subdict_a[name]["SNAPSHOTS"]): + if source_snapshot["NAME"] == last_remote_shapshot: + source_index = index + break + if source_index is None: + raise ValueError( + 'no common snapshots in dataset "%s" - can not send incremental' % name + ) + for snapshot_1, snapshot_2 in zip( + subdict_a[name]["SNAPSHOTS"][source_index:-1], + subdict_a[name]["SNAPSHOTS"][(source_index + 1) :], + ): + res.append( + [ + "push_snapshot_incremental", + (name, snapshot_1["NAME"], snapshot_2["NAME"]), + ] + ) + + return res + def get_cleanup_tasks(tree, prefix, ignore, keep_snapshots): - res = list() - skip = len(prefix) + res = list() + skip = len(prefix) - for dataset in tree: - name = dataset['NAME'][skip:] - if name in ignore or len(name) == 0: - continue - # if dataset['MOUNTPOINT'] == 'none': - # continue - if len(dataset['SNAPSHOTS']) <= keep_snapshots: - continue - del_snapshots = dataset['SNAPSHOTS'][:(-1 * keep_snapshots)] - for snapshot in del_snapshots: - res.append([name, snapshot['NAME']]) + for dataset in tree: + name = dataset["NAME"][skip:] + if name in ignore or len(name) == 0: + continue + # if dataset['MOUNTPOINT'] == 'none': + # continue + if len(dataset["SNAPSHOTS"]) <= keep_snapshots: + continue + del_snapshots = dataset["SNAPSHOTS"][: (-1 * keep_snapshots)] + for snapshot in del_snapshots: + res.append([name, snapshot["NAME"]]) + + return res - return res def get_snapshot_tasks(tree, prefix, ignore): - res = list() - skip = len(prefix) - date = datetime.datetime.now().strftime('%Y%m%d') - suffix = '_backup' - - def make_name(snapshots): - snapshot_names = [snapshot['NAME'] for snapshot in snapshots] - for index in range(1, 100): - new_name = '%s%02d%s' % (date, index, suffix) - if new_name not in snapshot_names: - return new_name - raise ValueError('more than 99 snapshots per day') - - for dataset in tree: - name = dataset['NAME'][skip:] - written = int(dataset['written']) - if name in ignore or len(name) == 0: - continue - if dataset['MOUNTPOINT'] == 'none': - continue - if len(dataset['SNAPSHOTS']) == 0: - res.append([name, written, date + '01' + suffix]) - continue - if written == 0: - continue - if written > (1024 ** 2): - res.append([name, written, make_name(dataset['SNAPSHOTS'])]) - continue - if dataset['type'] == 'volume': - res.append([name, written, make_name(dataset['SNAPSHOTS'])]) - continue - diff_out = run_command([ - 'zfs', 'diff', dataset['NAME'] + '@' + dataset['SNAPSHOTS'][-1]['NAME'] - ]) - if len(diff_out.strip(' \t\n')) > 0: - res.append([name, written, make_name(dataset['SNAPSHOTS'])]) - - return res - -def get_tree(host = None): - - cmd_list = ['zfs', 'list', '-H', '-p'] - cmd_list_snapshot = ['zfs', 'list', '-t', 'snapshot', '-H', '-p'] - cmd_list_property = ['zfs', 'get', 'all', '-H', '-p'] - - if host is not None: - cmd_list = ssh_command(host, cmd_list, compression = True) - cmd_list_snapshot = ssh_command(host, cmd_list_snapshot, compression = True) - cmd_list_property = ssh_command(host, cmd_list_property, compression = True) - - datasets = parse_table( - run_command(cmd_list), - ['NAME', 'USED', 'AVAIL', 'REFER', 'MOUNTPOINT'] - ) - snapshots = parse_table( - run_command(cmd_list_snapshot), - ['NAME', 'USED', 'AVAIL', 'REFER', 'MOUNTPOINT'] - ) - properties = parse_table( - run_command(cmd_list_property), - ['NAME', 'PROPERTY', 'VALUE', 'SOURCE'] - ) - merge_properties(datasets, snapshots, properties) - merge_snapshots_into_datasets(datasets, snapshots) - - return datasets + res = list() + skip = len(prefix) + date = datetime.datetime.now().strftime("%Y%m%d") + suffix = "_backup" + + def make_name(snapshots): + snapshot_names = [snapshot["NAME"] for snapshot in snapshots] + for index in range(1, 100): + new_name = "%s%02d%s" % (date, index, suffix) + if new_name not in snapshot_names: + return new_name + raise ValueError("more than 99 snapshots per day") + + for dataset in tree: + name = dataset["NAME"][skip:] + written = int(dataset["written"]) + if name in ignore or len(name) == 0: + continue + if dataset["MOUNTPOINT"] == "none": + continue + if len(dataset["SNAPSHOTS"]) == 0: + res.append([name, written, date + "01" + suffix]) + continue + if written == 0: + continue + if written > (1024 ** 2): + res.append([name, written, make_name(dataset["SNAPSHOTS"])]) + continue + if dataset["type"] == "volume": + res.append([name, written, make_name(dataset["SNAPSHOTS"])]) + continue + diff_out = run_command( + ["zfs", "diff", dataset["NAME"] + "@" + dataset["SNAPSHOTS"][-1]["NAME"]] + ) + if len(diff_out.strip(" \t\n")) > 0: + res.append([name, written, make_name(dataset["SNAPSHOTS"])]) + + return res + + +def get_tree(host=None): + + cmd_list = ["zfs", "list", "-H", "-p"] + cmd_list_snapshot = ["zfs", "list", "-t", "snapshot", "-H", "-p"] + cmd_list_property = ["zfs", "get", "all", "-H", "-p"] + + if host is not None: + cmd_list = ssh_command(host, cmd_list, compression=True) + cmd_list_snapshot = ssh_command(host, cmd_list_snapshot, compression=True) + cmd_list_property = ssh_command(host, cmd_list_property, compression=True) + + datasets = parse_table( + run_command(cmd_list), ["NAME", "USED", "AVAIL", "REFER", "MOUNTPOINT"] + ) + snapshots = parse_table( + run_command(cmd_list_snapshot), ["NAME", "USED", "AVAIL", "REFER", "MOUNTPOINT"] + ) + properties = parse_table( + run_command(cmd_list_property), ["NAME", "PROPERTY", "VALUE", "SOURCE"] + ) + merge_properties(datasets, snapshots, properties) + merge_snapshots_into_datasets(datasets, snapshots) + + return datasets + def merge_properties(datasets, snapshots, properties): - elements = {dataset['NAME']: dataset for dataset in datasets} - elements.update({snapshot['NAME']: snapshot for snapshot in snapshots}) - for property in properties: - elements[property['NAME']][property['PROPERTY']] = property['VALUE'] + elements = {dataset["NAME"]: dataset for dataset in datasets} + elements.update({snapshot["NAME"]: snapshot for snapshot in snapshots}) + for property in properties: + elements[property["NAME"]][property["PROPERTY"]] = property["VALUE"] + def merge_snapshots_into_datasets(datasets, snapshots): - for dataset in datasets: - dataset['SNAPSHOTS'] = [] - datasets_dict = {dataset['NAME']: dataset for dataset in datasets} - for snapshot in snapshots: - dataset_name, snapshot['NAME'] = snapshot['NAME'].split('@') - datasets_dict[dataset_name]['SNAPSHOTS'].append(snapshot) + for dataset in datasets: + dataset["SNAPSHOTS"] = [] + datasets_dict = {dataset["NAME"]: dataset for dataset in datasets} + for snapshot in snapshots: + dataset_name, snapshot["NAME"] = snapshot["NAME"].split("@") + datasets_dict[dataset_name]["SNAPSHOTS"].append(snapshot) + def parse_table(raw, head): - table = [item.split('\t') for item in raw.split('\n') if len(item.strip()) > 0] - return [{k: v for k, v in zip(head, line)} for line in table] + table = [item.split("\t") for item in raw.split("\n") if len(item.strip()) > 0] + return [{k: v for k, v in zip(head, line)} for line in table] + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES: MODIFY # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -def create_snapshot(dataset_name, snapshot_name, debug = False): - print('CREATING SNAPSHOT %s@%s ...' % (dataset_name, snapshot_name)) - cmd = ['zfs', 'snapshot', '%s@%s' % (dataset_name, snapshot_name)] - run_command(cmd, debug = debug) - print('... CREATING SNAPSHOT DONE.') -def delete_snapshot(dataset_name, snapshot_name, debug = False): - print('DELETING SNAPSHOT %s@%s ...' % (dataset_name, snapshot_name)) - cmd = ['zfs', 'destroy', '%s@%s' % (dataset_name, snapshot_name)] - run_command(cmd, debug = debug) - print('... DELETING SNAPSHOT DONE.') +def create_snapshot(dataset_name, snapshot_name, debug=False): + print("CREATING SNAPSHOT %s@%s ..." % (dataset_name, snapshot_name)) + cmd = ["zfs", "snapshot", "%s@%s" % (dataset_name, snapshot_name)] + run_command(cmd, debug=debug) + print("... CREATING SNAPSHOT DONE.") + + +def delete_snapshot(dataset_name, snapshot_name, debug=False): + print("DELETING SNAPSHOT %s@%s ..." % (dataset_name, snapshot_name)) + cmd = ["zfs", "destroy", "%s@%s" % (dataset_name, snapshot_name)] + run_command(cmd, debug=debug) + print("... DELETING SNAPSHOT DONE.") + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES: SEND & RECEIVE # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -def pull_snapshot(host, src, src_firstsnapshot, dest, debug = False): - print('PULLING FIRST %s@%s to %s ...' % (src, src_firstsnapshot, dest)) - cmd1 = ssh_command( - host, - ['zfs', 'send', '-c', '%s@%s' % (src, src_firstsnapshot)], - compression = False - ) - cmd2 = ['zfs', 'receive', dest] - run_chain_command(cmd1, cmd2, debug = debug) - print('... PULLING FIRST DONE.') - -def pull_snapshot_incremental(host, src, src_a, src_b, dest, debug = False): - print('PULLING FOLLOW-UP %s@[%s - %s] to %s ...' % (src, src_a, src_b, dest)) - cmd1 = ssh_command( - host, - ['zfs', 'send', '-c', '-i', '%s@%s' % (src, src_a), '%s@%s' % (src, src_b)], - compression = False - ) - cmd2 = ['zfs', 'receive', dest] - run_chain_command(cmd1, cmd2, debug = debug) - print('... PULLING FOLLOW-UP DONE.') - -def pull_new(host, dataset_src, dest, debug = False): - print('PULLING NEW %s to %s ...' % (dataset_src['NAME'], dest)) - src = dataset_src['NAME'] - src_firstsnapshot = dataset_src['SNAPSHOTS'][0]['NAME'] - src_snapshotpairs = [ - (a['NAME'], b['NAME']) - for a, b in zip(dataset_src['SNAPSHOTS'][:-1], dataset_src['SNAPSHOTS'][1:]) - ] - pull_snapshot(host, src, src_firstsnapshot, dest, debug = debug) - for src_a, src_b in src_snapshotpairs: - pull_snapshot_incremental(host, src, src_a, src_b, dest, debug = debug) - print('... PULLING NEW DONE.') - -def push_snapshot(host, src, src_firstsnapshot, dest, debug = False): - print('PUSHING FIRST %s@%s to %s ...' % (src, src_firstsnapshot, dest)) - cmd1 = ['zfs', 'send', '-c', '%s@%s' % (src, src_firstsnapshot)] - cmd2 = ssh_command( - host, - ['zfs', 'receive', dest], - compression = False - ) - run_chain_command(cmd1, cmd2, debug = debug) - print('... PUSHING FIRST DONE.') - -def push_snapshot_incremental(host, src, src_a, src_b, dest, debug = False): - print('PUSHING FOLLOW-UP %s@[%s - %s] to %s ...' % (src, src_a, src_b, dest)) - cmd1 = [ - 'zfs', 'send', '-c', - '-i', '%s@%s' % (src, src_a), '%s@%s' % (src, src_b) - ] - cmd2 = ssh_command( - host, - ['zfs', 'receive', dest], - compression = False - ) - run_chain_command(cmd1, cmd2, debug = debug) - print('... PUSHING FOLLOW-UP DONE.') - -def push_new(host, dataset_src, dest, debug = False): - print('PUSHING NEW %s to %s ...' % (dataset_src['NAME'], dest)) - src = dataset_src['NAME'] - src_firstsnapshot = dataset_src['SNAPSHOTS'][0]['NAME'] - src_snapshotpairs = [ - (a['NAME'], b['NAME']) - for a, b in zip(dataset_src['SNAPSHOTS'][:-1], dataset_src['SNAPSHOTS'][1:]) - ] - push_snapshot(host, src, src_firstsnapshot, dest, debug = debug) - for src_a, src_b in src_snapshotpairs: - push_snapshot_incremental(host, src, src_a, src_b, dest, debug = debug) - print('... PUSHING NEW DONE.') + +def pull_snapshot(host, src, src_firstsnapshot, dest, debug=False): + print("PULLING FIRST %s@%s to %s ..." % (src, src_firstsnapshot, dest)) + cmd1 = ssh_command( + host, + ["zfs", "send", "-c", "%s@%s" % (src, src_firstsnapshot)], + compression=False, + ) + cmd2 = ["zfs", "receive", dest] + run_chain_command(cmd1, cmd2, debug=debug) + print("... PULLING FIRST DONE.") + + +def pull_snapshot_incremental(host, src, src_a, src_b, dest, debug=False): + print("PULLING FOLLOW-UP %s@[%s - %s] to %s ..." % (src, src_a, src_b, dest)) + cmd1 = ssh_command( + host, + ["zfs", "send", "-c", "-i", "%s@%s" % (src, src_a), "%s@%s" % (src, src_b)], + compression=False, + ) + cmd2 = ["zfs", "receive", dest] + run_chain_command(cmd1, cmd2, debug=debug) + print("... PULLING FOLLOW-UP DONE.") + + +def pull_new(host, dataset_src, dest, debug=False): + print("PULLING NEW %s to %s ..." % (dataset_src["NAME"], dest)) + src = dataset_src["NAME"] + src_firstsnapshot = dataset_src["SNAPSHOTS"][0]["NAME"] + src_snapshotpairs = [ + (a["NAME"], b["NAME"]) + for a, b in zip(dataset_src["SNAPSHOTS"][:-1], dataset_src["SNAPSHOTS"][1:]) + ] + pull_snapshot(host, src, src_firstsnapshot, dest, debug=debug) + for src_a, src_b in src_snapshotpairs: + pull_snapshot_incremental(host, src, src_a, src_b, dest, debug=debug) + print("... PULLING NEW DONE.") + + +def push_snapshot(host, src, src_firstsnapshot, dest, debug=False): + print("PUSHING FIRST %s@%s to %s ..." % (src, src_firstsnapshot, dest)) + cmd1 = ["zfs", "send", "-c", "%s@%s" % (src, src_firstsnapshot)] + cmd2 = ssh_command(host, ["zfs", "receive", dest], compression=False) + run_chain_command(cmd1, cmd2, debug=debug) + print("... PUSHING FIRST DONE.") + + +def push_snapshot_incremental(host, src, src_a, src_b, dest, debug=False): + print("PUSHING FOLLOW-UP %s@[%s - %s] to %s ..." % (src, src_a, src_b, dest)) + cmd1 = ["zfs", "send", "-c", "-i", "%s@%s" % (src, src_a), "%s@%s" % (src, src_b)] + cmd2 = ssh_command(host, ["zfs", "receive", dest], compression=False) + run_chain_command(cmd1, cmd2, debug=debug) + print("... PUSHING FOLLOW-UP DONE.") + + +def push_new(host, dataset_src, dest, debug=False): + print("PUSHING NEW %s to %s ..." % (dataset_src["NAME"], dest)) + src = dataset_src["NAME"] + src_firstsnapshot = dataset_src["SNAPSHOTS"][0]["NAME"] + src_snapshotpairs = [ + (a["NAME"], b["NAME"]) + for a, b in zip(dataset_src["SNAPSHOTS"][:-1], dataset_src["SNAPSHOTS"][1:]) + ] + push_snapshot(host, src, src_firstsnapshot, dest, debug=debug) + for src_a, src_b in src_snapshotpairs: + push_snapshot_incremental(host, src, src_a, src_b, dest, debug=debug) + print("... PUSHING NEW DONE.") From 51bc45d7f8a8c08b1ea92cb6246660d7cf12e5fc Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 12:28:43 +0200 Subject: [PATCH 013/135] new lib files --- src/abgleich/zfs/clone.py | 25 +++++++++++++++++++++++++ src/abgleich/zfs/filesystem.py | 25 +++++++++++++++++++++++++ src/abgleich/zfs/snapshot.py | 25 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/abgleich/zfs/clone.py create mode 100644 src/abgleich/zfs/filesystem.py create mode 100644 src/abgleich/zfs/snapshot.py diff --git a/src/abgleich/zfs/clone.py b/src/abgleich/zfs/clone.py new file mode 100644 index 0000000..e02ebfa --- /dev/null +++ b/src/abgleich/zfs/clone.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/zfs/clone.py: ZFS clone + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" diff --git a/src/abgleich/zfs/filesystem.py b/src/abgleich/zfs/filesystem.py new file mode 100644 index 0000000..bca599c --- /dev/null +++ b/src/abgleich/zfs/filesystem.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/zfs/filesystem.py: ZFS filesystem + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py new file mode 100644 index 0000000..16d9a01 --- /dev/null +++ b/src/abgleich/zfs/snapshot.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/zfs/snapshot.py: ZFS snapshot + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" From 73240dc0fe40dc47f89e6f087d0d35ff6a6100b1 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 12:32:25 +0200 Subject: [PATCH 014/135] classes --- src/abgleich/zfs/clone.py | 24 ++++++++++++++++++++++-- src/abgleich/zfs/filesystem.py | 24 ++++++++++++++++++++++-- src/abgleich/zfs/snapshot.py | 24 ++++++++++++++++++++++-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/abgleich/zfs/clone.py b/src/abgleich/zfs/clone.py index e02ebfa..abff429 100644 --- a/src/abgleich/zfs/clone.py +++ b/src/abgleich/zfs/clone.py @@ -6,9 +6,9 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/clone.py: ZFS clone + src/abgleich/zfs/clone.py: ZFS clone - Copyright (C) 2019-2020 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License @@ -23,3 +23,23 @@ """ + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +# TODO + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASS +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +class Clone: + + def __init__(self): + pass + + @classmethod + def from_shell(cls, data): + + return cls() diff --git a/src/abgleich/zfs/filesystem.py b/src/abgleich/zfs/filesystem.py index bca599c..111d62f 100644 --- a/src/abgleich/zfs/filesystem.py +++ b/src/abgleich/zfs/filesystem.py @@ -6,9 +6,9 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/filesystem.py: ZFS filesystem + src/abgleich/zfs/filesystem.py: ZFS filesystem - Copyright (C) 2019-2020 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License @@ -23,3 +23,23 @@ """ + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +# TODO + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASS +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +class Filesystem: + + def __init__(self): + pass + + @classmethod + def from_shell(cls, data): + + return cls() diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 16d9a01..c09bccf 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -6,9 +6,9 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/snapshot.py: ZFS snapshot + src/abgleich/zfs/snapshot.py: ZFS snapshot - Copyright (C) 2019-2020 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License @@ -23,3 +23,23 @@ """ + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +# TODO + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASS +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +class Snapshot: + + def __init__(self): + pass + + @classmethod + def from_shell(cls, data): + + return cls() From 5f33eda96edd38b83a99b889fde7d1db2798e5a4 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 12:47:44 +0200 Subject: [PATCH 015/135] new command class --- src/abgleich/command.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/abgleich/command.py diff --git a/src/abgleich/command.py b/src/abgleich/command.py new file mode 100644 index 0000000..ae3c9c6 --- /dev/null +++ b/src/abgleich/command.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/command.py: Sub-process wrapper for commands + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +import typing + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASS +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +class Command: + + def __init__(self, cmd_list: typing.List[str]): + pass From e9f39c1e0216ba56ea8efa8993f3b81ebd1e37e2 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 12:47:51 +0200 Subject: [PATCH 016/135] new config class --- src/abgleich/config.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/abgleich/config.py diff --git a/src/abgleich/config.py b/src/abgleich/config.py new file mode 100644 index 0000000..440b342 --- /dev/null +++ b/src/abgleich/config.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/config.py: Handles configuration data + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" + + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +# TODO + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASS +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +class Config: + + def __init__(self, path: str): + pass From 8024c8c0d83aa8a22259fc9c918344ab0a8d61d3 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 12:48:08 +0200 Subject: [PATCH 017/135] new zpool class --- src/abgleich/zfs/zpool.py | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/abgleich/zfs/zpool.py diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py new file mode 100644 index 0000000..73ed9ee --- /dev/null +++ b/src/abgleich/zfs/zpool.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/zfs/zpool.py: ZFS zpool + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +# TODO + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASS +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +class Zpool: + + def __init__(self, name: str, location: str = 'localhost'): + pass + + @classmethod + def from_shell(cls, data): + + return cls() From 4afdc4bebea2c326693329bfbffc2c960764aa5c Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 12:55:15 +0200 Subject: [PATCH 018/135] added typeguard --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 040de24..fdc9076 100644 --- a/setup.py +++ b/setup.py @@ -77,7 +77,7 @@ include_package_data=True, python_requires=">=3.{MINOR:d}".format(MINOR=python_minor_min), setup_requires=[], - install_requires=["click", "tabulate", "pyyaml",], + install_requires=["click", "tabulate", "pyyaml", "typeguard",], extras_require={ "dev": [ "black", From cdefa41fe26cd9d26d8a989c720a37b08a10f123 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 13:23:51 +0200 Subject: [PATCH 019/135] simple config dict --- src/abgleich/config.py | 51 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/src/abgleich/config.py b/src/abgleich/config.py index 440b342..925a738 100644 --- a/src/abgleich/config.py +++ b/src/abgleich/config.py @@ -29,13 +29,56 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# TODO +import typing + +import typeguard +import yaml +from yaml import CLoader # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -class Config: +@typeguard.typechecked +class Config(dict): + + @classmethod + def from_fd(cls, fd: typing.TextIO): + + config = yaml.load(fd.read(), Loader=CLoader) + cls._validate(config) + + return cls(config) + + @classmethod + def _validate(cls, config: typing.Dict): + + schema = { + 'host': lambda v: isinstance(v, str) and len(v) > 0, + 'local': cls._validate_location, + 'remote': cls._validate_location, + 'keep_snapshots': lambda v: isinstance(v, int) and v >= 1, + 'ignore': lambda v: isinstance(v, list) and all((isinstance(item, str) and len(item) > 0 for item in v)), + } + + for field, validator in schema.items(): + if field not in config.keys(): + raise KeyError(f'missing configuration field "{field:s}"') + if not validator(config[field]): + raise ValueError(f'invalid value in field "{field:s}"') + + @classmethod + def _validate_location(cls, location: typing.Dict): + + schema = { + 'zpool': lambda v: isinstance(v, str) and len(v) > 0, + 'prefix': lambda v: isinstance(v, str) or v is None, + } + + for field, validator in schema.items(): + if field not in location.keys(): + return False + if not validator(location[field]): + return False - def __init__(self, path: str): - pass + return True From e4607cfe20519cf4eec0c28b7410641dd3cf4078 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 13:26:21 +0200 Subject: [PATCH 020/135] zpool param --- src/abgleich/zfs/zpool.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 73ed9ee..a838608 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -28,15 +28,18 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# TODO +import typing + +import typeguard # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +@typeguard.typechecked class Zpool: - def __init__(self, name: str, location: str = 'localhost'): + def __init__(self, name: str, prefix: typing.Union[str, None] = None, location: str = 'local'): pass @classmethod From eacb322569d4592004324a4395d37b6dad8f5b32 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 13:27:04 +0200 Subject: [PATCH 021/135] black --- src/abgleich/zfs/zpool.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index a838608..d8d91bb 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -36,10 +36,12 @@ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @typeguard.typechecked class Zpool: - - def __init__(self, name: str, prefix: typing.Union[str, None] = None, location: str = 'local'): + def __init__( + self, name: str, prefix: typing.Union[str, None] = None, location: str = "local" + ): pass @classmethod From ef3dbe188a0867c7791ea79d67fe3a7ed15c78f7 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 13:27:31 +0200 Subject: [PATCH 022/135] black --- src/abgleich/config.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/abgleich/config.py b/src/abgleich/config.py index 925a738..bca7af1 100644 --- a/src/abgleich/config.py +++ b/src/abgleich/config.py @@ -39,9 +39,9 @@ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @typeguard.typechecked class Config(dict): - @classmethod def from_fd(cls, fd: typing.TextIO): @@ -54,12 +54,13 @@ def from_fd(cls, fd: typing.TextIO): def _validate(cls, config: typing.Dict): schema = { - 'host': lambda v: isinstance(v, str) and len(v) > 0, - 'local': cls._validate_location, - 'remote': cls._validate_location, - 'keep_snapshots': lambda v: isinstance(v, int) and v >= 1, - 'ignore': lambda v: isinstance(v, list) and all((isinstance(item, str) and len(item) > 0 for item in v)), - } + "host": lambda v: isinstance(v, str) and len(v) > 0, + "local": cls._validate_location, + "remote": cls._validate_location, + "keep_snapshots": lambda v: isinstance(v, int) and v >= 1, + "ignore": lambda v: isinstance(v, list) + and all((isinstance(item, str) and len(item) > 0 for item in v)), + } for field, validator in schema.items(): if field not in config.keys(): @@ -71,9 +72,9 @@ def _validate(cls, config: typing.Dict): def _validate_location(cls, location: typing.Dict): schema = { - 'zpool': lambda v: isinstance(v, str) and len(v) > 0, - 'prefix': lambda v: isinstance(v, str) or v is None, - } + "zpool": lambda v: isinstance(v, str) and len(v) > 0, + "prefix": lambda v: isinstance(v, str) or v is None, + } for field, validator in schema.items(): if field not in location.keys(): From 698a0ae83120dc8890d410d97b307300d7ac0c87 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 13:42:35 +0200 Subject: [PATCH 023/135] ssh config --- src/abgleich/config.py | 47 +++++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/src/abgleich/config.py b/src/abgleich/config.py index bca7af1..0d8067c 100644 --- a/src/abgleich/config.py +++ b/src/abgleich/config.py @@ -42,44 +42,39 @@ @typeguard.typechecked class Config(dict): + @classmethod def from_fd(cls, fd: typing.TextIO): - config = yaml.load(fd.read(), Loader=CLoader) - cls._validate(config) - - return cls(config) + ssh_schema = { + "compression": lambda v: isinstance(v, bool), + "cipher": lambda v: isinstance(v, str) or v is None, + } - @classmethod - def _validate(cls, config: typing.Dict): + location_schema = { + "zpool": lambda v: isinstance(v, str) and len(v) > 0, + "prefix": lambda v: isinstance(v, str) or v is None, + } - schema = { + root_schema = { "host": lambda v: isinstance(v, str) and len(v) > 0, - "local": cls._validate_location, - "remote": cls._validate_location, + "local": lambda v: cls._validate(data = v, schema = location_schema), + "remote": lambda v: cls._validate(data = v, schema = location_schema), "keep_snapshots": lambda v: isinstance(v, int) and v >= 1, "ignore": lambda v: isinstance(v, list) and all((isinstance(item, str) and len(item) > 0 for item in v)), + "ssh": lambda v: cls._validate(data = v, schema = ssh_schema), } - for field, validator in schema.items(): - if field not in config.keys(): - raise KeyError(f'missing configuration field "{field:s}"') - if not validator(config[field]): - raise ValueError(f'invalid value in field "{field:s}"') + config = yaml.load(fd.read(), Loader=CLoader) + cls._validate(data = config, schema = root_schema) + return cls(config) @classmethod - def _validate_location(cls, location: typing.Dict): - - schema = { - "zpool": lambda v: isinstance(v, str) and len(v) > 0, - "prefix": lambda v: isinstance(v, str) or v is None, - } + def _validate(cls, data: typing.Dict, schema: typing.Dict): for field, validator in schema.items(): - if field not in location.keys(): - return False - if not validator(location[field]): - return False - - return True + if field not in data.keys(): + raise KeyError(f'missing configuration field "{field:s}"') + if not validator(data[field]): + raise ValueError(f'invalid value in field "{field:s}"') From f10c177e51c90c2d356e8923b36bdf997feb3851 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 13:43:55 +0200 Subject: [PATCH 024/135] consistent local name --- src/abgleich/config.py | 2 +- src/abgleich/zfs/zpool.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/abgleich/config.py b/src/abgleich/config.py index 0d8067c..73496d5 100644 --- a/src/abgleich/config.py +++ b/src/abgleich/config.py @@ -58,7 +58,7 @@ def from_fd(cls, fd: typing.TextIO): root_schema = { "host": lambda v: isinstance(v, str) and len(v) > 0, - "local": lambda v: cls._validate(data = v, schema = location_schema), + "localhost": lambda v: cls._validate(data = v, schema = location_schema), "remote": lambda v: cls._validate(data = v, schema = location_schema), "keep_snapshots": lambda v: isinstance(v, int) and v >= 1, "ignore": lambda v: isinstance(v, list) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index d8d91bb..7886b93 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -40,7 +40,7 @@ @typeguard.typechecked class Zpool: def __init__( - self, name: str, prefix: typing.Union[str, None] = None, location: str = "local" + self, name: str, prefix: typing.Union[str, None] = None, location: str = "localhost" ): pass From d794c214c2bd814dd5189eba60b8edad67f5a485 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 14:21:35 +0200 Subject: [PATCH 025/135] basic command class --- src/abgleich/abc.py | 38 ++++++++++++++++++++++++++ src/abgleich/command.py | 59 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/abgleich/abc.py diff --git a/src/abgleich/abc.py b/src/abgleich/abc.py new file mode 100644 index 0000000..edc9f0b --- /dev/null +++ b/src/abgleich/abc.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/abc.py: Abstract base classes + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +import abc + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASSES +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +class CommandABC(abc.ABC): + pass diff --git a/src/abgleich/command.py b/src/abgleich/command.py index ae3c9c6..f98f234 100644 --- a/src/abgleich/command.py +++ b/src/abgleich/command.py @@ -28,13 +28,66 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +import subprocess import typing +import typeguard + +from .abc import CommandABC + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -class Command: +@typeguard.typechecked +class Command(CommandABC): + + def __init__(self, cmd: typing.List[str]): + + self._cmd = cmd.copy() + + def run(self): + + proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, errors = proc.communicate() + status = not bool(proc.returncode) + output, errors = output.decode("utf-8"), errors.decode("utf-8") + + # TODO logging and raise error ... ? + + return status, output, errors + + def run_pipe(self, other: CommandABC): + + proc_1 = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc_2 = subprocess.Popen(other.cmd, stdin=proc_1.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output_1, errors_1 = proc_1.communicate() # TODO no output? + output_2, errors_2 = proc_2.communicate() + status_1 = not bool(proc_1.returncode) + status_2 = not bool(proc_2.returncode) + output_1, errors_1 = output_1.decode("utf-8"), errors_1.decode("utf-8") + output_2, errors_2 = output_2.decode("utf-8"), errors_2.decode("utf-8") + + # TODO logging and raise error ... ? + + return status_1, output_1, errors_1, status_2, output_2, errors_2 + + @property + def cmd(self) -> typing.List[str]: + + return self._cmd.copy() + + @classmethod + def with_ssh(cls, cmd: typing.List[str], config: typing.Dict) -> CommandABC: + + cmd_str = " ".join([item.replace(" ", "\\ ") for item in cmd]) + cmd = [ + "ssh", + "-T", # Disable pseudo-terminal allocation + "-o", "Compression=yes" if config['compression'] else "Compression=no", + ] + if config['cipher'] is not None: + cmd.extend(("-c", config['cipher'])) + cmd.append(cmd_str) - def __init__(self, cmd_list: typing.List[str]): - pass + return cls(cmd) From f74b8c1a5bb31225e22cc6e1311c5329fc29b87e Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 14:21:56 +0200 Subject: [PATCH 026/135] zfs abstract base classes --- src/abgleich/zfs/abc.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/abgleich/zfs/abc.py diff --git a/src/abgleich/zfs/abc.py b/src/abgleich/zfs/abc.py new file mode 100644 index 0000000..5dcf10f --- /dev/null +++ b/src/abgleich/zfs/abc.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/zfs/abc.py: Abstract base classes + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +import abc + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASSES +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +# TODO From 01de66b711ab9740c5e1ac3d918cd91f4a68a7d5 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 14:26:23 +0200 Subject: [PATCH 027/135] abc in zfs --- src/abgleich/zfs/abc.py | 12 +++++++++++- src/abgleich/zfs/clone.py | 9 ++++++--- src/abgleich/zfs/filesystem.py | 9 ++++++--- src/abgleich/zfs/snapshot.py | 9 ++++++--- src/abgleich/zfs/zpool.py | 6 ++++-- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/abgleich/zfs/abc.py b/src/abgleich/zfs/abc.py index 5dcf10f..787aa61 100644 --- a/src/abgleich/zfs/abc.py +++ b/src/abgleich/zfs/abc.py @@ -34,4 +34,14 @@ # CLASSES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# TODO +class CloneABC(abc.ABC): + pass + +class FilesystemABC(abc.ABC): + pass + +class SnapshotABC(abc.ABC): + pass + +class ZpoolABC(abc.ABC): + pass diff --git a/src/abgleich/zfs/clone.py b/src/abgleich/zfs/clone.py index abff429..ac0088c 100644 --- a/src/abgleich/zfs/clone.py +++ b/src/abgleich/zfs/clone.py @@ -28,18 +28,21 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# TODO +import typeguard + +from .abc import CloneABC # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -class Clone: +@typeguard.typechecked +class Clone(CloneABC): def __init__(self): pass @classmethod - def from_shell(cls, data): + def from_shell(cls) -> CloneABC: return cls() diff --git a/src/abgleich/zfs/filesystem.py b/src/abgleich/zfs/filesystem.py index 111d62f..b057dde 100644 --- a/src/abgleich/zfs/filesystem.py +++ b/src/abgleich/zfs/filesystem.py @@ -28,18 +28,21 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# TODO +import typeguard + +from .abc import FilesystemABC # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -class Filesystem: +@typeguard.typechecked +class Filesystem(FilesystemABC): def __init__(self): pass @classmethod - def from_shell(cls, data): + def from_shell(cls) -> FilesystemABC: return cls() diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index c09bccf..45da2e4 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -28,18 +28,21 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# TODO +import typeguard + +from .abc import SnapshotABC # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -class Snapshot: +@typeguard.typechecked +class Snapshot(SnapshotABC): def __init__(self): pass @classmethod - def from_shell(cls, data): + def from_shell(cls) -> SnapshotABC: return cls() diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 7886b93..414d5e2 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -32,19 +32,21 @@ import typeguard +from .abc import ZpoolABC + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @typeguard.typechecked -class Zpool: +class Zpool(ZpoolABC): def __init__( self, name: str, prefix: typing.Union[str, None] = None, location: str = "localhost" ): pass @classmethod - def from_shell(cls, data): + def from_shell(cls) -> ZpoolABC: return cls() From 409e6fde60012a09d2ec7ab089c04ea28ea82e07 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 14:59:36 +0200 Subject: [PATCH 028/135] improved schema and ssh handling --- src/abgleich/command.py | 20 +++++++++++++++----- src/abgleich/config.py | 9 +++++---- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/abgleich/command.py b/src/abgleich/command.py index f98f234..1cd2ab2 100644 --- a/src/abgleich/command.py +++ b/src/abgleich/command.py @@ -78,16 +78,26 @@ def cmd(self) -> typing.List[str]: return self._cmd.copy() @classmethod - def with_ssh(cls, cmd: typing.List[str], config: typing.Dict) -> CommandABC: + def on_side(cls, cmd: typing.List[str], side: str, config: typing.Dict) -> CommandABC: + + if config[side]['host'] == 'localhost': + return cls(cmd) + return cls.with_ssh(cmd, side_config = config[side], ssh_config = config['ssh']) + + @classmethod + def with_ssh(cls, cmd: typing.List[str], side_config: typing.Dict, ssh_config: typing.Dict) -> CommandABC: cmd_str = " ".join([item.replace(" ", "\\ ") for item in cmd]) cmd = [ "ssh", "-T", # Disable pseudo-terminal allocation - "-o", "Compression=yes" if config['compression'] else "Compression=no", + "-o", "Compression=yes" if ssh_config['compression'] else "Compression=no", ] - if config['cipher'] is not None: - cmd.extend(("-c", config['cipher'])) - cmd.append(cmd_str) + if ssh_config['cipher'] is not None: + cmd.extend(("-c", ssh_config['cipher'])) + cmd.extend([ + f'{side_config["user"]:s}@{side_config["host"]:s}', + cmd_str + ]) return cls(cmd) diff --git a/src/abgleich/config.py b/src/abgleich/config.py index 73496d5..0e5c0f0 100644 --- a/src/abgleich/config.py +++ b/src/abgleich/config.py @@ -51,15 +51,16 @@ def from_fd(cls, fd: typing.TextIO): "cipher": lambda v: isinstance(v, str) or v is None, } - location_schema = { + side_schema = { "zpool": lambda v: isinstance(v, str) and len(v) > 0, "prefix": lambda v: isinstance(v, str) or v is None, + "host": lambda v: isinstance(v, str) and len(v) > 0, + "user": lambda v: isinstance(v, str) and len(v) > 0, } root_schema = { - "host": lambda v: isinstance(v, str) and len(v) > 0, - "localhost": lambda v: cls._validate(data = v, schema = location_schema), - "remote": lambda v: cls._validate(data = v, schema = location_schema), + "source": lambda v: cls._validate(data = v, schema = side_schema), + "target": lambda v: cls._validate(data = v, schema = side_schema), "keep_snapshots": lambda v: isinstance(v, int) and v >= 1, "ignore": lambda v: isinstance(v, list) and all((isinstance(item, str) and len(item) > 0 for item in v)), From 6a8307cbd70ed98729e26c403e381f50d69fb4bb Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 15:18:37 +0200 Subject: [PATCH 029/135] commands raise errors --- src/abgleich/command.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/abgleich/command.py b/src/abgleich/command.py index 1cd2ab2..ed9861a 100644 --- a/src/abgleich/command.py +++ b/src/abgleich/command.py @@ -53,7 +53,8 @@ def run(self): status = not bool(proc.returncode) output, errors = output.decode("utf-8"), errors.decode("utf-8") - # TODO logging and raise error ... ? + if not status or len(errors.strip()) > 0: + raise SystemError('command failed', self.cmd, output, errors) return status, output, errors @@ -68,7 +69,13 @@ def run_pipe(self, other: CommandABC): output_1, errors_1 = output_1.decode("utf-8"), errors_1.decode("utf-8") output_2, errors_2 = output_2.decode("utf-8"), errors_2.decode("utf-8") - # TODO logging and raise error ... ? + if any(( + not status_1, + len(errors_1.strip()) > 0, + not status_2, + len(errors_2.strip()) > 0, + )): + raise SystemError('command pipe failed', self.cmd, output_1, errors_1, output_2, errors_2) return status_1, output_1, errors_1, status_2, output_2, errors_2 From 7ae7513c4583ff296bab091dd90f4ca8c4e2d394 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 15:19:44 +0200 Subject: [PATCH 030/135] zpool builds index --- src/abgleich/zfs/zpool.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 414d5e2..46b3b44 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -32,7 +32,9 @@ import typeguard -from .abc import ZpoolABC +from .abc import FilesystemABC, ZpoolABC +from .filesystem import Filesystem +from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -41,12 +43,26 @@ @typeguard.typechecked class Zpool(ZpoolABC): + def __init__( - self, name: str, prefix: typing.Union[str, None] = None, location: str = "localhost" + self, filesystems: typing.List[FilesystemABC], side: str, config: typing.Dict, ): - pass - @classmethod - def from_shell(cls) -> ZpoolABC: + self._filesystems = filesystems + self._side = side + self._config = config - return cls() + @classmethod + def from_config(cls, side: str, config: typing.Dict) -> ZpoolABC: + + status, out, err = Command.on_side(["zfs", "list", "-H", "-p"], side, config).run() + + return cls( + filesystems = [ + Filesystem.from_line(line, side, config) + for line in out.split('\n') + if len(line.strip()) > 0 + ], + side = side, + config = config, + ) From e66c46f10050c835318c73b358624ea67d2cf990 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 15:21:18 +0200 Subject: [PATCH 031/135] no need to return status --- src/abgleich/command.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/abgleich/command.py b/src/abgleich/command.py index ed9861a..f04cebe 100644 --- a/src/abgleich/command.py +++ b/src/abgleich/command.py @@ -56,7 +56,7 @@ def run(self): if not status or len(errors.strip()) > 0: raise SystemError('command failed', self.cmd, output, errors) - return status, output, errors + return output, errors def run_pipe(self, other: CommandABC): @@ -77,7 +77,7 @@ def run_pipe(self, other: CommandABC): )): raise SystemError('command pipe failed', self.cmd, output_1, errors_1, output_2, errors_2) - return status_1, output_1, errors_1, status_2, output_2, errors_2 + return output_1, errors_1, output_2, errors_2 @property def cmd(self) -> typing.List[str]: From 3c172c47bb6fbdfd6c6f5f6198db3d8c8c937c88 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 15:22:22 +0200 Subject: [PATCH 032/135] cleanup --- src/abgleich/zfs/zpool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 46b3b44..86616fe 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -55,12 +55,12 @@ def __init__( @classmethod def from_config(cls, side: str, config: typing.Dict) -> ZpoolABC: - status, out, err = Command.on_side(["zfs", "list", "-H", "-p"], side, config).run() + output, _ = Command.on_side(["zfs", "list", "-H", "-p"], side, config).run() return cls( filesystems = [ Filesystem.from_line(line, side, config) - for line in out.split('\n') + for line in output.split('\n') if len(line.strip()) > 0 ], side = side, From 2883076a0b5d2d46abf79c3bef091227734d4d65 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 15:55:52 +0200 Subject: [PATCH 033/135] new abc --- src/abgleich/zfs/abc.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/abgleich/zfs/abc.py b/src/abgleich/zfs/abc.py index 787aa61..b8874c7 100644 --- a/src/abgleich/zfs/abc.py +++ b/src/abgleich/zfs/abc.py @@ -40,6 +40,9 @@ class CloneABC(abc.ABC): class FilesystemABC(abc.ABC): pass +class PropertyABC(abc.ABC): + pass + class SnapshotABC(abc.ABC): pass From 3622feb0bae3d45961c1e234d9df52468d71b7a4 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 15:56:09 +0200 Subject: [PATCH 034/135] class holding a property --- src/abgleich/zfs/property.py | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/abgleich/zfs/property.py diff --git a/src/abgleich/zfs/property.py b/src/abgleich/zfs/property.py new file mode 100644 index 0000000..b3cbe02 --- /dev/null +++ b/src/abgleich/zfs/property.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/zfs/filesystem.py: ZFS filesystem + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +# import typing + +import typeguard + +from .abc import PropertyABC + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASS +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +@typeguard.typechecked +class Property(PropertyABC): + + def __init__(self): + + self._name = '' # TODO + + @property + def name(self) -> str: + return self._name + + @classmethod + def from_line(cls, line: str) -> PropertyABC: + + return cls() From 0c5195c0ebfa8fd81d7697a1a4f028b9635478fb Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 15:56:21 +0200 Subject: [PATCH 035/135] basic filesystem --- src/abgleich/zfs/filesystem.py | 48 ++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/abgleich/zfs/filesystem.py b/src/abgleich/zfs/filesystem.py index b057dde..a7f9908 100644 --- a/src/abgleich/zfs/filesystem.py +++ b/src/abgleich/zfs/filesystem.py @@ -28,9 +28,14 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +import typing + import typeguard -from .abc import FilesystemABC +from .abc import FilesystemABC, PropertyABC, SnapshotABC +from .property import Property +from .snapshot import Snapshot +from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -39,10 +44,43 @@ @typeguard.typechecked class Filesystem(FilesystemABC): - def __init__(self): - pass + def __init__(self, + name: str, + properties: typing.Dict[str, PropertyABC], + snapshots: typing.List[SnapshotABC], + side: str, + config: typing.Dict, + ): + + self._name = name + self._properties = properties + self._snapshots = snapshots + self._side = side + self._config = config @classmethod - def from_shell(cls) -> FilesystemABC: + def from_line(cls, line: str, side: str, config: typing.Dict) -> FilesystemABC: + + name = line.split('\t')[0] + + output, _ = Command.on_side(["zfs", "get", "all", "-H", "-p", name], side, config).run() + properties = {property.name: property for property in [ + Property.from_line(line) + for line in output.split('\n') + if len(line.strip()) > 0 + ]} + + output, _ = Command.on_side(["zfs", "list", "-t", "snapshot", "-H", "-p", name], side, config).run() + snapshots = [ + Snapshot.from_line(line, side, config) + for line in output.split('\n') + if len(line.strip()) > 0 + ] - return cls() + return cls( + name = name, + properties = properties, + snapshots = snapshots, + side = side, + config = config, + ) From d027cb025151ec2fc6ffd1b806f3a5567e5d67e2 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 15:56:39 +0200 Subject: [PATCH 036/135] basic snapshot --- src/abgleich/zfs/snapshot.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 45da2e4..973cc13 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -28,9 +28,12 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +import typing + import typeguard from .abc import SnapshotABC +from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -42,7 +45,8 @@ class Snapshot(SnapshotABC): def __init__(self): pass + @classmethod - def from_shell(cls) -> SnapshotABC: + def from_line(cls, line: str, side: str, config: typing.Dict) -> SnapshotABC: return cls() From 6e8b8d56b70201d6945f745105943af4f036e101 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 16:04:36 +0200 Subject: [PATCH 037/135] generator --- src/abgleich/zfs/filesystem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/filesystem.py b/src/abgleich/zfs/filesystem.py index a7f9908..402fd3f 100644 --- a/src/abgleich/zfs/filesystem.py +++ b/src/abgleich/zfs/filesystem.py @@ -64,11 +64,11 @@ def from_line(cls, line: str, side: str, config: typing.Dict) -> FilesystemABC: name = line.split('\t')[0] output, _ = Command.on_side(["zfs", "get", "all", "-H", "-p", name], side, config).run() - properties = {property.name: property for property in [ + properties = {property.name: property for property in ( Property.from_line(line) for line in output.split('\n') if len(line.strip()) > 0 - ]} + )} output, _ = Command.on_side(["zfs", "list", "-t", "snapshot", "-H", "-p", name], side, config).run() snapshots = [ From 9c1d0dfa08fff270d8ed07675e229bb7553a309f Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 16:16:20 +0200 Subject: [PATCH 038/135] logic error --- src/abgleich/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/abgleich/config.py b/src/abgleich/config.py index 0e5c0f0..b31615e 100644 --- a/src/abgleich/config.py +++ b/src/abgleich/config.py @@ -55,7 +55,7 @@ def from_fd(cls, fd: typing.TextIO): "zpool": lambda v: isinstance(v, str) and len(v) > 0, "prefix": lambda v: isinstance(v, str) or v is None, "host": lambda v: isinstance(v, str) and len(v) > 0, - "user": lambda v: isinstance(v, str) and len(v) > 0, + "user": lambda v: isinstance(v, str) or v is None, } root_schema = { @@ -79,3 +79,5 @@ def _validate(cls, data: typing.Dict, schema: typing.Dict): raise KeyError(f'missing configuration field "{field:s}"') if not validator(data[field]): raise ValueError(f'invalid value in field "{field:s}"') + + return True From a6eaa5382abc51c6a67391e7d1b40815d910b8f4 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 16:16:44 +0200 Subject: [PATCH 039/135] tree cli runs on new logic --- src/abgleich/cli/tree.py | 58 +++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/src/abgleich/cli/tree.py b/src/abgleich/cli/tree.py index e394acc..1fd654d 100644 --- a/src/abgleich/cli/tree.py +++ b/src/abgleich/cli/tree.py @@ -30,35 +30,45 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ import click -from tabulate import tabulate +# from tabulate import tabulate -from ..io import colorize, humanize_size -from ..zfs import get_tree +# from ..io import colorize, humanize_size +# from ..zfs import get_tree + +from ..config import Config +from ..zfs.zpool import Zpool # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# @click.command(short_help="show dataset tree") +# @click.argument("host", default="localhost", type=str) +# def tree(host): @click.command(short_help="show dataset tree") -@click.argument("host", default="localhost", type=str) -def tree(host): - cols = ["NAME", "USED", "REFER", "compressratio"] - col_align = ("left", "right", "right", "decimal") - size_cols = ["USED", "REFER"] - datasets = get_tree(host if host != "localhost" else None) - table = [] - for dataset in datasets: - table.append([dataset[col] for col in cols]) - for snapshot in dataset["SNAPSHOTS"]: - table.append( - ["- " + snapshot["NAME"]] + [snapshot[col] for col in cols[1:]] - ) - for row in table: - for col in [1, 2]: - row[col] = humanize_size(int(row[col]), add_color=True) - if not row[0].startswith("- "): - row[0] = colorize(row[0], "white") - else: - row[0] = colorize(row[0], "grey") - print(tabulate(table, headers=cols, tablefmt="github", colalign=col_align)) +@click.argument("configfile", type=click.File("r", encoding="utf-8")) +@click.argument("side", default="source", type=str) +def tree(configfile, side): + + zpool = Zpool.from_config(side, config = Config.from_fd(configfile)) + + # cols = ["NAME", "USED", "REFER", "compressratio"] + # col_align = ("left", "right", "right", "decimal") + # size_cols = ["USED", "REFER"] + # datasets = get_tree(host if host != "localhost" else None) + # table = [] + # for dataset in datasets: + # table.append([dataset[col] for col in cols]) + # for snapshot in dataset["SNAPSHOTS"]: + # table.append( + # ["- " + snapshot["NAME"]] + [snapshot[col] for col in cols[1:]] + # ) + # for row in table: + # for col in [1, 2]: + # row[col] = humanize_size(int(row[col]), add_color=True) + # if not row[0].startswith("- "): + # row[0] = colorize(row[0], "white") + # else: + # row[0] = colorize(row[0], "grey") + # print(tabulate(table, headers=cols, tablefmt="github", colalign=col_align)) From bbf002b55640d27a9a4ccce24909aef572cc74bf Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 16:22:10 +0200 Subject: [PATCH 040/135] exposing structure --- src/abgleich/zfs/filesystem.py | 5 +++++ src/abgleich/zfs/zpool.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/abgleich/zfs/filesystem.py b/src/abgleich/zfs/filesystem.py index 402fd3f..d35f07f 100644 --- a/src/abgleich/zfs/filesystem.py +++ b/src/abgleich/zfs/filesystem.py @@ -58,6 +58,11 @@ def __init__(self, self._side = side self._config = config + @property + def snapshots(self) -> typing.Generator[SnapshotABC, None, None]: + + return (snapshot for snapshot in self._snapshots) + @classmethod def from_line(cls, line: str, side: str, config: typing.Dict) -> FilesystemABC: diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 86616fe..55e5c43 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -52,6 +52,11 @@ def __init__( self._side = side self._config = config + @property + def filesystems(self) -> typing.Generator[FilesystemABC, None, None]: + + return (filesystem for filesystem in self._filesystems) + @classmethod def from_config(cls, side: str, config: typing.Dict) -> ZpoolABC: From 59a415819ae9cd9cdbcaed9f101910f3e193b486 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 16:32:52 +0200 Subject: [PATCH 041/135] better property --- src/abgleich/zfs/property.py | 38 ++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/abgleich/zfs/property.py b/src/abgleich/zfs/property.py index b3cbe02..218b3b6 100644 --- a/src/abgleich/zfs/property.py +++ b/src/abgleich/zfs/property.py @@ -28,7 +28,7 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# import typing +import typing import typeguard @@ -41,15 +41,45 @@ @typeguard.typechecked class Property(PropertyABC): - def __init__(self): + def __init__(self, + name: str, + value: typing.Union[str, int, None], + src: typing.Union[str, int, None], + ): - self._name = '' # TODO + self._name = name + self._value = value + self._src = src @property def name(self) -> str: return self._name + @property + def value(self) -> typing.Union[str, int, None]: + return self._value + + @property + def src(self) -> typing.Union[str, int, None]: + return self._src + + @classmethod + def _convert(cls, value: str) -> typing.Union[str, int, None]: + + if value.isnumeric(): + return int(value) + if value.strip() == '' or value == '-' or value.lower() == 'none': + return None + + return value + @classmethod def from_line(cls, line: str) -> PropertyABC: - return cls() + elements = line.split('\t') + + return cls( + name = elements[1], + value = cls._convert(elements[1]), + src = cls._convert(elements[2]), + ) From 5c805ee08ac8aa68bc9ac18820c7b0826a6fcecf Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 16:35:46 +0200 Subject: [PATCH 042/135] structure --- src/abgleich/zfs/snapshot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 973cc13..335a36e 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -45,7 +45,6 @@ class Snapshot(SnapshotABC): def __init__(self): pass - @classmethod def from_line(cls, line: str, side: str, config: typing.Dict) -> SnapshotABC: From 7585edee9772e377d3557d2bb021d8abef8cf973 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 16:44:32 +0200 Subject: [PATCH 043/135] snapshots are imported --- src/abgleich/zfs/snapshot.py | 37 ++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 335a36e..e5b2efc 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -32,7 +32,8 @@ import typeguard -from .abc import SnapshotABC +from .abc import PropertyABC, SnapshotABC +from .property import Property from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -42,10 +43,38 @@ @typeguard.typechecked class Snapshot(SnapshotABC): - def __init__(self): - pass + def __init__(self, + name: str, + parent: str, + properties: typing.Dict[str, PropertyABC], + side: str, + config: typing.Dict, + ): + + self._name = name + self._parent = parent + self._properties = properties + self._side = side + self._config = config @classmethod def from_line(cls, line: str, side: str, config: typing.Dict) -> SnapshotABC: - return cls() + name = line.split('\t')[0] + + output, _ = Command.on_side(["zfs", "get", "all", "-H", "-p", name], side, config).run() + properties = {property.name: property for property in ( + Property.from_line(line) + for line in output.split('\n') + if len(line.strip()) > 0 + )} + + parent, name = name.split('@') + + return cls( + name = name, + parent = parent, + properties = properties, + side = side, + config = config, + ) From d42c9e31b5515895b92185a3e23889fef8d41ab5 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 17:14:28 +0200 Subject: [PATCH 044/135] prepred stub for table --- src/abgleich/cli/tree.py | 1 + src/abgleich/zfs/zpool.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/abgleich/cli/tree.py b/src/abgleich/cli/tree.py index 1fd654d..9fbce3c 100644 --- a/src/abgleich/cli/tree.py +++ b/src/abgleich/cli/tree.py @@ -52,6 +52,7 @@ def tree(configfile, side): zpool = Zpool.from_config(side, config = Config.from_fd(configfile)) + zpool.print_table() # cols = ["NAME", "USED", "REFER", "compressratio"] # col_align = ("left", "right", "right", "decimal") diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 55e5c43..e0f10c3 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -57,6 +57,10 @@ def filesystems(self) -> typing.Generator[FilesystemABC, None, None]: return (filesystem for filesystem in self._filesystems) + def print_table(self, color: bool = True): + + pass + @classmethod def from_config(cls, side: str, config: typing.Dict) -> ZpoolABC: From 41aef700c720d46b9969c34540bd45a83ac28eec Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 17:18:34 +0200 Subject: [PATCH 045/135] terminology: filesystems are datasets --- src/abgleich/zfs/abc.py | 2 +- src/abgleich/zfs/{filesystem.py => dataset.py} | 8 ++++---- src/abgleich/zfs/zpool.py | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) rename src/abgleich/zfs/{filesystem.py => dataset.py} (94%) diff --git a/src/abgleich/zfs/abc.py b/src/abgleich/zfs/abc.py index b8874c7..7dacecb 100644 --- a/src/abgleich/zfs/abc.py +++ b/src/abgleich/zfs/abc.py @@ -37,7 +37,7 @@ class CloneABC(abc.ABC): pass -class FilesystemABC(abc.ABC): +class DatasetABC(abc.ABC): pass class PropertyABC(abc.ABC): diff --git a/src/abgleich/zfs/filesystem.py b/src/abgleich/zfs/dataset.py similarity index 94% rename from src/abgleich/zfs/filesystem.py rename to src/abgleich/zfs/dataset.py index d35f07f..29990e9 100644 --- a/src/abgleich/zfs/filesystem.py +++ b/src/abgleich/zfs/dataset.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/filesystem.py: ZFS filesystem + src/abgleich/zfs/dataset.py: ZFS dataset Copyright (C) 2019-2020 Sebastian M. Ernst @@ -32,7 +32,7 @@ import typeguard -from .abc import FilesystemABC, PropertyABC, SnapshotABC +from .abc import DatasetABC, PropertyABC, SnapshotABC from .property import Property from .snapshot import Snapshot from ..command import Command @@ -42,7 +42,7 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @typeguard.typechecked -class Filesystem(FilesystemABC): +class Dataset(DatasetABC): def __init__(self, name: str, @@ -64,7 +64,7 @@ def snapshots(self) -> typing.Generator[SnapshotABC, None, None]: return (snapshot for snapshot in self._snapshots) @classmethod - def from_line(cls, line: str, side: str, config: typing.Dict) -> FilesystemABC: + def from_line(cls, line: str, side: str, config: typing.Dict) -> DatasetABC: name = line.split('\t')[0] diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index e0f10c3..32d9dcd 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -32,8 +32,8 @@ import typeguard -from .abc import FilesystemABC, ZpoolABC -from .filesystem import Filesystem +from .abc import DatasetABC, ZpoolABC +from .dataset import Dataset from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -45,17 +45,17 @@ class Zpool(ZpoolABC): def __init__( - self, filesystems: typing.List[FilesystemABC], side: str, config: typing.Dict, + self, datasets: typing.List[DatasetABC], side: str, config: typing.Dict, ): - self._filesystems = filesystems + self._datasets = datasets self._side = side self._config = config @property - def filesystems(self) -> typing.Generator[FilesystemABC, None, None]: + def datasets(self) -> typing.Generator[DatasetABC, None, None]: - return (filesystem for filesystem in self._filesystems) + return (dataset for dataset in self._datasets) def print_table(self, color: bool = True): @@ -67,8 +67,8 @@ def from_config(cls, side: str, config: typing.Dict) -> ZpoolABC: output, _ = Command.on_side(["zfs", "list", "-H", "-p"], side, config).run() return cls( - filesystems = [ - Filesystem.from_line(line, side, config) + datasets = [ + Dataset.from_line(line, side, config) for line in output.split('\n') if len(line.strip()) > 0 ], From f8bc0309b85556f731df61119461f0bcb16c6887 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 18:03:56 +0200 Subject: [PATCH 046/135] type and field fixes --- src/abgleich/zfs/property.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/abgleich/zfs/property.py b/src/abgleich/zfs/property.py index 218b3b6..34b0129 100644 --- a/src/abgleich/zfs/property.py +++ b/src/abgleich/zfs/property.py @@ -34,6 +34,12 @@ from .abc import PropertyABC +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# TYPING +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +PropertyTypes = typing.Union[str, int, float, None] + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -43,8 +49,8 @@ class Property(PropertyABC): def __init__(self, name: str, - value: typing.Union[str, int, None], - src: typing.Union[str, int, None], + value: PropertyTypes, + src: PropertyTypes, ): self._name = name @@ -56,21 +62,29 @@ def name(self) -> str: return self._name @property - def value(self) -> typing.Union[str, int, None]: + def value(self) -> PropertyTypes: return self._value @property - def src(self) -> typing.Union[str, int, None]: + def src(self) -> PropertyTypes: return self._src @classmethod - def _convert(cls, value: str) -> typing.Union[str, int, None]: + def _convert(cls, value: str) -> PropertyTypes: + + value = value.strip() if value.isnumeric(): return int(value) + if value.strip() == '' or value == '-' or value.lower() == 'none': return None + try: + return float(value) + except ValueError: + pass + return value @classmethod @@ -80,6 +94,6 @@ def from_line(cls, line: str) -> PropertyABC: return cls( name = elements[1], - value = cls._convert(elements[1]), - src = cls._convert(elements[2]), + value = cls._convert(elements[2]), + src = cls._convert(elements[3]), ) From 9d5ae94ac135ea3fd2c0b87065eb8a3489466237 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 18:04:30 +0200 Subject: [PATCH 047/135] exposing properties --- src/abgleich/zfs/snapshot.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index e5b2efc..1511a7f 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -33,7 +33,7 @@ import typeguard from .abc import PropertyABC, SnapshotABC -from .property import Property +from .property import Property, PropertyTypes from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -57,6 +57,20 @@ def __init__(self, self._side = side self._config = config + def __getitem__(self, name: str) -> PropertyTypes: + + return self._properties[name] + + @property + def name(self) -> str: + + return self._name + + @property + def parent(self) -> str: + + return self._parent + @classmethod def from_line(cls, line: str, side: str, config: typing.Dict) -> SnapshotABC: From ba724df5d71a2a77e871da03fe51a7b1de9f1f01 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 18:04:46 +0200 Subject: [PATCH 048/135] exposing properties --- src/abgleich/zfs/dataset.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index 29990e9..c742e94 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -58,6 +58,15 @@ def __init__(self, self._side = side self._config = config + def __getitem__(self, name: str) -> PropertyABC: + + return self._properties[name] + + @property + def name(self) -> str: + + return self._name + @property def snapshots(self) -> typing.Generator[SnapshotABC, None, None]: From 9029aa7ac7e86c74571ceb30c0b8c85551d05f0c Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 18:05:01 +0200 Subject: [PATCH 049/135] render basic table --- src/abgleich/zfs/zpool.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 32d9dcd..d49de3f 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -30,6 +30,7 @@ import typing +from tabulate import tabulate import typeguard from .abc import DatasetABC, ZpoolABC @@ -59,7 +60,21 @@ def datasets(self) -> typing.Generator[DatasetABC, None, None]: def print_table(self, color: bool = True): - pass + table = [] + for dataset in self._datasets: + table.append([ + dataset.name, + str(dataset['used'].value), + str(dataset['referenced'].value), + f'{dataset["compressratio"].value:.02f}', + ]) + + print(tabulate( + table, + headers=("NAME", "USED", "REFER", "compressratio"), + tablefmt="github", + colalign=("left", "right", "right", "decimal"), + )) @classmethod def from_config(cls, side: str, config: typing.Dict) -> ZpoolABC: From 1b3d4a41be3d5d71dc5f6f139ba5d8bd61e8529e Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 18:13:02 +0200 Subject: [PATCH 050/135] type fix --- src/abgleich/zfs/snapshot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 1511a7f..59fe371 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -33,7 +33,7 @@ import typeguard from .abc import PropertyABC, SnapshotABC -from .property import Property, PropertyTypes +from .property import Property from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -57,7 +57,7 @@ def __init__(self, self._side = side self._config = config - def __getitem__(self, name: str) -> PropertyTypes: + def __getitem__(self, name: str) -> PropertyABC: return self._properties[name] From 1b981c518a21f8a014b4d7855d221c574381defd Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 18:13:11 +0200 Subject: [PATCH 051/135] table works --- src/abgleich/zfs/zpool.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index d49de3f..ab488bd 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -36,6 +36,7 @@ from .abc import DatasetABC, ZpoolABC from .dataset import Dataset from ..command import Command +from ..io import colorize, humanize_size # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -63,11 +64,18 @@ def print_table(self, color: bool = True): table = [] for dataset in self._datasets: table.append([ - dataset.name, - str(dataset['used'].value), - str(dataset['referenced'].value), + colorize(dataset.name, "white"), + humanize_size(dataset['used'].value, add_color=True), + humanize_size(dataset['referenced'].value, add_color=True), f'{dataset["compressratio"].value:.02f}', ]) + for snapshot in dataset.snapshots: + table.append([ + '- ' + colorize(snapshot.name, "grey"), + humanize_size(snapshot['used'].value, add_color=True), + humanize_size(snapshot['referenced'].value, add_color=True), + f'{snapshot["compressratio"].value:.02f}', + ]) print(tabulate( table, From 1ed0ba7b014ef3d31c6547f1c22d7fefabb17430 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 18:16:34 +0200 Subject: [PATCH 052/135] cleanup - tree runs on new api --- src/abgleich/cli/tree.py | 36 ++++-------------------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/src/abgleich/cli/tree.py b/src/abgleich/cli/tree.py index 9fbce3c..feea380 100644 --- a/src/abgleich/cli/tree.py +++ b/src/abgleich/cli/tree.py @@ -6,9 +6,9 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/cli/tree.py: tree command entry point + src/abgleich/cli/tree.py: tree command entry point - Copyright (C) 2019-2020 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License @@ -30,10 +30,6 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ import click -# from tabulate import tabulate - -# from ..io import colorize, humanize_size -# from ..zfs import get_tree from ..config import Config from ..zfs.zpool import Zpool @@ -42,34 +38,10 @@ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# @click.command(short_help="show dataset tree") -# @click.argument("host", default="localhost", type=str) -# def tree(host): - @click.command(short_help="show dataset tree") @click.argument("configfile", type=click.File("r", encoding="utf-8")) @click.argument("side", default="source", type=str) def tree(configfile, side): - zpool = Zpool.from_config(side, config = Config.from_fd(configfile)) - zpool.print_table() - - # cols = ["NAME", "USED", "REFER", "compressratio"] - # col_align = ("left", "right", "right", "decimal") - # size_cols = ["USED", "REFER"] - # datasets = get_tree(host if host != "localhost" else None) - # table = [] - # for dataset in datasets: - # table.append([dataset[col] for col in cols]) - # for snapshot in dataset["SNAPSHOTS"]: - # table.append( - # ["- " + snapshot["NAME"]] + [snapshot[col] for col in cols[1:]] - # ) - # for row in table: - # for col in [1, 2]: - # row[col] = humanize_size(int(row[col]), add_color=True) - # if not row[0].startswith("- "): - # row[0] = colorize(row[0], "white") - # else: - # row[0] = colorize(row[0], "grey") - # print(tabulate(table, headers=cols, tablefmt="github", colalign=col_align)) + zpool = Zpool.from_config(side, config = Config.from_fd(configfile)) + zpool.print_table() From abe43f5232f5a2571ec0b0bd20535ba9e7b3dc5e Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 18:19:48 +0200 Subject: [PATCH 053/135] cleanup --- src/abgleich/zfs/zpool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index ab488bd..ccebbeb 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -59,7 +59,7 @@ def datasets(self) -> typing.Generator[DatasetABC, None, None]: return (dataset for dataset in self._datasets) - def print_table(self, color: bool = True): + def print_table(self): table = [] for dataset in self._datasets: From 42b516cce3d42cdcfad6f66fb0bd096e17215a77 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 20:08:10 +0200 Subject: [PATCH 054/135] zfs path join --- src/abgleich/zfs/lib.py | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/abgleich/zfs/lib.py diff --git a/src/abgleich/zfs/lib.py b/src/abgleich/zfs/lib.py new file mode 100644 index 0000000..11475ef --- /dev/null +++ b/src/abgleich/zfs/lib.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/zfs/lib.py: ZFS library + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +import typeguard + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# ROUTINES +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +@typeguard.typechecked +def join(*args: str) -> str: + + if len(args) < 2: + raise ValueError('not enough elements to join') + + args = [arg.strip('/ \t\n') for arg in args] + + if any((len(arg) == 0 for arg in args)): + raise ValueError('can not join empty path elements') + + return '/'.join(args) From 46f947ad9a6c3391586a992832c9934145f3b5af Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 20:10:33 +0200 Subject: [PATCH 055/135] faster zpool index --- src/abgleich/zfs/dataset.py | 28 ++++++++++++++++++++++++++++ src/abgleich/zfs/property.py | 9 +++++++++ src/abgleich/zfs/snapshot.py | 18 ++++++++++++++++++ src/abgleich/zfs/zpool.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index c742e94..06adbd0 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -72,6 +72,34 @@ def snapshots(self) -> typing.Generator[SnapshotABC, None, None]: return (snapshot for snapshot in self._snapshots) + @classmethod + def from_lines(cls, name: str, entities: typing.Dict[str, typing.List[typing.List[str]]], side: str, config: typing.Dict) -> DatasetABC: + + properties = {property.name: property for property in ( + Property.from_params(*params) + for params in entities[name] + )} + entities.pop(name) + + snapshots = [ + Snapshot.from_lines( + snapshot_name, + entities[snapshot_name], + side, + config, + ) + for snapshot_name in entities.keys() + ] + snapshots.sort(key = lambda snapshot: snapshot['creation'].value) + + return cls( + name = name, + properties = properties, + snapshots = snapshots, + side = side, + config = config, + ) + @classmethod def from_line(cls, line: str, side: str, config: typing.Dict) -> DatasetABC: diff --git a/src/abgleich/zfs/property.py b/src/abgleich/zfs/property.py index 34b0129..507a208 100644 --- a/src/abgleich/zfs/property.py +++ b/src/abgleich/zfs/property.py @@ -87,6 +87,15 @@ def _convert(cls, value: str) -> PropertyTypes: return value + @classmethod + def from_params(cls, name, value, src) -> PropertyABC: + + return cls( + name = name, + value = cls._convert(value), + src = cls._convert(src), + ) + @classmethod def from_line(cls, line: str) -> PropertyABC: diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 59fe371..295bb7f 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -71,6 +71,24 @@ def parent(self) -> str: return self._parent + @classmethod + def from_lines(cls, name: str, lines: typing.List[typing.List[str]], side: str, config: typing.Dict) -> SnapshotABC: + + properties = {property.name: property for property in ( + Property.from_params(*params) + for params in lines + )} + + parent, name = name.split('@') + + return cls( + name = name, + parent = parent, + properties = properties, + side = side, + config = config, + ) + @classmethod def from_line(cls, line: str, side: str, config: typing.Dict) -> SnapshotABC: diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index ccebbeb..6c45d4c 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -35,6 +35,7 @@ from .abc import DatasetABC, ZpoolABC from .dataset import Dataset +from .lib import join from ..command import Command from ..io import colorize, humanize_size @@ -87,6 +88,37 @@ def print_table(self): @classmethod def from_config(cls, side: str, config: typing.Dict) -> ZpoolABC: + root = config[side]['zpool'] + if config[side]['prefix'] is not None: + root = join(root, config[side]['prefix']) + + output, _ = Command.on_side(["zfs", "get", "all", "-r", "-H", "-p", root], side, config).run() + output = [line.split('\t') for line in output.split('\n') if len(line.strip()) > 0] + entities = {name: [] for name in {line[0] for line in output}} + for line in output: + entities[line[0]].append(line[1:]) + + datasets = [ + Dataset.from_lines( + name, + {k: v for k, v in entities.items() if k == name or k.startswith(f'{name:s}@')}, + side, + config, + ) + for name in entities.keys() + if '@' not in name + ] + datasets.sort(key = lambda dataset: dataset.name) + + return cls( + datasets = datasets, + side = side, + config = config, + ) + + @classmethod + def from_config_slow(cls, side: str, config: typing.Dict) -> ZpoolABC: + output, _ = Command.on_side(["zfs", "list", "-H", "-p"], side, config).run() return cls( From 37a6a4d593fe00796ba47abb7124a75b02358a85 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 20:12:04 +0200 Subject: [PATCH 056/135] removing slow solution --- src/abgleich/zfs/dataset.py | 27 --------------------------- src/abgleich/zfs/property.py | 11 ----------- src/abgleich/zfs/snapshot.py | 22 ---------------------- src/abgleich/zfs/zpool.py | 15 --------------- 4 files changed, 75 deletions(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index 06adbd0..fc95b8e 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -99,30 +99,3 @@ def from_lines(cls, name: str, entities: typing.Dict[str, typing.List[typing.Lis side = side, config = config, ) - - @classmethod - def from_line(cls, line: str, side: str, config: typing.Dict) -> DatasetABC: - - name = line.split('\t')[0] - - output, _ = Command.on_side(["zfs", "get", "all", "-H", "-p", name], side, config).run() - properties = {property.name: property for property in ( - Property.from_line(line) - for line in output.split('\n') - if len(line.strip()) > 0 - )} - - output, _ = Command.on_side(["zfs", "list", "-t", "snapshot", "-H", "-p", name], side, config).run() - snapshots = [ - Snapshot.from_line(line, side, config) - for line in output.split('\n') - if len(line.strip()) > 0 - ] - - return cls( - name = name, - properties = properties, - snapshots = snapshots, - side = side, - config = config, - ) diff --git a/src/abgleich/zfs/property.py b/src/abgleich/zfs/property.py index 507a208..d8428be 100644 --- a/src/abgleich/zfs/property.py +++ b/src/abgleich/zfs/property.py @@ -95,14 +95,3 @@ def from_params(cls, name, value, src) -> PropertyABC: value = cls._convert(value), src = cls._convert(src), ) - - @classmethod - def from_line(cls, line: str) -> PropertyABC: - - elements = line.split('\t') - - return cls( - name = elements[1], - value = cls._convert(elements[2]), - src = cls._convert(elements[3]), - ) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 295bb7f..cfae18b 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -88,25 +88,3 @@ def from_lines(cls, name: str, lines: typing.List[typing.List[str]], side: str, side = side, config = config, ) - - @classmethod - def from_line(cls, line: str, side: str, config: typing.Dict) -> SnapshotABC: - - name = line.split('\t')[0] - - output, _ = Command.on_side(["zfs", "get", "all", "-H", "-p", name], side, config).run() - properties = {property.name: property for property in ( - Property.from_line(line) - for line in output.split('\n') - if len(line.strip()) > 0 - )} - - parent, name = name.split('@') - - return cls( - name = name, - parent = parent, - properties = properties, - side = side, - config = config, - ) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 6c45d4c..ed52142 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -115,18 +115,3 @@ def from_config(cls, side: str, config: typing.Dict) -> ZpoolABC: side = side, config = config, ) - - @classmethod - def from_config_slow(cls, side: str, config: typing.Dict) -> ZpoolABC: - - output, _ = Command.on_side(["zfs", "list", "-H", "-p"], side, config).run() - - return cls( - datasets = [ - Dataset.from_line(line, side, config) - for line in output.split('\n') - if len(line.strip()) > 0 - ], - side = side, - config = config, - ) From 9e71ee5e4a7f1ecc0ac70c530ba9fdf75bf989a4 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 21:35:16 +0200 Subject: [PATCH 057/135] fixed names and formatting --- src/abgleich/zfs/dataset.py | 9 +++++++-- src/abgleich/zfs/snapshot.py | 10 ++++++++-- src/abgleich/zfs/zpool.py | 12 ++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index fc95b8e..cccbd7d 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -73,7 +73,12 @@ def snapshots(self) -> typing.Generator[SnapshotABC, None, None]: return (snapshot for snapshot in self._snapshots) @classmethod - def from_lines(cls, name: str, entities: typing.Dict[str, typing.List[typing.List[str]]], side: str, config: typing.Dict) -> DatasetABC: + def from_entities(cls, + name: str, + entities: typing.Dict[str, typing.List[typing.List[str]]], + side: str, + config: typing.Dict, + ) -> DatasetABC: properties = {property.name: property for property in ( Property.from_params(*params) @@ -82,7 +87,7 @@ def from_lines(cls, name: str, entities: typing.Dict[str, typing.List[typing.Lis entities.pop(name) snapshots = [ - Snapshot.from_lines( + Snapshot.from_entity( snapshot_name, entities[snapshot_name], side, diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index cfae18b..12b4202 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -72,11 +72,17 @@ def parent(self) -> str: return self._parent @classmethod - def from_lines(cls, name: str, lines: typing.List[typing.List[str]], side: str, config: typing.Dict) -> SnapshotABC: + def from_entity( + cls, + name: str, + entity: typing.List[typing.List[str]], + side: str, + config: typing.Dict, + ) -> SnapshotABC: properties = {property.name: property for property in ( Property.from_params(*params) - for params in lines + for params in entity )} parent, name = name.split('@') diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index ed52142..a0db436 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -86,7 +86,11 @@ def print_table(self): )) @classmethod - def from_config(cls, side: str, config: typing.Dict) -> ZpoolABC: + def from_config( + cls, + side: str, + config: typing.Dict, + ) -> ZpoolABC: root = config[side]['zpool'] if config[side]['prefix'] is not None: @@ -95,11 +99,11 @@ def from_config(cls, side: str, config: typing.Dict) -> ZpoolABC: output, _ = Command.on_side(["zfs", "get", "all", "-r", "-H", "-p", root], side, config).run() output = [line.split('\t') for line in output.split('\n') if len(line.strip()) > 0] entities = {name: [] for name in {line[0] for line in output}} - for line in output: - entities[line[0]].append(line[1:]) + for line_list in output: + entities[line_list[0]].append(line_list[1:]) datasets = [ - Dataset.from_lines( + Dataset.from_entities( name, {k: v for k, v in entities.items() if k == name or k.startswith(f'{name:s}@')}, side, From 0d3dfe6cb191b84ddede8f78c336e7a3c34906c0 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 22:02:12 +0200 Subject: [PATCH 058/135] simplification --- src/abgleich/zfs/zpool.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index a0db436..42c37ca 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -33,7 +33,7 @@ from tabulate import tabulate import typeguard -from .abc import DatasetABC, ZpoolABC +from .abc import DatasetABC, SnapshotABC, ZpoolABC from .dataset import Dataset from .lib import join from ..command import Command @@ -64,19 +64,9 @@ def print_table(self): table = [] for dataset in self._datasets: - table.append([ - colorize(dataset.name, "white"), - humanize_size(dataset['used'].value, add_color=True), - humanize_size(dataset['referenced'].value, add_color=True), - f'{dataset["compressratio"].value:.02f}', - ]) + table.append(self._table_row(dataset)) for snapshot in dataset.snapshots: - table.append([ - '- ' + colorize(snapshot.name, "grey"), - humanize_size(snapshot['used'].value, add_color=True), - humanize_size(snapshot['referenced'].value, add_color=True), - f'{snapshot["compressratio"].value:.02f}', - ]) + table.append(self._table_row(snapshot, snapshot = True)) print(tabulate( table, @@ -85,6 +75,15 @@ def print_table(self): colalign=("left", "right", "right", "decimal"), )) + @staticmethod + def _table_row(entity: typing.Union[SnapshotABC, DatasetABC], snapshot: bool = False) -> typing.List[str]: + return [ + '- ' + colorize(entity.name, "grey") if snapshot else colorize(entity.name, "white"), + humanize_size(entity['used'].value, add_color=True), + humanize_size(entity['referenced'].value, add_color=True), + f'{entity["compressratio"].value:.02f}', + ] + @classmethod def from_config( cls, From 8f0dc2faed01a27b8956a4b9273ac34d30d09073 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sat, 11 Jul 2020 22:03:37 +0200 Subject: [PATCH 059/135] updated docu --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f5df65..6c24236 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ Requires (C)Python 3.5 or later. Tested with ZoL 0.7 and 0.8. ## USAGE -### `abgleich tree [hostname]` +### `abgleich tree config.yaml [source|target]` -Show zfs tree with snapshots, disk space and compression ratio. Append `hostname` (optional) for remote tree. `ssh` without password (public key) required. +Show zfs tree with snapshots, disk space and compression ratio. Append `source` or `target` (optional). `ssh` without password (public key) required. ### `abgleich snap config.yaml` From 65f7c4284a883201c20392ec89218efa0d56610e Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 10:56:32 +0200 Subject: [PATCH 060/135] allow direct access to slices --- src/abgleich/zfs/dataset.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index cccbd7d..61a8669 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -58,9 +58,11 @@ def __init__(self, self._side = side self._config = config - def __getitem__(self, name: str) -> PropertyABC: + def __getitem__(self, key: typing.Union[str, int, slice]) -> PropertyABC: - return self._properties[name] + if isinstance(key, str): + return self._properties[key] + return self._snapshots[key] @property def name(self) -> str: From ebf8ed042ba6a0a74244b2e0235df682f45ceefb Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 11:04:07 +0200 Subject: [PATCH 061/135] dataset is aware of name relative to root and can be compared --- src/abgleich/zfs/dataset.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index 61a8669..33079a5 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -33,9 +33,9 @@ import typeguard from .abc import DatasetABC, PropertyABC, SnapshotABC +from .lib import join from .property import Property from .snapshot import Snapshot -from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -58,6 +58,16 @@ def __init__(self, self._side = side self._config = config + root = config[side]['zpool'] + if config[side]['prefix'] is not None: + root = join(root, config[side]['prefix']) + assert self._name.startswith(root) + self._subname = self._name[len(root):] + + def __eq__(self, other: DatasetABC) -> bool: + + return self.subname == other.subname + def __getitem__(self, key: typing.Union[str, int, slice]) -> PropertyABC: if isinstance(key, str): @@ -69,6 +79,11 @@ def name(self) -> str: return self._name + @property + def subname(self) -> str: + + return self._subname + @property def snapshots(self) -> typing.Generator[SnapshotABC, None, None]: From b51c0b1f18335add05f1ab80d7c6d5a1163b1e48 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 11:06:52 +0200 Subject: [PATCH 062/135] snapshot is aware of parent relative to root and can be compared --- src/abgleich/zfs/snapshot.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 12b4202..c598e72 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -33,8 +33,8 @@ import typeguard from .abc import PropertyABC, SnapshotABC +from .lib import join from .property import Property -from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -57,6 +57,16 @@ def __init__(self, self._side = side self._config = config + root = config[side]['zpool'] + if config[side]['prefix'] is not None: + root = join(root, config[side]['prefix']) + assert self._parent.startswith(root) + self._subparent = self._parent[len(root):] + + def __eq__(self, other: SnapshotABC) -> bool: + + return self.subparent == other.subparent + def __getitem__(self, name: str) -> PropertyABC: return self._properties[name] @@ -71,6 +81,11 @@ def parent(self) -> str: return self._parent + @property + def subparent(self) -> str: + + return self._subparent + @classmethod def from_entity( cls, From f13da0be93c804f1affbb427bf4559ee3c7c1f45 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 11:07:35 +0200 Subject: [PATCH 063/135] dataset exposes number of snapshopts --- src/abgleich/zfs/dataset.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index 33079a5..04047f9 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -68,6 +68,10 @@ def __eq__(self, other: DatasetABC) -> bool: return self.subname == other.subname + def __len__(self) -> int: + + return len(self._snapshots) + def __getitem__(self, key: typing.Union[str, int, slice]) -> PropertyABC: if isinstance(key, str): From 62f86bea05c36c01644a7de5068ad8f1ed90ecd2 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 11:15:58 +0200 Subject: [PATCH 064/135] simplification --- src/abgleich/zfs/zpool.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 42c37ca..7d41ccc 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -66,7 +66,7 @@ def print_table(self): for dataset in self._datasets: table.append(self._table_row(dataset)) for snapshot in dataset.snapshots: - table.append(self._table_row(snapshot, snapshot = True)) + table.append(self._table_row(snapshot)) print(tabulate( table, @@ -76,9 +76,9 @@ def print_table(self): )) @staticmethod - def _table_row(entity: typing.Union[SnapshotABC, DatasetABC], snapshot: bool = False) -> typing.List[str]: + def _table_row(entity: typing.Union[SnapshotABC, DatasetABC]) -> typing.List[str]: return [ - '- ' + colorize(entity.name, "grey") if snapshot else colorize(entity.name, "white"), + '- ' + colorize(entity.name, "grey") if isinstance(entity, SnapshotABC) else colorize(entity.name, "white"), humanize_size(entity['used'].value, add_color=True), humanize_size(entity['referenced'].value, add_color=True), f'{entity["compressratio"].value:.02f}', From 859b5c6aa675162e00d044c5abbf66497643b08e Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 12:29:17 +0200 Subject: [PATCH 065/135] new abstract base classes --- src/abgleich/zfs/abc.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/abgleich/zfs/abc.py b/src/abgleich/zfs/abc.py index 7dacecb..15fb19f 100644 --- a/src/abgleich/zfs/abc.py +++ b/src/abgleich/zfs/abc.py @@ -37,6 +37,12 @@ class CloneABC(abc.ABC): pass +class ComparisonABC(abc.ABC): + pass + +class ComparisonItemABC(abc.ABC): + pass + class DatasetABC(abc.ABC): pass From 269e75dc823e3200c480841ce3d405eab0a86796 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 12:29:54 +0200 Subject: [PATCH 066/135] expose sortkey --- src/abgleich/zfs/dataset.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index 04047f9..f72b041 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -93,6 +93,11 @@ def snapshots(self) -> typing.Generator[SnapshotABC, None, None]: return (snapshot for snapshot in self._snapshots) + @property + def sortkey(self) -> str: + + return self._name + @classmethod def from_entities(cls, name: str, From b26608afae00c8d51918f1e5e67f780e9361faf0 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 12:30:05 +0200 Subject: [PATCH 067/135] expose sortkey (2) --- src/abgleich/zfs/snapshot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index c598e72..d83a4bf 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -86,6 +86,11 @@ def subparent(self) -> str: return self._subparent + @property + def sortkey(self) -> int: + + return self._properties['creation'] + @classmethod def from_entity( cls, From 5b1138cf8538041c4c95bfcebfb819c8c7f64702 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 12:30:42 +0200 Subject: [PATCH 068/135] zpools can be compared for eq --- src/abgleich/zfs/zpool.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 7d41ccc..b2175ff 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -55,11 +55,20 @@ def __init__( self._side = side self._config = config + def __eq__(self, other: ZpoolABC) -> bool: + + return self.side == other.side + @property def datasets(self) -> typing.Generator[DatasetABC, None, None]: return (dataset for dataset in self._datasets) + @property + def side(self) -> str: + + return self._side + def print_table(self): table = [] From 9a250f0792f6e204cfa088eff871fcda236a5626 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 12:31:39 +0200 Subject: [PATCH 069/135] comparing zpools and datasets --- src/abgleich/zfs/comparison.py | 165 +++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/abgleich/zfs/comparison.py diff --git a/src/abgleich/zfs/comparison.py b/src/abgleich/zfs/comparison.py new file mode 100644 index 0000000..536274b --- /dev/null +++ b/src/abgleich/zfs/comparison.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/zfs/comparison.py: ZFS comparison + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +import typing + +import typeguard + +from .abc import ComparisonABC, ComparisonItemABC, DatasetABC, SnapshotABC, ZpoolABC + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# TYPING +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +ComparisonParentTypes = typing.Union[ + ZpoolABC, + DatasetABC, + ] +ComparisonMergeTypes = typing.Union[ + typing.Generator[DatasetABC, None, None], + typing.Generator[SnapshotABC, None, None], + ] +ComparisonItemType = typing.Union[ + DatasetABC, + SnapshotABC, + None, + ] +ComparisonStrictItemType = typing.Union[ + DatasetABC, + SnapshotABC, + ] + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASS +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +@typeguard.typechecked +class Comparison(ComparisonABC): + + def __init__( + self, + a: ComparisonParentTypes, + b: ComparisonParentTypes, + merged: typing.List[ComparisonItemABC], + ): + + assert type(a) == type(b) + + self._a, self._b, self._merged = a, b, merged + + @property + def a(self) -> ComparisonParentTypes: + + return self._a + + @property + def b(self) -> ComparisonParentTypes: + + return self._b + + @property + def merged(self) -> typing.Generator[ComparisonItemABC, None, None]: + + return (item for item in self._merged) + + @staticmethod + def _merge_items( + items_a: ComparisonMergeTypes, + items_b: ComparisonMergeTypes, + ) -> typing.List[ComparisonItemABC]: + + items_a = {item.name: item for item in items_a} + items_b = {item.name: item for item in items_b} + + names = list(items_a.keys() | items_b.keys()) + merged = [ + ComparisonItem(items_a.get(name, None), items_b.get(name, None)) + for name in names + ] + merged.sort(key = lambda item: item.get_item().sortkey) + + return merged + + @classmethod + def from_zpools(cls, zpool_a: ZpoolABC, zpool_b: ZpoolABC) -> ComparisonABC: + + assert zpool_a is not zpool_b + assert zpool_a != zpool_b + + cls( + a = zpool_a, + b = zpool_b, + merged = cls._merge_items(zpool_a.datasets, zpool_b.datasets), + ) + + @classmethod + def from_datasets(cls, dataset_a: DatasetABC, dataset_b: DatasetABC) -> ComparisonABC: + + assert dataset_a is not dataset_b + assert dataset_a == dataset_b + + cls( + a = dataset_a, + b = dataset_b, + merged = cls._merge_items(dataset_a.snapshots, dataset_b.snapshots), + ) + + +@typeguard.typechecked +class ComparisonItem(ComparisonItemABC): + + def __init__(self, a: ComparisonItemType, b: ComparisonItemType): + + assert a is not None or b is not None + if a is not None and b is not None: + assert type(a) == type(b) + + self._a, self._b = a, b + + def get_item(self) -> ComparisonStrictItemType: + + if self._a is not None: + return self._a + return self._b + + @property + def complete(self) -> bool: + + return self._a is not None and self._b is not None + + @property + def a(self) -> ComparisonItemType: + + return self._a + + @property + def b(self) -> ComparisonItemType: + + return self._b From 9fbbd2ba7fd1babf368d5f91cf254b06a1b6720c Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 13:09:04 +0200 Subject: [PATCH 070/135] zpool can print comparison --- src/abgleich/zfs/zpool.py | 45 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index b2175ff..d1444bd 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -33,7 +33,8 @@ from tabulate import tabulate import typeguard -from .abc import DatasetABC, SnapshotABC, ZpoolABC +from .abc import ComparisonItemABC, DatasetABC, SnapshotABC, ZpoolABC +from .comparison import Comparison from .dataset import Dataset from .lib import join from ..command import Command @@ -86,6 +87,7 @@ def print_table(self): @staticmethod def _table_row(entity: typing.Union[SnapshotABC, DatasetABC]) -> typing.List[str]: + return [ '- ' + colorize(entity.name, "grey") if isinstance(entity, SnapshotABC) else colorize(entity.name, "white"), humanize_size(entity['used'].value, add_color=True), @@ -93,6 +95,47 @@ def _table_row(entity: typing.Union[SnapshotABC, DatasetABC]) -> typing.List[str f'{entity["compressratio"].value:.02f}', ] + def print_comparison_table(self, other: ZpoolABC): + + zpool_comparison = Comparison.from_zpools(self, other) + table = [] + + for dataset_item in zpool_comparison.merged: + table.append(self._comparison_table_row(dataset_item)) + if dataset_item.complete: + dataset_comparison = Comparison.from_datasets(dataset_item.a, dataset_item.b) + elif dataset_item.a is not None: + dataset_comparison = Comparison.from_datasets(dataset_item.a, None) + else: + dataset_comparison = Comparison.from_datasets(None, dataset_item.b) + for snapshot_item in dataset_comparison.merged: + table.append(self._comparison_table_row(snapshot_item)) + + print(tabulate( + table, + headers=["NAME", self.side, other.side], + tablefmt="github", + )) + + @staticmethod + def _comparison_table_row(entity: ComparisonItemABC) -> typing.List[str]: + + item = entity.get_item() + name = item.name + + if item.a is not None and item.b is not None: + a, b = colorize("X", "green"), colorize("X", "green") + elif item.a is None and item.b is not None: + a, b = "", colorize("X", "blue") + elif item.a is not None and item.b is None: + a, b = colorize("X", "red"), "" + + return [ + '- ' + colorize(name, "grey") if isinstance(item, SnapshotABC) else colorize(name, "white"), + a, + b, + ] + @classmethod def from_config( cls, From 4299b1ef8f236a9616276defc1ffe2c8d26dfc4e Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 13:09:33 +0200 Subject: [PATCH 071/135] comparison can be half-empty --- src/abgleich/zfs/comparison.py | 49 ++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/comparison.py b/src/abgleich/zfs/comparison.py index 536274b..23a5792 100644 --- a/src/abgleich/zfs/comparison.py +++ b/src/abgleich/zfs/comparison.py @@ -41,6 +41,7 @@ ComparisonParentTypes = typing.Union[ ZpoolABC, DatasetABC, + None, ] ComparisonMergeTypes = typing.Union[ typing.Generator[DatasetABC, None, None], @@ -107,8 +108,36 @@ def _merge_items( return merged + @staticmethod + def _single_items( + items_a: typing.Union[ComparisonMergeTypes, None], + items_b: typing.Union[ComparisonMergeTypes, None], + ) -> typing.List[ComparisonItemABC]: + + assert items_a is not None or items_b is not None + + if items_a is None: + return [ComparisonItem(None, item) for item in items_b] + return [ComparisonItem(item, None) for item in items_a] + @classmethod - def from_zpools(cls, zpool_a: ZpoolABC, zpool_b: ZpoolABC) -> ComparisonABC: + def from_zpools( + cls, + zpool_a: typing.Union[ZpoolABC, None], + zpool_b: typing.Union[ZpoolABC, None], + ) -> ComparisonABC: + + assert zpool_a is not None or zpool_b is not None + + if zpool_a is None or zpool_b is None: + return cls( + a = zpool_a, + b = zpool_b, + merged = cls._single_items( + getattr(zpool_a, 'datasets', None), + getattr(zpool_b, 'datasets', None), + ), + ) assert zpool_a is not zpool_b assert zpool_a != zpool_b @@ -120,7 +149,23 @@ def from_zpools(cls, zpool_a: ZpoolABC, zpool_b: ZpoolABC) -> ComparisonABC: ) @classmethod - def from_datasets(cls, dataset_a: DatasetABC, dataset_b: DatasetABC) -> ComparisonABC: + def from_datasets( + cls, + dataset_a: typing.Union[DatasetABC, None], + dataset_b: typing.Union[DatasetABC, None], + ) -> ComparisonABC: + + assert dataset_a is not None or dataset_b is not None + + if dataset_a is None or dataset_b is None: + return cls( + a = dataset_a, + b = dataset_b, + merged = cls._single_items( + getattr(dataset_a, 'snapshots', None), + getattr(dataset_b, 'snapshots', None), + ), + ) assert dataset_a is not dataset_b assert dataset_a == dataset_b From 0a0fd678d166ef369eb675475a9df737364b6e6f Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 13:09:51 +0200 Subject: [PATCH 072/135] compare cli uses new api --- src/abgleich/cli/compare.py | 44 ++++++++----------------------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/src/abgleich/cli/compare.py b/src/abgleich/cli/compare.py index 2bc141c..493f5c4 100644 --- a/src/abgleich/cli/compare.py +++ b/src/abgleich/cli/compare.py @@ -6,9 +6,9 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/cli/compare.py: compare command entry point + src/abgleich/cli/compare.py: compare command entry point - Copyright (C) 2019-2020 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License @@ -30,12 +30,9 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ import click -from tabulate import tabulate -import yaml -from yaml import CLoader -from ..io import colorize -from ..zfs import compare_trees, get_tree +from ..config import Config +from ..zfs.zpool import Zpool # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES @@ -45,31 +42,8 @@ @click.command(short_help="compare dataset trees") @click.argument("configfile", type=click.File("r", encoding="utf-8")) def compare(configfile): - config = yaml.load(configfile.read(), Loader=CLoader) - datasets_local = get_tree() - datasets_remote = get_tree(config["host"]) - diff = compare_trees( - datasets_local, config["prefix_local"], datasets_remote, config["prefix_remote"] - ) - table = [] - for element in diff: - element = ["" if item == False else item for item in element] - element = ["X" if item == True else item for item in element] - element = [ - "- " + item.split("@")[1] if "@" in item else item for item in element - ] - if element[1:] == ["X", ""]: - element[1] = colorize(element[1], "red") - elif element[1:] == ["X", "X"]: - element[1], element[2] = ( - colorize(element[1], "green"), - colorize(element[2], "green"), - ) - elif element[1:] == ["", "X"]: - element[2] = colorize(element[2], "blue") - if not element[0].startswith("- "): - element[0] = colorize(element[0], "white") - else: - element[0] = colorize(element[0], "grey") - table.append(element) - print(tabulate(table, headers=["NAME", "LOCAL", "REMOTE"], tablefmt="github")) + + source_zpool = Zpool.from_config('source', config = Config.from_fd(configfile)) + target_zpool = Zpool.from_config('target', config = Config.from_fd(configfile)) + + source_zpool.print_comparison_table(target_zpool) From 1defa615c30235a13ee4b3205dc4683493921cf3 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 13:30:43 +0200 Subject: [PATCH 073/135] read config only once --- src/abgleich/cli/compare.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/abgleich/cli/compare.py b/src/abgleich/cli/compare.py index 493f5c4..1249caf 100644 --- a/src/abgleich/cli/compare.py +++ b/src/abgleich/cli/compare.py @@ -43,7 +43,9 @@ @click.argument("configfile", type=click.File("r", encoding="utf-8")) def compare(configfile): - source_zpool = Zpool.from_config('source', config = Config.from_fd(configfile)) - target_zpool = Zpool.from_config('target', config = Config.from_fd(configfile)) + config = Config.from_fd(configfile) + + source_zpool = Zpool.from_config('source', config = config) + target_zpool = Zpool.from_config('target', config = config) source_zpool.print_comparison_table(target_zpool) From c76c0786014d98f0e4b57715f73652554405f83c Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 13:31:28 +0200 Subject: [PATCH 074/135] missing returns, attrs, type check --- src/abgleich/zfs/comparison.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/abgleich/zfs/comparison.py b/src/abgleich/zfs/comparison.py index 23a5792..22009dd 100644 --- a/src/abgleich/zfs/comparison.py +++ b/src/abgleich/zfs/comparison.py @@ -71,7 +71,9 @@ def __init__( merged: typing.List[ComparisonItemABC], ): - assert type(a) == type(b) + assert a is not None or b is not None + if a is not None and b is not None: + assert type(a) == type(b) self._a, self._b, self._merged = a, b, merged @@ -94,10 +96,11 @@ def merged(self) -> typing.Generator[ComparisonItemABC, None, None]: def _merge_items( items_a: ComparisonMergeTypes, items_b: ComparisonMergeTypes, + attr: str, ) -> typing.List[ComparisonItemABC]: - items_a = {item.name: item for item in items_a} - items_b = {item.name: item for item in items_b} + items_a = {getattr(item, attr): item for item in items_a} + items_b = {getattr(item, attr): item for item in items_b} names = list(items_a.keys() | items_b.keys()) merged = [ @@ -142,10 +145,10 @@ def from_zpools( assert zpool_a is not zpool_b assert zpool_a != zpool_b - cls( + return cls( a = zpool_a, b = zpool_b, - merged = cls._merge_items(zpool_a.datasets, zpool_b.datasets), + merged = cls._merge_items(zpool_a.datasets, zpool_b.datasets, 'subname'), ) @classmethod @@ -170,10 +173,10 @@ def from_datasets( assert dataset_a is not dataset_b assert dataset_a == dataset_b - cls( + return cls( a = dataset_a, b = dataset_b, - merged = cls._merge_items(dataset_a.snapshots, dataset_b.snapshots), + merged = cls._merge_items(dataset_a.snapshots, dataset_b.snapshots, 'name'), ) From 4266fb7debd7f9b7375d0ef367d514136fbea27b Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 13:31:52 +0200 Subject: [PATCH 075/135] clean subname --- src/abgleich/zfs/dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index f72b041..9265d1c 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -62,7 +62,7 @@ def __init__(self, if config[side]['prefix'] is not None: root = join(root, config[side]['prefix']) assert self._name.startswith(root) - self._subname = self._name[len(root):] + self._subname = self._name[len(root):].strip('/') def __eq__(self, other: DatasetABC) -> bool: From 37c04b3859627f67b7399f8bcbfc5b2aec59ff25 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 13:32:19 +0200 Subject: [PATCH 076/135] fixed sortkey, cleaned subparent --- src/abgleich/zfs/snapshot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index d83a4bf..6f02fa7 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -61,7 +61,7 @@ def __init__(self, if config[side]['prefix'] is not None: root = join(root, config[side]['prefix']) assert self._parent.startswith(root) - self._subparent = self._parent[len(root):] + self._subparent = self._parent[len(root):].strip('/') def __eq__(self, other: SnapshotABC) -> bool: @@ -89,7 +89,7 @@ def subparent(self) -> str: @property def sortkey(self) -> int: - return self._properties['creation'] + return self._properties['creation'].value @classmethod def from_entity( From cd42def3ae7bffc8a1aee572e542347d63f0c0ad Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 13:32:34 +0200 Subject: [PATCH 077/135] name fixes --- src/abgleich/zfs/zpool.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index d1444bd..a642abf 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -118,10 +118,10 @@ def print_comparison_table(self, other: ZpoolABC): )) @staticmethod - def _comparison_table_row(entity: ComparisonItemABC) -> typing.List[str]: + def _comparison_table_row(item: ComparisonItemABC) -> typing.List[str]: - item = entity.get_item() - name = item.name + entity = item.get_item() + name = entity.name if isinstance(entity, SnapshotABC) else entity.subname if item.a is not None and item.b is not None: a, b = colorize("X", "green"), colorize("X", "green") @@ -131,7 +131,7 @@ def _comparison_table_row(entity: ComparisonItemABC) -> typing.List[str]: a, b = colorize("X", "red"), "" return [ - '- ' + colorize(name, "grey") if isinstance(item, SnapshotABC) else colorize(name, "white"), + '- ' + colorize(name, "grey") if isinstance(entity, SnapshotABC) else colorize(name, "white"), a, b, ] From 34db2fbe473247c7ba1a4402c6ec2087c330c0d8 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 13:42:05 +0200 Subject: [PATCH 078/135] started zfs transactions --- src/abgleich/zfs/abc.py | 3 +++ src/abgleich/zfs/transaction.py | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/abgleich/zfs/transaction.py diff --git a/src/abgleich/zfs/abc.py b/src/abgleich/zfs/abc.py index 15fb19f..ac78deb 100644 --- a/src/abgleich/zfs/abc.py +++ b/src/abgleich/zfs/abc.py @@ -52,5 +52,8 @@ class PropertyABC(abc.ABC): class SnapshotABC(abc.ABC): pass +class TransactionABC(abc.ABC): + pass + class ZpoolABC(abc.ABC): pass diff --git a/src/abgleich/zfs/transaction.py b/src/abgleich/zfs/transaction.py new file mode 100644 index 0000000..61b2973 --- /dev/null +++ b/src/abgleich/zfs/transaction.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +""" + +ABGLEICH +zfs sync tool +https://github.com/pleiszenburg/abgleich + + src/abgleich/zfs/transaction.py: ZFS transactions + + Copyright (C) 2019-2020 Sebastian M. Ernst + + +The contents of this file are subject to the GNU Lesser General Public License +Version 2.1 ("LGPL" or "License"). You may not use this file except in +compliance with the License. You may obtain a copy of the License at +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +https://github.com/pleiszenburg/abgleich/blob/master/LICENSE + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + + +""" + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# IMPORT +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +# import typing + +import typeguard + +from .abc import TransactionABC + +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# CLASS +# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +@typeguard.typechecked +class Transaction(TransactionABC): + + pass From dd5f89ef6fbe948dc6e8d11ea8f76fa909d54a3b Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 14:00:27 +0200 Subject: [PATCH 079/135] basic transaction --- src/abgleich/zfs/transaction.py | 59 +++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/transaction.py b/src/abgleich/zfs/transaction.py index 61b2973..1c2f41f 100644 --- a/src/abgleich/zfs/transaction.py +++ b/src/abgleich/zfs/transaction.py @@ -28,11 +28,12 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# import typing +import typing import typeguard from .abc import TransactionABC +from ..abc import CommandABC # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -41,4 +42,58 @@ @typeguard.typechecked class Transaction(TransactionABC): - pass + def __init__( + self, + description: str, + commands: typing.Tuple[CommandABC], + ): + + assert len(commands) in (1, 2) + + self._description, self._commands = description, commands + + self._complete = False + self._running = False + self._error = None + + @property + def complete(self) -> bool: + + return self._complete + + @property + def commands(self) -> typing.Tuple[CommandABC]: + + return self._commands + + @property + def description(self) -> str: + + return self._description + + @property + def error(self) -> typing.Union[Exception, None]: + + return self._error + + @property + def running(self) -> bool: + + return self._running + + def run(self): + + if self._complete: + return + self._running = True + + try: + if len(self._commands) == 1: + output, errors = self._commands[0].run() + else: + output_1, errors_1, output_2, errors_2 = self._commands[0].run_pipe(self._commands[1]) + except SystemError as error: + self._error = error + finally: + self._running = False + self._complete = True From 260ff04d8bc5f27d147ad6868cf1803ef6e86c41 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 14:39:27 +0200 Subject: [PATCH 080/135] new abc --- src/abgleich/zfs/abc.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/abgleich/zfs/abc.py b/src/abgleich/zfs/abc.py index ac78deb..467e5db 100644 --- a/src/abgleich/zfs/abc.py +++ b/src/abgleich/zfs/abc.py @@ -55,5 +55,8 @@ class SnapshotABC(abc.ABC): class TransactionABC(abc.ABC): pass +class TransactionListABC(abc.ABC): + pass + class ZpoolABC(abc.ABC): pass From ab74bfd38d1a279946b4dc6253285bda127b3107 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 14:39:49 +0200 Subject: [PATCH 081/135] list of transactions --- src/abgleich/zfs/transaction.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/transaction.py b/src/abgleich/zfs/transaction.py index 1c2f41f..7141a1b 100644 --- a/src/abgleich/zfs/transaction.py +++ b/src/abgleich/zfs/transaction.py @@ -32,7 +32,7 @@ import typeguard -from .abc import TransactionABC +from .abc import TransactionABC, TransactionListABC from ..abc import CommandABC # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -97,3 +97,14 @@ def run(self): finally: self._running = False self._complete = True + +@typeguard.typechecked +class TransactionList(TransactionListABC): + + def __init__(self): + + self._transactions = [] + + def append(self, transaction: TransactionABC): + + self._transactions.append(transaction) From ff1e964deb26d18095c3e8bb5060209ed7f13ce1 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 14:40:49 +0200 Subject: [PATCH 082/135] zpool prepares list of transactions --- src/abgleich/zfs/zpool.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index a642abf..b9039c2 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -33,10 +33,11 @@ from tabulate import tabulate import typeguard -from .abc import ComparisonItemABC, DatasetABC, SnapshotABC, ZpoolABC +from .abc import ComparisonItemABC, DatasetABC, SnapshotABC, TransactionListABC, ZpoolABC from .comparison import Comparison from .dataset import Dataset from .lib import join +from .transaction import TransactionList from ..command import Command from ..io import colorize, humanize_size @@ -70,6 +71,21 @@ def side(self) -> str: return self._side + def get_snapshot_transactions(self) -> TransactionListABC: + + assert self._side == 'source' + + transactions = TransactionList() + for dataset in self._datasets: + if dataset.subname in self._config['ignore']: + continue + if dataset['mountpoint'].value is None: + continue + if dataset.changed: + transactions.append(dataset.get_snapshot_transaction()) + + return transactions + def print_table(self): table = [] From e67e3adef05e470ab33cf63891fda324ff9e6b3b Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 15:17:41 +0200 Subject: [PATCH 083/135] new abc --- src/abgleich/zfs/abc.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/abgleich/zfs/abc.py b/src/abgleich/zfs/abc.py index 467e5db..227ca28 100644 --- a/src/abgleich/zfs/abc.py +++ b/src/abgleich/zfs/abc.py @@ -58,5 +58,8 @@ class TransactionABC(abc.ABC): class TransactionListABC(abc.ABC): pass +class TransactionMetaABC(abc.ABC): + pass + class ZpoolABC(abc.ABC): pass From 1caaa41b4ce04b4bcb376ecad916ebcf1c198f66 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 15:18:32 +0200 Subject: [PATCH 084/135] transaction meta type --- src/abgleich/zfs/transaction.py | 39 +++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/abgleich/zfs/transaction.py b/src/abgleich/zfs/transaction.py index 7141a1b..eceac54 100644 --- a/src/abgleich/zfs/transaction.py +++ b/src/abgleich/zfs/transaction.py @@ -32,7 +32,7 @@ import typeguard -from .abc import TransactionABC, TransactionListABC +from .abc import TransactionABC, TransactionListABC, TransactionMetaABC from ..abc import CommandABC # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -44,13 +44,13 @@ class Transaction(TransactionABC): def __init__( self, - description: str, + meta: TransactionMetaABC, commands: typing.Tuple[CommandABC], ): assert len(commands) in (1, 2) - self._description, self._commands = description, commands + self._meta, self._commands = meta, commands self._complete = False self._running = False @@ -67,14 +67,14 @@ def commands(self) -> typing.Tuple[CommandABC]: return self._commands @property - def description(self) -> str: + def error(self) -> typing.Union[Exception, None]: - return self._description + return self._error @property - def error(self) -> typing.Union[Exception, None]: + def meta(self) -> TransactionMetaABC: - return self._error + return self._meta @property def running(self) -> bool: @@ -98,6 +98,27 @@ def run(self): self._running = False self._complete = True +MetaTypes = typing.Union[str, int, float] + +@typeguard.typechecked +class TransactionMeta(TransactionMetaABC): + + def __init__(self, **kwargs: MetaTypes): + + self._meta = kwargs + + def __getitem__(self, key: str) -> MetaTypes: + + return self._meta[key] + + def __len__(self) -> int: + + return len(self._meta) + + def keys(self) -> typing.Generator[str, None, None]: + + return (key for key in self._meta.keys()) + @typeguard.typechecked class TransactionList(TransactionListABC): @@ -105,6 +126,10 @@ def __init__(self): self._transactions = [] + def __len__(self) -> int: + + return len(self._transactions) + def append(self, transaction: TransactionABC): self._transactions.append(transaction) From 501a464abc72558a16b596941a873a094d899e55 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 15:20:03 +0200 Subject: [PATCH 085/135] was dataset changed; prepare snapshot transactions --- src/abgleich/zfs/dataset.py | 71 ++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index 9265d1c..23b8dc5 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -28,14 +28,17 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +import datetime import typing import typeguard -from .abc import DatasetABC, PropertyABC, SnapshotABC +from .abc import DatasetABC, PropertyABC, TransactionABC, SnapshotABC from .lib import join from .property import Property +from .transaction import Transaction, TransactionMeta from .snapshot import Snapshot +from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -78,6 +81,24 @@ def __getitem__(self, key: typing.Union[str, int, slice]) -> PropertyABC: return self._properties[key] return self._snapshots[key] + @property + def changed(self) -> bool: + + if len(self) == 0: + return True + if self._properties['written'].value == 0: + return False + if self._properties['written'].value > (1024 ** 2): + return True + if self._properties['type'].value == 'volume': + return True + + output, _ = Command.on_side( + ['zfs', 'diff', f'{self._name:s}@{self._snapshots[-1].name:s}'], + self._side, self._config, + ).run() + return len(output.strip(" \t\n")) > 0 + @property def name(self) -> str: @@ -98,6 +119,54 @@ def sortkey(self) -> str: return self._name + def get_snapshot_transaction(self) -> TransactionABC: + + snapshot_name = self._new_snapshot_name() + + return Transaction( + TransactionMeta( + type = 'snapshot', + dataset_subname = self._subname, + snapshot_name = snapshot_name, + written = self._properties['written'].value, + ), + (Command.on_side( + ['zfs', 'snapshot', f'{self._name:s}@{snapshot_name:s}'], + self._side, self._config, + ),) + ) + + def _new_snapshot_name(self) -> str: + + suffix = "_backup" + digits = 2 + + today = datetime.datetime.now().strftime("%Y%m%d") + max_snapshots = (10 ** digits) - 1 + + todays_names = [ + snapshot.name for snapshot in self._snapshots + if all(( + snapshot.name.startswith(today), + snapshot.name.endswith(suffix), + len(snapshot.name) == len(today) + digits + len(suffix), + )) + ] + todays_numbers = [ + int(name[len(today):len(today)+digits]) + for name in todays_names + if name[len(today):len(today)+digits].isnumeric() + ] + if len(todays_numbers) != 0: + todays_numbers.sort() + new_number = todays_numbers[-1] + 1 + if new_number > max_snapshots: + raise ValueError(f"more than {max_snapshots:d} snapshots per day") + else: + new_number = 1 + + return f'{today:s}{new_number:02d}{suffix}' + @classmethod def from_entities(cls, name: str, From ce2a2dc31b8e31fb0ebd2beb04f81c9751fc96fc Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 21:44:07 +0200 Subject: [PATCH 086/135] check for valid dataset and snapshot names --- src/abgleich/zfs/lib.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/abgleich/zfs/lib.py b/src/abgleich/zfs/lib.py index 11475ef..086500e 100644 --- a/src/abgleich/zfs/lib.py +++ b/src/abgleich/zfs/lib.py @@ -28,6 +28,8 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +import re + import typeguard # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -46,3 +48,14 @@ def join(*args: str) -> str: raise ValueError('can not join empty path elements') return '/'.join(args) + +_name_re = re.compile('^[A-Za-z0-9_]+$') + +@typeguard.typechecked +def valid_name(name: str, min_len: int = 1) -> bool: + + assert min_len >= 0 + + if len(name) < min_len: + return False + return bool(_name_re.match(name)) From 47781e7b1ad33ba1ec781798b5e4f52f255719ff Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 21:45:07 +0200 Subject: [PATCH 087/135] check for valid suffix --- src/abgleich/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/abgleich/config.py b/src/abgleich/config.py index b31615e..4c2bdfa 100644 --- a/src/abgleich/config.py +++ b/src/abgleich/config.py @@ -35,6 +35,8 @@ import yaml from yaml import CLoader +from .zfs.lib import valid_name + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -62,6 +64,8 @@ def from_fd(cls, fd: typing.TextIO): "source": lambda v: cls._validate(data = v, schema = side_schema), "target": lambda v: cls._validate(data = v, schema = side_schema), "keep_snapshots": lambda v: isinstance(v, int) and v >= 1, + "suffix": lambda v: v is None or (isinstance(v, str) and valid_name(v)), + "digits": lambda v: isinstance(v, int) and v >= 1, "ignore": lambda v: isinstance(v, list) and all((isinstance(item, str) and len(item) > 0 for item in v)), "ssh": lambda v: cls._validate(data = v, schema = ssh_schema), From d55a267bee051be24bf19fb29ad1764f8a83cf7f Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 21:45:41 +0200 Subject: [PATCH 088/135] digits and suffix for snapshots can be configured --- src/abgleich/zfs/dataset.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index 23b8dc5..275bbaf 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -138,24 +138,22 @@ def get_snapshot_transaction(self) -> TransactionABC: def _new_snapshot_name(self) -> str: - suffix = "_backup" - digits = 2 - today = datetime.datetime.now().strftime("%Y%m%d") - max_snapshots = (10 ** digits) - 1 + max_snapshots = (10 ** self._config['digits']) - 1 + suffix = self._config['suffix'] if self._config['suffix'] is not None else '' todays_names = [ snapshot.name for snapshot in self._snapshots if all(( snapshot.name.startswith(today), snapshot.name.endswith(suffix), - len(snapshot.name) == len(today) + digits + len(suffix), + len(snapshot.name) == len(today) + self._config['digits'] + len(suffix), )) ] todays_numbers = [ - int(name[len(today):len(today)+digits]) + int(name[len(today):len(today)+self._config['digits']]) for name in todays_names - if name[len(today):len(today)+digits].isnumeric() + if name[len(today):len(today)+self._config['digits']].isnumeric() ] if len(todays_numbers) != 0: todays_numbers.sort() From 60062538a3b439374f1ae4ad470b1ccfef0f460e Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Sun, 12 Jul 2020 21:49:33 +0200 Subject: [PATCH 089/135] prepare new snap cli --- src/abgleich/cli/snap.py | 59 ++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/src/abgleich/cli/snap.py b/src/abgleich/cli/snap.py index e8ba8ca..932568e 100644 --- a/src/abgleich/cli/snap.py +++ b/src/abgleich/cli/snap.py @@ -30,16 +30,9 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ import click -from tabulate import tabulate -import yaml -from yaml import CLoader -from ..io import colorize, humanize_size -from ..zfs import ( - create_snapshot, - get_tree, - get_snapshot_tasks, -) +from ..config import Config +from ..zfs.zpool import Zpool # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES @@ -50,26 +43,28 @@ @click.argument("configfile", type=click.File("r", encoding="utf-8")) def snap(configfile): - config = yaml.load(configfile.read(), Loader=CLoader) - - cols = ["NAME", "written", "FUTURE SNAPSHOT"] - col_align = ("left", "right") - datasets = get_tree() - snapshot_tasks = get_snapshot_tasks( - datasets, config["prefix_local"], config["ignore"] - ) - - table = [] - for name, written, snapshot_name in snapshot_tasks: - table.append([name, humanize_size(written, add_color=True), snapshot_name]) - - print(tabulate(table, headers=cols, tablefmt="github", colalign=col_align)) - - click.confirm("Do you want to continue?", abort=True) - - for name, _, snapshot_name in snapshot_tasks: - create_snapshot( - config["prefix_local"] + name, - snapshot_name, - # debug = True - ) + zpool = Zpool.from_config('source', config = Config.from_fd(configfile)) + + # config = yaml.load(configfile.read(), Loader=CLoader) + # + # cols = ["NAME", "written", "FUTURE SNAPSHOT"] + # col_align = ("left", "right") + # datasets = get_tree() + # snapshot_tasks = get_snapshot_tasks( + # datasets, config["prefix_local"], config["ignore"] + # ) + # + # table = [] + # for name, written, snapshot_name in snapshot_tasks: + # table.append([name, humanize_size(written, add_color=True), snapshot_name]) + # + # print(tabulate(table, headers=cols, tablefmt="github", colalign=col_align)) + # + # click.confirm("Do you want to continue?", abort=True) + # + # for name, _, snapshot_name in snapshot_tasks: + # create_snapshot( + # config["prefix_local"] + name, + # snapshot_name, + # # debug = True + # ) From 20bcd2103bc450e2bb4e01f96a4b942a83c3e38c Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Mon, 13 Jul 2020 08:17:06 +0200 Subject: [PATCH 090/135] snap runs on new api --- src/abgleich/cli/snap.py | 32 +++++++------------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/src/abgleich/cli/snap.py b/src/abgleich/cli/snap.py index 932568e..f819885 100644 --- a/src/abgleich/cli/snap.py +++ b/src/abgleich/cli/snap.py @@ -6,9 +6,9 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/cli/snap.py: snap command entry point + src/abgleich/cli/snap.py: snap command entry point - Copyright (C) 2019-2020 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License @@ -44,27 +44,9 @@ def snap(configfile): zpool = Zpool.from_config('source', config = Config.from_fd(configfile)) + transactions = zpool.get_snapshot_transactions() + transactions.print_table() - # config = yaml.load(configfile.read(), Loader=CLoader) - # - # cols = ["NAME", "written", "FUTURE SNAPSHOT"] - # col_align = ("left", "right") - # datasets = get_tree() - # snapshot_tasks = get_snapshot_tasks( - # datasets, config["prefix_local"], config["ignore"] - # ) - # - # table = [] - # for name, written, snapshot_name in snapshot_tasks: - # table.append([name, humanize_size(written, add_color=True), snapshot_name]) - # - # print(tabulate(table, headers=cols, tablefmt="github", colalign=col_align)) - # - # click.confirm("Do you want to continue?", abort=True) - # - # for name, _, snapshot_name in snapshot_tasks: - # create_snapshot( - # config["prefix_local"] + name, - # snapshot_name, - # # debug = True - # ) + click.confirm("Do you want to continue?", abort=True) + + transactions.run() From 465659261e6a8398c933ed7b9e8efec5fab64d28 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Mon, 13 Jul 2020 08:51:50 +0200 Subject: [PATCH 091/135] print list of transactions --- src/abgleich/zfs/transaction.py | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/abgleich/zfs/transaction.py b/src/abgleich/zfs/transaction.py index eceac54..3c5ef17 100644 --- a/src/abgleich/zfs/transaction.py +++ b/src/abgleich/zfs/transaction.py @@ -30,10 +30,12 @@ import typing +from tabulate import tabulate import typeguard from .abc import TransactionABC, TransactionListABC, TransactionMetaABC from ..abc import CommandABC +from ..io import humanize_size # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -99,6 +101,7 @@ def run(self): self._complete = True MetaTypes = typing.Union[str, int, float] +MetaNoneTypes = typing.Union[str, int, float, None] @typeguard.typechecked class TransactionMeta(TransactionMetaABC): @@ -115,6 +118,10 @@ def __len__(self) -> int: return len(self._meta) + def get(self, key: str) -> MetaNoneTypes: + + return self._meta.get(key, None) + def keys(self) -> typing.Generator[str, None, None]: return (key for key in self._meta.keys()) @@ -133,3 +140,69 @@ def __len__(self) -> int: def append(self, transaction: TransactionABC): self._transactions.append(transaction) + + def print_table(self): + + headers = self._table_headers() + colalign = self._table_colalign(headers) + + table = [ + [ + self._table_format_cell(header, transaction.meta.get(header)) + for header in headers + ] + for transaction in self._transactions + ] + + print(tabulate( + table, + headers=headers, + tablefmt="github", + colalign=colalign, + )) + + @staticmethod + def _table_format_cell(header: str, value: MetaNoneTypes) -> str: + + FORMAT = { + 'written': lambda v: humanize_size(v, add_color = True), + } + + return FORMAT.get(header, str)(value) + + @staticmethod + def _table_colalign(headers: typing.List[str]) -> typing.List[str]: + + RIGHT = ('written',) + DECIMAL = tuple() + + colalign = [] + for header in headers: + if header in RIGHT: + colalign.append('right') + elif header in DECIMAL: + colalign.append('decimal') + else: + colalign.append('left') + + return colalign + + def _table_headers(self) -> typing.List[str]: + + headers = set() + for transaction in self._transactions: + keys = list(transaction.meta.keys()) + assert 'type' in keys + headers.update(keys) + headers = list(headers) + headers.sort() + + type_index = headers.index('type') + if type_index != 0: + headers[0], headers[type_index] = headers[type_index], headers[0] + + return headers + + def run(self): + + pass From 2f73f36f3838888e6646315069b4872d382cce86 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Mon, 13 Jul 2020 09:08:37 +0200 Subject: [PATCH 092/135] convert commands to strings --- src/abgleich/command.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/abgleich/command.py b/src/abgleich/command.py index f04cebe..a34af04 100644 --- a/src/abgleich/command.py +++ b/src/abgleich/command.py @@ -46,6 +46,10 @@ def __init__(self, cmd: typing.List[str]): self._cmd = cmd.copy() + def __str__(self) -> str: + + return ' '.join(self._cmd) + def run(self): proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) From 5757a19e9a764a7a3169a1c4b701dcc3eb223367 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Mon, 13 Jul 2020 09:08:57 +0200 Subject: [PATCH 093/135] sometimes there is nothing to do --- src/abgleich/cli/snap.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/abgleich/cli/snap.py b/src/abgleich/cli/snap.py index f819885..ec597d6 100644 --- a/src/abgleich/cli/snap.py +++ b/src/abgleich/cli/snap.py @@ -45,6 +45,10 @@ def snap(configfile): zpool = Zpool.from_config('source', config = Config.from_fd(configfile)) transactions = zpool.get_snapshot_transactions() + + if len(transactions): + print('nothing to do') + return transactions.print_table() click.confirm("Do you want to continue?", abort=True) From 746c4e7fab7e7664287e548c2b3e40ce1905099e Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Mon, 13 Jul 2020 09:09:27 +0200 Subject: [PATCH 094/135] transactions are running --- src/abgleich/zfs/transaction.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/transaction.py b/src/abgleich/zfs/transaction.py index 3c5ef17..68edfe0 100644 --- a/src/abgleich/zfs/transaction.py +++ b/src/abgleich/zfs/transaction.py @@ -35,7 +35,7 @@ from .abc import TransactionABC, TransactionListABC, TransactionMetaABC from ..abc import CommandABC -from ..io import humanize_size +from ..io import colorize, humanize_size # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -143,6 +143,9 @@ def append(self, transaction: TransactionABC): def print_table(self): + if len(self) == 0: + return + headers = self._table_headers() colalign = self._table_colalign(headers) @@ -205,4 +208,23 @@ def _table_headers(self) -> typing.List[str]: def run(self): - pass + for transaction in self._transactions: + + print( + f'({colorize(transaction.meta["type"], "white"):s}) ' + f'{colorize(" | ".join([str(command) for command in transaction.commands]), "yellow"):s}' + ) + + assert not transaction.running + assert not transaction.complete + + transaction.run() + + assert not transaction.running + assert transaction.complete + + if transaction.error is not None: + print(colorize('FAILED', 'red')) + raise transaction.error + else: + print(colorize('OK', 'green')) From a084242efdd32f39d1789e08c3e800ad0dde5788 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Mon, 13 Jul 2020 09:11:00 +0200 Subject: [PATCH 095/135] fix check --- src/abgleich/cli/snap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/abgleich/cli/snap.py b/src/abgleich/cli/snap.py index ec597d6..c22ed2d 100644 --- a/src/abgleich/cli/snap.py +++ b/src/abgleich/cli/snap.py @@ -46,7 +46,7 @@ def snap(configfile): zpool = Zpool.from_config('source', config = Config.from_fd(configfile)) transactions = zpool.get_snapshot_transactions() - if len(transactions): + if len(transactions) == 0: print('nothing to do') return transactions.print_table() From d69055a772f670f197bcd4488409f13d43f12232 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 09:34:03 +0200 Subject: [PATCH 096/135] backup cli runs on new api --- src/abgleich/cli/backup.py | 63 +++++++++----------------------------- 1 file changed, 14 insertions(+), 49 deletions(-) diff --git a/src/abgleich/cli/backup.py b/src/abgleich/cli/backup.py index 3a9209b..bc1fbc6 100644 --- a/src/abgleich/cli/backup.py +++ b/src/abgleich/cli/backup.py @@ -6,9 +6,9 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/cli/backup.py: backup command entry point + src/abgleich/cli/backup.py: backup command entry point - Copyright (C) 2019-2020 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License @@ -30,17 +30,9 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ import click -from tabulate import tabulate -import yaml -from yaml import CLoader - -from ..io import colorize -from ..zfs import ( - get_backup_ops, - get_tree, - push_snapshot, - push_snapshot_incremental, -) + +from ..config import Config +from ..zfs.zpool import Zpool # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES @@ -51,45 +43,18 @@ @click.argument("configfile", type=click.File("r", encoding="utf-8")) def backup(configfile): - config = yaml.load(configfile.read(), Loader=CLoader) + config = Config.from_fd(configfile) - datasets_local = get_tree() - datasets_remote = get_tree(config["host"]) - ops = get_backup_ops( - datasets_local, - config["prefix_local"], - datasets_remote, - config["prefix_remote"], - config["ignore"], - ) + source_zpool = Zpool.from_config('source', config = config) + target_zpool = Zpool.from_config('target', config = config) - table = [] - for op in ops: - row = op.copy() - row[0] = colorize(row[0], "green" if "incremental" in row[0] else "blue") - table.append(row) + transactions = source_zpool.get_backup_transactions(target_zpool) - print(tabulate(table, headers=["OP", "PARAM"], tablefmt="github")) + if len(transactions) == 0: + print('nothing to do') + return + transactions.print_table() click.confirm("Do you want to continue?", abort=True) - for op, param in ops: - if op == "push_snapshot": - push_snapshot( - config["host"], - config["prefix_local"] + param[0], - param[1], - config["prefix_remote"] + param[0], - # debug = True - ) - elif op == "push_snapshot_incremental": - push_snapshot_incremental( - config["host"], - config["prefix_local"] + param[0], - param[1], - param[2], - config["prefix_remote"] + param[0], - # debug = True - ) - else: - raise ValueError("unknown operation") + transactions.run() From e67299a8409d5522b2faa7a3eba7744c9cb3f4d0 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 09:34:53 +0200 Subject: [PATCH 097/135] new root method in path handling --- src/abgleich/zfs/lib.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/abgleich/zfs/lib.py b/src/abgleich/zfs/lib.py index 086500e..901b2cf 100644 --- a/src/abgleich/zfs/lib.py +++ b/src/abgleich/zfs/lib.py @@ -29,6 +29,7 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ import re +import typing import typeguard @@ -49,6 +50,13 @@ def join(*args: str) -> str: return '/'.join(args) +@typeguard.typechecked +def root(zpool: str, prefix: typing.Union[str, None]) -> str: + + if prefix is None: + return zpool + return join(zpool, prefix) + _name_re = re.compile('^[A-Za-z0-9_]+$') @typeguard.typechecked From d86e991ce48dbb3dd9f310b540f103a68976cbe0 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 09:46:40 +0200 Subject: [PATCH 098/135] comparison exposes head of either side --- src/abgleich/zfs/comparison.py | 79 ++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/abgleich/zfs/comparison.py b/src/abgleich/zfs/comparison.py index 22009dd..86c7902 100644 --- a/src/abgleich/zfs/comparison.py +++ b/src/abgleich/zfs/comparison.py @@ -28,6 +28,7 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +import itertools import typing import typeguard @@ -82,16 +83,94 @@ def a(self) -> ComparisonParentTypes: return self._a + @property + def a_head(self) -> typing.List[ComparisonStrictItemType]: + + return self._head( + source = [item.a for item in self._merged], + target = [item.b for item in self._merged], + ) + @property def b(self) -> ComparisonParentTypes: return self._b + @property + def b_head(self) -> typing.List[ComparisonStrictItemType]: + + return self._head( + source = [item.b for item in self._merged], + target = [item.a for item in self._merged], + ) + @property def merged(self) -> typing.Generator[ComparisonItemABC, None, None]: return (item for item in self._merged) + @classmethod + def _head( + cls, + source: typing.List[ComparisonItemType], + target: typing.List[ComparisonItemType], + ) -> typing.List[ComparisonItemType]: + + source, target = cls._strip_none(source), cls._strip_none(target) + + if any((element is None for element in source)): + raise ValueError('source is not consecutive') + if any((element is None for element in target)): + raise ValueError('target is not consecutive') + + if len(source) == 0: + raise ValueError('source must not be empty') + + if len(set([item.name for item in source])) != len(source): + raise ValueError('source contains doublicate entires') + if len(set([item.name for item in target])) != len(target): + raise ValueError('target contains doublicate entires') + + if len(target) == 0: + source.insert(0, None) + return source # all of source, target is empty + + try: + source_index = [item.name for item in source].index(target[-1].name) + except ValueError: + raise ValueError('last target element not in source') + + old_source = source[:source_index+1] + + if len(old_source) <= len(target): + if target[-len(old_source):] != old_source: + raise ValueError('no clean match between end of target and beginning of source') + else: + if target != source[source_index+1-len(target):source_index+1]: + raise ValueError('no clean match between entire target and beginning of source') + + return source[source_index:] + + @classmethod + def _strip_none( + cls, + elements: typing.List[ComparisonItemType] + ) -> typing.List[ComparisonItemType]: + + elements = cls._left_strip_none(elements) # left strip + elements.reverse() # flip into reverse + elements = cls._left_strip_none(elements) # right strip + elements.reverse() # flip back + + return elements + + @staticmethod + def _left_strip_none( + elements: typing.List[ComparisonItemType] + ) -> typing.List[ComparisonItemType]: + + return list(itertools.dropwhile(lambda element: element is None, elements)) + @staticmethod def _merge_items( items_a: ComparisonMergeTypes, From 776c30176f6240b40c04c98156a6bea89d5da5d0 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 09:48:33 +0200 Subject: [PATCH 099/135] comparison docs --- src/abgleich/zfs/comparison.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/abgleich/zfs/comparison.py b/src/abgleich/zfs/comparison.py index 86c7902..091cfad 100644 --- a/src/abgleich/zfs/comparison.py +++ b/src/abgleich/zfs/comparison.py @@ -115,6 +115,11 @@ def _head( source: typing.List[ComparisonItemType], target: typing.List[ComparisonItemType], ) -> typing.List[ComparisonItemType]: + """ + Returns last element of target plus new elements from source. + If target is empty, returns [None, *source]. + If head of target and head of source are identical, returns last element. + """ source, target = cls._strip_none(source), cls._strip_none(target) From 441f259ef2676f5b9af30b6efff9c946f1c511fa Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 09:52:24 +0200 Subject: [PATCH 100/135] snapshot creates backup transaction --- src/abgleich/zfs/snapshot.py | 57 +++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 6f02fa7..2c9eefb 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -32,9 +32,11 @@ import typeguard -from .abc import PropertyABC, SnapshotABC -from .lib import join +from .abc import PropertyABC, SnapshotABC, TransactionABC +from .lib import root from .property import Property +from .transaction import Transaction, TransactionMeta +from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS @@ -57,11 +59,10 @@ def __init__(self, self._side = side self._config = config - root = config[side]['zpool'] - if config[side]['prefix'] is not None: - root = join(root, config[side]['prefix']) - assert self._parent.startswith(root) - self._subparent = self._parent[len(root):].strip('/') + self._root = root(config[side]['zpool'], config[side]['prefix']) + + assert self._parent.startswith(self._root) + self._subparent = self._parent[len(self._root):].strip('/') def __eq__(self, other: SnapshotABC) -> bool: @@ -71,6 +72,43 @@ def __getitem__(self, name: str) -> PropertyABC: return self._properties[name] + def get_backup_transaction( + self, + source_dataset: str, + target_dataset: str, + ancestor: typing.Union[None, SnapshotABC] = None, + ) -> TransactionABC: + + commands = [ + Command.on_side( + [ + "zfs", "send", "-c", + f"{source_dataset:s}@{self.name:s}", + ] if ancestor is None else [ + "zfs", "send", "-c", "-i", + f"{source_dataset:s}@%{ancestor.name:s}", + f"{source_dataset:s}@%{self.name:s}", + ], + 'source', self._config + ), + Command.on_side( + [ + "zfs", "receive", f"{target_dataset:s}" + ], + 'target', self._config + ), + ] + + return Transaction( + meta = TransactionMeta( + type = 'push_snapshot' if ancestor is None else 'push_snapshot_incremental', + snapshot_subparent = self._subparent, + ancestor_name = "" if ancestor is None else ancestor.name, + snapshot_name = self.name, + ), + commands = commands, + ) + @property def name(self) -> str: @@ -91,6 +129,11 @@ def sortkey(self) -> int: return self._properties['creation'].value + @property + def root(self) -> str: + + return self._root + @classmethod def from_entity( cls, From 315b8c404134ee3edd1005ec20d32c53c77776e7 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 09:53:28 +0200 Subject: [PATCH 101/135] dataset uses root api; commands are list --- src/abgleich/zfs/dataset.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index 275bbaf..f4767fd 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -34,7 +34,7 @@ import typeguard from .abc import DatasetABC, PropertyABC, TransactionABC, SnapshotABC -from .lib import join +from .lib import root from .property import Property from .transaction import Transaction, TransactionMeta from .snapshot import Snapshot @@ -61,11 +61,10 @@ def __init__(self, self._side = side self._config = config - root = config[side]['zpool'] - if config[side]['prefix'] is not None: - root = join(root, config[side]['prefix']) - assert self._name.startswith(root) - self._subname = self._name[len(root):].strip('/') + self._root = root(config[side]['zpool'], config[side]['prefix']) + + assert self._name.startswith(self._root) + self._subname = self._name[len(self._root):].strip('/') def __eq__(self, other: DatasetABC) -> bool: @@ -119,6 +118,11 @@ def sortkey(self) -> str: return self._name + @property + def root(self) -> str: + + return self._root + def get_snapshot_transaction(self) -> TransactionABC: snapshot_name = self._new_snapshot_name() @@ -130,10 +134,10 @@ def get_snapshot_transaction(self) -> TransactionABC: snapshot_name = snapshot_name, written = self._properties['written'].value, ), - (Command.on_side( + [Command.on_side( ['zfs', 'snapshot', f'{self._name:s}@{snapshot_name:s}'], self._side, self._config, - ),) + )] ) def _new_snapshot_name(self) -> str: From f83f5eada30796626d5c97aa598929b5d372df1b Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 09:55:17 +0200 Subject: [PATCH 102/135] zpool generates backup transactions --- src/abgleich/zfs/zpool.py | 62 +++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index b9039c2..e64c41c 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -36,7 +36,7 @@ from .abc import ComparisonItemABC, DatasetABC, SnapshotABC, TransactionListABC, ZpoolABC from .comparison import Comparison from .dataset import Dataset -from .lib import join +from .lib import join, root from .transaction import TransactionList from ..command import Command from ..io import colorize, humanize_size @@ -57,6 +57,8 @@ def __init__( self._side = side self._config = config + self._root = root(config[side]['zpool'], config[side]['prefix']) + def __eq__(self, other: ZpoolABC) -> bool: return self.side == other.side @@ -71,6 +73,54 @@ def side(self) -> str: return self._side + @property + def root(self) -> str: + + return self._root + + def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: + + assert self.side == 'source' + assert other.side == 'target' + + zpool_comparison = Comparison.from_zpools(self, other) + transactions = TransactionList() + + for dataset_item in zpool_comparison.merged: + + if dataset_item.get_item().subname in self._config['ignore']: + continue + if dataset_item.a is None: + continue + + if len(dataset_item.a.subname) == 0: + continue # TODO (???) + + if dataset_item.b is None: + snapshots = list(dataset_item.a.snapshots) + snapshots.insert(0, None) # no ancestor at the beginning + else: + dataset_comparison = Comparison.from_datasets(dataset_item.a, dataset_item.b) + snapshots = dataset_comparison.a_head + + if len(snapshots) < 2: + # raise ValueError('dataset has no snapshots, nothing to back up', dataset_item.a.subname, snapshots) + continue + + source_dataset = join(self.root, dataset_item.a.subname) + target_dataset = join(other.root, dataset_item.a.subname) + + transactions.extend(( + snapshot.get_backup_transaction( + source_dataset, + target_dataset, + ancestor, + ) + for ancestor, snapshot in zip(snapshots[:-1], snapshots[1:]) + )) + + return transactions + def get_snapshot_transactions(self) -> TransactionListABC: assert self._side == 'source' @@ -159,11 +209,11 @@ def from_config( config: typing.Dict, ) -> ZpoolABC: - root = config[side]['zpool'] - if config[side]['prefix'] is not None: - root = join(root, config[side]['prefix']) - - output, _ = Command.on_side(["zfs", "get", "all", "-r", "-H", "-p", root], side, config).run() + output, _ = Command.on_side( + ["zfs", "get", "all", "-r", "-H", "-p", root(config[side]['zpool'], config[side]['prefix'])], + side, + config, + ).run() output = [line.split('\t') for line in output.split('\n') if len(line.strip()) > 0] entities = {name: [] for name in {line[0] for line in output}} for line_list in output: From 563f4bcd12c8334e01d5b73a1e93ac05896ba43c Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 09:56:01 +0200 Subject: [PATCH 103/135] transaction commands are handled as lists; transaction list can be extended --- src/abgleich/zfs/transaction.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/transaction.py b/src/abgleich/zfs/transaction.py index 68edfe0..5df16e2 100644 --- a/src/abgleich/zfs/transaction.py +++ b/src/abgleich/zfs/transaction.py @@ -47,7 +47,7 @@ class Transaction(TransactionABC): def __init__( self, meta: TransactionMetaABC, - commands: typing.Tuple[CommandABC], + commands: typing.List[CommandABC], ): assert len(commands) in (1, 2) @@ -126,6 +126,12 @@ def keys(self) -> typing.Generator[str, None, None]: return (key for key in self._meta.keys()) +TransactionIterableTypes = typing.Union[ + typing.Generator[TransactionABC, None, None], + typing.List[TransactionABC], + typing.Tuple[TransactionABC], +] + @typeguard.typechecked class TransactionList(TransactionListABC): @@ -141,6 +147,10 @@ def append(self, transaction: TransactionABC): self._transactions.append(transaction) + def extend(self, transactions: TransactionIterableTypes): + + self._transactions.extend(transactions) + def print_table(self): if len(self) == 0: From ee72f5278128dfad7f8c203d425f5c34ccf0c5a9 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 10:19:12 +0200 Subject: [PATCH 104/135] init runs on ordered dict --- src/abgleich/zfs/dataset.py | 2 +- src/abgleich/zfs/zpool.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index f4767fd..d5e9e20 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -172,7 +172,7 @@ def _new_snapshot_name(self) -> str: @classmethod def from_entities(cls, name: str, - entities: typing.Dict[str, typing.List[typing.List[str]]], + entities: typing.OrderedDict[str, typing.List[typing.List[str]]], side: str, config: typing.Dict, ) -> DatasetABC: diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index e64c41c..b86c16d 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -28,6 +28,7 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +from collections import OrderedDict import typing from tabulate import tabulate @@ -215,14 +216,14 @@ def from_config( config, ).run() output = [line.split('\t') for line in output.split('\n') if len(line.strip()) > 0] - entities = {name: [] for name in {line[0] for line in output}} + entities = OrderedDict((line[0], []) for line in output) for line_list in output: entities[line_list[0]].append(line_list[1:]) datasets = [ Dataset.from_entities( name, - {k: v for k, v in entities.items() if k == name or k.startswith(f'{name:s}@')}, + OrderedDict((k, v) for k, v in entities.items() if k == name or k.startswith(f'{name:s}@')), side, config, ) From a9457d3e860da3b14c0da64622177b0b2791e4b2 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 10:22:19 +0200 Subject: [PATCH 105/135] no need to order snapshots --- src/abgleich/zfs/dataset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index d5e9e20..fcf999a 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -192,7 +192,6 @@ def from_entities(cls, ) for snapshot_name in entities.keys() ] - snapshots.sort(key = lambda snapshot: snapshot['creation'].value) return cls( name = name, From 57cf96817f6c2473fdb3c4bbbec3a859a9c01689 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 10:31:59 +0200 Subject: [PATCH 106/135] snapshot becomes self-aware of its ancestor --- src/abgleich/zfs/dataset.py | 6 ++++-- src/abgleich/zfs/snapshot.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index fcf999a..afe2d72 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -183,15 +183,17 @@ def from_entities(cls, )} entities.pop(name) - snapshots = [ + snapshots = [] + snapshots.extend(( Snapshot.from_entity( snapshot_name, entities[snapshot_name], + snapshots, side, config, ) for snapshot_name in entities.keys() - ] + )) return cls( name = name, diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 2c9eefb..48dde92 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -49,6 +49,7 @@ def __init__(self, name: str, parent: str, properties: typing.Dict[str, PropertyABC], + context: typing.List[SnapshotABC], side: str, config: typing.Dict, ): @@ -56,6 +57,7 @@ def __init__(self, self._name = name self._parent = parent self._properties = properties + self._context = context self._side = side self._config = config @@ -129,6 +131,16 @@ def sortkey(self) -> int: return self._properties['creation'].value + @property + def ancestor(self) -> typing.Union[None, SnapshotABC]: + + assert self in self._context + self_index = self._context.index(self) + + if self_index == 0: + return None + return self._context[self_index - 1] + @property def root(self) -> str: @@ -139,6 +151,7 @@ def from_entity( cls, name: str, entity: typing.List[typing.List[str]], + context: typing.List[SnapshotABC], side: str, config: typing.Dict, ) -> SnapshotABC: @@ -154,6 +167,7 @@ def from_entity( name = name, parent = parent, properties = properties, + context = context, side = side, config = config, ) From 0b8b3a766580f3a89cc5a2ea06f8eee02aa72f95 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 11:38:58 +0200 Subject: [PATCH 107/135] sortkey no longer needed --- src/abgleich/zfs/dataset.py | 5 ----- src/abgleich/zfs/snapshot.py | 5 ----- 2 files changed, 10 deletions(-) diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/zfs/dataset.py index afe2d72..986dbf2 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/zfs/dataset.py @@ -113,11 +113,6 @@ def snapshots(self) -> typing.Generator[SnapshotABC, None, None]: return (snapshot for snapshot in self._snapshots) - @property - def sortkey(self) -> str: - - return self._name - @property def root(self) -> str: diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index 48dde92..cd2bd1d 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -126,11 +126,6 @@ def subparent(self) -> str: return self._subparent - @property - def sortkey(self) -> int: - - return self._properties['creation'].value - @property def ancestor(self) -> typing.Union[None, SnapshotABC]: From 10580079bfe2602f1d007352bf13b25914712c72 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 11:42:57 +0200 Subject: [PATCH 108/135] snapshot merge maintains original order --- src/abgleich/zfs/comparison.py | 103 ++++++++++++++++++++++++++------- 1 file changed, 82 insertions(+), 21 deletions(-) diff --git a/src/abgleich/zfs/comparison.py b/src/abgleich/zfs/comparison.py index 091cfad..bb6b76e 100644 --- a/src/abgleich/zfs/comparison.py +++ b/src/abgleich/zfs/comparison.py @@ -176,25 +176,6 @@ def _left_strip_none( return list(itertools.dropwhile(lambda element: element is None, elements)) - @staticmethod - def _merge_items( - items_a: ComparisonMergeTypes, - items_b: ComparisonMergeTypes, - attr: str, - ) -> typing.List[ComparisonItemABC]: - - items_a = {getattr(item, attr): item for item in items_a} - items_b = {getattr(item, attr): item for item in items_b} - - names = list(items_a.keys() | items_b.keys()) - merged = [ - ComparisonItem(items_a.get(name, None), items_b.get(name, None)) - for name in names - ] - merged.sort(key = lambda item: item.get_item().sortkey) - - return merged - @staticmethod def _single_items( items_a: typing.Union[ComparisonMergeTypes, None], @@ -207,6 +188,24 @@ def _single_items( return [ComparisonItem(None, item) for item in items_b] return [ComparisonItem(item, None) for item in items_a] + @staticmethod + def _merge_datasets( + items_a: typing.Generator[DatasetABC, None, None], + items_b: typing.Generator[DatasetABC, None, None], + ) -> typing.List[ComparisonItemABC]: + + items_a = {item.subname: item for item in items_a} + items_b = {item.subname: item for item in items_b} + + names = list(items_a.keys() | items_b.keys()) + merged = [ + ComparisonItem(items_a.get(name, None), items_b.get(name, None)) + for name in names + ] + merged.sort(key = lambda item: item.get_item().name) + + return merged + @classmethod def from_zpools( cls, @@ -232,9 +231,71 @@ def from_zpools( return cls( a = zpool_a, b = zpool_b, - merged = cls._merge_items(zpool_a.datasets, zpool_b.datasets, 'subname'), + merged = cls._merge_datasets(zpool_a.datasets, zpool_b.datasets), ) + @staticmethod + def _merge_snapshots( + items_a: typing.Generator[SnapshotABC, None, None], + items_b: typing.Generator[SnapshotABC, None, None], + ) -> typing.List[ComparisonItemABC]: + + items_a = list(items_a) + items_b = list(items_b) + names_a = [item.name for item in items_a] + names_b = [item.name for item in items_b] + + assert len(set(names_a)) == len(items_a) # unique names + assert len(set(names_b)) == len(items_b) # unique names + + if len(items_a) == 0 and len(items_b) == 0: + return [] + if len(items_a) == 0: + return [ComparisonItem(None, item) for item in items_b] + if len(items_b) == 0: + return [ComparisonItem(item, None) for item in items_a] + + try: + start_b = names_a.index(names_b[0]) + except ValueError: + start_b = None + try: + start_a = names_b.index(names_a[0]) + except ValueError: + start_a = None + + assert start_a is not None or start_b is not None # overlap + + prefix_a = [] if start_a is None else [None for _ in range(start_a)] + prefix_b = [] if start_b is None else [None for _ in range(start_b)] + items_a = prefix_a + items_a + items_b = prefix_b + items_b + suffix_a = [] if len(items_a) >= len(items_b) else [None for _ in range(len(items_b) - len(items_a))] + suffix_b = [] if len(items_b) >= len(items_a) else [None for _ in range(len(items_a) - len(items_b))] + items_a = items_a + suffix_a + items_b = items_b + suffix_b + + assert len(items_a) == len(items_b) + + alt_a, alt_b, state_a, state_b = 0, 0, False, False + merged = [] + for item_a, item_b in zip(items_a, items_b): + new_state_a, new_state_b = item_a is not None, item_b is not None + if new_state_a != state_a: + alt_a, state_a = alt_a + 1, new_state_a + if alt_a > 2: + raise ValueError('gap in snapshot series') + if new_state_b != state_b: + alt_b, state_b = alt_b + 1, new_state_b + if alt_b > 2: + raise ValueError('gap in snapshot series') + if state_a and state_b: + if item_a.name != item_b.name: + raise ValueError('inconsistent snapshot names') + merged.append(ComparisonItem(item_a, item_b)) + + return merged + @classmethod def from_datasets( cls, @@ -260,7 +321,7 @@ def from_datasets( return cls( a = dataset_a, b = dataset_b, - merged = cls._merge_items(dataset_a.snapshots, dataset_b.snapshots, 'name'), + merged = cls._merge_snapshots(dataset_a.snapshots, dataset_b.snapshots), ) From ad48c0a3a880b4ae686c9647cd04cba866bc8837 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 11:43:14 +0200 Subject: [PATCH 109/135] table order fix --- src/abgleich/zfs/transaction.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/abgleich/zfs/transaction.py b/src/abgleich/zfs/transaction.py index 5df16e2..d0df141 100644 --- a/src/abgleich/zfs/transaction.py +++ b/src/abgleich/zfs/transaction.py @@ -212,7 +212,8 @@ def _table_headers(self) -> typing.List[str]: type_index = headers.index('type') if type_index != 0: - headers[0], headers[type_index] = headers[type_index], headers[0] + headers.pop(type_index) + headers.insert(0, 'type') return headers From b6d59b11eb20bc4c57dea3d031d87d0cc7b7b365 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 11:50:28 +0200 Subject: [PATCH 110/135] also backup snapshots at root level of prefix --- src/abgleich/zfs/zpool.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index b86c16d..7bebc52 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -94,9 +94,6 @@ def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: if dataset_item.a is None: continue - if len(dataset_item.a.subname) == 0: - continue # TODO (???) - if dataset_item.b is None: snapshots = list(dataset_item.a.snapshots) snapshots.insert(0, None) # no ancestor at the beginning @@ -105,11 +102,10 @@ def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: snapshots = dataset_comparison.a_head if len(snapshots) < 2: - # raise ValueError('dataset has no snapshots, nothing to back up', dataset_item.a.subname, snapshots) continue - source_dataset = join(self.root, dataset_item.a.subname) - target_dataset = join(other.root, dataset_item.a.subname) + source_dataset = self.root if len(dataset_item.a.subname) == 0 else join(self.root, dataset_item.a.subname) + target_dataset = other.root if len(dataset_item.a.subname) == 0 else join(other.root, dataset_item.a.subname) transactions.extend(( snapshot.get_backup_transaction( From e30274bf9c00b500cb7d3baa8d8717cbf323f7c0 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 12:09:06 +0200 Subject: [PATCH 111/135] snapshots use their own ancestor for backup transactions --- src/abgleich/zfs/comparison.py | 9 ++++----- src/abgleich/zfs/snapshot.py | 5 +++-- src/abgleich/zfs/zpool.py | 6 ++---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/abgleich/zfs/comparison.py b/src/abgleich/zfs/comparison.py index bb6b76e..3b6ebc4 100644 --- a/src/abgleich/zfs/comparison.py +++ b/src/abgleich/zfs/comparison.py @@ -116,9 +116,9 @@ def _head( target: typing.List[ComparisonItemType], ) -> typing.List[ComparisonItemType]: """ - Returns last element of target plus new elements from source. - If target is empty, returns [None, *source]. - If head of target and head of source are identical, returns last element. + Returns new elements from source. + If target is empty, returns source. + If head of target and head of source are identical, returns empty list. """ source, target = cls._strip_none(source), cls._strip_none(target) @@ -137,7 +137,6 @@ def _head( raise ValueError('target contains doublicate entires') if len(target) == 0: - source.insert(0, None) return source # all of source, target is empty try: @@ -154,7 +153,7 @@ def _head( if target != source[source_index+1-len(target):source_index+1]: raise ValueError('no clean match between entire target and beginning of source') - return source[source_index:] + return source[source_index+1:] @classmethod def _strip_none( diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index cd2bd1d..e20419b 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -68,7 +68,7 @@ def __init__(self, def __eq__(self, other: SnapshotABC) -> bool: - return self.subparent == other.subparent + return self.subparent == other.subparent and self.name == other.name def __getitem__(self, name: str) -> PropertyABC: @@ -78,9 +78,10 @@ def get_backup_transaction( self, source_dataset: str, target_dataset: str, - ancestor: typing.Union[None, SnapshotABC] = None, ) -> TransactionABC: + ancestor = self.ancestor + commands = [ Command.on_side( [ diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 7bebc52..f7f6a9a 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -96,12 +96,11 @@ def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: if dataset_item.b is None: snapshots = list(dataset_item.a.snapshots) - snapshots.insert(0, None) # no ancestor at the beginning else: dataset_comparison = Comparison.from_datasets(dataset_item.a, dataset_item.b) snapshots = dataset_comparison.a_head - if len(snapshots) < 2: + if len(snapshots) == 0: continue source_dataset = self.root if len(dataset_item.a.subname) == 0 else join(self.root, dataset_item.a.subname) @@ -111,9 +110,8 @@ def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: snapshot.get_backup_transaction( source_dataset, target_dataset, - ancestor, ) - for ancestor, snapshot in zip(snapshots[:-1], snapshots[1:]) + for snapshot in snapshots )) return transactions From fd3b5f36118a79fc76ba6438a62058ee14e309c2 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 12:54:19 +0200 Subject: [PATCH 112/135] cleanup runs on new cli --- src/abgleich/cli/cleanup.py | 47 ++++++++++--------------------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/src/abgleich/cli/cleanup.py b/src/abgleich/cli/cleanup.py index ecfacb0..604279f 100644 --- a/src/abgleich/cli/cleanup.py +++ b/src/abgleich/cli/cleanup.py @@ -6,9 +6,9 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/cli/cleanup.py: cleanup command entry point + src/abgleich/cli/cleanup.py: cleanup command entry point - Copyright (C) 2019-2020 Sebastian M. Ernst + Copyright (C) 2019-2020 Sebastian M. Ernst The contents of this file are subject to the GNU Lesser General Public License @@ -30,16 +30,9 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ import click -from tabulate import tabulate -import yaml -from yaml import CLoader -from ..io import colorize, humanize_size -from ..zfs import ( - get_tree, - get_cleanup_tasks, - delete_snapshot, -) +from ..config import Config +from ..zfs.zpool import Zpool # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES @@ -50,32 +43,18 @@ @click.argument("configfile", type=click.File("r", encoding="utf-8")) def cleanup(configfile): - config = yaml.load(configfile.read(), Loader=CLoader) + config = Config.from_fd(configfile) - cols = ["NAME", "DELETE SNAPSHOT"] - col_align = ("left", "left") - datasets = get_tree() - cleanup_tasks = get_cleanup_tasks( - datasets, config["prefix_local"], config["ignore"], config["keep_snapshots"] - ) - space_before = int(datasets[0]["AVAIL"]) + source_zpool = Zpool.from_config('source', config = config) + target_zpool = Zpool.from_config('target', config = config) - table = [] - for name, snapshot_name in cleanup_tasks: - table.append([name, snapshot_name]) + transactions = source_zpool.get_cleanup_transactions(target_zpool) - print(tabulate(table, headers=cols, tablefmt="github", colalign=col_align)) - print("%s available" % humanize_size(space_before, add_color=True)) + if len(transactions) == 0: + print('nothing to do') + return + transactions.print_table() click.confirm("Do you want to continue?", abort=True) - for name, snapshot_name in cleanup_tasks: - delete_snapshot( - config["prefix_local"] + name, - snapshot_name, - # debug = True - ) - - space_after = int(get_tree()[0]["AVAIL"]) - print("%s available" % humanize_size(space_before, add_color=True)) - print("%s freed" % humanize_size(space_before - space_before, add_color=True)) + transactions.run() From b57891dd66e3a012c19c87393e64bda4d83fa3df Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 13:55:12 +0200 Subject: [PATCH 113/135] fix error handling --- src/abgleich/command.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/abgleich/command.py b/src/abgleich/command.py index a34af04..29708be 100644 --- a/src/abgleich/command.py +++ b/src/abgleich/command.py @@ -66,11 +66,13 @@ def run_pipe(self, other: CommandABC): proc_1 = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc_2 = subprocess.Popen(other.cmd, stdin=proc_1.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - output_1, errors_1 = proc_1.communicate() # TODO no output? + output_2, errors_2 = proc_2.communicate() - status_1 = not bool(proc_1.returncode) status_2 = not bool(proc_2.returncode) - output_1, errors_1 = output_1.decode("utf-8"), errors_1.decode("utf-8") + _, errors_1 = proc_1.communicate() + status_1 = not bool(proc_1.returncode) + + errors_1 = errors_1.decode("utf-8") output_2, errors_2 = output_2.decode("utf-8"), errors_2.decode("utf-8") if any(( @@ -79,9 +81,9 @@ def run_pipe(self, other: CommandABC): not status_2, len(errors_2.strip()) > 0, )): - raise SystemError('command pipe failed', self.cmd, output_1, errors_1, output_2, errors_2) + raise SystemError('command pipe failed', self.cmd, other.cmd, errors_1, output_2, errors_2) - return output_1, errors_1, output_2, errors_2 + return errors_1, output_2, errors_2 @property def cmd(self) -> typing.List[str]: From 0509ec0f0faa46aa4c16b9c8c55cf5eab3658ef3 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 13:55:41 +0200 Subject: [PATCH 114/135] get tail for cleanup --- src/abgleich/zfs/comparison.py | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/abgleich/zfs/comparison.py b/src/abgleich/zfs/comparison.py index 3b6ebc4..4e2a9ab 100644 --- a/src/abgleich/zfs/comparison.py +++ b/src/abgleich/zfs/comparison.py @@ -91,6 +91,14 @@ def a_head(self) -> typing.List[ComparisonStrictItemType]: target = [item.b for item in self._merged], ) + @property + def a_overlap_tail(self) -> typing.List[ComparisonStrictItemType]: + + return self._overlap_tail( + source = [item.a for item in self._merged], + target = [item.b for item in self._merged], + ) + @property def b(self) -> ComparisonParentTypes: @@ -104,6 +112,14 @@ def b_head(self) -> typing.List[ComparisonStrictItemType]: target = [item.a for item in self._merged], ) + @property + def b_overlap_tail(self) -> typing.List[ComparisonStrictItemType]: + + return self._overlap_tail( + source = [item.b for item in self._merged], + target = [item.a for item in self._merged], + ) + @property def merged(self) -> typing.Generator[ComparisonItemABC, None, None]: @@ -155,6 +171,49 @@ def _head( return source[source_index+1:] + @classmethod + def _overlap_tail( + cls, + source: typing.List[ComparisonItemType], + target: typing.List[ComparisonItemType], + ) -> typing.List[ComparisonItemType]: + """ + Overlap must include first element of source. + """ + + source, target = cls._strip_none(source), cls._strip_none(target) + + if len(source) == 0 or len(target) == 0: + return [] + + if any((element is None for element in source)): + raise ValueError('source is not consecutive') + if any((element is None for element in target)): + raise ValueError('target is not consecutive') + + source_names = {item.name for item in source} + target_names = {item.name for item in target} + + if len(source_names) != len(source): + raise ValueError('source contains doublicate entires') + if len(target_names) != len(target): + raise ValueError('target contains doublicate entires') + + overlap_tail = [] + for item in source: + if item.name not in target_names: + break + overlap_tail.append(item) + + if len(overlap_tail) == 0: + return overlap_tail + + target_index = target.index(overlap_tail[0]) + if overlap_tail != target[target_index:target_index+len(overlap_tail)]: + raise ValueError('no clean match in overlap area') + + return overlap_tail + @classmethod def _strip_none( cls, From a4c93d3b98ac24634e4e10683c1428c197caaef2 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 13:56:16 +0200 Subject: [PATCH 115/135] cleanup transaction; name fix; check --- src/abgleich/zfs/snapshot.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/zfs/snapshot.py index e20419b..f3a412d 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/zfs/snapshot.py @@ -74,12 +74,32 @@ def __getitem__(self, name: str) -> PropertyABC: return self._properties[name] + def get_cleanup_transaction(self) -> TransactionABC: + + assert self._side == 'source' + + return Transaction( + meta = TransactionMeta( + type = 'cleanup_snapshot', + snapshot_subparent = self._subparent, + snapshot_name = self._name, + ), + commands = [ + Command.on_side( + ["zfs", "destroy", f"{self._parent:s}@{self._name:s}"], + self._side, self._config + ) + ], + ) + def get_backup_transaction( self, source_dataset: str, target_dataset: str, ) -> TransactionABC: + assert self._side == 'source' + ancestor = self.ancestor commands = [ @@ -89,8 +109,8 @@ def get_backup_transaction( f"{source_dataset:s}@{self.name:s}", ] if ancestor is None else [ "zfs", "send", "-c", "-i", - f"{source_dataset:s}@%{ancestor.name:s}", - f"{source_dataset:s}@%{self.name:s}", + f"{source_dataset:s}@{ancestor.name:s}", + f"{source_dataset:s}@{self.name:s}", ], 'source', self._config ), From 5e0e15177916f98702833dc3477cff42da9deb84 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 13:56:38 +0200 Subject: [PATCH 116/135] output fix --- src/abgleich/zfs/transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/abgleich/zfs/transaction.py b/src/abgleich/zfs/transaction.py index d0df141..5049379 100644 --- a/src/abgleich/zfs/transaction.py +++ b/src/abgleich/zfs/transaction.py @@ -93,7 +93,7 @@ def run(self): if len(self._commands) == 1: output, errors = self._commands[0].run() else: - output_1, errors_1, output_2, errors_2 = self._commands[0].run_pipe(self._commands[1]) + errors_1, output_2, errors_2 = self._commands[0].run_pipe(self._commands[1]) except SystemError as error: self._error = error finally: From 2c5f6fa328485299e129a75a28c4f40bd759ca25 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 13:57:35 +0200 Subject: [PATCH 117/135] src/abgleich/zfs/zpool.py --- src/abgleich/zfs/zpool.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index f7f6a9a..d6635a7 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -79,6 +79,31 @@ def root(self) -> str: return self._root + def get_cleanup_transactions(self, other: ZpoolABC) -> TransactionListABC: + + assert self.side == 'source' + assert other.side == 'target' + + zpool_comparison = Comparison.from_zpools(self, other) + transactions = TransactionList() + + for dataset_item in zpool_comparison.merged: + + if dataset_item.get_item().subname in self._config['ignore']: + continue + if dataset_item.a is None or dataset_item.b is None: + continue + + if len(dataset_item.a.subname) == 0: + continue + + dataset_comparison = Comparison.from_datasets(dataset_item.a, dataset_item.b) + snapshots = dataset_comparison.a_overlap_tail[:-self._config['keep_snapshots']] + + transactions.extend((snapshot.get_cleanup_transaction() for snapshot in snapshots)) + + return transactions + def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: assert self.side == 'source' @@ -94,6 +119,9 @@ def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: if dataset_item.a is None: continue + if len(dataset_item.a.subname) == 0: + continue + if dataset_item.b is None: snapshots = list(dataset_item.a.snapshots) else: @@ -103,8 +131,8 @@ def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: if len(snapshots) == 0: continue - source_dataset = self.root if len(dataset_item.a.subname) == 0 else join(self.root, dataset_item.a.subname) - target_dataset = other.root if len(dataset_item.a.subname) == 0 else join(other.root, dataset_item.a.subname) + source_dataset = join(self.root, dataset_item.a.subname) + target_dataset = join(other.root, dataset_item.a.subname) transactions.extend(( snapshot.get_backup_transaction( @@ -124,6 +152,8 @@ def get_snapshot_transactions(self) -> TransactionListABC: for dataset in self._datasets: if dataset.subname in self._config['ignore']: continue + if len(dataset.subname) == 0: + continue if dataset['mountpoint'].value is None: continue if dataset.changed: From b51fb6b49db43de8b63bde0c45ef313d95b33ef8 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 14:11:17 +0200 Subject: [PATCH 118/135] allow backup of prefix root --- src/abgleich/zfs/zpool.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index d6635a7..40970e3 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -94,9 +94,6 @@ def get_cleanup_transactions(self, other: ZpoolABC) -> TransactionListABC: if dataset_item.a is None or dataset_item.b is None: continue - if len(dataset_item.a.subname) == 0: - continue - dataset_comparison = Comparison.from_datasets(dataset_item.a, dataset_item.b) snapshots = dataset_comparison.a_overlap_tail[:-self._config['keep_snapshots']] @@ -119,9 +116,6 @@ def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: if dataset_item.a is None: continue - if len(dataset_item.a.subname) == 0: - continue - if dataset_item.b is None: snapshots = list(dataset_item.a.snapshots) else: @@ -131,8 +125,8 @@ def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: if len(snapshots) == 0: continue - source_dataset = join(self.root, dataset_item.a.subname) - target_dataset = join(other.root, dataset_item.a.subname) + source_dataset = self.root if len(dataset_item.a.subname) == 0 else join(self.root, dataset_item.a.subname) + target_dataset = other.root if len(dataset_item.a.subname) == 0 else join(other.root, dataset_item.a.subname) transactions.extend(( snapshot.get_backup_transaction( @@ -152,8 +146,6 @@ def get_snapshot_transactions(self) -> TransactionListABC: for dataset in self._datasets: if dataset.subname in self._config['ignore']: continue - if len(dataset.subname) == 0: - continue if dataset['mountpoint'].value is None: continue if dataset.changed: From 1218fbb6577dc4c58e5a7daa8ed5f2f7a41a86ae Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 14:36:30 +0200 Subject: [PATCH 119/135] cleanup shows freed space --- src/abgleich/cli/cleanup.py | 10 ++++++++++ src/abgleich/zfs/zpool.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/abgleich/cli/cleanup.py b/src/abgleich/cli/cleanup.py index 604279f..bc88fb2 100644 --- a/src/abgleich/cli/cleanup.py +++ b/src/abgleich/cli/cleanup.py @@ -29,10 +29,13 @@ # IMPORT # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +import time + import click from ..config import Config from ..zfs.zpool import Zpool +from ..io import humanize_size # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES @@ -47,6 +50,7 @@ def cleanup(configfile): source_zpool = Zpool.from_config('source', config = config) target_zpool = Zpool.from_config('target', config = config) + available_before = Zpool.available('source', config = config) transactions = source_zpool.get_cleanup_transactions(target_zpool) @@ -58,3 +62,9 @@ def cleanup(configfile): click.confirm("Do you want to continue?", abort=True) transactions.run() + + WAIT = 10 + print(f'waiting {WAIT:d} seconds ...') + time.sleep(WAIT) + available_after = Zpool.available('source', config = config) + print(f'{humanize_size(available_after, add_color = True):s} available, {humanize_size(available_after - available_before, add_color = True):s} freed') diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/zfs/zpool.py index 40970e3..64daa3a 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/zfs/zpool.py @@ -38,6 +38,7 @@ from .comparison import Comparison from .dataset import Dataset from .lib import join, root +from .property import Property from .transaction import TransactionList from ..command import Command from ..io import colorize, humanize_size @@ -219,6 +220,20 @@ def _comparison_table_row(item: ComparisonItemABC) -> typing.List[str]: b, ] + @staticmethod + def available( + side: str, + config: typing.Dict, + ) -> int: + + output, _ = Command.on_side( + ["zfs", "get", "available", "-H", "-p", root(config[side]['zpool'], config[side]['prefix'])], + side, + config, + ).run() + + return Property.from_params(*output.strip().split('\t')[1:]).value + @classmethod def from_config( cls, From 70b7684276edb70da4b1e838585357ea9a3456e1 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 14:41:32 +0200 Subject: [PATCH 120/135] removed old command code --- src/abgleich/cmd.py | 105 -------------------------------------------- 1 file changed, 105 deletions(-) delete mode 100644 src/abgleich/cmd.py diff --git a/src/abgleich/cmd.py b/src/abgleich/cmd.py deleted file mode 100644 index 04d85e7..0000000 --- a/src/abgleich/cmd.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- coding: utf-8 -*- - -""" - -ABGLEICH -zfs sync tool -https://github.com/pleiszenburg/abgleich - - src/abgleich/cmd.py: Subprocess wrappers - - Copyright (C) 2019-2020 Sebastian M. Ernst - - -The contents of this file are subject to the GNU Lesser General Public License -Version 2.1 ("LGPL" or "License"). You may not use this file except in -compliance with the License. You may obtain a copy of the License at -https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt -https://github.com/pleiszenburg/abgleich/blob/master/LICENSE - -Software distributed under the License is distributed on an "AS IS" basis, -WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the -specific language governing rights and limitations under the License. - - -""" - - -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# IMPORT -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -import subprocess - -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# ROUTINES -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - -def run_command(cmd_list, debug=False): - if debug: - print_commands(cmd_list) - return - proc = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - outs, errs = proc.communicate() - status_value = not bool(proc.returncode) - output, errors = outs.decode("utf-8"), errs.decode("utf-8") - if len(errors.strip()) != 0 or not status_value: - print(output) - print(errors) - raise - return output - - -def run_chain_command(cmd_list_1, cmd_list_2, debug=False): - if debug: - print_commands(cmd_list_1, cmd_list_2) - return - proc_1 = subprocess.Popen( - cmd_list_1, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - proc_2 = subprocess.Popen( - cmd_list_2, stdin=proc_1.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - outs_2, errs_2 = proc_2.communicate() - status_value_2 = not bool(proc_2.returncode) - _, errs_1 = proc_1.communicate() - status_value_1 = not bool(proc_1.returncode) - output_2, errors_2 = outs_2.decode("utf-8"), errs_2.decode("utf-8") - errors_1 = errs_1.decode("utf-8") - if any( - [ - len(errors_1.strip()) != 0, - not status_value_1, - len(errors_2.strip()) != 0, - not status_value_2, - ] - ): - print(errors_1) - print(output_2) - print(errors_2) - raise - return output_2 - - -def print_commands(*args): - commands = [" ".join(cmd_list) for cmd_list in args] - print("#> " + " | ".join(commands)) - - -def ssh_command(host, cmd_list, compression=False): - return get_ssh_prefix(compression) + [ - host, - " ".join([item.replace(" ", "\\ ") for item in cmd_list]), - ] - - -def get_ssh_prefix(compression=False): - return [ - "ssh", - "-T", - "-c", - "aes256-gcm@openssh.com", - "-o", - "Compression=yes" if compression else "Compression=no", - ] From 568401571d250dff03ba8d22c67c46b3a4ccaabc Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 14:42:30 +0200 Subject: [PATCH 121/135] removed old zfs code --- src/abgleich/zfs/__init__.py | 345 ----------------------------------- 1 file changed, 345 deletions(-) diff --git a/src/abgleich/zfs/__init__.py b/src/abgleich/zfs/__init__.py index 89541e3..c3221d3 100644 --- a/src/abgleich/zfs/__init__.py +++ b/src/abgleich/zfs/__init__.py @@ -23,348 +23,3 @@ """ - - -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# IMPORT -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -import datetime - -from ..cmd import ( - run_chain_command, - run_command, - ssh_command, -) - -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# ROUTINES -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - -def compare_trees(tree_a, prefix_a, tree_b, prefix_b): - assert not prefix_a.endswith("/") - assert not prefix_b.endswith("/") - prefix_a += "/" - prefix_b += "/" - subdict_a = { - "/" + dataset["NAME"][len(prefix_a) :]: dataset - for dataset in tree_a - if dataset["NAME"].startswith(prefix_a) # or dataset['NAME'] == prefix_a[:-1] - } - subdict_b = { - "/" + dataset["NAME"][len(prefix_b) :]: dataset - for dataset in tree_b - if dataset["NAME"].startswith(prefix_b) # or dataset['NAME'] == prefix_b[:-1] - } - tree_names = list(sorted(subdict_a.keys() | subdict_b.keys())) - res = list() - for name in tree_names: - res.append([name, name in subdict_a.keys(), name in subdict_b.keys()]) - res.extend( - __merge_snapshots__( - name, - subdict_a[name]["SNAPSHOTS"] if name in subdict_a else list(), - subdict_b[name]["SNAPSHOTS"] if name in subdict_b else list(), - ) - ) - return res - - -def __merge_snapshots__(dataset_name, snap_a, snap_b): - if len(snap_a) == 0 and len(snap_b) == 0: - return list() - names_a = [snapshot["NAME"] for snapshot in snap_a] - names_b = [snapshot["NAME"] for snapshot in snap_b] - if len(names_a) == 0 and len(names_b) > 0: - return [[dataset_name + "@" + name, False, True] for name in names_b] - if len(names_b) == 0 and len(names_a) > 0: - return [[dataset_name + "@" + name, True, False] for name in names_a] - creations_a = {snapshot["creation"]: snapshot for snapshot in snap_a} - creations_b = {snapshot["creation"]: snapshot for snapshot in snap_b} - creations = list(sorted(creations_a.keys() | creations_b.keys())) - ret = list() - for creation in creations: - in_a = creation in creations_a.keys() - in_b = creation in creations_b.keys() - if in_a: - name = creations_a[creation]["NAME"] - elif in_b: - name = creations_b[creation]["NAME"] - else: - raise ValueError("this should not happen") - if in_a and in_b: - if creations_a[creation]["NAME"] != creations_b[creation]["NAME"]: - raise ValueError("snapshot name mismatch for equal creation times") - ret.append([dataset_name + "@" + name, in_a, in_b]) - return ret - - -def get_backup_ops(tree_a, prefix_a, tree_b, prefix_b, ignore): - assert not prefix_a.endswith("/") - assert not prefix_b.endswith("/") - prefix_a += "/" - prefix_b += "/" - subdict_a = { - "/" + dataset["NAME"][len(prefix_a) :]: dataset - for dataset in tree_a - if dataset["NAME"].startswith(prefix_a) - } - subdict_b = { - "/" + dataset["NAME"][len(prefix_b) :]: dataset - for dataset in tree_b - if dataset["NAME"].startswith(prefix_b) - } - tree_names = list(sorted(subdict_a.keys() | subdict_b.keys())) - res = list() - for name in tree_names: - if name in ignore: - continue - dataset_in_a = name in subdict_a.keys() - dataset_in_b = name in subdict_b.keys() - if not dataset_in_a and dataset_in_b: - # raise ValueError('no source dataset "%s" - only remote' % name) - print('no source dataset "%s" - only remote' % name) - continue - if dataset_in_a and not dataset_in_b and len(subdict_a[name]["SNAPSHOTS"]) == 0: - raise ValueError('no snapshots in dataset "%s" - can not send' % name) - if dataset_in_a and not dataset_in_b: - res.append( - ["push_snapshot", (name, subdict_a[name]["SNAPSHOTS"][0]["NAME"])] - ) - for snapshot_1, snapshot_2 in zip( - subdict_a[name]["SNAPSHOTS"][:-1], subdict_a[name]["SNAPSHOTS"][1:] - ): - res.append( - [ - "push_snapshot_incremental", - (name, snapshot_1["NAME"], snapshot_2["NAME"]), - ] - ) - continue - last_remote_shapshot = subdict_b[name]["SNAPSHOTS"][-1]["NAME"] - source_index = None - for index, source_snapshot in enumerate(subdict_a[name]["SNAPSHOTS"]): - if source_snapshot["NAME"] == last_remote_shapshot: - source_index = index - break - if source_index is None: - raise ValueError( - 'no common snapshots in dataset "%s" - can not send incremental' % name - ) - for snapshot_1, snapshot_2 in zip( - subdict_a[name]["SNAPSHOTS"][source_index:-1], - subdict_a[name]["SNAPSHOTS"][(source_index + 1) :], - ): - res.append( - [ - "push_snapshot_incremental", - (name, snapshot_1["NAME"], snapshot_2["NAME"]), - ] - ) - - return res - - -def get_cleanup_tasks(tree, prefix, ignore, keep_snapshots): - - res = list() - skip = len(prefix) - - for dataset in tree: - name = dataset["NAME"][skip:] - if name in ignore or len(name) == 0: - continue - # if dataset['MOUNTPOINT'] == 'none': - # continue - if len(dataset["SNAPSHOTS"]) <= keep_snapshots: - continue - del_snapshots = dataset["SNAPSHOTS"][: (-1 * keep_snapshots)] - for snapshot in del_snapshots: - res.append([name, snapshot["NAME"]]) - - return res - - -def get_snapshot_tasks(tree, prefix, ignore): - - res = list() - skip = len(prefix) - date = datetime.datetime.now().strftime("%Y%m%d") - suffix = "_backup" - - def make_name(snapshots): - snapshot_names = [snapshot["NAME"] for snapshot in snapshots] - for index in range(1, 100): - new_name = "%s%02d%s" % (date, index, suffix) - if new_name not in snapshot_names: - return new_name - raise ValueError("more than 99 snapshots per day") - - for dataset in tree: - name = dataset["NAME"][skip:] - written = int(dataset["written"]) - if name in ignore or len(name) == 0: - continue - if dataset["MOUNTPOINT"] == "none": - continue - if len(dataset["SNAPSHOTS"]) == 0: - res.append([name, written, date + "01" + suffix]) - continue - if written == 0: - continue - if written > (1024 ** 2): - res.append([name, written, make_name(dataset["SNAPSHOTS"])]) - continue - if dataset["type"] == "volume": - res.append([name, written, make_name(dataset["SNAPSHOTS"])]) - continue - diff_out = run_command( - ["zfs", "diff", dataset["NAME"] + "@" + dataset["SNAPSHOTS"][-1]["NAME"]] - ) - if len(diff_out.strip(" \t\n")) > 0: - res.append([name, written, make_name(dataset["SNAPSHOTS"])]) - - return res - - -def get_tree(host=None): - - cmd_list = ["zfs", "list", "-H", "-p"] - cmd_list_snapshot = ["zfs", "list", "-t", "snapshot", "-H", "-p"] - cmd_list_property = ["zfs", "get", "all", "-H", "-p"] - - if host is not None: - cmd_list = ssh_command(host, cmd_list, compression=True) - cmd_list_snapshot = ssh_command(host, cmd_list_snapshot, compression=True) - cmd_list_property = ssh_command(host, cmd_list_property, compression=True) - - datasets = parse_table( - run_command(cmd_list), ["NAME", "USED", "AVAIL", "REFER", "MOUNTPOINT"] - ) - snapshots = parse_table( - run_command(cmd_list_snapshot), ["NAME", "USED", "AVAIL", "REFER", "MOUNTPOINT"] - ) - properties = parse_table( - run_command(cmd_list_property), ["NAME", "PROPERTY", "VALUE", "SOURCE"] - ) - merge_properties(datasets, snapshots, properties) - merge_snapshots_into_datasets(datasets, snapshots) - - return datasets - - -def merge_properties(datasets, snapshots, properties): - - elements = {dataset["NAME"]: dataset for dataset in datasets} - elements.update({snapshot["NAME"]: snapshot for snapshot in snapshots}) - for property in properties: - elements[property["NAME"]][property["PROPERTY"]] = property["VALUE"] - - -def merge_snapshots_into_datasets(datasets, snapshots): - - for dataset in datasets: - dataset["SNAPSHOTS"] = [] - datasets_dict = {dataset["NAME"]: dataset for dataset in datasets} - for snapshot in snapshots: - dataset_name, snapshot["NAME"] = snapshot["NAME"].split("@") - datasets_dict[dataset_name]["SNAPSHOTS"].append(snapshot) - - -def parse_table(raw, head): - - table = [item.split("\t") for item in raw.split("\n") if len(item.strip()) > 0] - return [{k: v for k, v in zip(head, line)} for line in table] - - -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# ROUTINES: MODIFY -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - -def create_snapshot(dataset_name, snapshot_name, debug=False): - print("CREATING SNAPSHOT %s@%s ..." % (dataset_name, snapshot_name)) - cmd = ["zfs", "snapshot", "%s@%s" % (dataset_name, snapshot_name)] - run_command(cmd, debug=debug) - print("... CREATING SNAPSHOT DONE.") - - -def delete_snapshot(dataset_name, snapshot_name, debug=False): - print("DELETING SNAPSHOT %s@%s ..." % (dataset_name, snapshot_name)) - cmd = ["zfs", "destroy", "%s@%s" % (dataset_name, snapshot_name)] - run_command(cmd, debug=debug) - print("... DELETING SNAPSHOT DONE.") - - -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# ROUTINES: SEND & RECEIVE -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - -def pull_snapshot(host, src, src_firstsnapshot, dest, debug=False): - print("PULLING FIRST %s@%s to %s ..." % (src, src_firstsnapshot, dest)) - cmd1 = ssh_command( - host, - ["zfs", "send", "-c", "%s@%s" % (src, src_firstsnapshot)], - compression=False, - ) - cmd2 = ["zfs", "receive", dest] - run_chain_command(cmd1, cmd2, debug=debug) - print("... PULLING FIRST DONE.") - - -def pull_snapshot_incremental(host, src, src_a, src_b, dest, debug=False): - print("PULLING FOLLOW-UP %s@[%s - %s] to %s ..." % (src, src_a, src_b, dest)) - cmd1 = ssh_command( - host, - ["zfs", "send", "-c", "-i", "%s@%s" % (src, src_a), "%s@%s" % (src, src_b)], - compression=False, - ) - cmd2 = ["zfs", "receive", dest] - run_chain_command(cmd1, cmd2, debug=debug) - print("... PULLING FOLLOW-UP DONE.") - - -def pull_new(host, dataset_src, dest, debug=False): - print("PULLING NEW %s to %s ..." % (dataset_src["NAME"], dest)) - src = dataset_src["NAME"] - src_firstsnapshot = dataset_src["SNAPSHOTS"][0]["NAME"] - src_snapshotpairs = [ - (a["NAME"], b["NAME"]) - for a, b in zip(dataset_src["SNAPSHOTS"][:-1], dataset_src["SNAPSHOTS"][1:]) - ] - pull_snapshot(host, src, src_firstsnapshot, dest, debug=debug) - for src_a, src_b in src_snapshotpairs: - pull_snapshot_incremental(host, src, src_a, src_b, dest, debug=debug) - print("... PULLING NEW DONE.") - - -def push_snapshot(host, src, src_firstsnapshot, dest, debug=False): - print("PUSHING FIRST %s@%s to %s ..." % (src, src_firstsnapshot, dest)) - cmd1 = ["zfs", "send", "-c", "%s@%s" % (src, src_firstsnapshot)] - cmd2 = ssh_command(host, ["zfs", "receive", dest], compression=False) - run_chain_command(cmd1, cmd2, debug=debug) - print("... PUSHING FIRST DONE.") - - -def push_snapshot_incremental(host, src, src_a, src_b, dest, debug=False): - print("PUSHING FOLLOW-UP %s@[%s - %s] to %s ..." % (src, src_a, src_b, dest)) - cmd1 = ["zfs", "send", "-c", "-i", "%s@%s" % (src, src_a), "%s@%s" % (src, src_b)] - cmd2 = ssh_command(host, ["zfs", "receive", dest], compression=False) - run_chain_command(cmd1, cmd2, debug=debug) - print("... PUSHING FOLLOW-UP DONE.") - - -def push_new(host, dataset_src, dest, debug=False): - print("PUSHING NEW %s to %s ..." % (dataset_src["NAME"], dest)) - src = dataset_src["NAME"] - src_firstsnapshot = dataset_src["SNAPSHOTS"][0]["NAME"] - src_snapshotpairs = [ - (a["NAME"], b["NAME"]) - for a, b in zip(dataset_src["SNAPSHOTS"][:-1], dataset_src["SNAPSHOTS"][1:]) - ] - push_snapshot(host, src, src_firstsnapshot, dest, debug=debug) - for src_a, src_b in src_snapshotpairs: - push_snapshot_incremental(host, src, src_a, src_b, dest, debug=debug) - print("... PUSHING NEW DONE.") From 132bc268ef553fa775191ab048eec2a472243c12 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 15:37:21 +0200 Subject: [PATCH 122/135] updated documentation --- README.md | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6c24236..8d6e018 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## SYNOPSIS -Simple ZFS sync tool. Shows local and remote ZFS dataset trees / zpools. Creates meaningful snapshots only if datasets have actually been changed. Compares a local dataset tree to a remote, backup dataset tree. Pushes backups to remote. Cleanes up older snapshot on local system. Runs form the command line and produces nice, user-friendly, readable, colorized output. +Simple ZFS sync tool. It shows source and target ZFS zpool, dataset and snapshot trees. It creates meaningful snapshots only if datasets have actually been changed. It compares a source zpool tree to a target, backup zpool tree. It pushes backups from a source to a target. Cleanes up older snapshot on local system. Runs form the command line and produces nice, user-friendly, readable, colorized output. ## INSTALLATION @@ -10,40 +10,60 @@ Simple ZFS sync tool. Shows local and remote ZFS dataset trees / zpools. Creates pip install -vU git+https://github.com/pleiszenburg/abgleich.git@master ``` -Requires (C)Python 3.5 or later. Tested with ZoL 0.7 and 0.8. +Requires (C)Python 3.6 or later. Tested with [OpenZFS](https://en.wikipedia.org/wiki/OpenZFS) 0.8.x on Linux. ## USAGE +All potentially changing or destructive actions are listed in detail before the user is asked to confirm them. None of the commands listed here will change a zpool, dataset or snapshot on its own without the user's explicit consent. + ### `abgleich tree config.yaml [source|target]` -Show zfs tree with snapshots, disk space and compression ratio. Append `source` or `target` (optional). `ssh` without password (public key) required. +Show zfs tree with snapshots, disk space and compression ratio. Append `source` or `target` (optional). `ssh` without password (public key) required if source and/or target is not equivalent to localhost. ### `abgleich snap config.yaml` -Determine which datasets have been changed since last snapshot. Generate snapshots where applicable. Superuser privileges required. +Determine which datasets have been changed since last snapshot. Generate snapshots where applicable. `ssh` without password (public key) required if source and/or target is not equivalent to localhost. Superuser privileges required. ### `abgleich compare config.yaml` -Compare local machine with remote host. See what is missing where. `ssh` without password (public key) required. Superuser privileges required. +Compare local machine with remote host. See what is missing where. `ssh` without password (public key) required if source and/or target is not equivalent to localhost. ### `abgleich backup config.yaml` -Send (new) datasets and snapshots to remote host. `ssh` without password (public key) required. Superuser privileges required. +Send (new) datasets and snapshots to target host. `ssh` without password (public key) required if source and/or target is not equivalent to localhost. Superuser privileges required. ### `abgleich cleanup config.yaml` -Cleanup older local snapshots. Keep `keep_snapshots` number of snapshots. Superuser privileges required. +Cleanup older local snapshots. Keep `keep_snapshots` number of snapshots. `ssh` without password (public key) required if source and/or target is not equivalent to localhost. Superuser privileges required. + +### Speed + +For (recommended) safety, `abgleich` runs fully statically typed by default, i.e. insanely slow. For must higher speed, the checks can be deactivated by setting the `PYTHONOPTIMIZE` environment variable to `1` or `2`, e.g. `PYTHONOPTIMIZE=1 abgleich tree config.yaml`. ### `config.yaml` Example configuration file: ```yaml -prefix_local: tank_ssd -prefix_remote: tank_hdd/BACKUP_SOMEMACHINE -host: bigdata +source: + zpool: tank_ssd + prefix: + host: localhost + user: +target: + zpool: tank_hdd + prefix: BACKUP_SOMEMACHINE + host: bigdata + user: root keep_snapshots: 2 +suffix: _backup +digits: 2 ignore: - - /ernst/CACHE - - /ernst/CCACHE + - user/CACHE + - user/CCACHE +ssh: + compression: no + cipher: aes256-gcm@openssh.com ``` + +The prefix can be empty on either side. If a `host` is `localhost`, the `user` field can be left empty. Both source and target can be remote hosts at the same time or localhost at the same time. `keep_snapshots` is an integer and must greater or equal to `1`. `suffix` describes the name suffix for new snapshots. `digits` specifies how many digits are used for a decimal number describing the n-th snapshot per dataset per day as part of the name of new snapshots. `ignore` lists stuff underneath the `prefix` which will be ignored by this tool, i.e. no snapshots, backups or cleanups. `ssh` allows to fine-tune the speed of backups. In fast local networks, it is best to set `compression` to `no` because the compression is usually slowing down the transfer. However, for low-bandwidth transmissions, it makes sense to set it to `yes`. For significantly better speed in fast local networks, make sure that both the source and the target system support a common cipher, which is accelerated by [AES-NI](https://en.wikipedia.org/wiki/AES_instruction_set) on both ends. From f223dab4261c5f1f7418e31ea3119e7b0a75705e Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 15:44:21 +0200 Subject: [PATCH 123/135] log changes --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 024c65e..ec7f36e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,10 @@ ## 0.0.2 (2020-XX-XX) +- FEATURE: New, fully object oriented base library - FEATURE: Python 3.8 support added +- FIX: `cleanup` does not delete snapshots on source if they are not present on target. +- FIX: Wait for ZFS's garbage collection after `cleanup` for getting a meaningful value for freed space. - Dropped Python 3.5 support ## 0.0.1 (2019-08-05) From 9e8373bbf7c34a413d14518a4e0c7dac7878f466 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 15:44:29 +0200 Subject: [PATCH 124/135] details --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8d6e018..7d96b27 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## SYNOPSIS -Simple ZFS sync tool. It shows source and target ZFS zpool, dataset and snapshot trees. It creates meaningful snapshots only if datasets have actually been changed. It compares a source zpool tree to a target, backup zpool tree. It pushes backups from a source to a target. Cleanes up older snapshot on local system. Runs form the command line and produces nice, user-friendly, readable, colorized output. +`abgleich` is a simple ZFS sync tool. It shows source and target ZFS zpool, dataset and snapshot trees. It creates meaningful snapshots only if datasets have actually been changed. It compares a source zpool tree to a target, backup zpool tree. It pushes backups from a source to a target. It cleanes up older snapshots on the source side if they are present on the target side. It runs on the command line and produces nice, user-friendly, human-readable, colorized output. ## INSTALLATION @@ -14,7 +14,7 @@ Requires (C)Python 3.6 or later. Tested with [OpenZFS](https://en.wikipedia.org/ ## USAGE -All potentially changing or destructive actions are listed in detail before the user is asked to confirm them. None of the commands listed here will change a zpool, dataset or snapshot on its own without the user's explicit consent. +All potentially changing or destructive actions are listed in detail before the user is asked to confirm them. None of the commands listed below create, change or destroy a zpool, dataset or snapshot on their own without the user's explicit consent. ### `abgleich tree config.yaml [source|target]` From 59d286cb4ddaca405518d5de01a4b644ffbe505f Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 16:44:19 +0200 Subject: [PATCH 125/135] clarifications --- README.md | 77 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 7d96b27..9429b5b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## SYNOPSIS -`abgleich` is a simple ZFS sync tool. It shows source and target ZFS zpool, dataset and snapshot trees. It creates meaningful snapshots only if datasets have actually been changed. It compares a source zpool tree to a target, backup zpool tree. It pushes backups from a source to a target. It cleanes up older snapshots on the source side if they are present on the target side. It runs on the command line and produces nice, user-friendly, human-readable, colorized output. +`abgleich` is a simple ZFS sync tool. It displays source and target ZFS zpool, dataset and snapshot trees. It creates meaningful snapshots only if datasets have actually been changed. It compares a source zpool tree to a target, backup zpool tree. It pushes backups from a source to a target. It cleanes up older snapshots on the source side if they are present on the target side. It runs on a command line and produces nice, user-friendly, human-readable, colorized output. ## INSTALLATION @@ -10,39 +10,30 @@ pip install -vU git+https://github.com/pleiszenburg/abgleich.git@master ``` -Requires (C)Python 3.6 or later. Tested with [OpenZFS](https://en.wikipedia.org/wiki/OpenZFS) 0.8.x on Linux. +Requires [CPython](https://en.wikipedia.org/wiki/CPython) 3.6 or later, a [Unix shell](https://en.wikipedia.org/wiki/Unix_shell) and [ssh](https://en.wikipedia.org/wiki/Secure_Shell). Tested with [OpenZFS](https://en.wikipedia.org/wiki/OpenZFS) 0.8.x on Linux. -## USAGE - -All potentially changing or destructive actions are listed in detail before the user is asked to confirm them. None of the commands listed below create, change or destroy a zpool, dataset or snapshot on their own without the user's explicit consent. - -### `abgleich tree config.yaml [source|target]` +`abgleich`, CPython and the Unix shell must only be installed on one of the involved systems. Any remote system will be contacted via ssh and provided with direct ZFS commands. -Show zfs tree with snapshots, disk space and compression ratio. Append `source` or `target` (optional). `ssh` without password (public key) required if source and/or target is not equivalent to localhost. +## INITIALIZATION -### `abgleich snap config.yaml` - -Determine which datasets have been changed since last snapshot. Generate snapshots where applicable. `ssh` without password (public key) required if source and/or target is not equivalent to localhost. Superuser privileges required. +All actions involving a remote host assume that `ssh` with public key authentication instead of passwords is correctly configured and working. -### `abgleich compare config.yaml` +Let's assume that everything in `source_tank/data` and below should be synced with `target_tank/some_backup/data`. `source_tank` and `target_tank` are zpools. `data` is the "prefix" for the source zpool, `some_backup/data` is the corresponding "prefix" for the target zpool. For `abgleich` to work, `source_tank/data` and `target_tank/some_backup` must exist. `target_tank/some_backup/data` must not exist. The latter will be created by `abgleich`. It is highly recommended to set the mountpoint of `target_tank/some_backup` to `none` before running `abgleich` for the first time. -Compare local machine with remote host. See what is missing where. `ssh` without password (public key) required if source and/or target is not equivalent to localhost. - -### `abgleich backup config.yaml` +Right to run the following commands are required: -Send (new) datasets and snapshots to target host. `ssh` without password (public key) required if source and/or target is not equivalent to localhost. Superuser privileges required. - -### `abgleich cleanup config.yaml` - -Cleanup older local snapshots. Keep `keep_snapshots` number of snapshots. `ssh` without password (public key) required if source and/or target is not equivalent to localhost. Superuser privileges required. - -### Speed - -For (recommended) safety, `abgleich` runs fully statically typed by default, i.e. insanely slow. For must higher speed, the checks can be deactivated by setting the `PYTHONOPTIMIZE` environment variable to `1` or `2`, e.g. `PYTHONOPTIMIZE=1 abgleich tree config.yaml`. +| command | source | target | +|----------------|:------:|:------:| +| `zfs list` | x | x | +| `zfs get` | x | x | +| `zfs snapshot` | x | | +| `zfs send` | x | | +| `zfs receive` | | x | +| `zfs destroy` | x | | ### `config.yaml` -Example configuration file: +Complete example configuration file: ```yaml source: @@ -59,11 +50,41 @@ keep_snapshots: 2 suffix: _backup digits: 2 ignore: - - user/CACHE - - user/CCACHE + - home/user/CACHE + - home/user/CCACHE ssh: compression: no cipher: aes256-gcm@openssh.com ``` -The prefix can be empty on either side. If a `host` is `localhost`, the `user` field can be left empty. Both source and target can be remote hosts at the same time or localhost at the same time. `keep_snapshots` is an integer and must greater or equal to `1`. `suffix` describes the name suffix for new snapshots. `digits` specifies how many digits are used for a decimal number describing the n-th snapshot per dataset per day as part of the name of new snapshots. `ignore` lists stuff underneath the `prefix` which will be ignored by this tool, i.e. no snapshots, backups or cleanups. `ssh` allows to fine-tune the speed of backups. In fast local networks, it is best to set `compression` to `no` because the compression is usually slowing down the transfer. However, for low-bandwidth transmissions, it makes sense to set it to `yes`. For significantly better speed in fast local networks, make sure that both the source and the target system support a common cipher, which is accelerated by [AES-NI](https://en.wikipedia.org/wiki/AES_instruction_set) on both ends. +The prefix can be empty on either side. If a `host` is set to `localhost`, the `user` field can be left empty. Both source and target can be remote hosts or localhost at the same time. `keep_snapshots` is an integer and must be greater or equal to `1` It specifies the number of snapshots that are kept per dataset on the source side when a cleanup operation is triggered. `suffix` contains the name suffix for new snapshots. `digits` specifies how many digits are used for a decimal number describing the n-th snapshot per dataset per day as part of the name of new snapshots. `ignore` lists stuff underneath the `prefix` which will be ignored by this tool, i.e. no snapshots, backups or cleanups. `ssh` allows to fine-tune the speed of backups. In fast local networks, it is best to set `compression` to `no` because the compression is usually slowing down the transfer. However, for low-bandwidth transmissions, it makes sense to set it to `yes`. For significantly better speed in fast local networks, make sure that both the source and the target system support a common cipher, which is accelerated by [AES-NI](https://en.wikipedia.org/wiki/AES_instruction_set) on both ends. + +## USAGE + +All potentially changing or destructive actions are listed in detail before the user is asked to confirm them. None of the commands listed below create, change or destroy a zpool, dataset or snapshot on their own without the user's explicit consent. + +### `abgleich tree config.yaml [source|target]` + +Show ZFS tree with snapshots, disk space and compression ratio. Append `source` or `target` (optional). + +### `abgleich snap config.yaml` + +Determine which datasets on the source side have been changed since last snapshot. Generate snapshots on the source side where applicable. + +### `abgleich compare config.yaml` + +Compare source ZFS tree with target ZFS tree. See what is missing where. + +### `abgleich backup config.yaml` + +Send (new) datasets and new snapshots from source to target. + +### `abgleich cleanup config.yaml` + +Cleanup older local snapshots on source side if they are present on both sides. Of those snapshots present on both sides, keep at least `keep_snapshots` number of snapshots on source side. + +## SPEED + +`abgleich` uses Python's [type hints](https://docs.python.org/3/library/typing.html) and enforces them with [typeguard](https://github.com/agronholm/typeguard) at runtime. It furthermore makes countless assertions. + +The enforcement of types and assertions can be controlled through the `PYTHONOPTIMIZE` environment variable. If set to `0` (the implicit default value), all checks are activated. `abgleich` will run slow. For safety, this mode is highly recommended. For significantly higher speed, all type checks and most assertions can be deactivated by setting the `PYTHONOPTIMIZE` to `1` or `2`, e.g. `PYTHONOPTIMIZE=1 abgleich tree config.yaml`. This is not recommended. You may want to check if another tool has altered this environment variable already by running `echo $PYTHONOPTIMIZE`. From 1e0107b69d79a51ea1e03db9b33af418dea1619d Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 16:53:19 +0200 Subject: [PATCH 126/135] fixes --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9429b5b..bb460ca 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ All actions involving a remote host assume that `ssh` with public key authentica Let's assume that everything in `source_tank/data` and below should be synced with `target_tank/some_backup/data`. `source_tank` and `target_tank` are zpools. `data` is the "prefix" for the source zpool, `some_backup/data` is the corresponding "prefix" for the target zpool. For `abgleich` to work, `source_tank/data` and `target_tank/some_backup` must exist. `target_tank/some_backup/data` must not exist. The latter will be created by `abgleich`. It is highly recommended to set the mountpoint of `target_tank/some_backup` to `none` before running `abgleich` for the first time. -Right to run the following commands are required: +Rights to run the following commands are required: | command | source | target | |----------------|:------:|:------:| @@ -45,7 +45,7 @@ target: zpool: tank_hdd prefix: BACKUP_SOMEMACHINE host: bigdata - user: root + user: zfsadmin keep_snapshots: 2 suffix: _backup digits: 2 @@ -57,7 +57,7 @@ ssh: cipher: aes256-gcm@openssh.com ``` -The prefix can be empty on either side. If a `host` is set to `localhost`, the `user` field can be left empty. Both source and target can be remote hosts or localhost at the same time. `keep_snapshots` is an integer and must be greater or equal to `1` It specifies the number of snapshots that are kept per dataset on the source side when a cleanup operation is triggered. `suffix` contains the name suffix for new snapshots. `digits` specifies how many digits are used for a decimal number describing the n-th snapshot per dataset per day as part of the name of new snapshots. `ignore` lists stuff underneath the `prefix` which will be ignored by this tool, i.e. no snapshots, backups or cleanups. `ssh` allows to fine-tune the speed of backups. In fast local networks, it is best to set `compression` to `no` because the compression is usually slowing down the transfer. However, for low-bandwidth transmissions, it makes sense to set it to `yes`. For significantly better speed in fast local networks, make sure that both the source and the target system support a common cipher, which is accelerated by [AES-NI](https://en.wikipedia.org/wiki/AES_instruction_set) on both ends. +The prefix can be empty on either side. If a `host` is set to `localhost`, the `user` field can be left empty. Both source and target can be remote hosts or localhost at the same time. `keep_snapshots` is an integer and must be greater or equal to `1`. It specifies the number of snapshots that are kept per dataset on the source side when a cleanup operation is triggered. `suffix` contains the name suffix for new snapshots. `digits` specifies how many digits are used for a decimal number describing the n-th snapshot per dataset per day as part of the name of new snapshots. `ignore` lists stuff underneath the `prefix` which will be ignored by this tool, i.e. no snapshots, backups or cleanups. `ssh` allows to fine-tune the speed of backups. In fast local networks, it is best to set `compression` to `no` because the compression is usually slowing down the transfer. However, for low-bandwidth transmissions, it makes sense to set it to `yes`. For significantly better speed in fast local networks, make sure that both the source and the target system support a common cipher, which is accelerated by [AES-NI](https://en.wikipedia.org/wiki/AES_instruction_set) on both ends. ## USAGE @@ -87,4 +87,4 @@ Cleanup older local snapshots on source side if they are present on both sides. `abgleich` uses Python's [type hints](https://docs.python.org/3/library/typing.html) and enforces them with [typeguard](https://github.com/agronholm/typeguard) at runtime. It furthermore makes countless assertions. -The enforcement of types and assertions can be controlled through the `PYTHONOPTIMIZE` environment variable. If set to `0` (the implicit default value), all checks are activated. `abgleich` will run slow. For safety, this mode is highly recommended. For significantly higher speed, all type checks and most assertions can be deactivated by setting the `PYTHONOPTIMIZE` to `1` or `2`, e.g. `PYTHONOPTIMIZE=1 abgleich tree config.yaml`. This is not recommended. You may want to check if another tool has altered this environment variable already by running `echo $PYTHONOPTIMIZE`. +The enforcement of types and assertions can be controlled through the `PYTHONOPTIMIZE` environment variable. If set to `0` (the implicit default value), all checks are activated. `abgleich` will run slow. For safety, this mode is highly recommended. For significantly higher speed, all type checks and most assertions can be deactivated by setting `PYTHONOPTIMIZE` to `1` or `2`, e.g. `PYTHONOPTIMIZE=1 abgleich tree config.yaml`. This is not recommended. You may want to check if another tool or configuration has altered this environment variable by running `echo $PYTHONOPTIMIZE`. From 6403667d7ba8427da69077ab4b270102dfd52f35 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 17:40:26 +0200 Subject: [PATCH 127/135] clean structure --- src/abgleich/abc.py | 38 ------------------ src/abgleich/cli/backup.py | 4 +- src/abgleich/cli/cleanup.py | 6 +-- src/abgleich/cli/compare.py | 4 +- src/abgleich/cli/snap.py | 4 +- src/abgleich/cli/tree.py | 4 +- src/abgleich/{zfs => core}/__init__.py | 2 +- src/abgleich/{zfs => core}/abc.py | 5 ++- src/abgleich/{ => core}/command.py | 2 +- src/abgleich/{zfs => core}/comparison.py | 2 +- src/abgleich/{ => core}/config.py | 4 +- src/abgleich/{zfs => core}/dataset.py | 4 +- src/abgleich/{ => core}/io.py | 2 +- src/abgleich/{zfs => core}/lib.py | 2 +- src/abgleich/{zfs => core}/property.py | 2 +- src/abgleich/{zfs => core}/snapshot.py | 4 +- src/abgleich/{zfs => core}/transaction.py | 7 ++-- src/abgleich/{zfs => core}/zpool.py | 6 +-- src/abgleich/zfs/clone.py | 48 ----------------------- 19 files changed, 33 insertions(+), 117 deletions(-) delete mode 100644 src/abgleich/abc.py rename src/abgleich/{zfs => core}/__init__.py (93%) rename src/abgleich/{zfs => core}/abc.py (94%) rename src/abgleich/{ => core}/command.py (98%) rename src/abgleich/{zfs => core}/comparison.py (99%) rename src/abgleich/{ => core}/config.py (97%) rename src/abgleich/{zfs => core}/dataset.py (98%) rename src/abgleich/{ => core}/io.py (98%) rename src/abgleich/{zfs => core}/lib.py (97%) rename src/abgleich/{zfs => core}/property.py (98%) rename src/abgleich/{zfs => core}/snapshot.py (98%) rename src/abgleich/{zfs => core}/transaction.py (96%) rename src/abgleich/{zfs => core}/zpool.py (98%) delete mode 100644 src/abgleich/zfs/clone.py diff --git a/src/abgleich/abc.py b/src/abgleich/abc.py deleted file mode 100644 index edc9f0b..0000000 --- a/src/abgleich/abc.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- coding: utf-8 -*- - -""" - -ABGLEICH -zfs sync tool -https://github.com/pleiszenburg/abgleich - - src/abgleich/abc.py: Abstract base classes - - Copyright (C) 2019-2020 Sebastian M. Ernst - - -The contents of this file are subject to the GNU Lesser General Public License -Version 2.1 ("LGPL" or "License"). You may not use this file except in -compliance with the License. You may obtain a copy of the License at -https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt -https://github.com/pleiszenburg/abgleich/blob/master/LICENSE - -Software distributed under the License is distributed on an "AS IS" basis, -WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the -specific language governing rights and limitations under the License. - - -""" - -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# IMPORT -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -import abc - -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# CLASSES -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -class CommandABC(abc.ABC): - pass diff --git a/src/abgleich/cli/backup.py b/src/abgleich/cli/backup.py index bc1fbc6..9381ed2 100644 --- a/src/abgleich/cli/backup.py +++ b/src/abgleich/cli/backup.py @@ -31,8 +31,8 @@ import click -from ..config import Config -from ..zfs.zpool import Zpool +from ..core.config import Config +from ..core.zpool import Zpool # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES diff --git a/src/abgleich/cli/cleanup.py b/src/abgleich/cli/cleanup.py index bc88fb2..cf0a461 100644 --- a/src/abgleich/cli/cleanup.py +++ b/src/abgleich/cli/cleanup.py @@ -33,9 +33,9 @@ import click -from ..config import Config -from ..zfs.zpool import Zpool -from ..io import humanize_size +from ..core.config import Config +from ..core.io import humanize_size +from ..core.zpool import Zpool # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES diff --git a/src/abgleich/cli/compare.py b/src/abgleich/cli/compare.py index 1249caf..b7c3362 100644 --- a/src/abgleich/cli/compare.py +++ b/src/abgleich/cli/compare.py @@ -31,8 +31,8 @@ import click -from ..config import Config -from ..zfs.zpool import Zpool +from ..core.config import Config +from ..core.zpool import Zpool # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES diff --git a/src/abgleich/cli/snap.py b/src/abgleich/cli/snap.py index c22ed2d..d1654b6 100644 --- a/src/abgleich/cli/snap.py +++ b/src/abgleich/cli/snap.py @@ -31,8 +31,8 @@ import click -from ..config import Config -from ..zfs.zpool import Zpool +from ..core.config import Config +from ..core.zpool import Zpool # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES diff --git a/src/abgleich/cli/tree.py b/src/abgleich/cli/tree.py index feea380..ed7acf0 100644 --- a/src/abgleich/cli/tree.py +++ b/src/abgleich/cli/tree.py @@ -31,8 +31,8 @@ import click -from ..config import Config -from ..zfs.zpool import Zpool +from ..core.config import Config +from ..core.zpool import Zpool # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # ROUTINES diff --git a/src/abgleich/zfs/__init__.py b/src/abgleich/core/__init__.py similarity index 93% rename from src/abgleich/zfs/__init__.py rename to src/abgleich/core/__init__.py index c3221d3..edf8425 100644 --- a/src/abgleich/zfs/__init__.py +++ b/src/abgleich/core/__init__.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/__init__.py: ZFS package root + src/abgleich/core/__init__.py: Core package root Copyright (C) 2019-2020 Sebastian M. Ernst diff --git a/src/abgleich/zfs/abc.py b/src/abgleich/core/abc.py similarity index 94% rename from src/abgleich/zfs/abc.py rename to src/abgleich/core/abc.py index 227ca28..cf4c99b 100644 --- a/src/abgleich/zfs/abc.py +++ b/src/abgleich/core/abc.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/abc.py: Abstract base classes + src/abgleich/core/abc.py: Abstract base classes Copyright (C) 2019-2020 Sebastian M. Ernst @@ -37,6 +37,9 @@ class CloneABC(abc.ABC): pass +class CommandABC(abc.ABC): + pass + class ComparisonABC(abc.ABC): pass diff --git a/src/abgleich/command.py b/src/abgleich/core/command.py similarity index 98% rename from src/abgleich/command.py rename to src/abgleich/core/command.py index 29708be..5058767 100644 --- a/src/abgleich/command.py +++ b/src/abgleich/core/command.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/command.py: Sub-process wrapper for commands + src/abgleich/core/command.py: Sub-process wrapper for commands Copyright (C) 2019-2020 Sebastian M. Ernst diff --git a/src/abgleich/zfs/comparison.py b/src/abgleich/core/comparison.py similarity index 99% rename from src/abgleich/zfs/comparison.py rename to src/abgleich/core/comparison.py index 4e2a9ab..236d22d 100644 --- a/src/abgleich/zfs/comparison.py +++ b/src/abgleich/core/comparison.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/comparison.py: ZFS comparison + src/abgleich/core/comparison.py: ZFS comparison Copyright (C) 2019-2020 Sebastian M. Ernst diff --git a/src/abgleich/config.py b/src/abgleich/core/config.py similarity index 97% rename from src/abgleich/config.py rename to src/abgleich/core/config.py index 4c2bdfa..0d351e3 100644 --- a/src/abgleich/config.py +++ b/src/abgleich/core/config.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/config.py: Handles configuration data + src/abgleich/core/config.py: Handles configuration data Copyright (C) 2019-2020 Sebastian M. Ernst @@ -35,7 +35,7 @@ import yaml from yaml import CLoader -from .zfs.lib import valid_name +from .lib import valid_name # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS diff --git a/src/abgleich/zfs/dataset.py b/src/abgleich/core/dataset.py similarity index 98% rename from src/abgleich/zfs/dataset.py rename to src/abgleich/core/dataset.py index 986dbf2..ee23ace 100644 --- a/src/abgleich/zfs/dataset.py +++ b/src/abgleich/core/dataset.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/dataset.py: ZFS dataset + src/abgleich/core/dataset.py: ZFS dataset Copyright (C) 2019-2020 Sebastian M. Ernst @@ -34,11 +34,11 @@ import typeguard from .abc import DatasetABC, PropertyABC, TransactionABC, SnapshotABC +from .command import Command from .lib import root from .property import Property from .transaction import Transaction, TransactionMeta from .snapshot import Snapshot -from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS diff --git a/src/abgleich/io.py b/src/abgleich/core/io.py similarity index 98% rename from src/abgleich/io.py rename to src/abgleich/core/io.py index 6569170..425d2eb 100644 --- a/src/abgleich/io.py +++ b/src/abgleich/core/io.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/io.py: Command line IO + src/abgleich/core/io.py: Command line IO Copyright (C) 2019-2020 Sebastian M. Ernst diff --git a/src/abgleich/zfs/lib.py b/src/abgleich/core/lib.py similarity index 97% rename from src/abgleich/zfs/lib.py rename to src/abgleich/core/lib.py index 901b2cf..108453e 100644 --- a/src/abgleich/zfs/lib.py +++ b/src/abgleich/core/lib.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/lib.py: ZFS library + src/abgleich/core/lib.py: ZFS library Copyright (C) 2019-2020 Sebastian M. Ernst diff --git a/src/abgleich/zfs/property.py b/src/abgleich/core/property.py similarity index 98% rename from src/abgleich/zfs/property.py rename to src/abgleich/core/property.py index d8428be..79e760e 100644 --- a/src/abgleich/zfs/property.py +++ b/src/abgleich/core/property.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/filesystem.py: ZFS filesystem + src/abgleich/core/filesystem.py: ZFS filesystem Copyright (C) 2019-2020 Sebastian M. Ernst diff --git a/src/abgleich/zfs/snapshot.py b/src/abgleich/core/snapshot.py similarity index 98% rename from src/abgleich/zfs/snapshot.py rename to src/abgleich/core/snapshot.py index f3a412d..0dc8973 100644 --- a/src/abgleich/zfs/snapshot.py +++ b/src/abgleich/core/snapshot.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/snapshot.py: ZFS snapshot + src/abgleich/core/snapshot.py: ZFS snapshot Copyright (C) 2019-2020 Sebastian M. Ernst @@ -33,10 +33,10 @@ import typeguard from .abc import PropertyABC, SnapshotABC, TransactionABC +from .command import Command from .lib import root from .property import Property from .transaction import Transaction, TransactionMeta -from ..command import Command # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS diff --git a/src/abgleich/zfs/transaction.py b/src/abgleich/core/transaction.py similarity index 96% rename from src/abgleich/zfs/transaction.py rename to src/abgleich/core/transaction.py index 5049379..e9f2276 100644 --- a/src/abgleich/zfs/transaction.py +++ b/src/abgleich/core/transaction.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/transaction.py: ZFS transactions + src/abgleich/core/transaction.py: ZFS transactions Copyright (C) 2019-2020 Sebastian M. Ernst @@ -33,9 +33,8 @@ from tabulate import tabulate import typeguard -from .abc import TransactionABC, TransactionListABC, TransactionMetaABC -from ..abc import CommandABC -from ..io import colorize, humanize_size +from .abc import CommandABC, TransactionABC, TransactionListABC, TransactionMetaABC +from .io import colorize, humanize_size # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS diff --git a/src/abgleich/zfs/zpool.py b/src/abgleich/core/zpool.py similarity index 98% rename from src/abgleich/zfs/zpool.py rename to src/abgleich/core/zpool.py index 64daa3a..7f2c0f2 100644 --- a/src/abgleich/zfs/zpool.py +++ b/src/abgleich/core/zpool.py @@ -6,7 +6,7 @@ zfs sync tool https://github.com/pleiszenburg/abgleich - src/abgleich/zfs/zpool.py: ZFS zpool + src/abgleich/core/zpool.py: ZFS zpool Copyright (C) 2019-2020 Sebastian M. Ernst @@ -35,13 +35,13 @@ import typeguard from .abc import ComparisonItemABC, DatasetABC, SnapshotABC, TransactionListABC, ZpoolABC +from .command import Command from .comparison import Comparison from .dataset import Dataset +from .io import colorize, humanize_size from .lib import join, root from .property import Property from .transaction import TransactionList -from ..command import Command -from ..io import colorize, humanize_size # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS diff --git a/src/abgleich/zfs/clone.py b/src/abgleich/zfs/clone.py deleted file mode 100644 index ac0088c..0000000 --- a/src/abgleich/zfs/clone.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- - -""" - -ABGLEICH -zfs sync tool -https://github.com/pleiszenburg/abgleich - - src/abgleich/zfs/clone.py: ZFS clone - - Copyright (C) 2019-2020 Sebastian M. Ernst - - -The contents of this file are subject to the GNU Lesser General Public License -Version 2.1 ("LGPL" or "License"). You may not use this file except in -compliance with the License. You may obtain a copy of the License at -https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt -https://github.com/pleiszenburg/abgleich/blob/master/LICENSE - -Software distributed under the License is distributed on an "AS IS" basis, -WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the -specific language governing rights and limitations under the License. - - -""" - -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# IMPORT -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -import typeguard - -from .abc import CloneABC - -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# CLASS -# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -@typeguard.typechecked -class Clone(CloneABC): - - def __init__(self): - pass - - @classmethod - def from_shell(cls) -> CloneABC: - - return cls() From b5afa734995d3e9a426a9800823912725e438bd9 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 17:43:22 +0200 Subject: [PATCH 128/135] delete all pyc files --- makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/makefile b/makefile index 06763c8..dd356a7 100644 --- a/makefile +++ b/makefile @@ -1,7 +1,7 @@ clean: -rm -r build/* - find src/ -name '*.pyc' -exec rm -f {} + + find src/ -name '*.pyc' -exec sudo rm -f {} + find src/ -name '*.pyo' -exec rm -f {} + find src/ -name '*~' -exec rm -f {} + find src/ -name '__pycache__' -exec rm -fr {} + From b73fc958ec0b4b11489f7068b6eb84a4eb4756b3 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 17:49:46 +0200 Subject: [PATCH 129/135] black --- src/abgleich/cli/backup.py | 6 +- src/abgleich/cli/cleanup.py | 16 +-- src/abgleich/cli/compare.py | 4 +- src/abgleich/cli/snap.py | 4 +- src/abgleich/cli/tree.py | 3 +- src/abgleich/core/abc.py | 11 ++ src/abgleich/core/command.py | 67 +++++++----- src/abgleich/core/comparison.py | 166 +++++++++++++++--------------- src/abgleich/core/config.py | 9 +- src/abgleich/core/dataset.py | 118 ++++++++++++---------- src/abgleich/core/lib.py | 14 ++- src/abgleich/core/property.py | 18 ++-- src/abgleich/core/snapshot.py | 108 ++++++++++---------- src/abgleich/core/transaction.py | 45 ++++----- src/abgleich/core/zpool.py | 168 +++++++++++++++++++------------ 15 files changed, 415 insertions(+), 342 deletions(-) diff --git a/src/abgleich/cli/backup.py b/src/abgleich/cli/backup.py index 9381ed2..543e75f 100644 --- a/src/abgleich/cli/backup.py +++ b/src/abgleich/cli/backup.py @@ -45,13 +45,13 @@ def backup(configfile): config = Config.from_fd(configfile) - source_zpool = Zpool.from_config('source', config = config) - target_zpool = Zpool.from_config('target', config = config) + source_zpool = Zpool.from_config("source", config=config) + target_zpool = Zpool.from_config("target", config=config) transactions = source_zpool.get_backup_transactions(target_zpool) if len(transactions) == 0: - print('nothing to do') + print("nothing to do") return transactions.print_table() diff --git a/src/abgleich/cli/cleanup.py b/src/abgleich/cli/cleanup.py index cf0a461..dfc63a0 100644 --- a/src/abgleich/cli/cleanup.py +++ b/src/abgleich/cli/cleanup.py @@ -48,14 +48,14 @@ def cleanup(configfile): config = Config.from_fd(configfile) - source_zpool = Zpool.from_config('source', config = config) - target_zpool = Zpool.from_config('target', config = config) - available_before = Zpool.available('source', config = config) + source_zpool = Zpool.from_config("source", config=config) + target_zpool = Zpool.from_config("target", config=config) + available_before = Zpool.available("source", config=config) transactions = source_zpool.get_cleanup_transactions(target_zpool) if len(transactions) == 0: - print('nothing to do') + print("nothing to do") return transactions.print_table() @@ -64,7 +64,9 @@ def cleanup(configfile): transactions.run() WAIT = 10 - print(f'waiting {WAIT:d} seconds ...') + print(f"waiting {WAIT:d} seconds ...") time.sleep(WAIT) - available_after = Zpool.available('source', config = config) - print(f'{humanize_size(available_after, add_color = True):s} available, {humanize_size(available_after - available_before, add_color = True):s} freed') + available_after = Zpool.available("source", config=config) + print( + f"{humanize_size(available_after, add_color = True):s} available, {humanize_size(available_after - available_before, add_color = True):s} freed" + ) diff --git a/src/abgleich/cli/compare.py b/src/abgleich/cli/compare.py index b7c3362..18afc6a 100644 --- a/src/abgleich/cli/compare.py +++ b/src/abgleich/cli/compare.py @@ -45,7 +45,7 @@ def compare(configfile): config = Config.from_fd(configfile) - source_zpool = Zpool.from_config('source', config = config) - target_zpool = Zpool.from_config('target', config = config) + source_zpool = Zpool.from_config("source", config=config) + target_zpool = Zpool.from_config("target", config=config) source_zpool.print_comparison_table(target_zpool) diff --git a/src/abgleich/cli/snap.py b/src/abgleich/cli/snap.py index d1654b6..e3d1d04 100644 --- a/src/abgleich/cli/snap.py +++ b/src/abgleich/cli/snap.py @@ -43,11 +43,11 @@ @click.argument("configfile", type=click.File("r", encoding="utf-8")) def snap(configfile): - zpool = Zpool.from_config('source', config = Config.from_fd(configfile)) + zpool = Zpool.from_config("source", config=Config.from_fd(configfile)) transactions = zpool.get_snapshot_transactions() if len(transactions) == 0: - print('nothing to do') + print("nothing to do") return transactions.print_table() diff --git a/src/abgleich/cli/tree.py b/src/abgleich/cli/tree.py index ed7acf0..9ff9eba 100644 --- a/src/abgleich/cli/tree.py +++ b/src/abgleich/cli/tree.py @@ -38,10 +38,11 @@ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @click.command(short_help="show dataset tree") @click.argument("configfile", type=click.File("r", encoding="utf-8")) @click.argument("side", default="source", type=str) def tree(configfile, side): - zpool = Zpool.from_config(side, config = Config.from_fd(configfile)) + zpool = Zpool.from_config(side, config=Config.from_fd(configfile)) zpool.print_table() diff --git a/src/abgleich/core/abc.py b/src/abgleich/core/abc.py index cf4c99b..7b30159 100644 --- a/src/abgleich/core/abc.py +++ b/src/abgleich/core/abc.py @@ -34,35 +34,46 @@ # CLASSES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + class CloneABC(abc.ABC): pass + class CommandABC(abc.ABC): pass + class ComparisonABC(abc.ABC): pass + class ComparisonItemABC(abc.ABC): pass + class DatasetABC(abc.ABC): pass + class PropertyABC(abc.ABC): pass + class SnapshotABC(abc.ABC): pass + class TransactionABC(abc.ABC): pass + class TransactionListABC(abc.ABC): pass + class TransactionMetaABC(abc.ABC): pass + class ZpoolABC(abc.ABC): pass diff --git a/src/abgleich/core/command.py b/src/abgleich/core/command.py index 5058767..ba43f7e 100644 --- a/src/abgleich/core/command.py +++ b/src/abgleich/core/command.py @@ -39,33 +39,42 @@ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @typeguard.typechecked class Command(CommandABC): - def __init__(self, cmd: typing.List[str]): self._cmd = cmd.copy() def __str__(self) -> str: - return ' '.join(self._cmd) + return " ".join(self._cmd) def run(self): - proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc = subprocess.Popen( + self.cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) output, errors = proc.communicate() status = not bool(proc.returncode) output, errors = output.decode("utf-8"), errors.decode("utf-8") if not status or len(errors.strip()) > 0: - raise SystemError('command failed', self.cmd, output, errors) + raise SystemError("command failed", self.cmd, output, errors) return output, errors def run_pipe(self, other: CommandABC): - proc_1 = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - proc_2 = subprocess.Popen(other.cmd, stdin=proc_1.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc_1 = subprocess.Popen( + self.cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + proc_2 = subprocess.Popen( + other.cmd, + stdin=proc_1.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) output_2, errors_2 = proc_2.communicate() status_2 = not bool(proc_2.returncode) @@ -75,13 +84,17 @@ def run_pipe(self, other: CommandABC): errors_1 = errors_1.decode("utf-8") output_2, errors_2 = output_2.decode("utf-8"), errors_2.decode("utf-8") - if any(( - not status_1, - len(errors_1.strip()) > 0, - not status_2, - len(errors_2.strip()) > 0, - )): - raise SystemError('command pipe failed', self.cmd, other.cmd, errors_1, output_2, errors_2) + if any( + ( + not status_1, + len(errors_1.strip()) > 0, + not status_2, + len(errors_2.strip()) > 0, + ) + ): + raise SystemError( + "command pipe failed", self.cmd, other.cmd, errors_1, output_2, errors_2 + ) return errors_1, output_2, errors_2 @@ -91,26 +104,28 @@ def cmd(self) -> typing.List[str]: return self._cmd.copy() @classmethod - def on_side(cls, cmd: typing.List[str], side: str, config: typing.Dict) -> CommandABC: + def on_side( + cls, cmd: typing.List[str], side: str, config: typing.Dict + ) -> CommandABC: - if config[side]['host'] == 'localhost': + if config[side]["host"] == "localhost": return cls(cmd) - return cls.with_ssh(cmd, side_config = config[side], ssh_config = config['ssh']) + return cls.with_ssh(cmd, side_config=config[side], ssh_config=config["ssh"]) @classmethod - def with_ssh(cls, cmd: typing.List[str], side_config: typing.Dict, ssh_config: typing.Dict) -> CommandABC: + def with_ssh( + cls, cmd: typing.List[str], side_config: typing.Dict, ssh_config: typing.Dict + ) -> CommandABC: cmd_str = " ".join([item.replace(" ", "\\ ") for item in cmd]) cmd = [ "ssh", - "-T", # Disable pseudo-terminal allocation - "-o", "Compression=yes" if ssh_config['compression'] else "Compression=no", - ] - if ssh_config['cipher'] is not None: - cmd.extend(("-c", ssh_config['cipher'])) - cmd.extend([ - f'{side_config["user"]:s}@{side_config["host"]:s}', - cmd_str - ]) + "-T", # Disable pseudo-terminal allocation + "-o", + "Compression=yes" if ssh_config["compression"] else "Compression=no", + ] + if ssh_config["cipher"] is not None: + cmd.extend(("-c", ssh_config["cipher"])) + cmd.extend([f'{side_config["user"]:s}@{side_config["host"]:s}', cmd_str]) return cls(cmd) diff --git a/src/abgleich/core/comparison.py b/src/abgleich/core/comparison.py index 236d22d..b1b541a 100644 --- a/src/abgleich/core/comparison.py +++ b/src/abgleich/core/comparison.py @@ -40,37 +40,31 @@ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ ComparisonParentTypes = typing.Union[ - ZpoolABC, - DatasetABC, - None, - ] + ZpoolABC, DatasetABC, None, +] ComparisonMergeTypes = typing.Union[ - typing.Generator[DatasetABC, None, None], - typing.Generator[SnapshotABC, None, None], - ] + typing.Generator[DatasetABC, None, None], typing.Generator[SnapshotABC, None, None], +] ComparisonItemType = typing.Union[ - DatasetABC, - SnapshotABC, - None, - ] + DatasetABC, SnapshotABC, None, +] ComparisonStrictItemType = typing.Union[ - DatasetABC, - SnapshotABC, - ] + DatasetABC, SnapshotABC, +] # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @typeguard.typechecked class Comparison(ComparisonABC): - def __init__( self, a: ComparisonParentTypes, b: ComparisonParentTypes, merged: typing.List[ComparisonItemABC], - ): + ): assert a is not None or b is not None if a is not None and b is not None: @@ -87,16 +81,16 @@ def a(self) -> ComparisonParentTypes: def a_head(self) -> typing.List[ComparisonStrictItemType]: return self._head( - source = [item.a for item in self._merged], - target = [item.b for item in self._merged], + source=[item.a for item in self._merged], + target=[item.b for item in self._merged], ) @property def a_overlap_tail(self) -> typing.List[ComparisonStrictItemType]: return self._overlap_tail( - source = [item.a for item in self._merged], - target = [item.b for item in self._merged], + source=[item.a for item in self._merged], + target=[item.b for item in self._merged], ) @property @@ -108,16 +102,16 @@ def b(self) -> ComparisonParentTypes: def b_head(self) -> typing.List[ComparisonStrictItemType]: return self._head( - source = [item.b for item in self._merged], - target = [item.a for item in self._merged], + source=[item.b for item in self._merged], + target=[item.a for item in self._merged], ) @property def b_overlap_tail(self) -> typing.List[ComparisonStrictItemType]: return self._overlap_tail( - source = [item.b for item in self._merged], - target = [item.a for item in self._merged], + source=[item.b for item in self._merged], + target=[item.a for item in self._merged], ) @property @@ -140,36 +134,40 @@ def _head( source, target = cls._strip_none(source), cls._strip_none(target) if any((element is None for element in source)): - raise ValueError('source is not consecutive') + raise ValueError("source is not consecutive") if any((element is None for element in target)): - raise ValueError('target is not consecutive') + raise ValueError("target is not consecutive") if len(source) == 0: - raise ValueError('source must not be empty') + raise ValueError("source must not be empty") if len(set([item.name for item in source])) != len(source): - raise ValueError('source contains doublicate entires') + raise ValueError("source contains doublicate entires") if len(set([item.name for item in target])) != len(target): - raise ValueError('target contains doublicate entires') + raise ValueError("target contains doublicate entires") if len(target) == 0: - return source # all of source, target is empty + return source # all of source, target is empty try: source_index = [item.name for item in source].index(target[-1].name) except ValueError: - raise ValueError('last target element not in source') + raise ValueError("last target element not in source") - old_source = source[:source_index+1] + old_source = source[: source_index + 1] if len(old_source) <= len(target): - if target[-len(old_source):] != old_source: - raise ValueError('no clean match between end of target and beginning of source') + if target[-len(old_source) :] != old_source: + raise ValueError( + "no clean match between end of target and beginning of source" + ) else: - if target != source[source_index+1-len(target):source_index+1]: - raise ValueError('no clean match between entire target and beginning of source') + if target != source[source_index + 1 - len(target) : source_index + 1]: + raise ValueError( + "no clean match between entire target and beginning of source" + ) - return source[source_index+1:] + return source[source_index + 1 :] @classmethod def _overlap_tail( @@ -187,17 +185,17 @@ def _overlap_tail( return [] if any((element is None for element in source)): - raise ValueError('source is not consecutive') + raise ValueError("source is not consecutive") if any((element is None for element in target)): - raise ValueError('target is not consecutive') + raise ValueError("target is not consecutive") source_names = {item.name for item in source} target_names = {item.name for item in target} if len(source_names) != len(source): - raise ValueError('source contains doublicate entires') + raise ValueError("source contains doublicate entires") if len(target_names) != len(target): - raise ValueError('target contains doublicate entires') + raise ValueError("target contains doublicate entires") overlap_tail = [] for item in source: @@ -209,27 +207,26 @@ def _overlap_tail( return overlap_tail target_index = target.index(overlap_tail[0]) - if overlap_tail != target[target_index:target_index+len(overlap_tail)]: - raise ValueError('no clean match in overlap area') + if overlap_tail != target[target_index : target_index + len(overlap_tail)]: + raise ValueError("no clean match in overlap area") return overlap_tail @classmethod def _strip_none( - cls, - elements: typing.List[ComparisonItemType] + cls, elements: typing.List[ComparisonItemType] ) -> typing.List[ComparisonItemType]: - elements = cls._left_strip_none(elements) # left strip - elements.reverse() # flip into reverse - elements = cls._left_strip_none(elements) # right strip - elements.reverse() # flip back + elements = cls._left_strip_none(elements) # left strip + elements.reverse() # flip into reverse + elements = cls._left_strip_none(elements) # right strip + elements.reverse() # flip back return elements @staticmethod def _left_strip_none( - elements: typing.List[ComparisonItemType] + elements: typing.List[ComparisonItemType], ) -> typing.List[ComparisonItemType]: return list(itertools.dropwhile(lambda element: element is None, elements)) @@ -238,7 +235,7 @@ def _left_strip_none( def _single_items( items_a: typing.Union[ComparisonMergeTypes, None], items_b: typing.Union[ComparisonMergeTypes, None], - ) -> typing.List[ComparisonItemABC]: + ) -> typing.List[ComparisonItemABC]: assert items_a is not None or items_b is not None @@ -250,7 +247,7 @@ def _single_items( def _merge_datasets( items_a: typing.Generator[DatasetABC, None, None], items_b: typing.Generator[DatasetABC, None, None], - ) -> typing.List[ComparisonItemABC]: + ) -> typing.List[ComparisonItemABC]: items_a = {item.subname: item for item in items_a} items_b = {item.subname: item for item in items_b} @@ -259,8 +256,8 @@ def _merge_datasets( merged = [ ComparisonItem(items_a.get(name, None), items_b.get(name, None)) for name in names - ] - merged.sort(key = lambda item: item.get_item().name) + ] + merged.sort(key=lambda item: item.get_item().name) return merged @@ -269,17 +266,17 @@ def from_zpools( cls, zpool_a: typing.Union[ZpoolABC, None], zpool_b: typing.Union[ZpoolABC, None], - ) -> ComparisonABC: + ) -> ComparisonABC: assert zpool_a is not None or zpool_b is not None if zpool_a is None or zpool_b is None: return cls( - a = zpool_a, - b = zpool_b, - merged = cls._single_items( - getattr(zpool_a, 'datasets', None), - getattr(zpool_b, 'datasets', None), + a=zpool_a, + b=zpool_b, + merged=cls._single_items( + getattr(zpool_a, "datasets", None), + getattr(zpool_b, "datasets", None), ), ) @@ -287,9 +284,9 @@ def from_zpools( assert zpool_a != zpool_b return cls( - a = zpool_a, - b = zpool_b, - merged = cls._merge_datasets(zpool_a.datasets, zpool_b.datasets), + a=zpool_a, + b=zpool_b, + merged=cls._merge_datasets(zpool_a.datasets, zpool_b.datasets), ) @staticmethod @@ -303,8 +300,8 @@ def _merge_snapshots( names_a = [item.name for item in items_a] names_b = [item.name for item in items_b] - assert len(set(names_a)) == len(items_a) # unique names - assert len(set(names_b)) == len(items_b) # unique names + assert len(set(names_a)) == len(items_a) # unique names + assert len(set(names_b)) == len(items_b) # unique names if len(items_a) == 0 and len(items_b) == 0: return [] @@ -322,14 +319,22 @@ def _merge_snapshots( except ValueError: start_a = None - assert start_a is not None or start_b is not None # overlap + assert start_a is not None or start_b is not None # overlap prefix_a = [] if start_a is None else [None for _ in range(start_a)] prefix_b = [] if start_b is None else [None for _ in range(start_b)] items_a = prefix_a + items_a items_b = prefix_b + items_b - suffix_a = [] if len(items_a) >= len(items_b) else [None for _ in range(len(items_b) - len(items_a))] - suffix_b = [] if len(items_b) >= len(items_a) else [None for _ in range(len(items_a) - len(items_b))] + suffix_a = ( + [] + if len(items_a) >= len(items_b) + else [None for _ in range(len(items_b) - len(items_a))] + ) + suffix_b = ( + [] + if len(items_b) >= len(items_a) + else [None for _ in range(len(items_a) - len(items_b))] + ) items_a = items_a + suffix_a items_b = items_b + suffix_b @@ -342,14 +347,14 @@ def _merge_snapshots( if new_state_a != state_a: alt_a, state_a = alt_a + 1, new_state_a if alt_a > 2: - raise ValueError('gap in snapshot series') + raise ValueError("gap in snapshot series") if new_state_b != state_b: alt_b, state_b = alt_b + 1, new_state_b if alt_b > 2: - raise ValueError('gap in snapshot series') + raise ValueError("gap in snapshot series") if state_a and state_b: if item_a.name != item_b.name: - raise ValueError('inconsistent snapshot names') + raise ValueError("inconsistent snapshot names") merged.append(ComparisonItem(item_a, item_b)) return merged @@ -359,17 +364,17 @@ def from_datasets( cls, dataset_a: typing.Union[DatasetABC, None], dataset_b: typing.Union[DatasetABC, None], - ) -> ComparisonABC: + ) -> ComparisonABC: assert dataset_a is not None or dataset_b is not None if dataset_a is None or dataset_b is None: return cls( - a = dataset_a, - b = dataset_b, - merged = cls._single_items( - getattr(dataset_a, 'snapshots', None), - getattr(dataset_b, 'snapshots', None), + a=dataset_a, + b=dataset_b, + merged=cls._single_items( + getattr(dataset_a, "snapshots", None), + getattr(dataset_b, "snapshots", None), ), ) @@ -377,15 +382,14 @@ def from_datasets( assert dataset_a == dataset_b return cls( - a = dataset_a, - b = dataset_b, - merged = cls._merge_snapshots(dataset_a.snapshots, dataset_b.snapshots), + a=dataset_a, + b=dataset_b, + merged=cls._merge_snapshots(dataset_a.snapshots, dataset_b.snapshots), ) @typeguard.typechecked class ComparisonItem(ComparisonItemABC): - def __init__(self, a: ComparisonItemType, b: ComparisonItemType): assert a is not None or b is not None diff --git a/src/abgleich/core/config.py b/src/abgleich/core/config.py index 0d351e3..f207f33 100644 --- a/src/abgleich/core/config.py +++ b/src/abgleich/core/config.py @@ -44,7 +44,6 @@ @typeguard.typechecked class Config(dict): - @classmethod def from_fd(cls, fd: typing.TextIO): @@ -61,18 +60,18 @@ def from_fd(cls, fd: typing.TextIO): } root_schema = { - "source": lambda v: cls._validate(data = v, schema = side_schema), - "target": lambda v: cls._validate(data = v, schema = side_schema), + "source": lambda v: cls._validate(data=v, schema=side_schema), + "target": lambda v: cls._validate(data=v, schema=side_schema), "keep_snapshots": lambda v: isinstance(v, int) and v >= 1, "suffix": lambda v: v is None or (isinstance(v, str) and valid_name(v)), "digits": lambda v: isinstance(v, int) and v >= 1, "ignore": lambda v: isinstance(v, list) and all((isinstance(item, str) and len(item) > 0 for item in v)), - "ssh": lambda v: cls._validate(data = v, schema = ssh_schema), + "ssh": lambda v: cls._validate(data=v, schema=ssh_schema), } config = yaml.load(fd.read(), Loader=CLoader) - cls._validate(data = config, schema = root_schema) + cls._validate(data=config, schema=root_schema) return cls(config) @classmethod diff --git a/src/abgleich/core/dataset.py b/src/abgleich/core/dataset.py index ee23ace..c1451aa 100644 --- a/src/abgleich/core/dataset.py +++ b/src/abgleich/core/dataset.py @@ -44,16 +44,17 @@ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @typeguard.typechecked class Dataset(DatasetABC): - - def __init__(self, + def __init__( + self, name: str, properties: typing.Dict[str, PropertyABC], snapshots: typing.List[SnapshotABC], side: str, config: typing.Dict, - ): + ): self._name = name self._properties = properties @@ -61,10 +62,10 @@ def __init__(self, self._side = side self._config = config - self._root = root(config[side]['zpool'], config[side]['prefix']) + self._root = root(config[side]["zpool"], config[side]["prefix"]) assert self._name.startswith(self._root) - self._subname = self._name[len(self._root):].strip('/') + self._subname = self._name[len(self._root) :].strip("/") def __eq__(self, other: DatasetABC) -> bool: @@ -85,17 +86,18 @@ def changed(self) -> bool: if len(self) == 0: return True - if self._properties['written'].value == 0: + if self._properties["written"].value == 0: return False - if self._properties['written'].value > (1024 ** 2): + if self._properties["written"].value > (1024 ** 2): return True - if self._properties['type'].value == 'volume': + if self._properties["type"].value == "volume": return True output, _ = Command.on_side( - ['zfs', 'diff', f'{self._name:s}@{self._snapshots[-1].name:s}'], - self._side, self._config, - ).run() + ["zfs", "diff", f"{self._name:s}@{self._snapshots[-1].name:s}"], + self._side, + self._config, + ).run() return len(output.strip(" \t\n")) > 0 @property @@ -124,36 +126,43 @@ def get_snapshot_transaction(self) -> TransactionABC: return Transaction( TransactionMeta( - type = 'snapshot', - dataset_subname = self._subname, - snapshot_name = snapshot_name, - written = self._properties['written'].value, - ), - [Command.on_side( - ['zfs', 'snapshot', f'{self._name:s}@{snapshot_name:s}'], - self._side, self._config, - )] - ) + type="snapshot", + dataset_subname=self._subname, + snapshot_name=snapshot_name, + written=self._properties["written"].value, + ), + [ + Command.on_side( + ["zfs", "snapshot", f"{self._name:s}@{snapshot_name:s}"], + self._side, + self._config, + ) + ], + ) def _new_snapshot_name(self) -> str: today = datetime.datetime.now().strftime("%Y%m%d") - max_snapshots = (10 ** self._config['digits']) - 1 - suffix = self._config['suffix'] if self._config['suffix'] is not None else '' + max_snapshots = (10 ** self._config["digits"]) - 1 + suffix = self._config["suffix"] if self._config["suffix"] is not None else "" todays_names = [ - snapshot.name for snapshot in self._snapshots - if all(( - snapshot.name.startswith(today), - snapshot.name.endswith(suffix), - len(snapshot.name) == len(today) + self._config['digits'] + len(suffix), - )) - ] + snapshot.name + for snapshot in self._snapshots + if all( + ( + snapshot.name.startswith(today), + snapshot.name.endswith(suffix), + len(snapshot.name) + == len(today) + self._config["digits"] + len(suffix), + ) + ) + ] todays_numbers = [ - int(name[len(today):len(today)+self._config['digits']]) + int(name[len(today) : len(today) + self._config["digits"]]) for name in todays_names - if name[len(today):len(today)+self._config['digits']].isnumeric() - ] + if name[len(today) : len(today) + self._config["digits"]].isnumeric() + ] if len(todays_numbers) != 0: todays_numbers.sort() new_number = todays_numbers[-1] + 1 @@ -162,38 +171,37 @@ def _new_snapshot_name(self) -> str: else: new_number = 1 - return f'{today:s}{new_number:02d}{suffix}' + return f"{today:s}{new_number:02d}{suffix}" @classmethod - def from_entities(cls, + def from_entities( + cls, name: str, entities: typing.OrderedDict[str, typing.List[typing.List[str]]], side: str, config: typing.Dict, - ) -> DatasetABC: + ) -> DatasetABC: - properties = {property.name: property for property in ( - Property.from_params(*params) - for params in entities[name] - )} + properties = { + property.name: property + for property in (Property.from_params(*params) for params in entities[name]) + } entities.pop(name) snapshots = [] - snapshots.extend(( - Snapshot.from_entity( - snapshot_name, - entities[snapshot_name], - snapshots, - side, - config, + snapshots.extend( + ( + Snapshot.from_entity( + snapshot_name, entities[snapshot_name], snapshots, side, config, ) - for snapshot_name in entities.keys() - )) + for snapshot_name in entities.keys() + ) + ) return cls( - name = name, - properties = properties, - snapshots = snapshots, - side = side, - config = config, - ) + name=name, + properties=properties, + snapshots=snapshots, + side=side, + config=config, + ) diff --git a/src/abgleich/core/lib.py b/src/abgleich/core/lib.py index 108453e..167e803 100644 --- a/src/abgleich/core/lib.py +++ b/src/abgleich/core/lib.py @@ -37,18 +37,20 @@ # ROUTINES # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @typeguard.typechecked def join(*args: str) -> str: if len(args) < 2: - raise ValueError('not enough elements to join') + raise ValueError("not enough elements to join") - args = [arg.strip('/ \t\n') for arg in args] + args = [arg.strip("/ \t\n") for arg in args] if any((len(arg) == 0 for arg in args)): - raise ValueError('can not join empty path elements') + raise ValueError("can not join empty path elements") + + return "/".join(args) - return '/'.join(args) @typeguard.typechecked def root(zpool: str, prefix: typing.Union[str, None]) -> str: @@ -57,7 +59,9 @@ def root(zpool: str, prefix: typing.Union[str, None]) -> str: return zpool return join(zpool, prefix) -_name_re = re.compile('^[A-Za-z0-9_]+$') + +_name_re = re.compile("^[A-Za-z0-9_]+$") + @typeguard.typechecked def valid_name(name: str, min_len: int = 1) -> bool: diff --git a/src/abgleich/core/property.py b/src/abgleich/core/property.py index 79e760e..ee12bdf 100644 --- a/src/abgleich/core/property.py +++ b/src/abgleich/core/property.py @@ -44,14 +44,12 @@ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @typeguard.typechecked class Property(PropertyABC): - - def __init__(self, - name: str, - value: PropertyTypes, - src: PropertyTypes, - ): + def __init__( + self, name: str, value: PropertyTypes, src: PropertyTypes, + ): self._name = name self._value = value @@ -77,7 +75,7 @@ def _convert(cls, value: str) -> PropertyTypes: if value.isnumeric(): return int(value) - if value.strip() == '' or value == '-' or value.lower() == 'none': + if value.strip() == "" or value == "-" or value.lower() == "none": return None try: @@ -90,8 +88,4 @@ def _convert(cls, value: str) -> PropertyTypes: @classmethod def from_params(cls, name, value, src) -> PropertyABC: - return cls( - name = name, - value = cls._convert(value), - src = cls._convert(src), - ) + return cls(name=name, value=cls._convert(value), src=cls._convert(src),) diff --git a/src/abgleich/core/snapshot.py b/src/abgleich/core/snapshot.py index 0dc8973..8c200dc 100644 --- a/src/abgleich/core/snapshot.py +++ b/src/abgleich/core/snapshot.py @@ -42,17 +42,18 @@ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @typeguard.typechecked class Snapshot(SnapshotABC): - - def __init__(self, + def __init__( + self, name: str, parent: str, properties: typing.Dict[str, PropertyABC], context: typing.List[SnapshotABC], side: str, config: typing.Dict, - ): + ): self._name = name self._parent = parent @@ -61,10 +62,10 @@ def __init__(self, self._side = side self._config = config - self._root = root(config[side]['zpool'], config[side]['prefix']) + self._root = root(config[side]["zpool"], config[side]["prefix"]) assert self._parent.startswith(self._root) - self._subparent = self._parent[len(self._root):].strip('/') + self._subparent = self._parent[len(self._root) :].strip("/") def __eq__(self, other: SnapshotABC) -> bool: @@ -76,61 +77,62 @@ def __getitem__(self, name: str) -> PropertyABC: def get_cleanup_transaction(self) -> TransactionABC: - assert self._side == 'source' + assert self._side == "source" return Transaction( - meta = TransactionMeta( - type = 'cleanup_snapshot', - snapshot_subparent = self._subparent, - snapshot_name = self._name, - ), - commands = [ + meta=TransactionMeta( + type="cleanup_snapshot", + snapshot_subparent=self._subparent, + snapshot_name=self._name, + ), + commands=[ Command.on_side( ["zfs", "destroy", f"{self._parent:s}@{self._name:s}"], - self._side, self._config - ) - ], - ) + self._side, + self._config, + ) + ], + ) def get_backup_transaction( - self, - source_dataset: str, - target_dataset: str, + self, source_dataset: str, target_dataset: str, ) -> TransactionABC: - assert self._side == 'source' + assert self._side == "source" ancestor = self.ancestor commands = [ Command.on_side( - [ - "zfs", "send", "-c", - f"{source_dataset:s}@{self.name:s}", - ] if ancestor is None else [ - "zfs", "send", "-c", "-i", - f"{source_dataset:s}@{ancestor.name:s}", - f"{source_dataset:s}@{self.name:s}", - ], - 'source', self._config + ["zfs", "send", "-c", f"{source_dataset:s}@{self.name:s}",] + if ancestor is None + else [ + "zfs", + "send", + "-c", + "-i", + f"{source_dataset:s}@{ancestor.name:s}", + f"{source_dataset:s}@{self.name:s}", + ], + "source", + self._config, ), Command.on_side( - [ - "zfs", "receive", f"{target_dataset:s}" - ], - 'target', self._config + ["zfs", "receive", f"{target_dataset:s}"], "target", self._config ), ] return Transaction( - meta = TransactionMeta( - type = 'push_snapshot' if ancestor is None else 'push_snapshot_incremental', - snapshot_subparent = self._subparent, - ancestor_name = "" if ancestor is None else ancestor.name, - snapshot_name = self.name, - ), - commands = commands, - ) + meta=TransactionMeta( + type="push_snapshot" + if ancestor is None + else "push_snapshot_incremental", + snapshot_subparent=self._subparent, + ancestor_name="" if ancestor is None else ancestor.name, + snapshot_name=self.name, + ), + commands=commands, + ) @property def name(self) -> str: @@ -170,20 +172,20 @@ def from_entity( context: typing.List[SnapshotABC], side: str, config: typing.Dict, - ) -> SnapshotABC: + ) -> SnapshotABC: - properties = {property.name: property for property in ( - Property.from_params(*params) - for params in entity - )} + properties = { + property.name: property + for property in (Property.from_params(*params) for params in entity) + } - parent, name = name.split('@') + parent, name = name.split("@") return cls( - name = name, - parent = parent, - properties = properties, - context = context, - side = side, - config = config, + name=name, + parent=parent, + properties=properties, + context=context, + side=side, + config=config, ) diff --git a/src/abgleich/core/transaction.py b/src/abgleich/core/transaction.py index e9f2276..0228380 100644 --- a/src/abgleich/core/transaction.py +++ b/src/abgleich/core/transaction.py @@ -40,13 +40,11 @@ # CLASS # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @typeguard.typechecked class Transaction(TransactionABC): - def __init__( - self, - meta: TransactionMetaABC, - commands: typing.List[CommandABC], + self, meta: TransactionMetaABC, commands: typing.List[CommandABC], ): assert len(commands) in (1, 2) @@ -92,19 +90,22 @@ def run(self): if len(self._commands) == 1: output, errors = self._commands[0].run() else: - errors_1, output_2, errors_2 = self._commands[0].run_pipe(self._commands[1]) + errors_1, output_2, errors_2 = self._commands[0].run_pipe( + self._commands[1] + ) except SystemError as error: self._error = error finally: self._running = False self._complete = True + MetaTypes = typing.Union[str, int, float] MetaNoneTypes = typing.Union[str, int, float, None] + @typeguard.typechecked class TransactionMeta(TransactionMetaABC): - def __init__(self, **kwargs: MetaTypes): self._meta = kwargs @@ -125,15 +126,16 @@ def keys(self) -> typing.Generator[str, None, None]: return (key for key in self._meta.keys()) + TransactionIterableTypes = typing.Union[ typing.Generator[TransactionABC, None, None], typing.List[TransactionABC], typing.Tuple[TransactionABC], ] + @typeguard.typechecked class TransactionList(TransactionListABC): - def __init__(self): self._transactions = [] @@ -166,18 +168,13 @@ def print_table(self): for transaction in self._transactions ] - print(tabulate( - table, - headers=headers, - tablefmt="github", - colalign=colalign, - )) + print(tabulate(table, headers=headers, tablefmt="github", colalign=colalign,)) @staticmethod def _table_format_cell(header: str, value: MetaNoneTypes) -> str: FORMAT = { - 'written': lambda v: humanize_size(v, add_color = True), + "written": lambda v: humanize_size(v, add_color=True), } return FORMAT.get(header, str)(value) @@ -185,17 +182,17 @@ def _table_format_cell(header: str, value: MetaNoneTypes) -> str: @staticmethod def _table_colalign(headers: typing.List[str]) -> typing.List[str]: - RIGHT = ('written',) + RIGHT = ("written",) DECIMAL = tuple() colalign = [] for header in headers: if header in RIGHT: - colalign.append('right') + colalign.append("right") elif header in DECIMAL: - colalign.append('decimal') + colalign.append("decimal") else: - colalign.append('left') + colalign.append("left") return colalign @@ -204,15 +201,15 @@ def _table_headers(self) -> typing.List[str]: headers = set() for transaction in self._transactions: keys = list(transaction.meta.keys()) - assert 'type' in keys + assert "type" in keys headers.update(keys) headers = list(headers) headers.sort() - type_index = headers.index('type') + type_index = headers.index("type") if type_index != 0: headers.pop(type_index) - headers.insert(0, 'type') + headers.insert(0, "type") return headers @@ -223,7 +220,7 @@ def run(self): print( f'({colorize(transaction.meta["type"], "white"):s}) ' f'{colorize(" | ".join([str(command) for command in transaction.commands]), "yellow"):s}' - ) + ) assert not transaction.running assert not transaction.complete @@ -234,7 +231,7 @@ def run(self): assert transaction.complete if transaction.error is not None: - print(colorize('FAILED', 'red')) + print(colorize("FAILED", "red")) raise transaction.error else: - print(colorize('OK', 'green')) + print(colorize("OK", "green")) diff --git a/src/abgleich/core/zpool.py b/src/abgleich/core/zpool.py index 7f2c0f2..728ef83 100644 --- a/src/abgleich/core/zpool.py +++ b/src/abgleich/core/zpool.py @@ -34,7 +34,13 @@ from tabulate import tabulate import typeguard -from .abc import ComparisonItemABC, DatasetABC, SnapshotABC, TransactionListABC, ZpoolABC +from .abc import ( + ComparisonItemABC, + DatasetABC, + SnapshotABC, + TransactionListABC, + ZpoolABC, +) from .command import Command from .comparison import Comparison from .dataset import Dataset @@ -50,7 +56,6 @@ @typeguard.typechecked class Zpool(ZpoolABC): - def __init__( self, datasets: typing.List[DatasetABC], side: str, config: typing.Dict, ): @@ -59,7 +64,7 @@ def __init__( self._side = side self._config = config - self._root = root(config[side]['zpool'], config[side]['prefix']) + self._root = root(config[side]["zpool"], config[side]["prefix"]) def __eq__(self, other: ZpoolABC) -> bool: @@ -82,37 +87,43 @@ def root(self) -> str: def get_cleanup_transactions(self, other: ZpoolABC) -> TransactionListABC: - assert self.side == 'source' - assert other.side == 'target' + assert self.side == "source" + assert other.side == "target" zpool_comparison = Comparison.from_zpools(self, other) transactions = TransactionList() for dataset_item in zpool_comparison.merged: - if dataset_item.get_item().subname in self._config['ignore']: + if dataset_item.get_item().subname in self._config["ignore"]: continue if dataset_item.a is None or dataset_item.b is None: continue - dataset_comparison = Comparison.from_datasets(dataset_item.a, dataset_item.b) - snapshots = dataset_comparison.a_overlap_tail[:-self._config['keep_snapshots']] + dataset_comparison = Comparison.from_datasets( + dataset_item.a, dataset_item.b + ) + snapshots = dataset_comparison.a_overlap_tail[ + : -self._config["keep_snapshots"] + ] - transactions.extend((snapshot.get_cleanup_transaction() for snapshot in snapshots)) + transactions.extend( + (snapshot.get_cleanup_transaction() for snapshot in snapshots) + ) return transactions def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: - assert self.side == 'source' - assert other.side == 'target' + assert self.side == "source" + assert other.side == "target" zpool_comparison = Comparison.from_zpools(self, other) transactions = TransactionList() for dataset_item in zpool_comparison.merged: - if dataset_item.get_item().subname in self._config['ignore']: + if dataset_item.get_item().subname in self._config["ignore"]: continue if dataset_item.a is None: continue @@ -120,34 +131,43 @@ def get_backup_transactions(self, other: ZpoolABC) -> TransactionListABC: if dataset_item.b is None: snapshots = list(dataset_item.a.snapshots) else: - dataset_comparison = Comparison.from_datasets(dataset_item.a, dataset_item.b) + dataset_comparison = Comparison.from_datasets( + dataset_item.a, dataset_item.b + ) snapshots = dataset_comparison.a_head if len(snapshots) == 0: continue - source_dataset = self.root if len(dataset_item.a.subname) == 0 else join(self.root, dataset_item.a.subname) - target_dataset = other.root if len(dataset_item.a.subname) == 0 else join(other.root, dataset_item.a.subname) + source_dataset = ( + self.root + if len(dataset_item.a.subname) == 0 + else join(self.root, dataset_item.a.subname) + ) + target_dataset = ( + other.root + if len(dataset_item.a.subname) == 0 + else join(other.root, dataset_item.a.subname) + ) - transactions.extend(( - snapshot.get_backup_transaction( - source_dataset, - target_dataset, - ) - for snapshot in snapshots - )) + transactions.extend( + ( + snapshot.get_backup_transaction(source_dataset, target_dataset,) + for snapshot in snapshots + ) + ) return transactions def get_snapshot_transactions(self) -> TransactionListABC: - assert self._side == 'source' + assert self._side == "source" transactions = TransactionList() for dataset in self._datasets: - if dataset.subname in self._config['ignore']: + if dataset.subname in self._config["ignore"]: continue - if dataset['mountpoint'].value is None: + if dataset["mountpoint"].value is None: continue if dataset.changed: transactions.append(dataset.get_snapshot_transaction()) @@ -162,20 +182,24 @@ def print_table(self): for snapshot in dataset.snapshots: table.append(self._table_row(snapshot)) - print(tabulate( - table, - headers=("NAME", "USED", "REFER", "compressratio"), - tablefmt="github", - colalign=("left", "right", "right", "decimal"), - )) + print( + tabulate( + table, + headers=("NAME", "USED", "REFER", "compressratio"), + tablefmt="github", + colalign=("left", "right", "right", "decimal"), + ) + ) @staticmethod def _table_row(entity: typing.Union[SnapshotABC, DatasetABC]) -> typing.List[str]: return [ - '- ' + colorize(entity.name, "grey") if isinstance(entity, SnapshotABC) else colorize(entity.name, "white"), - humanize_size(entity['used'].value, add_color=True), - humanize_size(entity['referenced'].value, add_color=True), + "- " + colorize(entity.name, "grey") + if isinstance(entity, SnapshotABC) + else colorize(entity.name, "white"), + humanize_size(entity["used"].value, add_color=True), + humanize_size(entity["referenced"].value, add_color=True), f'{entity["compressratio"].value:.02f}', ] @@ -187,7 +211,9 @@ def print_comparison_table(self, other: ZpoolABC): for dataset_item in zpool_comparison.merged: table.append(self._comparison_table_row(dataset_item)) if dataset_item.complete: - dataset_comparison = Comparison.from_datasets(dataset_item.a, dataset_item.b) + dataset_comparison = Comparison.from_datasets( + dataset_item.a, dataset_item.b + ) elif dataset_item.a is not None: dataset_comparison = Comparison.from_datasets(dataset_item.a, None) else: @@ -195,11 +221,9 @@ def print_comparison_table(self, other: ZpoolABC): for snapshot_item in dataset_comparison.merged: table.append(self._comparison_table_row(snapshot_item)) - print(tabulate( - table, - headers=["NAME", self.side, other.side], - tablefmt="github", - )) + print( + tabulate(table, headers=["NAME", self.side, other.side], tablefmt="github",) + ) @staticmethod def _comparison_table_row(item: ComparisonItemABC) -> typing.List[str]: @@ -215,38 +239,50 @@ def _comparison_table_row(item: ComparisonItemABC) -> typing.List[str]: a, b = colorize("X", "red"), "" return [ - '- ' + colorize(name, "grey") if isinstance(entity, SnapshotABC) else colorize(name, "white"), + "- " + colorize(name, "grey") + if isinstance(entity, SnapshotABC) + else colorize(name, "white"), a, b, ] @staticmethod - def available( - side: str, - config: typing.Dict, - ) -> int: + def available(side: str, config: typing.Dict,) -> int: output, _ = Command.on_side( - ["zfs", "get", "available", "-H", "-p", root(config[side]['zpool'], config[side]['prefix'])], + [ + "zfs", + "get", + "available", + "-H", + "-p", + root(config[side]["zpool"], config[side]["prefix"]), + ], side, config, - ).run() + ).run() - return Property.from_params(*output.strip().split('\t')[1:]).value + return Property.from_params(*output.strip().split("\t")[1:]).value @classmethod - def from_config( - cls, - side: str, - config: typing.Dict, - ) -> ZpoolABC: + def from_config(cls, side: str, config: typing.Dict,) -> ZpoolABC: output, _ = Command.on_side( - ["zfs", "get", "all", "-r", "-H", "-p", root(config[side]['zpool'], config[side]['prefix'])], + [ + "zfs", + "get", + "all", + "-r", + "-H", + "-p", + root(config[side]["zpool"], config[side]["prefix"]), + ], side, config, - ).run() - output = [line.split('\t') for line in output.split('\n') if len(line.strip()) > 0] + ).run() + output = [ + line.split("\t") for line in output.split("\n") if len(line.strip()) > 0 + ] entities = OrderedDict((line[0], []) for line in output) for line_list in output: entities[line_list[0]].append(line_list[1:]) @@ -254,17 +290,17 @@ def from_config( datasets = [ Dataset.from_entities( name, - OrderedDict((k, v) for k, v in entities.items() if k == name or k.startswith(f'{name:s}@')), + OrderedDict( + (k, v) + for k, v in entities.items() + if k == name or k.startswith(f"{name:s}@") + ), side, config, - ) + ) for name in entities.keys() - if '@' not in name - ] - datasets.sort(key = lambda dataset: dataset.name) + if "@" not in name + ] + datasets.sort(key=lambda dataset: dataset.name) - return cls( - datasets = datasets, - side = side, - config = config, - ) + return cls(datasets=datasets, side=side, config=config,) From 5f6c6ad16006606fc87701dbd599b3eed375fd5f Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 17:55:11 +0200 Subject: [PATCH 130/135] delete all stuff --- makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/makefile b/makefile index dd356a7..2402fde 100644 --- a/makefile +++ b/makefile @@ -2,9 +2,9 @@ clean: -rm -r build/* find src/ -name '*.pyc' -exec sudo rm -f {} + - find src/ -name '*.pyo' -exec rm -f {} + + find src/ -name '*.pyo' -exec sudo rm -f {} + find src/ -name '*~' -exec rm -f {} + - find src/ -name '__pycache__' -exec rm -fr {} + + find src/ -name '__pycache__' -exec sudo rm -fr {} + find src/ -name '*.htm' -exec rm -f {} + find src/ -name '*.html' -exec rm -f {} + find src/ -name '*.so' -exec rm -f {} + From 2840711916c67ef3b42acdbef90df8c3ed2a1bc8 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 18:09:30 +0200 Subject: [PATCH 131/135] added demo screenshot --- docs/demo.png | Bin 0 -> 255111 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/demo.png diff --git a/docs/demo.png b/docs/demo.png new file mode 100644 index 0000000000000000000000000000000000000000..225003c12c5119efc5d57622c344c63806974de4 GIT binary patch literal 255111 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≠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%0AgD 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

Mf*7OajbbG2G*T~Eu^j& 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 Date: Tue, 14 Jul 2020 18:10:15 +0200 Subject: [PATCH 132/135] prepare for pypi --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index bb460ca..c585014 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ ## INSTALLATION +```bash +pip install -vU abgleich +``` + +or + ```bash pip install -vU git+https://github.com/pleiszenburg/abgleich.git@master ``` From 617040b9bb40f47d2deb30a39616d99490fab9d2 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 18:11:56 +0200 Subject: [PATCH 133/135] include demo screenshot --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c585014..d907f16 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ `abgleich` is a simple ZFS sync tool. It displays source and target ZFS zpool, dataset and snapshot trees. It creates meaningful snapshots only if datasets have actually been changed. It compares a source zpool tree to a target, backup zpool tree. It pushes backups from a source to a target. It cleanes up older snapshots on the source side if they are present on the target side. It runs on a command line and produces nice, user-friendly, human-readable, colorized output. +![demo](https://github.com/pleiszenburg/abgleich/blob/master/docs/demo.png?raw=true "demo") + ## INSTALLATION ```bash From 0c2c22492c7aef8dcc49dff7b3493f63db9c83bd Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 18:12:57 +0200 Subject: [PATCH 134/135] prepare package upload --- makefile | 5 +++++ setup.py | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/makefile b/makefile index 2402fde..cff9b42 100644 --- a/makefile +++ b/makefile @@ -22,3 +22,8 @@ release: install: pip install -vU pip setuptools pip install -v -e .[dev] + +upload: + for filename in $$(ls dist/*.tar.gz dist/*.whl) ; do \ + twine upload $$filename $$filename.asc ; \ + done diff --git a/setup.py b/setup.py index fdc9076..4904d7e 100644 --- a/setup.py +++ b/setup.py @@ -81,11 +81,8 @@ extras_require={ "dev": [ "black", - # 'pytest', "python-language-server[all]", "setuptools", - # 'Sphinx', - # 'sphinx_rtd_theme', "twine", "wheel", ] From ac13bb6b5deef62364fec0045d5f067110ebaef6 Mon Sep 17 00:00:00 2001 From: "Sebastian M. Ernst" Date: Tue, 14 Jul 2020 18:13:54 +0200 Subject: [PATCH 135/135] prepare for release --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index ec7f36e..d677b31 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,6 @@ # Changes -## 0.0.2 (2020-XX-XX) +## 0.0.2 (2020-07-14) - FEATURE: New, fully object oriented base library - FEATURE: Python 3.8 support added