mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-11-20 08:20:15 +00:00
Compare commits
4 Commits
2025.11.4
...
check-fron
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
febfaf8db1 | ||
|
|
5d3a568b48 | ||
|
|
894f8ea226 | ||
|
|
96e6c0b15b |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
110
tests/homeassistant/test_api.py
Normal file
110
tests/homeassistant/test_api.py
Normal 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
|
||||
Reference in New Issue
Block a user