diff --git a/NEWS b/NEWS index 9e5a3e4e..e320b0e8 100644 --- a/NEWS +++ b/NEWS @@ -22,6 +22,8 @@ "working_directory" are used. * #1044: Fix an error in the systemd credential hook when the credential name contains a "." character. + * #1048: Fix a "no such file or directory" error in ZFS, Btrfs, and LVM hooks with nested + directories that reside on separate devices/filesystems. 1.9.14 * #409: With the PagerDuty monitoring hook, send borgmatic logs to PagerDuty so they show up in the diff --git a/borgmatic/hooks/data_source/snapshot.py b/borgmatic/hooks/data_source/snapshot.py index ece63ce6..4b67784f 100644 --- a/borgmatic/hooks/data_source/snapshot.py +++ b/borgmatic/hooks/data_source/snapshot.py @@ -1,3 +1,4 @@ +import os import pathlib IS_A_HOOK = False @@ -11,6 +12,10 @@ def get_contained_patterns(parent_directory, candidate_patterns): paths, but there's a parent directory (logical volume, dataset, subvolume, etc.) at /var, then /var is what we want to snapshot. + If a parent directory and a candidate pattern are on different devices, skip the pattern. That's + because any snapshot of a parent directory won't actually include "contained" directories if + they reside on separate devices. + For this function to work, a candidate pattern path can't have any globs or other non-literal characters in the initial portion of the path that matches the parent directory. For instance, a parent directory of /var would match a candidate pattern path of /var/log/*/data, but not a @@ -27,6 +32,8 @@ def get_contained_patterns(parent_directory, candidate_patterns): if not candidate_patterns: return () + parent_device = os.stat(parent_directory).st_dev + contained_patterns = tuple( candidate for candidate in candidate_patterns @@ -35,6 +42,7 @@ def get_contained_patterns(parent_directory, candidate_patterns): pathlib.PurePath(parent_directory) == candidate_path or pathlib.PurePath(parent_directory) in candidate_path.parents ) + if candidate.device == parent_device ) candidate_patterns -= set(contained_patterns) diff --git a/tests/unit/hooks/data_source/test_snapshot.py b/tests/unit/hooks/data_source/test_snapshot.py index 2c23a1e4..889a5642 100644 --- a/tests/unit/hooks/data_source/test_snapshot.py +++ b/tests/unit/hooks/data_source/test_snapshot.py @@ -1,34 +1,84 @@ +from flexmock import flexmock + from borgmatic.borg.pattern import Pattern from borgmatic.hooks.data_source import snapshot as module def test_get_contained_patterns_without_candidates_returns_empty(): + flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=flexmock())) + assert module.get_contained_patterns('/mnt', {}) == () def test_get_contained_patterns_with_self_candidate_returns_self(): - candidates = {Pattern('/foo'), Pattern('/mnt'), Pattern('/bar')} + device = flexmock() + flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=device)) + candidates = { + Pattern('/foo', device=device), + Pattern('/mnt', device=device), + Pattern('/bar', device=device), + } - assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt'),) - assert candidates == {Pattern('/foo'), Pattern('/bar')} + assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt', device=device),) + assert candidates == {Pattern('/foo', device=device), Pattern('/bar', device=device)} def test_get_contained_patterns_with_self_candidate_and_caret_prefix_returns_self(): - candidates = {Pattern('^/foo'), Pattern('^/mnt'), Pattern('^/bar')} + device = flexmock() + flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=device)) + candidates = { + Pattern('^/foo', device=device), + Pattern('^/mnt', device=device), + Pattern('^/bar', device=device), + } - assert module.get_contained_patterns('/mnt', candidates) == (Pattern('^/mnt'),) - assert candidates == {Pattern('^/foo'), Pattern('^/bar')} + assert module.get_contained_patterns('/mnt', candidates) == (Pattern('^/mnt', device=device),) + assert candidates == {Pattern('^/foo', device=device), Pattern('^/bar', device=device)} def test_get_contained_patterns_with_child_candidate_returns_child(): - candidates = {Pattern('/foo'), Pattern('/mnt/subdir'), Pattern('/bar')} + device = flexmock() + flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=device)) + candidates = { + Pattern('/foo', device=device), + Pattern('/mnt/subdir', device=device), + Pattern('/bar', device=device), + } - assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt/subdir'),) - assert candidates == {Pattern('/foo'), Pattern('/bar')} + assert module.get_contained_patterns('/mnt', candidates) == ( + Pattern('/mnt/subdir', device=device), + ) + assert candidates == {Pattern('/foo', device=device), Pattern('/bar', device=device)} def test_get_contained_patterns_with_grandchild_candidate_returns_child(): - candidates = {Pattern('/foo'), Pattern('/mnt/sub/dir'), Pattern('/bar')} + device = flexmock() + flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=device)) + candidates = { + Pattern('/foo', device=device), + Pattern('/mnt/sub/dir', device=device), + Pattern('/bar', device=device), + } - assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt/sub/dir'),) - assert candidates == {Pattern('/foo'), Pattern('/bar')} + assert module.get_contained_patterns('/mnt', candidates) == ( + Pattern('/mnt/sub/dir', device=device), + ) + assert candidates == {Pattern('/foo', device=device), Pattern('/bar', device=device)} + + +def test_get_contained_patterns_ignores_child_candidate_on_another_device(): + one_device = flexmock() + another_device = flexmock() + flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=one_device)) + candidates = { + Pattern('/foo', device=one_device), + Pattern('/mnt/subdir', device=another_device), + Pattern('/bar', device=one_device), + } + + assert module.get_contained_patterns('/mnt', candidates) == () + assert candidates == { + Pattern('/foo', device=one_device), + Pattern('/mnt/subdir', device=another_device), + Pattern('/bar', device=one_device), + }