updates.py 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093
  1. """Update checking and management routes."""
  2. import asyncio
  3. import logging
  4. import os
  5. import re
  6. import shutil
  7. import signal
  8. import sys
  9. import time
  10. import httpx
  11. from fastapi import APIRouter, BackgroundTasks, Depends
  12. from sqlalchemy import select
  13. from sqlalchemy.ext.asyncio import AsyncSession
  14. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  15. from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
  16. from backend.app.core.database import get_db
  17. from backend.app.core.permissions import Permission
  18. from backend.app.models.settings import Settings
  19. from backend.app.models.user import User
  20. logger = logging.getLogger(__name__)
  21. router = APIRouter(prefix="/updates", tags=["updates"])
  22. # Global state for update progress
  23. # How long one update step may run. Generous, for slow links and a Raspberry
  24. # Pi, but finite: a step that hung (a stalled git fetch or npm install) left the
  25. # status at "downloading" or "installing" for good, and every retry answered
  26. # "Update already in progress" until Bambuddy was restarted.
  27. GIT_STEP_TIMEOUT_SECONDS = 300
  28. INSTALL_STEP_TIMEOUT_SECONDS = 900
  29. TERMINATE_GRACE_SECONDS = 5
  30. class _StepTimedOut(Exception):
  31. def __init__(self, step: str, seconds: float) -> None:
  32. super().__init__(f"{step} timed out after {seconds:.0f} s")
  33. self.step = step
  34. async def _communicate(process, timeout: float, step: str) -> tuple[bytes, bytes]:
  35. """``process.communicate()`` with a deadline; at it the step is stopped."""
  36. try:
  37. return await asyncio.wait_for(process.communicate(), timeout)
  38. except TimeoutError:
  39. # Asked first: git removes its lock files on SIGTERM, and a lock left
  40. # by SIGKILL would make every retry fail. Killed if it does not go.
  41. for sig, grace in ((signal.SIGTERM, TERMINATE_GRACE_SECONDS), (signal.SIGKILL, 10)):
  42. _signal_step(process, sig)
  43. try:
  44. await asyncio.wait_for(process.wait(), grace)
  45. break
  46. except (TimeoutError, ProcessLookupError):
  47. if sig == signal.SIGKILL:
  48. logger.warning("%s did not exit after being killed", step)
  49. raise _StepTimedOut(step, timeout) from None
  50. def _signal_step(process, sig) -> None:
  51. """The whole process group where the step was started in its own session
  52. (npm and pip start children of their own); otherwise the process itself."""
  53. try:
  54. if hasattr(os, "killpg") and isinstance(process.pid, int) and os.getpgid(process.pid) == process.pid:
  55. os.killpg(process.pid, sig)
  56. elif sig == signal.SIGTERM:
  57. process.terminate()
  58. else:
  59. process.kill()
  60. except (ProcessLookupError, PermissionError, OSError):
  61. pass
  62. _update_status = {
  63. "status": "idle", # idle, checking, downloading, installing, complete, error
  64. "progress": 0,
  65. "message": "",
  66. "error": None,
  67. }
  68. # GitHub rate-limit backoff (#1420): when api.github.com returns 403 with
  69. # X-RateLimit-Remaining=0, refuse to retry until X-RateLimit-Reset (epoch
  70. # seconds). Falls back to a 1-hour pause if the header is absent. Prevents
  71. # the update checker from hammering GitHub once the unauthenticated quota
  72. # (60 req/hr per source IP) is exhausted.
  73. _GITHUB_RATE_LIMIT_FALLBACK_SECONDS = 3600
  74. _github_rate_limit_until: float = 0.0
  75. def _seconds_until_github_unblocked() -> float:
  76. """Return seconds remaining until GitHub backoff lifts, or 0 if unblocked."""
  77. remaining = _github_rate_limit_until - time.time()
  78. return remaining if remaining > 0 else 0.0
  79. def _record_github_rate_limit(response: httpx.Response) -> None:
  80. """Set the backoff window from a GitHub 403 response's headers."""
  81. global _github_rate_limit_until
  82. reset_header = response.headers.get("X-RateLimit-Reset")
  83. reset_at: float | None = None
  84. if reset_header:
  85. try:
  86. reset_at = float(reset_header)
  87. except ValueError:
  88. reset_at = None
  89. if reset_at is None:
  90. reset_at = time.time() + _GITHUB_RATE_LIMIT_FALLBACK_SECONDS
  91. # Floor at a 60s minimum: protects against clock skew between the container
  92. # and GitHub (parsed reset epoch in the past would otherwise leave us with
  93. # no real backoff and we'd hammer GitHub again immediately).
  94. reset_at = max(reset_at, time.time() + 60)
  95. # Only extend the window — never shorten it via an out-of-order response.
  96. if reset_at > _github_rate_limit_until:
  97. _github_rate_limit_until = reset_at
  98. logger.warning(
  99. "GitHub rate limit hit; suppressing update checks for %.0fs (reset header=%s)",
  100. _seconds_until_github_unblocked(),
  101. reset_header,
  102. )
  103. def _is_github_rate_limit_response(response: httpx.Response) -> bool:
  104. """Detect a rate-limit response from GitHub (403/429 with Remaining=0)."""
  105. if response.status_code not in (403, 429):
  106. return False
  107. remaining = response.headers.get("X-RateLimit-Remaining")
  108. if remaining == "0":
  109. return True
  110. # Some proxies strip the header; fall back to body inspection.
  111. try:
  112. body = response.text or ""
  113. except Exception:
  114. body = ""
  115. return "rate limit" in body.lower() or "API rate limit exceeded" in body
  116. def _is_docker_environment() -> bool:
  117. """Detect if running inside a Docker container."""
  118. if os.path.exists("/.dockerenv"):
  119. return True
  120. try:
  121. with open("/proc/1/cgroup") as f:
  122. if "docker" in f.read():
  123. return True
  124. except (FileNotFoundError, PermissionError):
  125. pass # cgroup file unavailable; continue with other detection methods
  126. # Check container runtime hint (systemd sets this for Docker/podman,
  127. # but NOT for LXC/LXD — avoids false positives on Proxmox containers)
  128. try:
  129. with open("/run/systemd/container") as f:
  130. runtime = f.read().strip()
  131. if runtime in ("docker", "podman", "oci"):
  132. return True
  133. except (FileNotFoundError, PermissionError):
  134. pass
  135. return False
  136. # Mount points the shipped compose file gives Bambuddy. Only these are
  137. # consulted when guessing the compose directory — an arbitrary bind mount
  138. # (a NAS share, an external library root) says nothing about where the
  139. # compose file lives.
  140. _COMPOSE_BIND_MOUNTPOINTS = ("/app/data", "/app/logs")
  141. # A named volume resolves to ``.../docker/volumes/<project>_bambuddy_data/_data``
  142. # in mountinfo. That names the compose *project* but reveals nothing about
  143. # the directory holding the compose file, so these entries are skipped.
  144. _DOCKER_NAMED_VOLUME_ROOT = re.compile(r"/docker/volumes/[^/]+/_data/?$")
  145. def _compose_dir_from_mountinfo() -> str | None:
  146. """Guess the host directory holding the compose file, or None (#2664).
  147. ``docker compose pull`` only works from the directory containing the
  148. compose file, so the command the update box prints is unusable until the
  149. user remembers where that is. Compose knows the answer — it stamps
  150. ``com.docker.compose.project.working_dir`` onto every container it
  151. creates — but reading your own labels requires the Docker socket, and
  152. mounting that into Bambuddy would hand the container root-equivalent
  153. access to the host in exchange for a convenience string. So we infer.
  154. ``/proc/self/mountinfo`` exposes the *host* side of a bind mount in its
  155. root field: a ``./data:/app/data`` line in the compose file surfaces as
  156. ``/opt/bambuddy/data``, whose parent is the compose directory. The leaf
  157. must match the mount point's own name before we take the parent —
  158. ``/mnt/nas/prints:/app/data`` is a bind mount whose parent is emphatically
  159. not a compose directory.
  160. This is a guess and is treated as one — it only ever prefills the setting
  161. the user can overwrite. The root field is relative to the *mounted device*
  162. rather than to the host's ``/``, so a compose directory that sits under a
  163. separate mount loses that mount's own prefix. Measured against real
  164. containers: a compose file on the root filesystem (here a ZFS dataset
  165. mounted at ``/``) came back exactly right, while one under ``/tmp`` — its
  166. own tmpfs — inferred ``/claude-1001/...`` for ``/tmp/claude-1001/...``.
  167. Nothing inside the container can tell the two apart, which is precisely
  168. why the field is editable. The shipped compose file uses named volumes,
  169. for which nothing is inferable at all.
  170. """
  171. try:
  172. with open("/proc/self/mountinfo") as f:
  173. lines = f.readlines()
  174. except OSError:
  175. return None
  176. for line in lines:
  177. parts = line.split()
  178. # mountID parentID major:minor root mountPoint ...
  179. if len(parts) < 5:
  180. continue
  181. root, mount_point = parts[3], parts[4]
  182. if mount_point not in _COMPOSE_BIND_MOUNTPOINTS:
  183. continue
  184. if _DOCKER_NAMED_VOLUME_ROOT.search(root):
  185. continue
  186. parent, _, leaf = root.rstrip("/").rpartition("/")
  187. if parent and leaf == mount_point.rsplit("/", 1)[-1]:
  188. return parent
  189. return None
  190. def _detect_compose_dir() -> str | None:
  191. """Best-effort compose directory for the update instructions (#2664).
  192. ``BAMBUDDY_COMPOSE_DIR`` wins when set — it is the only source that is
  193. stated rather than inferred, and the shipped compose file carries a
  194. commented ``${PWD}`` line for it.
  195. """
  196. env_dir = os.environ.get("BAMBUDDY_COMPOSE_DIR", "").strip()
  197. if env_dir:
  198. return env_dir
  199. if not _is_docker_environment():
  200. return None
  201. return _compose_dir_from_mountinfo()
  202. def _is_ha_addon() -> bool:
  203. """Detect if running as a Home Assistant Supervisor addon.
  204. HA Supervisor injects ``SUPERVISOR_TOKEN`` into every addon container;
  205. the variable is not set in any other environment, so a single env-var
  206. check is sufficient with no false-positive surface.
  207. """
  208. return bool(os.environ.get("SUPERVISOR_TOKEN"))
  209. def _is_windows_installer_install() -> bool:
  210. """Detect a Windows install that came from the Inno Setup installer.
  211. The installer stages backend source via ``shutil.copytree`` (no ``.git``
  212. directory) and does not bundle ``git.exe`` — so the git-fetch-and-reset
  213. update path used everywhere else is structurally inoperable here. We
  214. surface this as a distinct ``update_method`` and direct the user at the
  215. release asset instead.
  216. A Windows developer running from a real ``git clone`` keeps the git
  217. path (``.git`` present), so this only catches installer users.
  218. """
  219. if sys.platform != "win32":
  220. return False
  221. return not (settings.app_dir / ".git").exists()
  222. def _find_windows_installer_asset(release_data: dict) -> str | None:
  223. """Pick the Windows installer .exe out of a GitHub release's assets list.
  224. Both filenames the workflow uploads end in ``windows-x64-setup.exe``
  225. (versioned ``bambuddy-<version>-windows-x64-setup.exe`` and the
  226. unversioned alias ``bambuddy-windows-x64-setup.exe`` on non-daily tags
  227. only). Either works as a download URL; we prefer the versioned form
  228. because it's the one guaranteed to exist on every release including
  229. dailies.
  230. """
  231. assets = release_data.get("assets") or []
  232. versioned: str | None = None
  233. unversioned: str | None = None
  234. for asset in assets:
  235. name = asset.get("name") or ""
  236. url = asset.get("browser_download_url")
  237. if not isinstance(name, str) or not isinstance(url, str):
  238. continue
  239. if not name.endswith("windows-x64-setup.exe"):
  240. continue
  241. if name == "bambuddy-windows-x64-setup.exe":
  242. unversioned = url
  243. else:
  244. versioned = url
  245. return versioned or unversioned
  246. def _find_executable(name: str) -> str | None:
  247. """Find an executable in PATH or common locations."""
  248. # Try standard PATH first
  249. path = shutil.which(name)
  250. if path:
  251. return path
  252. # Common locations for executables (useful when running as systemd service)
  253. common_paths = [
  254. f"/usr/bin/{name}",
  255. f"/usr/local/bin/{name}",
  256. f"/opt/homebrew/bin/{name}",
  257. f"/home/linuxbrew/.linuxbrew/bin/{name}",
  258. f"{os.path.expanduser('~')}/.nvm/current/bin/{name}",
  259. f"{os.path.expanduser('~')}/.local/bin/{name}",
  260. ]
  261. for p in common_paths:
  262. if os.path.isfile(p) and os.access(p, os.X_OK):
  263. return p
  264. return None
  265. def _parse_github_remote(url: str) -> tuple[str, str] | None:
  266. """Extract `(owner, repo)` from a GitHub remote URL, or None if it isn't a
  267. GitHub URL we recognise.
  268. Handles the four forms `git remote -v` typically prints:
  269. - `git@github.com:owner/repo.git` (SSH, the dev default)
  270. - `git@github.com:owner/repo` (SSH without .git suffix)
  271. - `https://github.com/owner/repo.git` (HTTPS, what _perform_update sets)
  272. - `https://github.com/owner/repo` (HTTPS without .git)
  273. Anything else (a fork URL, a different host, a malformed value, the empty
  274. string from a missing origin) returns None so the caller treats it as
  275. "not pointing at our repo" and resets it.
  276. """
  277. s = url.strip()
  278. if not s:
  279. return None
  280. # SSH form: git@github.com:owner/repo[.git]
  281. ssh_prefix = "git@github.com:"
  282. https_prefix_a = "https://github.com/"
  283. https_prefix_b = "http://github.com/" # tolerated for legacy
  284. if s.startswith(ssh_prefix):
  285. path = s[len(ssh_prefix) :]
  286. elif s.startswith(https_prefix_a):
  287. path = s[len(https_prefix_a) :]
  288. elif s.startswith(https_prefix_b):
  289. path = s[len(https_prefix_b) :]
  290. else:
  291. return None
  292. if path.endswith(".git"):
  293. path = path[:-4]
  294. parts = path.strip("/").split("/")
  295. if len(parts) != 2 or not parts[0] or not parts[1]:
  296. return None
  297. return (parts[0], parts[1])
  298. async def _origin_points_at_repo(git_path: str, git_config: list[str], app_dir, expected_repo: str) -> bool:
  299. """Return True iff the working tree's `origin` already resolves to
  300. `<owner>/<repo>` matching `expected_repo` (e.g. "maziggy/bambuddy"),
  301. regardless of whether it's the SSH or HTTPS form. Used to skip the
  302. `git remote set-url origin https://...` rewrite when the developer's
  303. SSH origin is already correct — see `_perform_update` for context.
  304. ``app_dir`` is the working tree (where ``.git`` lives), not the data
  305. dir — see #1715 for the separate-mount layout that proved why this
  306. must NOT be ``base_dir``."""
  307. try:
  308. process = await asyncio.create_subprocess_exec(
  309. git_path,
  310. *git_config,
  311. "remote",
  312. "get-url",
  313. "origin",
  314. cwd=str(app_dir),
  315. stdout=asyncio.subprocess.PIPE,
  316. stderr=asyncio.subprocess.PIPE,
  317. )
  318. stdout, _ = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git remote get-url")
  319. except (OSError, asyncio.CancelledError, _StepTimedOut):
  320. # Fail closed: let the caller go through the rewrite branch if we
  321. # can't even invoke git. The unconditional set-url is the safer
  322. # fallback, only mildly destructive.
  323. return False
  324. if process.returncode != 0:
  325. # Most likely cause: no `origin` defined yet (fresh clone-style
  326. # checkout). Caller will set it.
  327. return False
  328. parsed = _parse_github_remote(stdout.decode().strip())
  329. if parsed is None:
  330. return False
  331. owner, repo = parsed
  332. expected_owner, expected_repo_name = expected_repo.split("/", 1)
  333. return owner == expected_owner and repo == expected_repo_name
  334. def parse_version(version: str) -> tuple:
  335. """Parse version string into tuple for comparison.
  336. Returns (major, minor, patch, micro, is_prerelease, prerelease_num)
  337. where is_prerelease is 0 for release, 1 for prerelease.
  338. This ensures releases sort higher than prereleases of same version.
  339. Examples:
  340. "0.1.5" -> (0, 1, 5, 0, 0, 0) # release
  341. "0.1.5b7" -> (0, 1, 5, 0, 1, 7) # beta 7
  342. "0.1.5b10" -> (0, 1, 5, 0, 1, 10) # beta 10
  343. "0.1.8.1" -> (0, 1, 8, 1, 0, 0) # patch release
  344. """
  345. # Remove 'v' prefix if present
  346. version = version.lstrip("v")
  347. # Strip daily build suffix (e.g., "0.2.2b4-daily.20260313" -> "0.2.2b4")
  348. version = re.sub(r"-daily\.\d+$", "", version)
  349. # Match version pattern: major.minor.patch[.micro][b|beta|alpha|rc]N
  350. match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?(?:b|beta|alpha|rc)?(\d+)?", version)
  351. if match:
  352. major = int(match.group(1))
  353. minor = int(match.group(2))
  354. patch = int(match.group(3))
  355. micro = int(match.group(4)) if match.group(4) else 0
  356. prerelease_num = int(match.group(5)) if match.group(5) else 0
  357. # Check if this is a prerelease (has b/beta/alpha/rc/daily suffix anywhere)
  358. is_prerelease = 1 if re.search(r"[a-zA-Z]", version) else 0
  359. return (major, minor, patch, micro, is_prerelease, prerelease_num)
  360. # Fallback: try simple split
  361. parts = []
  362. for part in version.split("."):
  363. try:
  364. parts.append(int(part))
  365. except ValueError:
  366. num = "".join(c for c in part if c.isdigit())
  367. parts.append(int(num) if num else 0)
  368. return tuple(parts) + (0, 0, 0)
  369. def is_newer_version(latest: str, current: str) -> bool:
  370. """Check if latest version is newer than current.
  371. Properly handles prerelease versions:
  372. - 0.1.5 > 0.1.5b7 (release is newer than any beta)
  373. - 0.1.5b8 > 0.1.5b7 (later beta is newer)
  374. - 0.1.6b1 > 0.1.5 (next version beta is newer than current release)
  375. """
  376. try:
  377. latest_parsed = parse_version(latest)
  378. current_parsed = parse_version(current)
  379. # Compare (major, minor, patch, micro) first
  380. latest_base = latest_parsed[:4]
  381. current_base = current_parsed[:4]
  382. if latest_base > current_base:
  383. return True
  384. elif latest_base < current_base:
  385. return False
  386. # Same base version - compare prerelease status
  387. # is_prerelease: 0 = release, 1 = prerelease
  388. # Release (0) should be "greater" than prerelease (1)
  389. latest_is_prerelease = latest_parsed[4] if len(latest_parsed) > 4 else 0
  390. current_is_prerelease = current_parsed[4] if len(current_parsed) > 4 else 0
  391. if latest_is_prerelease < current_is_prerelease:
  392. # latest is release, current is prerelease -> latest is newer
  393. return True
  394. elif latest_is_prerelease > current_is_prerelease:
  395. # latest is prerelease, current is release -> latest is NOT newer
  396. return False
  397. # Both are same type (both release or both prerelease)
  398. # Compare prerelease numbers
  399. latest_prerelease_num = latest_parsed[5] if len(latest_parsed) > 5 else 0
  400. current_prerelease_num = current_parsed[5] if len(current_parsed) > 5 else 0
  401. return latest_prerelease_num > current_prerelease_num
  402. except Exception:
  403. return False
  404. @router.get("/version")
  405. async def get_version():
  406. """Get current application version.
  407. Note: Unauthenticated - needed to display version in UI without login.
  408. """
  409. return {
  410. "version": APP_VERSION,
  411. "repo": GITHUB_REPO,
  412. }
  413. @router.get("/check")
  414. async def check_for_updates(
  415. db: AsyncSession = Depends(get_db),
  416. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  417. ):
  418. """Check GitHub for available updates."""
  419. global _update_status
  420. # Respect the check_updates setting
  421. result = await db.execute(select(Settings).where(Settings.key == "check_updates"))
  422. setting = result.scalar_one_or_none()
  423. if setting and setting.value.lower() == "false":
  424. return {
  425. "update_available": False,
  426. "current_version": APP_VERSION,
  427. "latest_version": None,
  428. "message": "Update checks are disabled",
  429. }
  430. # Check if beta updates should be included
  431. result = await db.execute(select(Settings).where(Settings.key == "include_beta_updates"))
  432. beta_setting = result.scalar_one_or_none()
  433. include_beta = beta_setting and beta_setting.value.lower() == "true"
  434. # Short-circuit if we're still inside a GitHub rate-limit backoff window (#1420).
  435. backoff_remaining = _seconds_until_github_unblocked()
  436. if backoff_remaining > 0:
  437. _update_status = {
  438. "status": "error",
  439. "progress": 0,
  440. "message": "GitHub rate limit reached",
  441. "error": "GitHub rate limit reached; retry later",
  442. }
  443. return {
  444. "update_available": False,
  445. "current_version": APP_VERSION,
  446. "latest_version": None,
  447. "error": "GitHub rate limit reached; retry later",
  448. "retry_after_seconds": int(backoff_remaining),
  449. }
  450. _update_status = {
  451. "status": "checking",
  452. "progress": 0,
  453. "message": "Checking for updates...",
  454. "error": None,
  455. }
  456. try:
  457. async with httpx.AsyncClient() as client:
  458. response = await client.get(
  459. f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
  460. headers={"Accept": "application/vnd.github.v3+json"},
  461. timeout=10.0,
  462. )
  463. if _is_github_rate_limit_response(response):
  464. _record_github_rate_limit(response)
  465. _update_status = {
  466. "status": "error",
  467. "progress": 0,
  468. "message": "GitHub rate limit reached",
  469. "error": "GitHub rate limit reached; retry later",
  470. }
  471. return {
  472. "update_available": False,
  473. "current_version": APP_VERSION,
  474. "latest_version": None,
  475. "error": "GitHub rate limit reached; retry later",
  476. "retry_after_seconds": int(_seconds_until_github_unblocked()),
  477. }
  478. if response.status_code == 404:
  479. # No releases yet
  480. _update_status = {
  481. "status": "idle",
  482. "progress": 100,
  483. "message": "No releases found",
  484. "error": None,
  485. }
  486. return {
  487. "update_available": False,
  488. "current_version": APP_VERSION,
  489. "latest_version": None,
  490. "message": "No releases found",
  491. }
  492. response.raise_for_status()
  493. releases = response.json()
  494. # Find the appropriate release based on beta setting
  495. release_data = None
  496. for release in releases:
  497. tag = release.get("tag_name", "")
  498. if include_beta:
  499. # Accept any release (first = newest)
  500. release_data = release
  501. break
  502. else:
  503. # Skip prereleases (based on version parsing, not GitHub flag)
  504. parsed = parse_version(tag)
  505. if parsed[4] == 0: # is_prerelease == 0
  506. release_data = release
  507. break
  508. if not release_data:
  509. _update_status = {
  510. "status": "idle",
  511. "progress": 100,
  512. "message": "No releases found",
  513. "error": None,
  514. }
  515. return {
  516. "update_available": False,
  517. "current_version": APP_VERSION,
  518. "latest_version": None,
  519. "message": "No releases found",
  520. }
  521. latest_version = release_data.get("tag_name", "").lstrip("v")
  522. release_name = release_data.get("name", latest_version)
  523. release_notes = release_data.get("body", "")
  524. release_url = release_data.get("html_url", "")
  525. published_at = release_data.get("published_at", "")
  526. update_available = is_newer_version(latest_version, APP_VERSION)
  527. _update_status = {
  528. "status": "idle",
  529. "progress": 100,
  530. "message": "Update available" if update_available else "Up to date",
  531. "error": None,
  532. }
  533. is_docker = _is_docker_environment()
  534. is_ha_addon = _is_ha_addon()
  535. is_windows_installer = _is_windows_installer_install()
  536. installer_download_url: str | None = None
  537. if is_ha_addon:
  538. update_method = "ha_addon"
  539. elif is_docker:
  540. update_method = "docker"
  541. elif is_windows_installer:
  542. update_method = "windows_installer"
  543. installer_download_url = _find_windows_installer_asset(release_data)
  544. else:
  545. update_method = "git"
  546. return {
  547. "update_available": update_available,
  548. "current_version": APP_VERSION,
  549. "latest_version": latest_version,
  550. "release_name": release_name,
  551. "release_notes": release_notes,
  552. "release_url": release_url,
  553. "published_at": published_at,
  554. "is_docker": is_docker,
  555. "is_ha_addon": is_ha_addon,
  556. "is_windows_installer": is_windows_installer,
  557. "update_method": update_method,
  558. "installer_download_url": installer_download_url,
  559. # Prefill only — never the value the user saved. The settings
  560. # response owns ``docker_compose_dir``; keeping the two apart
  561. # means clearing the field falls back to the guess instead of
  562. # resurrecting the cleared value from a stale update check.
  563. "compose_dir_detected": _detect_compose_dir() if update_method == "docker" else None,
  564. }
  565. except httpx.HTTPError as e:
  566. logger.error("Failed to check for updates: %s", e)
  567. _update_status = {
  568. "status": "error",
  569. "progress": 0,
  570. "message": "Failed to check for updates",
  571. "error": "Failed to check for updates",
  572. }
  573. return {
  574. "update_available": False,
  575. "current_version": APP_VERSION,
  576. "latest_version": None,
  577. "error": "Failed to check for updates",
  578. }
  579. async def _discover_target_release(db: AsyncSession) -> str | None:
  580. """Look up the tag we should install from GitHub releases.
  581. Same selection logic the GUI's update-check uses: respect
  582. `include_beta_updates`, skip prereleases when the user opted out, take
  583. the first matching release. Returns the raw tag name (e.g. `v0.2.4b1`)
  584. so the git ref is unambiguous, or None if there's no release to install.
  585. The previous in-app updater path was hardcoded to `git fetch origin main
  586. && git reset --hard origin/main`, which silently no-ops whenever main
  587. isn't where the latest release lives — e.g. during a beta release cycle
  588. where the next stable hasn't been merged to main yet. Anchoring to the
  589. release tag instead lets the GUI install whatever GitHub says is latest.
  590. """
  591. result = await db.execute(select(Settings).where(Settings.key == "include_beta_updates"))
  592. beta_setting = result.scalar_one_or_none()
  593. include_beta = beta_setting and beta_setting.value.lower() == "true"
  594. if _seconds_until_github_unblocked() > 0:
  595. logger.warning("Skipping update target discovery: GitHub rate-limit backoff still active")
  596. return None
  597. try:
  598. async with httpx.AsyncClient() as client:
  599. response = await client.get(
  600. f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
  601. headers={"Accept": "application/vnd.github.v3+json"},
  602. timeout=10.0,
  603. )
  604. if _is_github_rate_limit_response(response):
  605. _record_github_rate_limit(response)
  606. return None
  607. response.raise_for_status()
  608. releases = response.json()
  609. except (httpx.HTTPError, ValueError) as exc:
  610. logger.error("Could not fetch GitHub releases for update target: %s", exc)
  611. return None
  612. for release in releases:
  613. tag = release.get("tag_name", "")
  614. if not tag:
  615. continue
  616. if include_beta:
  617. return tag
  618. # Skip prereleases (parsed from version, not GitHub flag — GitHub's
  619. # is_prerelease flag isn't always set on dailies).
  620. parsed = parse_version(tag)
  621. if parsed[4] == 0:
  622. return tag
  623. return None
  624. async def _perform_update(target_ref: str):
  625. """Perform the actual update using git fetch and reset.
  626. `target_ref` is whatever git ref the caller wants to land on — typically
  627. a release tag like `v0.2.4b1` resolved by `_discover_target_release`,
  628. but accepts any ref `git reset --hard` understands (`origin/main`, a
  629. branch, a sha). Tag-based refs are the production path because they pin
  630. the install to a specific release artifact instead of whatever happens
  631. to be on a moving branch.
  632. """
  633. global _update_status
  634. try:
  635. # Every git step runs against the working tree (app_dir), NOT base_dir.
  636. # On a standard install with DATA_DIR=INSTALL_PATH/data, git happens
  637. # to walk up from a subdirectory of the repo to find .git so cwd=base_dir
  638. # used to silently work — but only by accident. On a native install with
  639. # DATA_DIR mounted at an unrelated path (e.g. /srv/bambuddy/data while
  640. # the install is /opt/bambuddy — see #1715), git can't walk up and every
  641. # operation fails with "not a git repository". safe.directory has the
  642. # same requirement: it must equal the repo root git discovers, not the
  643. # data dir, or every call returns "fatal: detected dubious ownership."
  644. app_dir = settings.app_dir
  645. # Find git executable (may not be in PATH when running as systemd service)
  646. git_path = _find_executable("git")
  647. if not git_path:
  648. _update_status = {
  649. "status": "error",
  650. "progress": 0,
  651. "message": "Git not found",
  652. "error": "Could not find git executable. Please ensure git is installed.",
  653. }
  654. return
  655. logger.info("Using git at: %s", git_path)
  656. # Git config to avoid safe.directory issues — must point at the working
  657. # tree (where .git lives), see app_dir comment above.
  658. git_config = ["-c", f"safe.directory={app_dir}"]
  659. _update_status = {
  660. "status": "downloading",
  661. "progress": 10,
  662. "message": "Configuring git...",
  663. "error": None,
  664. }
  665. # Ensure remote points at the expected repo. We previously rewrote
  666. # origin to HTTPS unconditionally on the assumption that systemd
  667. # service users wouldn't have SSH keys configured — which is fine
  668. # for that case, but stomps on developer checkouts where origin is
  669. # legitimately `git@github.com:maziggy/bambuddy.git` and the user
  670. # auths via SSH keys. After the rewrite, `git push` prompts for
  671. # HTTPS credentials and fails.
  672. # New behaviour: read the current origin, parse out the
  673. # `<owner>/<repo>` pair, and only rewrite if it doesn't already
  674. # resolve to the right GitHub repo. SSH origins pointing at the
  675. # correct repo are preserved; only missing / wrong / corrupted
  676. # origins get reset to HTTPS.
  677. https_url = f"https://github.com/{GITHUB_REPO}.git"
  678. if not await _origin_points_at_repo(git_path, git_config, app_dir, GITHUB_REPO):
  679. process = await asyncio.create_subprocess_exec(
  680. git_path,
  681. *git_config,
  682. "remote",
  683. "set-url",
  684. "origin",
  685. https_url,
  686. cwd=str(app_dir),
  687. stdout=asyncio.subprocess.PIPE,
  688. stderr=asyncio.subprocess.PIPE,
  689. )
  690. await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git remote set-url")
  691. _update_status = {
  692. "status": "downloading",
  693. "progress": 20,
  694. "message": "Fetching latest changes...",
  695. "error": None,
  696. }
  697. # Fetch branches AND tags from origin so any ref the caller passes
  698. # (release tag like `v0.2.4b1`, a branch like `main`, or a sha) is
  699. # locally resolvable for the reset below. `--tags` is required —
  700. # plain `git fetch origin` doesn't bring tags by default, so a
  701. # release tag would not be resolvable.
  702. #
  703. # `--force` lets a moved tag on the remote overwrite the local copy.
  704. # Without it, any tag that was re-tagged upstream (e.g. v0.2.1 being
  705. # re-pointed after a hotfix re-tag) makes `git fetch --tags` return
  706. # a non-zero exit even though every other ref fetched cleanly —
  707. # which we'd then surface as "Failed to fetch updates" to the user.
  708. # The in-app updater's contract is "sync me to the remote"; force-
  709. # overwriting a stale local tag matches that intent.
  710. process = await asyncio.create_subprocess_exec(
  711. git_path,
  712. *git_config,
  713. "fetch",
  714. "--prune",
  715. "--tags",
  716. "--force",
  717. "origin",
  718. cwd=str(app_dir),
  719. stdout=asyncio.subprocess.PIPE,
  720. stderr=asyncio.subprocess.PIPE,
  721. )
  722. stdout, stderr = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git fetch")
  723. if process.returncode != 0:
  724. error_msg = stderr.decode() if stderr else "Git fetch failed"
  725. logger.error("Git fetch failed: %s", error_msg)
  726. _update_status = {
  727. "status": "error",
  728. "progress": 0,
  729. "message": "Failed to fetch updates",
  730. "error": error_msg,
  731. }
  732. return
  733. _update_status = {
  734. "status": "downloading",
  735. "progress": 40,
  736. "message": "Applying updates...",
  737. "error": None,
  738. }
  739. # Hard reset to the target ref (clean update, no merge conflicts).
  740. # `target_ref` is typically a release tag like `v0.2.4b1` resolved
  741. # from the GitHub releases API by `_discover_target_release`. The
  742. # local branch name doesn't change — only HEAD moves. Falling back
  743. # to `origin/main` here was the source of the "in-app updater can't
  744. # reach beta releases" bug.
  745. process = await asyncio.create_subprocess_exec(
  746. git_path,
  747. *git_config,
  748. "reset",
  749. "--hard",
  750. target_ref,
  751. cwd=str(app_dir),
  752. stdout=asyncio.subprocess.PIPE,
  753. stderr=asyncio.subprocess.PIPE,
  754. )
  755. stdout, stderr = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git reset")
  756. if process.returncode != 0:
  757. error_msg = stderr.decode() if stderr else "Git reset failed"
  758. logger.error("Git reset failed: %s", error_msg)
  759. _update_status = {
  760. "status": "error",
  761. "progress": 0,
  762. "message": "Failed to apply updates",
  763. "error": error_msg,
  764. }
  765. return
  766. _update_status = {
  767. "status": "installing",
  768. "progress": 50,
  769. "message": "Installing dependencies...",
  770. "error": None,
  771. }
  772. # Install Python dependencies — must run from the source-code directory
  773. # (where requirements.txt lives). app_dir is already resolved at the top
  774. # of this function; see the comment there for why every step uses it
  775. # instead of base_dir.
  776. process = await asyncio.create_subprocess_exec(
  777. sys.executable,
  778. "-m",
  779. "pip",
  780. "install",
  781. "-r",
  782. "requirements.txt",
  783. "-q",
  784. cwd=str(app_dir),
  785. stdout=asyncio.subprocess.PIPE,
  786. stderr=asyncio.subprocess.PIPE,
  787. start_new_session=True,
  788. )
  789. stdout, stderr = await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "pip install")
  790. if process.returncode != 0:
  791. logger.warning("pip install warning: %s", stderr.decode() if stderr else "unknown")
  792. # Try to build frontend if npm is available (optional - static files are pre-built)
  793. npm_path = _find_executable("npm")
  794. frontend_dir = app_dir / "frontend"
  795. if npm_path and frontend_dir.exists():
  796. _update_status = {
  797. "status": "installing",
  798. "progress": 70,
  799. "message": "Building frontend...",
  800. "error": None,
  801. }
  802. # Optional: the release ships pre-built static files, so a build
  803. # that fails or runs out of time leaves those in place.
  804. try:
  805. # npm install
  806. process = await asyncio.create_subprocess_exec(
  807. npm_path,
  808. "install",
  809. cwd=str(frontend_dir),
  810. stdout=asyncio.subprocess.PIPE,
  811. stderr=asyncio.subprocess.PIPE,
  812. start_new_session=True,
  813. )
  814. await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "npm install")
  815. # npm run build
  816. process = await asyncio.create_subprocess_exec(
  817. npm_path,
  818. "run",
  819. "build",
  820. cwd=str(frontend_dir),
  821. stdout=asyncio.subprocess.PIPE,
  822. stderr=asyncio.subprocess.PIPE,
  823. start_new_session=True,
  824. )
  825. stdout, stderr = await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "npm run build")
  826. if process.returncode != 0:
  827. logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
  828. except _StepTimedOut as exc:
  829. logger.warning("%s; keeping the pre-built frontend", exc)
  830. else:
  831. logger.info("npm not found or frontend dir missing - using pre-built static files")
  832. _update_status = {
  833. "status": "complete",
  834. "progress": 100,
  835. "message": "Update complete! Please restart the application.",
  836. "error": None,
  837. }
  838. logger.info("Update completed successfully")
  839. except _StepTimedOut as exc:
  840. logger.error("Update failed: %s", exc)
  841. if exc.step == "pip install":
  842. # git reset has already moved the code: it must not be started on
  843. # dependencies that were never installed.
  844. advice = (
  845. "The new code is in place but its dependencies were not installed. "
  846. "Check for updates and run the update again before restarting."
  847. )
  848. elif exc.step == "git reset":
  849. # Stopped while the files were being replaced: some may be new.
  850. advice = (
  851. "The new code may be only partly in place. "
  852. "Check for updates and run the update again before restarting."
  853. )
  854. else:
  855. advice = "Nothing was installed. Check for updates and run the update again."
  856. _update_status = {
  857. "status": "error",
  858. "progress": 0,
  859. "message": f"Update failed: {exc.step} took too long",
  860. "error": f"{exc}. {advice}",
  861. }
  862. except Exception as e:
  863. logger.error("Update failed: %s", e)
  864. _update_status = {
  865. "status": "error",
  866. "progress": 0,
  867. "message": "Update failed",
  868. "error": "Update failed unexpectedly",
  869. }
  870. @router.post("/apply")
  871. async def apply_update(
  872. background_tasks: BackgroundTasks,
  873. db: AsyncSession = Depends(get_db),
  874. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  875. ):
  876. """Apply available update (git pull + rebuild)."""
  877. global _update_status
  878. if _update_status["status"] in ["downloading", "installing"]:
  879. return {
  880. "success": False,
  881. "message": "Update already in progress",
  882. "status": _update_status,
  883. }
  884. # Check for managed deployment shapes that own the update lifecycle.
  885. # HA addons are also Docker, so check HA first to surface the more
  886. # specific message.
  887. if _is_ha_addon():
  888. return {
  889. "success": False,
  890. "is_ha_addon": True,
  891. "is_docker": True,
  892. "message": (
  893. "Bambuddy is running as a Home Assistant addon. "
  894. "Updates are managed by the Home Assistant Supervisor "
  895. "(Settings → Add-ons → Bambuddy → Update)."
  896. ),
  897. }
  898. if _is_docker_environment():
  899. return {
  900. "success": False,
  901. "is_docker": True,
  902. "message": (
  903. "Docker installations cannot be updated in-app. "
  904. "Please update via Docker Compose: "
  905. "git pull && docker compose build --pull && docker compose up -d"
  906. ),
  907. }
  908. if _is_windows_installer_install():
  909. # The installer layout has no ``.git`` and no bundled ``git.exe`` —
  910. # the git-fetch path would fail. Frontend swaps the "Update now"
  911. # button for a Download Installer link via update_method, so this
  912. # branch is only reached if /apply is hit directly.
  913. return {
  914. "success": False,
  915. "is_windows_installer": True,
  916. "message": (
  917. "Windows installations are updated by re-running the installer. "
  918. "Download the latest installer from the Bambuddy releases page."
  919. ),
  920. }
  921. # Discover which release tag to install. Resolved here (where we have
  922. # a DB session) and passed into the background task; the BG task can't
  923. # reuse this request's session since FastAPI closes it on response.
  924. target_ref = await _discover_target_release(db)
  925. if target_ref is None:
  926. return {
  927. "success": False,
  928. "message": (
  929. "Could not determine a release to install. Either GitHub is "
  930. "unreachable or no release matches your update channel "
  931. "(check the include_beta_updates setting)."
  932. ),
  933. }
  934. # Start update in background
  935. background_tasks.add_task(_perform_update, target_ref)
  936. _update_status = {
  937. "status": "downloading",
  938. "progress": 10,
  939. "message": "Starting update...",
  940. "error": None,
  941. }
  942. return {
  943. "success": True,
  944. "message": "Update started",
  945. "status": _update_status,
  946. }
  947. @router.get("/status")
  948. async def get_update_status(
  949. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  950. ):
  951. """Get current update status."""
  952. return _update_status