zapret-kvn/xray_fluent/updates/auto_update.py
loop-uh d73e26e49d
Some checks failed
Windows project source guards / test (push) Has been cancelled
Страховка замены файлов и при входе в систему (ядро из Zapret GUI 42abc73a)
Перед заменой ставится разовая задача Windows: если откат упёрся в занятый
файл и программа осталась без exe, при следующем входе сценарий доведёт
откат и запустит Zapret KVN. Каталог страховки — ProgramData\ZapretKVN\
update-rescue, закрыт от записи обычными пользователями; доведённый при
запуске откат снимает задачу.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:57:45 +03:00

385 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Фоновая автоустановка: учёт попыток, возврат подключения и уборка мусора.
Обновление ставится без участия пользователя, поэтому единственное, что
отделяет неудачную сборку от бесконечного цикла «скачать → перезапуск →
откат → снова скачать», — запись о попытке в ``data/runtime``. Каталог
``data/`` переживает замену файлов, поэтому новая (или откатившаяся старая)
версия на старте видит, чем закончилась попытка, и решает, пробовать ли снова.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from ..constants import RUNTIME_DIR
from .app_updater import _is_newer_version, _parse_semver
from .installer.runner import STAGE_ROOT_NAME
from .installer.plan import WORK_DIR_PREFIX
_log = logging.getLogger(__name__)
ATTEMPT_FILE = RUNTIME_DIR / "app_update_attempt.json"
# Короткая причина неудачи, которую оставил установщик (installer.runner).
FAILURE_FILE = RUNTIME_DIR / "app_update_failure.txt"
# Установщик при «lock» пишет сюда код программы, державшей файл.
BLOCKER_FILE = RUNTIME_DIR / "app_update_blocker.txt"
VIA_CODES = ("files", "parts", "setup")
# Вид кода причины: его же принимает сервер обновлений.
WHY_RE = re.compile(r"\A[a-z][a-z0-9]{0,15}\Z")
# Одна и та же версия ставится автоматически не больше трёх раз; дальше —
# только кнопкой на странице «Обновления» (новый релиз сбрасывает счётчик).
MAX_AUTO_ATTEMPTS = 3
# Пауза после неудачной попытки растёт: 1 ч, 2 ч.
RETRY_BACKOFF_S = 60 * 60
# Возврат подключения относится только к перезапуску самого обновления, а не
# к ручному запуску приложения через день после сорвавшейся попытки.
RESUME_WINDOW_S = 15 * 60
# Из каталога загрузки работает установщик, поэтому свежие каталоги не трогаем.
TEMP_LEFTOVER_MIN_AGE_S = 10 * 60
# Вынесенные файлы, оставшиеся после отката с ошибками, — последняя копия
# прежней версии; держим её сутки на случай ручного восстановления.
BACKUP_LEFTOVER_MIN_AGE_S = 24 * 60 * 60
TEMP_PREFIX = WORK_DIR_PREFIX
# Каталоги резервных копий, которые создавали прежние версии установщика.
_LEGACY_BACKUP_DIRS = ("update_backups",)
@dataclass(slots=True)
class UpdateAttempt:
version: str
attempts: int
last_attempt: float
reconnect: bool
resume_pending: bool = True
# С какой версии шло обновление и когда сервер его разрешил: по ним новая
# версия сообщит серверу, что обновление дошло и сколько оно заняло.
from_version: str = ""
granted_at: float = 0.0
# Какой ступенью скачано обновление (files — хранилище, parts — куски
# архива, setup — архив целиком) и сколько, КиБ: уходит серверу вместе с «дошла».
via: str = ""
dl_kib: int = 0
def to_dict(self) -> dict:
return {
"version": self.version,
"attempts": self.attempts,
"last_attempt": self.last_attempt,
"reconnect": self.reconnect,
"resume_pending": self.resume_pending,
"from_version": self.from_version,
"granted_at": self.granted_at,
"via": self.via,
"dl_kib": self.dl_kib,
}
@dataclass(slots=True)
class StartupOutcome:
"""Итог предыдущей попытки, увиденный на старте.
``resume`` — было ли подключение до перезапуска (``None``: решать как
обычно, по настройке автоподключения).
"""
updated: bool
version: str
attempts: int
resume: bool | None
# Что сказать серверу, который раздаёт версию по ступеням: «дошла, с
# такой-то версии, за столько-то секунд» либо «не вышло». Пусто — нечего.
report: dict | None = None
def load_attempt(path: Path = ATTEMPT_FILE) -> UpdateAttempt | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
return UpdateAttempt(
version=str(data["version"]),
attempts=max(1, int(data.get("attempts", 1))),
last_attempt=float(data.get("last_attempt", 0.0)),
reconnect=bool(data.get("reconnect", False)),
resume_pending=bool(data.get("resume_pending", False)),
from_version=str(data.get("from_version") or ""),
granted_at=max(float(data.get("granted_at") or 0.0), 0.0),
via=str(data.get("via") or "") if str(data.get("via") or "") in VIA_CODES else "",
dl_kib=max(int(data.get("dl_kib") or 0), 0),
)
except FileNotFoundError:
return None
except (OSError, ValueError, KeyError, TypeError):
_log.warning("Unreadable update attempt record, discarding", exc_info=True)
_remove(path)
return None
def _write_attempt(record: UpdateAttempt, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(record.to_dict()), encoding="utf-8")
os.replace(tmp, path)
def _remove(path: Path) -> None:
try:
path.unlink()
except FileNotFoundError:
pass
except OSError:
_log.warning("Failed to remove %s", path, exc_info=True)
def record_attempt(
version: str,
*,
reconnect: bool,
restarting: bool = True,
now: float | None = None,
path: Path = ATTEMPT_FILE,
from_version: str = "",
granted_at: float = 0.0,
via: str = "",
downloaded: int = 0,
) -> UpdateAttempt:
"""Записать попытку установки.
``restarting=True`` — непосредственно перед запуском установщика: следующий
старт вернёт подключение. ``False`` — архив отвергнут ещё до установки
(битая сумма или содержимое): попытка засчитывается, чтобы неисправный
релиз не скачивался каждые полчаса, но перезапуска не было.
"""
previous = load_attempt(path)
attempts = previous.attempts + 1 if previous and previous.version == version else 1
record = UpdateAttempt(
version=version,
attempts=attempts,
last_attempt=time.time() if now is None else now,
reconnect=reconnect,
resume_pending=restarting,
from_version=str(from_version or ""),
granted_at=max(float(granted_at or 0.0), 0.0),
via=via if via in VIA_CODES else "",
dl_kib=max(int(downloaded or 0), 0) // 1024,
)
_write_attempt(record, path)
return record
def _take_failure_code(path: Path) -> str:
"""Причина, оставленная установщиком; файл читается один раз."""
try:
code = path.read_text(encoding="ascii", errors="replace").strip().lower()
except OSError:
return ""
_remove(path)
return code if WHY_RE.fullmatch(code) else ""
def resolve_startup(
current_version: str,
*,
now: float | None = None,
path: Path = ATTEMPT_FILE,
failure_path: Path = FAILURE_FILE,
blocker_path: Path = BLOCKER_FILE,
) -> StartupOutcome | None:
"""Разобрать запись о попытке после перезапуска.
Успех определяется только версией запущенной сборки: наличие или
отсутствие ``update_error.log`` ничего не доказывает (установщик мог
«успешно» поставить сборку, которая всё ещё сообщает старую версию).
"""
record = load_attempt(path)
why = _take_failure_code(failure_path)
who = _take_failure_code(blocker_path)
if record is None:
return None
if _parse_semver(record.version) is None or _parse_semver(current_version) is None:
_remove(path)
return None
moment = time.time() if now is None else now
resume: bool | None = None
if record.resume_pending and 0 <= moment - record.last_attempt <= RESUME_WINDOW_S:
resume = record.reconnect
if not _is_newer_version(record.version, current_version):
_remove(path)
return StartupOutcome(True, record.version, record.attempts, resume, _success_report(record, current_version, moment))
# Откат: счётчик остаётся, а возврат подключения и сообщение серверу о
# неудаче расходуются один раз — на первом запуске после попытки.
report = None
if record.resume_pending:
report = {"fail": record.version}
if why:
report["why"] = why
if who:
# При «lock» — кто держал файл программы (msmpeng, explorer…).
report["who"] = who
record.resume_pending = False
try:
_write_attempt(record, path)
except OSError:
_log.warning("Failed to update update attempt record", exc_info=True)
return StartupOutcome(False, record.version, record.attempts, resume, report)
# Дольше недели от разрешения до запуска — это уже не «время обновления».
_MAX_TOOK_S = 7 * 24 * 60 * 60
def _success_report(record: UpdateAttempt, current_version: str, moment: float) -> dict | None:
"""«Дошла»: с какой версии и за сколько секунд от разрешения сервера."""
previous = record.from_version
if not previous or _parse_semver(previous) is None or not _is_newer_version(current_version, previous):
return None
report = {"prev": previous}
took = moment - record.granted_at
if record.granted_at > 0 and 0 <= took <= _MAX_TOOK_S:
report["took"] = str(int(took))
if record.via:
report["via"] = record.via
report["dl"] = str(record.dl_kib)
return report
def auto_install_block_reason(
version: str,
current_version: str,
*,
now: float | None = None,
path: Path = ATTEMPT_FILE,
) -> str:
"""Пустая строка — можно ставить без участия пользователя."""
# _is_newer_version при нераспознанной версии сравнивает строки на
# неравенство, то есть считает её «всегда новее» — для тихой установки
# это вечный цикл перезапусков.
if _parse_semver(version) is None or _parse_semver(current_version) is None:
return "нераспознанная версия"
if not _is_newer_version(version, current_version):
return "версия не новее установленной"
record = load_attempt(path)
if record is None or record.version != version:
return ""
if record.attempts >= MAX_AUTO_ATTEMPTS:
return f"установка не удалась уже {record.attempts} раза"
moment = time.time() if now is None else now
wait = RETRY_BACKOFF_S * (2 ** (record.attempts - 1)) - (moment - record.last_attempt)
if wait > 0:
return f"повтор после неудачной попытки через {max(1, int(wait // 60))} мин"
return ""
# Замену, оборванную выключением, откатываем только если её журналу больше
# этого: свежий журнал — это идущая прямо сейчас установка, и запущенная ею
# для проверки новая версия не должна откатить саму себя.
INTERRUPTED_SWAP_MIN_AGE_S = 10 * 60
def recover_interrupted_swaps(*, runtime_dir: Path = RUNTIME_DIR, now: float | None = None) -> int:
"""Откатить замену файлов, оборванную на полпути (журнал на диске).
Возвращает, сколько замен откачено. Программу под замком установщика
(``ZAPRET_UPDATE_HOLD``) не трогает вовсе.
"""
from zapret_update import handoff, rescue, swap
if os.environ.get(handoff.HOLD_ENV):
return 0
moment = time.time() if now is None else now
recovered = 0
root = runtime_dir / STAGE_ROOT_NAME
try:
journals = list(root.glob(f"*/swap/{swap.JOURNAL_NAME}"))
except OSError:
return 0
for journal in journals:
try:
if moment - journal.stat().st_mtime < INTERRUPTED_SWAP_MIN_AGE_S:
continue
except OSError:
continue
errors = swap.recover(journal.parent)
if errors is None:
continue
if errors:
_log.error("Оборванная замена откачена не полностью: %s", "; ".join(errors))
else:
_log.warning("Оборванная замена файлов откачена: %s", journal.parent)
recovered += 1
# Страховка при входе в систему больше не нужна.
from xray_fluent.constants import BASE_DIR
from xray_fluent.updates.installer.runner import rescue_dir
rescue.disarm(rescue_dir(), BASE_DIR)
return recovered
def purge_update_leftovers(
*,
runtime_dir: Path = RUNTIME_DIR,
temp_dir: Path | None = None,
now: float | None = None,
) -> int:
"""Удалить брошенные каталоги прошлых обновлений. Вызывать не из GUI-потока.
Сначала откатывается замена, оборванная выключением: её вынесенные файлы
лежат в тех же каталогах.
"""
moment = time.time() if now is None else now
recover_interrupted_swaps(runtime_dir=runtime_dir, now=moment)
temp_root = Path(tempfile.gettempdir()) if temp_dir is None else temp_dir
candidates: list[tuple[Path, float]] = []
try:
candidates += [
(item, TEMP_LEFTOVER_MIN_AGE_S)
for item in temp_root.glob(f"{TEMP_PREFIX}*")
if item.is_dir()
]
except OSError:
pass
for name in (STAGE_ROOT_NAME, *_LEGACY_BACKUP_DIRS):
attempts = runtime_dir / name
try:
if attempts.is_dir():
candidates += [(item, BACKUP_LEFTOVER_MIN_AGE_S) for item in attempts.iterdir()]
except OSError:
pass
# Фиксированный каталог самых ранних версий установщика.
legacy = runtime_dir / "update_backup"
if legacy.exists():
candidates.append((legacy, BACKUP_LEFTOVER_MIN_AGE_S))
from zapret_update import swap
removed = 0
for item, min_age in candidates:
try:
age = moment - item.stat().st_mtime
except OSError:
continue
if age < min_age:
continue
# Откат не вернул всё — в swap/old единственная копия прежних файлов.
if item.is_dir() and swap.pending(item / "swap"):
continue
if item.is_dir():
shutil.rmtree(item, ignore_errors=True)
else:
_remove(item)
if not item.exists():
removed += 1
return removed