mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-09-29 18:03:47 +01:00
* Start the Supervisor auto update on every version reload A pending Supervisor update blocks Core, OS and app updates through the SUPERVISOR_UPDATED job condition. Only the startup and the daily scheduled reload started the auto update. Every other reload, such as the one Core requests when the user opens Settings, made the new version known without installing it. The user then saw "supervisor needs to be updated first" until the daily task ran. The updater now starts the auto update task after every reload while the system is running. The scheduled task calls the updater reload directly. The Supervisor update job rejects a concurrent update request, so a user requested update during the auto update fails cleanly instead of creating an update failed issue. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAWqYzDiFEvYjZwfBWoxkC * Report success for an update request during a running Supervisor update The auto update can already run when Core or a user requests the update. The requested outcome is underway, so the API reports success and the caller sees the result through the Supervisor restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAWqYzDiFEvYjZwfBWoxkC * Trim comments around the Supervisor auto update Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAWqYzDiFEvYjZwfBWoxkC * Drop comment on the updater auto update trigger Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAWqYzDiFEvYjZwfBWoxkC * Raise specific errors when restoring a backup requires a Supervisor update Move the supervisor version check in do_restore_full/do_restore_partial into a shared _check_supervisor_version helper. If the backup requires a newer Supervisor version: - Raise the new translatable BackupSupervisorVersionError (400) with the existing message when auto update is disabled. - Otherwise kick off the Supervisor auto update in the background via sys_create_task(auto_update_supervisor()) and raise the new translatable BackupSupervisorUpdateInProgressError (503), telling the caller to retry once the update completes. * Recheck for Supervisor update before failing a version-mismatch restore When a restore requires a newer Supervisor version and auto update is enabled, refresh version info first in case a newer Supervisor was just released. If an update is already known to be needed, make sure it gets kicked off directly instead of waiting on a reload. Only fall back to the translatable version-mismatch error if no update is available after the recheck. Also move auto_update_supervisor from Tasks to the Supervisor class so it can be reused outside of the task scheduler. * Track auto update and version fetch tasks to close restore-check races Supervisor.auto_update_supervisor() now stores and returns its update task (created with eager_start so a synchronous failure is reflected immediately) instead of firing-and-forgetting it. This lets callers check the task's actual done state rather than guessing based on need_update, which could be wrong if the update job rejected the call for an unrelated reason. HomeAssistantCore's install retry loop now also catches SupervisorJobError when triggering the Supervisor update it depends on, so it keeps retrying while an update is already in progress instead of giving up. Add Updater.start_fetch_data(), which starts (or reuses) a version fetch and returns its task. fetch_data() sets its throttle timestamp before running, even on failure, so a caller relying on fetch_data() directly can't tell "someone else just refreshed" from "someone else's fetch just failed" - it would silently skip either way. Callers that need to know the true outcome should await the shared task instead. reload() and BackupManager._check_supervisor_version() are updated to use it: the latter awaits the fetch task directly (skipping it entirely if there's no connectivity to fetch with) before checking auto_update_supervisor()'s task, closing a race where a stale/failed throttle window could cause a 400 (version mismatch) response instead of a 503 (update in progress). * Use Job decorator's detach option for update and version fetch tasks #7211 added a `detach` option to the Job decorator, making the manual task-tracking previously added here redundant. Replace it with the decorator-native option: - `Supervisor.update()` is now `detach=True` with `concurrency=JobConcurrency.REJECT`. Calling it returns the task performing the update - which may already be in progress from any previous caller, manual or automatic - instead of raising `SupervisorJobError` or blocking the caller until it (and the Supervisor restart it triggers) completes. Errors are raised on the returned task, not to the immediate caller. - `Supervisor.auto_update_supervisor()` is now a thin, non-detached method that simply returns `update()`'s own task directly instead of tracking a separate task itself. This ensures a concurrent manual update and an auto update share the exact same underlying task regardless of which caller started it. It never awaits the task to completion, since update() restarts Supervisor and awaiting that here could drop the caller's connection before a response is sent. - `Updater.fetch_data()` is now `detach=True` with `concurrency=JobConcurrency.REJECT`, replacing the bespoke `start_fetch_data()`/`_fetch_task` tracking. `reload()` and `BackupManager._check_supervisor_version()` await the returned task directly to get the real outcome of a fetch instead of relying on the throttle window. - The Supervisor update API endpoint and the Home Assistant Core install retry loop now use `if task := await ...: await task` instead of catching `SupervisorJobError`, since a concurrent call no longer raises that error - it returns the shared in-progress task instead. Updated tests across supervisor.py, updater.py, the API and Home Assistant Core install retry loop to match the new return values and removed now-obsolete `SupervisorJobError`-based concurrency tests. * Fix too-many-nested-blocks pylint warning in HomeAssistantCore.install() Flatten the nested need_update/auto_update if/else into an if/elif so the Supervisor update retry try/except isn't nested one level deeper, which pylint 4.0.8 (unlike the previously installed version in this container) now flags as too-many-nested-blocks (6/5). Cache need_update in a local variable since it's now referenced twice in the same iteration and must be consistent between both checks. * Update supervisor/api/supervisor.py --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Mike Degatano <michael.degatano@gmail.com> Co-authored-by: Stefan Agner <stefan@agner.ch>
735 lines
25 KiB
Python
735 lines
25 KiB
Python
"""Test Supervisor API."""
|
|
|
|
# pylint: disable=protected-access
|
|
import asyncio
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
|
|
|
import aiodocker
|
|
from aiodocker.containers import DockerContainer
|
|
from aiohttp.test_utils import TestClient
|
|
from awesomeversion import AwesomeVersion
|
|
from blockbuster import BlockingError
|
|
import pytest
|
|
|
|
from supervisor.const import CoreState, FeatureFlag
|
|
from supervisor.core import Core
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.docker.manager import DockerAPI
|
|
from supervisor.exceptions import (
|
|
HassioError,
|
|
HostJournalGatewaydConnectionError,
|
|
HostNotSupportedError,
|
|
StoreGitError,
|
|
)
|
|
from supervisor.homeassistant.const import WSEvent
|
|
from supervisor.store.repository import Repository
|
|
from supervisor.supervisor import Supervisor
|
|
from supervisor.updater import Updater
|
|
|
|
from tests.common import AsyncIterator, load_json_fixture
|
|
from tests.dbus_service_mocks.base import DBusServiceMock
|
|
from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService
|
|
|
|
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
|
|
|
|
|
|
async def test_api_supervisor_options_debug(
|
|
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
|
|
):
|
|
"""Test security options force security."""
|
|
api_client, prefix = api_client_with_prefix
|
|
assert not coresys.config.debug
|
|
|
|
await api_client.post(f"{prefix}/supervisor/options", json={"debug": True})
|
|
|
|
assert coresys.config.debug
|
|
|
|
|
|
async def test_api_supervisor_info_v1_includes_deprecated_fields(
|
|
api_client: TestClient,
|
|
):
|
|
"""Test v1 /supervisor/info includes deprecated contract fields."""
|
|
resp = await api_client.get("/supervisor/info")
|
|
assert resp.status == 200
|
|
|
|
data = (await resp.json())["data"]
|
|
assert "wait_boot" in data
|
|
assert "addons" in data
|
|
assert "addons_repositories" in data
|
|
|
|
|
|
async def test_api_supervisor_info_v2_excludes_deprecated_fields(
|
|
api_client_v2: TestClient,
|
|
):
|
|
"""Test v2 /supervisor/info excludes deprecated contract fields."""
|
|
resp = await api_client_v2.get("/v2/supervisor/info")
|
|
assert resp.status == 200
|
|
|
|
data = (await resp.json())["data"]
|
|
assert "wait_boot" not in data
|
|
assert "addons" not in data
|
|
assert "addons_repositories" not in data
|
|
|
|
|
|
async def test_api_supervisor_options_add_repository(
|
|
api_client: TestClient,
|
|
coresys: CoreSys,
|
|
supervisor_internet: AsyncMock,
|
|
):
|
|
"""Test add a repository via POST /supervisor/options REST API."""
|
|
assert REPO_URL not in coresys.store.repository_urls
|
|
|
|
with (
|
|
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
|
|
patch("supervisor.store.repository.RepositoryGit.validate", return_value=True),
|
|
):
|
|
response = await api_client.post(
|
|
"/supervisor/options", json={"addons_repositories": [REPO_URL]}
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert REPO_URL in coresys.store.repository_urls
|
|
|
|
|
|
async def test_api_supervisor_options_remove_repository(
|
|
api_client: TestClient,
|
|
coresys: CoreSys,
|
|
test_repository: Repository,
|
|
):
|
|
"""Test remove a repository via POST /supervisor/options REST API."""
|
|
assert test_repository.source in coresys.store.repository_urls
|
|
assert test_repository.slug in coresys.store.repositories
|
|
|
|
response = await api_client.post(
|
|
"/supervisor/options", json={"addons_repositories": []}
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert test_repository.source not in coresys.store.repository_urls
|
|
assert test_repository.slug not in coresys.store.repositories
|
|
|
|
|
|
async def test_api_supervisor_options_v1_accepts_deprecated_fields(
|
|
api_client: TestClient,
|
|
coresys: CoreSys,
|
|
):
|
|
"""Test v1 /supervisor/options accepts deprecated request fields."""
|
|
with patch.object(coresys.store, "update_repositories", new=AsyncMock()) as update:
|
|
response = await api_client.post(
|
|
"/supervisor/options",
|
|
json={"wait_boot": 42, "addons_repositories": []},
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert coresys.config.wait_boot == 42
|
|
update.assert_awaited_once_with(set())
|
|
|
|
|
|
async def test_api_supervisor_options_v2_rejects_deprecated_fields(
|
|
api_client_v2: TestClient,
|
|
):
|
|
"""Test v2 /supervisor/options rejects deprecated request fields."""
|
|
response = await api_client_v2.post("/v2/supervisor/options", json={"wait_boot": 7})
|
|
assert response.status == 400
|
|
|
|
response = await api_client_v2.post(
|
|
"/v2/supervisor/options", json={"addons_repositories": []}
|
|
)
|
|
assert response.status == 400
|
|
|
|
|
|
@pytest.mark.parametrize("git_error", [None, StoreGitError()])
|
|
async def test_api_supervisor_options_repositories_skipped_on_error(
|
|
api_client: TestClient, coresys: CoreSys, git_error: StoreGitError
|
|
):
|
|
"""Test repositories skipped on error via POST /supervisor/options REST API."""
|
|
with (
|
|
patch("supervisor.store.repository.RepositoryGit.load", side_effect=git_error),
|
|
patch("supervisor.store.repository.RepositoryGit.validate", return_value=False),
|
|
patch("supervisor.store.repository.RepositoryCustom.remove"),
|
|
):
|
|
response = await api_client.post(
|
|
"/supervisor/options", json={"addons_repositories": [REPO_URL]}
|
|
)
|
|
|
|
assert response.status == 400
|
|
assert len(coresys.resolution.suggestions) == 0
|
|
assert REPO_URL not in coresys.store.repository_urls
|
|
|
|
|
|
async def test_api_supervisor_options_repo_error_with_config_change(
|
|
api_client: TestClient, coresys: CoreSys
|
|
):
|
|
"""Test config change with add repository error via POST /supervisor/options REST API."""
|
|
assert not coresys.config.debug
|
|
|
|
with patch(
|
|
"supervisor.store.repository.RepositoryGit.load", side_effect=StoreGitError()
|
|
):
|
|
response = await api_client.post(
|
|
"/supervisor/options",
|
|
json={"debug": True, "addons_repositories": [REPO_URL]},
|
|
)
|
|
|
|
assert response.status == 400
|
|
assert REPO_URL not in coresys.store.repository_urls
|
|
|
|
assert coresys.config.debug
|
|
coresys.updater.save_data.assert_called_once()
|
|
coresys.config.save_data.assert_called_once()
|
|
|
|
|
|
async def test_api_supervisor_options_auto_update(
|
|
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
|
|
):
|
|
"""Test disabling auto update via api."""
|
|
api_client, prefix = api_client_with_prefix
|
|
assert coresys.updater.auto_update is True
|
|
|
|
response = await api_client.post(
|
|
f"{prefix}/supervisor/options", json={"auto_update": False}
|
|
)
|
|
|
|
assert response.status == 200
|
|
|
|
assert coresys.updater.auto_update is False
|
|
|
|
|
|
async def test_api_supervisor_options_diagnostics(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
coresys: CoreSys,
|
|
os_agent_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
|
|
):
|
|
"""Test changing diagnostics."""
|
|
api_client, prefix = api_client_with_prefix
|
|
os_agent_service: OSAgentService = os_agent_services["os_agent"]
|
|
os_agent_service.Diagnostics = False
|
|
await os_agent_service.ping()
|
|
assert coresys.dbus.agent.diagnostics is False
|
|
|
|
with patch("supervisor.utils.sentry.sentry_sdk.init") as sentry_init:
|
|
response = await api_client.post(
|
|
f"{prefix}/supervisor/options", json={"diagnostics": True}
|
|
)
|
|
assert response.status == 200
|
|
sentry_init.assert_called_once()
|
|
|
|
await os_agent_service.ping()
|
|
assert coresys.dbus.agent.diagnostics is True
|
|
|
|
with patch("supervisor.api.supervisor.close_sentry") as close_sentry:
|
|
response = await api_client.post(
|
|
f"{prefix}/supervisor/options", json={"diagnostics": False}
|
|
)
|
|
assert response.status == 200
|
|
close_sentry.assert_called_once()
|
|
|
|
await os_agent_service.ping()
|
|
assert coresys.dbus.agent.diagnostics is False
|
|
|
|
|
|
async def test_api_supervisor_logs(advanced_logs_tester):
|
|
"""Test supervisor logs."""
|
|
await advanced_logs_tester("/supervisor", "hassio_supervisor")
|
|
|
|
|
|
async def test_api_supervisor_fallback(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
journald_logs: MagicMock,
|
|
docker_logs: MagicMock,
|
|
):
|
|
"""Check that supervisor logs read from container logs if reading from journald gateway fails badly."""
|
|
api_client, prefix = api_client_with_prefix
|
|
journald_logs.side_effect = HassioError("Something bad happened!")
|
|
|
|
with patch("supervisor.api._LOGGER.exception") as logger:
|
|
resp = await api_client.get(f"{prefix}/supervisor/logs")
|
|
logger.assert_called_once_with(
|
|
"Failed to get supervisor logs using advanced_logs API"
|
|
)
|
|
|
|
assert resp.status == 200
|
|
assert resp.content_type == "text/plain"
|
|
content = await resp.read()
|
|
assert content.split(b"\n")[0:2] == [
|
|
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
|
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
|
]
|
|
|
|
# check fallback also works for the /follow endpoint (no mock reset needed)
|
|
|
|
with patch("supervisor.api._LOGGER.exception") as logger:
|
|
resp = await api_client.get(f"{prefix}/supervisor/logs/follow")
|
|
logger.assert_called_once_with(
|
|
"Failed to get supervisor logs using advanced_logs API"
|
|
)
|
|
|
|
assert resp.status == 200
|
|
assert resp.content_type == "text/plain"
|
|
|
|
# check the /latest endpoint as well
|
|
|
|
with patch("supervisor.api._LOGGER.exception") as logger:
|
|
resp = await api_client.get(f"{prefix}/supervisor/logs/latest")
|
|
logger.assert_called_once_with(
|
|
"Failed to get supervisor logs using advanced_logs API"
|
|
)
|
|
|
|
assert resp.status == 200
|
|
assert resp.content_type == "text/plain"
|
|
|
|
# also check generic Python error
|
|
journald_logs.side_effect = OSError("Something bad happened!")
|
|
|
|
with patch("supervisor.api._LOGGER.exception") as logger:
|
|
resp = await api_client.get(f"{prefix}/supervisor/logs")
|
|
logger.assert_called_once_with(
|
|
"Failed to get supervisor logs using advanced_logs API"
|
|
)
|
|
assert resp.status == 200
|
|
assert resp.content_type == "text/plain"
|
|
|
|
|
|
async def test_api_supervisor_fallback_log_capture(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
journald_logs: MagicMock,
|
|
docker_logs: MagicMock,
|
|
):
|
|
"""Check that Sentry log capture is executed only for unexpected errors."""
|
|
api_client, prefix = api_client_with_prefix
|
|
journald_logs.side_effect = HostNotSupportedError(
|
|
"No systemd-journal-gatewayd Unix socket available!"
|
|
)
|
|
|
|
with patch("supervisor.api.async_capture_exception") as capture_exception:
|
|
await api_client.get(f"{prefix}/supervisor/logs")
|
|
capture_exception.assert_not_called()
|
|
|
|
journald_logs.reset_mock()
|
|
|
|
journald_logs.side_effect = HostJournalGatewaydConnectionError(
|
|
"Unable to connect to systemd-journal-gatewayd"
|
|
)
|
|
|
|
with patch("supervisor.api.async_capture_exception") as capture_exception:
|
|
await api_client.get(f"{prefix}/supervisor/logs")
|
|
capture_exception.assert_not_called()
|
|
|
|
journald_logs.reset_mock()
|
|
|
|
journald_logs.side_effect = HassioError("Something bad happened!")
|
|
|
|
with patch("supervisor.api.async_capture_exception") as capture_exception:
|
|
await api_client.get(f"{prefix}/supervisor/logs")
|
|
capture_exception.assert_called_once()
|
|
|
|
|
|
async def test_api_supervisor_reload(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
supervisor_internet: AsyncMock,
|
|
websession: MagicMock,
|
|
):
|
|
"""Test supervisor reload."""
|
|
api_client, prefix = api_client_with_prefix
|
|
resp = await api_client.post(f"{prefix}/supervisor/reload")
|
|
assert resp.status == 200
|
|
assert await resp.json() == {"result": "ok", "data": {}}
|
|
|
|
|
|
async def test_api_supervisor_options_timezone(
|
|
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
|
|
):
|
|
"""Test setting supervisor timezone via API."""
|
|
api_client, prefix = api_client_with_prefix
|
|
assert coresys.timezone == "Etc/UTC"
|
|
|
|
resp = await api_client.post(
|
|
f"{prefix}/supervisor/options", json={"timezone": "Europe/Zurich"}
|
|
)
|
|
assert resp.status == 200
|
|
|
|
assert coresys.timezone == "Europe/Zurich"
|
|
|
|
|
|
async def test_api_supervisor_options_country(
|
|
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
|
|
):
|
|
"""Test setting supervisor country via API."""
|
|
api_client, prefix = api_client_with_prefix
|
|
assert coresys.config.country is None
|
|
|
|
resp = await api_client.post(f"{prefix}/supervisor/options", json={"country": "CH"})
|
|
assert resp.status == 200
|
|
|
|
assert coresys.config.country == "CH"
|
|
|
|
resp = await api_client.get(f"{prefix}/supervisor/info")
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["data"]["country"] == "CH"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("blockbuster", "option_value", "config_value"),
|
|
[("no_blockbuster", "on", False), ("no_blockbuster", "on-at-startup", True)],
|
|
indirect=["blockbuster"],
|
|
)
|
|
async def test_api_supervisor_options_blocking_io(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
coresys: CoreSys,
|
|
option_value: str,
|
|
config_value: bool,
|
|
):
|
|
"""Test setting supervisor detect blocking io option."""
|
|
api_client, prefix = api_client_with_prefix
|
|
# This should not fail with a blocking error yet
|
|
time.sleep(0)
|
|
|
|
resp = await api_client.post(
|
|
f"{prefix}/supervisor/options", json={"detect_blocking_io": option_value}
|
|
)
|
|
assert resp.status == 200
|
|
|
|
resp = await api_client.get(f"{prefix}/supervisor/info")
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["data"]["detect_blocking_io"] is True
|
|
|
|
# This remains false because we only turned it on for current run of supervisor, not permanently
|
|
assert coresys.config.detect_blocking_io is config_value
|
|
|
|
with pytest.raises(BlockingError):
|
|
time.sleep(0)
|
|
|
|
resp = await api_client.post(
|
|
f"{prefix}/supervisor/options", json={"detect_blocking_io": "off"}
|
|
)
|
|
assert resp.status == 200
|
|
|
|
resp = await api_client.get(f"{prefix}/supervisor/info")
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["data"]["detect_blocking_io"] is False
|
|
assert coresys.config.detect_blocking_io is False
|
|
|
|
# This should not raise blocking error anymore
|
|
time.sleep(0)
|
|
|
|
|
|
@pytest.mark.usefixtures("tmp_supervisor_data")
|
|
async def test_api_progress_updates_supervisor_update(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
coresys: CoreSys,
|
|
ha_ws_client: AsyncMock,
|
|
):
|
|
"""Test progress updates sent to Home Assistant for updates."""
|
|
api_client, prefix = api_client_with_prefix
|
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
|
coresys.core.set_state(CoreState.RUNNING)
|
|
|
|
logs = load_json_fixture("docker_pull_image_log.json")
|
|
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
|
|
|
with (
|
|
patch.object(
|
|
Supervisor,
|
|
"version",
|
|
new=PropertyMock(return_value=AwesomeVersion("2025.08.0")),
|
|
),
|
|
patch.object(
|
|
Updater,
|
|
"version_supervisor",
|
|
new=PropertyMock(return_value=AwesomeVersion("2025.08.3")),
|
|
),
|
|
patch.object(
|
|
Updater, "image_supervisor", new=PropertyMock(return_value="supervisor")
|
|
),
|
|
patch.object(Supervisor, "update_apparmor"),
|
|
patch.object(Core, "stop"),
|
|
):
|
|
resp = await api_client.post(f"{prefix}/supervisor/update")
|
|
|
|
assert resp.status == 200
|
|
|
|
events = [
|
|
{
|
|
"stage": evt.args[0]["data"]["data"]["stage"],
|
|
"progress": evt.args[0]["data"]["data"]["progress"],
|
|
"done": evt.args[0]["data"]["data"]["done"],
|
|
}
|
|
for evt in ha_ws_client.async_send_command.call_args_list
|
|
if "data" in evt.args[0]
|
|
and evt.args[0]["data"]["event"] == WSEvent.JOB
|
|
and evt.args[0]["data"]["data"]["name"] == "supervisor_update"
|
|
]
|
|
# Count-based progress: 2 layers need pulling (each worth 50%)
|
|
# Layers that already exist are excluded from progress calculation
|
|
assert events[:4] == [
|
|
{
|
|
"stage": None,
|
|
"progress": 0,
|
|
"done": False,
|
|
},
|
|
{
|
|
"stage": None,
|
|
"progress": 9.2,
|
|
"done": False,
|
|
},
|
|
{
|
|
"stage": None,
|
|
"progress": 25.6,
|
|
"done": False,
|
|
},
|
|
{
|
|
"stage": None,
|
|
"progress": 35.4,
|
|
"done": False,
|
|
},
|
|
]
|
|
assert events[-5:] == [
|
|
{
|
|
"stage": None,
|
|
"progress": 95.5,
|
|
"done": False,
|
|
},
|
|
{
|
|
"stage": None,
|
|
"progress": 96.9,
|
|
"done": False,
|
|
},
|
|
{
|
|
"stage": None,
|
|
"progress": 98.2,
|
|
"done": False,
|
|
},
|
|
{
|
|
"stage": None,
|
|
"progress": 100,
|
|
"done": False,
|
|
},
|
|
{
|
|
"stage": None,
|
|
"progress": 100,
|
|
"done": True,
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("dev", [True, False])
|
|
async def test_api_supervisor_update_no_update_available(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
dev: bool,
|
|
):
|
|
"""Test versionless update is a no-op when no update is available.
|
|
|
|
Home Assistant Core triggers a versionless Supervisor update during
|
|
onboarding. Without an available update (always the case in DEV) this
|
|
must report no update rather than installing the latest published version.
|
|
"""
|
|
api_client, prefix = api_client_with_prefix
|
|
|
|
with (
|
|
patch.object(CoreSys, "dev", new=PropertyMock(return_value=dev)),
|
|
patch.object(Supervisor, "need_update", new=PropertyMock(return_value=False)),
|
|
patch.object(Supervisor, "update") as update,
|
|
):
|
|
resp = await api_client.post(f"{prefix}/supervisor/update")
|
|
|
|
assert resp.status == 400
|
|
result = await resp.json()
|
|
assert "No supervisor update available" in result["message"]
|
|
update.assert_not_called()
|
|
|
|
|
|
async def test_api_supervisor_update_already_running(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
):
|
|
"""Test an update request while one is already running awaits and reports its result."""
|
|
api_client, prefix = api_client_with_prefix
|
|
|
|
async def _already_running_update() -> None:
|
|
"""Simulate the shared, already-in-progress update completing successfully."""
|
|
|
|
with (
|
|
patch.object(Supervisor, "need_update", new=PropertyMock(return_value=True)),
|
|
patch.object(
|
|
Supervisor,
|
|
"update",
|
|
AsyncMock(
|
|
return_value=asyncio.get_event_loop().create_task(
|
|
_already_running_update()
|
|
)
|
|
),
|
|
),
|
|
):
|
|
resp = await api_client.post(f"{prefix}/supervisor/update")
|
|
|
|
assert resp.status == 200
|
|
|
|
|
|
async def test_api_supervisor_update_dev_explicit_version(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
):
|
|
"""Test an explicit version can be forced in DEV without an update available."""
|
|
api_client, prefix = api_client_with_prefix
|
|
|
|
with (
|
|
patch.object(CoreSys, "dev", new=PropertyMock(return_value=True)),
|
|
patch.object(Supervisor, "need_update", new=PropertyMock(return_value=False)),
|
|
patch.object(Supervisor, "update", AsyncMock(return_value=None)) as update,
|
|
):
|
|
resp = await api_client.post(
|
|
f"{prefix}/supervisor/update", json={"version": "2025.08.3"}
|
|
)
|
|
|
|
assert resp.status == 200
|
|
update.assert_called_once_with(AwesomeVersion("2025.08.3"))
|
|
|
|
|
|
async def test_api_supervisor_stats(
|
|
api_client_with_prefix: tuple[TestClient, str], container: DockerContainer
|
|
):
|
|
"""Test supervisor stats."""
|
|
api_client, prefix = api_client_with_prefix
|
|
container.show.return_value["State"]["Status"] = "running"
|
|
container.show.return_value["State"]["Running"] = True
|
|
|
|
if prefix == "/v2":
|
|
# V2 always requests one-shot stats
|
|
stats_fixture = load_json_fixture("container_stats.json")
|
|
del stats_fixture["precpu_stats"]
|
|
with patch.object(
|
|
DockerAPI,
|
|
"_query_one_shot_stats",
|
|
AsyncMock(return_value=stats_fixture),
|
|
):
|
|
resp = await api_client.get(f"{prefix}/supervisor/stats")
|
|
else:
|
|
container.stats = AsyncMock(
|
|
return_value=[load_json_fixture("container_stats.json")]
|
|
)
|
|
resp = await api_client.get(f"{prefix}/supervisor/stats")
|
|
|
|
assert resp.status == 200
|
|
result = await resp.json()
|
|
if prefix == "/v2":
|
|
assert "cpu_percent" not in result["data"]
|
|
else:
|
|
assert result["data"]["cpu_percent"] == 90.0
|
|
assert result["data"]["memory_usage"] == 59700000
|
|
assert result["data"]["memory_limit"] == 4000000000
|
|
assert result["data"]["memory_percent"] == 1.49
|
|
|
|
|
|
async def test_supervisor_api_stats_failure(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
coresys: CoreSys,
|
|
caplog: pytest.LogCaptureFixture,
|
|
):
|
|
"""Test supervisor stats failure."""
|
|
api_client, prefix = api_client_with_prefix
|
|
|
|
if prefix == "/v2":
|
|
# V2 always requests one-shot stats, which doesn't call containers.container
|
|
with patch.object(
|
|
DockerAPI,
|
|
"_query_one_shot_stats",
|
|
AsyncMock(side_effect=aiodocker.DockerError(500, {"message": "fail"})),
|
|
):
|
|
resp = await api_client.get(f"{prefix}/supervisor/stats")
|
|
else:
|
|
coresys.docker.containers.container.return_value.stats.side_effect = (
|
|
aiodocker.DockerError(500, {"message": "fail"})
|
|
)
|
|
resp = await api_client.get(f"{prefix}/supervisor/stats")
|
|
|
|
assert resp.status == 500
|
|
body = await resp.json()
|
|
assert (
|
|
body["message"]
|
|
== "An unknown error occurred with Supervisor. Check Supervisor logs for details"
|
|
)
|
|
assert body["error_key"] == "supervisor_unknown_error"
|
|
if prefix != "/v2":
|
|
assert (
|
|
"Can't read stats from hassio_supervisor: [500] {'message': 'fail'}"
|
|
in caplog.text
|
|
)
|
|
|
|
|
|
async def test_api_supervisor_info_feature_flags(
|
|
api_client: TestClient, coresys: CoreSys
|
|
):
|
|
"""Test that supervisor info returns all feature flags with default False."""
|
|
resp = await api_client.get("/supervisor/info")
|
|
assert resp.status == 200
|
|
result = await resp.json()
|
|
|
|
assert "feature_flags" in result["data"]
|
|
feature_flags = result["data"]["feature_flags"]
|
|
|
|
# All known feature flags should be present and default to False
|
|
for feature in FeatureFlag:
|
|
assert feature.value in feature_flags
|
|
assert feature_flags[feature.value] is False
|
|
|
|
|
|
async def test_api_supervisor_options_feature_flags_enable(
|
|
api_client: TestClient, coresys: CoreSys
|
|
):
|
|
"""Test enabling a feature flag via supervisor options."""
|
|
assert not coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API)
|
|
|
|
response = await api_client.post(
|
|
"/supervisor/options",
|
|
json={"feature_flags": {"supervisor_v2_api": True}},
|
|
)
|
|
assert response.status == 200
|
|
assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is True
|
|
|
|
|
|
async def test_api_supervisor_options_feature_flags_disable(
|
|
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
|
|
):
|
|
"""Test disabling a feature flag via supervisor options."""
|
|
api_client, prefix = api_client_with_prefix
|
|
coresys.config.set_feature_flag(FeatureFlag.SUPERVISOR_V2_API, True)
|
|
assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is True
|
|
|
|
response = await api_client.post(
|
|
f"{prefix}/supervisor/options",
|
|
json={"feature_flags": {"supervisor_v2_api": False}},
|
|
)
|
|
assert response.status == 200
|
|
assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is False
|
|
|
|
|
|
async def test_api_supervisor_options_feature_flags_partial_update(
|
|
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
|
|
):
|
|
"""Test that omitting a feature flag in options leaves its state unchanged."""
|
|
api_client, prefix = api_client_with_prefix
|
|
coresys.config.set_feature_flag(FeatureFlag.SUPERVISOR_V2_API, True)
|
|
|
|
# Post options without mentioning feature_flags at all
|
|
response = await api_client.post(
|
|
f"{prefix}/supervisor/options", json={"debug": False}
|
|
)
|
|
assert response.status == 200
|
|
|
|
# The feature flag should remain True
|
|
assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is True
|
|
|
|
|
|
async def test_api_supervisor_options_feature_flags_unknown_flag(
|
|
api_client_with_prefix: tuple[TestClient, str],
|
|
):
|
|
"""Test that an unknown feature flag name is rejected."""
|
|
api_client, prefix = api_client_with_prefix
|
|
response = await api_client.post(
|
|
f"{prefix}/supervisor/options",
|
|
json={"feature_flags": {"unknown_feature": True}},
|
|
)
|
|
assert response.status == 400
|