1
0
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:
Stefan Agner
2026-01-29 09:06:45 +01:00
committed by GitHub
parent 641b205ee7
commit a2db716a5f
4 changed files with 323 additions and 5 deletions

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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
)

View 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()