From a2db716a5ff21035dd4a094e3a24a0ca3c1f6c7b Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 29 Jan 2026 09:06:45 +0100 Subject: [PATCH] Check frontend availability after Home Assistant Core updates (#6311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Check frontend availability after Home Assistant Core updates Add verification that the frontend is actually accessible at "/" after core updates to ensure the web interface is serving properly, not just that the API endpoints respond. Previously, the update verification only checked API endpoints and whether the frontend component was loaded. This could miss cases where the API is responsive but the frontend fails to serve the UI. Changes: - Add check_frontend_available() method to HomeAssistantAPI that fetches the root path and verifies it returns HTML content - Integrate frontend check into core update verification flow after confirming the frontend component is loaded - Trigger automatic rollback if frontend is inaccessible after update - Fix blocking I/O calls in rollback log file handling to use async executor 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Avoid checking frontend if config data is None * Improve pytest tests * Make sure Core returns a valid config * Remove Core version check in frontend availability test The call site already makes sure that an actual Home Assistant Core instance is running before calling the frontend availability test. So this is rather redundant. Simplify the code by removing the version check and update tests accordingly. * Add test coverage for get_config --------- Co-authored-by: Claude --- supervisor/homeassistant/api.py | 34 ++++- supervisor/homeassistant/core.py | 14 ++- tests/api/test_homeassistant.py | 73 +++++++++++ tests/homeassistant/test_api.py | 207 +++++++++++++++++++++++++++++++ 4 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 tests/homeassistant/test_api.py diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py index 873c1e440..daba82cd2 100644 --- a/supervisor/homeassistant/api.py +++ b/supervisor/homeassistant/api.py @@ -175,7 +175,10 @@ class HomeAssistantAPI(CoreSysAttributes): async def get_config(self) -> dict[str, Any]: """Return Home Assistant config.""" - return await self._get_json("api/config") + config = await self._get_json("api/config") + if config is None or not isinstance(config, dict): + raise HomeAssistantAPIError("No config received from Home Assistant API") + return config async def get_core_state(self) -> dict[str, Any]: """Return Home Assistant core state.""" @@ -219,3 +222,32 @@ class HomeAssistantAPI(CoreSysAttributes): if state := await self.get_api_state(): return state.core_state == "RUNNING" or state.offline_db_migration return False + + async def check_frontend_available(self) -> bool: + """Check if the frontend is accessible by fetching the root path. + + Caller should make sure that Home Assistant Core is running before + calling this method. + + Returns: + True if the frontend responds successfully, False otherwise. + + """ + try: + async with self.make_request("get", "", timeout=30) as resp: + # Frontend should return HTML content + if resp.status == 200: + content_type = resp.headers.get(hdrs.CONTENT_TYPE, "") + if "text/html" in content_type: + _LOGGER.debug("Frontend is accessible and serving HTML") + return True + _LOGGER.warning( + "Frontend responded but with unexpected content type: %s", + content_type, + ) + return False + _LOGGER.warning("Frontend returned status %s", resp.status) + return False + except HomeAssistantAPIError as err: + _LOGGER.debug("Cannot reach frontend: %s", err) + return False diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index c905e7b4c..b1df3ea09 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -304,12 +304,18 @@ class HomeAssistantCore(JobGroup): except HomeAssistantError: # The API stoped responding between the up checks an now self._error_state = True - data = None + return # Verify that the frontend is loaded - if data and "frontend" not in data.get("components", []): + if "frontend" not in data.get("components", []): _LOGGER.error("API responds but frontend is not loaded") self._error_state = True + # Check that the frontend is actually accessible + elif not await self.sys_homeassistant.api.check_frontend_available(): + _LOGGER.error( + "Frontend component loaded but frontend is not accessible" + ) + self._error_state = True else: return @@ -322,12 +328,12 @@ class HomeAssistantCore(JobGroup): # Make a copy of the current log file if it exists logfile = self.sys_config.path_homeassistant / "home-assistant.log" - if logfile.exists(): + if await self.sys_run_in_executor(logfile.exists): rollback_log = ( self.sys_config.path_homeassistant / "home-assistant-rollback.log" ) - shutil.copy(logfile, rollback_log) + await self.sys_run_in_executor(shutil.copy, logfile, rollback_log) _LOGGER.info( "A backup of the logfile is stored in /config/home-assistant-rollback.log" ) diff --git a/tests/api/test_homeassistant.py b/tests/api/test_homeassistant.py index dbc48faf7..0ce240008 100644 --- a/tests/api/test_homeassistant.py +++ b/tests/api/test_homeassistant.py @@ -18,6 +18,8 @@ from supervisor.homeassistant.api import APIState, HomeAssistantAPI from supervisor.homeassistant.const import WSEvent from supervisor.homeassistant.core import HomeAssistantCore from supervisor.homeassistant.module import HomeAssistant +from supervisor.resolution.const import ContextType, IssueType +from supervisor.resolution.data import Issue from tests.common import AsyncIterator, load_json_fixture @@ -287,6 +289,7 @@ async def test_api_progress_updates_home_assistant_update( patch.object( HomeAssistantAPI, "get_config", return_value={"components": ["frontend"]} ), + patch.object(HomeAssistantAPI, "check_frontend_available", return_value=True), ): resp = await api_client.post("/core/update", json={"version": "2025.8.3"}) @@ -436,3 +439,73 @@ async def test_config_check_error(api_client: TestClient, container: DockerConta assert result.status == 400 resp = await result.json() assert resp["message"] == "Test logs 1\nTest logs 2" + + +async def test_update_frontend_check_success(api_client: TestClient, coresys: CoreSys): + """Test that update succeeds when frontend check passes.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + with ( + patch.object( + DockerHomeAssistant, + "version", + new=PropertyMock(return_value=AwesomeVersion("2025.8.0")), + ), + patch.object( + HomeAssistantAPI, "get_config", return_value={"components": ["frontend"]} + ), + patch.object(HomeAssistantAPI, "check_frontend_available", return_value=True), + ): + resp = await api_client.post("/core/update", json={"version": "2025.8.3"}) + + assert resp.status == 200 + + +async def test_update_frontend_check_fails_triggers_rollback( + api_client: TestClient, + coresys: CoreSys, + caplog: pytest.LogCaptureFixture, + tmp_supervisor_data: Path, +): + """Test that update triggers rollback when frontend check fails.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + # Mock successful first update, failed frontend check, then successful rollback + update_call_count = 0 + + async def mock_update(*args, **kwargs): + nonlocal update_call_count + update_call_count += 1 + if update_call_count == 1: + # First update succeeds + coresys.homeassistant.version = AwesomeVersion("2025.8.3") + elif update_call_count == 2: + # Rollback succeeds + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + with ( + patch.object(DockerInterface, "update", new=mock_update), + patch.object( + DockerHomeAssistant, + "version", + new=PropertyMock(return_value=AwesomeVersion("2025.8.0")), + ), + patch.object( + HomeAssistantAPI, "get_config", return_value={"components": ["frontend"]} + ), + patch.object(HomeAssistantAPI, "check_frontend_available", return_value=False), + ): + resp = await api_client.post("/core/update", json={"version": "2025.8.3"}) + + # Update should trigger rollback, which succeeds and returns 200 + assert resp.status == 200 + assert "Frontend component loaded but frontend is not accessible" in caplog.text + assert "HomeAssistant update failed -> rollback!" in caplog.text + # Should have called update twice (once for update, once for rollback) + assert update_call_count == 2 + # An update_rollback issue should be created + assert ( + Issue(IssueType.UPDATE_ROLLBACK, ContextType.CORE) in coresys.resolution.issues + ) diff --git a/tests/homeassistant/test_api.py b/tests/homeassistant/test_api.py new file mode 100644 index 000000000..35125ced3 --- /dev/null +++ b/tests/homeassistant/test_api.py @@ -0,0 +1,207 @@ +"""Test Home Assistant API.""" + +from contextlib import asynccontextmanager +from unittest.mock import MagicMock, patch + +from aiohttp import hdrs +from awesomeversion import AwesomeVersion +import pytest + +from supervisor.coresys import CoreSys +from supervisor.exceptions import HomeAssistantAPIError + + +async def test_check_frontend_available_success(coresys: CoreSys): + """Test frontend availability check succeeds with valid HTML response.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.headers = {hdrs.CONTENT_TYPE: "text/html; charset=utf-8"} + + @asynccontextmanager + async def mock_make_request(*args, **kwargs): + yield mock_response + + with patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ): + result = await coresys.homeassistant.api.check_frontend_available() + + assert result is True + + +async def test_check_frontend_available_wrong_status(coresys: CoreSys): + """Test frontend availability check fails with non-200 status.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 404 + mock_response.headers = {hdrs.CONTENT_TYPE: "text/html"} + + @asynccontextmanager + async def mock_make_request(*args, **kwargs): + yield mock_response + + with patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ): + result = await coresys.homeassistant.api.check_frontend_available() + + assert result is False + + +async def test_check_frontend_available_wrong_content_type( + coresys: CoreSys, caplog: pytest.LogCaptureFixture +): + """Test frontend availability check fails with wrong content type.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.headers = {hdrs.CONTENT_TYPE: "application/json"} + + @asynccontextmanager + async def mock_make_request(*args, **kwargs): + yield mock_response + + with patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ): + result = await coresys.homeassistant.api.check_frontend_available() + + assert result is False + assert "unexpected content type" in caplog.text + + +async def test_check_frontend_available_api_error(coresys: CoreSys): + """Test frontend availability check handles API errors gracefully.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + @asynccontextmanager + async def mock_make_request(*args, **kwargs): + raise HomeAssistantAPIError("Connection failed") + yield # pragma: no cover + + with patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ): + result = await coresys.homeassistant.api.check_frontend_available() + + assert result is False + + +async def test_get_config_success(coresys: CoreSys): + """Test get_config returns valid config dictionary.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + expected_config = { + "latitude": 32.87336, + "longitude": -117.22743, + "elevation": 0, + "unit_system": { + "length": "km", + "mass": "g", + "temperature": "°C", + "volume": "L", + }, + "location_name": "Home", + "time_zone": "America/Los_Angeles", + "components": ["frontend", "config"], + "version": "2025.8.0", + } + + mock_response = MagicMock() + mock_response.status = 200 + + async def mock_json(): + return expected_config + + mock_response.json = mock_json + + @asynccontextmanager + async def mock_make_request(*_args, **_kwargs): + yield mock_response + + with patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ): + result = await coresys.homeassistant.api.get_config() + + assert result == expected_config + + +async def test_get_config_returns_none(coresys: CoreSys): + """Test get_config raises error when None is returned.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 200 + + async def mock_json(): + return None + + mock_response.json = mock_json + + @asynccontextmanager + async def mock_make_request(*_args, **_kwargs): + yield mock_response + + with ( + patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ), + pytest.raises( + HomeAssistantAPIError, match="No config received from Home Assistant API" + ), + ): + await coresys.homeassistant.api.get_config() + + +async def test_get_config_returns_non_dict(coresys: CoreSys): + """Test get_config raises error when non-dict is returned.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 200 + + async def mock_json(): + return ["not", "a", "dict"] + + mock_response.json = mock_json + + @asynccontextmanager + async def mock_make_request(*_args, **_kwargs): + yield mock_response + + with ( + patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ), + pytest.raises( + HomeAssistantAPIError, match="No config received from Home Assistant API" + ), + ): + await coresys.homeassistant.api.get_config() + + +async def test_get_config_api_error(coresys: CoreSys): + """Test get_config propagates API errors from underlying _get_json call.""" + coresys.homeassistant.version = AwesomeVersion("2025.8.0") + + mock_response = MagicMock() + mock_response.status = 500 + + @asynccontextmanager + async def mock_make_request(*_args, **_kwargs): + yield mock_response + + with ( + patch.object( + type(coresys.homeassistant.api), "make_request", new=mock_make_request + ), + pytest.raises( + HomeAssistantAPIError, match="Home Assistant Core API return 500" + ), + ): + await coresys.homeassistant.api.get_config()