"""Обновление кода сервиса из релизов 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())