| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478 |
- """Update checking and management routes."""
- import asyncio
- import logging
- import os
- import re
- import shutil
- import sys
- import httpx
- from fastapi import APIRouter, BackgroundTasks, Depends
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
- from backend.app.core.database import get_db
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/updates", tags=["updates"])
- # Global state for update progress
- _update_status = {
- "status": "idle", # idle, checking, downloading, installing, complete, error
- "progress": 0,
- "message": "",
- "error": None,
- }
- def _is_docker_environment() -> bool:
- """Detect if running inside a Docker container."""
- if os.path.exists("/.dockerenv"):
- return True
- try:
- with open("/proc/1/cgroup") as f:
- if "docker" in f.read():
- return True
- except (FileNotFoundError, PermissionError):
- pass
- git_dir = settings.base_dir / ".git"
- return not git_dir.exists()
- def _find_executable(name: str) -> str | None:
- """Find an executable in PATH or common locations."""
- # Try standard PATH first
- path = shutil.which(name)
- if path:
- return path
- # Common locations for executables (useful when running as systemd service)
- common_paths = [
- f"/usr/bin/{name}",
- f"/usr/local/bin/{name}",
- f"/opt/homebrew/bin/{name}",
- f"/home/linuxbrew/.linuxbrew/bin/{name}",
- f"{os.path.expanduser('~')}/.nvm/current/bin/{name}",
- f"{os.path.expanduser('~')}/.local/bin/{name}",
- ]
- for p in common_paths:
- if os.path.isfile(p) and os.access(p, os.X_OK):
- return p
- return None
- def parse_version(version: str) -> tuple:
- """Parse version string into tuple for comparison.
- Returns (major, minor, patch, is_prerelease, prerelease_num)
- where is_prerelease is 0 for release, 1 for prerelease.
- This ensures releases sort higher than prereleases of same version.
- Examples:
- "0.1.5" -> (0, 1, 5, 0, 0) # release
- "0.1.5b7" -> (0, 1, 5, 1, 7) # beta 7
- "0.1.5b10" -> (0, 1, 5, 1, 10) # beta 10
- """
- # Remove 'v' prefix if present
- version = version.lstrip("v")
- # Match version pattern: major.minor.patch[b|beta|alpha|rc]N
- match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:b|beta|alpha|rc)?(\d+)?", version)
- if match:
- major = int(match.group(1))
- minor = int(match.group(2))
- patch = int(match.group(3))
- prerelease_num = int(match.group(4)) if match.group(4) else 0
- # Check if this is a prerelease (has b/beta/alpha/rc suffix)
- is_prerelease = 1 if re.search(r"[a-zA-Z]", version.split(".")[-1]) else 0
- return (major, minor, patch, is_prerelease, prerelease_num)
- # Fallback: try simple split
- parts = []
- for part in version.split("."):
- try:
- parts.append(int(part))
- except ValueError:
- num = "".join(c for c in part if c.isdigit())
- parts.append(int(num) if num else 0)
- return tuple(parts) + (0, 0)
- def is_newer_version(latest: str, current: str) -> bool:
- """Check if latest version is newer than current.
- Properly handles prerelease versions:
- - 0.1.5 > 0.1.5b7 (release is newer than any beta)
- - 0.1.5b8 > 0.1.5b7 (later beta is newer)
- - 0.1.6b1 > 0.1.5 (next version beta is newer than current release)
- """
- try:
- latest_parsed = parse_version(latest)
- current_parsed = parse_version(current)
- # Compare (major, minor, patch) first
- latest_base = latest_parsed[:3]
- current_base = current_parsed[:3]
- if latest_base > current_base:
- return True
- elif latest_base < current_base:
- return False
- # Same base version - compare prerelease status
- # is_prerelease: 0 = release, 1 = prerelease
- # Release (0) should be "greater" than prerelease (1)
- latest_is_prerelease = latest_parsed[3] if len(latest_parsed) > 3 else 0
- current_is_prerelease = current_parsed[3] if len(current_parsed) > 3 else 0
- if latest_is_prerelease < current_is_prerelease:
- # latest is release, current is prerelease -> latest is newer
- return True
- elif latest_is_prerelease > current_is_prerelease:
- # latest is prerelease, current is release -> latest is NOT newer
- return False
- # Both are same type (both release or both prerelease)
- # Compare prerelease numbers
- latest_prerelease_num = latest_parsed[4] if len(latest_parsed) > 4 else 0
- current_prerelease_num = current_parsed[4] if len(current_parsed) > 4 else 0
- return latest_prerelease_num > current_prerelease_num
- except Exception:
- return False
- @router.get("/version")
- async def get_version():
- """Get current application version."""
- return {
- "version": APP_VERSION,
- "repo": GITHUB_REPO,
- }
- @router.get("/check")
- async def check_for_updates(db: AsyncSession = Depends(get_db)):
- """Check GitHub for available updates."""
- global _update_status
- _update_status = {
- "status": "checking",
- "progress": 0,
- "message": "Checking for updates...",
- "error": None,
- }
- try:
- async with httpx.AsyncClient() as client:
- response = await client.get(
- f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
- headers={"Accept": "application/vnd.github.v3+json"},
- timeout=10.0,
- )
- if response.status_code == 404:
- # No releases yet
- _update_status = {
- "status": "idle",
- "progress": 100,
- "message": "No releases found",
- "error": None,
- }
- return {
- "update_available": False,
- "current_version": APP_VERSION,
- "latest_version": None,
- "message": "No releases found",
- }
- response.raise_for_status()
- release_data = response.json()
- latest_version = release_data.get("tag_name", "").lstrip("v")
- release_name = release_data.get("name", latest_version)
- release_notes = release_data.get("body", "")
- release_url = release_data.get("html_url", "")
- published_at = release_data.get("published_at", "")
- update_available = is_newer_version(latest_version, APP_VERSION)
- _update_status = {
- "status": "idle",
- "progress": 100,
- "message": "Update available" if update_available else "Up to date",
- "error": None,
- }
- is_docker = _is_docker_environment()
- return {
- "update_available": update_available,
- "current_version": APP_VERSION,
- "latest_version": latest_version,
- "release_name": release_name,
- "release_notes": release_notes,
- "release_url": release_url,
- "published_at": published_at,
- "is_docker": is_docker,
- "update_method": "docker" if is_docker else "git",
- }
- except httpx.HTTPError as e:
- logger.error(f"Failed to check for updates: {e}")
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "Failed to check for updates",
- "error": str(e),
- }
- return {
- "update_available": False,
- "current_version": APP_VERSION,
- "latest_version": None,
- "error": str(e),
- }
- async def _perform_update():
- """Perform the actual update using git fetch and reset."""
- global _update_status
- try:
- base_dir = settings.base_dir
- # Find git executable (may not be in PATH when running as systemd service)
- git_path = _find_executable("git")
- if not git_path:
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "Git not found",
- "error": "Could not find git executable. Please ensure git is installed.",
- }
- return
- logger.info(f"Using git at: {git_path}")
- # Git config to avoid safe.directory issues
- git_config = ["-c", f"safe.directory={base_dir}"]
- _update_status = {
- "status": "downloading",
- "progress": 10,
- "message": "Configuring git...",
- "error": None,
- }
- # Ensure remote uses HTTPS (SSH may not be available)
- https_url = f"https://github.com/{GITHUB_REPO}.git"
- process = await asyncio.create_subprocess_exec(
- git_path,
- *git_config,
- "remote",
- "set-url",
- "origin",
- https_url,
- cwd=str(base_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- await process.communicate()
- _update_status = {
- "status": "downloading",
- "progress": 20,
- "message": "Fetching latest changes...",
- "error": None,
- }
- # Fetch from origin
- process = await asyncio.create_subprocess_exec(
- git_path,
- *git_config,
- "fetch",
- "origin",
- "main",
- cwd=str(base_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- stdout, stderr = await process.communicate()
- if process.returncode != 0:
- error_msg = stderr.decode() if stderr else "Git fetch failed"
- logger.error(f"Git fetch failed: {error_msg}")
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "Failed to fetch updates",
- "error": error_msg,
- }
- return
- _update_status = {
- "status": "downloading",
- "progress": 40,
- "message": "Applying updates...",
- "error": None,
- }
- # Hard reset to origin/main (clean update, no merge conflicts)
- process = await asyncio.create_subprocess_exec(
- git_path,
- *git_config,
- "reset",
- "--hard",
- "origin/main",
- cwd=str(base_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- stdout, stderr = await process.communicate()
- if process.returncode != 0:
- error_msg = stderr.decode() if stderr else "Git reset failed"
- logger.error(f"Git reset failed: {error_msg}")
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "Failed to apply updates",
- "error": error_msg,
- }
- return
- _update_status = {
- "status": "installing",
- "progress": 50,
- "message": "Installing dependencies...",
- "error": None,
- }
- # Install Python dependencies
- process = await asyncio.create_subprocess_exec(
- sys.executable,
- "-m",
- "pip",
- "install",
- "-r",
- "requirements.txt",
- "-q",
- cwd=str(base_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- stdout, stderr = await process.communicate()
- if process.returncode != 0:
- logger.warning(f"pip install warning: {stderr.decode() if stderr else 'unknown'}")
- # Try to build frontend if npm is available (optional - static files are pre-built)
- npm_path = _find_executable("npm")
- frontend_dir = base_dir / "frontend"
- if npm_path and frontend_dir.exists():
- _update_status = {
- "status": "installing",
- "progress": 70,
- "message": "Building frontend...",
- "error": None,
- }
- # npm install
- process = await asyncio.create_subprocess_exec(
- npm_path,
- "install",
- cwd=str(frontend_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- await process.communicate()
- # npm run build
- process = await asyncio.create_subprocess_exec(
- npm_path,
- "run",
- "build",
- cwd=str(frontend_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- stdout, stderr = await process.communicate()
- if process.returncode != 0:
- logger.warning(f"Frontend build warning: {stderr.decode() if stderr else 'unknown'}")
- else:
- logger.info("npm not found or frontend dir missing - using pre-built static files")
- _update_status = {
- "status": "complete",
- "progress": 100,
- "message": "Update complete! Please restart the application.",
- "error": None,
- }
- logger.info("Update completed successfully")
- except Exception as e:
- logger.error(f"Update failed: {e}")
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "Update failed",
- "error": str(e),
- }
- @router.post("/apply")
- async def apply_update(background_tasks: BackgroundTasks):
- """Apply available update (git pull + rebuild)."""
- global _update_status
- if _update_status["status"] in ["downloading", "installing"]:
- return {
- "success": False,
- "message": "Update already in progress",
- "status": _update_status,
- }
- # Check if running in Docker
- if _is_docker_environment():
- return {
- "success": False,
- "is_docker": True,
- "message": (
- "Docker installations cannot be updated in-app. "
- "Please update via Docker Compose: "
- "git pull && docker compose build --pull && docker compose up -d"
- ),
- }
- # Start update in background
- background_tasks.add_task(_perform_update)
- _update_status = {
- "status": "downloading",
- "progress": 10,
- "message": "Starting update...",
- "error": None,
- }
- return {
- "success": True,
- "message": "Update started",
- "status": _update_status,
- }
- @router.get("/status")
- async def get_update_status():
- """Get current update status."""
- return _update_status
|