updates.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. """Update checking and management routes."""
  2. import asyncio
  3. import logging
  4. import os
  5. import shutil
  6. import sys
  7. from pathlib import Path
  8. import httpx
  9. from fastapi import APIRouter, BackgroundTasks, Depends
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
  12. from backend.app.core.database import get_db
  13. from backend.app.api.routes.settings import get_setting
  14. logger = logging.getLogger(__name__)
  15. router = APIRouter(prefix="/updates", tags=["updates"])
  16. # Global state for update progress
  17. _update_status = {
  18. "status": "idle", # idle, checking, downloading, installing, complete, error
  19. "progress": 0,
  20. "message": "",
  21. "error": None,
  22. }
  23. def _find_executable(name: str) -> str | None:
  24. """Find an executable in PATH or common locations."""
  25. # Try standard PATH first
  26. path = shutil.which(name)
  27. if path:
  28. return path
  29. # Common locations for executables (useful when running as systemd service)
  30. common_paths = [
  31. f"/usr/bin/{name}",
  32. f"/usr/local/bin/{name}",
  33. f"/opt/homebrew/bin/{name}",
  34. f"/home/linuxbrew/.linuxbrew/bin/{name}",
  35. f"{os.path.expanduser('~')}/.nvm/current/bin/{name}",
  36. f"{os.path.expanduser('~')}/.local/bin/{name}",
  37. ]
  38. for p in common_paths:
  39. if os.path.isfile(p) and os.access(p, os.X_OK):
  40. return p
  41. return None
  42. def parse_version(version: str) -> tuple[int, ...]:
  43. """Parse version string into tuple for comparison."""
  44. # Remove 'v' prefix if present
  45. version = version.lstrip("v")
  46. # Split and convert to integers
  47. parts = []
  48. for part in version.split("."):
  49. try:
  50. parts.append(int(part))
  51. except ValueError:
  52. # Handle pre-release versions like "1.0.0-beta"
  53. num = "".join(c for c in part if c.isdigit())
  54. parts.append(int(num) if num else 0)
  55. return tuple(parts)
  56. def is_newer_version(latest: str, current: str) -> bool:
  57. """Check if latest version is newer than current."""
  58. try:
  59. return parse_version(latest) > parse_version(current)
  60. except Exception:
  61. return False
  62. @router.get("/version")
  63. async def get_version():
  64. """Get current application version."""
  65. return {
  66. "version": APP_VERSION,
  67. "repo": GITHUB_REPO,
  68. }
  69. @router.get("/check")
  70. async def check_for_updates(db: AsyncSession = Depends(get_db)):
  71. """Check GitHub for available updates."""
  72. global _update_status
  73. _update_status = {
  74. "status": "checking",
  75. "progress": 0,
  76. "message": "Checking for updates...",
  77. "error": None,
  78. }
  79. try:
  80. async with httpx.AsyncClient() as client:
  81. response = await client.get(
  82. f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
  83. headers={"Accept": "application/vnd.github.v3+json"},
  84. timeout=10.0,
  85. )
  86. if response.status_code == 404:
  87. # No releases yet
  88. _update_status = {
  89. "status": "idle",
  90. "progress": 100,
  91. "message": "No releases found",
  92. "error": None,
  93. }
  94. return {
  95. "update_available": False,
  96. "current_version": APP_VERSION,
  97. "latest_version": None,
  98. "message": "No releases found",
  99. }
  100. response.raise_for_status()
  101. release_data = response.json()
  102. latest_version = release_data.get("tag_name", "").lstrip("v")
  103. release_name = release_data.get("name", latest_version)
  104. release_notes = release_data.get("body", "")
  105. release_url = release_data.get("html_url", "")
  106. published_at = release_data.get("published_at", "")
  107. update_available = is_newer_version(latest_version, APP_VERSION)
  108. _update_status = {
  109. "status": "idle",
  110. "progress": 100,
  111. "message": "Update available" if update_available else "Up to date",
  112. "error": None,
  113. }
  114. return {
  115. "update_available": update_available,
  116. "current_version": APP_VERSION,
  117. "latest_version": latest_version,
  118. "release_name": release_name,
  119. "release_notes": release_notes,
  120. "release_url": release_url,
  121. "published_at": published_at,
  122. }
  123. except httpx.HTTPError as e:
  124. logger.error(f"Failed to check for updates: {e}")
  125. _update_status = {
  126. "status": "error",
  127. "progress": 0,
  128. "message": "Failed to check for updates",
  129. "error": str(e),
  130. }
  131. return {
  132. "update_available": False,
  133. "current_version": APP_VERSION,
  134. "latest_version": None,
  135. "error": str(e),
  136. }
  137. async def _perform_update():
  138. """Perform the actual update using git fetch and reset."""
  139. global _update_status
  140. try:
  141. base_dir = settings.base_dir
  142. # Find git executable (may not be in PATH when running as systemd service)
  143. git_path = _find_executable("git")
  144. if not git_path:
  145. _update_status = {
  146. "status": "error",
  147. "progress": 0,
  148. "message": "Git not found",
  149. "error": "Could not find git executable. Please ensure git is installed.",
  150. }
  151. return
  152. logger.info(f"Using git at: {git_path}")
  153. # Git config to avoid safe.directory issues
  154. git_config = ["-c", f"safe.directory={base_dir}"]
  155. _update_status = {
  156. "status": "downloading",
  157. "progress": 10,
  158. "message": "Configuring git...",
  159. "error": None,
  160. }
  161. # Ensure remote uses HTTPS (SSH may not be available)
  162. https_url = f"https://github.com/{GITHUB_REPO}.git"
  163. process = await asyncio.create_subprocess_exec(
  164. git_path, *git_config, "remote", "set-url", "origin", https_url,
  165. cwd=str(base_dir),
  166. stdout=asyncio.subprocess.PIPE,
  167. stderr=asyncio.subprocess.PIPE,
  168. )
  169. await process.communicate()
  170. _update_status = {
  171. "status": "downloading",
  172. "progress": 20,
  173. "message": "Fetching latest changes...",
  174. "error": None,
  175. }
  176. # Fetch from origin
  177. process = await asyncio.create_subprocess_exec(
  178. git_path, *git_config, "fetch", "origin", "main",
  179. cwd=str(base_dir),
  180. stdout=asyncio.subprocess.PIPE,
  181. stderr=asyncio.subprocess.PIPE,
  182. )
  183. stdout, stderr = await process.communicate()
  184. if process.returncode != 0:
  185. error_msg = stderr.decode() if stderr else "Git fetch failed"
  186. logger.error(f"Git fetch failed: {error_msg}")
  187. _update_status = {
  188. "status": "error",
  189. "progress": 0,
  190. "message": "Failed to fetch updates",
  191. "error": error_msg,
  192. }
  193. return
  194. _update_status = {
  195. "status": "downloading",
  196. "progress": 40,
  197. "message": "Applying updates...",
  198. "error": None,
  199. }
  200. # Hard reset to origin/main (clean update, no merge conflicts)
  201. process = await asyncio.create_subprocess_exec(
  202. git_path, *git_config, "reset", "--hard", "origin/main",
  203. cwd=str(base_dir),
  204. stdout=asyncio.subprocess.PIPE,
  205. stderr=asyncio.subprocess.PIPE,
  206. )
  207. stdout, stderr = await process.communicate()
  208. if process.returncode != 0:
  209. error_msg = stderr.decode() if stderr else "Git reset failed"
  210. logger.error(f"Git reset failed: {error_msg}")
  211. _update_status = {
  212. "status": "error",
  213. "progress": 0,
  214. "message": "Failed to apply updates",
  215. "error": error_msg,
  216. }
  217. return
  218. _update_status = {
  219. "status": "installing",
  220. "progress": 50,
  221. "message": "Installing dependencies...",
  222. "error": None,
  223. }
  224. # Install Python dependencies
  225. process = await asyncio.create_subprocess_exec(
  226. sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "-q",
  227. cwd=str(base_dir),
  228. stdout=asyncio.subprocess.PIPE,
  229. stderr=asyncio.subprocess.PIPE,
  230. )
  231. stdout, stderr = await process.communicate()
  232. if process.returncode != 0:
  233. logger.warning(f"pip install warning: {stderr.decode() if stderr else 'unknown'}")
  234. # Try to build frontend if npm is available (optional - static files are pre-built)
  235. npm_path = _find_executable("npm")
  236. frontend_dir = base_dir / "frontend"
  237. if npm_path and frontend_dir.exists():
  238. _update_status = {
  239. "status": "installing",
  240. "progress": 70,
  241. "message": "Building frontend...",
  242. "error": None,
  243. }
  244. # npm install
  245. process = await asyncio.create_subprocess_exec(
  246. npm_path, "install",
  247. cwd=str(frontend_dir),
  248. stdout=asyncio.subprocess.PIPE,
  249. stderr=asyncio.subprocess.PIPE,
  250. )
  251. await process.communicate()
  252. # npm run build
  253. process = await asyncio.create_subprocess_exec(
  254. npm_path, "run", "build",
  255. cwd=str(frontend_dir),
  256. stdout=asyncio.subprocess.PIPE,
  257. stderr=asyncio.subprocess.PIPE,
  258. )
  259. stdout, stderr = await process.communicate()
  260. if process.returncode != 0:
  261. logger.warning(f"Frontend build warning: {stderr.decode() if stderr else 'unknown'}")
  262. else:
  263. logger.info("npm not found or frontend dir missing - using pre-built static files")
  264. _update_status = {
  265. "status": "complete",
  266. "progress": 100,
  267. "message": "Update complete! Please restart the application.",
  268. "error": None,
  269. }
  270. logger.info("Update completed successfully")
  271. except Exception as e:
  272. logger.error(f"Update failed: {e}")
  273. _update_status = {
  274. "status": "error",
  275. "progress": 0,
  276. "message": "Update failed",
  277. "error": str(e),
  278. }
  279. @router.post("/apply")
  280. async def apply_update(background_tasks: BackgroundTasks):
  281. """Apply available update (git pull + rebuild)."""
  282. global _update_status
  283. if _update_status["status"] in ["downloading", "installing"]:
  284. return {
  285. "success": False,
  286. "message": "Update already in progress",
  287. "status": _update_status,
  288. }
  289. # Start update in background
  290. background_tasks.add_task(_perform_update)
  291. _update_status = {
  292. "status": "downloading",
  293. "progress": 10,
  294. "message": "Starting update...",
  295. "error": None,
  296. }
  297. return {
  298. "success": True,
  299. "message": "Update started",
  300. "status": _update_status,
  301. }
  302. @router.get("/status")
  303. async def get_update_status():
  304. """Get current update status."""
  305. return _update_status