Files
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

103 lines
3.7 KiB
Python
Executable File

#!/usr/bin/env python3
# nocheck: mirrored-unit-test - subclasses utils.recovery.base and is exercised
# collectively by tests/unit/python/utils/recovery/test_role_recover_scripts.py; a
# per-role copy would assert the base class twice over
"""Restore the local backup root from the encrypted backup device.
Opens the LUKS device (interactive passphrase prompt, or stdin with
``--passphrase-stdin``), mounts it, picks the newest device snapshot
(any machine hash: a fresh host after total loss has a new
/etc/machine-id) and mirrors it into the target
(``rsync -aHAX --delete``), then unmounts and closes the device again.
No pre-recover service backup runs: the device itself is the backup
this role maintains.
Usage:
recover.py DEVICE MOUNT_DIR TARGET_DIR [--device-target SUBPATH]
[--snapshot TIMESTAMP] [--passphrase-stdin]
``--device-target`` is the role's ``services.local-2-device.target``
subpath the backup unit synced into (snapshots live under
``<mount>/<target>/...``, not directly under the mountpoint).
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[4] # nocheck: project-root-import
sys.path.insert(0, str(_REPO_ROOT))
from utils.recovery.base import DirectoryRecovery # noqa: E402
_MAPPER = "svc-bkp-local-2-device-recover"
class DeviceRecovery(DirectoryRecovery):
unit_pattern = "svc-bkp-local-2-device*.service"
def __init__(self, source_dir: str, target_dir: str) -> None:
super().__init__(source_dir, target_dir, service_backup=False)
def _newest_snapshot(mount_dir: Path, snapshot: str | None) -> Path:
candidates = sorted(
mount_dir.glob("*/svc-bkp-local-2-device/*"),
key=lambda p: p.name,
)
if snapshot:
candidates = [p for p in candidates if p.name == snapshot]
if not candidates:
raise SystemExit(
f"ERROR: no device snapshot under {mount_dir}/*/svc-bkp-local-2-device"
+ (f" matching {snapshot}" if snapshot else "")
)
return candidates[-1]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("device", help="LUKS device or image file to open")
parser.add_argument("mount_dir", help="directory to mount the opened device on")
parser.add_argument("target_dir", help="local backup root to restore into")
parser.add_argument(
"--device-target",
default="",
help="services.local-2-device.target subpath holding the snapshots on the device",
)
parser.add_argument(
"--snapshot",
help="snapshot timestamp to restore (default: newest on the device)",
)
parser.add_argument(
"--passphrase-stdin",
action="store_true",
help="read the LUKS passphrase from stdin instead of the terminal",
)
args = parser.parse_args()
open_cmd = ["cryptsetup", "luksOpen", args.device, _MAPPER]
if args.passphrase_stdin:
open_cmd.insert(2, "--key-file=-")
subprocess.run(open_cmd, check=True)
mount_dir = Path(args.mount_dir)
try:
mount_dir.mkdir(parents=True, exist_ok=True)
subprocess.run(["mount", f"/dev/mapper/{_MAPPER}", str(mount_dir)], check=True)
try:
snapshot_root = mount_dir / args.device_target.lstrip("/")
snapshot = _newest_snapshot(snapshot_root, args.snapshot)
print(f"OK: restoring device snapshot {snapshot}")
return DeviceRecovery(str(snapshot / "backup"), args.target_dir).run()
finally:
subprocess.run(["umount", str(mount_dir)], check=False)
finally:
subprocess.run(["cryptsetup", "luksClose", _MAPPER], check=False)
if __name__ == "__main__":
raise SystemExit(main())