@@ -0,0 +1,386 @@
|
||||
"""Вспомогательные функции админ-дашборда: хвост лога, бэкап 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),
|
||||
}
|
||||
Reference in New Issue
Block a user