Browse Source

Simplify logic around checking for read-only Btrfs subvolumes (#1105).

Dan Helfman 1 month ago
parent
commit
5dca281439

+ 49 - 59
borgmatic/hooks/data_source/btrfs.py

@@ -50,46 +50,6 @@ def path_is_a_subvolume(btrfs_command, path):
     return True
     return True
 
 
 
 
-def get_containing_subvolume_path(btrfs_command, path):
-    '''
-    Given a btrfs command and a path, return the subvolume path that contains the given path (or is
-    the same as the path).
-    '''
-    # Probe the given pattern's path and all of its parents, grandparents, etc. to try to find a
-    # Btrfs subvolume.
-    for candidate_path in (
-        path,
-        *tuple(str(ancestor) for ancestor in pathlib.PurePath(path).parents),
-    ):
-        if path_is_a_subvolume(btrfs_command, candidate_path):
-            logger.debug(f'Path {candidate_path} is a Btrfs subvolume')
-            return candidate_path
-
-    return None
-
-
-def get_all_subvolume_paths(btrfs_command, patterns):
-    '''
-    Given a btrfs command and a sequence of patterns, get the sorted paths for all Btrfs subvolumes
-    containing those patterns.
-    '''
-    return tuple(
-        sorted(
-            {
-                subvolume_path
-                for pattern in patterns
-                if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
-                if pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
-                for subvolume_path in (get_containing_subvolume_path(btrfs_command, pattern.path),)
-                if subvolume_path
-            }
-        ),
-    )
-
-
-Subvolume = collections.namedtuple('Subvolume', ('path', 'contained_patterns'), defaults=((),))
-
-
 def get_subvolume_property(btrfs_command, subvolume_path, property_name):
 def get_subvolume_property(btrfs_command, subvolume_path, property_name):
     '''
     '''
     Given a btrfs command, a subvolume path, and a property name to lookup, return the value of the
     Given a btrfs command, a subvolume path, and a property name to lookup, return the value of the
@@ -121,26 +81,61 @@ def get_subvolume_property(btrfs_command, subvolume_path, property_name):
     }.get(value, value)
     }.get(value, value)
 
 
 
 
-def omit_read_only_subvolume_paths(btrfs_command, subvolume_paths):
+def get_containing_subvolume_path(btrfs_command, path):
     '''
     '''
-    Given a Btrfs command to run and a sequence of Btrfs subvolume paths, filter them down to just
-    those that are read-write. The idea is that Btrfs can't actually snapshot a read-only subvolume,
-    so we should just ignore them.
+    Given a btrfs command and a path, return the subvolume path that contains the given path (or is
+    the same as the path).
+
+    If there is no such subvolume path or the containing subvolume is read-only, return None.
     '''
     '''
-    retained_subvolume_paths = []
+    # Probe the given pattern's path and all of its parents, grandparents, etc. to try to find a
+    # Btrfs subvolume.
+    for candidate_path in (
+        path,
+        *tuple(str(ancestor) for ancestor in pathlib.PurePath(path).parents),
+    ):
+        if not path_is_a_subvolume(btrfs_command, candidate_path):
+            continue
 
 
-    for subvolume_path in subvolume_paths:
         try:
         try:
-            if get_subvolume_property(btrfs_command, subvolume_path, 'ro'):
-                logger.debug(f'Ignoring Btrfs subvolume {subvolume_path} because it is read-only')
-            else:
-                retained_subvolume_paths.append(subvolume_path)
-        except subprocess.CalledProcessError as error:  # noqa: PERF203
+            if get_subvolume_property(btrfs_command, candidate_path, 'ro'):
+                logger.debug(f'Ignoring Btrfs subvolume {candidate_path} because it is read-only')
+
+                return None
+
+            logger.debug(f'Path {candidate_path} is a Btrfs subvolume')
+
+            return candidate_path
+        except subprocess.CalledProcessError as error:
             logger.debug(
             logger.debug(
-                f'Error determining read-only status of Btrfs subvolume {subvolume_path}: {error}',
+                f'Error determining read-only status of Btrfs subvolume {candidate_path}: {error}',
             )
             )
 
 
