Files
computer-playbook/roles/svc-bkp-nfs-2-local/files/python/recover.py
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

55 lines
1.8 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 an NFS export subtree from a backup snapshot.
Runs the role's deployed backup unit first (the usual differential
``backup-nfs-to-local`` generation of the live export), then mirrors the
snapshot into the target (``rsync -aHAX --delete``). Run on the host
serving the export with every consuming stack stopped; NFS clients
re-mount on redeploy.
Usage:
recover.py SOURCE_DIR TARGET_DIR [--no-safety-backup]
"""
from __future__ import annotations
import argparse
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
from utils.storage.nfs import STATE_SUBDIR # noqa: E402
class NfsExportRecovery(DirectoryRecovery):
unit_pattern = "svc-bkp-nfs-2-local*.service"
rsync_extra_args = (
f"--filter=protect /{STATE_SUBDIR}/backup",
f"--exclude=/{STATE_SUBDIR}/backup",
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source_dir", help="snapshot directory to restore from")
parser.add_argument("target_dir", help="live export subtree to restore into")
parser.add_argument(
"--no-safety-backup",
action="store_true",
help="skip the pre-recover safety backup of the current target (only when it holds nothing worth saving)",
)
args = parser.parse_args()
return NfsExportRecovery(
args.source_dir, args.target_dir, service_backup=not args.no_safety_backup
).run()
if __name__ == "__main__":
raise SystemExit(main())