Compare commits

...

4 Commits

Author SHA1 Message Date
Stefan Agner
febfaf8db1 Make sure Core returns a valid config 2025-11-13 17:49:41 +01:00
Stefan Agner
5d3a568b48 Improve pytest tests 2025-11-13 16:08:09 +01:00
Stefan Agner
894f8ea226 Avoid checking frontend if config data is None 2025-11-13 16:03:25 +01:00
Stefan Agner
96e6c0b15b 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>
2025-11-13 09:51:38 +01:00
4 changed files with 229 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,36 @@ 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.
Returns:
True if the frontend responds successfully, False otherwise.
"""
# Skip check on landingpage
if (
self.sys_homeassistant.version is None
or self.sys_homeassistant.version == LANDINGPAGE
):
return False
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

@@ -303,12 +303,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
@@ -321,12 +327,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

@@ -17,6 +17,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.api import common_test_api_advanced_logs
from tests.common import AsyncIterator, load_json_fixture
@@ -367,3 +369,73 @@ async def test_api_progress_updates_home_assistant_update(
"done": True,
},
]
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,110 @@
"""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
from supervisor.homeassistant.const import LANDINGPAGE
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_check_frontend_available_landingpage(coresys: CoreSys):
"""Test frontend availability check returns False for landingpage."""
coresys.homeassistant.version = LANDINGPAGE
result = await coresys.homeassistant.api.check_frontend_available()
assert result is False
async def test_check_frontend_available_no_version(coresys: CoreSys):
"""Test frontend availability check returns False when no version set."""
coresys.homeassistant.version = None
result = await coresys.homeassistant.api.check_frontend_available()
assert result is False