| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093 |
- """Update checking and management routes."""
- import asyncio
- import logging
- import os
- import re
- import shutil
- import signal
- import sys
- import time
- import httpx
- from fastapi import APIRouter, BackgroundTasks, Depends
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.auth import RequirePermissionIfAuthEnabled
- from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.settings import Settings
- from backend.app.models.user import User
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/updates", tags=["updates"])
- # Global state for update progress
- # How long one update step may run. Generous, for slow links and a Raspberry
- # Pi, but finite: a step that hung (a stalled git fetch or npm install) left the
- # status at "downloading" or "installing" for good, and every retry answered
- # "Update already in progress" until Bambuddy was restarted.
- GIT_STEP_TIMEOUT_SECONDS = 300
- INSTALL_STEP_TIMEOUT_SECONDS = 900
- TERMINATE_GRACE_SECONDS = 5
- class _StepTimedOut(Exception):
- def __init__(self, step: str, seconds: float) -> None:
- super().__init__(f"{step} timed out after {seconds:.0f} s")
- self.step = step
- async def _communicate(process, timeout: float, step: str) -> tuple[bytes, bytes]:
- """``process.communicate()`` with a deadline; at it the step is stopped."""
- try:
- return await asyncio.wait_for(process.communicate(), timeout)
- except TimeoutError:
- # Asked first: git removes its lock files on SIGTERM, and a lock left
- # by SIGKILL would make every retry fail. Killed if it does not go.
- for sig, grace in ((signal.SIGTERM, TERMINATE_GRACE_SECONDS), (signal.SIGKILL, 10)):
- _signal_step(process, sig)
- try:
- await asyncio.wait_for(process.wait(), grace)
- break
- except (TimeoutError, ProcessLookupError):
- if sig == signal.SIGKILL:
- logger.warning("%s did not exit after being killed", step)
- raise _StepTimedOut(step, timeout) from None
- def _signal_step(process, sig) -> None:
- """The whole process group where the step was started in its own session
- (npm and pip start children of their own); otherwise the process itself."""
- try:
- if hasattr(os, "killpg") and isinstance(process.pid, int) and os.getpgid(process.pid) == process.pid:
- os.killpg(process.pid, sig)
- elif sig == signal.SIGTERM:
- process.terminate()
- else:
- process.kill()
- except (ProcessLookupError, PermissionError, OSError):
- pass
- _update_status = {
- "status": "idle", # idle, checking, downloading, installing, complete, error
- "progress": 0,
- "message": "",
- "error": None,
- }
- # GitHub rate-limit backoff (#1420): when api.github.com returns 403 with
- # X-RateLimit-Remaining=0, refuse to retry until X-RateLimit-Reset (epoch
- # seconds). Falls back to a 1-hour pause if the header is absent. Prevents
- # the update checker from hammering GitHub once the unauthenticated quota
- # (60 req/hr per source IP) is exhausted.
- _GITHUB_RATE_LIMIT_FALLBACK_SECONDS = 3600
- _github_rate_limit_until: float = 0.0
- def _seconds_until_github_unblocked() -> float:
- """Return seconds remaining until GitHub backoff lifts, or 0 if unblocked."""
- remaining = _github_rate_limit_until - time.time()
- return remaining if remaining > 0 else 0.0
- def _record_github_rate_limit(response: httpx.Response) -> None:
- """Set the backoff window from a GitHub 403 response's headers."""
- global _github_rate_limit_until
- reset_header = response.headers.get("X-RateLimit-Reset")
- reset_at: float | None = None
- if reset_header:
- try:
- reset_at = float(reset_header)
- except ValueError:
- reset_at = None
- if reset_at is None:
- reset_at = time.time() + _GITHUB_RATE_LIMIT_FALLBACK_SECONDS
- # Floor at a 60s minimum: protects against clock skew between the container
- # and GitHub (parsed reset epoch in the past would otherwise leave us with
- # no real backoff and we'd hammer GitHub again immediately).
- reset_at = max(reset_at, time.time() + 60)
- # Only extend the window — never shorten it via an out-of-order response.
- if reset_at > _github_rate_limit_until:
- _github_rate_limit_until = reset_at
- logger.warning(
- "GitHub rate limit hit; suppressing update checks for %.0fs (reset header=%s)",
- _seconds_until_github_unblocked(),
- reset_header,
- )
- def _is_github_rate_limit_response(response: httpx.Response) -> bool:
- """Detect a rate-limit response from GitHub (403/429 with Remaining=0)."""
- if response.status_code not in (403, 429):
- return False
- remaining = response.headers.get("X-RateLimit-Remaining")
- if remaining == "0":
- return True
- # Some proxies strip the header; fall back to body inspection.
- try:
- body = response.text or ""
- except Exception:
- body = ""
- return "rate limit" in body.lower() or "API rate limit exceeded" in body
- def _is_docker_environment() -> bool:
- """Detect if running inside a Docker container."""
- if os.path.exists("/.dockerenv"):
- return True
- try:
- with open("/proc/1/cgroup") as f:
- if "docker" in f.read():
- return True
- except (FileNotFoundError, PermissionError):
- pass # cgroup file unavailable; continue with other detection methods
- # Check container runtime hint (systemd sets this for Docker/podman,
- # but NOT for LXC/LXD — avoids false positives on Proxmox containers)
- try:
- with open("/run/systemd/container") as f:
- runtime = f.read().strip()
- if runtime in ("docker", "podman", "oci"):
- return True
- except (FileNotFoundError, PermissionError):
- pass
- return False
- # Mount points the shipped compose file gives Bambuddy. Only these are
- # consulted when guessing the compose directory — an arbitrary bind mount
- # (a NAS share, an external library root) says nothing about where the
- # compose file lives.
- _COMPOSE_BIND_MOUNTPOINTS = ("/app/data", "/app/logs")
- # A named volume resolves to ``.../docker/volumes/<project>_bambuddy_data/_data``
- # in mountinfo. That names the compose *project* but reveals nothing about
- # the directory holding the compose file, so these entries are skipped.
- _DOCKER_NAMED_VOLUME_ROOT = re.compile(r"/docker/volumes/[^/]+/_data/?$")
- def _compose_dir_from_mountinfo() -> str | None:
- """Guess the host directory holding the compose file, or None (#2664).
- ``docker compose pull`` only works from the directory containing the
- compose file, so the command the update box prints is unusable until the
- user remembers where that is. Compose knows the answer — it stamps
- ``com.docker.compose.project.working_dir`` onto every container it
- creates — but reading your own labels requires the Docker socket, and
- mounting that into Bambuddy would hand the container root-equivalent
- access to the host in exchange for a convenience string. So we infer.
- ``/proc/self/mountinfo`` exposes the *host* side of a bind mount in its
- root field: a ``./data:/app/data`` line in the compose file surfaces as
- ``/opt/bambuddy/data``, whose parent is the compose directory. The leaf
- must match the mount point's own name before we take the parent —
- ``/mnt/nas/prints:/app/data`` is a bind mount whose parent is emphatically
- not a compose directory.
- This is a guess and is treated as one — it only ever prefills the setting
- the user can overwrite. The root field is relative to the *mounted device*
- rather than to the host's ``/``, so a compose directory that sits under a
- separate mount loses that mount's own prefix. Measured against real
- containers: a compose file on the root filesystem (here a ZFS dataset
- mounted at ``/``) came back exactly right, while one under ``/tmp`` — its
- own tmpfs — inferred ``/claude-1001/...`` for ``/tmp/claude-1001/...``.
- Nothing inside the container can tell the two apart, which is precisely
- why the field is editable. The shipped compose file uses named volumes,
- for which nothing is inferable at all.
- """
- try:
- with open("/proc/self/mountinfo") as f:
- lines = f.readlines()
- except OSError:
- return None
- for line in lines:
- parts = line.split()
- # mountID parentID major:minor root mountPoint ...
- if len(parts) < 5:
- continue
- root, mount_point = parts[3], parts[4]
- if mount_point not in _COMPOSE_BIND_MOUNTPOINTS:
- continue
- if _DOCKER_NAMED_VOLUME_ROOT.search(root):
- continue
- parent, _, leaf = root.rstrip("/").rpartition("/")
- if parent and leaf == mount_point.rsplit("/", 1)[-1]:
- return parent
- return None
- def _detect_compose_dir() -> str | None:
- """Best-effort compose directory for the update instructions (#2664).
- ``BAMBUDDY_COMPOSE_DIR`` wins when set — it is the only source that is
- stated rather than inferred, and the shipped compose file carries a
- commented ``${PWD}`` line for it.
- """
- env_dir = os.environ.get("BAMBUDDY_COMPOSE_DIR", "").strip()
- if env_dir:
- return env_dir
- if not _is_docker_environment():
- return None
- return _compose_dir_from_mountinfo()
- def _is_ha_addon() -> bool:
- """Detect if running as a Home Assistant Supervisor addon.
- HA Supervisor injects ``SUPERVISOR_TOKEN`` into every addon container;
- the variable is not set in any other environment, so a single env-var
- check is sufficient with no false-positive surface.
- """
- return bool(os.environ.get("SUPERVISOR_TOKEN"))
- def _is_windows_installer_install() -> bool:
- """Detect a Windows install that came from the Inno Setup installer.
- The installer stages backend source via ``shutil.copytree`` (no ``.git``
- directory) and does not bundle ``git.exe`` — so the git-fetch-and-reset
- update path used everywhere else is structurally inoperable here. We
- surface this as a distinct ``update_method`` and direct the user at the
- release asset instead.
- A Windows developer running from a real ``git clone`` keeps the git
- path (``.git`` present), so this only catches installer users.
- """
- if sys.platform != "win32":
- return False
- return not (settings.app_dir / ".git").exists()
- def _find_windows_installer_asset(release_data: dict) -> str | None:
- """Pick the Windows installer .exe out of a GitHub release's assets list.
- Both filenames the workflow uploads end in ``windows-x64-setup.exe``
- (versioned ``bambuddy-<version>-windows-x64-setup.exe`` and the
- unversioned alias ``bambuddy-windows-x64-setup.exe`` on non-daily tags
- only). Either works as a download URL; we prefer the versioned form
- because it's the one guaranteed to exist on every release including
- dailies.
- """
- assets = release_data.get("assets") or []
- versioned: str | None = None
- unversioned: str | None = None
- for asset in assets:
- name = asset.get("name") or ""
- url = asset.get("browser_download_url")
- if not isinstance(name, str) or not isinstance(url, str):
- continue
- if not name.endswith("windows-x64-setup.exe"):
- continue
- if name == "bambuddy-windows-x64-setup.exe":
- unversioned = url
- else:
- versioned = url
- return versioned or unversioned
- def _find_executable(name: str) -> str | None:
- """Find an executable in PATH or common locations."""
- # Try standard PATH first
- path = shutil.which(name)
- if path:
- return path
- # Common locations for executables (useful when running as systemd service)
- common_paths = [
- f"/usr/bin/{name}",
- f"/usr/local/bin/{name}",
- f"/opt/homebrew/bin/{name}",
- f"/home/linuxbrew/.linuxbrew/bin/{name}",
- f"{os.path.expanduser('~')}/.nvm/current/bin/{name}",
- f"{os.path.expanduser('~')}/.local/bin/{name}",
- ]
- for p in common_paths:
- if os.path.isfile(p) and os.access(p, os.X_OK):
- return p
- return None
- def _parse_github_remote(url: str) -> tuple[str, str] | None:
- """Extract `(owner, repo)` from a GitHub remote URL, or None if it isn't a
- GitHub URL we recognise.
- Handles the four forms `git remote -v` typically prints:
- - `git@github.com:owner/repo.git` (SSH, the dev default)
- - `git@github.com:owner/repo` (SSH without .git suffix)
- - `https://github.com/owner/repo.git` (HTTPS, what _perform_update sets)
- - `https://github.com/owner/repo` (HTTPS without .git)
- Anything else (a fork URL, a different host, a malformed value, the empty
- string from a missing origin) returns None so the caller treats it as
- "not pointing at our repo" and resets it.
- """
- s = url.strip()
- if not s:
- return None
- # SSH form: git@github.com:owner/repo[.git]
- ssh_prefix = "git@github.com:"
- https_prefix_a = "https://github.com/"
- https_prefix_b = "http://github.com/" # tolerated for legacy
- if s.startswith(ssh_prefix):
- path = s[len(ssh_prefix) :]
- elif s.startswith(https_prefix_a):
- path = s[len(https_prefix_a) :]
- elif s.startswith(https_prefix_b):
- path = s[len(https_prefix_b) :]
- else:
- return None
- if path.endswith(".git"):
- path = path[:-4]
- parts = path.strip("/").split("/")
- if len(parts) != 2 or not parts[0] or not parts[1]:
- return None
- return (parts[0], parts[1])
- async def _origin_points_at_repo(git_path: str, git_config: list[str], app_dir, expected_repo: str) -> bool:
- """Return True iff the working tree's `origin` already resolves to
- `<owner>/<repo>` matching `expected_repo` (e.g. "maziggy/bambuddy"),
- regardless of whether it's the SSH or HTTPS form. Used to skip the
- `git remote set-url origin https://...` rewrite when the developer's
- SSH origin is already correct — see `_perform_update` for context.
- ``app_dir`` is the working tree (where ``.git`` lives), not the data
- dir — see #1715 for the separate-mount layout that proved why this
- must NOT be ``base_dir``."""
- try:
- process = await asyncio.create_subprocess_exec(
- git_path,
- *git_config,
- "remote",
- "get-url",
- "origin",
- cwd=str(app_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- stdout, _ = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git remote get-url")
- except (OSError, asyncio.CancelledError, _StepTimedOut):
- # Fail closed: let the caller go through the rewrite branch if we
- # can't even invoke git. The unconditional set-url is the safer
- # fallback, only mildly destructive.
- return False
- if process.returncode != 0:
- # Most likely cause: no `origin` defined yet (fresh clone-style
- # checkout). Caller will set it.
- return False
- parsed = _parse_github_remote(stdout.decode().strip())
- if parsed is None:
- return False
- owner, repo = parsed
- expected_owner, expected_repo_name = expected_repo.split("/", 1)
- return owner == expected_owner and repo == expected_repo_name
- def parse_version(version: str) -> tuple:
- """Parse version string into tuple for comparison.
- Returns (major, minor, patch, micro, is_prerelease, prerelease_num)
- where is_prerelease is 0 for release, 1 for prerelease.
- This ensures releases sort higher than prereleases of same version.
- Examples:
- "0.1.5" -> (0, 1, 5, 0, 0, 0) # release
- "0.1.5b7" -> (0, 1, 5, 0, 1, 7) # beta 7
- "0.1.5b10" -> (0, 1, 5, 0, 1, 10) # beta 10
- "0.1.8.1" -> (0, 1, 8, 1, 0, 0) # patch release
- """
- # Remove 'v' prefix if present
- version = version.lstrip("v")
- # Strip daily build suffix (e.g., "0.2.2b4-daily.20260313" -> "0.2.2b4")
- version = re.sub(r"-daily\.\d+$", "", version)
- # Match version pattern: major.minor.patch[.micro][b|beta|alpha|rc]N
- match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?(?:b|beta|alpha|rc)?(\d+)?", version)
- if match:
- major = int(match.group(1))
- minor = int(match.group(2))
- patch = int(match.group(3))
- micro = int(match.group(4)) if match.group(4) else 0
- prerelease_num = int(match.group(5)) if match.group(5) else 0
- # Check if this is a prerelease (has b/beta/alpha/rc/daily suffix anywhere)
- is_prerelease = 1 if re.search(r"[a-zA-Z]", version) else 0
- return (major, minor, patch, micro, is_prerelease, prerelease_num)
- # Fallback: try simple split
- parts = []
- for part in version.split("."):
- try:
- parts.append(int(part))
- except ValueError:
- num = "".join(c for c in part if c.isdigit())
- parts.append(int(num) if num else 0)
- return tuple(parts) + (0, 0, 0)
- def is_newer_version(latest: str, current: str) -> bool:
- """Check if latest version is newer than current.
- Properly handles prerelease versions:
- - 0.1.5 > 0.1.5b7 (release is newer than any beta)
- - 0.1.5b8 > 0.1.5b7 (later beta is newer)
- - 0.1.6b1 > 0.1.5 (next version beta is newer than current release)
- """
- try:
- latest_parsed = parse_version(latest)
- current_parsed = parse_version(current)
- # Compare (major, minor, patch, micro) first
- latest_base = latest_parsed[:4]
- current_base = current_parsed[:4]
- if latest_base > current_base:
- return True
- elif latest_base < current_base:
- return False
- # Same base version - compare prerelease status
- # is_prerelease: 0 = release, 1 = prerelease
- # Release (0) should be "greater" than prerelease (1)
- latest_is_prerelease = latest_parsed[4] if len(latest_parsed) > 4 else 0
- current_is_prerelease = current_parsed[4] if len(current_parsed) > 4 else 0
- if latest_is_prerelease < current_is_prerelease:
- # latest is release, current is prerelease -> latest is newer
- return True
- elif latest_is_prerelease > current_is_prerelease:
- # latest is prerelease, current is release -> latest is NOT newer
- return False
- # Both are same type (both release or both prerelease)
- # Compare prerelease numbers
- latest_prerelease_num = latest_parsed[5] if len(latest_parsed) > 5 else 0
- current_prerelease_num = current_parsed[5] if len(current_parsed) > 5 else 0
- return latest_prerelease_num > current_prerelease_num
- except Exception:
- return False
- @router.get("/version")
- async def get_version():
- """Get current application version.
- Note: Unauthenticated - needed to display version in UI without login.
- """
- return {
- "version": APP_VERSION,
- "repo": GITHUB_REPO,
- }
- @router.get("/check")
- async def check_for_updates(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
- ):
- """Check GitHub for available updates."""
- global _update_status
- # Respect the check_updates setting
- result = await db.execute(select(Settings).where(Settings.key == "check_updates"))
- setting = result.scalar_one_or_none()
- if setting and setting.value.lower() == "false":
- return {
- "update_available": False,
- "current_version": APP_VERSION,
- "latest_version": None,
- "message": "Update checks are disabled",
- }
- # Check if beta updates should be included
- result = await db.execute(select(Settings).where(Settings.key == "include_beta_updates"))
- beta_setting = result.scalar_one_or_none()
- include_beta = beta_setting and beta_setting.value.lower() == "true"
- # Short-circuit if we're still inside a GitHub rate-limit backoff window (#1420).
- backoff_remaining = _seconds_until_github_unblocked()
- if backoff_remaining > 0:
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "GitHub rate limit reached",
- "error": "GitHub rate limit reached; retry later",
- }
- return {
- "update_available": False,
- "current_version": APP_VERSION,
- "latest_version": None,
- "error": "GitHub rate limit reached; retry later",
- "retry_after_seconds": int(backoff_remaining),
- }
- _update_status = {
- "status": "checking",
- "progress": 0,
- "message": "Checking for updates...",
- "error": None,
- }
- try:
- async with httpx.AsyncClient() as client:
- response = await client.get(
- f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
- headers={"Accept": "application/vnd.github.v3+json"},
- timeout=10.0,
- )
- if _is_github_rate_limit_response(response):
- _record_github_rate_limit(response)
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "GitHub rate limit reached",
- "error": "GitHub rate limit reached; retry later",
- }
- return {
- "update_available": False,
- "current_version": APP_VERSION,
- "latest_version": None,
- "error": "GitHub rate limit reached; retry later",
- "retry_after_seconds": int(_seconds_until_github_unblocked()),
- }
- if response.status_code == 404:
- # No releases yet
- _update_status = {
- "status": "idle",
- "progress": 100,
- "message": "No releases found",
- "error": None,
- }
- return {
- "update_available": False,
- "current_version": APP_VERSION,
- "latest_version": None,
- "message": "No releases found",
- }
- response.raise_for_status()
- releases = response.json()
- # Find the appropriate release based on beta setting
- release_data = None
- for release in releases:
- tag = release.get("tag_name", "")
- if include_beta:
- # Accept any release (first = newest)
- release_data = release
- break
- else:
- # Skip prereleases (based on version parsing, not GitHub flag)
- parsed = parse_version(tag)
- if parsed[4] == 0: # is_prerelease == 0
- release_data = release
- break
- if not release_data:
- _update_status = {
- "status": "idle",
- "progress": 100,
- "message": "No releases found",
- "error": None,
- }
- return {
- "update_available": False,
- "current_version": APP_VERSION,
- "latest_version": None,
- "message": "No releases found",
- }
- latest_version = release_data.get("tag_name", "").lstrip("v")
- release_name = release_data.get("name", latest_version)
- release_notes = release_data.get("body", "")
- release_url = release_data.get("html_url", "")
- published_at = release_data.get("published_at", "")
- update_available = is_newer_version(latest_version, APP_VERSION)
- _update_status = {
- "status": "idle",
- "progress": 100,
- "message": "Update available" if update_available else "Up to date",
- "error": None,
- }
- is_docker = _is_docker_environment()
- is_ha_addon = _is_ha_addon()
- is_windows_installer = _is_windows_installer_install()
- installer_download_url: str | None = None
- if is_ha_addon:
- update_method = "ha_addon"
- elif is_docker:
- update_method = "docker"
- elif is_windows_installer:
- update_method = "windows_installer"
- installer_download_url = _find_windows_installer_asset(release_data)
- else:
- update_method = "git"
- return {
- "update_available": update_available,
- "current_version": APP_VERSION,
- "latest_version": latest_version,
- "release_name": release_name,
- "release_notes": release_notes,
- "release_url": release_url,
- "published_at": published_at,
- "is_docker": is_docker,
- "is_ha_addon": is_ha_addon,
- "is_windows_installer": is_windows_installer,
- "update_method": update_method,
- "installer_download_url": installer_download_url,
- # Prefill only — never the value the user saved. The settings
- # response owns ``docker_compose_dir``; keeping the two apart
- # means clearing the field falls back to the guess instead of
- # resurrecting the cleared value from a stale update check.
- "compose_dir_detected": _detect_compose_dir() if update_method == "docker" else None,
- }
- except httpx.HTTPError as e:
- logger.error("Failed to check for updates: %s", e)
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "Failed to check for updates",
- "error": "Failed to check for updates",
- }
- return {
- "update_available": False,
- "current_version": APP_VERSION,
- "latest_version": None,
- "error": "Failed to check for updates",
- }
- async def _discover_target_release(db: AsyncSession) -> str | None:
- """Look up the tag we should install from GitHub releases.
- Same selection logic the GUI's update-check uses: respect
- `include_beta_updates`, skip prereleases when the user opted out, take
- the first matching release. Returns the raw tag name (e.g. `v0.2.4b1`)
- so the git ref is unambiguous, or None if there's no release to install.
- The previous in-app updater path was hardcoded to `git fetch origin main
- && git reset --hard origin/main`, which silently no-ops whenever main
- isn't where the latest release lives — e.g. during a beta release cycle
- where the next stable hasn't been merged to main yet. Anchoring to the
- release tag instead lets the GUI install whatever GitHub says is latest.
- """
- result = await db.execute(select(Settings).where(Settings.key == "include_beta_updates"))
- beta_setting = result.scalar_one_or_none()
- include_beta = beta_setting and beta_setting.value.lower() == "true"
- if _seconds_until_github_unblocked() > 0:
- logger.warning("Skipping update target discovery: GitHub rate-limit backoff still active")
- return None
- try:
- async with httpx.AsyncClient() as client:
- response = await client.get(
- f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
- headers={"Accept": "application/vnd.github.v3+json"},
- timeout=10.0,
- )
- if _is_github_rate_limit_response(response):
- _record_github_rate_limit(response)
- return None
- response.raise_for_status()
- releases = response.json()
- except (httpx.HTTPError, ValueError) as exc:
- logger.error("Could not fetch GitHub releases for update target: %s", exc)
- return None
- for release in releases:
- tag = release.get("tag_name", "")
- if not tag:
- continue
- if include_beta:
- return tag
- # Skip prereleases (parsed from version, not GitHub flag — GitHub's
- # is_prerelease flag isn't always set on dailies).
- parsed = parse_version(tag)
- if parsed[4] == 0:
- return tag
- return None
- async def _perform_update(target_ref: str):
- """Perform the actual update using git fetch and reset.
- `target_ref` is whatever git ref the caller wants to land on — typically
- a release tag like `v0.2.4b1` resolved by `_discover_target_release`,
- but accepts any ref `git reset --hard` understands (`origin/main`, a
- branch, a sha). Tag-based refs are the production path because they pin
- the install to a specific release artifact instead of whatever happens
- to be on a moving branch.
- """
- global _update_status
- try:
- # Every git step runs against the working tree (app_dir), NOT base_dir.
- # On a standard install with DATA_DIR=INSTALL_PATH/data, git happens
- # to walk up from a subdirectory of the repo to find .git so cwd=base_dir
- # used to silently work — but only by accident. On a native install with
- # DATA_DIR mounted at an unrelated path (e.g. /srv/bambuddy/data while
- # the install is /opt/bambuddy — see #1715), git can't walk up and every
- # operation fails with "not a git repository". safe.directory has the
- # same requirement: it must equal the repo root git discovers, not the
- # data dir, or every call returns "fatal: detected dubious ownership."
- app_dir = settings.app_dir
- # Find git executable (may not be in PATH when running as systemd service)
- git_path = _find_executable("git")
- if not git_path:
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "Git not found",
- "error": "Could not find git executable. Please ensure git is installed.",
- }
- return
- logger.info("Using git at: %s", git_path)
- # Git config to avoid safe.directory issues — must point at the working
- # tree (where .git lives), see app_dir comment above.
- git_config = ["-c", f"safe.directory={app_dir}"]
- _update_status = {
- "status": "downloading",
- "progress": 10,
- "message": "Configuring git...",
- "error": None,
- }
- # Ensure remote points at the expected repo. We previously rewrote
- # origin to HTTPS unconditionally on the assumption that systemd
- # service users wouldn't have SSH keys configured — which is fine
- # for that case, but stomps on developer checkouts where origin is
- # legitimately `git@github.com:maziggy/bambuddy.git` and the user
- # auths via SSH keys. After the rewrite, `git push` prompts for
- # HTTPS credentials and fails.
- # New behaviour: read the current origin, parse out the
- # `<owner>/<repo>` pair, and only rewrite if it doesn't already
- # resolve to the right GitHub repo. SSH origins pointing at the
- # correct repo are preserved; only missing / wrong / corrupted
- # origins get reset to HTTPS.
- https_url = f"https://github.com/{GITHUB_REPO}.git"
- if not await _origin_points_at_repo(git_path, git_config, app_dir, GITHUB_REPO):
- process = await asyncio.create_subprocess_exec(
- git_path,
- *git_config,
- "remote",
- "set-url",
- "origin",
- https_url,
- cwd=str(app_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git remote set-url")
- _update_status = {
- "status": "downloading",
- "progress": 20,
- "message": "Fetching latest changes...",
- "error": None,
- }
- # Fetch branches AND tags from origin so any ref the caller passes
- # (release tag like `v0.2.4b1`, a branch like `main`, or a sha) is
- # locally resolvable for the reset below. `--tags` is required —
- # plain `git fetch origin` doesn't bring tags by default, so a
- # release tag would not be resolvable.
- #
- # `--force` lets a moved tag on the remote overwrite the local copy.
- # Without it, any tag that was re-tagged upstream (e.g. v0.2.1 being
- # re-pointed after a hotfix re-tag) makes `git fetch --tags` return
- # a non-zero exit even though every other ref fetched cleanly —
- # which we'd then surface as "Failed to fetch updates" to the user.
- # The in-app updater's contract is "sync me to the remote"; force-
- # overwriting a stale local tag matches that intent.
- process = await asyncio.create_subprocess_exec(
- git_path,
- *git_config,
- "fetch",
- "--prune",
- "--tags",
- "--force",
- "origin",
- cwd=str(app_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- stdout, stderr = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git fetch")
- if process.returncode != 0:
- error_msg = stderr.decode() if stderr else "Git fetch failed"
- logger.error("Git fetch failed: %s", error_msg)
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "Failed to fetch updates",
- "error": error_msg,
- }
- return
- _update_status = {
- "status": "downloading",
- "progress": 40,
- "message": "Applying updates...",
- "error": None,
- }
- # Hard reset to the target ref (clean update, no merge conflicts).
- # `target_ref` is typically a release tag like `v0.2.4b1` resolved
- # from the GitHub releases API by `_discover_target_release`. The
- # local branch name doesn't change — only HEAD moves. Falling back
- # to `origin/main` here was the source of the "in-app updater can't
- # reach beta releases" bug.
- process = await asyncio.create_subprocess_exec(
- git_path,
- *git_config,
- "reset",
- "--hard",
- target_ref,
- cwd=str(app_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- stdout, stderr = await _communicate(process, GIT_STEP_TIMEOUT_SECONDS, "git reset")
- if process.returncode != 0:
- error_msg = stderr.decode() if stderr else "Git reset failed"
- logger.error("Git reset failed: %s", error_msg)
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "Failed to apply updates",
- "error": error_msg,
- }
- return
- _update_status = {
- "status": "installing",
- "progress": 50,
- "message": "Installing dependencies...",
- "error": None,
- }
- # Install Python dependencies — must run from the source-code directory
- # (where requirements.txt lives). app_dir is already resolved at the top
- # of this function; see the comment there for why every step uses it
- # instead of base_dir.
- process = await asyncio.create_subprocess_exec(
- sys.executable,
- "-m",
- "pip",
- "install",
- "-r",
- "requirements.txt",
- "-q",
- cwd=str(app_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- start_new_session=True,
- )
- stdout, stderr = await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "pip install")
- if process.returncode != 0:
- logger.warning("pip install warning: %s", stderr.decode() if stderr else "unknown")
- # Try to build frontend if npm is available (optional - static files are pre-built)
- npm_path = _find_executable("npm")
- frontend_dir = app_dir / "frontend"
- if npm_path and frontend_dir.exists():
- _update_status = {
- "status": "installing",
- "progress": 70,
- "message": "Building frontend...",
- "error": None,
- }
- # Optional: the release ships pre-built static files, so a build
- # that fails or runs out of time leaves those in place.
- try:
- # npm install
- process = await asyncio.create_subprocess_exec(
- npm_path,
- "install",
- cwd=str(frontend_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- start_new_session=True,
- )
- await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "npm install")
- # npm run build
- process = await asyncio.create_subprocess_exec(
- npm_path,
- "run",
- "build",
- cwd=str(frontend_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- start_new_session=True,
- )
- stdout, stderr = await _communicate(process, INSTALL_STEP_TIMEOUT_SECONDS, "npm run build")
- if process.returncode != 0:
- logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
- except _StepTimedOut as exc:
- logger.warning("%s; keeping the pre-built frontend", exc)
- else:
- logger.info("npm not found or frontend dir missing - using pre-built static files")
- _update_status = {
- "status": "complete",
- "progress": 100,
- "message": "Update complete! Please restart the application.",
- "error": None,
- }
- logger.info("Update completed successfully")
- except _StepTimedOut as exc:
- logger.error("Update failed: %s", exc)
- if exc.step == "pip install":
- # git reset has already moved the code: it must not be started on
- # dependencies that were never installed.
- advice = (
- "The new code is in place but its dependencies were not installed. "
- "Check for updates and run the update again before restarting."
- )
- elif exc.step == "git reset":
- # Stopped while the files were being replaced: some may be new.
- advice = (
- "The new code may be only partly in place. "
- "Check for updates and run the update again before restarting."
- )
- else:
- advice = "Nothing was installed. Check for updates and run the update again."
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": f"Update failed: {exc.step} took too long",
- "error": f"{exc}. {advice}",
- }
- except Exception as e:
- logger.error("Update failed: %s", e)
- _update_status = {
- "status": "error",
- "progress": 0,
- "message": "Update failed",
- "error": "Update failed unexpectedly",
- }
- @router.post("/apply")
- async def apply_update(
- background_tasks: BackgroundTasks,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
- ):
- """Apply available update (git pull + rebuild)."""
- global _update_status
- if _update_status["status"] in ["downloading", "installing"]:
- return {
- "success": False,
- "message": "Update already in progress",
- "status": _update_status,
- }
- # Check for managed deployment shapes that own the update lifecycle.
- # HA addons are also Docker, so check HA first to surface the more
- # specific message.
- if _is_ha_addon():
- return {
- "success": False,
- "is_ha_addon": True,
- "is_docker": True,
- "message": (
- "Bambuddy is running as a Home Assistant addon. "
- "Updates are managed by the Home Assistant Supervisor "
- "(Settings → Add-ons → Bambuddy → Update)."
- ),
- }
- if _is_docker_environment():
- return {
- "success": False,
- "is_docker": True,
- "message": (
- "Docker installations cannot be updated in-app. "
- "Please update via Docker Compose: "
- "git pull && docker compose build --pull && docker compose up -d"
- ),
- }
- if _is_windows_installer_install():
- # The installer layout has no ``.git`` and no bundled ``git.exe`` —
- # the git-fetch path would fail. Frontend swaps the "Update now"
- # button for a Download Installer link via update_method, so this
- # branch is only reached if /apply is hit directly.
- return {
- "success": False,
- "is_windows_installer": True,
- "message": (
- "Windows installations are updated by re-running the installer. "
- "Download the latest installer from the Bambuddy releases page."
- ),
- }
- # Discover which release tag to install. Resolved here (where we have
- # a DB session) and passed into the background task; the BG task can't
- # reuse this request's session since FastAPI closes it on response.
- target_ref = await _discover_target_release(db)
- if target_ref is None:
- return {
- "success": False,
- "message": (
- "Could not determine a release to install. Either GitHub is "
- "unreachable or no release matches your update channel "
- "(check the include_beta_updates setting)."
- ),
- }
- # Start update in background
- background_tasks.add_task(_perform_update, target_ref)
- _update_status = {
- "status": "downloading",
- "progress": 10,
- "message": "Starting update...",
- "error": None,
- }
- return {
- "success": True,
- "message": "Update started",
- "status": _update_status,
- }
- @router.get("/status")
- async def get_update_status(
- _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
- ):
- """Get current update status."""
- return _update_status
|