Files
site/WESP_REL/app/services/admin_dashboard_service.py
2026-07-17 12:57:18 +03:00

387 lines
14 KiB
Python

"""Вспомогательные функции админ-дашборда: хвост лога, бэкап 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),
}