"""Self-update — check Gitea repo for new commits and apply updates.""" import asyncio import logging import os import shutil from fastapi import APIRouter, Depends, HTTPException from backend.auth import get_current_user from backend.config import BASE_DIR router = APIRouter(prefix="/api/updates", tags=["updates"]) log = logging.getLogger("atlus.updates") # --------------------------------------------------------------------------- # Guard # --------------------------------------------------------------------------- _IS_GIT = (BASE_DIR / ".git").is_dir() def _find_git() -> str | None: """Find the git binary, even with systemd's minimal PATH.""" found = shutil.which("git") if found: return found for p in ("/usr/bin/git", "/usr/local/bin/git", "/bin/git"): if os.path.isfile(p) and os.access(p, os.X_OK): return p return None _GIT_BIN = _find_git() _HAS_GIT = _GIT_BIN is not None def _require_git(): if not _HAS_GIT: raise HTTPException(503, "git is not installed") if not _IS_GIT: raise HTTPException(503, "Atlus was not installed via git — updates unavailable") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _safe_env(): """Build environment with full PATH for git commands.""" env = {**os.environ, "LC_ALL": "C"} path = env.get("PATH", "") for p in ("/usr/bin", "/usr/sbin", "/bin", "/sbin"): if p not in path: path = p + ":" + path env["PATH"] = path # Ensure HOME is set — systemd may strip it, git needs it for SSH keys if "HOME" not in env: import pwd try: env["HOME"] = pwd.getpwuid(os.getuid()).pw_dir except KeyError: env["HOME"] = "/root" return env async def _git(*args: str, timeout: float = 30) -> str: """Run a git command in the Atlus install directory.""" _require_git() cmd = [_GIT_BIN, "-C", str(BASE_DIR)] + list(args) log.debug("Running: %s", " ".join(cmd)) proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=_safe_env(), ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: proc.kill() await proc.wait() raise HTTPException(504, "git operation timed out") if proc.returncode != 0: msg = stderr.decode().strip() or stdout.decode().strip() log.warning("git %s failed (rc=%d): %s", args[0] if args else "?", proc.returncode, msg) raise HTTPException(500, f"git error: {msg}") return stdout.decode().strip() async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]: """Run a git command, return (returncode, stdout) without raising.""" _require_git() cmd = [_GIT_BIN, "-C", str(BASE_DIR)] + list(args) proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=_safe_env(), ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: proc.kill() await proc.wait() return 1, "" if proc.returncode != 0: log.debug("git %s returned %d: %s", args[0] if args else "?", proc.returncode, stderr.decode().strip()) return proc.returncode, stdout.decode().strip() # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("/check") async def check_for_updates(_user: str = Depends(get_current_user)): """Check if there are new commits on the remote.""" # Get current local HEAD local_hash = await _git("rev-parse", "HEAD") # Fetch latest from remote (may take a few seconds) try: await _git("fetch", "origin", timeout=30) except HTTPException as e: # Fetch failed (no network, no auth, etc.) error_msg = str(e.detail) if hasattr(e, 'detail') else str(e) log.warning("git fetch failed: %s", error_msg) return { "available": False, "local_hash": local_hash[:8], "remote_hash": local_hash[:8], "behind_count": 0, "error": f"Could not reach remote: {error_msg}", } # Get remote HEAD rc, remote_hash = await _git_nofail("rev-parse", "origin/main") if rc != 0: # Try origin/master as fallback rc, remote_hash = await _git_nofail("rev-parse", "origin/master") if rc != 0: return { "available": False, "local_hash": local_hash[:8], "remote_hash": "", "behind_count": 0, "error": "Could not determine remote branch", } # Count commits behind rc, count_str = await _git_nofail("rev-list", "--count", f"HEAD..{remote_hash}") behind_count = int(count_str) if rc == 0 and count_str.isdigit() else 0 return { "available": behind_count > 0, "local_hash": local_hash[:8], "remote_hash": remote_hash[:8], "behind_count": behind_count, } @router.post("/apply") async def apply_update(_user: str = Depends(get_current_user)): """Pull latest changes and schedule a service restart.""" # Pull latest pull_output = await _git("pull", "--ff-only", timeout=60) # Reinstall Python dependencies pip_bin = str(BASE_DIR / "venv" / "bin" / "pip") if not os.path.exists(pip_bin): # Fallback: try system pip pip_bin = shutil.which("pip3") or shutil.which("pip") or "pip3" req_file = str(BASE_DIR / "backend" / "requirements.txt") if os.path.exists(req_file): proc = await asyncio.create_subprocess_exec( pip_bin, "install", "-r", req_file, "-q", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: await asyncio.wait_for(proc.communicate(), timeout=120) except asyncio.TimeoutError: pass # Non-fatal — deps might already be satisfied # Schedule restart after response is sent if shutil.which("systemctl"): asyncio.create_task(_delayed_restart()) return { "success": True, "message": "Update applied. Restarting service...", "pull_output": pull_output[-500:], } async def _delayed_restart(delay: float = 2.0): """Wait briefly then restart the atlus systemd service.""" await asyncio.sleep(delay) proc = await asyncio.create_subprocess_exec( "systemctl", "restart", "atlus", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) await proc.wait()