-    return tuple(retained_subvolume_paths)
+            return None
+
+    return None
+
+
+def get_all_subvolume_paths(btrfs_command, patterns):
+    '''
+    Given a btrfs command and a sequence of patterns, get the sorted paths for all Btrfs subvolumes
+    containing those patterns.
+    '''
+    return tuple(
+        sorted(
+            {
+                subvolume_path
+                for pattern in patterns
+                if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
+                if pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
+                for subvolume_path in (get_containing_subvolume_path(btrfs_command, pattern.path),)
+                if subvolume_path
+            }
+        ),
+    )
+
+
+Subvolume = collections.namedtuple('Subvolume', ('path', 'contained_patterns'), defaults=((),))
 
 
 
 
 def get_subvolumes(btrfs_command, patterns):
 def get_subvolumes(btrfs_command, patterns):
@@ -161,12 +156,7 @@ def get_subvolumes(btrfs_command, patterns):
     # backup. Sort the subvolumes from longest to shortest mount points, so longer subvolumes get
     # backup. Sort the subvolumes from longest to shortest mount points, so longer subvolumes get
     # a whack at the candidate pattern piñata before their parents do. (Patterns are consumed during
     # a whack at the candidate pattern piñata before their parents do. (Patterns are consumed during
     # this process, so no two subvolumes end up with the same contained patterns.)
     # this process, so no two subvolumes end up with the same contained patterns.)
-    for subvolume_path in reversed(
-        omit_read_only_subvolume_paths(
-            btrfs_command,
-            get_all_subvolume_paths(btrfs_command, patterns),
-        ),
-    ):
+    for subvolume_path in reversed(get_all_subvolume_paths(btrfs_command, patterns)):
         subvolumes.extend(
         subvolumes.extend(
             Subvolume(subvolume_path, contained_patterns)
             Subvolume(subvolume_path, contained_patterns)
             for contained_patterns in (
             for contained_patterns in (

+ 3 - 4
docs/how-to/snapshot-your-filesystems.md

@@ -162,12 +162,11 @@ btrfs:
 ```
 ```
 
 
 No other options are necessary to enable Btrfs support, but if desired you can
 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:
+override the `btrfs` command used by the Btrfs hook. For instance:
 
 
 ```yaml
 ```yaml
 btrfs:
 btrfs:
     btrfs_command: /usr/local/bin/btrfs
     btrfs_command: /usr/local/bin/btrfs
-    findmnt_command: /usr/local/bin/findmnt
 ```
 ```
 
 
 If you're using systemd to run borgmatic, you may need to modify the [sample systemd service
 If you're using systemd to run borgmatic, you may need to modify the [sample systemd service
@@ -183,8 +182,8 @@ feedback](https://torsion.org/borgmatic/#issues) you have on this feature.
 #### Subvolume discovery
 #### Subvolume discovery
 
 
 For any read-write subvolume you'd like backed up, add its subvolume path to
 For any read-write subvolume you'd like backed up, add its subvolume path to
-borgmatic's `source_directories` option. Btrfs does not support snapshotting
-read-only subvolumes.
+borgmatic's `source_directories` option. borgmatic does not currently support
+snapshotting read-only subvolumes.
 
 
 <span class="minilink minilink-addedin">New in version 2.0.7</span> The path can
 <span class="minilink minilink-addedin">New in version 2.0.7</span> The path can
 be either the path of the subvolume itself or the mount point where the
 be either the path of the subvolume itself or the mount point where the

+ 57 - 79
tests/unit/hooks/data_source/test_btrfs.py

@@ -37,6 +37,39 @@ def test_path_is_a_subvolume_caches_result_after_first_call():
     assert module.path_is_a_subvolume('btrfs', '/mnt0') is True
     assert module.path_is_a_subvolume('btrfs', '/mnt0') is True
 
 
 
 
+def test_get_subvolume_property_with_invalid_btrfs_output_errors():
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output',
+    ).and_return('invalid')
+
+    with pytest.raises(ValueError):
+        module.get_subvolume_property('btrfs', '/foo', 'ro')
+
+
+def test_get_subvolume_property_with_true_output_returns_true_bool():
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output',
+    ).and_return('ro=true')
+
+    assert module.get_subvolume_property('btrfs', '/foo', 'ro') is True
+
+
+def test_get_subvolume_property_with_false_output_returns_false_bool():
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output',
+    ).and_return('ro=false')
+
+    assert module.get_subvolume_property('btrfs', '/foo', 'ro') is False
+
+
+def test_get_subvolume_property_passes_through_general_value():
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output',
+    ).and_return('thing=value')
+
+    assert module.get_subvolume_property('btrfs', '/foo', 'thing') == 'value'
+
+
 def test_get_containing_subvolume_path_with_subvolume_self_returns_it():
 def test_get_containing_subvolume_path_with_subvolume_self_returns_it():
     flexmock(module).should_receive('path_is_a_subvolume').with_args(
     flexmock(module).should_receive('path_is_a_subvolume').with_args(
         'btrfs', '/foo/bar/baz'
         'btrfs', '/foo/bar/baz'
@@ -44,6 +77,7 @@ def test_get_containing_subvolume_path_with_subvolume_self_returns_it():
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/foo/bar').never()
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/foo/bar').never()
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/foo').never()
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/foo').never()
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/').never()
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/').never()
+    flexmock(module).should_receive('get_subvolume_property').and_return(False)
 
 
     assert module.get_containing_subvolume_path('btrfs', '/foo/bar/baz') == '/foo/bar/baz'
     assert module.get_containing_subvolume_path('btrfs', '/foo/bar/baz') == '/foo/bar/baz'
 
 
@@ -57,6 +91,7 @@ def test_get_containing_subvolume_path_with_subvolume_parent_returns_it():
     ).and_return(True)
     ).and_return(True)
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/foo').never()
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/foo').never()
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/').never()
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/').never()
+    flexmock(module).should_receive('get_subvolume_property').and_return(False)
 
 
     assert module.get_containing_subvolume_path('btrfs', '/foo/bar/baz') == '/foo/bar'
     assert module.get_containing_subvolume_path('btrfs', '/foo/bar/baz') == '/foo/bar'
 
 
