Обновление перемещало папку app целиком, а код выполняется именно из неё. Windows не даёт двигать такую папку: перемещение не прошло, откат тоже, и app исчезла - сервис переставал запускаться. Теперь папка остаётся на месте: новая версия проверяется во временном каталоге ДО установки, затем файлы перезаписываются по одному с копией для отката. Устаревшие модули удаляются, __pycache__ не трогается. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
259 lines
11 KiB
Python
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())
|