updates.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856
  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 time
  9. import httpx
  10. from fastapi import APIRouter, BackgroundTasks, Depends
  11. from sqlalchemy import select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  14. from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
  15. from backend.app.core.database import get_db
  16. from backend.app.core.permissions import Permission
  17. from backend.app.models.settings import Settings
  18. from backend.app.models.user import User
  19. logger = logging.getLogger(__name__)
  20. router = APIRouter(prefix="/updates", tags=["updates"])
  21. # Global state for update progress
  22. _update_status = {
  23. "status": "idle", # idle, checking, downloading, installing, complete, error
  24. "progress": 0,
  25. "message": "",
  26. "error": None,
  27. }
  28. # GitHub rate-limit backoff (#1420): when api.github.com returns 403 with
  29. # X-RateLimit-Remaining=0, refuse to retry until X-RateLimit-Reset (epoch
  30. # seconds). Falls back to a 1-hour pause if the header is absent. Prevents
  31. # the update checker from hammering GitHub once the unauthenticated quota
  32. # (60 req/hr per source IP) is exhausted.
  33. _GITHUB_RATE_LIMIT_FALLBACK_SECONDS = 3600
  34. _github_rate_limit_until: float = 0.0
  35. def _seconds_until_github_unblocked() -> float:
  36. """Return seconds remaining until GitHub backoff lifts, or 0 if unblocked."""
  37. remaining = _github_rate_limit_until - time.time()
  38. return remaining if remaining > 0 else 0.0
  39. def _record_github_rate_limit(response: httpx.Response) -> None:
  40. """Set the backoff window from a GitHub 403 response's headers."""
  41. global _github_rate_limit_until
  42. reset_header = response.headers.get("X-RateLimit-Reset")
  43. reset_at: float | None = None
  44. if reset_header:
  45. try:
  46. reset_at = float(reset_header)
  47. except ValueError:
  48. reset_at = None
  49. if reset_at is None:
  50. reset_at = time.time() + _GITHUB_RATE_LIMIT_FALLBACK_SECONDS
  51. # Floor at a 60s minimum: protects against clock skew between the container
  52. # and GitHub (parsed reset epoch in the past would otherwise leave us with
  53. # no real backoff and we'd hammer GitHub again immediately).
  54. reset_at = max(reset_at, time.time() + 60)
  55. # Only extend the window — never shorten it via an out-of-order response.
  56. if reset_at > _github_rate_limit_until:
  57. _github_rate_limit_until = reset_at
  58. logger.warning(
  59. "GitHub rate limit hit; suppressing update checks for %.0fs (reset header=%s)",
  60. _seconds_until_github_unblocked(),
  61. reset_header,
  62. )
  63. def _is_github_rate_limit_response(response: httpx.Response) -> bool:
  64. """Detect a rate-limit response from GitHub (403/429 with Remaining=0)."""
  65. if response.status_code not in (403, 429):
  66. return False
  67. remaining = response.headers.get("X-RateLimit-Remaining")
  68. if remaining == "0":
  69. return True
  70. # Some proxies strip the header; fall back to body inspection.
  71. try:
  72. body = response.text or ""
  73. except Exception:
  74. body = ""
  75. return "rate limit" in body.lower() or "API rate limit exceeded" in body
  76. def _is_docker_environment() -> bool:
  77. """Detect if running inside a Docker container."""
  78. if os.path.exists("/.dockerenv"):
  79. return True
  80. try:
  81. with open("/proc/1/cgroup") as f:
  82. if "docker" in f.read():
  83. return True
  84. except (FileNotFoundError, PermissionError):
  85. pass # cgroup file unavailable; continue with other detection methods
  86. # Check container runtime hint (systemd sets this for Docker/podman,
  87. # but NOT for LXC/LXD — avoids false positives on Proxmox containers)
  88. try:
  89. with open("/run/systemd/container") as f:
  90. runtime = f.read().strip()
  91. if runtime in ("docker", "podman", "oci"):
  92. return True
  93. except (FileNotFoundError, PermissionError):
  94. pass
  95. return False
  96. def _is_ha_addon() -> bool:
  97. """Detect if running as a Home Assistant Supervisor addon.
  98. HA Supervisor injects ``SUPERVISOR_TOKEN`` into every addon container;
  99. the variable is not set in any other environment, so a single env-var
  100. check is sufficient with no false-positive surface.
  101. """
  102. return bool(os.environ.get("SUPERVISOR_TOKEN"))
  103. def _find_executable(name: str) -> str | None:
  104. """Find an executable in PATH or common locations."""
  105. # Try standard PATH first
  106. path = shutil.which(name)
  107. if path:
  108. return path
  109. # Common locations for executables (useful when running as systemd service)
  110. common_paths = [
  111. f"/usr/bin/{name}",
  112. f"/usr/local/bin/{name}",
  113. f"/opt/homebrew/bin/{name}",
  114. f"/home/linuxbrew/.linuxbrew/bin/{name}",
  115. f"{os.path.expanduser('~')}/.nvm/current/bin/{name}",
  116. f"{os.path.expanduser('~')}/.local/bin/{name}",
  117. ]
  118. for p in common_paths:
  119. if os.path.isfile(p) and os.access(p, os.X_OK):
  120. return p
  121. return None
  122. def _parse_github_remote(url: str) -> tuple[str, str] | None:
  123. """Extract `(owner, repo)` from a GitHub remote URL, or None if it isn't a
  124. GitHub URL we recognise.
  125. Handles the four forms `git remote -v` typically prints:
  126. - `git@github.com:owner/repo.git` (SSH, the dev default)
  127. - `git@github.com:owner/repo` (SSH without .git suffix)
  128. - `https://github.com/owner/repo.git` (HTTPS, what _perform_update sets)
  129. - `https://github.com/owner/repo` (HTTPS without .git)
  130. Anything else (a fork URL, a different host, a malformed value, the empty
  131. string from a missing origin) returns None so the caller treats it as
  132. "not pointing at our repo" and resets it.
  133. """
  134. s = url.strip()
  135. if not s:
  136. return None
  137. # SSH form: git@github.com:owner/repo[.git]
  138. ssh_prefix = "git@github.com:"
  139. https_prefix_a = "https://github.com/"
  140. https_prefix_b = "http://github.com/" # tolerated for legacy
  141. if s.startswith(ssh_prefix):
  142. path = s[len(ssh_prefix) :]
  143. elif s.startswith(https_prefix_a):
  144. path = s[len(https_prefix_a) :]
  145. elif s.startswith(https_prefix_b):
  146. path = s[len(https_prefix_b) :]
  147. else:
  148. return None
  149. if path.endswith(".git"):
  150. path = path[:-4]
  151. parts = path.strip("/").split("/")
  152. if len(parts) != 2 or not parts[0] or not parts[1]:
  153. return None
  154. return (parts[0], parts[1])
  155. async def _origin_points_at_repo(git_path: str, git_config: list[str], base_dir, expected_repo: str) -> bool:
  156. """Return True iff the working tree's `origin` already resolves to
  157. `<owner>/<repo>` matching `expected_repo` (e.g. "maziggy/bambuddy"),
  158. regardless of whether it's the SSH or HTTPS form. Used to skip the
  159. `git remote set-url origin https://...` rewrite when the developer's
  160. SSH origin is already correct — see `_perform_update` for context."""
  161. try:
  162. process = await asyncio.create_subprocess_exec(
  163. git_path,
  164. *git_config,
  165. "remote",
  166. "get-url",
  167. "origin",
  168. cwd=str(base_dir),
  169. stdout=asyncio.subprocess.PIPE,
  170. stderr=asyncio.subprocess.PIPE,
  171. )
  172. stdout, _ = await process.communicate()
  173. except (OSError, asyncio.CancelledError):
  174. # Fail closed: let the caller go through the rewrite branch if we
  175. # can't even invoke git. The unconditional set-url is the safer
  176. # fallback, only mildly destructive.
  177. return False
  178. if process.returncode != 0:
  179. # Most likely cause: no `origin` defined yet (fresh clone-style
  180. # checkout). Caller will set it.
  181. return False
  182. parsed = _parse_github_remote(stdout.decode().strip())
  183. if parsed is None:
  184. return False
  185. owner, repo = parsed
  186. expected_owner, expected_repo_name = expected_repo.split("/", 1)
  187. return owner == expected_owner and repo == expected_repo_name
  188. def parse_version(version: str) -> tuple:
  189. """Parse version string into tuple for comparison.
  190. Returns (major, minor, patch, micro, is_prerelease, prerelease_num)
  191. where is_prerelease is 0 for release, 1 for prerelease.
  192. This ensures releases sort higher than prereleases of same version.
  193. Examples:
  194. "0.1.5" -> (0, 1, 5, 0, 0, 0) # release
  195. "0.1.5b7" -> (0, 1, 5, 0, 1, 7) # beta 7
  196. "0.1.5b10" -> (0, 1, 5, 0, 1, 10) # beta 10
  197. "0.1.8.1" -> (0, 1, 8, 1, 0, 0) # patch release
  198. """
  199. # Remove 'v' prefix if present
  200. version = version.lstrip("v")
  201. # Strip daily build suffix (e.g., "0.2.2b4-daily.20260313" -> "0.2.2b4")
  202. version = re.sub(r"-daily\.\d+$", "", version)
  203. # Match version pattern: major.minor.patch[.micro][b|beta|alpha|rc]N
  204. match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?(?:b|beta|alpha|rc)?(\d+)?", version)
  205. if match:
  206. major = int(match.group(1))
  207. minor = int(match.group(2))
  208. patch = int(match.group(3))
  209. micro = int(match.group(4)) if match.group(4) else 0
  210. prerelease_num = int(match.group(5)) if match.group(5) else 0
  211. # Check if this is a prerelease (has b/beta/alpha/rc/daily suffix anywhere)
  212. is_prerelease = 1 if re.search(r"[a-zA-Z]", version) else 0
  213. return (major, minor, patch, micro, is_prerelease, prerelease_num)
  214. # Fallback: try simple split
  215. parts = []
  216. for part in version.split("."):
  217. try:
  218. parts.append(int(part))
  219. except ValueError:
  220. num = "".join(c for c in part if c.isdigit())
  221. parts.append(int(num) if num else 0)
  222. return tuple(parts) + (0, 0, 0)
  223. def is_newer_version(latest: str, current: str) -> bool:
  224. """Check if latest version is newer than current.
  225. Properly handles prerelease versions:
  226. - 0.1.5 > 0.1.5b7 (release is newer than any beta)
  227. - 0.1.5b8 > 0.1.5b7 (later beta is newer)
  228. - 0.1.6b1 > 0.1.5 (next version beta is newer than current release)
  229. """
  230. try:
  231. latest_parsed = parse_version(latest)
  232. current_parsed = parse_version(current)
  233. # Compare (major, minor, patch, micro) first
  234. latest_base = latest_parsed[:4]
  235. current_base = current_parsed[:4]
  236. if latest_base > current_base:
  237. return True
  238. elif latest_base < current_base:
  239. return False
  240. # Same base version - compare prerelease status
  241. # is_prerelease: 0 = release, 1 = prerelease
  242. # Release (0) should be "greater" than prerelease (1)
  243. latest_is_prerelease = latest_parsed[4] if len(latest_parsed) > 4 else 0
  244. current_is_prerelease = current_parsed[4] if len(current_parsed) > 4 else 0
  245. if latest_is_prerelease < current_is_prerelease:
  246. # latest is release, current is prerelease -> latest is newer
  247. return True
  248. elif latest_is_prerelease > current_is_prerelease:
  249. # latest is prerelease, current is release -> latest is NOT newer
  250. return False
  251. # Both are same type (both release or both prerelease)
  252. # Compare prerelease numbers
  253. latest_prerelease_num = latest_parsed[5] if len(latest_parsed) > 5 else 0
  254. current_prerelease_num = current_parsed[5] if len(current_parsed) > 5 else 0
  255. return latest_prerelease_num > current_prerelease_num
  256. except Exception:
  257. return False
  258. @router.get("/version")
  259. async def get_version():
  260. """Get current application version.
  261. Note: Unauthenticated - needed to display version in UI without login.
  262. """
  263. return {
  264. "version": APP_VERSION,
  265. "repo": GITHUB_REPO,
  266. }
  267. @router.get("/check")
  268. async def check_for_updates(
  269. db: AsyncSession = Depends(get_db),
  270. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  271. ):
  272. """Check GitHub for available updates."""
  273. global _update_status
  274. # Respect the check_updates setting
  275. result = await db.execute(select(Settings).where(Settings.key == "check_updates"))
  276. setting = result.scalar_one_or_none()
  277. if setting and setting.value.lower() == "false":
  278. return {
  279. "update_available": False,
  280. "current_version": APP_VERSION,
  281. "latest_version": None,
  282. "message": "Update checks are disabled",
  283. }
  284. # Check if beta updates should be included
  285. result = await db.execute(select(Settings).where(Settings.key == "include_beta_updates"))
  286. beta_setting = result.scalar_one_or_none()
  287. include_beta = beta_setting and beta_setting.value.lower() == "true"
  288. # Short-circuit if we're still inside a GitHub rate-limit backoff window (#1420).
  289. backoff_remaining = _seconds_until_github_unblocked()
  290. if backoff_remaining > 0:
  291. _update_status = {
  292. "status": "error",
  293. "progress": 0,
  294. "message": "GitHub rate limit reached",
  295. "error": "GitHub rate limit reached; retry later",
  296. }
  297. return {
  298. "update_available": False,
  299. "current_version": APP_VERSION,
  300. "latest_version": None,
  301. "error": "GitHub rate limit reached; retry later",
  302. "retry_after_seconds": int(backoff_remaining),
  303. }
  304. _update_status = {
  305. "status": "checking",
  306. "progress": 0,
  307. "message": "Checking for updates...",
  308. "error": None,
  309. }
  310. try:
  311. async with httpx.AsyncClient() as client:
  312. response = await client.get(
  313. f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
  314. headers={"Accept": "application/vnd.github.v3+json"},
  315. timeout=10.0,
  316. )
  317. if _is_github_rate_limit_response(response):
  318. _record_github_rate_limit(response)
  319. _update_status = {
  320. "status": "error",
  321. "progress": 0,
  322. "message": "GitHub rate limit reached",
  323. "error": "GitHub rate limit reached; retry later",
  324. }
  325. return {
  326. "update_available": False,
  327. "current_version": APP_VERSION,
  328. "latest_version": None,
  329. "error": "GitHub rate limit reached; retry later",
  330. "retry_after_seconds": int(_seconds_until_github_unblocked()),
  331. }
  332. if response.status_code == 404:
  333. # No releases yet
  334. _update_status = {
  335. "status": "idle",
  336. "progress": 100,
  337. "message": "No releases found",
  338. "error": None,
  339. }
  340. return {
  341. "update_available": False,
  342. "current_version": APP_VERSION,
  343. "latest_version": None,
  344. "message": "No releases found",
  345. }
  346. response.raise_for_status()
  347. releases = response.json()
  348. # Find the appropriate release based on beta setting
  349. release_data = None
  350. for release in releases:
  351. tag = release.get("tag_name", "")
  352. if include_beta:
  353. # Accept any release (first = newest)
  354. release_data = release
  355. break
  356. else:
  357. # Skip prereleases (based on version parsing, not GitHub flag)
  358. parsed = parse_version(tag)
  359. if parsed[4] == 0: # is_prerelease == 0
  360. release_data = release
  361. break
  362. if not release_data:
  363. _update_status = {
  364. "status": "idle",
  365. "progress": 100,
  366. "message": "No releases found",
  367. "error": None,
  368. }
  369. return {
  370. "update_available": False,
  371. "current_version": APP_VERSION,
  372. "latest_version": None,
  373. "message": "No releases found",
  374. }
  375. latest_version = release_data.get("tag_name", "").lstrip("v")
  376. release_name = release_data.get("name", latest_version)
  377. release_notes = release_data.get("body", "")
  378. release_url = release_data.get("html_url", "")
  379. published_at = release_data.get("published_at", "")
  380. update_available = is_newer_version(latest_version, APP_VERSION)
  381. _update_status = {
  382. "status": "idle",
  383. "progress": 100,
  384. "message": "Update available" if update_available else "Up to date",
  385. "error": None,
  386. }
  387. is_docker = _is_docker_environment()
  388. is_ha_addon = _is_ha_addon()
  389. if is_ha_addon:
  390. update_method = "ha_addon"
  391. elif is_docker:
  392. update_method = "docker"
  393. else:
  394. update_method = "git"
  395. return {
  396. "update_available": update_available,
  397. "current_version": APP_VERSION,
  398. "latest_version": latest_version,
  399. "release_name": release_name,
  400. "release_notes": release_notes,
  401. "release_url": release_url,
  402. "published_at": published_at,
  403. "is_docker": is_docker,
  404. "is_ha_addon": is_ha_addon,
  405. "update_method": update_method,
  406. }
  407. except httpx.HTTPError as e:
  408. logger.error("Failed to check for updates: %s", e)
  409. _update_status = {
  410. "status": "error",
  411. "progress": 0,
  412. "message": "Failed to check for updates",
  413. "error": "Failed to check for updates",
  414. }
  415. return {
  416. "update_available": False,
  417. "current_version": APP_VERSION,
  418. "latest_version": None,
  419. "error": "Failed to check for updates",
  420. }
  421. async def _discover_target_release(db: AsyncSession) -> str | None:
  422. """Look up the tag we should install from GitHub releases.
  423. Same selection logic the GUI's update-check uses: respect
  424. `include_beta_updates`, skip prereleases when the user opted out, take
  425. the first matching release. Returns the raw tag name (e.g. `v0.2.4b1`)
  426. so the git ref is unambiguous, or None if there's no release to install.
  427. The previous in-app updater path was hardcoded to `git fetch origin main
  428. && git reset --hard origin/main`, which silently no-ops whenever main
  429. isn't where the latest release lives — e.g. during a beta release cycle
  430. where the next stable hasn't been merged to main yet. Anchoring to the
  431. release tag instead lets the GUI install whatever GitHub says is latest.
  432. """
  433. result = await db.execute(select(Settings).where(Settings.key == "include_beta_updates"))
  434. beta_setting = result.scalar_one_or_none()
  435. include_beta = beta_setting and beta_setting.value.lower() == "true"
  436. if _seconds_until_github_unblocked() > 0:
  437. logger.warning("Skipping update target discovery: GitHub rate-limit backoff still active")
  438. return None
  439. try:
  440. async with httpx.AsyncClient() as client:
  441. response = await client.get(
  442. f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
  443. headers={"Accept": "application/vnd.github.v3+json"},
  444. timeout=10.0,
  445. )
  446. if _is_github_rate_limit_response(response):
  447. _record_github_rate_limit(response)
  448. return None
  449. response.raise_for_status()
  450. releases = response.json()
  451. except (httpx.HTTPError, ValueError) as exc:
  452. logger.error("Could not fetch GitHub releases for update target: %s", exc)
  453. return None
  454. for release in releases:
  455. tag = release.get("tag_name", "")
  456. if not tag:
  457. continue
  458. if include_beta:
  459. return tag
  460. # Skip prereleases (parsed from version, not GitHub flag — GitHub's
  461. # is_prerelease flag isn't always set on dailies).
  462. parsed = parse_version(tag)
  463. if parsed[4] == 0:
  464. return tag
  465. return None
  466. async def _perform_update(target_ref: str):
  467. """Perform the actual update using git fetch and reset.
  468. `target_ref` is whatever git ref the caller wants to land on — typically
  469. a release tag like `v0.2.4b1` resolved by `_discover_target_release`,
  470. but accepts any ref `git reset --hard` understands (`origin/main`, a
  471. branch, a sha). Tag-based refs are the production path because they pin
  472. the install to a specific release artifact instead of whatever happens
  473. to be on a moving branch.
  474. """
  475. global _update_status
  476. try:
  477. base_dir = settings.base_dir
  478. # Find git executable (may not be in PATH when running as systemd service)
  479. git_path = _find_executable("git")
  480. if not git_path:
  481. _update_status = {
  482. "status": "error",
  483. "progress": 0,
  484. "message": "Git not found",
  485. "error": "Could not find git executable. Please ensure git is installed.",
  486. }
  487. return
  488. logger.info("Using git at: %s", git_path)
  489. # Git config to avoid safe.directory issues
  490. git_config = ["-c", f"safe.directory={base_dir}"]
  491. _update_status = {
  492. "status": "downloading",
  493. "progress": 10,
  494. "message": "Configuring git...",
  495. "error": None,
  496. }
  497. # Ensure remote points at the expected repo. We previously rewrote
  498. # origin to HTTPS unconditionally on the assumption that systemd
  499. # service users wouldn't have SSH keys configured — which is fine
  500. # for that case, but stomps on developer checkouts where origin is
  501. # legitimately `git@github.com:maziggy/bambuddy.git` and the user
  502. # auths via SSH keys. After the rewrite, `git push` prompts for
  503. # HTTPS credentials and fails.
  504. # New behaviour: read the current origin, parse out the
  505. # `<owner>/<repo>` pair, and only rewrite if it doesn't already
  506. # resolve to the right GitHub repo. SSH origins pointing at the
  507. # correct repo are preserved; only missing / wrong / corrupted
  508. # origins get reset to HTTPS.
  509. https_url = f"https://github.com/{GITHUB_REPO}.git"
  510. if not await _origin_points_at_repo(git_path, git_config, base_dir, GITHUB_REPO):
  511. process = await asyncio.create_subprocess_exec(
  512. git_path,
  513. *git_config,
  514. "remote",
  515. "set-url",
  516. "origin",
  517. https_url,
  518. cwd=str(base_dir),
  519. stdout=asyncio.subprocess.PIPE,
  520. stderr=asyncio.subprocess.PIPE,
  521. )
  522. await process.communicate()
  523. _update_status = {
  524. "status": "downloading",
  525. "progress": 20,
  526. "message": "Fetching latest changes...",
  527. "error": None,
  528. }
  529. # Fetch branches AND tags from origin so any ref the caller passes
  530. # (release tag like `v0.2.4b1`, a branch like `main`, or a sha) is
  531. # locally resolvable for the reset below. `--tags` is required —
  532. # plain `git fetch origin` doesn't bring tags by default, so a
  533. # release tag would not be resolvable.
  534. #
  535. # `--force` lets a moved tag on the remote overwrite the local copy.
  536. # Without it, any tag that was re-tagged upstream (e.g. v0.2.1 being
  537. # re-pointed after a hotfix re-tag) makes `git fetch --tags` return
  538. # a non-zero exit even though every other ref fetched cleanly —
  539. # which we'd then surface as "Failed to fetch updates" to the user.
  540. # The in-app updater's contract is "sync me to the remote"; force-
  541. # overwriting a stale local tag matches that intent.
  542. process = await asyncio.create_subprocess_exec(
  543. git_path,
  544. *git_config,
  545. "fetch",
  546. "--prune",
  547. "--tags",
  548. "--force",
  549. "origin",
  550. cwd=str(base_dir),
  551. stdout=asyncio.subprocess.PIPE,
  552. stderr=asyncio.subprocess.PIPE,
  553. )
  554. stdout, stderr = await process.communicate()
  555. if process.returncode != 0:
  556. error_msg = stderr.decode() if stderr else "Git fetch failed"
  557. logger.error("Git fetch failed: %s", error_msg)
  558. _update_status = {
  559. "status": "error",
  560. "progress": 0,
  561. "message": "Failed to fetch updates",
  562. "error": error_msg,
  563. }
  564. return
  565. _update_status = {
  566. "status": "downloading",
  567. "progress": 40,
  568. "message": "Applying updates...",
  569. "error": None,
  570. }
  571. # Hard reset to the target ref (clean update, no merge conflicts).
  572. # `target_ref` is typically a release tag like `v0.2.4b1` resolved
  573. # from the GitHub releases API by `_discover_target_release`. The
  574. # local branch name doesn't change — only HEAD moves. Falling back
  575. # to `origin/main` here was the source of the "in-app updater can't
  576. # reach beta releases" bug.
  577. process = await asyncio.create_subprocess_exec(
  578. git_path,
  579. *git_config,
  580. "reset",
  581. "--hard",
  582. target_ref,
  583. cwd=str(base_dir),
  584. stdout=asyncio.subprocess.PIPE,
  585. stderr=asyncio.subprocess.PIPE,
  586. )
  587. stdout, stderr = await process.communicate()
  588. if process.returncode != 0:
  589. error_msg = stderr.decode() if stderr else "Git reset failed"
  590. logger.error("Git reset failed: %s", error_msg)
  591. _update_status = {
  592. "status": "error",
  593. "progress": 0,
  594. "message": "Failed to apply updates",
  595. "error": error_msg,
  596. }
  597. return
  598. _update_status = {
  599. "status": "installing",
  600. "progress": 50,
  601. "message": "Installing dependencies...",
  602. "error": None,
  603. }
  604. # Install Python dependencies — must run from the source-code directory
  605. # (where requirements.txt lives), not the data dir. On native installs
  606. # systemd sets DATA_DIR=INSTALL_PATH/data, so `base_dir` is the data dir,
  607. # not the working tree. `git reset` above worked from base_dir because
  608. # git walks up looking for .git, but `pip install -r requirements.txt`
  609. # needs the file in cwd literally.
  610. app_dir = settings.app_dir
  611. process = await asyncio.create_subprocess_exec(
  612. sys.executable,
  613. "-m",
  614. "pip",
  615. "install",
  616. "-r",
  617. "requirements.txt",
  618. "-q",
  619. cwd=str(app_dir),
  620. stdout=asyncio.subprocess.PIPE,
  621. stderr=asyncio.subprocess.PIPE,
  622. )
  623. stdout, stderr = await process.communicate()
  624. if process.returncode != 0:
  625. logger.warning("pip install warning: %s", stderr.decode() if stderr else "unknown")
  626. # Try to build frontend if npm is available (optional - static files are pre-built)
  627. npm_path = _find_executable("npm")
  628. frontend_dir = app_dir / "frontend"
  629. if npm_path and frontend_dir.exists():
  630. _update_status = {
  631. "status": "installing",
  632. "progress": 70,
  633. "message": "Building frontend...",
  634. "error": None,
  635. }
  636. # npm install
  637. process = await asyncio.create_subprocess_exec(
  638. npm_path,
  639. "install",
  640. cwd=str(frontend_dir),
  641. stdout=asyncio.subprocess.PIPE,
  642. stderr=asyncio.subprocess.PIPE,
  643. )
  644. await process.communicate()
  645. # npm run build
  646. process = await asyncio.create_subprocess_exec(
  647. npm_path,
  648. "run",
  649. "build",
  650. cwd=str(frontend_dir),
  651. stdout=asyncio.subprocess.PIPE,
  652. stderr=asyncio.subprocess.PIPE,
  653. )
  654. stdout, stderr = await process.communicate()
  655. if process.returncode != 0:
  656. logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
  657. else:
  658. logger.info("npm not found or frontend dir missing - using pre-built static files")
  659. _update_status = {
  660. "status": "complete",
  661. "progress": 100,
  662. "message": "Update complete! Please restart the application.",
  663. "error": None,
  664. }
  665. logger.info("Update completed successfully")
  666. except Exception as e:
  667. logger.error("Update failed: %s", e)
  668. _update_status = {
  669. "status": "error",
  670. "progress": 0,
  671. "message": "Update failed",
  672. "error": "Update failed unexpectedly",
  673. }
  674. @router.post("/apply")
  675. async def apply_update(
  676. background_tasks: BackgroundTasks,
  677. db: AsyncSession = Depends(get_db),
  678. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  679. ):
  680. """Apply available update (git pull + rebuild)."""
  681. global _update_status
  682. if _update_status["status"] in ["downloading", "installing"]:
  683. return {
  684. "success": False,
  685. "message": "Update already in progress",
  686. "status": _update_status,
  687. }
  688. # Check for managed deployment shapes that own the update lifecycle.
  689. # HA addons are also Docker, so check HA first to surface the more
  690. # specific message.
  691. if _is_ha_addon():
  692. return {
  693. "success": False,
  694. "is_ha_addon": True,
  695. "is_docker": True,
  696. "message": (
  697. "Bambuddy is running as a Home Assistant addon. "
  698. "Updates are managed by the Home Assistant Supervisor "
  699. "(Settings → Add-ons → Bambuddy → Update)."
  700. ),
  701. }
  702. if _is_docker_environment():
  703. return {
  704. "success": False,
  705. "is_docker": True,
  706. "message": (
  707. "Docker installations cannot be updated in-app. "
  708. "Please update via Docker Compose: "
  709. "git pull && docker compose build --pull && docker compose up -d"
  710. ),
  711. }
  712. # Discover which release tag to install. Resolved here (where we have
  713. # a DB session) and passed into the background task; the BG task can't
  714. # reuse this request's session since FastAPI closes it on response.
  715. target_ref = await _discover_target_release(db)
  716. if target_ref is None:
  717. return {
  718. "success": False,
  719. "message": (
  720. "Could not determine a release to install. Either GitHub is "
  721. "unreachable or no release matches your update channel "
  722. "(check the include_beta_updates setting)."
  723. ),
  724. }
  725. # Start update in background
  726. background_tasks.add_task(_perform_update, target_ref)
  727. _update_status = {
  728. "status": "downloading",
  729. "progress": 10,
  730. "message": "Starting update...",
  731. "error": None,
  732. }
  733. return {
  734. "success": True,
  735. "message": "Update started",
  736. "status": _update_status,
  737. }
  738. @router.get("/status")
  739. async def get_update_status(
  740. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  741. ):
  742. """Get current update status."""
  743. return _update_status