Files
computer-playbook/utils/recovery/base.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

106 lines
3.8 KiB
Python

"""Abstract recover flow shared by every ``svc-bkp-*`` role.
A recover mirrors a snapshot into a live target directory
(``rsync -aHAX --delete``), so the target's current state would be lost
without a net. The flow therefore first runs the role's deployed backup
service (``systemctl start`` on the role's unit), which applies the
usual differential backup logic to the live data before anything is
overwritten. Roles subclass :class:`DirectoryRecovery`, set
``unit_pattern`` and ship a thin ``files/python/recover.py`` CLI around it
(enforced by ``tests/lint/ansible/roles/test_bkp_roles_have_recover.py``).
Host-agnostic: source and target may be a local path or a remote
``[user@]host:/path`` (rsync pushes/pulls over ssh). A remote target
skips the local pre-recover safety backup, since that host's live data
is the remote's concern.
"""
from __future__ import annotations
import subprocess
from abc import ABC
from pathlib import Path
def is_remote(location: str) -> bool:
"""A location not starting with ``/`` is a remote ``[user@]host:/path``."""
return not location.startswith("/")
class DirectoryRecovery(ABC):
"""Service-backed recovery for a directory target.
Args:
source_dir: snapshot directory to restore from.
target_dir: live directory to restore into.
service_backup: run the role's backup unit first (default);
disable only when the target holds nothing worth saving.
"""
unit_pattern: str
rsync_extra_args: tuple[str, ...] = ()
def __init__(
self, source_dir: str, target_dir: str, *, service_backup: bool = True
) -> None:
if not getattr(self, "unit_pattern", ""):
raise ValueError(f"{type(self).__name__} must set unit_pattern")
self.source_dir = Path(source_dir)
self.target_dir = Path(target_dir)
self.service_backup = service_backup
if not is_remote(str(self.source_dir)) and not self.source_dir.is_dir():
raise SystemExit(f"ERROR: snapshot {self.source_dir} does not exist")
if not is_remote(str(self.target_dir)) and not self.target_dir.is_dir():
raise SystemExit(
f"ERROR: target {self.target_dir} does not exist; refusing to "
"create it implicitly"
)
def _backup_units(self) -> list[str]:
listing = subprocess.run(
[
"systemctl",
"list-unit-files",
self.unit_pattern,
"--no-legend",
],
check=True,
capture_output=True,
text=True,
).stdout
return [line.split()[0] for line in listing.splitlines() if line.split()]
def backup_target(self) -> None:
"""Run the role's deployed backup unit against the live data."""
units = self._backup_units()
if not units:
raise SystemExit(
f"ERROR: no unit matches {self.unit_pattern}; deploy the role "
"first or pass --no-safety-backup when the target holds "
"nothing worth saving"
)
for unit in units:
print(f"OK: running backup unit {unit} before the recover")
subprocess.run(["systemctl", "start", unit], check=True)
def restore(self) -> None:
subprocess.run(
[
"rsync",
"-aHAX",
"--numeric-ids",
"--delete",
*self.rsync_extra_args,
f"{self.source_dir}/",
f"{self.target_dir}/",
],
check=True,
)
print(f"OK: {self.target_dir} restored from {self.source_dir}")
def run(self) -> int:
if self.service_backup and not is_remote(str(self.target_dir)):
self.backup_target()
self.restore()
return 0