diff --git a/supervisor/addons/manager.py b/supervisor/addons/manager.py index 00fe1d62084..1dea162d4db 100644 --- a/supervisor/addons/manager.py +++ b/supervisor/addons/manager.py @@ -184,7 +184,9 @@ async def shutdown(self, stage: AddonStartup) -> None: on_condition=AddonsJobError, concurrency=JobConcurrency.QUEUE, ) - async def install(self, slug: str) -> None: + async def install( + self, slug: str, *, validation_complete: asyncio.Event | None = None + ) -> None: """Install an add-on.""" self.sys_jobs.current.reference = slug @@ -197,6 +199,10 @@ async def install(self, slug: str) -> None: store.validate_availability() + # If being run in the background, notify caller that validation has completed + if validation_complete: + validation_complete.set() + await Addon(self.coresys, slug).install() _LOGGER.info("Add-on '%s' successfully installed", slug) @@ -226,7 +232,11 @@ async def uninstall(self, slug: str, *, remove_config: bool = False) -> None: on_condition=AddonsJobError, ) async def update( - self, slug: str, backup: bool | None = False + self, + slug: str, + backup: bool | None = False, + *, + validation_complete: asyncio.Event | None = None, ) -> asyncio.Task | None: """Update add-on. @@ -251,6 +261,10 @@ async def update( # Check if available, Maybe something have changed store.validate_availability() + # If being run in the background, notify caller that validation has completed + if validation_complete: + validation_complete.set() + if backup: await self.sys_backups.do_backup_partial( name=f"addon_{addon.slug}_{addon.version}", diff --git a/supervisor/api/backups.py b/supervisor/api/backups.py index f2d8b549660..3a4215a87cc 100644 --- a/supervisor/api/backups.py +++ b/supervisor/api/backups.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from collections.abc import Callable import errno from io import IOBase import logging @@ -46,12 +45,9 @@ ATTR_TYPE, ATTR_VERSION, REQUEST_FROM, - BusEvent, - CoreState, ) from ..coresys import CoreSysAttributes from ..exceptions import APIError, APIForbidden, APINotFound -from ..jobs import JobSchedulerOptions, SupervisorJob from ..mounts.const import MountUsage from ..resolution.const import UnhealthyReason from .const import ( @@ -61,7 +57,7 @@ ATTR_LOCATIONS, CONTENT_TYPE_TAR, ) -from .utils import api_process, api_validate +from .utils import api_process, api_validate, background_task _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -289,41 +285,6 @@ def _validate_cloud_backup_location( f"Location {LOCATION_CLOUD_BACKUP} is only available for Home Assistant" ) - async def _background_backup_task( - self, backup_method: Callable, *args, **kwargs - ) -> tuple[asyncio.Task, str]: - """Start backup task in background and return task and job ID.""" - event = asyncio.Event() - job, backup_task = cast( - tuple[SupervisorJob, asyncio.Task], - self.sys_jobs.schedule_job( - backup_method, JobSchedulerOptions(), *args, **kwargs - ), - ) - - async def release_on_freeze(new_state: CoreState): - if new_state == CoreState.FREEZE: - event.set() - - # Wait for system to get into freeze state before returning - # If the backup fails validation it will raise before getting there - listener = self.sys_bus.register_event( - BusEvent.SUPERVISOR_STATE_CHANGE, release_on_freeze - ) - try: - event_task = self.sys_create_task(event.wait()) - _, pending = await asyncio.wait( - (backup_task, event_task), - return_when=asyncio.FIRST_COMPLETED, - ) - # It seems backup returned early (error or something), make sure to cancel - # the event task to avoid "Task was destroyed but it is pending!" errors. - if event_task in pending: - event_task.cancel() - return (backup_task, job.uuid) - finally: - self.sys_bus.remove_listener(listener) - @api_process async def backup_full(self, request: web.Request): """Create full backup.""" @@ -342,8 +303,8 @@ async def backup_full(self, request: web.Request): body[ATTR_ADDITIONAL_LOCATIONS] = locations background = body.pop(ATTR_BACKGROUND) - backup_task, job_id = await self._background_backup_task( - self.sys_backups.do_backup_full, **body + backup_task, job_id = await background_task( + self, self.sys_backups.do_backup_full, **body ) if background and not backup_task.done(): @@ -378,8 +339,8 @@ async def backup_partial(self, request: web.Request): body[ATTR_ADDONS] = list(self.sys_addons.local) background = body.pop(ATTR_BACKGROUND) - backup_task, job_id = await self._background_backup_task( - self.sys_backups.do_backup_partial, **body + backup_task, job_id = await background_task( + self, self.sys_backups.do_backup_partial, **body ) if background and not backup_task.done(): @@ -402,8 +363,8 @@ async def restore_full(self, request: web.Request): request, body.get(ATTR_LOCATION, backup.location) ) background = body.pop(ATTR_BACKGROUND) - restore_task, job_id = await self._background_backup_task( - self.sys_backups.do_restore_full, backup, **body + restore_task, job_id = await background_task( + self, self.sys_backups.do_restore_full, backup, **body ) if background and not restore_task.done() or await restore_task: @@ -422,8 +383,8 @@ async def restore_partial(self, request: web.Request): request, body.get(ATTR_LOCATION, backup.location) ) background = body.pop(ATTR_BACKGROUND) - restore_task, job_id = await self._background_backup_task( - self.sys_backups.do_restore_partial, backup, **body + restore_task, job_id = await background_task( + self, self.sys_backups.do_restore_partial, backup, **body ) if background and not restore_task.done() or await restore_task: diff --git a/supervisor/api/homeassistant.py b/supervisor/api/homeassistant.py index 7f26b080e8b..8fb3cb4262c 100644 --- a/supervisor/api/homeassistant.py +++ b/supervisor/api/homeassistant.py @@ -20,6 +20,7 @@ ATTR_CPU_PERCENT, ATTR_IMAGE, ATTR_IP_ADDRESS, + ATTR_JOB_ID, ATTR_MACHINE, ATTR_MEMORY_LIMIT, ATTR_MEMORY_PERCENT, @@ -37,8 +38,8 @@ from ..coresys import CoreSysAttributes from ..exceptions import APIDBMigrationInProgress, APIError from ..validate import docker_image, network_port, version_tag -from .const import ATTR_FORCE, ATTR_SAFE_MODE -from .utils import api_process, api_validate +from .const import ATTR_BACKGROUND, ATTR_FORCE, ATTR_SAFE_MODE +from .utils import api_process, api_validate, background_task _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -61,6 +62,7 @@ { vol.Optional(ATTR_VERSION): version_tag, vol.Optional(ATTR_BACKUP): bool, + vol.Optional(ATTR_BACKGROUND, default=False): bool, } ) @@ -170,18 +172,24 @@ async def stats(self, request: web.Request) -> dict[Any, str]: } @api_process - async def update(self, request: web.Request) -> None: + async def update(self, request: web.Request) -> dict[str, str] | None: """Update Home Assistant.""" body = await api_validate(SCHEMA_UPDATE, request) await self._check_offline_migration() - await asyncio.shield( - self.sys_homeassistant.core.update( - version=body.get(ATTR_VERSION, self.sys_homeassistant.latest_version), - backup=body.get(ATTR_BACKUP), - ) + background = body[ATTR_BACKGROUND] + update_task, job_id = await background_task( + self, + self.sys_homeassistant.core.update, + version=body.get(ATTR_VERSION, self.sys_homeassistant.latest_version), + backup=body.get(ATTR_BACKUP), ) + if background and not update_task.done(): + return {ATTR_JOB_ID: job_id} + + return await update_task + @api_process async def stop(self, request: web.Request) -> Awaitable[None]: """Stop Home Assistant.""" diff --git a/supervisor/api/store.py b/supervisor/api/store.py index 3bcb4189127..85c9e30a24e 100644 --- a/supervisor/api/store.py +++ b/supervisor/api/store.py @@ -1,7 +1,6 @@ """Init file for Supervisor Home Assistant RESTful API.""" import asyncio -from collections.abc import Awaitable from pathlib import Path from typing import Any, cast @@ -36,6 +35,7 @@ ATTR_ICON, ATTR_INGRESS, ATTR_INSTALLED, + ATTR_JOB_ID, ATTR_LOGO, ATTR_LONG_DESCRIPTION, ATTR_MAINTAINER, @@ -57,11 +57,13 @@ from ..store.addon import AddonStore from ..store.repository import Repository from ..store.validate import validate_repository -from .const import CONTENT_TYPE_PNG, CONTENT_TYPE_TEXT +from .const import ATTR_BACKGROUND, CONTENT_TYPE_PNG, CONTENT_TYPE_TEXT +from .utils import background_task SCHEMA_UPDATE = vol.Schema( { vol.Optional(ATTR_BACKUP): bool, + vol.Optional(ATTR_BACKGROUND, default=False): bool, } ) @@ -69,6 +71,12 @@ {vol.Required(ATTR_REPOSITORY): vol.All(str, validate_repository)} ) +SCHEMA_INSTALL = vol.Schema( + { + vol.Optional(ATTR_BACKGROUND, default=False): bool, + } +) + def _read_static_text_file(path: Path) -> Any: """Read in a static text file asset for API output. @@ -217,24 +225,45 @@ async def addons_list(self, request: web.Request) -> dict[str, Any]: } @api_process - def addons_addon_install(self, request: web.Request) -> Awaitable[None]: + async def addons_addon_install(self, request: web.Request) -> dict[str, str] | None: """Install add-on.""" addon = self._extract_addon(request) - return asyncio.shield(self.sys_addons.install(addon.slug)) + body = await api_validate(SCHEMA_INSTALL, request) + + background = body[ATTR_BACKGROUND] + + install_task, job_id = await background_task( + self, self.sys_addons.install, addon.slug + ) + + if background and not install_task.done(): + return {ATTR_JOB_ID: job_id} + + return await install_task @api_process - async def addons_addon_update(self, request: web.Request) -> None: + async def addons_addon_update(self, request: web.Request) -> dict[str, str] | None: """Update add-on.""" addon = self._extract_addon(request, installed=True) if addon == request.get(REQUEST_FROM): raise APIForbidden(f"Add-on {addon.slug} can't update itself!") body = await api_validate(SCHEMA_UPDATE, request) + background = body[ATTR_BACKGROUND] + + update_task, job_id = await background_task( + self, + self.sys_addons.update, + addon.slug, + backup=body.get(ATTR_BACKUP), + ) + + if background and not update_task.done(): + return {ATTR_JOB_ID: job_id} - if start_task := await asyncio.shield( - self.sys_addons.update(addon.slug, backup=body.get(ATTR_BACKUP)) - ): + if start_task := await update_task: await start_task + return None @api_process async def addons_addon_info(self, request: web.Request) -> dict[str, Any]: diff --git a/supervisor/api/utils.py b/supervisor/api/utils.py index e7bc54aafdd..f5c245897dd 100644 --- a/supervisor/api/utils.py +++ b/supervisor/api/utils.py @@ -1,7 +1,9 @@ """Init file for Supervisor util for RESTful API.""" +import asyncio +from collections.abc import Callable import json -from typing import Any +from typing import Any, cast from aiohttp import web from aiohttp.hdrs import AUTHORIZATION @@ -23,6 +25,7 @@ ) from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import APIError, BackupFileNotFoundError, DockerAPIError, HassioError +from ..jobs import JobSchedulerOptions, SupervisorJob from ..utils import check_exception_chain, get_message_from_exception_chain from ..utils.json import json_dumps, json_loads as json_loads_util from ..utils.log_format import format_message @@ -198,3 +201,47 @@ async def api_validate( data_validated[origin_value] = data[origin_value] return data_validated + + +async def background_task( + coresys_obj: CoreSysAttributes, + task_method: Callable, + *args, + **kwargs, +) -> tuple[asyncio.Task, str]: + """Start task in background and return task and job ID. + + Args: + coresys_obj: Instance that accesses coresys data using CoreSysAttributes + task_method: The method to execute in the background. Must include a keyword arg 'validation_complete' of type asyncio.Event. Should set it after any initial validation has completed + *args: Arguments to pass to task_method + **kwargs: Keyword arguments to pass to task_method + + Returns: + Tuple of (task, job_id) + + """ + event = asyncio.Event() + job, task = cast( + tuple[SupervisorJob, asyncio.Task], + coresys_obj.sys_jobs.schedule_job( + task_method, + JobSchedulerOptions(), + *args, + validation_complete=event, + **kwargs, + ), + ) + + # Wait for provided event before returning + # If the task fails validation it should raise before getting there + event_task = coresys_obj.sys_create_task(event.wait()) + _, pending = await asyncio.wait( + (task, event_task), + return_when=asyncio.FIRST_COMPLETED, + ) + # It seems task returned early (error or something), make sure to cancel + # the event task to avoid "Task was destroyed but it is pending!" errors. + if event_task in pending: + event_task.cancel() + return (task, job.uuid) diff --git a/supervisor/backups/manager.py b/supervisor/backups/manager.py index c37e4aea323..5919ef8cd93 100644 --- a/supervisor/backups/manager.py +++ b/supervisor/backups/manager.py @@ -598,6 +598,7 @@ async def do_backup_full( homeassistant_exclude_database: bool | None = None, extra: dict | None = None, additional_locations: list[LOCATION_TYPE] | None = None, + validation_complete: asyncio.Event | None = None, ) -> Backup | None: """Create a full backup.""" await self._check_location(location) @@ -614,6 +615,10 @@ async def do_backup_full( name, filename, BackupType.FULL, password, compressed, location, extra ) + # If being run in the background, notify caller that validation has completed + if validation_complete: + validation_complete.set() + _LOGGER.info("Creating new full backup with slug %s", new_backup.slug) backup = await self._do_backup( new_backup, @@ -648,6 +653,7 @@ async def do_backup_partial( homeassistant_exclude_database: bool | None = None, extra: dict | None = None, additional_locations: list[LOCATION_TYPE] | None = None, + validation_complete: asyncio.Event | None = None, ) -> Backup | None: """Create a partial backup.""" await self._check_location(location) @@ -684,6 +690,10 @@ async def do_backup_partial( continue _LOGGER.warning("Add-on %s not found/installed", addon_slug) + # If being run in the background, notify caller that validation has completed + if validation_complete: + validation_complete.set() + backup = await self._do_backup( new_backup, addon_list, @@ -817,8 +827,10 @@ async def _validate_backup_location( async def do_restore_full( self, backup: Backup, + *, password: str | None = None, location: str | None | type[DEFAULT] = DEFAULT, + validation_complete: asyncio.Event | None = None, ) -> bool: """Restore a backup.""" # Add backup ID to job @@ -838,6 +850,10 @@ async def do_restore_full( _LOGGER.error, ) + # If being run in the background, notify caller that validation has completed + if validation_complete: + validation_complete.set() + _LOGGER.info("Full-Restore %s start", backup.slug) await self.sys_core.set_state(CoreState.FREEZE) @@ -876,11 +892,13 @@ async def do_restore_full( async def do_restore_partial( self, backup: Backup, + *, homeassistant: bool = False, addons: list[str] | None = None, folders: list[str] | None = None, password: str | None = None, location: str | None | type[DEFAULT] = DEFAULT, + validation_complete: asyncio.Event | None = None, ) -> bool: """Restore a backup.""" # Add backup ID to job @@ -908,6 +926,10 @@ async def do_restore_partial( _LOGGER.error, ) + # If being run in the background, notify caller that validation has completed + if validation_complete: + validation_complete.set() + _LOGGER.info("Partial-Restore %s start", backup.slug) await self.sys_core.set_state(CoreState.FREEZE) diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index 3106c26225d..deadfa8281b 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -229,6 +229,7 @@ async def update( self, version: AwesomeVersion | None = None, backup: bool | None = False, + validation_complete: asyncio.Event | None = None, ) -> None: """Update HomeAssistant version.""" to_version = version or self.sys_homeassistant.latest_version @@ -248,6 +249,10 @@ async def update( f"Version {to_version!s} is already installed", _LOGGER.warning ) + # If being run in the background, notify caller that validation has completed + if validation_complete: + validation_complete.set() + if backup: await self.sys_backups.do_backup_partial( name=f"core_{self.instance.version}", diff --git a/tests/api/test_homeassistant.py b/tests/api/test_homeassistant.py index eff67def0c2..4e88fe2b69d 100644 --- a/tests/api/test_homeassistant.py +++ b/tests/api/test_homeassistant.py @@ -1,13 +1,16 @@ """Test homeassistant api.""" +import asyncio from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch from aiohttp.test_utils import TestClient from awesomeversion import AwesomeVersion import pytest +from supervisor.backups.manager import BackupManager from supervisor.coresys import CoreSys +from supervisor.docker.interface import DockerInterface from supervisor.homeassistant.api import APIState from supervisor.homeassistant.core import HomeAssistantCore from supervisor.homeassistant.module import HomeAssistant @@ -188,3 +191,77 @@ async def test_force_stop_during_migration(api_client: TestClient, coresys: Core with patch.object(HomeAssistantCore, "stop") as stop: await api_client.post("/homeassistant/stop", json={"force": True}) stop.assert_called_once() + + +@pytest.mark.parametrize( + ("make_backup", "backup_called", "update_called"), + [(True, True, False), (False, False, True)], +) +async def test_home_assistant_background_update( + api_client: TestClient, + coresys: CoreSys, + make_backup: bool, + backup_called: bool, + update_called: bool, +): + """Test background update of Home Assistant.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + event = asyncio.Event() + mock_update_called = mock_backup_called = False + + # Mock backup/update as long-running tasks + async def mock_docker_interface_update(*args, **kwargs): + nonlocal mock_update_called + mock_update_called = True + await event.wait() + + async def mock_partial_backup(*args, **kwargs): + nonlocal mock_backup_called + mock_backup_called = True + await event.wait() + + with ( + patch.object(DockerInterface, "update", new=mock_docker_interface_update), + patch.object(BackupManager, "do_backup_partial", new=mock_partial_backup), + patch.object( + DockerInterface, + "version", + new=PropertyMock(return_value=AwesomeVersion("2025.8.0")), + ), + ): + resp = await api_client.post( + "/core/update", + json={"background": True, "backup": make_backup, "version": "2025.8.3"}, + ) + + assert mock_backup_called is backup_called + assert mock_update_called is update_called + + assert resp.status == 200 + body = await resp.json() + assert (job := coresys.jobs.get_job(body["data"]["job_id"])) + assert job.name == "home_assistant_core_update" + event.set() + + +async def test_background_home_assistant_update_fails_fast( + api_client: TestClient, coresys: CoreSys +): + """Test background Home Assistant update returns error not job if validation doesn't succeed.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + + with ( + patch.object( + DockerInterface, + "version", + new=PropertyMock(return_value=AwesomeVersion("2025.8.3")), + ), + ): + resp = await api_client.post( + "/core/update", + json={"background": True, "version": "2025.8.3"}, + ) + + assert resp.status == 400 + body = await resp.json() + assert body["message"] == "Version 2025.8.3 is already installed" diff --git a/tests/api/test_store.py b/tests/api/test_store.py index 48d1cd7f796..29a670ab0c2 100644 --- a/tests/api/test_store.py +++ b/tests/api/test_store.py @@ -10,6 +10,7 @@ from supervisor.addons.addon import Addon from supervisor.arch import CpuArch +from supervisor.backups.manager import BackupManager from supervisor.config import CoreConfig from supervisor.const import AddonState from supervisor.coresys import CoreSys @@ -390,3 +391,104 @@ async def test_api_store_addons_changelog_corrupted( assert resp.status == 200 result = await resp.text() assert result == "Text with an invalid UTF-8 char: �" + + +@pytest.mark.usefixtures("test_repository", "tmp_supervisor_data") +async def test_addon_install_in_background(api_client: TestClient, coresys: CoreSys): + """Test installing an addon in the background.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + event = asyncio.Event() + + # Mock a long-running install task + async def mock_addon_install(*args, **kwargs): + await event.wait() + + with patch.object(Addon, "install", new=mock_addon_install): + resp = await api_client.post( + "/store/addons/local_ssh/install", json={"background": True} + ) + + assert resp.status == 200 + body = await resp.json() + assert (job := coresys.jobs.get_job(body["data"]["job_id"])) + assert job.name == "addon_manager_install" + event.set() + + +@pytest.mark.usefixtures("install_addon_ssh") +async def test_background_addon_install_fails_fast( + api_client: TestClient, coresys: CoreSys +): + """Test background addon install returns error not job if validation fails.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + + resp = await api_client.post( + "/store/addons/local_ssh/install", json={"background": True} + ) + assert resp.status == 400 + body = await resp.json() + assert body["message"] == "Add-on local_ssh is already installed" + + +@pytest.mark.parametrize( + ("make_backup", "backup_called", "update_called"), + [(True, True, False), (False, False, True)], +) +@pytest.mark.usefixtures("test_repository", "tmp_supervisor_data") +async def test_addon_update_in_background( + api_client: TestClient, + coresys: CoreSys, + install_addon_ssh: Addon, + make_backup: bool, + backup_called: bool, + update_called: bool, +): + """Test updating an addon in the background.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + install_addon_ssh.data_store["version"] = "10.0.0" + event = asyncio.Event() + mock_update_called = mock_backup_called = False + + # Mock backup/update as long-running tasks + async def mock_addon_update(*args, **kwargs): + nonlocal mock_update_called + mock_update_called = True + await event.wait() + + async def mock_partial_backup(*args, **kwargs): + nonlocal mock_backup_called + mock_backup_called = True + await event.wait() + + with ( + patch.object(Addon, "update", new=mock_addon_update), + patch.object(BackupManager, "do_backup_partial", new=mock_partial_backup), + ): + resp = await api_client.post( + "/store/addons/local_ssh/update", + json={"background": True, "backup": make_backup}, + ) + + assert mock_backup_called is backup_called + assert mock_update_called is update_called + + assert resp.status == 200 + body = await resp.json() + assert (job := coresys.jobs.get_job(body["data"]["job_id"])) + assert job.name == "addon_manager_update" + event.set() + + +@pytest.mark.usefixtures("install_addon_ssh") +async def test_background_addon_update_fails_fast( + api_client: TestClient, coresys: CoreSys +): + """Test background addon update returns error not job if validation doesn't succeed.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + + resp = await api_client.post( + "/store/addons/local_ssh/update", json={"background": True} + ) + assert resp.status == 400 + body = await resp.json() + assert body["message"] == "No update available for add-on local_ssh"