Check Gitea releases in Settings and install the latest tag via git fetch/checkout with pip sync and restart when running from a git checkout.
250 lines
8.4 KiB
Python
250 lines
8.4 KiB
Python
"""Panel self-update from the EvilFox Gitea repository."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_REPO_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main'
|
|
DEFAULT_GIT_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main.git'
|
|
DEFAULT_API_LATEST = 'https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest'
|
|
UPDATE_REMOTE = 'panel-update'
|
|
|
|
|
|
def _env(name: str, default: str) -> str:
|
|
return (os.environ.get(name) or default).strip()
|
|
|
|
|
|
def repo_url() -> str:
|
|
return _env('PANEL_UPDATE_REPO_URL', DEFAULT_REPO_URL).rstrip('/')
|
|
|
|
|
|
def git_url() -> str:
|
|
return _env('PANEL_UPDATE_GIT_URL', DEFAULT_GIT_URL)
|
|
|
|
|
|
def api_latest_url() -> str:
|
|
return _env('PANEL_UPDATE_API_LATEST', DEFAULT_API_LATEST)
|
|
|
|
|
|
def parse_version(value: str) -> tuple:
|
|
"""Parse semver-ish tags like v2.3.0 / 2.3.0-beta into a comparable tuple."""
|
|
raw = (value or '').strip()
|
|
if not raw:
|
|
return (0, 0, 0, 1, '')
|
|
raw = raw.lstrip('vV')
|
|
main, _, pre = raw.partition('-')
|
|
parts = []
|
|
for piece in main.split('.'):
|
|
if piece.isdigit():
|
|
parts.append(int(piece))
|
|
else:
|
|
m = re.match(r'(\d+)', piece or '')
|
|
parts.append(int(m.group(1)) if m else 0)
|
|
while len(parts) < 3:
|
|
parts.append(0)
|
|
# release > prerelease: empty pre sorts higher
|
|
pre_rank = 0 if not pre else 1
|
|
return (parts[0], parts[1], parts[2], pre_rank, pre)
|
|
|
|
|
|
def version_gt(a: str, b: str) -> bool:
|
|
return parse_version(a) > parse_version(b)
|
|
|
|
|
|
def _run(cmd: list, cwd: str, timeout: int = 120) -> tuple[int, str, str]:
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
cwd=cwd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
encoding='utf-8',
|
|
errors='replace',
|
|
)
|
|
return proc.returncode, (proc.stdout or '').strip(), (proc.stderr or '').strip()
|
|
except FileNotFoundError:
|
|
return 127, '', f'Command not found: {cmd[0]}'
|
|
except subprocess.TimeoutExpired:
|
|
return 124, '', f'Timeout running: {" ".join(cmd)}'
|
|
|
|
|
|
class UpdateManager:
|
|
def __init__(self, app_root: str, current_version: str):
|
|
self.app_root = os.path.abspath(app_root)
|
|
self.current_version = current_version
|
|
|
|
def detect_mode(self) -> dict:
|
|
frozen = bool(getattr(sys, 'frozen', False))
|
|
in_docker = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
|
|
git_dir = os.path.join(self.app_root, '.git')
|
|
is_git = os.path.isdir(git_dir)
|
|
git_ok = False
|
|
if is_git and not frozen:
|
|
code, _, _ = _run(['git', '--version'], self.app_root, timeout=10)
|
|
git_ok = code == 0
|
|
can_auto = bool(git_ok and not frozen)
|
|
mode = 'git' if can_auto else ('docker' if in_docker else ('binary' if frozen else 'manual'))
|
|
return {
|
|
'mode': mode,
|
|
'can_auto_update': can_auto,
|
|
'is_git': is_git,
|
|
'frozen': frozen,
|
|
'in_docker': in_docker,
|
|
'app_root': self.app_root,
|
|
'repo_url': repo_url(),
|
|
'git_url': git_url(),
|
|
}
|
|
|
|
def _ensure_update_remote(self) -> Optional[str]:
|
|
code, out, err = _run(['git', 'remote'], self.app_root, timeout=15)
|
|
if code != 0:
|
|
return err or out or 'git remote failed'
|
|
remotes = set((out or '').split())
|
|
target = git_url()
|
|
if UPDATE_REMOTE in remotes:
|
|
code, _, err = _run(
|
|
['git', 'remote', 'set-url', UPDATE_REMOTE, target],
|
|
self.app_root,
|
|
timeout=15,
|
|
)
|
|
else:
|
|
code, _, err = _run(
|
|
['git', 'remote', 'add', UPDATE_REMOTE, target],
|
|
self.app_root,
|
|
timeout=15,
|
|
)
|
|
if code != 0:
|
|
return err or 'Failed to configure update remote'
|
|
return None
|
|
|
|
def working_tree_dirty(self) -> bool:
|
|
code, out, _ = _run(['git', 'status', '--porcelain'], self.app_root, timeout=30)
|
|
return code == 0 and bool(out.strip())
|
|
|
|
def apply_update(self, target_version: str, allow_dirty: bool = False) -> dict:
|
|
mode = self.detect_mode()
|
|
if not mode['can_auto_update']:
|
|
return {
|
|
'status': 'error',
|
|
'message': (
|
|
'Auto-update requires a git checkout of the panel sources. '
|
|
'Docker/binary installs: pull a new image or download a release manually.'
|
|
),
|
|
'mode': mode,
|
|
}
|
|
|
|
target = (target_version or '').strip()
|
|
if not re.match(r'^v?\d+(\.\d+){0,3}([.-][\w.]+)?$', target):
|
|
return {'status': 'error', 'message': f'Invalid target version: {target_version}'}
|
|
if not target.startswith('v'):
|
|
target = 'v' + target
|
|
|
|
if not allow_dirty and self.working_tree_dirty():
|
|
return {
|
|
'status': 'error',
|
|
'message': (
|
|
'Working tree has local changes. Commit/stash them or set allow_dirty, '
|
|
'then retry auto-update.'
|
|
),
|
|
}
|
|
|
|
err = self._ensure_update_remote()
|
|
if err:
|
|
return {'status': 'error', 'message': err}
|
|
|
|
code, out, stderr = _run(
|
|
['git', 'fetch', '--tags', '--force', UPDATE_REMOTE],
|
|
self.app_root,
|
|
timeout=180,
|
|
)
|
|
if code != 0:
|
|
return {
|
|
'status': 'error',
|
|
'message': f'git fetch failed: {stderr or out or code}',
|
|
}
|
|
|
|
# Prefer annotated/lightweight tag; fall back to remote main tip if tag missing
|
|
code, _, _ = _run(
|
|
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'],
|
|
self.app_root,
|
|
timeout=15,
|
|
)
|
|
if code == 0:
|
|
checkout_ref = target
|
|
else:
|
|
# tag might only exist as FETCH_HEAD / remote tag
|
|
code2, _, _ = _run(
|
|
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}^{{}}'],
|
|
self.app_root,
|
|
timeout=15,
|
|
)
|
|
checkout_ref = target if code2 == 0 else f'tags/{target}'
|
|
|
|
code, out, stderr = _run(
|
|
['git', 'checkout', '--force', checkout_ref],
|
|
self.app_root,
|
|
timeout=60,
|
|
)
|
|
if code != 0:
|
|
# last resort: checkout remote main
|
|
code_m, out_m, err_m = _run(
|
|
['git', 'checkout', '--force', '-B', 'main', f'{UPDATE_REMOTE}/main'],
|
|
self.app_root,
|
|
timeout=60,
|
|
)
|
|
if code_m != 0:
|
|
return {
|
|
'status': 'error',
|
|
'message': f'git checkout {target} failed: {stderr or out or err_m or out_m}',
|
|
}
|
|
checkout_ref = f'{UPDATE_REMOTE}/main'
|
|
|
|
req = os.path.join(self.app_root, 'requirements.txt')
|
|
pip_log = ''
|
|
if os.path.isfile(req):
|
|
code_p, out_p, err_p = _run(
|
|
[sys.executable, '-m', 'pip', 'install', '-r', req],
|
|
self.app_root,
|
|
timeout=300,
|
|
)
|
|
pip_log = (out_p or err_p or '')[:1500]
|
|
if code_p != 0:
|
|
return {
|
|
'status': 'error',
|
|
'message': f'pip install failed after checkout: {err_p or out_p or code_p}',
|
|
'checkout': checkout_ref,
|
|
}
|
|
|
|
return {
|
|
'status': 'success',
|
|
'message': f'Updated to {target}. Panel will restart.',
|
|
'target_version': target,
|
|
'checkout': checkout_ref,
|
|
'pip': pip_log[:500],
|
|
'restart': True,
|
|
}
|
|
|
|
@staticmethod
|
|
def schedule_restart(delay_sec: float = 1.5) -> None:
|
|
def _restart():
|
|
time.sleep(max(0.5, float(delay_sec)))
|
|
try:
|
|
argv = [sys.executable] + sys.argv
|
|
logger.info('Restarting panel after update: %s', argv)
|
|
os.execv(sys.executable, argv)
|
|
except Exception:
|
|
logger.exception('Failed to restart after update; exiting')
|
|
os._exit(0)
|
|
|
|
threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()
|