updates.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. """Update checking and management routes."""
  2. import asyncio
  3. import logging
  4. import subprocess
  5. import sys
  6. from pathlib import Path
  7. import httpx
  8. from fastapi import APIRouter, BackgroundTasks, Depends
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
  11. from backend.app.core.database import get_db
  12. from backend.app.api.routes.settings import get_setting
  13. logger = logging.getLogger(__name__)
  14. router = APIRouter(prefix="/updates", tags=["updates"])
  15. # Global state for update progress
  16. _update_status = {
  17. "status": "idle", # idle, checking, downloading, installing, complete, error
  18. "progress": 0,
  19. "message": "",
  20. "error": None,
  21. }
  22. def parse_version(version: str) -> tuple[int, ...]:
  23. """Parse version string into tuple for comparison."""
  24. # Remove 'v' prefix if present
  25. version = version.lstrip("v")
  26. # Split and convert to integers
  27. parts = []
  28. for part in version.split("."):
  29. try:
  30. parts.append(int(part))
  31. except ValueError:
  32. # Handle pre-release versions like "1.0.0-beta"
  33. num = "".join(c for c in part if c.isdigit())
  34. parts.append(int(num) if num else 0)
  35. return tuple(parts)
  36. def is_newer_version(latest: str, current: str) -> bool:
  37. """Check if latest version is newer than current."""
  38. try:
  39. return parse_version(latest) > parse_version(current)
  40. except Exception:
  41. return False
  42. @router.get("/version")
  43. async def get_version():
  44. """Get current application version."""
  45. return {
  46. "version": APP_VERSION,
  47. "repo": GITHUB_REPO,
  48. }
  49. @router.get("/check")
  50. async def check_for_updates(db: AsyncSession = Depends(get_db)):
  51. """Check GitHub for available updates."""
  52. global _update_status
  53. _update_status = {
  54. "status": "checking",
  55. "progress": 0,
  56. "message": "Checking for updates...",
  57. "error": None,
  58. }
  59. try:
  60. async with httpx.AsyncClient() as client:
  61. response = await client.get(
  62. f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
  63. headers={"Accept": "application/vnd.github.v3+json"},
  64. timeout=10.0,
  65. )
  66. if response.status_code == 404:
  67. # No releases yet
  68. _update_status = {
  69. "status": "idle",
  70. "progress": 100,
  71. "message": "No releases found",
  72. "error": None,
  73. }
  74. return {
  75. "update_available": False,
  76. "current_version": APP_VERSION,
  77. "latest_version": None,
  78. "message": "No releases found",
  79. }
  80. response.raise_for_status()
  81. release_data = response.json()
  82. latest_version = release_data.get("tag_name", "").lstrip("v")
  83. release_name = release_data.get("name", latest_version)
  84. release_notes = release_data.get("body", "")
  85. release_url = release_data.get("html_url", "")
  86. published_at = release_data.get("published_at", "")
  87. update_available = is_newer_version(latest_version, APP_VERSION)
  88. _update_status = {
  89. "status": "idle",
  90. "progress": 100,
  91. "message": "Update available" if update_available else "Up to date",
  92. "error": None,
  93. }
  94. return {
  95. "update_available": update_available,
  96. "current_version": APP_VERSION,
  97. "latest_version": latest_version,
  98. "release_name": release_name,
  99. "release_notes": release_notes,
  100. "release_url": release_url,
  101. "published_at": published_at,
  102. }
  103. except httpx.HTTPError as e:
  104. logger.error(f"Failed to check for updates: {e}")
  105. _update_status = {
  106. "status": "error",
  107. "progress": 0,
  108. "message": "Failed to check for updates",
  109. "error": str(e),
  110. }
  111. return {
  112. "update_available": False,
  113. "current_version": APP_VERSION,
  114. "latest_version": None,
  115. "error": str(e),
  116. }
  117. async def _perform_update():
  118. """Perform the actual update using git pull."""
  119. global _update_status
  120. try:
  121. _update_status = {
  122. "status": "downloading",
  123. "progress": 20,
  124. "message": "Pulling latest changes...",
  125. "error": None,
  126. }
  127. # Run git pull in the project directory
  128. base_dir = settings.base_dir
  129. process = await asyncio.create_subprocess_exec(
  130. "git", "pull", "--rebase",
  131. cwd=str(base_dir),
  132. stdout=asyncio.subprocess.PIPE,
  133. stderr=asyncio.subprocess.PIPE,
  134. )
  135. stdout, stderr = await process.communicate()
  136. if process.returncode != 0:
  137. error_msg = stderr.decode() if stderr else "Git pull failed"
  138. logger.error(f"Git pull failed: {error_msg}")
  139. _update_status = {
  140. "status": "error",
  141. "progress": 0,
  142. "message": "Failed to pull updates",
  143. "error": error_msg,
  144. }
  145. return
  146. _update_status = {
  147. "status": "installing",
  148. "progress": 50,
  149. "message": "Installing dependencies...",
  150. "error": None,
  151. }
  152. # Install Python dependencies
  153. process = await asyncio.create_subprocess_exec(
  154. sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "-q",
  155. cwd=str(base_dir),
  156. stdout=asyncio.subprocess.PIPE,
  157. stderr=asyncio.subprocess.PIPE,
  158. )
  159. stdout, stderr = await process.communicate()
  160. if process.returncode != 0:
  161. logger.warning(f"pip install warning: {stderr.decode() if stderr else 'unknown'}")
  162. _update_status = {
  163. "status": "installing",
  164. "progress": 70,
  165. "message": "Building frontend...",
  166. "error": None,
  167. }
  168. # Build frontend
  169. frontend_dir = base_dir / "frontend"
  170. if frontend_dir.exists():
  171. # npm install
  172. process = await asyncio.create_subprocess_exec(
  173. "npm", "install",
  174. cwd=str(frontend_dir),
  175. stdout=asyncio.subprocess.PIPE,
  176. stderr=asyncio.subprocess.PIPE,
  177. )
  178. await process.communicate()
  179. # npm run build
  180. process = await asyncio.create_subprocess_exec(
  181. "npm", "run", "build",
  182. cwd=str(frontend_dir),
  183. stdout=asyncio.subprocess.PIPE,
  184. stderr=asyncio.subprocess.PIPE,
  185. )
  186. stdout, stderr = await process.communicate()
  187. if process.returncode != 0:
  188. logger.warning(f"Frontend build warning: {stderr.decode() if stderr else 'unknown'}")
  189. _update_status = {
  190. "status": "complete",
  191. "progress": 100,
  192. "message": "Update complete! Please restart the application.",
  193. "error": None,
  194. }
  195. logger.info("Update completed successfully")
  196. except Exception as e:
  197. logger.error(f"Update failed: {e}")
  198. _update_status = {
  199. "status": "error",
  200. "progress": 0,
  201. "message": "Update failed",
  202. "error": str(e),
  203. }
  204. @router.post("/apply")
  205. async def apply_update(background_tasks: BackgroundTasks):
  206. """Apply available update (git pull + rebuild)."""
  207. global _update_status
  208. if _update_status["status"] in ["downloading", "installing"]:
  209. return {
  210. "success": False,
  211. "message": "Update already in progress",
  212. "status": _update_status,
  213. }
  214. # Start update in background
  215. background_tasks.add_task(_perform_update)
  216. _update_status = {
  217. "status": "downloading",
  218. "progress": 10,
  219. "message": "Starting update...",
  220. "error": None,
  221. }
  222. return {
  223. "success": True,
  224. "message": "Update started",
  225. "status": _update_status,
  226. }
  227. @router.get("/status")
  228. async def get_update_status():
  229. """Get current update status."""
  230. return _update_status