diff --git a/NEWS b/NEWS
index c9a55a51..e8f686ff 100644
--- a/NEWS
+++ b/NEWS
@@ -1,4 +1,7 @@
1.9.4.dev0
+ * #251 (beta): Add a Btrfs hook for snapshotting and backing up Btrfs subvolumes. See the
+ documentation for more information:
+ https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
* #926: Fix library error when running within a PyInstaller bundle.
* Reorganize data source and monitoring hooks to make developing new hooks easier.
diff --git a/README.md b/README.md
index 9fc79c7f..5861a9a7 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
+
diff --git a/borgmatic/config/paths.py b/borgmatic/config/paths.py
index 5482efef..e5786237 100644
--- a/borgmatic/config/paths.py
+++ b/borgmatic/config/paths.py
@@ -33,10 +33,13 @@ def get_borgmatic_source_directory(config):
TEMPORARY_DIRECTORY_PREFIX = 'borgmatic-'
-def replace_temporary_subdirectory_with_glob(path):
+def replace_temporary_subdirectory_with_glob(
+ path, temporary_directory_prefix=TEMPORARY_DIRECTORY_PREFIX
+):
'''
- Given an absolute temporary directory path, look for a subdirectory within it starting with the
- temporary directory prefix and replace it with an appropriate glob. For instance, given:
+ Given an absolute temporary directory path and an optional temporary directory prefix, look for
+ a subdirectory within it starting with the temporary directory prefix (or a default) and replace
+ it with an appropriate glob. For instance, given:
/tmp/borgmatic-aet8kn93/borgmatic
@@ -50,8 +53,8 @@ def replace_temporary_subdirectory_with_glob(path):
'/',
*(
(
- f'{TEMPORARY_DIRECTORY_PREFIX}*'
- if subdirectory.startswith(TEMPORARY_DIRECTORY_PREFIX)
+ f'{temporary_directory_prefix}*'
+ if subdirectory.startswith(temporary_directory_prefix)
else subdirectory
)
for subdirectory in path.split(os.path.sep)
diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml
index ddf9aaeb..c2e900ef 100644
--- a/borgmatic/config/schema.yaml
+++ b/borgmatic/config/schema.yaml
@@ -2288,3 +2288,19 @@ properties:
example: /usr/local/bin/umount
description: |
Configuration for integration with the ZFS filesystem.
+ btrfs:
+ type: ["object", "null"]
+ additionalProperties: false
+ properties:
+ btrfs_command:
+ type: string
+ description: |
+ Command to use instead of "btrfs".
+ example: /usr/local/bin/btrfs
+ findmnt_command:
+ type: string
+ description: |
+ Command to use instead of "findmnt".
+ example: /usr/local/bin/findmnt
+ description: |
+ Configuration for integration with the Btrfs filesystem.
diff --git a/borgmatic/hooks/data_source/btrfs.py b/borgmatic/hooks/data_source/btrfs.py
new file mode 100644
index 00000000..3d812356
--- /dev/null
+++ b/borgmatic/hooks/data_source/btrfs.py
@@ -0,0 +1,294 @@
+import glob
+import logging
+import os
+import shutil
+import subprocess
+
+import borgmatic.config.paths
+import borgmatic.execute
+
+logger = logging.getLogger(__name__)
+
+
+def use_streaming(hook_config, config, log_prefix): # pragma: no cover
+ '''
+ Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
+ '''
+ return False
+
+
+def get_filesystem_mount_points(findmnt_command):
+ '''
+ Given a findmnt command to run, get all top-level Btrfs filesystem mount points.
+ '''
+ findmnt_output = borgmatic.execute.execute_command_and_capture_output(
+ (
+ findmnt_command,
+ '-nt',
+ 'btrfs',
+ )
+ )
+
+ return tuple(line.rstrip().split(' ')[0] for line in findmnt_output.splitlines())
+
+
+def get_subvolumes_for_filesystem(btrfs_command, filesystem_mount_point):
+ '''
+ Given a Btrfs command to run and a Btrfs filesystem mount point, get the subvolumes for that
+ filesystem.
+ '''
+ btrfs_output = borgmatic.execute.execute_command_and_capture_output(
+ (
+ btrfs_command,
+ 'subvolume',
+ 'list',
+ filesystem_mount_point,
+ )
+ )
+
+ return tuple(
+ subvolume_path
+ for line in btrfs_output.splitlines()
+ for subvolume_subpath in (line.rstrip().split(' ')[-1],)
+ for subvolume_path in (os.path.join(filesystem_mount_point, subvolume_subpath),)
+ if subvolume_subpath.strip()
+ if filesystem_mount_point.strip()
+ )
+
+
+def get_subvolumes(btrfs_command, findmnt_command, source_directories=None):
+ '''
+ Given a Btrfs command to run and a sequence of configured source directories, find the
+ intersection between the current Btrfs filesystem and subvolume mount points and the configured
+ borgmatic source directories. The idea is that these are the requested subvolumes to snapshot.
+
+ If the source directories is None, then return all subvolumes.
+
+ Return the result as a sequence of matching subvolume mount points.
+ '''
+ source_directories_lookup = set(source_directories or ())
+ subvolumes = []
+
+ # For each filesystem mount point, find its subvolumes and match them again the given source
+ # directories to find the subvolumes to backup. Also try to match the filesystem mount point
+ # itself (since it's implicitly a subvolume).
+ for mount_point in get_filesystem_mount_points(findmnt_command):
+ if source_directories is None or mount_point in source_directories_lookup:
+ subvolumes.append(mount_point)
+
+ subvolumes.extend(
+ subvolume_path
+ for subvolume_path in get_subvolumes_for_filesystem(btrfs_command, mount_point)
+ if source_directories is None or subvolume_path in source_directories_lookup
+ )
+
+ return tuple(subvolumes)
+
+
+BORGMATIC_SNAPSHOT_PREFIX = '.borgmatic-snapshot-'
+
+
+def make_snapshot_path(subvolume_path): # pragma: no cover
+ '''
+ Given the path to a subvolume, make a corresponding snapshot path for it.
+ '''
+ return os.path.join(
+ subvolume_path,
+ f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
+ '.', # Borg 1.4+ "slashdot" hack.
+ # Included so that the snapshot ends up in the Borg archive at the "original" subvolume
+ # path.
+ subvolume_path.lstrip(os.path.sep),
+ )
+
+
+def make_snapshot_exclude_path(subvolume_path): # pragma: no cover
+ '''
+ Given the path to a subvolume, make a corresponding exclude path for its embedded snapshot path.
+ This is to work around a quirk of Btrfs: If you make a snapshot path as a child directory of a
+ subvolume, then the snapshot's own initial directory component shows up as an empty directory
+ within the snapshot itself. For instance, if you have a Btrfs subvolume at /mnt and make a
+ snapshot of it at:
+
+ /mnt/.borgmatic-snapshot-1234/mnt
+
+ ... then the snapshot itself will have an empty directory at:
+
+ /mnt/.borgmatic-snapshot-1234/mnt/.borgmatic-snapshot-1234
+
+ So to prevent that from ending up in the Borg archive, this function produces its path for
+ exclusion.
+ '''
+ snapshot_directory = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
+
+ return os.path.join(
+ subvolume_path,
+ snapshot_directory,
+ subvolume_path.lstrip(os.path.sep),
+ snapshot_directory,
+ )
+
+
+def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma: no cover
+ '''
+ Given a Btrfs command to run, the path to a subvolume, and the path for a snapshot, create a new
+ Btrfs snapshot of the subvolume.
+ '''
+ os.makedirs(os.path.dirname(snapshot_path), mode=0o700, exist_ok=True)
+
+ borgmatic.execute.execute_command(
+ (
+ btrfs_command,
+ 'subvolume',
+ 'snapshot',
+ '-r', # Read-only,
+ subvolume_path,
+ snapshot_path,
+ ),
+ output_log_level=logging.DEBUG,
+ )
+
+
+def dump_data_sources(
+ hook_config,
+ config,
+ log_prefix,
+ config_paths,
+ borgmatic_runtime_directory,
+ source_directories,
+ dry_run,
+):
+ '''
+ Given a Btrfs configuration dict, a configuration dict, a log prefix, the borgmatic
+ configuration file paths, the borgmatic runtime directory, the configured source directories,
+ and whether this is a dry run, auto-detect and snapshot any Btrfs subvolume mount points listed
+ in the given source directories. Also update those source directories, replacing subvolume mount
+ points with corresponding snapshot directories so they get stored in the Borg archive instead.
+ Use the log prefix in any log entries.
+
+ 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.
+ '''
+ dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
+ logger.info(f'{log_prefix}: Snapshotting Btrfs subvolumes{dry_run_label}')
+
+ # Based on the configured source directories, determine Btrfs subvolumes to backup.
+ btrfs_command = hook_config.get('btrfs_command', 'btrfs')
+ findmnt_command = hook_config.get('findmnt_command', 'findmnt')
+ subvolumes = get_subvolumes(btrfs_command, findmnt_command, source_directories)
+
+ if not subvolumes:
+ logger.warning(f'{log_prefix}: No Btrfs subvolumes found to snapshot{dry_run_label}')
+
+ # Snapshot each subvolume, rewriting source directories to use their snapshot paths.
+ for subvolume_path in subvolumes:
+ logger.debug(f'{log_prefix}: Creating Btrfs snapshot for {subvolume_path} subvolume')
+
+ snapshot_path = make_snapshot_path(subvolume_path)
+
+ if dry_run:
+ continue
+
+ snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path)
+
+ if subvolume_path in source_directories:
+ source_directories.remove(subvolume_path)
+
+ source_directories.append(snapshot_path)
+ config.setdefault('exclude_patterns', []).append(make_snapshot_exclude_path(subvolume_path))
+
+ return []
+
+
+def delete_snapshot(btrfs_command, snapshot_path): # pragma: no cover
+ '''
+ Given a Btrfs command to run and the name of a snapshot path, delete it.
+ '''
+ borgmatic.execute.execute_command(
+ (
+ btrfs_command,
+ 'subvolume',
+ 'delete',
+ snapshot_path,
+ ),
+ output_log_level=logging.DEBUG,
+ )
+
+
+def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
+ '''
+ Given a Btrfs configuration dict, a configuration dict, a log prefix, the borgmatic runtime
+ directory, and whether this is a dry run, delete any Btrfs snapshots created by borgmatic. Use
+ the log prefix in any log entries. If this is a dry run, then don't actually remove anything.
+ '''
+ dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
+
+ btrfs_command = hook_config.get('btrfs_command', 'btrfs')
+ findmnt_command = hook_config.get('findmnt_command', 'findmnt')
+
+ try:
+ all_subvolume_paths = get_subvolumes(btrfs_command, findmnt_command)
+ except FileNotFoundError as error:
+ logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
+ return
+ except subprocess.CalledProcessError as error:
+ logger.debug(f'{log_prefix}: {error}')
+ return
+
+ for subvolume_path in all_subvolume_paths:
+ subvolume_snapshots_glob = borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
+ os.path.normpath(make_snapshot_path(subvolume_path)),
+ temporary_directory_prefix=BORGMATIC_SNAPSHOT_PREFIX,
+ )
+
+ logger.debug(
+ f'{log_prefix}: Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}'
+ )
+
+ for snapshot_path in glob.glob(subvolume_snapshots_glob):
+ if not os.path.isdir(snapshot_path):
+ continue
+
+ logger.debug(f'{log_prefix}: Deleting Btrfs snapshot {snapshot_path}{dry_run_label}')
+
+ if dry_run:
+ continue
+
+ try:
+ delete_snapshot(btrfs_command, snapshot_path)
+ except FileNotFoundError:
+ logger.debug(f'{log_prefix}: Could not find "{btrfs_command}" command')
+ return
+ except subprocess.CalledProcessError as error:
+ logger.debug(f'{log_prefix}: {error}')
+ return
+
+ # Strip off the subvolume path from the end of the snapshot path and then delete the
+ # resulting directory.
+ shutil.rmtree(snapshot_path.rsplit(subvolume_path, 1)[0])
+
+
+def make_data_source_dump_patterns(
+ hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
+): # pragma: no cover
+ '''
+ Restores aren't implemented, because stored files can be extracted directly with "extract".
+ '''
+ return ()
+
+
+def restore_data_source_dump(
+ hook_config,
+ config,
+ log_prefix,
+ data_source,
+ dry_run,
+ extract_process,
+ connection_params,
+ borgmatic_runtime_directory,
+): # pragma: no cover
+ '''
+ Restores aren't implemented, because stored files can be extracted directly with "extract".
+ '''
+ raise NotImplementedError()
diff --git a/borgmatic/hooks/data_source/zfs.py b/borgmatic/hooks/data_source/zfs.py
index 6e000674..5f011a1c 100644
--- a/borgmatic/hooks/data_source/zfs.py
+++ b/borgmatic/hooks/data_source/zfs.py
@@ -104,6 +104,7 @@ def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # p
first).
'''
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
+
borgmatic.execute.execute_command(
(
mount_command,
@@ -131,8 +132,7 @@ def dump_data_sources(
is a dry run, auto-detect and 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.
+ stored in the Borg archive instead. Use the log prefix in any log entries.
Return an empty sequence, since there are no ongoing dump processes from this hook.
@@ -148,6 +148,9 @@ def dump_data_sources(
# Snapshot each dataset, rewriting source directories to use the snapshot paths.
snapshot_name = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
+ if not requested_datasets:
+ logger.warning(f'{log_prefix}: No ZFS datasets found to snapshot{dry_run_label}')
+
for dataset_name, mount_point in requested_datasets:
full_snapshot_name = f'{dataset_name}@{snapshot_name}'
logger.debug(f'{log_prefix}: Creating ZFS snapshot {full_snapshot_name}{dry_run_label}')
diff --git a/docs/how-to/snapshot-your-filesystems.md b/docs/how-to/snapshot-your-filesystems.md
index 0e49090a..d8f5327f 100644
--- a/docs/how-to/snapshot-your-filesystems.md
+++ b/docs/how-to/snapshot-your-filesystems.md
@@ -52,7 +52,7 @@ feedback](https://torsion.org/borgmatic/#issues) you have on this feature.
You have a couple of options for borgmatic to find and backup your ZFS datasets:
* For any dataset you'd like backed up, add its mount point to borgmatic's
- `source_directories`.
+ `source_directories` option.
* Or set the borgmatic-specific user property
`org.torsion.borgmatic:backup=auto` onto your dataset, e.g. by running `zfs
set org.torsion.borgmatic:backup=auto datasetname`. Then borgmatic can find
@@ -62,11 +62,11 @@ If you have multiple borgmatic configuration files with ZFS enabled, and you'd
like particular datasets to be backed up only for particular configuration
files, use the `source_directories` option instead of the user property.
-During a backup, borgmatic automatically snapshots these discovered datasets,
-temporary mounts the snapshots within its [runtime
+During a backup, borgmatic automatically snapshots these discovered datasets
+(non-recursively), temporary mounts the snapshots within its [runtime
directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory),
-and includes the snapshotted files in the files sent to Borg. borgmatic is
-also responsible for cleaning up (destroying) these snapshots after a backup
+and includes the snapshotted files in the paths sent to Borg. borgmatic is also
+responsible for cleaning up (destroying) these snapshots after a backup
completes.
Additionally, borgmatic rewrites the snapshot file paths so that they appear
@@ -88,3 +88,60 @@ Filesystem snapshots are stored in a Borg archive as normal files, so
you can use the standard
[extract action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) to
extract them.
+
+
+### Btrfs
+
+New in version 1.9.4 Beta feature borgmatic supports taking
+snapshots with the [Btrfs filesystem](https://btrfs.readthedocs.io/) and sending
+those snapshots to Borg for backup.
+
+To use this feature, first you need one or more Btrfs subvolumes on mounted
+filesystems. Then, enable Btrfs within borgmatic by adding the following line to
+your configuration file:
+
+```yaml
+btrfs:
+```
+
+No other options are necessary to enable Btrfs support, but if desired you can
+override some of the commands used by the Btrfs hook. For instance:
+
+```yaml
+btrfs:
+ btrfs_command: /usr/local/bin/btrfs
+ findmnt_command: /usr/local/bin/findmnt
+```
+
+As long as the Btrfs hook is in beta, it may be subject to breaking changes
+and/or may not work well for your use cases. But feel free to use it in
+production if you're okay with these caveats, and please [provide any
+feedback](https://torsion.org/borgmatic/#issues) you have on this feature.
+
+
+#### Subvolume discovery
+
+For any subvolume you'd like backed up, add its path to borgmatic's
+`source_directories` option. During a backup, borgmatic snapshots these
+subvolumes (non-recursively) and includes the snapshotted files in the paths
+sent to Borg. borgmatic is also responsible for cleaning up (deleting) these
+snapshots after a backup completes.
+
+Additionally, borgmatic rewrites the snapshot file paths so that they appear at
+their original subvolume locations in a Borg archive. For instance, if your
+subvolume exists at `/mnt/subvolume`, then the snapshotted files will appear in
+an archive at `/mnt/subvolume` as well.
+
+With Borg version 1.2 and
+earlierSnapshotted files are instead stored at a path dependent on the
+temporary snapshot directory in use at the time the archive was created, as Borg
+1.2 and earlier do not support path rewriting.
+
+
+#### Extract a subvolume
+
+Subvolume snapshots are stored in a Borg archive as normal files, so you can use
+the standard [extract
+action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) to extract
+them.
diff --git a/docs/static/btrfs.png b/docs/static/btrfs.png
new file mode 100644
index 00000000..7c52a2f3
Binary files /dev/null and b/docs/static/btrfs.png differ
diff --git a/tests/unit/config/test_paths.py b/tests/unit/config/test_paths.py
index 294212ba..a63c86e1 100644
--- a/tests/unit/config/test_paths.py
+++ b/tests/unit/config/test_paths.py
@@ -45,6 +45,22 @@ def test_replace_temporary_subdirectory_with_glob_transforms_path():
)
+def test_replace_temporary_subdirectory_with_glob_passes_through_non_matching_path():
+ assert (
+ module.replace_temporary_subdirectory_with_glob('/tmp/foo-aet8kn93/borgmatic')
+ == '/tmp/foo-aet8kn93/borgmatic'
+ )
+
+
+def test_replace_temporary_subdirectory_with_glob_uses_custom_temporary_directory_prefix():
+ assert (
+ module.replace_temporary_subdirectory_with_glob(
+ '/tmp/.borgmatic-aet8kn93/borgmatic', temporary_directory_prefix='.borgmatic-'
+ )
+ == '/tmp/.borgmatic-*/borgmatic'
+ )
+
+
def test_runtime_directory_uses_config_option():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os).should_receive('makedirs')
diff --git a/tests/unit/hooks/data_source/test_btrfs.py b/tests/unit/hooks/data_source/test_btrfs.py
new file mode 100644
index 00000000..f983a66d
--- /dev/null
+++ b/tests/unit/hooks/data_source/test_btrfs.py
@@ -0,0 +1,670 @@
+from flexmock import flexmock
+
+from borgmatic.hooks.data_source import btrfs as module
+
+
+def test_get_filesystem_mount_points_parses_findmnt_output():
+ flexmock(module.borgmatic.execute).should_receive(
+ 'execute_command_and_capture_output'
+ ).and_return(
+ '/mnt0 /dev/loop0 btrfs rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/\n'
+ '/mnt1 /dev/loop1 btrfs rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/\n'
+ )
+
+ assert module.get_filesystem_mount_points('findmnt') == ('/mnt0', '/mnt1')
+
+
+def test_get_subvolumes_for_filesystem_parses_subvolume_list_output():
+ flexmock(module.borgmatic.execute).should_receive(
+ 'execute_command_and_capture_output'
+ ).and_return(
+ 'ID 270 gen 107 top level 5 path subvol1\nID 272 gen 74 top level 5 path subvol2\n'
+ )
+
+ assert module.get_subvolumes_for_filesystem('btrfs', '/mnt') == ('/mnt/subvol1', '/mnt/subvol2')
+
+
+def test_get_subvolumes_for_filesystem_skips_empty_subvolume_paths():
+ flexmock(module.borgmatic.execute).should_receive(
+ 'execute_command_and_capture_output'
+ ).and_return('\n \nID 272 gen 74 top level 5 path subvol2\n')
+
+ assert module.get_subvolumes_for_filesystem('btrfs', '/mnt') == ('/mnt/subvol2',)
+
+
+def test_get_subvolumes_for_filesystem_skips_empty_filesystem_mount_points():
+ flexmock(module.borgmatic.execute).should_receive(
+ 'execute_command_and_capture_output'
+ ).and_return(
+ 'ID 270 gen 107 top level 5 path subvol1\nID 272 gen 74 top level 5 path subvol2\n'
+ )
+
+ assert module.get_subvolumes_for_filesystem('btrfs', ' ') == ()
+
+
+def test_get_subvolumes_collects_subvolumes_matching_source_directories_from_all_filesystems():
+ flexmock(module).should_receive('get_filesystem_mount_points').and_return(('/mnt1', '/mnt2'))
+ flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
+ 'btrfs', '/mnt1'
+ ).and_return(('/one', '/two'))
+ flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
+ 'btrfs', '/mnt2'
+ ).and_return(('/three', '/four'))
+
+ assert module.get_subvolumes(
+ 'btrfs', 'findmnt', source_directories=['/one', '/four', '/five', '/six', '/mnt2', '/mnt3']
+ ) == ('/one', '/mnt2', '/four')
+
+
+def test_get_subvolumes_without_source_directories_collects_all_subvolumes_from_all_filesystems():
+ flexmock(module).should_receive('get_filesystem_mount_points').and_return(('/mnt1', '/mnt2'))
+ flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
+ 'btrfs', '/mnt1'
+ ).and_return(('/one', '/two'))
+ flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
+ 'btrfs', '/mnt2'
+ ).and_return(('/three', '/four'))
+
+ assert module.get_subvolumes('btrfs', 'findmnt') == (
+ '/mnt1',
+ '/one',
+ '/two',
+ '/mnt2',
+ '/three',
+ '/four',
+ )
+
+
+def test_dump_data_sources_snapshots_each_subvolume_and_updates_source_directories():
+ source_directories = ['/foo', '/mnt/subvol1']
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_return(('/mnt/subvol1', '/mnt/subvol2'))
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ )
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
+ )
+ flexmock(module).should_receive('snapshot_subvolume').with_args(
+ 'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ ).once()
+ flexmock(module).should_receive('snapshot_subvolume').with_args(
+ 'btrfs', '/mnt/subvol2', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
+ ).once()
+ flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
+ '/mnt/subvol1'
+ ).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
+ flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
+ '/mnt/subvol2'
+ ).and_return('/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234')
+
+ assert (
+ module.dump_data_sources(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ config_paths=('test.yaml',),
+ borgmatic_runtime_directory='/run/borgmatic',
+ source_directories=source_directories,
+ dry_run=False,
+ )
+ == []
+ )
+
+ assert source_directories == [
+ '/foo',
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
+ ]
+ assert config == {
+ 'btrfs': {},
+ 'exclude_patterns': [
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
+ ],
+ }
+
+
+def test_dump_data_sources_uses_custom_btrfs_command_in_commands():
+ source_directories = ['/foo', '/mnt/subvol1']
+ config = {'btrfs': {'btrfs_command': '/usr/local/bin/btrfs'}}
+ flexmock(module).should_receive('get_subvolumes').and_return(('/mnt/subvol1',))
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ )
+ flexmock(module).should_receive('snapshot_subvolume').with_args(
+ '/usr/local/bin/btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ ).once()
+ flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
+ '/mnt/subvol1'
+ ).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
+
+ assert (
+ module.dump_data_sources(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ config_paths=('test.yaml',),
+ borgmatic_runtime_directory='/run/borgmatic',
+ source_directories=source_directories,
+ dry_run=False,
+ )
+ == []
+ )
+
+ assert source_directories == [
+ '/foo',
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
+ ]
+ assert config == {
+ 'btrfs': {
+ 'btrfs_command': '/usr/local/bin/btrfs',
+ },
+ 'exclude_patterns': [
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
+ ],
+ }
+
+
+def test_dump_data_sources_uses_custom_findmnt_command_in_commands():
+ source_directories = ['/foo', '/mnt/subvol1']
+ config = {'btrfs': {'findmnt_command': '/usr/local/bin/findmnt'}}
+ flexmock(module).should_receive('get_subvolumes').with_args(
+ 'btrfs', '/usr/local/bin/findmnt', source_directories
+ ).and_return(('/mnt/subvol1',)).once()
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ )
+ flexmock(module).should_receive('snapshot_subvolume').with_args(
+ 'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ ).once()
+ flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
+ '/mnt/subvol1'
+ ).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
+
+ assert (
+ module.dump_data_sources(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ config_paths=('test.yaml',),
+ borgmatic_runtime_directory='/run/borgmatic',
+ source_directories=source_directories,
+ dry_run=False,
+ )
+ == []
+ )
+
+ assert source_directories == [
+ '/foo',
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
+ ]
+ assert config == {
+ 'btrfs': {
+ 'findmnt_command': '/usr/local/bin/findmnt',
+ },
+ 'exclude_patterns': [
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
+ ],
+ }
+
+
+def test_dump_data_sources_with_dry_run_skips_snapshot_and_source_directories_update():
+ source_directories = ['/foo', '/mnt/subvol1']
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_return(('/mnt/subvol1',))
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ )
+ flexmock(module).should_receive('snapshot_subvolume').never()
+ flexmock(module).should_receive('make_snapshot_exclude_path').never()
+
+ assert (
+ module.dump_data_sources(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ config_paths=('test.yaml',),
+ borgmatic_runtime_directory='/run/borgmatic',
+ source_directories=source_directories,
+ dry_run=True,
+ )
+ == []
+ )
+
+ assert source_directories == ['/foo', '/mnt/subvol1']
+ assert config == {'btrfs': {}}
+
+
+def test_dump_data_sources_without_matching_subvolumes_skips_snapshot_and_source_directories_update():
+ source_directories = ['/foo', '/mnt/subvol1']
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_return(())
+ flexmock(module).should_receive('make_snapshot_path').never()
+ flexmock(module).should_receive('snapshot_subvolume').never()
+ flexmock(module).should_receive('make_snapshot_exclude_path').never()
+
+ assert (
+ module.dump_data_sources(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ config_paths=('test.yaml',),
+ borgmatic_runtime_directory='/run/borgmatic',
+ source_directories=source_directories,
+ dry_run=False,
+ )
+ == []
+ )
+
+ assert source_directories == ['/foo', '/mnt/subvol1']
+ assert config == {'btrfs': {}}
+
+
+def test_dump_data_sources_snapshots_adds_to_existing_exclude_patterns():
+ source_directories = ['/foo', '/mnt/subvol1']
+ config = {'btrfs': {}, 'exclude_patterns': ['/bar']}
+ flexmock(module).should_receive('get_subvolumes').and_return(('/mnt/subvol1', '/mnt/subvol2'))
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ )
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
+ )
+ flexmock(module).should_receive('snapshot_subvolume').with_args(
+ 'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ ).once()
+ flexmock(module).should_receive('snapshot_subvolume').with_args(
+ 'btrfs', '/mnt/subvol2', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
+ ).once()
+ flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
+ '/mnt/subvol1'
+ ).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
+ flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
+ '/mnt/subvol2'
+ ).and_return('/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234')
+
+ assert (
+ module.dump_data_sources(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ config_paths=('test.yaml',),
+ borgmatic_runtime_directory='/run/borgmatic',
+ source_directories=source_directories,
+ dry_run=False,
+ )
+ == []
+ )
+
+ assert source_directories == [
+ '/foo',
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
+ ]
+ assert config == {
+ 'btrfs': {},
+ 'exclude_patterns': [
+ '/bar',
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
+ ],
+ }
+
+
+def test_remove_data_source_dumps_deletes_snapshots():
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_return(('/mnt/subvol1', '/mnt/subvol2'))
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
+ '/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
+ )
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
+ '/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
+ )
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).with_args(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
+ temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
+ ).and_return(
+ '/mnt/subvol1/.borgmatic-*/mnt/subvol1'
+ )
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).with_args(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
+ temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
+ ).and_return(
+ '/mnt/subvol2/.borgmatic-*/mnt/subvol2'
+ )
+ flexmock(module.glob).should_receive('glob').with_args(
+ '/mnt/subvol1/.borgmatic-*/mnt/subvol1'
+ ).and_return(
+ ('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
+ )
+ flexmock(module.glob).should_receive('glob').with_args(
+ '/mnt/subvol2/.borgmatic-*/mnt/subvol2'
+ ).and_return(
+ ('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
+ )
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
+ ).and_return(False)
+ flexmock(module).should_receive('delete_snapshot').with_args(
+ 'btrfs', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ ).once()
+ flexmock(module).should_receive('delete_snapshot').with_args(
+ 'btrfs', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
+ ).once()
+ flexmock(module).should_receive('delete_snapshot').with_args(
+ 'btrfs', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
+ ).once()
+ flexmock(module).should_receive('delete_snapshot').with_args(
+ 'btrfs', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
+ ).never()
+ flexmock(module.shutil).should_receive('rmtree').with_args(
+ '/mnt/subvol1/.borgmatic-1234'
+ ).once()
+ flexmock(module.shutil).should_receive('rmtree').with_args(
+ '/mnt/subvol1/.borgmatic-5678'
+ ).once()
+ flexmock(module.shutil).should_receive('rmtree').with_args(
+ '/mnt/subvol2/.borgmatic-1234'
+ ).once()
+ flexmock(module.shutil).should_receive('rmtree').with_args(
+ '/mnt/subvol2/.borgmatic-5678'
+ ).never()
+
+ module.remove_data_source_dumps(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ borgmatic_runtime_directory='/run/borgmatic',
+ dry_run=False,
+ )
+
+
+def test_remove_data_source_dumps_with_get_subvolumes_file_not_found_error_bails():
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_raise(FileNotFoundError)
+ flexmock(module).should_receive('make_snapshot_path').never()
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).never()
+ flexmock(module).should_receive('delete_snapshot').never()
+ flexmock(module.shutil).should_receive('rmtree').never()
+
+ module.remove_data_source_dumps(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ borgmatic_runtime_directory='/run/borgmatic',
+ dry_run=False,
+ )
+
+
+def test_remove_data_source_dumps_with_get_subvolumes_called_process_error_bails():
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_raise(
+ module.subprocess.CalledProcessError(1, 'command', 'error')
+ )
+ flexmock(module).should_receive('make_snapshot_path').never()
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).never()
+ flexmock(module).should_receive('delete_snapshot').never()
+ flexmock(module.shutil).should_receive('rmtree').never()
+
+ module.remove_data_source_dumps(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ borgmatic_runtime_directory='/run/borgmatic',
+ dry_run=False,
+ )
+
+
+def test_remove_data_source_dumps_with_dry_run_skips_deletes():
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_return(('/mnt/subvol1', '/mnt/subvol2'))
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
+ '/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
+ )
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
+ '/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
+ )
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).with_args(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
+ temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
+ ).and_return(
+ '/mnt/subvol1/.borgmatic-*/mnt/subvol1'
+ )
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).with_args(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
+ temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
+ ).and_return(
+ '/mnt/subvol2/.borgmatic-*/mnt/subvol2'
+ )
+ flexmock(module.glob).should_receive('glob').with_args(
+ '/mnt/subvol1/.borgmatic-*/mnt/subvol1'
+ ).and_return(
+ ('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
+ )
+ flexmock(module.glob).should_receive('glob').with_args(
+ '/mnt/subvol2/.borgmatic-*/mnt/subvol2'
+ ).and_return(
+ ('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
+ )
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
+ ).and_return(False)
+ flexmock(module).should_receive('delete_snapshot').never()
+ flexmock(module.shutil).should_receive('rmtree').never()
+
+ module.remove_data_source_dumps(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ borgmatic_runtime_directory='/run/borgmatic',
+ dry_run=True,
+ )
+
+
+def test_remove_data_source_dumps_without_subvolumes_skips_deletes():
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_return(())
+ flexmock(module).should_receive('make_snapshot_path').never()
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).never()
+ flexmock(module).should_receive('delete_snapshot').never()
+ flexmock(module.shutil).should_receive('rmtree').never()
+
+ module.remove_data_source_dumps(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ borgmatic_runtime_directory='/run/borgmatic',
+ dry_run=False,
+ )
+
+
+def test_remove_data_source_without_snapshots_skips_deletes():
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_return(('/mnt/subvol1', '/mnt/subvol2'))
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
+ '/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
+ )
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
+ '/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
+ )
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).with_args(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
+ temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
+ ).and_return(
+ '/mnt/subvol1/.borgmatic-*/mnt/subvol1'
+ )
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).with_args(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
+ temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
+ ).and_return(
+ '/mnt/subvol2/.borgmatic-*/mnt/subvol2'
+ )
+ flexmock(module.glob).should_receive('glob').and_return(())
+ flexmock(module.os.path).should_receive('isdir').never()
+ flexmock(module).should_receive('delete_snapshot').never()
+ flexmock(module.shutil).should_receive('rmtree').never()
+
+ module.remove_data_source_dumps(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ borgmatic_runtime_directory='/run/borgmatic',
+ dry_run=False,
+ )
+
+
+def test_remove_data_source_dumps_with_delete_snapshot_file_not_found_error_bails():
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_return(('/mnt/subvol1', '/mnt/subvol2'))
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
+ '/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
+ )
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
+ '/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
+ )
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).with_args(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
+ temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
+ ).and_return(
+ '/mnt/subvol1/.borgmatic-*/mnt/subvol1'
+ )
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).with_args(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
+ temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
+ ).and_return(
+ '/mnt/subvol2/.borgmatic-*/mnt/subvol2'
+ )
+ flexmock(module.glob).should_receive('glob').with_args(
+ '/mnt/subvol1/.borgmatic-*/mnt/subvol1'
+ ).and_return(
+ ('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
+ )
+ flexmock(module.glob).should_receive('glob').with_args(
+ '/mnt/subvol2/.borgmatic-*/mnt/subvol2'
+ ).and_return(
+ ('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
+ )
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
+ ).and_return(False)
+ flexmock(module).should_receive('delete_snapshot').and_raise(FileNotFoundError)
+ flexmock(module.shutil).should_receive('rmtree').never()
+
+ module.remove_data_source_dumps(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ borgmatic_runtime_directory='/run/borgmatic',
+ dry_run=False,
+ )
+
+
+def test_remove_data_source_dumps_with_delete_snapshot_called_process_error_bails():
+ config = {'btrfs': {}}
+ flexmock(module).should_receive('get_subvolumes').and_return(('/mnt/subvol1', '/mnt/subvol2'))
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
+ '/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
+ )
+ flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
+ '/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
+ )
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).with_args(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
+ temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
+ ).and_return(
+ '/mnt/subvol1/.borgmatic-*/mnt/subvol1'
+ )
+ flexmock(module.borgmatic.config.paths).should_receive(
+ 'replace_temporary_subdirectory_with_glob'
+ ).with_args(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
+ temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
+ ).and_return(
+ '/mnt/subvol2/.borgmatic-*/mnt/subvol2'
+ )
+ flexmock(module.glob).should_receive('glob').with_args(
+ '/mnt/subvol1/.borgmatic-*/mnt/subvol1'
+ ).and_return(
+ ('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
+ )
+ flexmock(module.glob).should_receive('glob').with_args(
+ '/mnt/subvol2/.borgmatic-*/mnt/subvol2'
+ ).and_return(
+ ('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
+ )
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
+ ).and_return(True)
+ flexmock(module.os.path).should_receive('isdir').with_args(
+ '/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
+ ).and_return(False)
+ flexmock(module).should_receive('delete_snapshot').and_raise(
+ module.subprocess.CalledProcessError(1, 'command', 'error')
+ )
+ flexmock(module.shutil).should_receive('rmtree').never()
+
+ module.remove_data_source_dumps(
+ hook_config=config['btrfs'],
+ config=config,
+ log_prefix='test',
+ borgmatic_runtime_directory='/run/borgmatic',
+ dry_run=False,
+ )
diff --git a/tests/unit/hooks/data_source/test_zfs.py b/tests/unit/hooks/data_source/test_zfs.py
index 7f634dad..2eea24f7 100644
--- a/tests/unit/hooks/data_source/test_zfs.py
+++ b/tests/unit/hooks/data_source/test_zfs.py
@@ -1,12 +1,13 @@
import pytest
from flexmock import flexmock
-import borgmatic.execute
from borgmatic.hooks.data_source import zfs as module
def test_get_datasets_to_backup_filters_datasets_by_source_directories():
- flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
+ flexmock(module.borgmatic.execute).should_receive(
+ 'execute_command_and_capture_output'
+ ).and_return(
'dataset\t/dataset\t-\nother\t/other\t-',
)
@@ -16,7 +17,9 @@ def test_get_datasets_to_backup_filters_datasets_by_source_directories():
def test_get_datasets_to_backup_filters_datasets_by_user_property():
- flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
+ flexmock(module.borgmatic.execute).should_receive(
+ 'execute_command_and_capture_output'
+ ).and_return(
'dataset\t/dataset\tauto\nother\t/other\t-',
)
@@ -26,7 +29,9 @@ def test_get_datasets_to_backup_filters_datasets_by_user_property():
def test_get_datasets_to_backup_with_invalid_list_output_raises():
- flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
+ flexmock(module.borgmatic.execute).should_receive(
+ 'execute_command_and_capture_output'
+ ).and_return(
'dataset',
)
@@ -35,7 +40,9 @@ def test_get_datasets_to_backup_with_invalid_list_output_raises():
def test_get_get_all_datasets_does_not_filter_datasets():
- flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
+ flexmock(module.borgmatic.execute).should_receive(
+ 'execute_command_and_capture_output'
+ ).and_return(
'dataset\t/dataset\nother\t/other',
)
@@ -46,7 +53,9 @@ def test_get_get_all_datasets_does_not_filter_datasets():
def test_get_all_datasets_with_invalid_list_output_raises():
- flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
+ flexmock(module.borgmatic.execute).should_receive(
+ 'execute_command_and_capture_output'
+ ).and_return(
'dataset',
)
@@ -88,6 +97,29 @@ def test_dump_data_sources_snapshots_and_mounts_and_updates_source_directories()
assert source_directories == [snapshot_mount_path]
+def test_dump_data_sources_snapshots_with_no_datasets_skips_snapshots():
+ flexmock(module).should_receive('get_datasets_to_backup').and_return(())
+ flexmock(module.os).should_receive('getpid').and_return(1234)
+ flexmock(module).should_receive('snapshot_dataset').never()
+ flexmock(module).should_receive('mount_snapshot').never()
+ source_directories = ['/mnt/dataset']
+
+ assert (
+ module.dump_data_sources(
+ hook_config={},
+ config={'source_directories': '/mnt/dataset', 'zfs': {}},
+ log_prefix='test',
+ config_paths=('test.yaml',),
+ borgmatic_runtime_directory='/run/borgmatic',
+ source_directories=source_directories,
+ dry_run=False,
+ )
+ == []
+ )
+
+ assert source_directories == ['/mnt/dataset']
+
+
def test_dump_data_sources_uses_custom_commands():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(('dataset', '/mnt/dataset'),)
@@ -155,7 +187,9 @@ def test_dump_data_sources_with_dry_run_skips_commands_and_does_not_touch_source
def test_get_all_snapshots_parses_list_output():
- flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
+ flexmock(module.borgmatic.execute).should_receive(
+ 'execute_command_and_capture_output'
+ ).and_return(
'dataset1@borgmatic-1234\ndataset2@borgmatic-4567',
)