"""Бэкап самого контроллера: дамп БД и/или конфигурации.""" from __future__ import annotations import io import json import os import subprocess import tarfile from datetime import datetime, timezone from loguru import logger from ..core.config import get_settings def _safe_settings_dump() -> dict: s = get_settings() data = s.model_dump() # маскируем секреты for k in list(data.keys()): if any(x in k.lower() for x in ("password", "secret", "key")): data[k] = "***" return data def make_config_only_archive() -> tuple[str, bytes]: """Tar.gz с настройками контроллера (без БД).""" buf = io.BytesIO() ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") name = f"controller-config-{ts}.tar.gz" settings_json = json.dumps(_safe_settings_dump(), indent=2, default=str).encode() with tarfile.open(fileobj=buf, mode="w:gz") as tar: info = tarfile.TarInfo(name="settings.json") info.size = len(settings_json) info.mtime = int(datetime.now().timestamp()) tar.addfile(info, io.BytesIO(settings_json)) readme = ( b"ROSzetta - config-only backup\n" b"Contains masked settings.json (no DB, no secrets).\n" ) info2 = tarfile.TarInfo(name="README.txt") info2.size = len(readme) info2.mtime = int(datetime.now().timestamp()) tar.addfile(info2, io.BytesIO(readme)) return name, buf.getvalue() def _dump_database() -> bytes: """Возвращает pg_dump БД (custom-format) либо raise.""" s = get_settings() # parse postgresql+psycopg2://user:pass@host:port/db url = s.database_url.replace("postgresql+psycopg2://", "postgresql://") cmd = ["pg_dump", "-Fc", url] logger.info("running pg_dump") try: out = subprocess.run( cmd, check=True, capture_output=True, timeout=300, env={**os.environ}, ) except FileNotFoundError as exc: raise RuntimeError("pg_dump not installed in backend image") from exc except subprocess.CalledProcessError as exc: raise RuntimeError(f"pg_dump failed: {exc.stderr.decode(errors='replace')[:400]}") from exc return out.stdout def make_full_archive() -> tuple[str, bytes]: """Tar.gz с дампом БД + settings.json.""" buf = io.BytesIO() ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") name = f"controller-full-{ts}.tar.gz" db_dump = _dump_database() settings_json = json.dumps(_safe_settings_dump(), indent=2, default=str).encode() with tarfile.open(fileobj=buf, mode="w:gz") as tar: for fname, data in [ ("db.dump", db_dump), ("settings.json", settings_json), ( "README.txt", b"ROSzetta - full backup\n" b"Restore: pg_restore -d db.dump\n", ), ]: info = tarfile.TarInfo(name=fname) info.size = len(data) info.mtime = int(datetime.now().timestamp()) tar.addfile(info, io.BytesIO(data)) return name, buf.getvalue() def restore_full_archive(data: bytes) -> dict: """Разворачивает full-бэкап: дроп схемы public + pg_restore из db.dump в архиве. ВНИМАНИЕ: операция деструктивна. Текущая БД будет полностью заменена. """ s = get_settings() try: with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: try: member = tar.getmember("db.dump") except KeyError as exc: raise RuntimeError("Архив не содержит db.dump (нужен full backup)") from exc f = tar.extractfile(member) if f is None: raise RuntimeError("Не удалось прочитать db.dump из архива") dump_bytes = f.read() except tarfile.TarError as exc: raise RuntimeError(f"Невалидный tar.gz: {exc}") from exc url = s.database_url.replace("postgresql+psycopg2://", "postgresql://") logger.warning("controller restore: dropping schema public") try: subprocess.run( ["psql", url, "-v", "ON_ERROR_STOP=1", "-c", "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;"], check=True, capture_output=True, timeout=60, env={**os.environ}, ) except FileNotFoundError as exc: raise RuntimeError("psql not installed in backend image") from exc except subprocess.CalledProcessError as exc: raise RuntimeError(f"psql DROP SCHEMA failed: {exc.stderr.decode(errors='replace')[:400]}") from exc logger.warning("controller restore: running pg_restore ({} bytes)", len(dump_bytes)) try: proc = subprocess.run( ["pg_restore", "--no-owner", "--no-privileges", "-d", url], input=dump_bytes, check=True, capture_output=True, timeout=600, env={**os.environ}, ) except FileNotFoundError as exc: raise RuntimeError("pg_restore not installed in backend image") from exc except subprocess.CalledProcessError as exc: raise RuntimeError(f"pg_restore failed: {exc.stderr.decode(errors='replace')[:400]}") from exc return { "ok": True, "message": "Бэкап успешно развёрнут. Перезайдите в систему — данные обновлены.", "stderr": proc.stderr.decode(errors='replace')[:400] if proc.stderr else "", }