387 lines
14 KiB
Python
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),
|
|
}
|