From 141ca2478cd019006555a87a3874a4703ac0adfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80?= <78488229+viktor138irk@users.noreply.github.com> Date: Thu, 7 May 2026 01:25:07 +0900 Subject: [PATCH] Add update center backend --- web/update_core.py | 223 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 web/update_core.py diff --git a/web/update_core.py b/web/update_core.py new file mode 100644 index 0000000..16f5827 --- /dev/null +++ b/web/update_core.py @@ -0,0 +1,223 @@ +import json +import os +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any + +SOURCE_DIR = Path(os.environ.get('VIRTUALITY_SOURCE_DIR', '/opt/virtuality/source')) +STATE_DIR = Path('/var/lib/virtuality/update') +STATE_FILE = STATE_DIR / 'state.json' +LOG_FILE = Path('/var/log/virtuality/update.log') +DEFAULT_BRANCH = os.environ.get('VIRTUALITY_UPDATE_BRANCH', 'main') +REMOTE = os.environ.get('VIRTUALITY_UPDATE_REMOTE', 'origin') + + +class UpdateError(Exception): + pass + + +def run_cmd(cmd: list[str], cwd: Path | None = None, timeout: int = 30) -> dict[str, Any]: + try: + result = subprocess.run(cmd, cwd=str(cwd) if cwd else None, capture_output=True, text=True, timeout=timeout, check=False) + return {'ok': result.returncode == 0, 'code': result.returncode, 'stdout': result.stdout.strip(), 'stderr': result.stderr.strip(), 'cmd': ' '.join(cmd)} + except Exception as exc: + return {'ok': False, 'code': -1, 'stdout': '', 'stderr': str(exc), 'cmd': ' '.join(cmd)} + + +def ensure_state_dir() -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + + +def utc_now() -> str: + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + +def read_json(path: Path, default: Any) -> Any: + try: + return json.loads(path.read_text()) + except Exception: + return default + + +def write_json(path: Path, data: Any) -> None: + ensure_state_dir() + tmp = path.with_suffix('.tmp') + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + tmp.replace(path) + + +def state() -> dict[str, Any]: + ensure_state_dir() + data = read_json(STATE_FILE, {}) + data.setdefault('status', 'idle') + data.setdefault('message', 'Готово к проверке обновлений') + data.setdefault('updated_at', '') + data.setdefault('started_at', '') + data.setdefault('finished_at', '') + return data + + +def source_ready() -> bool: + return (SOURCE_DIR / '.git').exists() + + +def current_commit() -> str: + result = run_cmd(['git', 'rev-parse', 'HEAD'], cwd=SOURCE_DIR, timeout=10) + return result['stdout'] if result['ok'] else '' + + +def latest_commit() -> str: + result = run_cmd(['git', 'rev-parse', f'{REMOTE}/{DEFAULT_BRANCH}'], cwd=SOURCE_DIR, timeout=10) + return result['stdout'] if result['ok'] else '' + + +def file_from_git(ref: str, path: str) -> str: + result = run_cmd(['git', 'show', f'{ref}:{path}'], cwd=SOURCE_DIR, timeout=10) + return result['stdout'] if result['ok'] else '' + + +def local_file_text(path: str) -> str: + target = SOURCE_DIR / path + try: + return target.read_text().strip() + except Exception: + return '' + + +def current_version() -> str: + return local_file_text('VERSION') or '0.0.0' + + +def latest_version() -> str: + value = file_from_git(f'{REMOTE}/{DEFAULT_BRANCH}', 'VERSION').strip() + return value or current_version() + + +def version_tuple(version: str) -> tuple[int, ...]: + clean = version.strip().lower().lstrip('v') + parts = [] + for item in clean.split('.'): + digits = ''.join(ch for ch in item if ch.isdigit()) + parts.append(int(digits or 0)) + return tuple(parts + [0] * (4 - len(parts))) + + +def load_manifest(ref: str | None = None) -> dict[str, Any]: + text = '' + if ref: + text = file_from_git(ref, 'updates/versions.json') + if not text: + path = SOURCE_DIR / 'updates' / 'versions.json' + try: + text = path.read_text() + except Exception: + text = '' + if not text: + return {'versions': []} + try: + data = json.loads(text) + if isinstance(data, dict) and isinstance(data.get('versions'), list): + return data + except Exception: + pass + return {'versions': []} + + +def missing_versions(local_version: str, remote_version: str, manifest: dict[str, Any]) -> list[dict[str, Any]]: + local_key = version_tuple(local_version) + remote_key = version_tuple(remote_version) + versions = [] + for item in manifest.get('versions', []): + if not isinstance(item, dict): + continue + version = str(item.get('version', '')).strip() + if not version: + continue + key = version_tuple(version) + if local_key < key <= remote_key: + versions.append(item) + return sorted(versions, key=lambda item: version_tuple(str(item.get('version', '0.0.0')))) + + +def git_log_between(base: str, head: str, limit: int = 50) -> list[dict[str, str]]: + if not base or not head or base == head: + return [] + fmt = '%H%x1f%h%x1f%s%x1f%ci' + result = run_cmd(['git', 'log', '--pretty=format:' + fmt, f'{base}..{head}', f'-n{limit}'], cwd=SOURCE_DIR, timeout=20) + rows = [] + if not result['ok']: + return rows + for line in result['stdout'].splitlines(): + parts = line.split('\x1f') + if len(parts) >= 4: + rows.append({'sha': parts[0], 'short': parts[1], 'subject': parts[2], 'date': parts[3]}) + return rows + + +def check_updates(fetch: bool = True) -> dict[str, Any]: + ensure_state_dir() + if not source_ready(): + raise UpdateError(f'Не найден git-репозиторий: {SOURCE_DIR}') + + fetch_result = {'ok': True, 'stdout': '', 'stderr': '', 'cmd': 'skip'} + if fetch: + fetch_result = run_cmd(['git', 'fetch', '--quiet', REMOTE, DEFAULT_BRANCH], cwd=SOURCE_DIR, timeout=60) + + local_commit = current_commit() + remote_commit = latest_commit() + local_version = current_version() + remote_version = latest_version() + manifest = load_manifest(f'{REMOTE}/{DEFAULT_BRANCH}') + missed = missing_versions(local_version, remote_version, manifest) + commits = git_log_between(local_commit, remote_commit) + has_update = bool(local_commit and remote_commit and local_commit != remote_commit) + + data = { + 'ok': True, + 'source_dir': str(SOURCE_DIR), + 'remote': REMOTE, + 'branch': DEFAULT_BRANCH, + 'fetch_ok': fetch_result['ok'], + 'fetch_error': fetch_result['stderr'] or fetch_result['stdout'], + 'current_commit': local_commit, + 'latest_commit': remote_commit, + 'current_version': local_version, + 'latest_version': remote_version, + 'has_update': has_update, + 'missing_versions': missed, + 'commits': commits, + 'checked_at': utc_now(), + 'state': state(), + 'log_tail': update_log_tail(), + } + write_json(STATE_DIR / 'last_check.json', data) + return data + + +def update_log_tail(max_lines: int = 160) -> str: + try: + return '\n'.join(LOG_FILE.read_text(errors='replace').splitlines()[-max_lines:]) + except Exception: + return '' + + +def start_update() -> dict[str, Any]: + ensure_state_dir() + if not source_ready(): + raise UpdateError(f'Не найден git-репозиторий: {SOURCE_DIR}') + script = SOURCE_DIR / 'scripts' / 'apply_github_update.sh' + if not script.exists(): + raise UpdateError(f'Не найден скрипт обновления: {script}') + + data = { + 'status': 'running', + 'message': 'Обновление запущено', + 'started_at': utc_now(), + 'updated_at': utc_now(), + 'finished_at': '', + } + write_json(STATE_FILE, data) + subprocess.Popen(['bash', str(script)], cwd=str(SOURCE_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + return data