@@ -72,6 +107,7 @@ def test_get_containing_subvolume_path_with_subvolume_grandparent_returns_it():
         True
         True
     )
     )
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/').never()
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/').never()
+    flexmock(module).should_receive('get_subvolume_property').and_return(False)
 
 
     assert module.get_containing_subvolume_path('btrfs', '/foo/bar/baz') == '/foo'
     assert module.get_containing_subvolume_path('btrfs', '/foo/bar/baz') == '/foo'
 
 
@@ -87,6 +123,27 @@ def test_get_containing_subvolume_path_without_subvolume_ancestor_returns_none()
         False
         False
     )
     )
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/').and_return(False)
     flexmock(module).should_receive('path_is_a_subvolume').with_args('btrfs', '/').and_return(False)
+    flexmock(module).should_receive('get_subvolume_property').and_return(False)
+
+    assert module.get_containing_subvolume_path('btrfs', '/foo/bar/baz') is None
+
+
+def test_get_containing_subvolume_path_with_read_only_subvolume_returns_none():
+    flexmock(module).should_receive('path_is_a_subvolume').with_args(
+        'btrfs', '/foo/bar/baz'
+    ).and_return(True)
+    flexmock(module).should_receive('get_subvolume_property').and_return(True)
+
+    assert module.get_containing_subvolume_path('btrfs', '/foo/bar/baz') is None
+
+
+def test_get_containing_subvolume_path_with_read_only_error_returns_none():
+    flexmock(module).should_receive('path_is_a_subvolume').with_args(
+        'btrfs', '/foo/bar/baz'
+    ).and_return(True)
+    flexmock(module).should_receive('get_subvolume_property').and_raise(
+        module.subprocess.CalledProcessError(1, 'wtf')
+    )
 
 
     assert module.get_containing_subvolume_path('btrfs', '/foo/bar/baz') is None
     assert module.get_containing_subvolume_path('btrfs', '/foo/bar/baz') is None
 
 
@@ -182,85 +239,8 @@ def test_get_all_subvolume_paths_sorts_subvolume_paths():
     ) == ('/bar', '/baz', '/foo')
     ) == ('/bar', '/baz', '/foo')
 
 
 
 
