Files
bambuddy/backend/app/api/routes/updates.py
maziggy 25a23eadd7 fix(updates): switch Windows installer installs to release-asset update flow
In-app "Install Update" on Windows installer installs failed with "Could
  not find git executable" because (1) _find_executable's fallback paths
  are Unix-only, and (2) the installer stages backend/ via shutil.copytree
  so there is no .git directory — even with Git for Windows installed, the
  fetch would die on "not a git repository". Adding Windows paths would
  only have changed which error users saw.

  Switches the Windows installer path to a fourth update_method
  ("windows_installer") that mirrors the existing docker / ha_addon
  branches — surface a link to the release .exe and let the user re-run
  the installer, matching the Discord / Spotify Windows update model.

  Backend:
  - New _is_windows_installer_install() — true iff sys.platform == "win32"
    AND no .git in app_dir, so Windows devs with a real git clone keep
    the git path.
  - New _find_windows_installer_asset() picks the matching release asset
    (prefers versioned bambuddy-<ver>-windows-x64-setup.exe, falls back
    to the unversioned alias on non-daily tags).
  - /updates/check now returns is_windows_installer / update_method /
    installer_download_url.
  - /updates/apply short-circuits with a friendly message after the
    existing HA / Docker guards — defense in depth, the frontend swaps
    the button so the POST should not fire on Windows.

  Frontend:
  - UpdateCheckResult extended with the new fields and 'windows_installer'
    in the update_method union.
  - SettingsPage renders a Bambu-green styled <a target="_blank"
    rel="noopener"> between the Docker snippet and the in-app Update
    button, with installer_download_url falling back to release_url then
    the tag page so the link is never broken.
  - applyUpdateMutation onSuccess toast guard extended to treat
    is_windows_installer the same as HA / Docker.
2026-06-21 10:25:57 +02:00

932 lines
35 KiB
Python

"""Update checking and management routes."""
import asyncio
import logging
import os
import re
import shutil
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
_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
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 process.communicate()
except (OSError, asyncio.CancelledError):
# 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,
}
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 process.communicate()
_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 process.communicate()
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 process.communicate()
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,
)
stdout, stderr = await process.communicate()
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,
}
# npm install
process = await asyncio.create_subprocess_exec(
npm_path,
"install",
cwd=str(frontend_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await process.communicate()
# 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,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
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 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