From 07d819cb41a5927f89912e9449d656818f173a08 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 17 Oct 2026 22:28:13 +0200 Subject: [PATCH] fix(backup): carry extended attributes, ACLs and hardlinks into a generation rsync -a expands to -rlptgoD, so the backup stored neither extended attributes nor ACLs nor hardlinks while the restore side asks for -aHAX. A generation therefore never held what a restore wants back, and rsync faithfully mirrored a snapshot that has none. Reproduced end to end against a source carrying user.oc.* attributes and a hardlink: with the old argv, getfattr -d -R over the generation returns nothing and the hardlink splits into two inodes; with -aHAX both sides come back identical to the source. That is the shape in which an OpenCloud volume survived a disaster-recovery drill with its files intact and its storage metadata gone, so its posix driver skipped every space it could no longer read. restore/files.py had the same gap on this repository's own restore path. Two consequences worth knowing. The first backup after this re-copies every attribute-carrying volume once, because rsync declines to hardlink against a generation whose xattrs do not match. And a backup destination on a filesystem without xattr support now fails the run instead of producing a generation that cannot be restored. Co-Authored-By: Claude Opus 5 (1M context) --- src/baudolo/backup/volume.py | 2 +- src/baudolo/restore/files.py | 2 +- tests/unit/backup/test_volume.py | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/baudolo/backup/volume.py b/src/baudolo/backup/volume.py index 5500e94..632a3af 100644 --- a/src/baudolo/backup/volume.py +++ b/src/baudolo/backup/volume.py @@ -75,7 +75,7 @@ def backup_volume( pathlib.Path(dest).mkdir(parents=True, exist_ok=True) last = get_last_backup_dir(versions_dir, volume_name, dest) - cmd = ["rsync", "-aP", "--no-D", "--delete", "--delete-excluded"] + cmd = ["rsync", "-aHAX", "-P", "--no-D", "--delete", "--delete-excluded"] if authoritative: cmd.append("--checksum") if last: diff --git a/src/baudolo/restore/files.py b/src/baudolo/restore/files.py index ff05112..3adc13a 100644 --- a/src/baudolo/restore/files.py +++ b/src/baudolo/restore/files.py @@ -59,6 +59,6 @@ def restore_volume_files(volume_name: str, backup_files_dir: str) -> int: # rsync reads "dir/" as its contents and "dir" as the directory itself. src = f"{Path(backup_files_dir)}{os.sep}" dest = f"{Path(mountpoint)}{os.sep}" - run(["rsync", "-avv", "--delete", src, dest]) + run(["rsync", "-aHAX", "-vv", "--delete", src, dest]) print("File restore complete.") return 0 diff --git a/tests/unit/backup/test_volume.py b/tests/unit/backup/test_volume.py index 4efe29b..1c69790 100644 --- a/tests/unit/backup/test_volume.py +++ b/tests/unit/backup/test_volume.py @@ -46,9 +46,12 @@ class TestBackupVolume(unittest.TestCase): def test_it_carries_no_kernel_objects_into_a_generation(self) -> None: self.assertIn("--no-D", self.copy()) + def test_it_stores_the_metadata_a_restore_has_to_give_back(self) -> None: + self.assertIn("-aHAX", self.copy()) + def test_it_keeps_no_twin_of_what_the_second_pass_replaces(self) -> None: command = self.copy(authoritative=True) - self.assertEqual(command[:2], ["rsync", "-aP"]) + self.assertEqual(command[:3], ["rsync", "-aHAX", "-P"]) self.assertNotIn("--backup", command) def test_it_creates_the_destination(self) -> None: