Преглед изворни кода

Btrfs snapshotting (#251).

Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/946
Dan Helfman пре 6 месеци
родитељ
комит
cfff6c6855

+ 3 - 0
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.
 

+ 1 - 0
README.md

@@ -62,6 +62,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
 <a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://openzfs.org/"><img src="docs/static/openzfs.png" alt="OpenZFS" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
+<a href="https://btrfs.readthedocs.io/"><img src="docs/static/btrfs.png" alt="Btrfs" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://rclone.org"><img src="docs/static/rclone.png" alt="rclone" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>

+ 8 - 5
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)

+ 16 - 0
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.

+ 294 - 0
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()

+ 5 - 2
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}')

+ 62 - 5
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
+
+<span class="minilink minilink-addedin">New in version 1.9.4</span> <span
+class="minilink minilink-addedin">Beta feature</span> 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.
+
+<span class="minilink minilink-addedin">With Borg version 1.2 and
+earlier</span>Snapshotted 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.

BIN
docs/static/btrfs.png


+ 16 - 0
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')

+ 670 - 0
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,
+    )

+ 41 - 7
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',
     )