Глобально уникальные ID (docs/changes/016): - у устройств, групп, задач, резервных копий и записей журнала ID вида <префикс>_<uuid7> (dev_, grp_, job_, bkp_, evt_): типы не пересекаются, внутри типа ID не повторяются и сортируются по времени; - миграция БД v1 заменяет числовые ID с пересчётом ссылок (копия файла БД перед миграцией, сверка числа строк, одна транзакция); - резервная копия = пара файлов с одним bkp_ ID, файлы в S3 получают метаданные backup-id/device-id; синхронизация метаданных с бакетом; - журнал событий в БД (events): создание/изменение/удаление, задачи, бэкапы, смена online/offline, вход в UI; API чтения GET /api/v1/events. Журнал в UI, ротация и очистка (docs/changes/017): - страница «Журнал»: фильтры, подгрузка «Показать ещё», окно записи; - настройки ротации (срок и максимум записей) хранятся в БД (схема v2), ротация при старте, раз в час и после сохранения настроек; - очистка журнала только через окно с паролем пользователя, блокировка после 5 неверных попыток, остаётся запись о факте очистки; через API очистки нет. Тесты: 20 из 20. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
152 lines
8.0 KiB
Python
152 lines
8.0 KiB
Python
"""Список бэкапов из бакета с фильтрами и связью с метаданными (backups.id = bkp_…)."""
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
from datetime import date
|
|
|
|
from app import ids, s3
|
|
from app.config import get_settings
|
|
from app.db import session_scope
|
|
from app.models import Backup, Device, now
|
|
from app.services import devices, events, groups
|
|
|
|
log = logging.getLogger("ros_control.backups")
|
|
_sync_lock = threading.Lock() # один процесс: не создаём дубликаты «сирот» при параллельных запросах
|
|
|
|
|
|
def _kind(key: str) -> str:
|
|
return "backup" if key.endswith(".backup") else "rsc" if key.endswith(".rsc") else "other"
|
|
|
|
|
|
def sync_rows(objects: list[dict]) -> dict[str, dict]:
|
|
"""Сопоставляет файлы бакета со строками backups (по ключу) и возвращает key -> {backup_id, device_id}.
|
|
|
|
- файлы без строки (загруженные до появления ID или вручную) получают строку bkp_… по паре <устройство>/<метка>;
|
|
- копия, у которой не осталось файлов в бакете, помечается удалённой (строка и ID остаются в истории);
|
|
- вернувшиеся файлы снимают пометку."""
|
|
keys = {o["key"]: o for o in objects}
|
|
with _sync_lock, session_scope() as s:
|
|
by_key: dict[str, dict] = {}
|
|
for b in s.query(Backup).all():
|
|
present = [k for k in (b.key_binary, b.key_rsc) if k and k in keys]
|
|
for k in present:
|
|
by_key[k] = {"backup_id": b.id, "device_id": b.device_id}
|
|
if b.status == "done" and b.deleted_at is None and not present:
|
|
b.deleted_at = now()
|
|
events.record("backup.deleted", "backup", b.id, f"Файлы резервной копии {b.device_name} удалены из бакета",
|
|
device_id=b.device_id, data={"key_binary": b.key_binary, "key_rsc": b.key_rsc}, s=s)
|
|
elif present and b.deleted_at is not None:
|
|
b.deleted_at = None
|
|
|
|
orphans: dict[tuple[str, str], dict] = {}
|
|
for key, o in keys.items():
|
|
parts = key.split("/")
|
|
if key in by_key or len(parts) < 3 or _kind(key) == "other":
|
|
continue
|
|
stem = parts[-1].rsplit(".", 1)[0]
|
|
orphans.setdefault((parts[1], stem), {})[_kind(key)] = (key, o["last_modified"])
|
|
dev_by_name = {d.name: d.id for d in s.query(Device)}
|
|
for (name, _stem), files in orphans.items():
|
|
at = max(t for _, t in files.values())
|
|
b = Backup(id=ids.new_id("bkp", at), device_id=dev_by_name.get(name), device_name=name, status="done",
|
|
requested_at=at, key_binary=files.get("backup", (None,))[0], key_rsc=files.get("rsc", (None,))[0])
|
|
s.add(b)
|
|
s.flush()
|
|
events.record("backup.imported", "backup", b.id, f"Файлы бакета связаны с резервной копией {name}",
|
|
device_id=b.device_id, data={"key_binary": b.key_binary, "key_rsc": b.key_rsc}, s=s)
|
|
for k in (b.key_binary, b.key_rsc):
|
|
if k:
|
|
by_key[k] = {"backup_id": b.id, "device_id": b.device_id}
|
|
return by_key
|
|
|
|
|
|
async def reconcile() -> None:
|
|
"""При старте: привести метаданные в соответствие с бакетом (не критично — сбой не мешает работе)."""
|
|
s = get_settings()
|
|
if not (s.s3_bucket and s.s3_access_key):
|
|
return
|
|
try:
|
|
objects = await s3.list_backups(None)
|
|
events.set_actor("system")
|
|
sync_rows(objects)
|
|
except Exception: # noqa: BLE001
|
|
log.exception("reconcile бэкапов не выполнен")
|
|
|
|
|
|
async def search(device: str = "", group: str = "", kind: str = "", date_from: date | None = None,
|
|
date_to: date | None = None, q: str = "") -> tuple[list[dict], dict]:
|
|
"""Отфильтрованные бэкапы и итоги по всему бакету ({"total": N, "size": байты}).
|
|
Ключи вида backups/<устройство>/<файл>: устройство берётся из ключа, группа — по имени устройства.
|
|
group: "" — все, "none" — без группы (в т.ч. бэкапы удалённых устройств), иначе ID группы (grp_…).
|
|
Даты — по времени изменения объекта (UTC), включительно. Каждому файлу сопоставлен backup_id."""
|
|
group_names = {g["id"]: g["name"] for g in groups.list_groups()}
|
|
device_group = {d.name: group_names.get(d.group_id) for d in devices.list_devices()}
|
|
want_group = group_names.get(group) if ids.is_id(group, "grp") else None
|
|
|
|
everything = await s3.list_backups(None)
|
|
try:
|
|
links = sync_rows(everything)
|
|
except Exception: # noqa: BLE001 — список важнее связывания
|
|
log.exception("sync_rows не выполнен")
|
|
links = {}
|
|
stats = {"total": len(everything), "size": sum(i["size"] for i in everything)}
|
|
q = q.strip().lower()
|
|
out = []
|
|
for item in everything:
|
|
parts = item["key"].split("/")
|
|
name = parts[1] if len(parts) >= 3 else ""
|
|
link = links.get(item["key"], {})
|
|
item = {**item, "device": name, "group": device_group.get(name), "kind": _kind(item["key"]),
|
|
"backup_id": link.get("backup_id"), "device_id": link.get("device_id")}
|
|
if device and name != device:
|
|
continue
|
|
if group == "none" and item["group"] is not None:
|
|
continue
|
|
if ids.is_id(group, "grp") and (want_group is None or item["group"] != want_group):
|
|
continue
|
|
if kind and item["kind"] != kind:
|
|
continue
|
|
day = item["last_modified"].date()
|
|
if (date_from and day < date_from) or (date_to and day > date_to):
|
|
continue
|
|
if q and q not in item["key"].lower():
|
|
continue
|
|
out.append(item)
|
|
return out, stats
|
|
|
|
|
|
async def list_backups(device: str = "", group: str = "", kind: str = "", date_from: date | None = None,
|
|
date_to: date | None = None, q: str = "") -> list[dict]:
|
|
return (await search(device, group, kind, date_from, date_to, q))[0]
|
|
|
|
|
|
MAX_DELETE = 500
|
|
|
|
|
|
async def delete_many(keys: list[str]) -> tuple[int, int]:
|
|
"""Групповое удаление файлов из бакета. Все ключи проверяются до начала удаления
|
|
(чужой ключ — ValueError, ничего не удаляется). Возвращает (удалено, не удалось)."""
|
|
keys = list(dict.fromkeys(keys))
|
|
if not keys:
|
|
raise ValueError("Не выбрано ни одного файла")
|
|
if len(keys) > MAX_DELETE:
|
|
raise ValueError(f"За один раз можно удалить не более {MAX_DELETE} файлов")
|
|
if not all(s3.key_allowed(k) for k in keys):
|
|
raise ValueError("Недопустимый ключ")
|
|
sem = asyncio.Semaphore(8)
|
|
|
|
async def one(key: str) -> bool:
|
|
async with sem:
|
|
try:
|
|
await s3.delete_object(key)
|
|
return True
|
|
except Exception: # noqa: BLE001 — считаем неудачей одного файла, остальные удаляем
|
|
return False
|
|
|
|
results = await asyncio.gather(*(one(k) for k in keys))
|
|
try: # метаданные: копии, у которых не осталось файлов, помечаются удалёнными (с событием)
|
|
sync_rows(await s3.list_backups(None))
|
|
except Exception: # noqa: BLE001
|
|
log.exception("sync_rows после удаления не выполнен")
|
|
return sum(results), len(results) - sum(results)
|