updates.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. """Update checking and management routes."""
  2. import asyncio
  3. import logging
  4. import os
  5. import re
  6. import shutil
  7. import sys
  8. import httpx
  9. from fastapi import APIRouter, BackgroundTasks, Depends
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  12. from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
  13. from backend.app.core.database import get_db
  14. from backend.app.core.permissions import Permission
  15. from backend.app.models.user import User
  16. logger = logging.getLogger(__name__)
  17. router = APIRouter(prefix="/updates", tags=["updates"])
  18. # Global state for update progress
  19. _update_status = {
  20. "status": "idle", # idle, checking, downloading, installing, complete, error
  21. "progress": 0,
  22. "message": "",
  23. "error": None,
  24. }
  25. def _is_docker_environment() -> bool:
  26. """Detect if running inside a Docker container."""
  27. if os.path.exists("/.dockerenv"):
  28. return True
  29. try:
  30. with open("/proc/1/cgroup") as f:
  31. if "docker" in f.read():
  32. return True
  33. except (FileNotFoundError, PermissionError):
  34. pass # cgroup file unavailable; continue with other detection methods
  35. git_dir = settings.base_dir / ".git"
  36. return not git_dir.exists()
  37. def _find_executable(name: str) -> str | None:
  38. """Find an executable in PATH or common locations."""
  39. # Try standard PATH first
  40. path = shutil.which(name)
  41. if path:
  42. return path
  43. # Common locations for executables (useful when running as systemd service)
  44. common_paths = [
  45. f"/usr/bin/{name}",
  46. f"/usr/local/bin/{name}",
  47. f"/opt/homebrew/bin/{name}",
  48. f"/home/linuxbrew/.linuxbrew/bin/{name}",
  49. f"{os.path.expanduser('~')}/.nvm/current/bin/{name}",
  50. f"{os.path.expanduser('~')}/.local/bin/{name}",
  51. ]
  52. for p in common_paths:
  53. if os.path.isfile(p) and os.access(p, os.X_OK):
  54. return p
  55. return None
  56. def parse_version(version: str) -> tuple:
  57. """Parse version string into tuple for comparison.
  58. Returns (major, minor, patch, micro, is_prerelease, prerelease_num)
  59. where is_prerelease is 0 for release, 1 for prerelease.
  60. This ensures releases sort higher than prereleases of same version.
  61. Examples:
  62. "0.1.5" -> (0, 1, 5, 0, 0, 0) # release
  63. "0.1.5b7" -> (0, 1, 5, 0, 1, 7) # beta 7
  64. "0.1.5b10" -> (0, 1, 5, 0, 1, 10) # beta 10
  65. "0.1.8.1" -> (0, 1, 8, 1, 0, 0) # patch release
  66. """
  67. # Remove 'v' prefix if present
  68. version = version.lstrip("v")
  69. # Match version pattern: major.minor.patch[.micro][b|beta|alpha|rc]N
  70. match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?(?:b|beta|alpha|rc)?(\d+)?", version)
  71. if match:
  72. major = int(match.group(1))
  73. minor = int(match.group(2))
  74. patch = int(match.group(3))
  75. micro = int(match.group(4)) if match.group(4) else 0
  76. prerelease_num = int(match.group(5)) if match.group(5) else 0
  77. # Check if this is a prerelease (has b/beta/alpha/rc suffix)
  78. is_prerelease = 1 if re.search(r"[a-zA-Z]", version.split(".")[-1]) else 0
  79. return (major, minor, patch, micro, is_prerelease, prerelease_num)
  80. # Fallback: try simple split
  81. parts = []
  82. for part in version.split("."):
  83. try:
  84. parts.append(int(part))
  85. except ValueError:
  86. num = "".join(c for c in part if c.isdigit())
  87. parts.append(int(num) if num else 0)
  88. return tuple(parts) + (0, 0, 0)
  89. def is_newer_version(latest: str, current: str) -> bool:
  90. """Check if latest version is newer than current.
  91. Properly handles prerelease versions:
  92. - 0.1.5 > 0.1.5b7 (release is newer than any beta)
  93. - 0.1.5b8 > 0.1.5b7 (later beta is newer)
  94. - 0.1.6b1 > 0.1.5 (next version beta is newer than current release)
  95. """
  96. try:
  97. latest_parsed = parse_version(latest)
  98. current_parsed = parse_version(current)
  99. # Compare (major, minor, patch, micro) first
  100. latest_base = latest_parsed[:4]
  101. current_base = current_parsed[:4]
  102. if latest_base > current_base:
  103. return True
  104. elif latest_base < current_base:
  105. return False
  106. # Same base version - compare prerelease status
  107. # is_prerelease: 0 = release, 1 = prerelease
  108. # Release (0) should be "greater" than prerelease (1)
  109. latest_is_prerelease = latest_parsed[4] if len(latest_parsed) > 4 else 0
  110. current_is_prerelease = current_parsed[4] if len(current_parsed) > 4 else 0
  111. if latest_is_prerelease < current_is_prerelease:
  112. # latest is release, current is prerelease -> latest is newer
  113. return True
  114. elif latest_is_prerelease > current_is_prerelease:
  115. # latest is prerelease, current is release -> latest is NOT newer
  116. return False
  117. # Both are same type (both release or both prerelease)
  118. # Compare prerelease numbers
  119. latest_prerelease_num = latest_parsed[5] if len(latest_parsed) > 5 else 0
  120. current_prerelease_num = current_parsed[5] if len(current_parsed) > 5 else 0
  121. return latest_prerelease_num > current_prerelease_num
  122. except Exception:
  123. return False
  124. @router.get("/version")
  125. async def get_version():
  126. """Get current application version.
  127. Note: Unauthenticated - needed to display version in UI without login.
  128. """
  129. return {
  130. "version": APP_VERSION,
  131. "repo": GITHUB_REPO,
  132. }
  133. @router.get("/check")
  134. async def check_for_updates(
  135. db: AsyncSession = Depends(get_db),
  136. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  137. ):
  138. """Check GitHub for available updates."""
  139. global _update_status
  140. _update_status = {
  141. "status": "checking",
  142. "progress": 0,
  143. "message": "Checking for updates...",
  144. "error": None,
  145. }
  146. try:
  147. async with httpx.AsyncClient() as client:
  148. response = await client.get(
  149. f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
  150. headers={"Accept": "application/vnd.github.v3+json"},
  151. timeout=10.0,
  152. )
  153. if response.status_code == 404:
  154. # No releases yet
  155. _update_status = {
  156. "status": "idle",
  157. "progress": 100,
  158. "message": "No releases found",
  159. "error": None,
  160. }
  161. return {
  162. "update_available": False,
  163. "current_version": APP_VERSION,
  164. "latest_version": None,
  165. "message": "No releases found",
  166. }
  167. response.raise_for_status()
  168. release_data = response.json()
  169. latest_version = release_data.get("tag_name", "").lstrip("v")
  170. release_name = release_data.get("name", latest_version)
  171. release_notes = release_data.get("body", "")
  172. release_url = release_data.get("html_url", "")
  173. published_at = release_data.get("published_at", "")
  174. update_available = is_newer_version(latest_version, APP_VERSION)
  175. _update_status = {
  176. "status": "idle",
  177. "progress": 100,
  178. "message": "Update available" if update_available else "Up to date",
  179. "error": None,
  180. }
  181. is_docker = _is_docker_environment()
  182. return {
  183. "update_available": update_available,
  184. "current_version": APP_VERSION,
  185. "latest_version": latest_version,
  186. "release_name": release_name,
  187. "release_notes": release_notes,
  188. "release_url": release_url,
  189. "published_at": published_at,
  190. "is_docker": is_docker,
  191. "update_method": "docker" if is_docker else "git",
  192. }
  193. except httpx.HTTPError as e:
  194. logger.error("Failed to check for updates: %s", e)
  195. _update_status = {
  196. "status": "error",
  197. "progress": 0,
  198. "message": "Failed to check for updates",
  199. "error": "Failed to check for updates",
  200. }
  201. return {
  202. "update_available": False,
  203. "current_version": APP_VERSION,
  204. "latest_version": None,
  205. "error": "Failed to check for updates",
  206. }
  207. async def _perform_update():
  208. """Perform the actual update using git fetch and reset."""
  209. global _update_status
  210. try:
  211. base_dir = settings.base_dir
  212. # Find git executable (may not be in PATH when running as systemd service)
  213. git_path = _find_executable("git")
  214. if not git_path:
  215. _update_status = {
  216. "status": "error",
  217. "progress": 0,
  218. "message": "Git not found",
  219. "error": "Could not find git executable. Please ensure git is installed.",
  220. }
  221. return
  222. logger.info("Using git at: %s", git_path)
  223. # Git config to avoid safe.directory issues
  224. git_config = ["-c", f"safe.directory={base_dir}"]
  225. _update_status = {
  226. "status": "downloading",
  227. "progress": 10,
  228. "message": "Configuring git...",
  229. "error": None,
  230. }
  231. # Ensure remote uses HTTPS (SSH may not be available)
  232. https_url = f"https://github.com/{GITHUB_REPO}.git"
  233. process = await asyncio.create_subprocess_exec(
  234. git_path,
  235. *git_config,
  236. "remote",
  237. "set-url",
  238. "origin",
  239. https_url,
  240. cwd=str(base_dir),
  241. stdout=asyncio.subprocess.PIPE,
  242. stderr=asyncio.subprocess.PIPE,
  243. )
  244. await process.communicate()
  245. _update_status = {
  246. "status": "downloading",
  247. "progress": 20,
  248. "message": "Fetching latest changes...",
  249. "error": None,
  250. }
  251. # Fetch from origin
  252. process = await asyncio.create_subprocess_exec(
  253. git_path,
  254. *git_config,
  255. "fetch",
  256. "origin",
  257. "main",
  258. cwd=str(base_dir),
  259. stdout=asyncio.subprocess.PIPE,
  260. stderr=asyncio.subprocess.PIPE,
  261. )
  262. stdout, stderr = await process.communicate()
  263. if process.returncode != 0:
  264. error_msg = stderr.decode() if stderr else "Git fetch failed"
  265. logger.error("Git fetch failed: %s", error_msg)
  266. _update_status = {
  267. "status": "error",
  268. "progress": 0,
  269. "message": "Failed to fetch updates",
  270. "error": error_msg,
  271. }
  272. return
  273. _update_status = {
  274. "status": "downloading",
  275. "progress": 40,
  276. "message": "Applying updates...",
  277. "error": None,
  278. }
  279. # Hard reset to origin/main (clean update, no merge conflicts)
  280. process = await asyncio.create_subprocess_exec(
  281. git_path,
  282. *git_config,
  283. "reset",
  284. "--hard",
  285. "origin/main",
  286. cwd=str(base_dir),
  287. stdout=asyncio.subprocess.PIPE,
  288. stderr=asyncio.subprocess.PIPE,
  289. )
  290. stdout, stderr = await process.communicate()
  291. if process.returncode != 0:
  292. error_msg = stderr.decode() if stderr else "Git reset failed"
  293. logger.error("Git reset failed: %s", error_msg)
  294. _update_status = {
  295. "status": "error",
  296. "progress": 0,
  297. "message": "Failed to apply updates",
  298. "error": error_msg,
  299. }
  300. return
  301. _update_status = {
  302. "status": "installing",
  303. "progress": 50,
  304. "message": "Installing dependencies...",
  305. "error": None,
  306. }
  307. # Install Python dependencies
  308. process = await asyncio.create_subprocess_exec(
  309. sys.executable,
  310. "-m",
  311. "pip",
  312. "install",
  313. "-r",
  314. "requirements.txt",
  315. "-q",
  316. cwd=str(base_dir),
  317. stdout=asyncio.subprocess.PIPE,
  318. stderr=asyncio.subprocess.PIPE,
  319. )
  320. stdout, stderr = await process.communicate()
  321. if process.returncode != 0:
  322. logger.warning("pip install warning: %s", stderr.decode() if stderr else "unknown")
  323. # Try to build frontend if npm is available (optional - static files are pre-built)
  324. npm_path = _find_executable("npm")
  325. frontend_dir = base_dir / "frontend"
  326. if npm_path and frontend_dir.exists():
  327. _update_status = {
  328. "status": "installing",
  329. "progress": 70,
  330. "message": "Building frontend...",
  331. "error": None,
  332. }
  333. # npm install
  334. process = await asyncio.create_subprocess_exec(
  335. npm_path,
  336. "install",
  337. cwd=str(frontend_dir),
  338. stdout=asyncio.subprocess.PIPE,
  339. stderr=asyncio.subprocess.PIPE,
  340. )
  341. await process.communicate()
  342. # npm run build
  343. process = await asyncio.create_subprocess_exec(
  344. npm_path,
  345. "run",
  346. "build",
  347. cwd=str(frontend_dir),
  348. stdout=asyncio.subprocess.PIPE,
  349. stderr=asyncio.subprocess.PIPE,
  350. )
  351. stdout, stderr = await process.communicate()
  352. if process.returncode != 0:
  353. logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
  354. else:
  355. logger.info("npm not found or frontend dir missing - using pre-built static files")
  356. _update_status = {
  357. "status": "complete",
  358. "progress": 100,
  359. "message": "Update complete! Please restart the application.",
  360. "error": None,
  361. }
  362. logger.info("Update completed successfully")
  363. except Exception as e:
  364. logger.error("Update failed: %s", e)
  365. _update_status = {
  366. "status": "error",
  367. "progress": 0,
  368. "message": "Update failed",
  369. "error": "Update failed unexpectedly",
  370. }
  371. @router.post("/apply")
  372. async def apply_update(
  373. background_tasks: BackgroundTasks,
  374. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  375. ):
  376. """Apply available update (git pull + rebuild)."""
  377. global _update_status
  378. if _update_status["status"] in ["downloading", "installing"]:
  379. return {
  380. "success": False,
  381. "message": "Update already in progress",
  382. "status": _update_status,
  383. }
  384. # Check if running in Docker
  385. if _is_docker_environment():
  386. return {
  387. "success": False,
  388. "is_docker": True,
  389. "message": (
  390. "Docker installations cannot be updated in-app. "
  391. "Please update via Docker Compose: "
  392. "git pull && docker compose build --pull && docker compose up -d"
  393. ),
  394. }
  395. # Start update in background
  396. background_tasks.add_task(_perform_update)
  397. _update_status = {
  398. "status": "downloading",
  399. "progress": 10,
  400. "message": "Starting update...",
  401. "error": None,
  402. }
  403. return {
  404. "success": True,
  405. "message": "Update started",
  406. "status": _update_status,
  407. }
  408. @router.get("/status")
  409. async def get_update_status(
  410. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  411. ):
  412. """Get current update status."""
  413. return _update_status