mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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>
106 lines
3.8 KiB
Python
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
|