Files
pvemanager/backend/app/services/update_service.py
Dilshod Rasulov 11d2c06fb1 fix(updates): parse version build suffix like 1.5.2-1
_parse_version split on "." only, so "2-1" hit int() and the whole
version collapsed to (0,) — update checks ignored build bumps and could
even offer a "downgrade". Extract every numeric run via regex instead:
"1.5.2-1" -> (1, 5, 2, 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:23:23 +05:00

428 lines
16 KiB
Python
Raw 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.
"""
Update Service - Check for updates and perform system updates
"""
import os
import re
import subprocess
import asyncio
import logging
from typing import Optional, Dict, Any
from datetime import datetime
import httpx
logger = logging.getLogger(__name__)
# Путь к корню проекта (монтируется через volume или хост)
# В Docker это /project, на хосте - определяется через переменную окружения
PROJECT_ROOT = os.environ.get('PROJECT_ROOT', '/project')
VERSION_FILE = os.path.join(PROJECT_ROOT, "VERSION")
# Для fallback - путь внутри контейнера где может быть VERSION
CONTAINER_VERSION_FILE = "/app/VERSION"
# GitHub token для доступа к приватным репозиториям (опционально)
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN', None)
# Отключить проверку обновлений (для приватных репозиториев без токена)
DISABLE_UPDATE_CHECK = os.environ.get('DISABLE_UPDATE_CHECK', 'false').lower() == 'true'
# Статус обновления (in-memory)
update_status = {
"is_updating": False,
"started_at": None,
"stage": None,
"progress": 0,
"error": None,
"completed": False
}
def get_current_version() -> str:
"""Получить текущую версию из файла VERSION"""
# Сначала пробуем файл из проекта (если монтирован)
if os.path.exists(VERSION_FILE):
try:
with open(VERSION_FILE, "r") as f:
return f.read().strip()
except Exception as e:
logger.error(f"Error reading VERSION file from project: {e}")
# Fallback - читаем из контейнера
if os.path.exists(CONTAINER_VERSION_FILE):
try:
with open(CONTAINER_VERSION_FILE, "r") as f:
return f.read().strip()
except Exception as e:
logger.error(f"Error reading VERSION file from container: {e}")
return "unknown"
def is_git_available() -> bool:
"""Проверить, доступен ли git"""
try:
result = subprocess.run(
["git", "--version"],
capture_output=True,
text=True,
timeout=5
)
return result.returncode == 0
except Exception:
return False
def is_project_mounted() -> bool:
"""Проверить, смонтирован ли проект с .git"""
git_dir = os.path.join(PROJECT_ROOT, ".git")
return os.path.exists(git_dir)
def ensure_safe_directory():
"""Добавить PROJECT_ROOT в git safe.directory"""
try:
subprocess.run(
["git", "config", "--global", "--add", "safe.directory", PROJECT_ROOT],
capture_output=True,
timeout=5
)
except Exception:
pass
def configure_git_for_public_access():
"""Настроить git для работы без аутентификации с публичным репозиторием"""
try:
# Отключить запросы пароля
subprocess.run(
["git", "config", "--global", "credential.helper", ""],
cwd=PROJECT_ROOT,
capture_output=True,
timeout=5
)
# Получить текущий remote URL
remote_result = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
timeout=5
)
if remote_result.returncode == 0:
current_url = remote_result.stdout.strip()
# Если URL использует https с credentials, очистить их
if "https://" in current_url and "@" in current_url:
# Убрать credentials из URL
clean_url = current_url.split("@")[-1]
clean_url = "https://" + clean_url
subprocess.run(
["git", "remote", "set-url", "origin", clean_url],
cwd=PROJECT_ROOT,
capture_output=True,
timeout=5
)
return True
except Exception as e:
logger.warning(f"Could not configure git: {e}")
return False
CORRECT_REPO_URL = "https://git.tzim.uz/markmorado/pvemanager"
_LEGACY_URLS = {"https://git.tzim.uz/dilshod/pve_manager", "https://github.com/markmorado/pvemanager"}
def get_repository_url_from_settings():
"""Get configured repository URL from database settings"""
try:
from ..db import SessionLocal
from ..models import PanelSettings
db = SessionLocal()
try:
setting = db.query(PanelSettings).filter(PanelSettings.key == "git_repository_url").first()
if setting and setting.value and setting.value not in _LEGACY_URLS:
return setting.value
finally:
db.close()
except Exception as e:
logger.warning(f"Could not get repository URL from settings: {e}")
return CORRECT_REPO_URL
def parse_repo_url(url: str) -> tuple:
"""Parse repository URL to extract owner and repo name"""
# Remove .git suffix
url = url.replace(".git", "")
# GitHub: https://github.com/owner/repo
if "github.com" in url:
parts = url.split("github.com/")
if len(parts) == 2:
owner_repo = parts[1].split("/")
if len(owner_repo) >= 2:
return owner_repo[0], owner_repo[1], "github"
# GitLab or other Git hosting (Gitea, etc.)
# https://git.tzim.uz/markmorado/pvemanager
parts = url.split("://")
if len(parts) == 2:
path_parts = parts[1].split("/")
if len(path_parts) >= 3:
domain = path_parts[0]
owner = path_parts[1]
repo = path_parts[2]
return owner, repo, domain
return None, None, None
def _parse_version(v: str) -> tuple:
"""Parse version string into a tuple of ints for comparison.
Handles a build suffix after '-': '1.5.2-1' -> (1, 5, 2, 1), '1.0.3' -> (1, 0, 3).
Splitting on '.' alone breaks on '2-1' (int() raises), collapsing the whole
version to (0,); extracting every numeric run keeps build bumps comparable.
"""
nums = re.findall(r"\d+", v.strip())
return tuple(int(x) for x in nums) if nums else (0,)
def _is_newer(remote: str, local: str) -> bool:
"""Return True only if remote version is strictly greater than local"""
return _parse_version(remote) > _parse_version(local)
async def check_for_updates() -> Dict[str, Any]:
"""
Проверить наличие обновлений через GitHub API
Сравнивает локальную версию с удалённой
"""
current_version = get_current_version()
result = {
"current_version": current_version,
"latest_version": None,
"update_available": False,
"changelog": None,
"error": None,
"git_available": is_git_available(),
"project_mounted": is_project_mounted(),
"disabled": DISABLE_UPDATE_CHECK
}
# Если проверка обновлений отключена
if DISABLE_UPDATE_CHECK:
result["error"] = "Update check is disabled"
result["latest_version"] = current_version
return result
# Получаем URL репозитория из настроек БД
repo_url = get_repository_url_from_settings()
result["repository_url"] = repo_url
# Парсим owner/repo из URL
owner, repo, host = parse_repo_url(repo_url)
if not owner or not repo:
result["error"] = "Could not parse repository URL"
result["latest_version"] = current_version
return result
try:
# Подготавливаем заголовки для запроса
headers = {}
if GITHUB_TOKEN:
headers["Authorization"] = f"token {GITHUB_TOKEN}"
# Используем raw URL для получения файлов
async with httpx.AsyncClient(timeout=30.0) as client:
# Пробуем оба варианта: main и master
version_url = None
version_response = None
last_response = None
for branch in ["main", "master"]:
# Формируем URL в зависимости от хостинга
if host == "github":
test_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/VERSION"
else:
# Для Gitea, GitLab и других (формат: https://domain/owner/repo/raw/branch/file)
test_url = f"https://{host}/{owner}/{repo}/raw/branch/{branch}/VERSION"
test_response = await client.get(test_url, headers=headers)
last_response = test_response
if test_response.status_code == 200:
version_url = test_url
version_response = test_response
break
if version_response:
result["latest_version"] = version_response.text.strip()
elif last_response and last_response.status_code in (401, 403, 404):
result["error"] = (
f"Repository is private or not accessible (HTTP {last_response.status_code}). "
"Set DISABLE_UPDATE_CHECK=true to hide this error."
)
result["latest_version"] = current_version
return result
else:
status_code = last_response.status_code if last_response else "unknown"
result["error"] = f"Failed to fetch VERSION file: HTTP {status_code}"
result["latest_version"] = current_version
return result
# Сравнить версии
if _is_newer(result["latest_version"], current_version):
result["update_available"] = True
# Получить CHANGELOG.md (используем ту же ветку, что и для VERSION)
branch = version_url.split("/")[-2] if version_url else "main"
if host == "github":
changelog_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/CHANGELOG.md"
else:
changelog_url = f"https://{host}/{owner}/{repo}/raw/branch/{branch}/CHANGELOG.md"
changelog_response = await client.get(changelog_url, headers=headers)
if changelog_response.status_code == 200:
# Извлекаем только последнюю версию из changelog
changelog_lines = changelog_response.text.split('\n')
latest_changelog = []
in_latest_version = False
version_count = 0
for line in changelog_lines:
if line.startswith('## [v'):
version_count += 1
if version_count == 1:
in_latest_version = True
elif version_count == 2:
break
if in_latest_version:
latest_changelog.append(line)
result["changelog"] = '\n'.join(latest_changelog)
# Получить информацию о коммитах через API
if result["project_mounted"]:
try:
# Получить локальный commit hash
local_commit_result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
timeout=5
)
if local_commit_result.returncode == 0:
local_commit = local_commit_result.stdout.strip()
# Получить удаленный commit через API
commits_url = f"https://api.github.com/repos/{owner}/{repo}/commits/main"
commits_response = await client.get(commits_url)
if commits_response.status_code == 200:
remote_commit = commits_response.json()["sha"]
# Сравнить хеши
if local_commit != remote_commit:
result["commits_behind"] = 1 # Упрощенно
except Exception as e:
logger.warning(f"Could not check commits: {e}")
except httpx.TimeoutException:
result["error"] = "Timeout while checking for updates"
except httpx.HTTPError as e:
result["error"] = f"HTTP error: {str(e)}"
logger.error(f"HTTP error checking for updates: {e}")
except Exception as e:
result["error"] = str(e)
logger.error(f"Error checking for updates: {e}")
return result
def get_update_status() -> Dict[str, Any]:
"""Получить текущий статус обновления"""
return update_status.copy()
async def perform_update() -> Dict[str, Any]:
"""
Выполнить обновление системы.
Записывает файл-триггер .update_trigger в PROJECT_ROOT.
Watchdog-сервис на хосте (pvemanager-update.service / update_host.sh)
обнаруживает триггер и выполняет:
git pull → docker compose down → docker compose build --no-cache app → docker compose up -d
Такой подход необходим, потому что «docker compose down» убивает сам контейнер
(и любой nohup-процесс внутри него) прежде, чем успевает завершиться rebuild.
"""
global update_status
if update_status["is_updating"]:
return {"success": False, "error": "Update already in progress"}
if not is_project_mounted():
return {
"success": False,
"error": (
"Project directory not mounted. "
"Make sure the compose volume '.:/project:rw' is present "
"and pvemanager-update.service is running on the host."
),
}
trigger_path = os.path.join(PROJECT_ROOT, ".update_trigger")
try:
with open(trigger_path, "w") as f:
f.write(datetime.now().isoformat())
logger.info(f"Update trigger written to {trigger_path}")
except OSError as e:
logger.error(f"Failed to write update trigger: {e}")
return {"success": False, "error": f"Cannot write update trigger: {e}"}
# Обновляем in-memory статус — UI-баннер будет показан сразу
update_status = {
"is_updating": True,
"started_at": datetime.now().isoformat(),
"stage": "restarting",
"progress": 80,
"error": None,
"completed": False,
}
return {
"success": True,
"message": (
"Update triggered. The host watchdog (pvemanager-update.service) "
"will now run git pull, rebuild and restart the panel."
),
}
def reset_update_status():
"""Сбросить статус обновления"""
global update_status
update_status = {
"is_updating": False,
"started_at": None,
"stage": None,
"progress": 0,
"error": None,
"completed": False
}