Files
computer-playbook/utils/recovery/base.py
T
kevinveenbirkenbachandClaude Opus 5 b033aab42b fix(backup): carry extended attributes through the restore
A recover mirrored a snapshot with rsync -a, which transfers no extended attributes, so a restored volume came back byte-identical while every application that keeps identity in xattrs read the tree as unknown. OpenCloud posixfs is the case that surfaced it: after the DR drill its storage log reports node.Xattrs on the platform administrator space as no data available, the showcase files are assimilated as new objects, and the graph call that lists drives returns no personal drive, so the design spec dereferences undefined. Measured here, source tagged with user.oc.name, on tmpfs and on ext4 alike: rsync -a returns 0 and drops the attribute, rsync -aHX and rsync -aHAX return 0 and carry it. The shared restore flow and the NFS half of this repo now use -aHAX, the form ensure_subject_subvolume.sh already uses, continuing what 69321d4135 started for numeric ownership.

The backup half still has the same gap: backup-docker-to-local 7.0.2 builds its command as rsync -aP --no-D --delete --delete-excluded with no flag passthrough, so until it carries -X and the pin moves, a snapshot holds no attributes for this restore to put back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-17 14:16:29 +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