2 Commits
v3.2.1 ... main

Author SHA1 Message Date
c2f1cb8e8c Release version 3.2.2 2026-07-31 19:20:49 +02:00
eeaa838d02 fix(backup): carve the btrfs snapshot inside its subject, not beside it
The kernel refuses a snapshot whose destination sits on another
filesystem. Placing it at <parent>/.baudolo-<tag> hit that on the most
sensible layout there is: a dedicated disk mounted straight onto
/var/lib/docker, where the parent directory belongs to a different
filesystem and every run failed with EXDEV.

Placing it at <subject>/.baudolo-<tag> makes source and destination the
same filesystem by construction, so the failure cannot occur on any
layout. It also aligns the two backends: the zfs path already resolves
its snapshot inside the subject, at <subject>/.zfs/snapshot/<tag>.

btrfs does not include nested subvolumes in a snapshot, so a leftover
from an interrupted run appears in the next snapshot as an empty
directory rather than recursing, and the copy only ever reads the volume
tree below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:19:42 +02:00
5 changed files with 25 additions and 9 deletions

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## [3.2.2] - 2026-07-31
- Backup: the btrfs snapshot is carved inside its subject, as
*<data root>/.baudolo-<tag>*, not beside it. The kernel refuses a snapshot
whose destination is on another filesystem, which is exactly what the parent
directory is when the data root is a mountpoint of its own — a dedicated disk
mounted onto */var/lib/docker* failed every run with EXDEV. Placing it inside
makes source and destination the same filesystem by construction, and aligns
btrfs with the zfs path, which already resolves its snapshot inside the
subject at *<subject>/.zfs/snapshot/<tag>*. A leftover from an interrupted run
appears in the next snapshot as an empty directory rather than recursing,
since btrfs does not include nested subvolumes.
## [3.2.1] - 2026-07-31 ## [3.2.1] - 2026-07-31
- Backup: the snapshot resolver keeps the trailing separator *get_storage_path* - Backup: the snapshot resolver keeps the trailing separator *get_storage_path*

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "backup-docker-to-local" name = "backup-docker-to-local"
version = "3.2.1" version = "3.2.2"
description = "Backup Docker volumes to local with rsync and optional DB dumps." description = "Backup Docker volumes to local with rsync and optional DB dumps."
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"

View File

@@ -41,7 +41,10 @@ def _resolver(subject: str, root: str) -> Callable[[str], str]:
def _btrfs(subject: str, name: str, run: Callable[[str], list[str]]) -> tuple[str, str]: def _btrfs(subject: str, name: str, run: Callable[[str], list[str]]) -> tuple[str, str]:
target = os.path.join(os.path.dirname(os.path.abspath(subject)), f".{name}") # The snapshot goes inside the subject, never beside it: the kernel rejects
# a snapshot whose destination is on another filesystem, which is exactly
# what the parent directory is when the subject is a mountpoint of its own.
target = os.path.join(os.path.abspath(subject), f".{name}")
run(f"btrfs subvolume snapshot -r {subject} {target}") run(f"btrfs subvolume snapshot -r {subject} {target}")
return target, f"btrfs subvolume delete {target}" return target, f"btrfs subvolume delete {target}"

View File

@@ -64,7 +64,7 @@ class TestSnapshotBranch(unittest.TestCase):
def test_it_reads_from_the_snapshot_and_not_from_the_live_tree(self) -> None: def test_it_reads_from_the_snapshot_and_not_from_the_live_tree(self) -> None:
source = drive(present=True)[0]["source"] source = drive(present=True)[0]["source"]
self.assertTrue(source.startswith("/var/lib/.baudolo-"), source) self.assertTrue(source.startswith("/var/lib/docker/.baudolo-"), source)
def test_it_compares_by_content_against_the_previous_generation(self) -> None: def test_it_compares_by_content_against_the_previous_generation(self) -> None:
self.assertTrue(drive(present=True)[0]["authoritative"]) self.assertTrue(drive(present=True)[0]["authoritative"])

View File

@@ -22,27 +22,27 @@ class Runner:
class TestBtrfs(unittest.TestCase): class TestBtrfs(unittest.TestCase):
def test_it_creates_a_read_only_snapshot_beside_the_subject(self) -> None: def test_it_creates_a_read_only_snapshot_inside_the_subject(self) -> None:
run = Runner() run = Runner()
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run): with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run):
pass pass
self.assertEqual( self.assertEqual(
run.calls[0], run.calls[0],
"btrfs subvolume snapshot -r /var/lib/docker /var/lib/.baudolo-20260731", "btrfs subvolume snapshot -r /var/lib/docker /var/lib/docker/.baudolo-20260731",
) )
def test_it_removes_the_snapshot_afterwards(self) -> None: def test_it_removes_the_snapshot_afterwards(self) -> None:
run = Runner() run = Runner()
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run): with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run):
pass pass
self.assertEqual(run.calls[-1], "btrfs subvolume delete /var/lib/.baudolo-20260731") self.assertEqual(run.calls[-1], "btrfs subvolume delete /var/lib/docker/.baudolo-20260731")
def test_it_maps_a_volume_path_into_the_snapshot(self) -> None: def test_it_maps_a_volume_path_into_the_snapshot(self) -> None:
run = Runner() run = Runner()
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run) as resolve: with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run) as resolve:
self.assertEqual( self.assertEqual(
resolve("/var/lib/docker/volumes/postgres_data/_data"), resolve("/var/lib/docker/volumes/postgres_data/_data"),
"/var/lib/.baudolo-20260731/volumes/postgres_data/_data", "/var/lib/docker/.baudolo-20260731/volumes/postgres_data/_data",
) )
def test_it_keeps_the_trailing_slash_rsync_reads_as_contents(self) -> None: def test_it_keeps_the_trailing_slash_rsync_reads_as_contents(self) -> None:
@@ -50,7 +50,7 @@ class TestBtrfs(unittest.TestCase):
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run) as resolve: with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run) as resolve:
self.assertEqual( self.assertEqual(
resolve("/var/lib/docker/volumes/postgres_data/_data/"), resolve("/var/lib/docker/volumes/postgres_data/_data/"),
"/var/lib/.baudolo-20260731/volumes/postgres_data/_data/", "/var/lib/docker/.baudolo-20260731/volumes/postgres_data/_data/",
) )
def test_it_removes_the_snapshot_even_when_the_body_raises(self) -> None: def test_it_removes_the_snapshot_even_when_the_body_raises(self) -> None:
@@ -109,7 +109,7 @@ class TestRejections(unittest.TestCase):
def test_the_subject_itself_resolves_to_the_snapshot_root(self) -> None: def test_the_subject_itself_resolves_to_the_snapshot_root(self) -> None:
run = Runner() run = Runner()
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run) as resolve: with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run) as resolve:
self.assertEqual(resolve("/var/lib/docker"), "/var/lib/.baudolo-20260731") self.assertEqual(resolve("/var/lib/docker"), "/var/lib/docker/.baudolo-20260731")
class Busy(Runner): class Busy(Runner):