Compare commits

...
2 Commits
Author SHA1 Message Date
kevinveenbirkenbach f824fabea1 Release version 7.1.0 2026-10-17 22:35:15 +02:00
kevinveenbirkenbachandClaude Opus 5 07d819cb41 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) <noreply@anthropic.com>
2026-10-17 22:28:49 +02:00
5 changed files with 21 additions and 4 deletions

No files matched your search

+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [7.1.0] - 2026-10-17
Fixed:
- Backup: rsync -a stored no xattrs, ACLs or hardlinks; now -aHAX
- Restore: restore_volume_files had the same gap; now -aHAX too
- An OpenCloud volume lost its storage metadata through a DR drill
Upgrade notes:
- First backup re-copies every attribute-carrying volume once in full
- A destination without xattr support now fails the backup run outright
- exFAT, xattr-less NFS and object-storage FUSE mounts must move first
## [7.0.2] - 2026-09-23
* Backup: failed live pre-copy no longer aborts when a stopped copy follows
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "backup-docker-to-local"
version = "7.0.2"
version = "7.1.0"
description = "Backup Docker volumes to local with rsync and optional DB dumps."
readme = "README.md"
requires-python = ">=3.9"
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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: