plate-config-app/app/services/update_service.py
Danil Iarosh 35c9693740 fix: stabilize OTA updates in v2.0.2
- Исправлен перезапуск приложения после замены файлов и добавлено подтверждение запуска новой версии.
- Обеспечены откат при ошибке и корректное завершение фоновых задач.
- Исключены посторонние DLL из Windows-сборки, обновлены тесты и инструкция ручного перехода с v2.0.1.
2026-09-17 10:30:18 +03:00

649 lines
25 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.

"""Проверка, загрузка и установка обновлений Plate Config App."""
from __future__ import annotations
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import uuid
import zipfile
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import BinaryIO
from urllib.parse import quote, unquote, urlparse
from urllib.request import Request, urlopen
LATEST_RELEASE_URL = (
"https://git.robotail.pro/Robotail/plate-config-app/releases/latest"
)
RELEASE_DOWNLOAD_BASE_URL = (
"https://git.robotail.pro/Robotail/plate-config-app/releases/download"
)
UPDATE_HOST = "git.robotail.pro"
HTTP_TIMEOUT_SECONDS = 10.0
DOWNLOAD_TIMEOUT_SECONDS = 30.0
DOWNLOAD_CHUNK_SIZE = 1024 * 1024
MAX_DOWNLOAD_BYTES = 300 * 1024 * 1024
MAX_UNPACKED_BYTES = 600 * 1024 * 1024
MAX_ARCHIVE_MEMBERS = 10_000
UPDATE_READY_ENV = "PLATE_CONFIG_UPDATE_READY_FILE"
VERSION_PATTERN = re.compile(
r"[vV]?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
)
TOP_LEVEL_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
class UpdateError(RuntimeError):
"""Ошибка проверки, загрузки или запуска обновления."""
class UpdateCancelled(UpdateError):
"""Загрузка обновления отменена пользователем или закрытием приложения."""
@dataclass(frozen=True)
class UpdateInfo:
tag: str
version: str
asset_name: str
download_url: str
def normalize_version(value: str) -> str:
normalized = value.strip()
if normalized[:1].casefold() == "v":
normalized = normalized[1:]
return normalized
def parse_version(value: str) -> tuple[int, int, int]:
"""Преобразовать версию X.Y.Z или vX.Y.Z в числовой кортеж."""
match = VERSION_PATTERN.fullmatch(value.strip())
if match is None:
raise UpdateError(
f"Некорректная версия {value!r}: ожидается формат vX.Y.Z"
)
return tuple(int(component) for component in match.groups())
def platform_executable_name(
*,
system: str | None = None,
machine: str | None = None,
) -> str:
system_name = (system or platform.system()).casefold()
machine_name = (machine or platform.machine()).casefold()
if machine_name not in {"amd64", "x86_64"}:
raise UpdateError(f"Автообновление не поддерживает архитектуру {machine_name}")
if system_name == "windows":
return "plate_config_app-windows-x86_64.exe"
if system_name == "linux":
return "plate_config_app-linux-x86_64"
raise UpdateError(f"Автообновление не поддерживает платформу {system_name}")
def platform_asset_name(
*,
system: str | None = None,
machine: str | None = None,
) -> str:
executable_name = platform_executable_name(system=system, machine=machine)
if executable_name.casefold().endswith(".exe"):
executable_name = executable_name[:-4]
return f"{executable_name}.zip"
def archive_executable_name(asset_name: str) -> str:
names = {
"plate_config_app-windows-x86_64.zip": (
"plate_config_app-windows-x86_64.exe"
),
"plate_config_app-linux-x86_64.zip": "plate_config_app-linux-x86_64",
}
try:
return names[asset_name]
except KeyError as exc:
raise UpdateError("Неизвестное имя архива обновления") from exc
def extract_release_tag(final_url: str) -> str:
parsed = urlparse(final_url)
if parsed.scheme != "https" or parsed.hostname != UPDATE_HOST:
raise UpdateError("Сервер релизов перенаправил запрос на неизвестный адрес")
parts = [unquote(part) for part in parsed.path.split("/") if part]
try:
release_index = parts.index("releases")
except ValueError as exc:
raise UpdateError("Не удалось определить тег последнего релиза") from exc
if parts[release_index + 1:release_index + 2] != ["tag"]:
raise UpdateError("Не удалось определить тег последнего релиза")
tag_parts = parts[release_index + 2:release_index + 3]
if not tag_parts or not tag_parts[0].strip():
raise UpdateError("Сервер релизов вернул пустой тег")
return tag_parts[0].strip()
def build_download_url(tag: str, asset_name: str) -> str:
encoded_tag = quote(tag, safe="")
encoded_asset = quote(asset_name, safe="")
return f"{RELEASE_DOWNLOAD_BASE_URL}/{encoded_tag}/{encoded_asset}"
def check_for_update(
current_version: str,
*,
timeout: float = HTTP_TIMEOUT_SECONDS,
opener: Callable[..., BinaryIO] | None = None,
system: str | None = None,
machine: str | None = None,
) -> UpdateInfo | None:
"""Перейти по latest URL и вернуть данные отличающегося релиза."""
request = Request(
LATEST_RELEASE_URL,
headers={
"Accept": "text/html,application/xhtml+xml",
"User-Agent": f"PlateConfigApp/{normalize_version(current_version)}",
},
)
open_request = opener or urlopen
try:
with open_request(request, timeout=timeout) as response:
final_url = response.geturl()
except Exception as exc:
raise UpdateError(f"Не удалось проверить обновления: {exc}") from exc
tag = extract_release_tag(final_url)
remote_version = parse_version(tag)
if remote_version <= parse_version(current_version):
return None
version = normalize_version(tag)
asset_name = platform_asset_name(system=system, machine=machine)
return UpdateInfo(
tag=tag,
version=version,
asset_name=asset_name,
download_url=build_download_url(tag, asset_name),
)
def _validate_executable_signature(signature: bytes, executable_name: str) -> None:
if executable_name.casefold().endswith(".exe"):
valid = signature.startswith(b"MZ")
else:
valid = signature == b"\x7fELF"
if not valid:
raise UpdateError("Сервер вернул файл, не похожий на исполняемый артефакт")
def _validated_archive_members(
archive: zipfile.ZipFile,
executable_name: str,
) -> list[zipfile.ZipInfo]:
members = archive.infolist()
if len(members) > MAX_ARCHIVE_MEMBERS:
raise UpdateError("В архиве обновления слишком много файлов")
total_size = 0
normalized_names: set[str] = set()
executable_member: zipfile.ZipInfo | None = None
for member in members:
raw_name = member.filename
if not raw_name or "\\" in raw_name or "\x00" in raw_name:
raise UpdateError("Архив обновления содержит небезопасный путь")
relative_path = PurePosixPath(raw_name.rstrip("/"))
if (
relative_path.is_absolute()
or not relative_path.parts
or any(part in {"", ".", ".."} for part in relative_path.parts)
):
raise UpdateError("Архив обновления содержит небезопасный путь")
if not TOP_LEVEL_NAME_PATTERN.fullmatch(relative_path.parts[0]):
raise UpdateError("Архив обновления содержит недопустимое имя")
normalized_name = relative_path.as_posix().casefold()
if normalized_name in normalized_names:
raise UpdateError("Архив обновления содержит повторяющиеся пути")
normalized_names.add(normalized_name)
unix_mode = member.external_attr >> 16
file_type = stat.S_IFMT(unix_mode)
if file_type not in {0, stat.S_IFREG, stat.S_IFDIR}:
raise UpdateError("Архив обновления содержит ссылку или специальный файл")
if member.flag_bits & 0x1:
raise UpdateError("Зашифрованные архивы обновления не поддерживаются")
if not member.is_dir():
total_size += member.file_size
if total_size > MAX_UNPACKED_BYTES:
raise UpdateError("Распакованный размер обновления превышает предел")
if relative_path.as_posix() == executable_name:
if member.is_dir():
raise UpdateError("Исполняемый файл обновления имеет неверный тип")
executable_member = member
if executable_member is None:
raise UpdateError("В архиве обновления не найден исполняемый файл")
with archive.open(executable_member, "r") as executable:
_validate_executable_signature(executable.read(4), executable_name)
return members
def _validate_update_archive(path: Path, executable_name: str) -> None:
try:
with zipfile.ZipFile(path, "r") as archive:
_validated_archive_members(archive, executable_name)
bad_member = archive.testzip()
except (OSError, zipfile.BadZipFile, RuntimeError) as exc:
raise UpdateError(f"Не удалось проверить ZIP-архив обновления: {exc}") from exc
if bad_member is not None:
raise UpdateError(f"Повреждён файл в архиве обновления: {bad_member}")
def _extract_update_archive(
archive_path: Path,
staging_directory: Path,
executable_name: str,
) -> Path:
try:
staging_directory.mkdir()
with zipfile.ZipFile(archive_path, "r") as archive:
members = _validated_archive_members(archive, executable_name)
for member in members:
relative_path = PurePosixPath(member.filename.rstrip("/"))
destination = staging_directory.joinpath(*relative_path.parts)
if member.is_dir():
destination.mkdir(parents=True, exist_ok=True)
continue
destination.parent.mkdir(parents=True, exist_ok=True)
with (
archive.open(member, "r") as source,
destination.open("xb") as output,
):
shutil.copyfileobj(source, output)
executable_path = staging_directory / executable_name
with executable_path.open("rb") as executable:
_validate_executable_signature(executable.read(4), executable_name)
if not executable_name.casefold().endswith(".exe"):
executable_path.chmod(0o755)
return executable_path
except Exception:
shutil.rmtree(staging_directory, ignore_errors=True)
raise
def download_update(
info: UpdateInfo,
target_directory: Path,
*,
timeout: float = DOWNLOAD_TIMEOUT_SECONDS,
opener: Callable[..., BinaryIO] | None = None,
progress: Callable[[int], None] | None = None,
cancelled: Callable[[], bool] | None = None,
) -> Path:
"""Скачать и проверить ZIP-пакет обновления рядом с приложением."""
target_directory = target_directory.resolve()
if not target_directory.is_dir():
raise UpdateError("Каталог приложения не найден")
if not os.access(target_directory, os.W_OK):
raise UpdateError("Нет прав на запись в каталог приложения")
temporary_path = target_directory / (
f".{info.asset_name}.download-{uuid.uuid4().hex}"
)
request = Request(
info.download_url,
headers={
"Accept": "application/octet-stream",
"User-Agent": f"PlateConfigAppUpdater/{info.version}",
},
)
open_request = opener or urlopen
received = 0
try:
with open_request(request, timeout=timeout) as response:
final_url = urlparse(response.geturl())
if final_url.scheme != "https" or final_url.hostname != UPDATE_HOST:
raise UpdateError("Загрузка перенаправлена на неизвестный сервер")
if final_url.path.rstrip("/") == "/user/login":
raise UpdateError("Release-артефакты недоступны без авторизации")
content_length = response.headers.get("Content-Length")
total = int(content_length) if content_length else 0
if total > MAX_DOWNLOAD_BYTES:
raise UpdateError("Размер обновления превышает допустимый предел")
with temporary_path.open("wb") as output:
while True:
if cancelled is not None and cancelled():
raise UpdateCancelled("Загрузка обновления отменена")
chunk = response.read(DOWNLOAD_CHUNK_SIZE)
if not chunk:
break
received += len(chunk)
if received > MAX_DOWNLOAD_BYTES:
raise UpdateError("Размер обновления превышает допустимый предел")
output.write(chunk)
if progress is not None and total > 0:
progress(min(received * 100 // total, 100))
output.flush()
os.fsync(output.fileno())
if received == 0:
raise UpdateError("Сервер вернул пустой файл обновления")
if total > 0 and received != total:
raise UpdateError("Файл обновления скачан не полностью")
executable_name = archive_executable_name(info.asset_name)
_validate_update_archive(temporary_path, executable_name)
if progress is not None:
progress(100)
return temporary_path
except Exception:
temporary_path.unlink(missing_ok=True)
raise
def current_executable() -> Path:
if not getattr(sys, "frozen", False):
raise UpdateError("Самообновление доступно только в собранной версии")
return Path(sys.executable).resolve()
def signal_update_ready() -> None:
ready_file = os.environ.pop(UPDATE_READY_ENV, None)
if ready_file:
try:
Path(ready_file).write_text(str(os.getpid()), encoding="ascii")
except OSError:
pass
def _write_windows_updater(script_path: Path) -> None:
script = r'''param(
[int]$ApplicationProcessId,
[string]$Staging,
[string]$Target,
[string]$Backup,
[string]$ReadyFile
)
$ErrorActionPreference = "Stop"
$applicationDirectory = Split-Path -Parent $Target
$errorLog = Join-Path $applicationDirectory "update-error.log"
$installed = [System.Collections.Generic.List[string]]::new()
$backedUp = [System.Collections.Generic.List[string]]::new()
$restarted = $null
try {
Wait-Process -Id $ApplicationProcessId -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $Backup | Out-Null
$entries = @(Get-ChildItem -LiteralPath $Staging -Force)
foreach ($entry in $entries) {
$destination = Join-Path $applicationDirectory $entry.Name
if (Test-Path -LiteralPath $destination) {
$deadline = (Get-Date).AddSeconds(30)
while ($true) {
try {
Move-Item -LiteralPath $destination -Destination $Backup -Force -ErrorAction Stop
break
} catch {
if ((Get-Date) -ge $deadline) { throw }
Start-Sleep -Milliseconds 250
}
}
$backedUp.Add($entry.Name)
}
Move-Item -LiteralPath $entry.FullName -Destination $destination -Force -ErrorAction Stop
$installed.Add($entry.Name)
}
if (-not (Test-Path -LiteralPath $Target -PathType Leaf)) {
throw "В пакете обновления отсутствует исполняемый файл"
}
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
$previousReadyFile = $env:PLATE_CONFIG_UPDATE_READY_FILE
$previousResetEnvironment = $env:PYINSTALLER_RESET_ENVIRONMENT
try {
$env:PLATE_CONFIG_UPDATE_READY_FILE = $ReadyFile
$env:PYINSTALLER_RESET_ENVIRONMENT = "1"
$restarted = Start-Process -FilePath $Target -WorkingDirectory $applicationDirectory -PassThru
} finally {
$env:PLATE_CONFIG_UPDATE_READY_FILE = $previousReadyFile
$env:PYINSTALLER_RESET_ENVIRONMENT = $previousResetEnvironment
}
$deadline = (Get-Date).AddSeconds(60)
while (-not (Test-Path -LiteralPath $ReadyFile -PathType Leaf)) {
if ($restarted.HasExited) { throw "Новая версия завершилась до запуска интерфейса" }
if ((Get-Date) -ge $deadline) { throw "Новая версия не подтвердила запуск" }
Start-Sleep -Milliseconds 250
}
if ($restarted.HasExited) { throw "Новая версия завершилась сразу после запуска" }
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force
} catch {
$failure = $_
if ($restarted -and -not $restarted.HasExited) {
& taskkill.exe /PID $restarted.Id /T /F *> $null
Start-Sleep -Milliseconds 500
}
$rollbackErrors = [System.Collections.Generic.List[string]]::new()
foreach ($name in $installed) {
$destination = Join-Path $applicationDirectory $name
if (Test-Path -LiteralPath $destination) {
try {
Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction Stop
} catch {
$rollbackErrors.Add($_.ToString())
}
}
}
foreach ($name in $backedUp) {
$backupItem = Join-Path $Backup $name
$destination = Join-Path $applicationDirectory $name
if (Test-Path -LiteralPath $backupItem) {
try {
Move-Item -LiteralPath $backupItem -Destination $destination -Force -ErrorAction Stop
} catch {
$rollbackErrors.Add($_.ToString())
}
}
}
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
if ($rollbackErrors.Count -eq 0) {
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
}
@($failure.ToString()) + @($rollbackErrors) | Out-File -LiteralPath $errorLog -Encoding utf8
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
exit 1
}
'''
script_path.write_text(script, encoding="utf-8-sig", newline="\r\n")
def _write_linux_updater(script_path: Path) -> None:
script = r'''#!/bin/sh
set -eu
application_pid="$1"
staging_path="$2"
target_path="$3"
backup_path="$4"
ready_path="$5"
application_dir="$(dirname -- "$target_path")"
error_log="$application_dir/update-error.log"
installed_file="$backup_path/.installed"
backed_up_file="$backup_path/.backed_up"
new_pid=""
rollback() {
exit_code="$?"
trap - EXIT HUP INT TERM
set +e
if [ -n "$new_pid" ]; then
kill "$new_pid" 2>/dev/null
wait "$new_pid" 2>/dev/null
fi
rollback_failed=0
if [ -f "$installed_file" ]; then
while IFS= read -r name; do
rm -rf -- "$application_dir/$name" || rollback_failed=1
done < "$installed_file"
fi
if [ -f "$backed_up_file" ]; then
while IFS= read -r name; do
if [ -e "$backup_path/$name" ]; then
mv -f -- "$backup_path/$name" "$application_dir/$name" || rollback_failed=1
fi
done < "$backed_up_file"
fi
rm -f -- "$ready_path"
rm -rf -- "$staging_path"
if [ "$rollback_failed" -eq 0 ]; then
rm -rf -- "$backup_path"
fi
printf '%s\n' "Не удалось заменить приложение" >"$error_log"
rm -f -- "$0"
if [ "$exit_code" -eq 0 ]; then
exit_code=1
fi
exit "$exit_code"
}
trap rollback EXIT HUP INT TERM
while kill -0 "$application_pid" 2>/dev/null; do
sleep 0.2
done
rm -rf -- "$backup_path"
mkdir -p -- "$backup_path"
: >"$installed_file"
: >"$backed_up_file"
for source_path in "$staging_path"/*; do
name="$(basename -- "$source_path")"
destination="$application_dir/$name"
if [ -e "$destination" ]; then
mv -f -- "$destination" "$backup_path/$name"
printf '%s\n' "$name" >>"$backed_up_file"
fi
mv -f -- "$source_path" "$destination"
printf '%s\n' "$name" >>"$installed_file"
done
test -f "$target_path"
chmod 755 -- "$target_path"
rm -f -- "$ready_path"
PLATE_CONFIG_UPDATE_READY_FILE="$ready_path" PYINSTALLER_RESET_ENVIRONMENT=1 "$target_path" >/dev/null 2>&1 &
new_pid=$!
attempts=0
while [ ! -f "$ready_path" ]; do
if ! kill -0 "$new_pid" 2>/dev/null; then
exit 1
fi
attempts=$((attempts + 1))
if [ "$attempts" -ge 240 ]; then
exit 1
fi
sleep 0.25
done
if ! kill -0 "$new_pid" 2>/dev/null; then
exit 1
fi
trap - EXIT HUP INT TERM
rm -f -- "$ready_path"
rm -rf -- "$backup_path" "$staging_path"
rm -f -- "$0"
'''
script_path.write_text(script, encoding="utf-8", newline="\n")
script_path.chmod(0o700)
def launch_update(
downloaded_path: Path,
*,
executable_path: Path | None = None,
process_id: int | None = None,
system: str | None = None,
) -> None:
"""Распаковать обновление и запустить helper для замены пакета."""
archive_path = downloaded_path.resolve()
target = (executable_path or current_executable()).resolve()
if not archive_path.is_file():
raise UpdateError("Скачанный архив обновления не найден")
if not os.access(target.parent, os.W_OK):
raise UpdateError("Нет прав на замену приложения")
updater_id = uuid.uuid4().hex
application_pid = process_id or os.getpid()
system_name = (system or platform.system()).casefold()
if system_name not in {"windows", "linux"}:
raise UpdateError(f"Автообновление не поддерживает платформу {system_name}")
staging_path = target.parent / f".plate_config_update-{updater_id}"
backup_path = target.parent / f".plate_config_backup-{updater_id}"
ready_path = target.parent / f".plate_config_ready-{updater_id}"
script_path: Path | None = None
try:
_extract_update_archive(archive_path, staging_path, target.name)
archive_path.unlink()
except Exception:
shutil.rmtree(staging_path, ignore_errors=True)
raise
if system_name == "windows":
script_path = target.parent / f".plate_config_updater-{updater_id}.ps1"
try:
_write_windows_updater(script_path)
creation_flags = (
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
)
subprocess.Popen(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script_path),
str(application_pid),
str(staging_path),
str(target),
str(backup_path),
str(ready_path),
],
close_fds=True,
creationflags=creation_flags,
)
except Exception:
script_path.unlink(missing_ok=True)
shutil.rmtree(staging_path, ignore_errors=True)
raise
return
if system_name == "linux":
script_path = target.parent / f".plate_config_updater-{updater_id}.sh"
try:
_write_linux_updater(script_path)
subprocess.Popen(
[
"/bin/sh",
str(script_path),
str(application_pid),
str(staging_path),
str(target),
str(backup_path),
str(ready_path),
],
cwd=str(target.parent),
close_fds=True,
start_new_session=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
script_path.unlink(missing_ok=True)
shutil.rmtree(staging_path, ignore_errors=True)
raise
return