Files
tech-service-portal/app/backup.py
T

247 lines
12 KiB
Python

"""Резервное копирование: продуктовая база + конфигурация (+ по желанию фото и картинки) в один zip.
База выгружается в переносимом виде (JSON по таблицам), поэтому копию с PostgreSQL можно восстановить в SQLite и наоборот.
Конфигурация: настройки сервера (таблица settings — она входит в базу) и несекретные переменные окружения; секреты
(SECRET_KEY, пароли, строка подключения к БД) не сохраняются — только их названия, чтобы при переезде знать, что задать.
Копии лежат в DATA_DIR/backups (в Docker — том appdata)."""
import io
import json
import logging
import os
import re
import zipfile
from datetime import date, datetime, time, timedelta
from pathlib import Path
from sqlalchemy import Boolean, Date, DateTime, Float, Integer, JSON, Time, text
from sqlalchemy.orm import Session
from . import config, mode, settings_store as ss
from .db import Base
log = logging.getLogger("backup")
FORMAT = 1
NAME_RE = re.compile(r"backup_\d{8}_\d{6}(_[a-z0-9-]{1,20})?\.zip")
SKIP_TABLES = {"refresh_tokens"} # сессии не переносим: после восстановления все входят заново
ENV_PUBLIC = ("APP_ENV", "APP_TZ", "APP_PORT", "FORWARDED_ALLOW_IPS", "DATA_DIR", "PHOTO_DIR", "ACCESS_TTL_MIN",
"REFRESH_TTL_DAYS", "LOGIN_MAX_FAILS", "LOGIN_WINDOW_SEC", "SCHEDULER_INTERVAL", "ADMIN_USERNAME")
ENV_SECRET = ("SECRET_KEY", "DATABASE_URL", "DB_PASSWORD", "ADMIN_PASSWORD")
SECRET_SETTINGS = {"assistant_api_key"}
DEFAULTS = {"backup_auto_hours": "24", "backup_keep": "14", "backup_media": "0"}
class BackupError(Exception):
pass
def folder() -> Path:
p = Path(config.DATA_DIR) / "backups"
p.mkdir(parents=True, exist_ok=True)
return p
def setting(db: Session, key: str) -> str:
v = ss.get(db, key)
return v if v != "" else DEFAULTS[key]
def _enc(v):
if isinstance(v, (datetime, date, time)):
return v.isoformat()
return v
def _dec(col, v):
if v is None:
return None
t = col.type
if isinstance(t, DateTime):
return datetime.fromisoformat(v)
if isinstance(t, Date):
return date.fromisoformat(v)
if isinstance(t, Time):
return time.fromisoformat(v)
if isinstance(t, Boolean):
return bool(v)
if isinstance(t, Integer) and not isinstance(v, bool):
return int(v)
if isinstance(t, Float):
return float(v)
return v
def tables():
return [t for t in Base.metadata.sorted_tables if t.name not in SKIP_TABLES]
def list_backups() -> list[dict]:
out = []
for f in sorted(folder().glob("backup_*.zip"), reverse=True):
if not NAME_RE.fullmatch(f.name):
continue
info = {"name": f.name, "size": f.stat().st_size, "created_at": config.iso(datetime.fromtimestamp(f.stat().st_mtime, config.TZ).replace(tzinfo=None))}
try:
with zipfile.ZipFile(f) as z:
m = json.loads(z.read("manifest.json"))
info.update(note=m.get("note", ""), media=m.get("include_media", False), rows=sum(m.get("tables", {}).values()),
dialect=m.get("db_dialect", ""))
except Exception:
info.update(note="повреждённый файл", media=False, rows=0)
out.append(info)
return out
def path_of(name: str) -> Path:
if not NAME_RE.fullmatch(name or ""):
raise BackupError("Недопустимое имя файла копии")
f = folder() / name
if not f.is_file():
raise BackupError("Копия не найдена")
return f
def create(db: Session, *, include_media: bool = False, note: str = "", tag: str = "") -> Path:
"""Создать копию. В демо-режиме запрещено: копировалась бы демо-база, а не продуктовая."""
if mode.is_demo_request():
raise BackupError("Сейчас включён демонстрационный режим — копия продуктовой базы недоступна")
stamp = config.now().strftime("%Y%m%d_%H%M%S")
name = f"backup_{stamp}" + (f"_{tag}" if tag else "") + ".zip"
dst = folder() / name
tmp = dst.with_suffix(".part")
counts = {}
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
for t in tables():
rows = [{c.name: _enc(r._mapping[c.name]) for c in t.columns} for r in db.execute(t.select().order_by(*t.primary_key.columns))]
if t.name == "settings": # секрет провайдера AI-помощника в копию не кладём
rows = [r for r in rows if r["key"] not in SECRET_SETTINGS]
counts[t.name] = len(rows)
z.writestr(f"db/{t.name}.json", json.dumps(rows, ensure_ascii=False))
env = {k: os.environ[k] for k in ENV_PUBLIC if k in os.environ}
z.writestr("config/environment.json", json.dumps({
"variables": env, "secrets_not_saved": [k for k in ENV_SECRET if k in os.environ],
"server_settings": {k: ss.get(db, k) for k in ss.EDITABLE},
"note": "Секреты (SECRET_KEY, пароли, DATABASE_URL) в копию не входят — задайте их заново при переезде.",
}, ensure_ascii=False, indent=1))
if include_media:
root = ss.photo_root(db)
for f in root.rglob("*"):
if f.is_file():
z.write(f, "media/" + f.relative_to(root).as_posix())
z.writestr("manifest.json", json.dumps({
"app": "checklists-portal", "format": FORMAT, "created_at": config.iso(config.now()),
"db_dialect": db.get_bind().dialect.name, "tables": counts, "include_media": include_media, "note": note,
}, ensure_ascii=False, indent=1))
tmp.replace(dst)
log.info("Создана резервная копия %s", dst.name)
return dst
def prune(keep: int):
files = [f for f in sorted(folder().glob("backup_*.zip"), reverse=True) if NAME_RE.fullmatch(f.name)]
auto = [f for f in files if "_auto" in f.name]
for f in auto[keep:]: # чистим только автоматические — ручные копии удаляет администратор
f.unlink(missing_ok=True)
def auto_backup(db: Session) -> Path | None:
"""Вызывается планировщиком: делает копию, если с последней автокопии прошло больше заданного интервала."""
if mode.is_demo_request():
return None
hours = int(setting(db, "backup_auto_hours") or 0)
if hours <= 0:
return None
last = [f for f in folder().glob("backup_*_auto.zip")]
if last and max(f.stat().st_mtime for f in last) > (datetime.now().timestamp() - hours * 3600):
return None
p = create(db, include_media=setting(db, "backup_media") == "1", note="автоматическая копия", tag="auto")
prune(max(1, int(setting(db, "backup_keep") or 14)))
return p
def save_upload(data: bytes) -> Path:
try:
with zipfile.ZipFile(io.BytesIO(data)) as z:
m = json.loads(z.read("manifest.json"))
except Exception:
raise BackupError("Это не файл резервной копии портала")
if m.get("app") != "checklists-portal":
raise BackupError("Это не файл резервной копии портала")
name = f"backup_{config.now():%Y%m%d_%H%M%S}_upload.zip"
(folder() / name).write_bytes(data)
return folder() / name
def restore(db: Session, name: str) -> dict:
"""Полностью заменить данные продуктовой базы содержимым копии. Перед этим автоматически делается копия текущего состояния."""
if mode.is_demo_request():
raise BackupError("Выключите демонстрационный режим перед восстановлением")
src = path_of(name)
with zipfile.ZipFile(src) as z:
m = json.loads(z.read("manifest.json"))
if m.get("app") != "checklists-portal" or m.get("format", 0) > FORMAT:
raise BackupError("Копия создана несовместимой версией портала")
data = {t.name: json.loads(z.read(f"db/{t.name}.json")) if f"db/{t.name}.json" in z.namelist() else [] for t in tables()}
safety = create(db, note=f"перед восстановлением из {name}", tag="before-restore")
bind = db.get_bind()
pg = bind.dialect.name == "postgresql"
try:
for t in Base.metadata.sorted_tables: # снять необязательные ссылки — иначе циклы (отдел ↔ руководитель) мешают удалению
nullable = {c.name: None for c in t.columns if c.foreign_keys and c.nullable}
if nullable:
db.execute(t.update().values(nullable))
for t in reversed(Base.metadata.sorted_tables):
db.execute(t.delete())
inserted, later = set(), []
for t in tables():
rows = data.get(t.name, [])
cols = {c.name: c for c in t.columns}
# ссылки на ещё не загруженные таблицы и на саму себя ставим потом (отделы, вложенные места и т. п.)
deferred = [c.name for c in t.columns if c.foreign_keys and c.nullable
and any(fk.column.table.name not in inserted or fk.column.table.name == t.name for fk in c.foreign_keys)]
batch = []
for r in rows:
row = {k: _dec(cols[k], v) for k, v in r.items() if k in cols}
for k in deferred:
if row.get(k) is not None:
later.append((t, row["id"], k, row[k]))
row[k] = None
batch.append(row)
if batch:
db.execute(t.insert(), batch)
inserted.add(t.name)
for t, rid, col, val in later:
db.execute(t.update().where(t.c.id == rid).values({col: val}))
if pg:
for t in tables():
if "id" in t.c and isinstance(t.c.id.type, Integer):
db.execute(text(f"SELECT setval(pg_get_serial_sequence('{t.name}', 'id'), "
f"COALESCE((SELECT MAX(id) FROM {t.name}), 1), (SELECT COUNT(*) > 0 FROM {t.name}))"))
db.commit()
except Exception as e:
db.rollback()
log.exception("Ошибка восстановления")
raise BackupError(f"Восстановление не выполнено, данные не изменены: {e}")
files = 0
if m.get("include_media"):
root = ss.photo_root(db).resolve()
for info in z.infolist():
if not info.filename.startswith("media/") or info.is_dir():
continue
dst = (root / info.filename[6:]).resolve()
if root not in dst.parents: # защита от путей вида ../
continue
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(z.read(info))
files += 1
return {"restored": name, "rows": sum(len(v) for v in data.values()), "media_files": files, "safety_copy": safety.name}
def next_auto(db: Session) -> str | None:
hours = int(setting(db, "backup_auto_hours") or 0)
if hours <= 0:
return None
last = [f.stat().st_mtime for f in folder().glob("backup_*_auto.zip")]
if not last:
return config.iso(config.now())
return config.iso(datetime.fromtimestamp(max(last), config.TZ).replace(tzinfo=None) + timedelta(hours=hours))