From 1234e44eb2efff59624cdaf1af268c79bab3858b Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 1 Jan 2026 13:59:52 +0100 Subject: [PATCH] - Docker update detection for in-app updates - Added _is_docker_environment() function - Check endpoint returns is_docker and update_method fields - Apply endpoint rejects Docker with helpful instructions - Added updates API tests - Updated CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 1 + backend/app/api/routes/updates.py | 29 ++++++++++++ backend/tests/integration/test_updates_api.py | 47 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 backend/tests/integration/test_updates_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0c5080d..a6e83468a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.6b4] - 2026-01-01 ### Added +- **Docker update detection** - Automatically detects Docker installations and shows appropriate update instructions instead of failing with git errors - **Camera popup window improvements** - Auto-resize to fit video resolution on first open - Persist window size and position to localStorage diff --git a/backend/app/api/routes/updates.py b/backend/app/api/routes/updates.py index 9787fcf09..d7fc09aa3 100644 --- a/backend/app/api/routes/updates.py +++ b/backend/app/api/routes/updates.py @@ -27,6 +27,20 @@ _update_status = { } +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 + git_dir = settings.base_dir / ".git" + return not git_dir.exists() + + def _find_executable(name: str) -> str | None: """Find an executable in PATH or common locations.""" # Try standard PATH first @@ -199,6 +213,7 @@ async def check_for_updates(db: AsyncSession = Depends(get_db)): "error": None, } + is_docker = _is_docker_environment() return { "update_available": update_available, "current_version": APP_VERSION, @@ -207,6 +222,8 @@ async def check_for_updates(db: AsyncSession = Depends(get_db)): "release_notes": release_notes, "release_url": release_url, "published_at": published_at, + "is_docker": is_docker, + "update_method": "docker" if is_docker else "git", } except httpx.HTTPError as e: @@ -426,6 +443,18 @@ async def apply_update(background_tasks: BackgroundTasks): "status": _update_status, } + # Check if running in Docker + 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" + ), + } + # Start update in background background_tasks.add_task(_perform_update) diff --git a/backend/tests/integration/test_updates_api.py b/backend/tests/integration/test_updates_api.py new file mode 100644 index 000000000..4daaff14c --- /dev/null +++ b/backend/tests/integration/test_updates_api.py @@ -0,0 +1,47 @@ +"""Integration tests for Updates API endpoints.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import AsyncClient + + +class TestUpdatesAPI: + @pytest.mark.asyncio + async def test_get_version(self, async_client: AsyncClient): + response = await async_client.get("/api/v1/updates/version") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_apply_update_docker_rejection(self, async_client: AsyncClient): + with patch("backend.app.api.routes.updates._is_docker_environment", return_value=True): + response = await async_client.post("/api/v1/updates/apply") + result = response.json() + assert result["success"] is False + assert result["is_docker"] is True + + @pytest.mark.asyncio + async def test_apply_update_non_docker(self, async_client: AsyncClient): + """Test non-Docker path - mock _perform_update to prevent side effects.""" + with ( + patch("backend.app.api.routes.updates._is_docker_environment", return_value=False), + patch("backend.app.api.routes.updates._perform_update", new_callable=AsyncMock), + ): + response = await async_client.post("/api/v1/updates/apply") + assert response.json()["success"] is True + + def test_is_docker_with_dockerenv(self): + from backend.app.api.routes.updates import _is_docker_environment + + with patch("os.path.exists", return_value=True): + assert _is_docker_environment() is True + + def test_parse_version(self): + from backend.app.api.routes.updates import parse_version + + assert parse_version("0.1.5")[:3] == (0, 1, 5) + + def test_is_newer_version(self): + from backend.app.api.routes.updates import is_newer_version + + assert is_newer_version("0.1.5", "0.1.5b7") is True