"""REST: управление хранилищами записи — список, CRUD, выбор активного, политика. Несколько хранилищ (local-папка или NAS/SMB) + одно активное (куда пишем новые записи). Квота — на каждом хранилище. NAS монтируется на ХОСТЕ (backend не монтирует сам), здесь лишь хранятся параметры и генерируется готовая команда mount/fstab.""" from __future__ import annotations import os import re from dataclasses import replace from fastapi import APIRouter, HTTPException, Request from ..auth import require_role from ..config import StorageItem, save_config from ..runtime import runtime from ..services.system import is_mounted, storage_disk router = APIRouter(prefix="/api/storages", tags=["storages"]) ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$") STORAGES_ROOT = "/storages" # общий bind-mount корень для доп. хранилищ HOST_STORAGES_ROOT = "/opt/nvr/storages" # он же на хосте (для команды монтирования NAS) ALLOWED_SEGMENTS = (60, 120, 300, 600, 900) # 1 / 2 / 5 / 10 / 15 минут # ── helpers ───────────────────────────────────────────────────── async def _item_payload(item: StorageItem, active: str) -> dict: nas = item.type == "nas" return { "id": item.id, "name": item.name, "type": item.type, "path": item.path, "quota_gb": item.quota_gb, "server": item.server, "share": item.share, "username": item.username, "password": item.password, "subpath": item.subpath, "enabled": item.enabled, "active": item.id == active, "mounted": is_mounted(item.path) if nas else True, "used_bytes": await runtime.db.total_size_under(item.path), "disk": storage_disk(item.path), } def _nas_mount_help(item: StorageItem) -> dict: """Готовая команда монтирования SMB на хосте + строка fstab (монтаж — на хосте).""" host_path = f"{HOST_STORAGES_ROOT}/{item.id}" src = f"//{item.server}/{item.share}" if item.subpath: src += "/" + item.subpath.strip("/") cred = f"username={item.username},password={item.password}" opts = f"{cred},uid=0,gid=0,vers=3.0,iocharset=utf8" return { "host_path": host_path, "mount_cmd": f"sudo mkdir -p {host_path} && sudo mount -t cifs {src} {host_path} -o {opts}", "fstab": f"{src} {host_path} cifs {opts},_netdev,nofail 0 0", "note": "После монтирования на хосте выполните `docker compose up -d` (или включите " "rshared-проброс), чтобы том стал виден в контейнере.", } # ── список ────────────────────────────────────────────────────── @router.get("") async def list_storages() -> dict: cfg = runtime.config active = cfg.storage.active items = [await _item_payload(s, active) for s in cfg.storage.items] return { "storages": items, "active": active, "retention_days": cfg.storage.retention_days, "segment_seconds": cfg.storage.segment_seconds, } # ── политика (глобально): срок хранения и длина сегмента ───────── @router.post("/policy") async def update_policy(request: Request) -> dict: require_role(request, "admin") body = await request.json() st = runtime.config.storage kw: dict = {} seg_changed = False if "segment_seconds" in body: try: seg = int(body["segment_seconds"]) except (TypeError, ValueError): raise HTTPException(400, "segment_seconds должен быть числом") if seg not in ALLOWED_SEGMENTS: raise HTTPException(400, "допустимо 60 / 120 / 300 / 600 / 900 секунд") kw["segment_seconds"] = seg seg_changed = seg != st.segment_seconds if "retention_days" in body: try: rd = int(body["retention_days"]) except (TypeError, ValueError): raise HTTPException(400, "retention_days должен быть числом") if rd < 0 or rd > 3650: raise HTTPException(400, "retention_days в диапазоне 0..3650") kw["retention_days"] = rd if not kw: raise HTTPException(400, "ожидается segment_seconds и/или retention_days") runtime.config.storage = replace(st, **kw) save_config(runtime.config) if seg_changed: await runtime.supervisor.restart_all() return { "ok": True, "segment_seconds": runtime.config.storage.segment_seconds, "retention_days": runtime.config.storage.retention_days, } # ── CRUD ──────────────────────────────────────────────────────── @router.post("") async def create_storage(request: Request) -> dict: require_role(request, "admin") body = await request.json() sid = (body.get("id") or "").strip() if not ID_RE.match(sid): raise HTTPException(400, "id: разрешены латиница, цифры, _ и - (до 32 символов)") if runtime.config.storage_by_id(sid): raise HTTPException(409, "хранилище с таким id уже существует") stype = body.get("type", "local") if stype not in ("local", "nas"): raise HTTPException(400, "type: local | nas") name = (body.get("name") or sid).strip() quota = max(0, int(body.get("quota_gb") or 0)) helper = None if stype == "local": # путь = подпапка общего корня /storages/; допускаем явный path (напр. /records) path = (body.get("path") or "").strip() or f"{STORAGES_ROOT}/{sid}" item = StorageItem(id=sid, name=name, type="local", path=path, quota_gb=quota) try: os.makedirs(path, exist_ok=True) except OSError as exc: raise HTTPException(400, f"не удалось создать папку {path}: {exc}") else: server = (body.get("server") or "").strip() share = (body.get("share") or "").strip() if not server or not share: raise HTTPException(400, "для NAS обязательны server и share") path = f"{STORAGES_ROOT}/{sid}" item = StorageItem( id=sid, name=name, type="nas", path=path, quota_gb=quota, server=server, share=share, username=(body.get("username") or "").strip(), password=body.get("password") or "", subpath=(body.get("subpath") or "").strip(), ) # создаём точку монтирования на хосте (через bind-mount /storages) — писать туда # рекордер начнёт только когда SMB реально смонтируют (guard по os.path.ismount) try: os.makedirs(path, exist_ok=True) except OSError: pass helper = _nas_mount_help(item) new_items = runtime.config.storage.items + (item,) runtime.config.storage = replace(runtime.config.storage, items=new_items) save_config(runtime.config) out = await _item_payload(item, runtime.config.storage.active) if helper: out["mount_help"] = helper return out @router.put("/{sid}") async def update_storage(sid: str, request: Request) -> dict: require_role(request, "admin") item = runtime.config.storage_by_id(sid) if not item: raise HTTPException(404, "storage not found") body = await request.json() kw: dict = {} if "name" in body: kw["name"] = (body.get("name") or item.name).strip() if "quota_gb" in body: kw["quota_gb"] = max(0, int(body.get("quota_gb") or 0)) if "enabled" in body: kw["enabled"] = bool(body["enabled"]) for f in ("server", "share", "username", "subpath"): # метаданные NAS if f in body: kw[f] = (body.get(f) or "").strip() if "password" in body: kw["password"] = body.get("password") or "" if not kw: raise HTTPException(400, "нет полей для изменения") # id/type/path неизменны (привязаны к записям на диске → не ломаем архив) new = replace(item, **kw) items = list(runtime.config.storage.items) idx = next(i for i, s in enumerate(items) if s.id == sid) items[idx] = new runtime.config.storage = replace(runtime.config.storage, items=tuple(items)) save_config(runtime.config) # quota применится при следующем проходе retention; path не менялся → рекордеры не трогаем out = await _item_payload(new, runtime.config.storage.active) if new.type == "nas": out["mount_help"] = _nas_mount_help(new) return out @router.delete("/{sid}") async def delete_storage(sid: str, request: Request) -> dict: """Убирает хранилище из списка. Файлы и записи в БД НЕ трогаются (архив сохраняется).""" require_role(request, "admin") if not runtime.config.storage_by_id(sid): raise HTTPException(404, "storage not found") if sid == runtime.config.storage.active: raise HTTPException(409, "нельзя удалить активное хранилище — сначала выберите другое") if len(runtime.config.storage.items) <= 1: raise HTTPException(409, "нельзя удалить последнее хранилище") items = tuple(s for s in runtime.config.storage.items if s.id != sid) runtime.config.storage = replace(runtime.config.storage, items=items) save_config(runtime.config) return {"ok": True} @router.post("/{sid}/activate") async def activate_storage(sid: str, request: Request) -> dict: """Делает хранилище активным (новые записи идут туда). Перезапускает рекордеры.""" require_role(request, "admin") item = runtime.config.storage_by_id(sid) if not item: raise HTTPException(404, "storage not found") runtime.config.storage = replace(runtime.config.storage, active=sid) save_config(runtime.config) await runtime.supervisor.restart_all() # репойнт рекордеров на новый путь mounted = is_mounted(item.path) if item.type == "nas" else True return {"ok": True, "active": sid, "mounted": mounted}