mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-02-15 07:27:13 +00:00
Check frontend availability after Home Assistant Core updates (#6311)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
207
tests/homeassistant/test_api.py
Normal file
207
tests/homeassistant/test_api.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user