updates.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  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 import select
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  13. from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
  14. from backend.app.core.database import get_db
  15. from backend.app.core.permissions import Permission
  16. from backend.app.models.settings import Settings
  17. from backend.app.models.user import User
  18. logger = logging.getLogger(__name__)
  19. router = APIRouter(prefix="/updates", tags=["updates"])
  20. # Global state for update progress
  21. _update_status = {
  22. "status": "idle", # idle, checking, downloading, installing, complete, error
  23. "progress": 0,
  24. "message": "",
  25. "error": None,
  26. }
  27. def _is_docker_environment() -> bool:
  28. """Detect if running inside a Docker container."""
  29. if os.path.exists("/.dockerenv"):
  30. return True
  31. try:
  32. with open("/proc/1/cgroup") as f:
  33. if "docker" in f.read():
  34. return True
  35. except (FileNotFoundError, PermissionError):
  36. pass # cgroup file unavailable; continue with other detection methods
  37. # Check container runtime hint (systemd sets this for Docker/podman,
  38. # but NOT for LXC/LXD — avoids false positives on Proxmox containers)
  39. try:
  40. with open("/run/systemd/container") as f:
  41. runtime = f.read().strip()
  42. if runtime in ("docker", "podman", "oci"):
  43. return True
  44. except (FileNotFoundError, PermissionError):
  45. pass
  46. return False
  47. def _find_executable(name: str) -> str | None:
  48. """Find an executable in PATH or common locations."""
  49. # Try standard PATH first
  50. path = shutil.which(name)
  51. if path:
  52. return path
  53. # Common locations for executables (useful when running as systemd service)
  54. common_paths = [
  55. f"/usr/bin/{name}",
  56. f"/usr/local/bin/{name}",
  57. f"/opt/homebrew/bin/{name}",
  58. f"/home/linuxbrew/.linuxbrew/bin/{name}",
  59. f"{os.path.expanduser('~')}/.nvm/current/bin/{name}",
  60. f"{os.path.expanduser('~')}/.local/bin/{name}",
  61. ]
  62. for p in common_paths:
  63. if os.path.isfile(p) and os.access(p, os.X_OK):
  64. return p
  65. return None
  66. def _parse_github_remote(url: str) -> tuple[str, str] | None:
  67. """Extract `(owner, repo)` from a GitHub remote URL, or None if it isn't a
  68. GitHub URL we recognise.
  69. Handles the four forms `git remote -v` typically prints:
  70. - `git@github.com:owner/repo.git` (SSH, the dev default)
  71. - `git@github.com:owner/repo` (SSH without .git suffix)
  72. - `https://github.com/owner/repo.git` (HTTPS, what _perform_update sets)
  73. - `https://github.com/owner/repo` (HTTPS without .git)
  74. Anything else (a fork URL, a different host, a malformed value, the empty
  75. string from a missing origin) returns None so the caller treats it as
  76. "not pointing at our repo" and resets it.
  77. """
  78. s = url.strip()
  79. if not s:
  80. return None
  81. # SSH form: git@github.com:owner/repo[.git]
  82. ssh_prefix = "git@github.com:"
  83. https_prefix_a = "https://github.com/"
  84. https_prefix_b = "http://github.com/" # tolerated for legacy
  85. if s.startswith(ssh_prefix):
  86. path = s[len(ssh_prefix) :]
  87. elif s.startswith(https_prefix_a):
  88. path = s[len(https_prefix_a) :]
  89. elif s.startswith(https_prefix_b):
  90. path = s[len(https_prefix_b) :]
  91. else:
  92. return None
  93. if path.endswith(".git"):
  94. path = path[:-4]
  95. parts = path.strip("/").split("/")
  96. if len(parts) != 2 or not parts[0] or not parts[1]:
  97. return None
  98. return (parts[0], parts[1])
  99. async def _origin_points_at_repo(git_path: str, git_config: list[str], base_dir, expected_repo: str) -> bool:
  100. """Return True iff the working tree's `origin` already resolves to
  101. `<owner>/<repo>` matching `expected_repo` (e.g. "maziggy/bambuddy"),
  102. regardless of whether it's the SSH or HTTPS form. Used to skip the
  103. `git remote set-url origin https://...` rewrite when the developer's
  104. SSH origin is already correct — see `_perform_update` for context."""
  105. try:
  106. process = await asyncio.create_subprocess_exec(
  107. git_path,
  108. *git_config,
  109. "remote",
  110. "get-url",
  111. "origin",
  112. cwd=str(base_dir),
  113. stdout=asyncio.subprocess.PIPE,
  114. stderr=asyncio.subprocess.PIPE,
  115. )
  116. stdout, _ = await process.communicate()
  117. except (OSError, asyncio.CancelledError):
  118. # Fail closed: let the caller go through the rewrite branch if we
  119. # can't even invoke git. The unconditional set-url is the safer
  120. # fallback, only mildly destructive.
  121. return False
  122. if process.returncode != 0:
  123. # Most likely cause: no `origin` defined yet (fresh clone-style
  124. # checkout). Caller will set it.
  125. return False
  126. parsed = _parse_github_remote(stdout.decode().strip())
  127. if parsed is None:
  128. return False
  129. owner, repo = parsed
  130. expected_owner, expected_repo_name = expected_repo.split("/", 1)
  131. return owner == expected_owner and repo == expected_repo_name
  132. def parse_version(version: str) -> tuple:
  133. """Parse version string into tuple for comparison.
  134. Returns (major, minor, patch, micro, is_prerelease, prerelease_num)
  135. where is_prerelease is 0 for release, 1 for prerelease.
  136. This ensures releases sort higher than prereleases of same version.
  137. Examples:
  138. "0.1.5" -> (0, 1, 5, 0, 0, 0) # release
  139. "0.1.5b7" -> (0, 1, 5, 0, 1, 7) # beta 7
  140. "0.1.5b10" -> (0, 1, 5, 0, 1, 10) # beta 10
  141. "0.1.8.1" -> (0, 1, 8, 1, 0, 0) # patch release
  142. """
  143. # Remove 'v' prefix if present
  144. version = version.lstrip("v")
  145. # Strip daily build suffix (e.g., "0.2.2b4-daily.20260313" -> "0.2.2b4")
  146. version = re.sub(r"-daily\.\d+$", "", version)
  147. # Match version pattern: major.minor.patch[.micro][b|beta|alpha|rc]N
  148. match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?(?:b|beta|alpha|rc)?(\d+)?", version)
  149. if match:
  150. major = int(match.group(1))
  151. minor = int(match.group(2))
  152. patch = int(match.group(3))
  153. micro = int(match.group(4)) if match.group(4) else 0
  154. prerelease_num = int(match.group(5)) if match.group(5) else 0
  155. # Check if this is a prerelease (has b/beta/alpha/rc/daily suffix anywhere)
  156. is_prerelease = 1 if re.search(r"[a-zA-Z]", version) else 0
  157. return (major, minor, patch, micro, is_prerelease, prerelease_num)
  158. # Fallback: try simple split
  159. parts = []
  160. for part in version.split("."):
  161. try:
  162. parts.append(int(part))
  163. except ValueError:
  164. num = "".join(c for c in part if c.isdigit())
  165. parts.append(int(num) if num else 0)
  166. return tuple(parts) + (0, 0, 0)
  167. def is_newer_version(latest: str, current: str) -> bool:
  168. """Check if latest version is newer than current.
  169. Properly handles prerelease versions:
  170. - 0.1.5 > 0.1.5b7 (release is newer than any beta)
  171. - 0.1.5b8 > 0.1.5b7 (later beta is newer)
  172. - 0.1.6b1 > 0.1.5 (next version beta is newer than current release)
  173. """
  174. try:
  175. latest_parsed = parse_version(latest)
  176. current_parsed = parse_version(current)
  177. # Compare (major, minor, patch, micro) first
  178. latest_base = latest_parsed[:4]
  179. current_base = current_parsed[:4]
  180. if latest_base > current_base:
  181. return True
  182. elif latest_base < current_base:
  183. return False
  184. # Same base version - compare prerelease status
  185. # is_prerelease: 0 = release, 1 = prerelease
  186. # Release (0) should be "greater" than prerelease (1)
  187. latest_is_prerelease = latest_parsed[4] if len(latest_parsed) > 4 else 0
  188. current_is_prerelease = current_parsed[4] if len(current_parsed) > 4 else 0
  189. if latest_is_prerelease < current_is_prerelease:
  190. # latest is release, current is prerelease -> latest is newer
  191. return True
  192. elif latest_is_prerelease > current_is_prerelease:
  193. # latest is prerelease, current is release -> latest is NOT newer
  194. return False
  195. # Both are same type (both release or both prerelease)
  196. # Compare prerelease numbers
  197. latest_prerelease_num = latest_parsed[5] if len(latest_parsed) > 5 else 0
  198. current_prerelease_num = current_parsed[5] if len(current_parsed) > 5 else 0
  199. return latest_prerelease_num > current_prerelease_num
  200. except Exception:
  201. return False
  202. @router.get("/version")
  203. async def get_version():
  204. """Get current application version.
  205. Note: Unauthenticated - needed to display version in UI without login.
  206. """
  207. return {
  208. "version": APP_VERSION,
  209. "repo": GITHUB_REPO,
  210. }
  211. @router.get("/check")
  212. async def check_for_updates(
  213. db: AsyncSession = Depends(get_db),
  214. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  215. ):
  216. """Check GitHub for available updates."""
  217. global _update_status
  218. # Respect the check_updates setting
  219. result = await db.execute(select(Settings).where(Settings.key == "check_updates"))
  220. setting = result.scalar_one_or_none()
  221. if setting and setting.value.lower() == "false":
  222. return {
  223. "update_available": False,
  224. "current_version": APP_VERSION,
  225. "latest_version": None,
  226. "message": "Update checks are disabled",
  227. }
  228. # Check if beta updates should be included
  229. result = await db.execute(select(Settings).where(Settings.key == "include_beta_updates"))
  230. beta_setting = result.scalar_one_or_none()
  231. include_beta = beta_setting and beta_setting.value.lower() == "true"
  232. _update_status = {
  233. "status": "checking",
  234. "progress": 0,
  235. "message": "Checking for updates...",
  236. "error": None,
  237. }
  238. try:
  239. async with httpx.AsyncClient() as client:
  240. response = await client.get(
  241. f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
  242. headers={"Accept": "application/vnd.github.v3+json"},
  243. timeout=10.0,
  244. )
  245. if response.status_code == 404:
  246. # No releases yet
  247. _update_status = {
  248. "status": "idle",
  249. "progress": 100,
  250. "message": "No releases found",
  251. "error": None,
  252. }
  253. return {
  254. "update_available": False,
  255. "current_version": APP_VERSION,
  256. "latest_version": None,
  257. "message": "No releases found",
  258. }
  259. response.raise_for_status()
  260. releases = response.json()
  261. # Find the appropriate release based on beta setting
  262. release_data = None
  263. for release in releases:
  264. tag = release.get("tag_name", "")
  265. if include_beta:
  266. # Accept any release (first = newest)
  267. release_data = release
  268. break
  269. else:
  270. # Skip prereleases (based on version parsing, not GitHub flag)
  271. parsed = parse_version(tag)
  272. if parsed[4] == 0: # is_prerelease == 0
  273. release_data = release
  274. break
  275. if not release_data:
  276. _update_status = {
  277. "status": "idle",
  278. "progress": 100,
  279. "message": "No releases found",
  280. "error": None,
  281. }
  282. return {
  283. "update_available": False,
  284. "current_version": APP_VERSION,
  285. "latest_version": None,
  286. "message": "No releases found",
  287. }
  288. latest_version = release_data.get("tag_name", "").lstrip("v")
  289. release_name = release_data.get("name", latest_version)
  290. release_notes = release_data.get("body", "")
  291. release_url = release_data.get("html_url", "")
  292. published_at = release_data.get("published_at", "")
  293. update_available = is_newer_version(latest_version, APP_VERSION)
  294. _update_status = {
  295. "status": "idle",
  296. "progress": 100,
  297. "message": "Update available" if update_available else "Up to date",
  298. "error": None,
  299. }
  300. is_docker = _is_docker_environment()
  301. return {
  302. "update_available": update_available,
  303. "current_version": APP_VERSION,
  304. "latest_version": latest_version,
  305. "release_name": release_name,
  306. "release_notes": release_notes,
  307. "release_url": release_url,
  308. "published_at": published_at,
  309. "is_docker": is_docker,
  310. "update_method": "docker" if is_docker else "git",
  311. }
  312. except httpx.HTTPError as e:
  313. logger.error("Failed to check for updates: %s", e)
  314. _update_status = {
  315. "status": "error",
  316. "progress": 0,
  317. "message": "Failed to check for updates",
  318. "error": "Failed to check for updates",
  319. }
  320. return {
  321. "update_available": False,
  322. "current_version": APP_VERSION,
  323. "latest_version": None,
  324. "error": "Failed to check for updates",
  325. }
  326. async def _perform_update():
  327. """Perform the actual update using git fetch and reset."""
  328. global _update_status
  329. try:
  330. base_dir = settings.base_dir
  331. # Find git executable (may not be in PATH when running as systemd service)
  332. git_path = _find_executable("git")
  333. if not git_path:
  334. _update_status = {
  335. "status": "error",
  336. "progress": 0,
  337. "message": "Git not found",
  338. "error": "Could not find git executable. Please ensure git is installed.",
  339. }
  340. return
  341. logger.info("Using git at: %s", git_path)
  342. # Git config to avoid safe.directory issues
  343. git_config = ["-c", f"safe.directory={base_dir}"]
  344. _update_status = {
  345. "status": "downloading",
  346. "progress": 10,
  347. "message": "Configuring git...",
  348. "error": None,
  349. }
  350. # Ensure remote points at the expected repo. We previously rewrote
  351. # origin to HTTPS unconditionally on the assumption that systemd
  352. # service users wouldn't have SSH keys configured — which is fine
  353. # for that case, but stomps on developer checkouts where origin is
  354. # legitimately `git@github.com:maziggy/bambuddy.git` and the user
  355. # auths via SSH keys. After the rewrite, `git push` prompts for
  356. # HTTPS credentials and fails.
  357. # New behaviour: read the current origin, parse out the
  358. # `<owner>/<repo>` pair, and only rewrite if it doesn't already
  359. # resolve to the right GitHub repo. SSH origins pointing at the
  360. # correct repo are preserved; only missing / wrong / corrupted
  361. # origins get reset to HTTPS.
  362. https_url = f"https://github.com/{GITHUB_REPO}.git"
  363. if not await _origin_points_at_repo(git_path, git_config, base_dir, GITHUB_REPO):
  364. process = await asyncio.create_subprocess_exec(
  365. git_path,
  366. *git_config,
  367. "remote",
  368. "set-url",
  369. "origin",
  370. https_url,
  371. cwd=str(base_dir),
  372. stdout=asyncio.subprocess.PIPE,
  373. stderr=asyncio.subprocess.PIPE,
  374. )
  375. await process.communicate()
  376. _update_status = {
  377. "status": "downloading",
  378. "progress": 20,
  379. "message": "Fetching latest changes...",
  380. "error": None,
  381. }
  382. # Fetch from origin
  383. process = await asyncio.create_subprocess_exec(
  384. git_path,
  385. *git_config,
  386. "fetch",
  387. "origin",
  388. "main",
  389. cwd=str(base_dir),
  390. stdout=asyncio.subprocess.PIPE,
  391. stderr=asyncio.subprocess.PIPE,
  392. )
  393. stdout, stderr = await process.communicate()
  394. if process.returncode != 0:
  395. error_msg = stderr.decode() if stderr else "Git fetch failed"
  396. logger.error("Git fetch failed: %s", error_msg)
  397. _update_status = {
  398. "status": "error",
  399. "progress": 0,
  400. "message": "Failed to fetch updates",
  401. "error": error_msg,
  402. }
  403. return
  404. _update_status = {
  405. "status": "downloading",
  406. "progress": 40,
  407. "message": "Applying updates...",
  408. "error": None,
  409. }
  410. # Hard reset to origin/main (clean update, no merge conflicts)
  411. process = await asyncio.create_subprocess_exec(
  412. git_path,
  413. *git_config,
  414. "reset",
  415. "--hard",
  416. "origin/main",
  417. cwd=str(base_dir),
  418. stdout=asyncio.subprocess.PIPE,
  419. stderr=asyncio.subprocess.PIPE,
  420. )
  421. stdout, stderr = await process.communicate()
  422. if process.returncode != 0:
  423. error_msg = stderr.decode() if stderr else "Git reset failed"
  424. logger.error("Git reset failed: %s", error_msg)
  425. _update_status = {
  426. "status": "error",
  427. "progress": 0,
  428. "message": "Failed to apply updates",
  429. "error": error_msg,
  430. }
  431. return
  432. _update_status = {
  433. "status": "installing",
  434. "progress": 50,
  435. "message": "Installing dependencies...",
  436. "error": None,
  437. }
  438. # Install Python dependencies — must run from the source-code directory
  439. # (where requirements.txt lives), not the data dir. On native installs
  440. # systemd sets DATA_DIR=INSTALL_PATH/data, so `base_dir` is the data dir,
  441. # not the working tree. `git reset` above worked from base_dir because
  442. # git walks up looking for .git, but `pip install -r requirements.txt`
  443. # needs the file in cwd literally.
  444. app_dir = settings.app_dir
  445. process = await asyncio.create_subprocess_exec(
  446. sys.executable,
  447. "-m",
  448. "pip",
  449. "install",
  450. "-r",
  451. "requirements.txt",
  452. "-q",
  453. cwd=str(app_dir),
  454. stdout=asyncio.subprocess.PIPE,
  455. stderr=asyncio.subprocess.PIPE,
  456. )
  457. stdout, stderr = await process.communicate()
  458. if process.returncode != 0:
  459. logger.warning("pip install warning: %s", stderr.decode() if stderr else "unknown")
  460. # Try to build frontend if npm is available (optional - static files are pre-built)
  461. npm_path = _find_executable("npm")
  462. frontend_dir = app_dir / "frontend"
  463. if npm_path and frontend_dir.exists():
  464. _update_status = {
  465. "status": "installing",
  466. "progress": 70,
  467. "message": "Building frontend...",
  468. "error": None,
  469. }
  470. # npm install
  471. process = await asyncio.create_subprocess_exec(
  472. npm_path,
  473. "install",
  474. cwd=str(frontend_dir),
  475. stdout=asyncio.subprocess.PIPE,
  476. stderr=asyncio.subprocess.PIPE,
  477. )
  478. await process.communicate()
  479. # npm run build
  480. process = await asyncio.create_subprocess_exec(
  481. npm_path,
  482. "run",
  483. "build",
  484. cwd=str(frontend_dir),
  485. stdout=asyncio.subprocess.PIPE,
  486. stderr=asyncio.subprocess.PIPE,
  487. )
  488. stdout, stderr = await process.communicate()
  489. if process.returncode != 0:
  490. logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
  491. else:
  492. logger.info("npm not found or frontend dir missing - using pre-built static files")
  493. _update_status = {
  494. "status": "complete",
  495. "progress": 100,
  496. "message": "Update complete! Please restart the application.",
  497. "error": None,
  498. }
  499. logger.info("Update completed successfully")
  500. except Exception as e:
  501. logger.error("Update failed: %s", e)
  502. _update_status = {
  503. "status": "error",
  504. "progress": 0,
  505. "message": "Update failed",
  506. "error": "Update failed unexpectedly",
  507. }
  508. @router.post("/apply")
  509. async def apply_update(
  510. background_tasks: BackgroundTasks,
  511. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  512. ):
  513. """Apply available update (git pull + rebuild)."""
  514. global _update_status
  515. if _update_status["status"] in ["downloading", "installing"]:
  516. return {
  517. "success": False,
  518. "message": "Update already in progress",
  519. "status": _update_status,
  520. }
  521. # Check if running in Docker
  522. if _is_docker_environment():
  523. return {
  524. "success": False,
  525. "is_docker": True,
  526. "message": (
  527. "Docker installations cannot be updated in-app. "
  528. "Please update via Docker Compose: "
  529. "git pull && docker compose build --pull && docker compose up -d"
  530. ),
  531. }
  532. # Start update in background
  533. background_tasks.add_task(_perform_update)
  534. _update_status = {
  535. "status": "downloading",
  536. "progress": 10,
  537. "message": "Starting update...",
  538. "error": None,
  539. }
  540. return {
  541. "success": True,
  542. "message": "Update started",
  543. "status": _update_status,
  544. }
  545. @router.get("/status")
  546. async def get_update_status(
  547. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  548. ):
  549. """Get current update status."""
  550. return _update_status