mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-09-29 18:03:47 +01:00
* Block start/restart/rebuild/update APIs while Supervisor is not fully running Supervisor boots apps and Home Assistant Core in a specific order, and replays a similarly ordered sequence while restoring a backup (during which it is in the freeze state). Calling start/restart/rebuild/update via the API while one of those sequences is still in progress risks running it out of order, or blocking a scheduled start from that sequence outright via job concurrency. Add a require_running_system decorator that rejects these API calls unless Supervisor is fully started (CoreState.RUNNING), and apply it to: - App start/restart/rebuild (fixes #7189) and update - Home Assistant Core start/restart/rebuild/update - Plugin (audio/dns/multicast) restart/update, and cli/observer update Also add a translatable APISystemNotReadyError so the API returns a clean, user-facing message and error_key instead of leaking the technical JobConditionException message. * Address review: neutral error wording + require_running_system test coverage - Reword APISystemNotReadyError so it no longer implies the block is only due to startup, since the same guard also applies while frozen for a backup restore. - Add tests/api/test_require_running_system.py covering the rejection path (400 + system_not_ready_error, business logic not invoked) for app start/restart/rebuild, app update via the store API, Core start/restart/rebuild/update, and all plugin update/restart routes, parametrized across SETUP/STARTUP/FREEZE. * Simplify APISystemNotReadyError message wording * Switch status code to 503
127 lines
4.6 KiB
Python
127 lines
4.6 KiB
Python
"""Test the require_running_system API guard.
|
|
|
|
Covers the start/restart/rebuild/update routes gated by
|
|
supervisor.api.utils.require_running_system: they must reject the call with
|
|
a 400 and the system_not_ready_error error_key - and must not invoke the
|
|
underlying business-logic method - whenever Supervisor isn't in
|
|
CoreState.RUNNING (e.g. while still booting, or while frozen for a backup
|
|
restore).
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from aiohttp.test_utils import TestClient
|
|
import pytest
|
|
|
|
from supervisor.apps.app import App
|
|
from supervisor.apps.manager import AppManager
|
|
from supervisor.const import CoreState
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.homeassistant.core import HomeAssistantCore
|
|
from supervisor.plugins.audio import PluginAudio
|
|
from supervisor.plugins.cli import PluginCli
|
|
from supervisor.plugins.dns import PluginDns
|
|
from supervisor.plugins.multicast import PluginMulticast
|
|
from supervisor.plugins.observer import PluginObserver
|
|
|
|
from ..const import TEST_ADDON_SLUG
|
|
|
|
# States that must be rejected. Not exhaustive (e.g. omits INITIALIZE,
|
|
# SHUTDOWN, STOPPING, CLOSE) but covers the two scenarios called out in the
|
|
# require_running_system docstring: still booting, and restoring a backup.
|
|
BLOCKED_STATES = [CoreState.SETUP, CoreState.STARTUP, CoreState.FREEZE]
|
|
|
|
|
|
async def _assert_blocked(resp, mocked: AsyncMock) -> None:
|
|
"""Assert the call was rejected before reaching the business logic."""
|
|
assert resp.status == 503
|
|
body = await resp.json()
|
|
assert body["error_key"] == "system_not_ready_error"
|
|
mocked.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("blocked_state", BLOCKED_STATES)
|
|
@pytest.mark.parametrize("method", ["start", "restart", "rebuild"])
|
|
async def test_app_lifecycle_blocked_when_not_running(
|
|
app_api_client_with_root: tuple[TestClient, str],
|
|
install_app_ssh: App,
|
|
coresys: CoreSys,
|
|
method: str,
|
|
blocked_state: CoreState,
|
|
):
|
|
"""Test app start/restart/rebuild are rejected unless Supervisor is running."""
|
|
client, root = app_api_client_with_root
|
|
await coresys.core.set_state(blocked_state)
|
|
|
|
with patch.object(App, method, new=AsyncMock()) as mocked:
|
|
resp = await client.post(f"{root}/{TEST_ADDON_SLUG}/{method}")
|
|
|
|
await _assert_blocked(resp, mocked)
|
|
|
|
|
|
@pytest.mark.parametrize("blocked_state", BLOCKED_STATES)
|
|
async def test_app_update_blocked_when_not_running(
|
|
store_app_api_client_with_root: tuple[TestClient, str],
|
|
install_app_ssh: App,
|
|
coresys: CoreSys,
|
|
blocked_state: CoreState,
|
|
):
|
|
"""Test app update (via the store API) is rejected unless Supervisor is running."""
|
|
client, root = store_app_api_client_with_root
|
|
await coresys.core.set_state(blocked_state)
|
|
|
|
with patch.object(AppManager, "update", new=AsyncMock()) as mocked:
|
|
resp = await client.post(f"/{root}/{TEST_ADDON_SLUG}/update")
|
|
|
|
await _assert_blocked(resp, mocked)
|
|
|
|
|
|
@pytest.mark.parametrize("blocked_state", BLOCKED_STATES)
|
|
@pytest.mark.parametrize("method", ["start", "restart", "rebuild", "update"])
|
|
async def test_core_lifecycle_blocked_when_not_running(
|
|
core_api_client_with_root: tuple[TestClient, str],
|
|
coresys: CoreSys,
|
|
method: str,
|
|
blocked_state: CoreState,
|
|
):
|
|
"""Test Core start/restart/rebuild/update are rejected unless Supervisor is running."""
|
|
client, root = core_api_client_with_root
|
|
await coresys.core.set_state(blocked_state)
|
|
|
|
with patch.object(HomeAssistantCore, method, new=AsyncMock()) as mocked:
|
|
resp = await client.post(f"{root}/{method}")
|
|
|
|
await _assert_blocked(resp, mocked)
|
|
|
|
|
|
@pytest.mark.parametrize("blocked_state", BLOCKED_STATES)
|
|
@pytest.mark.parametrize(
|
|
("plugin_path", "plugin_class", "method"),
|
|
[
|
|
("audio", PluginAudio, "update"),
|
|
("audio", PluginAudio, "restart"),
|
|
("dns", PluginDns, "update"),
|
|
("dns", PluginDns, "restart"),
|
|
("multicast", PluginMulticast, "update"),
|
|
("multicast", PluginMulticast, "restart"),
|
|
("cli", PluginCli, "update"),
|
|
("observer", PluginObserver, "update"),
|
|
],
|
|
)
|
|
async def test_plugin_lifecycle_blocked_when_not_running(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
coresys: CoreSys,
|
|
plugin_path: str,
|
|
plugin_class: type,
|
|
method: str,
|
|
blocked_state: CoreState,
|
|
):
|
|
"""Test plugin update/restart routes are rejected unless Supervisor is running."""
|
|
client, prefix = api_client_with_prefix
|
|
await coresys.core.set_state(blocked_state)
|
|
|
|
with patch.object(plugin_class, method, new=AsyncMock()) as mocked:
|
|
resp = await client.post(f"{prefix}/{plugin_path}/{method}")
|
|
|
|
await _assert_blocked(resp, mocked)
|