Files
2026-07-17 12:57:18 +03:00

1344 lines
58 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Модуль автообновления
"""
import os
import wesp_runtime_env
wesp_runtime_env.apply_kiosk_headless_env()
from app.services.auto_update_db import (
load_auto_update_dict,
recipes_db_path,
upsert_auto_update_dict,
)
import requests
import json
import time
import threading
import shutil
import zipfile
import subprocess
import sys
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
import logging
from urllib.parse import urlparse, urlunparse
import base64
import hashlib
import shlex
# Обязательный импорт cryptography
try:
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
except ImportError:
# logger еще не определен, используем print
print("❌ КРИТИЧЕСКАЯ ОШИБКА: Библиотека cryptography обязательна для работы автообновления!")
print(" Установите: pip install cryptography")
raise ImportError("Библиотека cryptography обязательна. Установите: pip install cryptography")
# Опциональный импорт keyring для системных хранилищ секретов
try:
import keyring
KEYRING_AVAILABLE = True
except ImportError:
keyring = None # type: ignore[assignment,misc]
KEYRING_AVAILABLE = False
# Настраиваем logging ПЕРЕД использованием logger
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s | %(levelname)-8s | %(name)-20s | %(funcName)-20s | %(lineno)-4d | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
def wesp_data_config_path(script_dir: Optional[str] = None) -> str:
"""Путь к data/config.json (единый с Flask и sync_client_state)."""
base = script_dir or os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, "data", "config.json")
# Сообщение про keyring — только при загрузке секретов из файла (см. _load_secrets).
class SecretEncryption:
"""Класс для шифрования/расшифровки секретов с использованием соли"""
@staticmethod
def _collect_decrypt_passwords() -> list:
"""Уникальные ключи для расшифровки (порядок: keyring → WESP_MASTER_PASSWORD → SECRET_KEY)."""
seen = set()
passwords = []
def _add(candidate):
if isinstance(candidate, str) and len(candidate) >= 8 and candidate not in seen:
seen.add(candidate)
passwords.append(candidate)
if KEYRING_AVAILABLE:
try:
_add(keyring.get_password("WESP_AutoUpdate", "master_password"))
except Exception as e:
logger.warning("Системное хранилище секретов недоступно: %s", e)
_add((os.environ.get("WESP_MASTER_PASSWORD") or "").strip() or None)
try:
from config import get_config_class
cls = get_config_class()
sk = getattr(cls, "SECRET_KEY", None)
_add(sk if isinstance(sk, str) else None)
except Exception as exc:
logger.debug("SECRET_KEY как ключ расшифровки недоступен: %s", exc)
return passwords
@staticmethod
def _get_master_password() -> str:
"""
Первый доступный ключ шифрования (для encrypt без явного password).
При расшифровке decrypt_secret перебирает все источники через _collect_decrypt_passwords.
"""
passwords = SecretEncryption._collect_decrypt_passwords()
if passwords:
return passwords[0]
logger.error("КРИТИЧЕСКАЯ ОШИБКА: не задан ключ для расшифровки секретов Gitea!")
logger.error("Задайте WESP_SECRET_KEY (рекомендуется), WESP_MASTER_PASSWORD или keyring; либо сохраните секреты в админке.")
raise ValueError("Не задан ключ для расшифровки секретов Gitea")
@staticmethod
def _get_encryption_key(password: Optional[str] = None, salt: Optional[bytes] = None) -> bytes:
"""
Генерирует ключ шифрования из пароля и соли
Использует PBKDF2 для создания ключа
"""
if password is None:
password = SecretEncryption._get_master_password()
if salt is None:
# Генерируем соль из системной информации (стабильная для одного сервера)
system_info = f"{os.getenv('COMPUTERNAME', '')}{os.getenv('USERNAME', '')}{os.path.expanduser('~')}"
salt = hashlib.sha256(system_info.encode()).digest()[:16]
# Используем PBKDF2 для создания ключа
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
return key
@staticmethod
def encrypt_secret(plaintext: str, password: Optional[str] = None) -> str:
"""Шифрует секрет с использованием соли (обязательно)"""
if not plaintext:
raise ValueError("Нельзя шифровать пустое значение")
key = SecretEncryption._get_encryption_key(password)
fernet = Fernet(key)
encrypted = fernet.encrypt(plaintext.encode())
return base64.urlsafe_b64encode(encrypted).decode()
@staticmethod
def decrypt_secret(ciphertext: str, password: Optional[str] = None) -> str:
"""Расшифровывает секрет (обязательно зашифрованный)."""
if not ciphertext:
raise ValueError("Нельзя расшифровать пустое значение")
passwords = [password] if password is not None else SecretEncryption._collect_decrypt_passwords()
if not passwords:
raise ValueError("Не задан ключ для расшифровки секретов Gitea")
last_err = None
encrypted = base64.urlsafe_b64decode(ciphertext.encode())
for pwd in passwords:
try:
key = SecretEncryption._get_encryption_key(pwd)
fernet = Fernet(key)
decrypted = fernet.decrypt(encrypted)
return decrypted.decode()
except Exception as e:
last_err = e
logger.error("Ошибка расшифровки (перепробованы все ключи): %s", last_err)
raise ValueError(
f"Не удалось расшифровать секрет. Сохраните секреты заново в админке или проверьте WESP_SECRET_KEY: {last_err}"
)
@staticmethod
def is_encrypted(value: str) -> bool:
"""Проверяет, зашифровано ли значение (Fernet или base64(Fernet) из encrypt_secret)."""
if not isinstance(value, str):
return False
s = value.strip()
if len(s) < 24:
return False
try:
decoded = base64.urlsafe_b64decode(s.encode())
if len(decoded) >= 16:
return True
except Exception:
pass
return s.startswith("gAAAAAB") and len(s) > 50
class AutoUpdater:
"""Менеджер автообновления через Gitea API с защитой данных"""
def __init__(self):
script_dir = os.path.dirname(os.path.abspath(__file__))
self.base_dir = script_dir
self.data_dir = os.path.join(script_dir, "data")
os.makedirs(self.data_dir, exist_ok=True)
self.config_file = wesp_data_config_path(script_dir)
legacy_root_cfg = os.path.join(script_dir, "config.json")
if not os.path.exists(self.config_file) and os.path.exists(legacy_root_cfg):
try:
shutil.copy2(legacy_root_cfg, self.config_file)
logger.info("Скопирован legacy config.json из корня в data/config.json")
except OSError as exc:
logger.warning("Не удалось скопировать config.json в data/: %s", exc)
self.is_running = False
self.update_thread = None
self.is_updating = False
self.pending_release: Optional[Dict] = None
self.update_progress: Optional[Dict] = None
self.last_check_at: Optional[datetime] = None
self._state_lock = threading.Lock()
self._last_forced_check_mono: float = 0.0
self.restart_cmd = self._resolve_restart_cmd()
self.config = self.load_config()
self.update_config = self.config.get('auto_update', {})
if not isinstance(self.update_config, dict):
self.update_config = {}
try:
_ci = int(self.update_config.get('check_interval_sec', 3600) or 3600)
except (TypeError, ValueError):
_ci = 3600
self.check_interval = max(60, min(86400, _ci))
self.enabled = self.update_config.get('enabled', False)
self.gitea_url = ""
self.gitea_owner = ""
self.gitea_repo = ""
self.gitea_token = ""
self.gitea_username = ""
self.gitea_password = ""
secrets = self._load_secrets()
self.gitea_url = self.update_config.get('gitea_url', '') or secrets.get('gitea_url', '')
self.gitea_owner = self.update_config.get('gitea_owner', '') or secrets.get('gitea_owner', '')
self.gitea_repo = self.update_config.get('gitea_repo', '') or secrets.get('gitea_repo', '')
self.gitea_token = secrets.get('gitea_token', '') or self.update_config.get('gitea_token', '')
self.gitea_username = secrets.get('gitea_username', '') or self.update_config.get('gitea_username', '')
self.gitea_password = secrets.get('gitea_password', '') or self.update_config.get('gitea_password', '')
repository_url = self.update_config.get('repository_url', '') or secrets.get('repository_url', '')
if repository_url:
self._parse_repository_url(repository_url)
self.current_version = self._resolve_current_version()
self.protected_files = [
'data/recipes.db',
'data/recipes.db-shm',
'data/recipes.db-wal',
'data/reports.db',
'data/reports.db-shm',
'data/reports.db-wal',
'data/update_state.json',
'data/.pending_restart.json',
'credentials.json',
'calibration_factor.json',
'weight_0.json',
'gitea_secrets.json', # Секреты Gitea
]
self.protected_folders = [
'__pycache__',
'.git',
'backups',
'temp_updates',
'.secret',
'*.db-shm',
'*.db-wal',
]
# Файлы, которые обновляются с умным слиянием
self.merge_files = [
'config.json'
]
logger.info(
"AutoUpdater: версия %s, проверка %s, Gitea %s",
self.current_version,
"включена" if self.enabled else "выключена",
f"{self.gitea_url}/{self.gitea_owner}/{self.gitea_repo}" if self.gitea_url else "не настроен",
)
@staticmethod
def _resolve_restart_cmd() -> str:
cmd = (os.environ.get("WESP_UPDATE_RESTART_CMD") or "").strip()
if cmd:
return cmd
try:
from config import get_config_class
return str(getattr(get_config_class(), "UPDATE_RESTART_CMD", "") or "").strip()
except Exception:
return ""
def _resolve_current_version(self) -> str:
v = str(self.config.get("version") or "").strip()
if v:
return v
try:
from config import get_config_class
cls = get_config_class()
sv = str(getattr(cls, "SYNC_CLIENT_VERSION", "") or "").strip()
if sv:
return sv
except Exception as exc:
logger.debug("SYNC_CLIENT_VERSION недоступен: %s", exc)
return (os.getenv("WESP_SYNC_CLIENT_VERSION", "2.0.0") or "2.0.0").strip() or "2.0.0"
def refresh_check_cache(self, release_info: Optional[Dict]) -> None:
with self._state_lock:
self.last_check_at = datetime.now(timezone.utc)
self.pending_release = dict(release_info) if release_info else None
def get_pending_update(self) -> Optional[Dict]:
with self._state_lock:
if not self.pending_release:
return None
return dict(self.pending_release)
def clear_pending_after_install(self) -> None:
with self._state_lock:
self.pending_release = None
def _set_update_progress(
self,
stage: str,
message: str,
percent: int,
*,
detail: Optional[str] = None,
target_version: Optional[str] = None,
) -> None:
with self._state_lock:
self.update_progress = {
"stage": stage,
"message": message,
"percent": max(0, min(100, int(percent))),
"detail": detail,
"target_version": target_version,
}
def get_update_progress(self) -> Optional[Dict]:
with self._state_lock:
if not self.update_progress:
return None
return dict(self.update_progress)
def _clear_update_progress(self) -> None:
with self._state_lock:
self.update_progress = None
def _parse_repository_url(self, url: str):
"""Парсит полный URL репозитория Gitea"""
try:
parsed = urlparse(url)
# Формат: https://git.example.com/owner/repo
parts = parsed.path.strip('/').split('/')
if len(parts) >= 2:
if not self.gitea_url:
self.gitea_url = f"{parsed.scheme}://{parsed.netloc}"
if not self.gitea_owner:
self.gitea_owner = parts[0]
if not self.gitea_repo:
self.gitea_repo = parts[1]
logger.info(f"Распарсен URL репозитория: {self.gitea_url}/{self.gitea_owner}/{self.gitea_repo}")
except Exception as e:
logger.warning(f"Ошибка парсинга URL репозитория: {e}")
def load_config(self) -> Dict:
"""config.json (прочие ключи) + auto_update из recipes.db, если БД есть; иначе legacy из JSON."""
cfg: Dict = {}
try:
if os.path.exists(self.config_file):
with open(self.config_file, "r", encoding="utf-8") as f:
raw = json.load(f)
if isinstance(raw, dict):
cfg = raw
except Exception as e:
logger.error(f"Ошибка загрузки config.json: {e}")
au_sql = load_auto_update_dict(self.base_dir)
if au_sql is not None:
cfg["auto_update"] = au_sql
elif recipes_db_path(self.base_dir).is_file():
cfg["auto_update"] = {}
elif not isinstance(cfg.get("auto_update"), dict):
cfg["auto_update"] = {}
return cfg
def _load_secrets(self) -> Dict:
"""
Безопасная загрузка секретов с приоритетом:
1. Переменные окружения (наивысший приоритет)
2. Файл .secret/gitea_secrets.json
3. config.json (низший приоритет, только для публичных настроек)
"""
secrets = {}
# 1. Переменные окружения (наивысший приоритет)
env_mapping = {
'GITEA_URL': 'gitea_url',
'GITEA_OWNER': 'gitea_owner',
'GITEA_REPO': 'gitea_repo',
'GITEA_TOKEN': 'gitea_token',
'GITEA_USERNAME': 'gitea_username',
'GITEA_PASSWORD': 'gitea_password',
'GITEA_REPOSITORY_URL': 'repository_url',
}
for env_key, secret_key in env_mapping.items():
value = os.getenv(env_key, '')
if value:
secrets[secret_key] = value
logger.debug(f"Секрет загружен из переменной окружения: {secret_key}")
# 2. Файл .secret/gitea_secrets.json
secret_file = os.path.join(self.base_dir, '.secret', 'gitea_secrets.json')
if os.path.exists(secret_file):
try:
# Устанавливаем права только для чтения владельцем
if os.name != 'nt': # Unix-like системы
os.chmod(secret_file, 0o600)
with open(secret_file, 'r', encoding='utf-8') as f:
file_secrets = json.load(f)
# Объединяем, но не перезаписываем переменные окружения
skipped = 0
for key, value in file_secrets.items():
if key not in secrets and value:
if isinstance(value, str):
if not SecretEncryption.is_encrypted(value):
logger.warning(
"Пропуск %s в gitea_secrets.json: значение не зашифровано. "
"Сохраните секреты через админку.",
key,
)
skipped += 1
continue
try:
decrypted = SecretEncryption.decrypt_secret(value)
secrets[key] = decrypted
logger.debug("Секрет расшифрован и загружен: %s", key)
except Exception as e:
logger.warning(
"Пропуск %s в gitea_secrets.json (не расшифровывается): %s. "
"Задайте новые секреты в админке.",
key,
e,
)
skipped += 1
else:
secrets[key] = value
logger.debug("Значение загружено из файла: %s", key)
if skipped:
logger.info(
"gitea_secrets.json: пропущено %s пол(я/ей). "
"Админка → Обновления (Gitea) → сохраните токен или включите «Удалить секреты».",
skipped,
)
except Exception as e:
logger.warning("Ошибка загрузки секретов из файла: %s", e)
# 3. config.json уже загружен в self.update_config, но секреты там не должны быть
return secrets
def save_config(self, config: Dict):
"""Пишет data/config.json без ключа auto_update; auto_update — в recipes.db."""
try:
forbidden_keys = ["master_password", "WESP_MASTER_PASSWORD", "wesp_master_password"]
for key in forbidden_keys:
if key in config:
logger.error(f"КРИТИЧЕСКАЯ ОШИБКА: Попытка сохранить мастер-пароль в config.json!")
logger.error(" Мастер-пароль НИКОГДА не должен сохраняться в файлы!")
raise ValueError(f"Попытка сохранить мастер-пароль в конфигурацию: {key}")
cfg_out = dict(config) if isinstance(config, dict) else {}
au = cfg_out.pop("auto_update", None)
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(cfg_out, f, ensure_ascii=False, indent=2)
if isinstance(au, dict):
try:
upsert_auto_update_dict(self.base_dir, au)
except Exception as e:
logger.error(f"Ошибка записи auto_update в SQLite: {e}", exc_info=True)
except Exception as e:
logger.error(f"Ошибка сохранения конфигурации: {e}")
def _get_headers(self) -> Dict[str, str]:
"""Получение заголовков для запросов к Gitea API"""
headers = {
'Accept': 'application/json',
'User-Agent': 'WESP-AutoUpdater/1.0'
}
# Приоритет: токен > логин/пароль > без аутентификации
if self.gitea_token:
headers['Authorization'] = f'token {self.gitea_token}'
elif self.gitea_username and self.gitea_password:
import base64
credentials = f"{self.gitea_username}:{self.gitea_password}"
encoded = base64.b64encode(credentials.encode()).decode()
headers['Authorization'] = f'Basic {encoded}'
return headers
def check_for_updates(self) -> Optional[Dict]:
"""
Проверка наличия обновлений через Gitea API
Возвращает информацию о последнем релизе, если версия новее текущей
"""
if not self.enabled:
return None
if not self.gitea_url or not self.gitea_owner or not self.gitea_repo:
logger.warning("Настройки Gitea не заполнены")
self.refresh_check_cache(None)
return None
try:
# Gitea API: GET /api/v1/repos/{owner}/{repo}/releases
api_url = f"{self.gitea_url}/api/v1/repos/{self.gitea_owner}/{self.gitea_repo}/releases"
logger.info(f"Проверка обновлений: {api_url}")
response = requests.get(
api_url,
headers=self._get_headers(),
timeout=10
)
if response.status_code != 200:
body_preview = (response.text or "")[:200]
if response.status_code == 404:
logger.error(
"Gitea API 404: %s — репозиторий не найден или нет доступа (проверьте owner/repo и токен API)",
api_url,
)
else:
logger.error("Ошибка API Gitea: %s%s", response.status_code, body_preview)
self.refresh_check_cache(None)
return None
releases = response.json()
if not releases:
logger.info("Релизов не найдено")
self.refresh_check_cache(None)
return None
# Берем последний релиз (первый в списке)
latest_release = releases[0]
latest_version = latest_release.get('tag_name', '').lstrip('v')
logger.info(f"Последний релиз: {latest_version}, текущая версия: {self.current_version}")
# Сравнение версий
if self._compare_versions(latest_version, self.current_version) > 0:
logger.info(f"✨ Найдено обновление: {latest_version}")
info = {
'version': latest_version,
'tag_name': latest_release.get('tag_name', ''),
'name': latest_release.get('name', ''),
'body': latest_release.get('body', ''),
'published_at': latest_release.get('published_at', ''),
'assets': latest_release.get('assets', [])
}
self.refresh_check_cache(info)
return info
else:
logger.info("Установлена актуальная версия")
self.refresh_check_cache(None)
return None
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка сети при проверке обновлений: {e}")
self.refresh_check_cache(None)
return None
except Exception as e:
logger.error(f"Ошибка при проверке обновлений: {e}", exc_info=True)
self.refresh_check_cache(None)
return None
def _compare_versions(self, v1: str, v2: str) -> int:
"""
Сравнение версий (например, "1.3.1" vs "1.3.0")
Возвращает: 1 если v1 > v2, -1 если v1 < v2, 0 если равны
"""
try:
parts1 = [int(x) for x in v1.split('.')]
parts2 = [int(x) for x in v2.split('.')]
# Дополняем нулями до одинаковой длины
max_len = max(len(parts1), len(parts2))
parts1.extend([0] * (max_len - len(parts1)))
parts2.extend([0] * (max_len - len(parts2)))
for p1, p2 in zip(parts1, parts2):
if p1 > p2:
return 1
elif p1 < p2:
return -1
return 0
except Exception:
# Fallback: строковое сравнение
return 1 if v1 > v2 else (-1 if v1 < v2 else 0)
def _fix_download_url_host(self, url: str) -> str:
"""Подменить хост browser_download_url на gitea_url из настроек (ROOT_URL ≠ API URL)."""
configured = (self.gitea_url or "").strip().rstrip("/")
if not configured or not url:
return url
cfg = urlparse(configured)
cur = urlparse(url)
if not cfg.netloc or cfg.netloc == cur.netloc:
return url
return urlunparse(
(cfg.scheme or cur.scheme, cfg.netloc, cur.path, cur.params, cur.query, cur.fragment)
)
def _asset_download_urls(self, zip_asset: Dict) -> List[str]:
"""Кандидаты URL для скачивания ZIP (API + browser, с исправленным хостом)."""
urls: List[str] = []
seen: set = set()
asset_id = zip_asset.get("id")
base = (self.gitea_url or "").rstrip("/")
if asset_id and base:
urls.append(
f"{base}/api/v1/repos/{self.gitea_owner}/{self.gitea_repo}/releases/assets/{asset_id}"
)
browser = (zip_asset.get("browser_download_url") or "").strip()
if browser:
urls.append(browser)
fixed = self._fix_download_url_host(browser)
if fixed != browser:
urls.append(fixed)
out: List[str] = []
for u in urls:
if u and u not in seen:
seen.add(u)
out.append(u)
return out
def download_update(self, release_info: Dict) -> Optional[str]:
"""
Скачивание архива обновления
Возвращает путь к скачанному файлу или None
"""
try:
# Ищем ZIP архив в assets релиза
zip_asset = None
for asset in release_info.get('assets', []):
if asset.get('name', '').endswith('.zip'):
zip_asset = asset
break
if not zip_asset:
logger.error("ZIP архив не найден в релизе")
return None
download_urls = self._asset_download_urls(zip_asset)
if not download_urls:
logger.error("Не удалось определить URL для скачивания ZIP")
return None
temp_dir = os.path.join(self.base_dir, 'temp_updates')
os.makedirs(temp_dir, exist_ok=True)
zip_path = os.path.join(temp_dir, f"update_{release_info['version']}.zip")
target_version = str(release_info.get("version") or "")
response = None
total_urls = len(download_urls)
for idx, download_url in enumerate(download_urls, start=1):
self._set_update_progress(
"download",
"Скачивание обновления…",
12 + int(3 * idx),
detail=f"Способ {idx} из {total_urls}",
target_version=target_version,
)
logger.info(f"Скачивание обновления: {download_url}")
try:
resp = requests.get(
download_url,
headers=self._get_headers(),
timeout=300,
stream=True,
allow_redirects=True,
)
except requests.exceptions.RequestException as exc:
logger.warning("Ошибка сети при скачивании %s: %s", download_url, exc)
continue
if resp.status_code == 200:
response = resp
break
logger.warning("Скачивание %s: HTTP %s", download_url, resp.status_code)
if response is None:
logger.error(
"Не удалось скачать обновление (пробовали %s URL). "
"Проверьте Gitea URL в админке и доступность ZIP в релизе.",
len(download_urls),
)
return None
total_bytes = int(response.headers.get("content-length") or 0)
downloaded = 0
with open(zip_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if not chunk:
continue
f.write(chunk)
downloaded += len(chunk)
if total_bytes > 0:
frac = downloaded / total_bytes
pct = 18 + int(27 * frac)
detail = f"{downloaded // 1024} КБ из {total_bytes // 1024} КБ"
else:
pct = 18 + min(27, downloaded // (512 * 1024))
detail = f"Загружено {downloaded // 1024} КБ"
self._set_update_progress(
"download",
"Скачивание обновления…",
pct,
detail=detail,
target_version=target_version,
)
logger.info(f"Обновление скачано: {zip_path}")
return zip_path
except Exception as e:
logger.error(f"Ошибка при скачивании обновления: {e}", exc_info=True)
return None
def create_backup(self) -> Optional[str]:
"""Создание резервной копии перед обновлением"""
try:
backups_dir = os.path.join(self.base_dir, 'backups')
os.makedirs(backups_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_name = f"backup_{self.current_version}_{timestamp}"
backup_path = os.path.join(backups_dir, backup_name)
logger.info(f"Создание резервной копии: {backup_path}")
self._set_update_progress("backup", "Создание резервной копии…", 8)
# Копируем все файлы, кроме исключенных
def ignore_func(src, names):
ignored = []
for name in names:
# Исключаем папки
if name in ['__pycache__', '.git', 'backups', 'temp_updates', '.secret']:
ignored.append(name)
# Исключаем файлы БД
elif name.endswith('.db') or name.endswith('.db-shm') or name.endswith('.db-wal'):
ignored.append(name)
return ignored
shutil.copytree(
self.base_dir,
backup_path,
ignore=ignore_func
)
logger.info(f"Резервная копия создана: {backup_path}")
self._set_update_progress("backup", "Резервная копия создана", 15)
return backup_path
except Exception as e:
logger.error(f"Ошибка создания резервной копии: {e}", exc_info=True)
return None
def _should_protect_file(self, file_path: str) -> bool:
"""Проверка, нужно ли защищать файл от обновления"""
filename = os.path.basename(file_path)
norm_path = file_path.replace("\\", "/")
try:
rel = os.path.relpath(file_path, self.base_dir).replace("\\", "/")
except ValueError:
rel = norm_path
if rel in self.protected_files or filename in self.protected_files:
return True
for pattern in self.protected_files:
if pattern.startswith("*."):
if filename.endswith(pattern[1:]):
return True
elif pattern in (rel, norm_path):
return True
if filename.endswith((".db", ".db-shm", ".db-wal")):
return True
if rel.startswith("data/") and filename.endswith((".db", ".db-shm", ".db-wal")):
return True
for folder in self.protected_folders:
if folder.startswith("*."):
continue
parts = norm_path.split("/")
if folder in parts:
return True
return False
def _restore_from_backup(self, backup_path: Optional[str]) -> None:
if not backup_path or not os.path.isdir(backup_path):
return
logger.warning("Восстановление файлов из резервной копии: %s", backup_path)
try:
for root, dirs, files in os.walk(backup_path):
dirs[:] = [
d
for d in dirs
if d not in ("__pycache__", ".git", "backups", "temp_updates", ".secret")
]
for file in files:
src = os.path.join(root, file)
rel = os.path.relpath(src, backup_path)
dst = os.path.join(self.base_dir, rel)
if self._should_protect_file(dst):
continue
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
logger.info("Восстановление из резервной копии завершено")
except Exception as exc:
logger.error("Ошибка восстановления из резервной копии: %s", exc, exc_info=True)
def _write_pending_restart(
self,
backup_path: str,
target_version: str,
previous_version: str,
) -> None:
listen = os.environ.get("WESP_LISTEN", "0.0.0.0:80")
host_port = listen.split(":")[-1] if ":" in listen else "80"
health_url = os.environ.get(
"WESP_HEALTH_URL",
f"http://127.0.0.1:{host_port}/api/health",
)
payload = {
"backup_path": backup_path,
"target_version": target_version,
"previous_version": previous_version,
"health_url": health_url,
"listen": listen,
}
path = os.path.join(self.base_dir, "data", ".pending_restart.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
f.write("\n")
def _install_deps_and_verify(self, backup_path: str, target_version: str) -> bool:
import importlib
from pathlib import Path
import install_deps
importlib.reload(install_deps)
from app.services.update_state_store import mark_failed
from app.services.update_verify import (
install_requirements_from_package,
run_smoke_test,
wheelhouse_covers_requirements,
)
req = install_deps.production_requirements_path(self.base_dir)
wheels = Path(self.base_dir) / "vendor" / "wheels"
ok, missing = wheelhouse_covers_requirements(req, wheels)
if not ok:
detail = ", ".join(missing[:8])
msg = "В пакете нет зависимостей"
logger.error("%s: %s", msg, detail)
self._set_update_progress("error", msg, 0, detail=detail, target_version=target_version)
mark_failed(self.base_dir, message=msg, stage="deps", detail=detail)
self._restore_from_backup(backup_path)
return False
self._set_update_progress(
"deps",
"Установка зависимостей из пакета…",
88,
target_version=target_version,
)
py = install_deps.install_python_executable(self.base_dir)
logger.info("OTA pip: %s", py)
try:
install_requirements_from_package(self.base_dir)
except install_deps.UpdateDepsError as exc:
msg = "Не удалось установить зависимости из пакета"
logger.error("%s: %s", msg, exc)
self._set_update_progress("error", msg, 0, detail=str(exc), target_version=target_version)
mark_failed(self.base_dir, message=msg, stage="deps", detail=str(exc))
self._restore_from_backup(backup_path)
return False
self._set_update_progress(
"verify",
"Проверка запуска…",
94,
target_version=target_version,
)
from app.services.update_verify import run_smoke_test
smoke_ok, smoke_msg = run_smoke_test(
self.base_dir,
python_exe=install_deps.install_python_executable(self.base_dir),
)
if not smoke_ok:
msg = "Проверка запуска не пройдена"
logger.error("%s: %s", msg, smoke_msg)
self._set_update_progress("error", msg, 0, detail=smoke_msg[:200], target_version=target_version)
mark_failed(self.base_dir, message=msg, stage="verify", detail=smoke_msg[:200])
self._restore_from_backup(backup_path)
return False
return True
def _run_restart_cmd(self) -> None:
cmd = (self.restart_cmd or "").strip()
if not cmd:
default_script = os.path.join(self.base_dir, "scripts", "post_update.sh")
if os.path.isfile(default_script):
cmd = f'bash "{default_script}"'
if not cmd:
logger.info("WESP_UPDATE_RESTART_CMD не задан — перезапуск вручную")
self._set_update_progress("restart", "Обновление завершено — перезапустите сервер вручную", 98)
return
try:
parts = shlex.split(cmd)
if not parts:
return
self._set_update_progress("restart", "Перезапуск системы…", 98)
logger.info("Перезапуск после обновления")
subprocess.Popen(parts, close_fds=True)
except Exception as exc:
logger.error("Не удалось выполнить команду перезапуска: %s", exc, exc_info=True)
self._set_update_progress("restart", "Не удалось выполнить перезапуск автоматически", 98)
def _merge_config_json(self, old_config: Dict, new_config: Dict) -> Dict:
"""
Умное слияние config.json
Сохраняет пользовательские настройки, обновляет версию
"""
merged = old_config.copy()
# Обновляем версию
if 'version' in new_config:
merged['version'] = new_config['version']
# Добавляем новые поля из обновления (если их нет)
for key, value in new_config.items():
if key not in merged:
merged[key] = value
elif isinstance(value, dict) and isinstance(merged.get(key), dict):
# Рекурсивно сливаем вложенные словари
merged[key] = self._merge_config_json(merged[key], value)
# Сохраняем пользовательские настройки (не перезаписываем)
user_keys = [
'server_url', 'client_id', 'updated_at', 'first_bootstrap_done',
'pinned_server_id', 'gitea', 'auto_update', 'sync_compression',
'version_constraints'
]
for key in user_keys:
if key in old_config and key not in ['version']: # version обновляем
merged[key] = old_config[key]
return merged
def apply_update(self, zip_path: str, release_info: Dict) -> bool:
"""
Применение обновления
Распаковывает архив и заменяет файлы с защитой данных
"""
try:
target_version = str(release_info.get("version") or "")
logger.info(f"Применение обновления {target_version}")
self._set_update_progress(
"apply",
f"Применение обновления {target_version}…",
48,
target_version=target_version,
)
# Создаем временную папку для распаковки
extract_dir = os.path.join(self.base_dir, 'temp_updates', 'extract')
if os.path.exists(extract_dir):
shutil.rmtree(extract_dir)
os.makedirs(extract_dir, exist_ok=True)
# Распаковываем архив
logger.info(f"Распаковка архива...")
self._set_update_progress(
"extract",
"Распаковка архива…",
52,
target_version=target_version,
)
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(extract_dir)
# Находим корневую папку проекта в архиве (может быть вложена)
project_root = extract_dir
for root, dirs, files in os.walk(extract_dir):
if "run.py" in files or "proga.py" in files or "config.json" in files:
project_root = root
break
# Загружаем старый config.json для слияния
old_config = self.load_config()
new_config = None
# Копируем файлы, исключая защищенные
logger.info(f"Копирование файлов...")
copied_count = 0
skipped_count = 0
protected_count = 0
pending_copies: List[Tuple[str, str, str]] = []
for root, dirs, files in os.walk(project_root):
dirs[:] = [d for d in dirs if not any(
pattern in d for pattern in ['__pycache__', '.git', 'backups', 'temp_updates', '.secret']
)]
for file in files:
src_path = os.path.join(root, file)
rel_path = os.path.relpath(src_path, project_root)
dst_path = os.path.join(self.base_dir, rel_path)
if file == "config.json":
dst_path = self.config_file
if self._should_protect_file(dst_path):
protected_count += 1
logger.debug(f"Защищен от обновления: {rel_path}")
continue
if file == 'config.json' and os.path.exists(dst_path):
try:
with open(src_path, 'r', encoding='utf-8') as f:
new_config = json.load(f)
except Exception:
pass
continue
pending_copies.append((src_path, dst_path, rel_path))
total_files = len(pending_copies)
self._set_update_progress(
"install",
"Копирование файлов…",
55,
detail=f"0 из {total_files}" if total_files else None,
target_version=target_version,
)
for idx, (src_path, dst_path, rel_path) in enumerate(pending_copies, start=1):
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
shutil.copy2(src_path, dst_path)
copied_count += 1
if idx == total_files or idx % 25 == 0:
pct = 55 + int(33 * idx / max(total_files, 1))
self._set_update_progress(
"install",
"Копирование файлов…",
pct,
detail=f"Скопировано {idx} из {total_files}",
target_version=target_version,
)
# Применяем умное слияние config.json
if new_config:
logger.info(f"Слияние config.json...")
self._set_update_progress(
"config",
"Обновление конфигурации…",
92,
target_version=target_version,
)
merged_config = self._merge_config_json(old_config, new_config)
self.save_config(merged_config)
logger.info(f"config.json обновлен с сохранением пользовательских настроек")
logger.info(f"Обновление применено: скопировано {copied_count} файлов, пропущено {skipped_count}, защищено {protected_count}")
self._set_update_progress(
"done",
f"Обновление до версии {target_version} применено",
96,
detail=f"Скопировано {copied_count} файлов, защищено {protected_count}",
target_version=target_version,
)
# Обновляем версию в памяти
self.current_version = release_info['version']
# Очищаем временные файлы
try:
shutil.rmtree(extract_dir)
os.remove(zip_path)
except:
pass
return True
except Exception as e:
logger.error(f"Ошибка при применении обновления: {e}", exc_info=True)
return False
def update(self, release_info: Optional[Dict] = None) -> bool:
"""
Полный процесс обновления
"""
if self.is_updating:
logger.warning("Обновление уже выполняется")
return False
if not self.enabled:
logger.warning("Автообновление отключено")
return False
self.is_updating = True
target_version = str((release_info or {}).get("version") or "")
try:
self._set_update_progress(
"prepare",
"Подготовка к обновлению…",
3,
target_version=target_version or None,
)
if not release_info:
release_info = self.check_for_updates()
if not release_info:
logger.info("Обновлений не найдено")
return False
target_version = str(release_info.get("version") or "")
self._set_update_progress(
"prepare",
"Подготовка к обновлению…",
3,
target_version=target_version,
)
backup_path = self.create_backup()
if not backup_path:
logger.error("Не удалось создать резервную копию")
self._set_update_progress("error", "Не удалось создать резервную копию", 0, target_version=target_version)
return False
previous_version = self.current_version
try:
from app.services.update_state_store import mark_in_progress
mark_in_progress(
self.base_dir,
target_version=target_version,
previous_version=previous_version,
backup_path=backup_path,
)
except Exception as exc:
logger.debug("update_state mark_in_progress: %s", exc)
zip_path = self.download_update(release_info)
if not zip_path:
logger.error("Не удалось скачать обновление")
self._set_update_progress("error", "Не удалось скачать обновление", 0, target_version=target_version)
return False
if not self.apply_update(zip_path, release_info):
logger.error("Не удалось применить обновление")
self._set_update_progress("error", "Не удалось применить обновление", 0, target_version=target_version)
self._restore_from_backup(backup_path)
try:
from app.services.update_state_store import mark_failed
mark_failed(self.base_dir, message="Не удалось применить обновление", stage="install")
except Exception:
pass
return False
if not self._install_deps_and_verify(backup_path, target_version):
return False
self.clear_pending_after_install()
logger.info(f"Обновление до версии {release_info['version']} успешно применено")
self._write_pending_restart(backup_path, target_version, previous_version)
try:
from app.services.update_state_store import mark_pending_restart
mark_pending_restart(self.base_dir)
except Exception:
pass
self._set_update_progress(
"restart",
"Перезапуск системы…",
99,
target_version=target_version,
)
self._run_restart_cmd()
return True
finally:
self.is_updating = False
self._clear_update_progress()
def _update_loop(self):
"""Цикл проверки обновлений в фоновом потоке (без автоустановки)."""
logger.info("Запущен цикл проверки обновлений")
while self.is_running:
try:
if self.enabled:
release_info = self.check_for_updates()
if release_info:
logger.info(
"Доступно обновление %s — ожидает выбора пользователя",
release_info.get("version"),
)
for _ in range(self.check_interval):
if not self.is_running:
break
time.sleep(1)
except Exception as e:
logger.error(f"Ошибка в цикле обновлений: {e}", exc_info=True)
time.sleep(60)
logger.info("Цикл проверки обновлений остановлен")
def start(self):
"""Запуск фоновой проверки обновлений"""
if not self.enabled:
logger.info("Проверка обновлений отключена в конфигурации")
return
if self.is_running:
return
if not self.gitea_url or not self.gitea_owner or not self.gitea_repo:
logger.warning("Настройки Gitea не заполнены, проверка обновлений не запущена")
return
self.is_running = True
self.update_thread = threading.Thread(
target=self._update_loop, daemon=True, name="wesp-update-checker"
)
self.update_thread.start()
logger.info("Фоновая проверка обновлений запущена")
def stop(self):
"""Остановка фоновой проверки"""
self.is_running = False
if self.update_thread:
self.update_thread.join(timeout=5)
logger.info("Фоновая проверка обновлений остановлена")
# Глобальный экземпляр
auto_updater = None
def init_auto_updater(*, reload: bool = False) -> None:
"""Инициализация / перезагрузка фоновой проверки обновлений."""
global auto_updater
try:
if reload and auto_updater is not None:
auto_updater.stop()
script_dir = os.path.dirname(os.path.abspath(__file__))
config_file = wesp_data_config_path(script_dir)
legacy_root = os.path.join(script_dir, "config.json")
if not os.path.exists(config_file) and os.path.exists(legacy_root):
try:
shutil.copy2(legacy_root, config_file)
except OSError:
pass
auto_updater = AutoUpdater()
if auto_updater.enabled and auto_updater.gitea_url and auto_updater.gitea_owner and auto_updater.gitea_repo:
auto_updater.start()
logger.info("Фоновая проверка обновлений запущена")
elif auto_updater.enabled:
logger.warning(
"Проверка включена, но Gitea не полностью настроен (url/owner/repo)"
)
else:
logger.info(
"Проверка обновлений выключена — включите «Проверять обновления» в админке и нажмите «Сохранить»"
)
except Exception as e:
logger.error("Ошибка инициализации проверки обновлений: %s", e, exc_info=True)
def stop_auto_updater():
"""Остановка фоновой проверки обновлений"""
global auto_updater
if auto_updater:
auto_updater.stop()
logger.info("Проверка обновлений остановлена")
auto_updater = None
if __name__ == "__main__":
import sys
if sys.platform == "win32":
import codecs
sys.stdout = codecs.getwriter("utf-8")(sys.stdout.detach())
print("Тестирование автообновления")
updater = AutoUpdater()
if updater.enabled:
release_info = updater.check_for_updates()
if release_info:
print(f"Найдено обновление: {release_info['version']}")
print(f"Название: {release_info['name']}")
print(f"Описание: {release_info['body'][:100]}...")
else:
print("Обновлений не найдено")
else:
print("Автообновление отключено")