-def test_get_subvolume_property_with_invalid_btrfs_output_errors():
-    flexmock(module.borgmatic.execute).should_receive(
-        'execute_command_and_capture_output',
-    ).and_return('invalid')
-
-    with pytest.raises(ValueError):
-        module.get_subvolume_property('btrfs', '/foo', 'ro')
-
-
-def test_get_subvolume_property_with_true_output_returns_true_bool():
-    flexmock(module.borgmatic.execute).should_receive(
-        'execute_command_and_capture_output',
-    ).and_return('ro=true')
-
-    assert module.get_subvolume_property('btrfs', '/foo', 'ro') is True
-
-
-def test_get_subvolume_property_with_false_output_returns_false_bool():
-    flexmock(module.borgmatic.execute).should_receive(
-        'execute_command_and_capture_output',
-    ).and_return('ro=false')
-
-    assert module.get_subvolume_property('btrfs', '/foo', 'ro') is False
-
-
-def test_get_subvolume_property_passes_through_general_value():
-    flexmock(module.borgmatic.execute).should_receive(
-        'execute_command_and_capture_output',
-    ).and_return('thing=value')
-
-    assert module.get_subvolume_property('btrfs', '/foo', 'thing') == 'value'
-
-
-def test_omit_read_only_subvolume_paths_filters_out_read_only_subvolumes():
-    flexmock(module).should_receive('get_subvolume_property').with_args(
-        'btrfs',
-        '/foo',
-        'ro',
-    ).and_return(False)
-    flexmock(module).should_receive('get_subvolume_property').with_args(
-        'btrfs',
-        '/bar',
-        'ro',
-    ).and_return(True)
-    flexmock(module).should_receive('get_subvolume_property').with_args(
-        'btrfs',
-        '/baz',
-        'ro',
-    ).and_return(False)
-
-    assert module.omit_read_only_subvolume_paths('btrfs', ('/foo', '/bar', '/baz')) == (
-        '/foo',
-        '/baz',
-    )
-
-
-def test_omit_read_only_subvolume_paths_filters_out_erroring_subvolumes():
-    flexmock(module).should_receive('get_subvolume_property').with_args(
-        'btrfs',
-        '/foo',
-        'ro',
-    ).and_raise(module.subprocess.CalledProcessError(1, 'btrfs'))
-    flexmock(module).should_receive('get_subvolume_property').with_args(
-        'btrfs',
-        '/bar',
-        'ro',
-    ).and_return(True)
-    flexmock(module).should_receive('get_subvolume_property').with_args(
-        'btrfs',
-        '/baz',
-        'ro',
-    ).and_return(False)
-
-    assert module.omit_read_only_subvolume_paths('btrfs', ('/foo', '/bar', '/baz')) == ('/baz',)
-
-
 def test_get_subvolumes_collects_subvolumes_matching_patterns():
 def test_get_subvolumes_collects_subvolumes_matching_patterns():
     flexmock(module).should_receive('get_all_subvolume_paths').and_return(('/mnt1', '/mnt2'))
     flexmock(module).should_receive('get_all_subvolume_paths').and_return(('/mnt1', '/mnt2'))
-    flexmock(module).should_receive('omit_read_only_subvolume_paths').and_return(('/mnt1', '/mnt2'))
 
 
     contained_pattern = Pattern(
     contained_pattern = Pattern(
         '/mnt1',
         '/mnt1',
@@ -285,7 +265,6 @@ def test_get_subvolumes_collects_subvolumes_matching_patterns():
 
 
 def test_get_subvolumes_skips_non_root_patterns():
 def test_get_subvolumes_skips_non_root_patterns():
     flexmock(module).should_receive('get_all_subvolume_paths').and_return(('/mnt1', '/mnt2'))
     flexmock(module).should_receive('get_all_subvolume_paths').and_return(('/mnt1', '/mnt2'))
-    flexmock(module).should_receive('omit_read_only_subvolume_paths').and_return(('/mnt1', '/mnt2'))
 
 
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
         'get_contained_patterns',
         'get_contained_patterns',
@@ -316,7 +295,6 @@ def test_get_subvolumes_skips_non_root_patterns():
 
 
 def test_get_subvolumes_skips_non_config_patterns():
 def test_get_subvolumes_skips_non_config_patterns():
     flexmock(module).should_receive('get_all_subvolume_paths').and_return(('/mnt1', '/mnt2'))
     flexmock(module).should_receive('get_all_subvolume_paths').and_return(('/mnt1', '/mnt2'))
-    flexmock(module).should_receive('omit_read_only_subvolume_paths').and_return(('/mnt1', '/mnt2'))
 
 
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
         'get_contained_patterns',
         'get_contained_patterns',