Files
kevinveenbirkenbachandClaude Opus 5.5 b894db0a75 fix(svc-bkp-volume-2-local): restore into the resolved volume mountpoint
rsync 3.5.0 (Debian security update 3.5.0+ds1-0+deb13u1) refuses a
destination symlink owned by neither root nor the calling user.
svc-opt-ssd-hdd turns a volume's `_data` into a symlink to /mnt/hdd or
/mnt/ssd. The restore drill empties the volume and runs `compose create`;
docker repopulates it and lchowns the `_data` symlink to the image
directory's owner (e.g. www-data). The restore then failed with
"refusing to follow a symlink owned by an untrusted user" and
"change_dir#1 .../_data/ failed: Too many levels of symbolic links",
the drill left its subjects down, and the dependent checks timed out
(CI run 40979252521, ssd-hdd#0, on feature/store-i18n; recover.py is
identical on main).

VolumeRecovery now resolves the mountpoint before handing it to rsync:
os.path.realpath locally, `ssh <host> realpath -e -- <mountpoint>` for
--docker-host, so rsync writes into the real directory and never meets
the symlink. `--insecure-links` was not used because older rsync builds
on other distributions lack it. Two new unit tests (local symlinked
mountpoint, remote resolution); both fail against the previous code.

Root cause and fix by the i18n session (feature/store-i18n triage of
ssd-hdd#0), ported to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:51:44 +01:00

163 lines
6.0 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 a backup-docker-to-local generation: a volume's files, or the
generation's database dumps.
Both modes run the role's deployed backup unit first, unless
``--no-safety-backup`` says the target holds nothing worth saving.
Volume mode mirrors the snapshot into the volume's mountpoint
(``rsync -aHAX --delete``); stop the consuming project first. With
``--docker-host ssh://user@host`` it works on a remote host.
Database mode (``--databases``) replays every dump with
``baudolo-restore --empty`` - a single database through its engine's
subcommand, a ``pg_dumpall`` dump through ``cluster`` - and refuses while a
consumer runs. Local-only: the credentials live in the target's databases.csv.
Usage:
recover.py SOURCE_DIR VOLUME [--no-safety-backup] [--docker-host ENDPOINT]
recover.py --databases GENERATION_DIR [--no-safety-backup]
"""
from __future__ import annotations
import argparse
import os
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.paths import FILE_DATABASE_SECRETS # noqa: E402
from utils.recovery import databases # noqa: E402
from utils.recovery.base import DirectoryRecovery # noqa: E402
UNIT_PATTERN = "svc-bkp-volume-2-local*.service"
class VolumeRecovery(DirectoryRecovery):
unit_pattern = UNIT_PATTERN
def __init__(
self,
source_dir: str,
volume: str,
*,
service_backup: bool = True,
docker_host: str | None = None,
) -> None:
host = docker_host.split("://", 1)[-1] if docker_host else None
docker = ["docker", *(["-H", docker_host] if docker_host else [])]
mountpoint = subprocess.run(
[*docker, "volume", "inspect", "--format", "{{.Mountpoint}}", volume],
check=True,
capture_output=True,
text=True,
).stdout.strip()
options = subprocess.run(
[*docker, "volume", "inspect", "--format", "{{json .Options}}", volume],
check=True,
capture_output=True,
text=True,
).stdout.strip()
if options not in ("", "null", "{}"):
probe = (
["ssh", host, "mountpoint", "-q", mountpoint]
if host
else ["mountpoint", "-q", mountpoint]
)
if subprocess.run(probe, check=False).returncode != 0:
raise SystemExit(
f"volume {volume} declares its own backing store ({options}) but "
f"{mountpoint} is not mounted; docker mounts such volumes only while "
"a container holds them, so a restore now would land on the node "
"disk and be shadowed by the real backing store on the next mount. "
"Start a container that holds the volume, then retry."
)
# Exception: rsync >= 3.5.0 refuses a destination symlink not owned by root, and svc-opt-ssd-hdd's _data is one.
resolved = (
subprocess.run(
["ssh", host, "realpath", "-e", "--", mountpoint],
check=True,
capture_output=True,
text=True,
).stdout.strip()
if host
else os.path.realpath(mountpoint)
)
target = f"{host}:{resolved}" if host else resolved
super().__init__(source_dir, target, service_backup=service_backup)
class DatabaseRecovery(DirectoryRecovery):
"""Replay a generation's sql dumps, with the same safety-backup contract."""
unit_pattern = UNIT_PATTERN
def __init__(self, generation_dir: str, *, service_backup: bool = True) -> None:
self.generation_dir = Path(generation_dir)
super().__init__(generation_dir, generation_dir, service_backup=service_backup)
def restore(self) -> None:
databases.replay(self.generation_dir, FILE_DATABASE_SECRETS)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"source_dir",
nargs="?",
help="snapshot to restore from (e.g. <backups>/<machine-hash>/backup-docker-to-local/<generation>/<volume>/files)",
)
parser.add_argument("volume", nargs="?", help="docker volume name to restore into")
parser.add_argument(
"--databases",
metavar="GENERATION_DIR",
help="replay the generation's sql dumps instead of restoring one volume's files",
)
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)",
)
parser.add_argument(
"--docker-host",
help="remote docker endpoint (e.g. ssh://user@host) to recover the volume on another host",
)
args = parser.parse_args()
service_backup = not args.no_safety_backup
if args.databases:
if args.source_dir or args.volume:
parser.error(
"--databases takes the generation directory and no positionals"
)
if args.docker_host:
parser.error(
"--databases is local-only: the credentials live in the target host's "
"own databases.csv, so run it on that host"
)
try:
return DatabaseRecovery(args.databases, service_backup=service_backup).run()
except databases.RecoveryError as error:
print(f"FAIL: {error}", file=sys.stderr)
return 1
if not args.source_dir or not args.volume:
parser.error("volume mode needs SOURCE_DIR and VOLUME")
return VolumeRecovery(
args.source_dir,
args.volume,
service_backup=service_backup,
docker_host=args.docker_host,
).run()
if __name__ == "__main__":
raise SystemExit(main())