Files
talkscore-asr/app/updater.py
T
Vladimir BryzgalovandClaude Opus 5 59857fff0f Исправлено разрушение папки app при обновлении на Windows
Обновление перемещало папку app целиком, а код выполняется именно из неё.
Windows не даёт двигать такую папку: перемещение не прошло, откат тоже,
и app исчезла - сервис переставал запускаться.

Теперь папка остаётся на месте: новая версия проверяется во временном
каталоге ДО установки, затем файлы перезаписываются по одному с копией
для отката. Устаревшие модули удаляются, __pycache__ не трогается.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:00:03 +05:00

259 lines
11 KiB
Python

"""Обновление кода сервиса из релизов Gitea.
Обновляется только папка app - несколько десятков килобайт вместо всего пакета.
Python, библиотеки, ffmpeg и модели остаются на месте.
Порядок: скачали, сверили контрольную сумму, распаковали во временную папку,
отложили текущую версию, поставили новую, проверили импорт. Если проверка не
прошла, возвращаем отложенную версию: сервис не должен остаться сломанным.
"""
import hashlib
import io
import os
import logging
import shutil
import subprocess
import sys
import zipfile
from collections.abc import Callable
from pathlib import Path
log = logging.getLogger(__name__)
__all__ = ["UpdateError", "parse_version", "is_newer", "verify_sha256",
"install_update", "check_and_update"]
# Эти файлы принадлежат пользователю: их правят руками и обновление их не трогает.
USER_FILES = {"config.toml", "replacements.txt"}
UPDATE_TIMEOUT_SEC = 120
class UpdateError(RuntimeError):
"""Обновление не удалось. Сервис при этом остаётся на рабочей версии."""
def parse_version(value: str) -> tuple[int, int, int]:
"""Разбирает «1.2.3» или «v1.2.3». Непонятное считается нулевой версией."""
parts = (value or "").strip().lstrip("vV").split(".")
numbers = []
for part in parts[:3]:
digits = "".join(ch for ch in part if ch.isdigit())
numbers.append(int(digits) if digits else 0)
while len(numbers) < 3:
numbers.append(0)
return tuple(numbers) # type: ignore[return-value]
def is_newer(remote: str, local: str) -> bool:
"""Строго больше: равная и более старая версия не ставится."""
return parse_version(remote) > parse_version(local)
def verify_sha256(data: bytes, expected: str) -> None:
actual = hashlib.sha256(data).hexdigest()
if actual.lower() != (expected or "").strip().lower():
raise UpdateError(f"контрольная сумма не совпала: ждали {expected}, получили {actual}")
def _safe_members(archive: zipfile.ZipFile) -> list[str]:
"""Отбирает пути внутри app, отвергая выход за пределы папки сервиса."""
names = []
for name in archive.namelist():
if name.endswith("/"):
continue
path = Path(name)
if path.is_absolute() or ".." in path.parts:
raise UpdateError(f"недопустимый путь в архиве: {name}")
if path.parts and path.parts[0] == "app":
names.append(name)
if not names:
raise UpdateError("в архиве нет папки app")
return names
def _default_verify(staging: Path) -> bool:
"""Проверяет, что новый код импортируется, ещё до его установки.
Код берётся из временной папки, рабочая при этом не тронута: если новая
версия окажется нерабочей, ставить её не начнём вовсе.
"""
env = dict(os.environ)
env.update({"PYTHONPATH": str(staging)})
env.setdefault("TALKSCORE_ASR_HOME", str(staging.parent))
try:
result = subprocess.run(
[sys.executable, "-c", "import app.main"],
cwd=str(staging), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, env=env,
)
if result.returncode != 0:
log.error("новая версия не импортируется: %s",
result.stderr.decode("utf-8", "replace")[:500])
return result.returncode == 0
except (subprocess.SubprocessError, OSError) as exc:
log.error("не удалось проверить новую версию: %s", exc)
return False
def install_update(archive_bytes: bytes, base_dir: Path,
verify: Callable[[Path], bool] | None = None) -> None:
"""Обновляет файлы внутри app, не трогая саму папку.
Windows не даёт переместить или удалить папку, из которой сейчас выполняется
код, а обновление запускается именно оттуда. Поэтому папка остаётся на месте,
а файлы в ней перезаписываются по одному, с копией для отката.
"""
base = Path(base_dir)
verify = verify or _default_verify
staging = base / ".update-staging"
backup = base / ".update-backup"
live = base / "app"
try:
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True)
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as z:
for name in _safe_members(z):
if Path(name).name in USER_FILES:
continue
z.extract(name, staging)
if not (staging / "app").is_dir():
raise UpdateError("в архиве нет папки app")
# Проверяем до установки: рабочая версия ещё цела и продолжит работать,
# если новая окажется сломанной.
if not verify(staging):
raise UpdateError("новая версия не прошла проверку запуска")
live.mkdir(parents=True, exist_ok=True)
shutil.rmtree(backup, ignore_errors=True)
shutil.copytree(live, backup)
try:
_sync_files(staging / "app", live)
except Exception:
_sync_files(backup, live)
raise
finally:
shutil.rmtree(staging, ignore_errors=True)
shutil.rmtree(backup, ignore_errors=True)
def _sync_files(src: Path, dst: Path) -> None:
"""Приводит dst к состоянию src: копирует файлы и убирает лишние.
Устаревший модуль, удалённый в новой версии, обязан исчезнуть и здесь,
иначе он останется импортируемым и будет вести себя как призрак.
"""
dst.mkdir(parents=True, exist_ok=True)
wanted = set()
for item in sorted(src.rglob("*")):
rel = item.relative_to(src)
target = dst / rel
wanted.add(rel)
if item.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
for item in sorted(dst.rglob("*"), reverse=True):
rel = item.relative_to(dst)
if rel in wanted or rel.parts[0] == "__pycache__":
continue
if item.is_file():
item.unlink(missing_ok=True)
elif item.is_dir() and not any(item.iterdir()):
item.rmdir()
def _api(session, url: str, token: str):
headers = {"Authorization": f"token {token}"} if token else {}
response = session.get(url, headers=headers, timeout=30)
# На приватном репозитории Gitea отвечает 404, а не 403: она скрывает сам
# факт его существования. Поэтому 404 почти всегда означает не «нет релиза»,
# а «этот токен сюда не пускают».
if response.status_code in (401, 403, 404):
if not token:
raise UpdateError(
"репозиторий не отдаётся без токена. Впишите его в config.toml, "
"раздел [update], поле token (Gitea: Settings - Applications - "
"Generate Token, право read:repository)")
raise UpdateError(
f"Gitea ответила {response.status_code}. Обычно это значит, что токен "
"в config.toml неверный или у него нет права read:repository")
response.raise_for_status()
return response
def check_and_update(base_dir: Path, server: str, repo: str, token: str,
local_version: str) -> str | None:
"""Проверяет последний релиз и ставит его, если он новее. Возвращает версию."""
import requests
base = Path(base_dir)
with requests.Session() as session:
url = f"{server.rstrip('/')}/api/v1/repos/{repo}/releases/latest"
release = _api(session, url, token).json()
remote_version = str(release.get("tag_name", ""))
if not is_newer(remote_version, local_version):
return None
assets = {a["name"]: a for a in release.get("assets", [])}
archive_name = next((n for n in assets if n.startswith("app-") and n.endswith(".zip")), None)
if archive_name is None:
raise UpdateError("в релизе нет архива app-*.zip")
payload = _api(session, assets[archive_name]["browser_download_url"], token).content
sums_name = f"{archive_name}.sha256"
if sums_name not in assets:
raise UpdateError("в релизе нет файла с контрольной суммой")
expected = _api(session, assets[sums_name]["browser_download_url"], token).text.split()[0]
verify_sha256(payload, expected)
install_update(payload, base)
return remote_version
def main() -> int:
"""Точка входа для start.bat: обновляется, но никогда не мешает запуску."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
try:
from app.config import load_settings
from app.version import __version__
settings = load_settings()
if not settings.update_enabled:
return 0
print(f" Текущая версия: {__version__}. Проверяю обновления...")
new_version = check_and_update(
base_dir=settings.base_dir,
server=settings.update_server,
repo=settings.update_repo,
token=settings.update_token,
local_version=__version__,
)
if new_version:
print(f" Обновлено до {new_version}")
else:
print(" Установлена последняя версия")
except Exception as exc: # noqa: BLE001 - падение обновления не должно мешать работе
# Полный стек requests занимает пол-экрана и пугает, оставляем суть.
reason = str(exc).split("(Caused by")[0].strip()[:160] or type(exc).__name__
print(f" Обновиться не удалось: {reason}")
print(" Запускаюсь на текущей версии")
return 0
if __name__ == "__main__":
sys.exit(main())