From c65aa2400179a12d3771a6d2e042c859da4168c2 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 23 Nov 2024 10:50:58 -0800 Subject: [PATCH] Add test coverage for ZFS hook (#261). --- README.md | 1 + borgmatic/hooks/zfs.py | 58 +++--- docs/static/openzfs.png | Bin 0 -> 16007 bytes pyproject.toml | 2 +- tests/unit/actions/test_create.py | 46 ++++- tests/unit/config/test_paths.py | 30 +++ tests/unit/hooks/test_zfs.py | 326 ++++++++++++++++++++++++++++++ 7 files changed, 430 insertions(+), 33 deletions(-) create mode 100644 docs/static/openzfs.png create mode 100644 tests/unit/hooks/test_zfs.py diff --git a/README.md b/README.md index 01151e70..63eea8af 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/). MariaDB MongoDB SQLite +OpenZFS Healthchecks Uptime Kuma Cronitor diff --git a/borgmatic/hooks/zfs.py b/borgmatic/hooks/zfs.py index dfb13eb6..766a5a01 100644 --- a/borgmatic/hooks/zfs.py +++ b/borgmatic/hooks/zfs.py @@ -10,7 +10,7 @@ import borgmatic.execute logger = logging.getLogger(__name__) -def use_streaming(hook_config, config, log_prefix): +def use_streaming(hook_config, config, log_prefix): # pragma: no cover ''' Return whether dump streaming is used for this hook. (Spoiler: It isn't.) ''' @@ -26,7 +26,7 @@ def get_datasets_to_backup(zfs_command, source_directories): Given a ZFS command to run and a sequence of configured source directories, find the intersection between the current ZFS dataset mount points and the configured borgmatic source directories. The idea is that these are the requested datasets to snapshot. But also include any - datasets tagged with a borgmatic-specific user property whether or not they appear in source + datasets tagged with a borgmatic-specific user property, whether or not they appear in source directories. Return the result as a sequence of (dataset name, mount point) pairs. @@ -44,12 +44,15 @@ def get_datasets_to_backup(zfs_command, source_directories): ) source_directories_set = set(source_directories) - return tuple( - (dataset_name, mount_point) - for line in list_output.splitlines() - for (dataset_name, mount_point, user_property_value) in (line.rstrip().split('\t'),) - if mount_point in source_directories_set or user_property_value == 'auto' - ) + try: + return tuple( + (dataset_name, mount_point) + for line in list_output.splitlines() + for (dataset_name, mount_point, user_property_value) in (line.rstrip().split('\t'),) + if mount_point in source_directories_set or user_property_value == 'auto' + ) + except ValueError: + raise ValueError('Invalid {zfs_command} list output') def get_all_datasets(zfs_command): @@ -69,14 +72,17 @@ def get_all_datasets(zfs_command): ) ) - return tuple( - (dataset_name, mount_point) - for line in list_output.splitlines() - for (dataset_name, mount_point) in (line.rstrip().split('\t'),) - ) + try: + return tuple( + (dataset_name, mount_point) + for line in list_output.splitlines() + for (dataset_name, mount_point) in (line.rstrip().split('\t'),) + ) + except ValueError: + raise ValueError('Invalid {zfs_command} list output') -def snapshot_dataset(zfs_command, full_snapshot_name): +def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover ''' Given a ZFS command to run and a snapshot name of the form "dataset@snapshot", create a new ZFS snapshot. @@ -92,7 +98,7 @@ def snapshot_dataset(zfs_command, full_snapshot_name): ) -def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): +def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # pragma: no cover ''' Given a mount command to run, an existing snapshot name of the form "dataset@snapshot", and the path where the snapshot should be mounted, mount the snapshot (making any necessary directories @@ -122,12 +128,12 @@ def dump_data_sources( ''' Given a ZFS configuration dict, a configuration dict, a log prefix, the borgmatic runtime directory, the configured source directories, and whether this is a dry run, auto-detect and - snapshot any ZFS dataset mount points listed in the given source directories and also any - dataset with a borgmatic-specific user property. Also update those source directories, replacing - dataset mount points with corresponding snapshot directories. Use the log prefix in any log - entries. + snapshot any ZFS dataset mount points listed in the given source directories and any dataset + with a borgmatic-specific user property. Also update those source directories, replacing dataset + mount points with corresponding snapshot directories so they get stored in the Borg archive + instead of the dataset mount points. Use the log prefix in any log entries. - Return an empty sequence, since there are no ongoing dump processes. + Return an empty sequence, since there are no ongoing dump processes from this hook. If this is a dry run, then don't actually snapshot anything. ''' @@ -174,7 +180,7 @@ def dump_data_sources( return [] -def unmount_snapshot(umount_command, snapshot_mount_path): +def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover ''' Given a umount command to run and the mount path of a snapshot, unmount it. ''' @@ -187,7 +193,7 @@ def unmount_snapshot(umount_command, snapshot_mount_path): ) -def destroy_snapshot(zfs_command, full_snapshot_name): +def destroy_snapshot(zfs_command, full_snapshot_name): # pragma: no cover ''' Given a ZFS command to run and the name of a snapshot in the form "dataset@snapshot", destroy it. @@ -246,7 +252,7 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_ snapshots_glob = os.path.join( borgmatic.config.paths.replace_temporary_subdirectory_with_glob( - os.path.normpath(borgmatic_runtime_directory) + os.path.normpath(borgmatic_runtime_directory), ), 'zfs_snapshots', ) @@ -263,7 +269,8 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_ # we'll try again below. The point of doing it here is that we don't want to try to unmount # a non-mounted directory (which *will* fail), and probing for whether a directory is # mounted is tough to do in a cross-platform way. - shutil.rmtree(snapshots_directory, ignore_errors=True) + if not dry_run: + shutil.rmtree(snapshots_directory, ignore_errors=True) for _, mount_point in datasets: snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep)) @@ -277,7 +284,8 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_ if not dry_run: unmount_snapshot(umount_command, snapshot_mount_path) - shutil.rmtree(snapshots_directory) + if not dry_run: + shutil.rmtree(snapshots_directory) # Destroy snapshots. full_snapshot_names = get_all_snapshots(zfs_command) diff --git a/docs/static/openzfs.png b/docs/static/openzfs.png new file mode 100644 index 0000000000000000000000000000000000000000..837ba3b96505ce96646b2fe1b58158e63e676f71 GIT binary patch literal 16007 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