"""Вспомогательные функции админ-дашборда: хвост лога, бэкап SQLite, команды службы.""" from __future__ import annotations import gc import io import json import os import shlex import shutil import time import sqlite3 import subprocess import tempfile import zipfile from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from flask import Flask from sqlalchemy.engine.url import make_url def tail_text_file(path: Path, max_lines: int = 200, max_bytes: int = 512_000) -> Tuple[List[str], str]: """Читает последние строки текстового файла (ограничение по размеру хвоста).""" if not path.is_file(): return [], "Файл не найден" try: size = path.stat().st_size with path.open("rb") as f: if size <= max_bytes: f.seek(0) chunk = f.read() else: f.seek(-max_bytes, os.SEEK_END) chunk = f.read() text = chunk.decode("utf-8", errors="replace") lines = text.splitlines() return lines[-max_lines:], "" except OSError as exc: return [], str(exc) def sqlite_bind_paths(app: Flask) -> Dict[str, Path]: """Имена bind SQLAlchemy → абсолютные пути к файлам SQLite (только файловые URI).""" return dict(_sqlite_paths_from_app(app)) def _sqlite_paths_from_app(app: Flask) -> List[Tuple[str, Path]]: out: List[Tuple[str, Path]] = [] uris = [app.config.get("SQLALCHEMY_DATABASE_URI", "")] binds = app.config.get("SQLALCHEMY_BINDS") or {} for name, uri in [("recipes", uris[0]), *[(k, v) for k, v in binds.items()]]: if not uri: continue try: url = make_url(uri) except Exception: continue if url.drivername != "sqlite": continue db = url.database if not db or db == ":memory:": continue p = Path(db) if not p.is_absolute(): p = Path(app.config.get("BASE_DIR", ".")) / p out.append((name, p.resolve())) return out def _sqlite_snapshot_bytes(fpath: Path) -> bytes: """ Согласованный снимок файла SQLite для архива. При journal_mode=WAL простое копирование .db на диске может не включать свежие данные (они в -wal); sqlite3.backup объединяет состояние в один файл. """ if not fpath.is_file(): raise FileNotFoundError(f"База не найдена: {fpath}") src = sqlite3.connect(str(fpath)) try: fd, tmp_name = tempfile.mkstemp(suffix=".db") os.close(fd) try: dst = sqlite3.connect(tmp_name) try: src.backup(dst) finally: dst.close() return Path(tmp_name).read_bytes() finally: try: Path(tmp_name).unlink(missing_ok=True) except OSError: pass finally: src.close() def build_sqlite_backup_zip(app: Flask) -> Tuple[bytes, str]: """Упаковывает файлы SQLite в ZIP; возвращает (bytes, предлагаемое имя файла).""" from datetime import datetime pairs = _sqlite_paths_from_app(app) if not pairs: raise ValueError("Нет файловых баз SQLite для резервной копии") buf = io.BytesIO() stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") used_names: set[str] = set() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for bind_name, fpath in pairs: arc = f"{bind_name}-{fpath.name}" if arc in used_names: arc = f"{bind_name}-{fpath.name}-{len(used_names)}" used_names.add(arc) raw = _sqlite_snapshot_bytes(fpath) zf.writestr(arc, raw, compress_type=zipfile.ZIP_DEFLATED) name = f"wesp-sqlite-backup-{stamp}.zip" return buf.getvalue(), name _SQLITE_MAGIC = b"SQLite format 3\x00" def _remove_sqlite_sidecars(db_path: Path) -> None: """Удаляет -wal / -shm / -journal рядом с файлом БД (после подмены файла).""" base = str(db_path) for suffix in ("-wal", "-shm", "-journal"): p = Path(base + suffix) try: if p.is_file(): p.unlink() except OSError: pass def _checkpoint_sqlite(db_path: Path) -> None: """Сбрасывает WAL перед подменой файла на диске.""" if not db_path.is_file(): return try: con = sqlite3.connect(str(db_path)) try: con.execute("PRAGMA wal_checkpoint(TRUNCATE)") finally: con.close() except sqlite3.Error: pass def _restore_sqlite_via_backup(dest_path: Path, raw: bytes) -> None: """Вливает снимок SQLite в существующий файл (работает при открытых соединениях).""" fd, tmp_name = tempfile.mkstemp(suffix=".db") os.close(fd) try: Path(tmp_name).write_bytes(raw) src = sqlite3.connect(tmp_name) try: dst = sqlite3.connect(str(dest_path)) try: src.backup(dst) finally: dst.close() finally: src.close() finally: try: Path(tmp_name).unlink(missing_ok=True) except OSError: pass def _replace_sqlite_file(tmp_file: Path, dest_path: Path) -> None: """Атомарная подмена файла SQLite; при блокировке — sqlite3.backup в целевой файл.""" gc.collect() _checkpoint_sqlite(dest_path) _remove_sqlite_sidecars(dest_path) last_exc: Optional[OSError] = None for attempt in range(8): try: os.replace(str(tmp_file), str(dest_path)) _remove_sqlite_sidecars(dest_path) return except OSError as exc: last_exc = exc if attempt >= 7: break gc.collect() _checkpoint_sqlite(dest_path) _remove_sqlite_sidecars(dest_path) time.sleep(0.05 * (attempt + 1)) if last_exc is not None: raw = tmp_file.read_bytes() _restore_sqlite_via_backup(dest_path, raw) try: tmp_file.unlink(missing_ok=True) except OSError: pass _remove_sqlite_sidecars(dest_path) def _dispose_sqlalchemy_engines(app: Flask) -> None: """Сбрасывает пулы соединений перед заменой файлов на диске.""" from app import db with app.app_context(): db.session.remove() try: db.engine.dispose() except Exception: pass for bind_key in app.config.get("SQLALCHEMY_BINDS") or {}: try: db.get_engine(app=app, bind=bind_key).dispose() except Exception: pass def restore_sqlite_from_zip(app: Flask, zip_bytes: bytes) -> Dict[str, Any]: """ Восстанавливает файлы SQLite из ZIP того же формата, что и build_sqlite_backup_zip (имена внутри: «{bind}-{имя_файла}», например recipes-recipes.db). Текущие файлы копируются в *.restore-bak-{utc_stamp}. """ if len(zip_bytes) > 80 * 1024 * 1024: raise ValueError("Архив слишком большой (максимум 80 МБ)") pairs = _sqlite_paths_from_app(app) if not pairs: raise ValueError("Нет файловых баз SQLite в конфигурации") stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") tmp_root = Path(app.config.get("BASE_DIR", ".")).resolve() / "wesp_restore_tmp" tmp_root.mkdir(parents=True, exist_ok=True) pid = os.getpid() restored: List[Dict[str, str]] = [] _dispose_sqlalchemy_engines(app) try: with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: members = [ n.strip().replace("\\", "/") for n in zf.namelist() if n and not n.endswith("/") ] for bind_name, dest_path in pairs: arc = f"{bind_name}-{dest_path.name}" match = None for m in members: if m.rsplit("/", 1)[-1] == arc: match = m break if not match: raise ValueError( f"В архиве нет файла «{arc}» для bind «{bind_name}». " "Нужен ZIP, скачанный кнопкой «Скачать ZIP»." ) raw = zf.read(match) if len(raw) < 16 or not raw.startswith(_SQLITE_MAGIC): raise ValueError(f"«{arc}» не является корректным файлом SQLite") dest_path.parent.mkdir(parents=True, exist_ok=True) if dest_path.is_file(): bak = dest_path.with_name(f"{dest_path.name}.restore-bak-{stamp}") shutil.copy2(dest_path, bak) _remove_sqlite_sidecars(dest_path) tmp_file = tmp_root / f"{arc}.{pid}.tmp" try: tmp_file.write_bytes(raw) _replace_sqlite_file(tmp_file, dest_path) finally: try: if tmp_file.is_file(): tmp_file.unlink() except OSError: pass _remove_sqlite_sidecars(dest_path) restored.append({"bind": bind_name, "path": str(dest_path)}) except zipfile.BadZipFile as exc: raise ValueError("Некорректный ZIP-архив") from exc return { "restored": restored, "message": ( "Базы восстановлены из архива. Перезапустите процесс WESP " "(или службу), затем обновите страницу." ), } def run_service_command(app: Flask, action: str) -> Dict[str, Any]: """action: restart | stop — только если задана команда в конфиге.""" if action == "restart": from app.services.system_restart_service import resolve_admin_restart_cmd cmd = resolve_admin_restart_cmd(app.config) elif action == "stop": cmd = app.config.get("WESP_ADMIN_STOP_CMD") or "" else: return {"ok": False, "code": "bad_action", "message": "Неизвестное действие"} cmd = (cmd or "").strip() if not cmd: return { "ok": False, "code": "not_configured", "message": ( "Команда не задана. Для перезапуска/остановки укажите в окружении " "WESP_ADMIN_RESTART_CMD или WESP_ADMIN_STOP_CMD (например systemctl …)." ), } try: args = shlex.split(cmd) subprocess.Popen( args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) except OSError as exc: return {"ok": False, "code": "exec_error", "message": str(exc)} return {"ok": True, "code": "started", "message": "Команда запущена в фоне"} def install_state_path(app: Flask) -> Path: raw = (app.config.get("WESP_INSTALL_STATE_PATH") or "").strip() if raw: p = Path(raw).expanduser() if not p.is_absolute(): p = Path(str(app.config.get("BASE_DIR", "."))) / p return p.resolve() data_dir = Path(str(app.config.get("DATA_DIR") or "")).expanduser() if not str(data_dir): data_dir = Path(str(app.config.get("BASE_DIR", "."))) / "data" return data_dir.resolve() / "wesp_install_state.json" def get_install_and_warranty(app: Flask) -> Dict[str, Any]: """ Дата первого запуска (фиксируется в JSON при первом обращении) и гарантия WESP_WARRANTY_DAYS. Возвращает UTC ISO для first_launch_at, дату окончания гарантии и целые дни до неё (может быть < 0). """ path = install_state_path(app) warranty_days = int(app.config.get("WESP_WARRANTY_DAYS", 365)) warranty_days = max(1, warranty_days) now = datetime.now(timezone.utc) first_launch: Optional[datetime] = None if path.is_file(): try: data = json.loads(path.read_text(encoding="utf-8")) raw_ts = data.get("first_launch_at") or data.get("first_started_at") if raw_ts: first_launch = datetime.fromisoformat(str(raw_ts).replace("Z", "+00:00")) if first_launch.tzinfo is None: first_launch = first_launch.replace(tzinfo=timezone.utc) except (OSError, ValueError, TypeError, json.JSONDecodeError): first_launch = None if first_launch is None: first_launch = now try: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps({"first_launch_at": first_launch.isoformat()}, indent=2), encoding="utf-8", ) except OSError: pass warranty_end = first_launch + timedelta(days=warranty_days) days_left = (warranty_end.date() - now.date()).days return { "first_launch_at": first_launch.isoformat(), "warranty_until": warranty_end.date().isoformat(), "warranty_days_remaining": int(days_left), }