Files
computer-playbook/roles/svc-bkp-local-2-device/files/python/script.py
T
kevinveenbirkenbachandClaude Opus 5.5 21eba1e88f fix(backup): carry hard links, ACLs and xattrs on every backup and recover hop
baudolo 7.1.x writes volume generations with `rsync -aHAX`, so the
producing host keeps `user.*` extended attributes (OpenCloud keeps its
storage metadata in `user.oc.*`). Every later copy dropped them again,
because none of the other rsync calls passed `-X` (nor `-A`/`-H`):

- NFS export snapshots (svc-bkp-nfs-2-local `script.sh`),
- the backup host's pull (svc-bkp-remote-2-local `pull_specific_host.py`),
- the device sync (svc-bkp-local-2-device `script.py`),
- every recover into a live target (`utils/recovery/base.py`, used by the
  volume, NFS and device recover scripts),
- the `recover full` and `recover remote` tree pulls.

A disaster recovery through the documented chain therefore restored
OpenCloud without `user.oc.*`, silently: the copies succeeded and the
dirval stamp does not cover attributes.

All of these now run `rsync -aHAX` (`-aHAXP` for the device sync). A
target filesystem without xattr support makes rsync exit non-zero as soon
as a source file carries attributes, so the hop fails loudly instead of
dropping them; there is no per-device opt-out. The single-image pull in
`recover remote` keeps `-a`. The provider's ssh-wrapper needs no change:
its glob accepts any server flags and its character guard allows them.

Two tests write a `user.oc.id` attribute and assert it survives the NFS
snapshot and the recover; with the old `-a` both fail with
`OSError: [Errno 61] No data available`.

Closes #1021

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 18:20:46 +02:00

86 lines
2.7 KiB
Python
Executable File

#!/usr/bin/env python3
# nocheck: mirrored-unit-test - main() is one straight line of rsync and mount
# subprocess calls against an attached backup device; every branch it has is a return
# code from the host, so the assertions would only restate the mocks
import datetime as dt
import glob
import os
import subprocess
import sys
from pathlib import Path
def main():
source_path = sys.argv[1]
print(f"source path: {source_path}")
backup_to_usb_destination_path = sys.argv[2]
print(f"backup to usb destination path: {backup_to_usb_destination_path}")
if not Path(backup_to_usb_destination_path).is_dir():
print(f"Directory {backup_to_usb_destination_path} does not exist")
sys.exit(1)
machine_id = subprocess.run(
["sha256sum", "/etc/machine-id"], capture_output=True, text=True, check=True
).stdout.strip()[:64]
print(f"machine id: {machine_id}")
versions_path = str(
Path(backup_to_usb_destination_path) / f"{machine_id}/svc-bkp-local-2-device/"
)
print(f"versions path: {versions_path}")
if not Path(versions_path).is_dir():
print(f"Creating {versions_path}...")
Path(versions_path).mkdir(parents=True, exist_ok=True)
previous_version_path = max(
glob.glob(f"{versions_path}*"), key=os.path.getmtime, default=None
)
print(f"previous versions path: {previous_version_path}")
current_version_path = str(
Path(versions_path) / dt.datetime.now(tz=dt.UTC).strftime("%Y%m%d%H%M%S")
)
print(f"current versions path: {current_version_path}")
print("Creating backup destination folder...")
Path(current_version_path).mkdir(parents=True, exist_ok=True)
print("Starting synchronization...")
try:
rsync_command = [
"rsync",
"-aHAXP",
"--numeric-ids",
"--delete",
"--delete-excluded",
]
if previous_version_path is not None:
rsync_command.append("--link-dest=" + previous_version_path)
rsync_command.extend([source_path, current_version_path])
rsync_output = subprocess.check_output(
rsync_command, stderr=subprocess.STDOUT, text=True
)
print(rsync_output)
print("Synchronization finished")
sys.exit(0)
except subprocess.CalledProcessError as e:
print(e.output)
if (
"rsync warning: some files vanished before they could be transferred"
in e.output
):
print("Synchronization finished with rsync warning")
sys.exit(0)
else:
print("Synchronization failed")
sys.exit(1)
if __name__ == "__main__":
main()