From a329828bdf0fd3bc3d38c86f2b91975dfad2ef67 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 2 Jan 2025 15:45:46 +0100 Subject: [PATCH] Handle backup errors more consistently (#133522) * Add backup manager and read writer errors * Clean up not needed default argument * Clean up todo comment * Trap agent bugs during upload * Always release stream * Clean up leftover * Update test for backup with automatic settings * Fix use of vol.Any * Refactor test helper * Only update successful timestamp if completed event is sent * Always delete surplus copies * Fix after rebase * Fix after rebase * Revert "Fix use of vol.Any" This reverts commit 28fd7a544899bb6ed05f771e9e608bc5b41d2b5e. * Inherit BackupReaderWriterError in IncorrectPasswordError --------- Co-authored-by: Erik Montnemery --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/backup/config.py | 6 +- homeassistant/components/backup/manager.py | 217 +++++--- homeassistant/components/backup/models.py | 6 + homeassistant/components/hassio/backup.py | 62 ++- tests/components/backup/common.py | 12 + tests/components/backup/test_manager.py | 546 +++++++++++++++++--- tests/components/backup/test_websocket.py | 166 +++++- tests/components/cloud/test_backup.py | 40 +- tests/components/hassio/test_backup.py | 295 ++++++++++- 10 files changed, 1152 insertions(+), 200 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index be9638feecb..00b226a9fee 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -21,6 +21,7 @@ from .manager import ( BackupManager, BackupPlatformProtocol, BackupReaderWriter, + BackupReaderWriterError, CoreBackupReaderWriter, CreateBackupEvent, IncorrectPasswordError, @@ -40,6 +41,7 @@ __all__ = [ "BackupAgentPlatformProtocol", "BackupPlatformProtocol", "BackupReaderWriter", + "BackupReaderWriterError", "CreateBackupEvent", "Folder", "IncorrectPasswordError", diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index cdecf55848f..d58c7365c8a 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -17,7 +17,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from .const import LOGGER -from .models import Folder +from .models import BackupManagerError, Folder if TYPE_CHECKING: from .manager import BackupManager, ManagerBackup @@ -318,9 +318,9 @@ class BackupSchedule: password=config_data.create_backup.password, with_automatic_settings=True, ) + except BackupManagerError as err: + LOGGER.error("Error creating backup: %s", err) except Exception: # noqa: BLE001 - # another more specific exception will be added - # and handled in the future LOGGER.exception("Unexpected error creating automatic backup") manager.remove_next_backup_event = async_track_point_in_time( diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index dad5b038459..33405d97883 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -46,15 +46,11 @@ from .const import ( EXCLUDE_FROM_BACKUP, LOGGER, ) -from .models import AgentBackup, Folder +from .models import AgentBackup, BackupManagerError, Folder from .store import BackupStore from .util import make_backup_dir, read_backup, validate_password -class IncorrectPasswordError(HomeAssistantError): - """Raised when the password is incorrect.""" - - @dataclass(frozen=True, kw_only=True, slots=True) class NewBackup: """New backup class.""" @@ -245,6 +241,14 @@ class BackupReaderWriter(abc.ABC): """Restore a backup.""" +class BackupReaderWriterError(HomeAssistantError): + """Backup reader/writer error.""" + + +class IncorrectPasswordError(BackupReaderWriterError): + """Raised when the password is incorrect.""" + + class BackupManager: """Define the format that backup managers can have.""" @@ -373,7 +377,9 @@ class BackupManager: ) for result in pre_backup_results: if isinstance(result, Exception): - raise result + raise BackupManagerError( + f"Error during pre-backup: {result}" + ) from result async def async_post_backup_actions(self) -> None: """Perform post backup actions.""" @@ -386,7 +392,9 @@ class BackupManager: ) for result in post_backup_results: if isinstance(result, Exception): - raise result + raise BackupManagerError( + f"Error during post-backup: {result}" + ) from result async def load_platforms(self) -> None: """Load backup platforms.""" @@ -422,11 +430,21 @@ class BackupManager: return_exceptions=True, ) for idx, result in enumerate(sync_backup_results): - if isinstance(result, Exception): + if isinstance(result, BackupReaderWriterError): + # writer errors will affect all agents + # no point in continuing + raise BackupManagerError(str(result)) from result + if isinstance(result, BackupAgentError): agent_errors[agent_ids[idx]] = result - LOGGER.exception( - "Error during backup upload - %s", result, exc_info=result - ) + continue + if isinstance(result, Exception): + # trap bugs from agents + agent_errors[agent_ids[idx]] = result + LOGGER.error("Unexpected error: %s", result, exc_info=result) + continue + if isinstance(result, BaseException): + raise result + return agent_errors async def async_get_backups( @@ -449,7 +467,7 @@ class BackupManager: agent_errors[agent_ids[idx]] = result continue if isinstance(result, BaseException): - raise result + raise result # unexpected error for agent_backup in result: if (backup_id := agent_backup.backup_id) not in backups: if known_backup := self.known_backups.get(backup_id): @@ -499,7 +517,7 @@ class BackupManager: agent_errors[agent_ids[idx]] = result continue if isinstance(result, BaseException): - raise result + raise result # unexpected error if not result: continue if backup is None: @@ -563,7 +581,7 @@ class BackupManager: agent_errors[agent_ids[idx]] = result continue if isinstance(result, BaseException): - raise result + raise result # unexpected error if not agent_errors: self.known_backups.remove(backup_id) @@ -578,7 +596,7 @@ class BackupManager: ) -> None: """Receive and store a backup file from upload.""" if self.state is not BackupManagerState.IDLE: - raise HomeAssistantError(f"Backup manager busy: {self.state}") + raise BackupManagerError(f"Backup manager busy: {self.state}") self.async_on_backup_event( ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS) ) @@ -652,6 +670,7 @@ class BackupManager: include_homeassistant=include_homeassistant, name=name, password=password, + raise_task_error=True, with_automatic_settings=with_automatic_settings, ) assert self._backup_finish_task @@ -669,11 +688,12 @@ class BackupManager: include_homeassistant: bool, name: str | None, password: str | None, + raise_task_error: bool = False, with_automatic_settings: bool = False, ) -> NewBackup: """Initiate generating a backup.""" if self.state is not BackupManagerState.IDLE: - raise HomeAssistantError(f"Backup manager busy: {self.state}") + raise BackupManagerError(f"Backup manager busy: {self.state}") if with_automatic_settings: self.config.data.last_attempted_automatic_backup = dt_util.now() @@ -692,6 +712,7 @@ class BackupManager: include_homeassistant=include_homeassistant, name=name, password=password, + raise_task_error=raise_task_error, with_automatic_settings=with_automatic_settings, ) except Exception: @@ -714,15 +735,18 @@ class BackupManager: include_homeassistant: bool, name: str | None, password: str | None, + raise_task_error: bool, with_automatic_settings: bool, ) -> NewBackup: """Initiate generating a backup.""" if not agent_ids: - raise HomeAssistantError("At least one agent must be selected") - if any(agent_id not in self.backup_agents for agent_id in agent_ids): - raise HomeAssistantError("Invalid agent selected") + raise BackupManagerError("At least one agent must be selected") + if invalid_agents := [ + agent_id for agent_id in agent_ids if agent_id not in self.backup_agents + ]: + raise BackupManagerError(f"Invalid agents selected: {invalid_agents}") if include_all_addons and include_addons: - raise HomeAssistantError( + raise BackupManagerError( "Cannot include all addons and specify specific addons" ) @@ -730,41 +754,64 @@ class BackupManager: name or f"{"Automatic" if with_automatic_settings else "Custom"} backup {HAVERSION}" ) - new_backup, self._backup_task = await self._reader_writer.async_create_backup( - agent_ids=agent_ids, - backup_name=backup_name, - extra_metadata={ - "instance_id": await instance_id.async_get(self.hass), - "with_automatic_settings": with_automatic_settings, - }, - include_addons=include_addons, - include_all_addons=include_all_addons, - include_database=include_database, - include_folders=include_folders, - include_homeassistant=include_homeassistant, - on_progress=self.async_on_backup_event, - password=password, - ) - self._backup_finish_task = self.hass.async_create_task( + + try: + ( + new_backup, + self._backup_task, + ) = await self._reader_writer.async_create_backup( + agent_ids=agent_ids, + backup_name=backup_name, + extra_metadata={ + "instance_id": await instance_id.async_get(self.hass), + "with_automatic_settings": with_automatic_settings, + }, + include_addons=include_addons, + include_all_addons=include_all_addons, + include_database=include_database, + include_folders=include_folders, + include_homeassistant=include_homeassistant, + on_progress=self.async_on_backup_event, + password=password, + ) + except BackupReaderWriterError as err: + raise BackupManagerError(str(err)) from err + + backup_finish_task = self._backup_finish_task = self.hass.async_create_task( self._async_finish_backup(agent_ids, with_automatic_settings), name="backup_manager_finish_backup", ) + if not raise_task_error: + + def log_finish_task_error(task: asyncio.Task[None]) -> None: + if task.done() and not task.cancelled() and (err := task.exception()): + if isinstance(err, BackupManagerError): + LOGGER.error("Error creating backup: %s", err) + else: + LOGGER.error("Unexpected error: %s", err, exc_info=err) + + backup_finish_task.add_done_callback(log_finish_task_error) + return new_backup async def _async_finish_backup( self, agent_ids: list[str], with_automatic_settings: bool ) -> None: + """Finish a backup.""" if TYPE_CHECKING: assert self._backup_task is not None try: written_backup = await self._backup_task - except Exception as err: # noqa: BLE001 - LOGGER.debug("Generating backup failed", exc_info=err) + except Exception as err: self.async_on_backup_event( CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) ) if with_automatic_settings: self._update_issue_backup_failed() + + if isinstance(err, BackupReaderWriterError): + raise BackupManagerError(str(err)) from err + raise # unexpected error else: LOGGER.debug( "Generated new backup with backup_id %s, uploading to agents %s", @@ -777,25 +824,47 @@ class BackupManager: state=CreateBackupState.IN_PROGRESS, ) ) - agent_errors = await self._async_upload_backup( - backup=written_backup.backup, - agent_ids=agent_ids, - open_stream=written_backup.open_stream, - ) - await written_backup.release_stream() - if with_automatic_settings: - # create backup was successful, update last_completed_automatic_backup - self.config.data.last_completed_automatic_backup = dt_util.now() - self.store.save() - self._update_issue_after_agent_upload(agent_errors) - self.known_backups.add(written_backup.backup, agent_errors) + try: + agent_errors = await self._async_upload_backup( + backup=written_backup.backup, + agent_ids=agent_ids, + open_stream=written_backup.open_stream, + ) + except BaseException: + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + ) + raise # manager or unexpected error + finally: + try: + await written_backup.release_stream() + except Exception: + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + ) + raise + self.known_backups.add(written_backup.backup, agent_errors) + if agent_errors: + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + ) + else: + if with_automatic_settings: + # create backup was successful, update last_completed_automatic_backup + self.config.data.last_completed_automatic_backup = dt_util.now() + self.store.save() + + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.COMPLETED) + ) + + if with_automatic_settings: + self._update_issue_after_agent_upload(agent_errors) # delete old backups more numerous than copies + # try this regardless of agent errors above await delete_backups_exceeding_configured_count(self) - self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.COMPLETED) - ) finally: self._backup_task = None self._backup_finish_task = None @@ -814,7 +883,7 @@ class BackupManager: ) -> None: """Initiate restoring a backup.""" if self.state is not BackupManagerState.IDLE: - raise HomeAssistantError(f"Backup manager busy: {self.state}") + raise BackupManagerError(f"Backup manager busy: {self.state}") self.async_on_backup_event( RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS) @@ -854,7 +923,7 @@ class BackupManager: """Initiate restoring a backup.""" agent = self.backup_agents[agent_id] if not await agent.async_get_backup(backup_id): - raise HomeAssistantError( + raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) @@ -1027,11 +1096,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): backup_id = _generate_backup_id(date_str, backup_name) if include_addons or include_all_addons or include_folders: - raise HomeAssistantError( + raise BackupReaderWriterError( "Addons and folders are not supported by core backup" ) if not include_homeassistant: - raise HomeAssistantError("Home Assistant must be included in backup") + raise BackupReaderWriterError("Home Assistant must be included in backup") backup_task = self._hass.async_create_task( self._async_create_backup( @@ -1102,6 +1171,13 @@ class CoreBackupReaderWriter(BackupReaderWriter): password, local_agent_tar_file_path, ) + except (BackupManagerError, OSError, tarfile.TarError, ValueError) as err: + # BackupManagerError from async_pre_backup_actions + # OSError from file operations + # TarError from tarfile + # ValueError from json_bytes + raise BackupReaderWriterError(str(err)) from err + else: backup = AgentBackup( addons=[], backup_id=backup_id, @@ -1119,12 +1195,15 @@ class CoreBackupReaderWriter(BackupReaderWriter): async_add_executor_job = self._hass.async_add_executor_job async def send_backup() -> AsyncIterator[bytes]: - f = await async_add_executor_job(tar_file_path.open, "rb") try: - while chunk := await async_add_executor_job(f.read, 2**20): - yield chunk - finally: - await async_add_executor_job(f.close) + f = await async_add_executor_job(tar_file_path.open, "rb") + try: + while chunk := await async_add_executor_job(f.read, 2**20): + yield chunk + finally: + await async_add_executor_job(f.close) + except OSError as err: + raise BackupReaderWriterError(str(err)) from err async def open_backup() -> AsyncIterator[bytes]: return send_backup() @@ -1132,14 +1211,20 @@ class CoreBackupReaderWriter(BackupReaderWriter): async def remove_backup() -> None: if local_agent_tar_file_path: return - await async_add_executor_job(tar_file_path.unlink, True) + try: + await async_add_executor_job(tar_file_path.unlink, True) + except OSError as err: + raise BackupReaderWriterError(str(err)) from err return WrittenBackup( backup=backup, open_stream=open_backup, release_stream=remove_backup ) finally: # Inform integrations the backup is done - await manager.async_post_backup_actions() + try: + await manager.async_post_backup_actions() + except BackupManagerError as err: + raise BackupReaderWriterError(str(err)) from err def _mkdir_and_generate_backup_contents( self, @@ -1252,11 +1337,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): """ if restore_addons or restore_folders: - raise HomeAssistantError( + raise BackupReaderWriterError( "Addons and folders are not supported in core restore" ) if not restore_homeassistant and not restore_database: - raise HomeAssistantError( + raise BackupReaderWriterError( "Home Assistant or database must be included in restore" ) diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index a937933f04c..81c00d699c6 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -6,6 +6,8 @@ from dataclasses import asdict, dataclass from enum import StrEnum from typing import Any, Self +from homeassistant.exceptions import HomeAssistantError + @dataclass(frozen=True, kw_only=True) class AddonInfo: @@ -67,3 +69,7 @@ class AgentBackup: protected=data["protected"], size=data["size"], ) + + +class BackupManagerError(HomeAssistantError): + """Backup manager error.""" diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 9626a6b2724..3714c937303 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -10,6 +10,7 @@ from typing import Any, cast from aiohasupervisor.exceptions import ( SupervisorBadRequestError, + SupervisorError, SupervisorNotFoundError, ) from aiohasupervisor.models import ( @@ -23,6 +24,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupReaderWriter, + BackupReaderWriterError, CreateBackupEvent, Folder, IncorrectPasswordError, @@ -234,20 +236,23 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ] locations = [agent.location for agent in hassio_agents] - backup = await self._client.backups.partial_backup( - supervisor_backups.PartialBackupOptions( - addons=include_addons_set, - folders=include_folders_set, - homeassistant=include_homeassistant, - name=backup_name, - password=password, - compressed=True, - location=locations or LOCATION_CLOUD_BACKUP, - homeassistant_exclude_database=not include_database, - background=True, - extra=extra_metadata, + try: + backup = await self._client.backups.partial_backup( + supervisor_backups.PartialBackupOptions( + addons=include_addons_set, + folders=include_folders_set, + homeassistant=include_homeassistant, + name=backup_name, + password=password, + compressed=True, + location=locations or LOCATION_CLOUD_BACKUP, + homeassistant_exclude_database=not include_database, + background=True, + extra=extra_metadata, + ) ) - ) + except SupervisorError as err: + raise BackupReaderWriterError(f"Error creating backup: {err}") from err backup_task = self._hass.async_create_task( self._async_wait_for_backup( backup, remove_after_upload=not bool(locations) @@ -279,22 +284,35 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): finally: unsub() if not backup_id: - raise HomeAssistantError("Backup failed") + raise BackupReaderWriterError("Backup failed") async def open_backup() -> AsyncIterator[bytes]: - return await self._client.backups.download_backup(backup_id) + try: + return await self._client.backups.download_backup(backup_id) + except SupervisorError as err: + raise BackupReaderWriterError( + f"Error downloading backup: {err}" + ) from err async def remove_backup() -> None: if not remove_after_upload: return - await self._client.backups.remove_backup( - backup_id, - options=supervisor_backups.RemoveBackupOptions( - location={LOCATION_CLOUD_BACKUP} - ), - ) + try: + await self._client.backups.remove_backup( + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_CLOUD_BACKUP} + ), + ) + except SupervisorError as err: + raise BackupReaderWriterError(f"Error removing backup: {err}") from err - details = await self._client.backups.backup_info(backup_id) + try: + details = await self._client.backups.backup_info(backup_id) + except SupervisorError as err: + raise BackupReaderWriterError( + f"Error getting backup details: {err}" + ) from err return WrittenBackup( backup=_backup_details_to_agent_backup(details), diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index ffecd1c4186..4f456cc6d72 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -166,3 +166,15 @@ async def setup_backup_integration( agent._loaded_backups = True return result + + +async def setup_backup_platform( + hass: HomeAssistant, + *, + domain: str, + platform: Any, +) -> None: + """Set up a mock domain.""" + mock_platform(hass, f"{domain}.backup", platform) + assert await async_setup_component(hass, domain, {}) + await hass.async_block_till_done() diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 7ddd4ec97e1..0797eef2274 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Generator +from dataclasses import replace from io import StringIO import json from pathlib import Path @@ -17,13 +18,15 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgentPlatformProtocol, BackupManager, - BackupPlatformProtocol, + BackupReaderWriterError, Folder, LocalBackupAgent, backup as local_backup_platform, ) +from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import ( + BackupManagerError, BackupManagerState, CoreBackupReaderWriter, CreateBackupEvent, @@ -42,9 +45,9 @@ from .common import ( TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, BackupAgentTest, + setup_backup_platform, ) -from tests.common import MockPlatform, mock_platform from tests.typing import ClientSessionGenerator, WebSocketGenerator _EXPECTED_FILES = [ @@ -61,18 +64,6 @@ _EXPECTED_FILES_WITH_DATABASE = { } -async def _setup_backup_platform( - hass: HomeAssistant, - *, - domain: str = "some_domain", - platform: BackupPlatformProtocol | BackupAgentPlatformProtocol | None = None, -) -> None: - """Set up a mock domain.""" - mock_platform(hass, f"{domain}.backup", platform or MockPlatform()) - assert await async_setup_component(hass, domain, {}) - await hass.async_block_till_done() - - @pytest.fixture(autouse=True) def mock_delay_save() -> Generator[None]: """Mock the delay save constant.""" @@ -159,12 +150,15 @@ async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None: ("parameters", "expected_error"), [ ({"agent_ids": []}, "At least one agent must be selected"), - ({"agent_ids": ["non_existing"]}, "Invalid agent selected"), + ({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"), ( {"include_addons": ["ssl"], "include_all_addons": True}, "Cannot include all addons and specify specific addons", ), - ({"include_homeassistant": False}, "Home Assistant must be included in backup"), + ( + {"include_homeassistant": False}, + "Home Assistant must be included in backup", + ), ], ) async def test_create_backup_wrong_parameters( @@ -242,7 +236,7 @@ async def test_async_initiate_backup( core_get_backup_agents.return_value = [local_agent] await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -393,19 +387,96 @@ async def test_async_initiate_backup( @pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")]) async def test_async_initiate_backup_with_agent_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mocked_json_bytes: Mock, - mocked_tarfile: Mock, generate_backup_id: MagicMock, path_glob: MagicMock, hass_storage: dict[str, Any], + exception: Exception, ) -> None: - """Test generate backup.""" + """Test agent upload error during backup generation.""" agent_ids = [LOCAL_AGENT_ID, "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id + backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id + backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id + backups_info: list[dict[str, Any]] = [ + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup1", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + { + "addons": [], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup2", + "database_included": False, + "date": "1980-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test 2", + "protected": False, + "size": 1, + "with_automatic_settings": None, + }, + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup3", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + ] + remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -413,7 +484,7 @@ async def test_async_initiate_backup_with_agent_error( core_get_backup_agents.return_value = [local_agent] await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -431,12 +502,18 @@ async def test_async_initiate_backup_with_agent_error( assert result["success"] is True assert result["result"] == { - "backups": [], + "backups": backups_info, "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, } + await ws_client.send_json_auto_id( + {"type": "backup/config/update", "retention": {"copies": 1, "days": None}} + ) + result = await ws_client.receive_json() + assert result["success"] + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) result = await ws_client.receive_json() @@ -445,11 +522,16 @@ async def test_async_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["success"] is True + delete_backup = AsyncMock() + with ( patch("pathlib.Path.open", mock_open(read_data=b"test")), patch.object( - remote_agent, "async_upload_backup", side_effect=Exception("Test exception") + remote_agent, + "async_upload_backup", + side_effect=exception, ), + patch.object(remote_agent, "async_delete_backup", delete_backup), ): await ws_client.send_json_auto_id( {"type": "backup/generate", "agent_ids": agent_ids} @@ -486,13 +568,13 @@ async def test_async_initiate_backup_with_agent_error( assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, "stage": None, - "state": CreateBackupState.COMPLETED, + "state": CreateBackupState.FAILED, } result = await ws_client.receive_json() assert result["event"] == {"manager_state": BackupManagerState.IDLE} - expected_backup_data = { + new_expected_backup_data = { "addons": [], "agent_ids": ["backup.local"], "backup_id": "abc123", @@ -508,20 +590,14 @@ async def test_async_initiate_backup_with_agent_error( "with_automatic_settings": False, } - await ws_client.send_json_auto_id( - {"type": "backup/details", "backup_id": backup_id} - ) - result = await ws_client.receive_json() - assert result["result"] == { - "agent_errors": {}, - "backup": expected_backup_data, - } - await ws_client.send_json_auto_id({"type": "backup/info"}) result = await ws_client.receive_json() + backups_response = result["result"].pop("backups") + + assert len(backups_response) == 4 + assert new_expected_backup_data in backups_response assert result["result"] == { "agent_errors": {}, - "backups": [expected_backup_data], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, } @@ -534,6 +610,9 @@ async def test_async_initiate_backup_with_agent_error( } ] + # one of the two matching backups with the remote agent should have been deleted + assert delete_backup.call_count == 1 + @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( @@ -702,7 +781,7 @@ async def test_create_backup_failure_raises_issue( await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -743,6 +822,337 @@ async def test_create_backup_failure_raises_issue( assert issue.translation_placeholders == issue_data["translation_placeholders"] +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", [BackupReaderWriterError("Boom!"), BaseException("Boom!")] +) +async def test_async_initiate_backup_non_agent_upload_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test an unknown or writer upload error during backup generation.""" + hass_storage[DOMAIN] = { + "data": {}, + "key": DOMAIN, + "version": 1, + } + agent_ids = [LOCAL_AGENT_ID, "test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.open", mock_open(read_data=b"test")), + patch.object( + remote_agent, + "async_upload_backup", + side_effect=exception, + ), + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert not hass_storage[DOMAIN]["data"] + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", [BackupReaderWriterError("Boom!"), Exception("Boom!")] +) +async def test_async_initiate_backup_with_task_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + create_backup: AsyncMock, + exception: Exception, +) -> None: + """Test backup task error during backup generation.""" + backup_task: asyncio.Future[Any] = asyncio.Future() + backup_task.set_exception(exception) + create_backup.return_value = (NewBackup(backup_job_id="abc123"), backup_task) + agent_ids = [LOCAL_AGENT_ID, "test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "read_call_count", + "read_exception", + "close_call_count", + "close_exception", + "unlink_call_count", + "unlink_exception", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None, 1, None), + (1, None, 1, OSError("Boom!"), 1, None, 1, None), + (1, None, 1, None, 1, OSError("Boom!"), 1, None), + (1, None, 1, None, 1, None, 1, OSError("Boom!")), + ], +) +async def test_initiate_backup_file_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + open_call_count: int, + open_exception: Exception | None, + read_call_count: int, + read_exception: Exception | None, + close_call_count: int, + close_exception: Exception | None, + unlink_call_count: int, + unlink_exception: Exception | None, +) -> None: + """Test file error during generate backup.""" + agent_ids = ["test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + open_mock = mock_open(read_data=b"test") + open_mock.side_effect = open_exception + open_mock.return_value.read.side_effect = read_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch("pathlib.Path.unlink", side_effect=unlink_exception) as unlink_mock, + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert open_mock.call_count == open_call_count + assert open_mock.return_value.read.call_count == read_call_count + assert open_mock.return_value.close.call_count == close_call_count + assert unlink_mock.call_count == unlink_call_count + + async def test_loading_platforms( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -754,8 +1164,9 @@ async def test_loading_platforms( get_agents_mock = AsyncMock(return_value=[]) - await _setup_backup_platform( + await setup_backup_platform( hass, + domain="test", platform=Mock( async_pre_backup=AsyncMock(), async_post_backup=AsyncMock(), @@ -776,7 +1187,7 @@ class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): def get_backup_path(self, backup_id: str) -> Path: """Return the local path to a backup.""" - return "test.tar" + return Path("test.tar") @pytest.mark.parametrize( @@ -797,7 +1208,7 @@ async def test_loading_platform_with_listener( get_agents_mock = AsyncMock(return_value=[agent_class("remote1", backups=[])]) register_listener_mock = Mock() - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -846,7 +1257,7 @@ async def test_not_loading_bad_platforms( platform_mock: Mock, ) -> None: """Test not loading bad backup platforms.""" - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=platform_mock, @@ -857,16 +1268,14 @@ async def test_not_loading_bad_platforms( assert platform_mock.mock_calls == [] -async def test_exception_platform_pre( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +async def test_exception_platform_pre(hass: HomeAssistant) -> None: """Test exception in pre step.""" async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") remote_agent = BackupAgentTest("remote", backups=[]) - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -878,28 +1287,25 @@ async def test_exception_platform_pre( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - await hass.services.async_call( - DOMAIN, - "create", - blocking=True, - ) + with pytest.raises(BackupManagerError) as err: + await hass.services.async_call( + DOMAIN, + "create", + blocking=True, + ) - assert "Generating backup failed" in caplog.text - assert "Test exception" in caplog.text + assert str(err.value) == "Error during pre-backup: Test exception" @pytest.mark.usefixtures("mock_backup_generation") -async def test_exception_platform_post( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_exception_platform_post(hass: HomeAssistant) -> None: """Test exception in post step.""" async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") remote_agent = BackupAgentTest("remote", backups=[]) - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -911,14 +1317,14 @@ async def test_exception_platform_post( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - await hass.services.async_call( - DOMAIN, - "create", - blocking=True, - ) + with pytest.raises(BackupManagerError) as err: + await hass.services.async_call( + DOMAIN, + "create", + blocking=True, + ) - assert "Generating backup failed" in caplog.text - assert "Test exception" in caplog.text + assert str(err.value) == "Error during post-backup: Test exception" @pytest.mark.parametrize( @@ -974,7 +1380,7 @@ async def test_receive_backup( ) -> None: """Test receive backup and upload to the local and a remote agent.""" remote_agent = BackupAgentTest("remote", backups=[]) - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -1098,8 +1504,8 @@ async def test_async_trigger_restore( manager = BackupManager(hass, CoreBackupReaderWriter(hass)) hass.data[DATA_MANAGER] = manager - await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await _setup_backup_platform( + await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -1156,8 +1562,8 @@ async def test_async_trigger_restore_wrong_password(hass: HomeAssistant) -> None manager = BackupManager(hass, CoreBackupReaderWriter(hass)) hass.data[DATA_MANAGER] = manager - await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await _setup_backup_platform( + await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -1228,7 +1634,7 @@ async def test_async_trigger_restore_wrong_parameters( """Test trigger restore.""" manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) + await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) await manager.load_platforms() local_agent = manager.backup_agents[LOCAL_AGENT_ID] diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index b407241be54..a3b29a55ad8 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -2,13 +2,19 @@ from collections.abc import Generator from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup import AgentBackup, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgentError, + BackupAgentPlatformProtocol, + BackupReaderWriterError, + Folder, +) from homeassistant.components.backup.agent import BackupAgentUnreachableError from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.components.backup.manager import ( @@ -19,6 +25,7 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, @@ -26,6 +33,7 @@ from .common import ( TEST_BACKUP_DEF456, BackupAgentTest, setup_backup_integration, + setup_backup_platform, ) from tests.common import async_fire_time_changed, async_mock_service @@ -472,27 +480,45 @@ async def test_generate_calls_create( ) -@pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("create_backup_settings", "expected_call_params"), + ( + "create_backup_settings", + "expected_call_params", + "side_effect", + "last_completed_automatic_backup", + ), [ ( - {}, { - "agent_ids": [], + "agent_ids": ["test.remote"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + { + "agent_ids": ["test.remote"], + "backup_name": ANY, + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, "include_addons": None, "include_all_addons": False, "include_database": True, "include_folders": None, "include_homeassistant": True, - "name": None, + "on_progress": ANY, "password": None, - "with_automatic_settings": True, }, + None, + "2024-11-13T12:01:01+01:00", ), ( { - "agent_ids": ["test-agent"], + "agent_ids": ["test.remote"], "include_addons": ["test-addon"], "include_all_addons": False, "include_database": True, @@ -501,32 +527,78 @@ async def test_generate_calls_create( "password": "test-password", }, { - "agent_ids": ["test-agent"], + "agent_ids": ["test.remote"], + "backup_name": "test-name", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, "include_addons": ["test-addon"], "include_all_addons": False, "include_database": True, - "include_folders": ["media"], + "include_folders": [Folder.MEDIA], "include_homeassistant": True, - "name": "test-name", + "on_progress": ANY, "password": "test-password", - "with_automatic_settings": True, }, + None, + "2024-11-13T12:01:01+01:00", + ), + ( + { + "agent_ids": ["test.remote"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + { + "agent_ids": ["test.remote"], + "backup_name": ANY, + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + BackupAgentError("Boom!"), + None, ), ], ) async def test_generate_with_default_settings_calls_create( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], freezer: FrozenDateTimeFactory, - snapshot: SnapshotAssertion, + create_backup: AsyncMock, create_backup_settings: dict[str, Any], expected_call_params: dict[str, Any], + side_effect: Exception | None, + last_completed_automatic_backup: str, ) -> None: """Test backup/generate_with_automatic_settings calls async_initiate_backup.""" - await setup_backup_integration(hass, with_hassio=False) - client = await hass_ws_client(hass) - freezer.move_to("2024-11-13 12:01:00+01:00") + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + remote_agent = BackupAgentTest("remote", backups=[]) + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await client.send_json_auto_id( @@ -535,17 +607,47 @@ async def test_generate_with_default_settings_calls_create( result = await client.receive_json() assert result["success"] - with patch( - "homeassistant.components.backup.manager.BackupManager.async_initiate_backup", - return_value=NewBackup(backup_job_id="abc123"), - ) as generate_backup: + freezer.tick() + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass_storage[DOMAIN]["data"]["config"]["create_backup"] + == create_backup_settings + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"] + is None + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"] + is None + ) + + with patch.object(remote_agent, "async_upload_backup", side_effect=side_effect): await client.send_json_auto_id( {"type": "backup/generate_with_automatic_settings"} ) result = await client.receive_json() assert result["success"] assert result["result"] == {"backup_job_id": "abc123"} - generate_backup.assert_called_once_with(**expected_call_params) + + await hass.async_block_till_done() + + create_backup.assert_called_once_with(**expected_call_params) + + freezer.tick() + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"] + == "2024-11-13T12:01:01+01:00" + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"] + == last_completed_automatic_backup + ) @pytest.mark.parametrize( @@ -1193,7 +1295,23 @@ async def test_config_update_errors( 1, 2, BACKUP_CALL, - [Exception("Boom"), None], + [BackupReaderWriterError("Boom"), None], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "daily", + }, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-13T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-11T04:45:00+01:00", + 1, + 2, + BACKUP_CALL, + [Exception("Boom"), None], # unknown error ), ], ) @@ -2272,7 +2390,7 @@ async def test_subscribe_event( hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, ) -> None: - """Test generating a backup.""" + """Test subscribe event.""" await setup_backup_integration(hass, with_hassio=False) manager = hass.data[DATA_MANAGER] diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 86b25d61d88..5d9513a1d1b 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -35,7 +35,10 @@ async def setup_integration( cloud_logged_in: None, ) -> AsyncGenerator[None]: """Set up cloud integration.""" - with patch("homeassistant.components.backup.is_hassio", return_value=False): + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -345,7 +348,7 @@ async def test_agents_upload( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0.0, + size=0, ) aioclient_mock.put(mock_get_upload_details.return_value["url"]) @@ -382,7 +385,7 @@ async def test_agents_upload( async def test_agents_upload_fail_put( hass: HomeAssistant, hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], aioclient_mock: AiohttpClientMocker, mock_get_upload_details: Mock, put_mock_kwargs: dict[str, Any], @@ -401,7 +404,7 @@ async def test_agents_upload_fail_put( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0.0, + size=0, ) aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs) @@ -421,9 +424,14 @@ async def test_agents_upload_fail_put( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO("test")}, ) + await hass.async_block_till_done() assert resp.status == 201 - assert "Error during backup upload - Failed to upload backup" in caplog.text + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] @pytest.mark.parametrize("side_effect", [ClientError, CloudError]) @@ -431,9 +439,9 @@ async def test_agents_upload_fail_put( async def test_agents_upload_fail_cloud( hass: HomeAssistant, hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], mock_get_upload_details: Mock, side_effect: Exception, - caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup, when cloud user is logged in.""" client = await hass_client() @@ -450,7 +458,7 @@ async def test_agents_upload_fail_cloud( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0.0, + size=0, ) with ( patch( @@ -468,15 +476,20 @@ async def test_agents_upload_fail_cloud( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO("test")}, ) + await hass.async_block_till_done() assert resp.status == 201 - assert "Error during backup upload - Failed to get upload details" in caplog.text + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] async def test_agents_upload_not_protected( hass: HomeAssistant, hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: """Test agent upload backup, when cloud user is logged in.""" client = await hass_client() @@ -492,7 +505,7 @@ async def test_agents_upload_not_protected( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0.0, + size=0, ) with ( patch("pathlib.Path.open"), @@ -505,9 +518,14 @@ async def test_agents_upload_not_protected( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO("test")}, ) + await hass.async_block_till_done() assert resp.status == 201 - assert "Error during backup upload - Cloud backups must be protected" in caplog.text + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 450c9ddded9..5657193fc49 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -16,6 +16,7 @@ from unittest.mock import ANY, AsyncMock, Mock, patch from aiohasupervisor.exceptions import ( SupervisorBadRequestError, + SupervisorError, SupervisorNotFoundError, ) from aiohasupervisor.models import ( @@ -46,7 +47,7 @@ TEST_BACKUP = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), @@ -71,7 +72,7 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( compressed=TEST_BACKUP.compressed, date=TEST_BACKUP.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", location=TEST_BACKUP.location, @@ -197,7 +198,7 @@ async def hassio_enabled( @pytest.fixture async def setup_integration( hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock -) -> AsyncGenerator[None]: +) -> None: """Set up Backup integration.""" assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() @@ -451,7 +452,7 @@ async def test_agent_upload( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0.0, + size=0, ) supervisor_client.backups.reload.assert_not_called() @@ -732,6 +733,292 @@ async def test_reader_writer_create( supervisor_client.backups.download_backup.assert_not_called() supervisor_client.backups.remove_backup.assert_not_called() + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("side_effect", "error_code", "error_message"), + [ + ( + SupervisorError("Boom!"), + "home_assistant_error", + "Error creating backup: Boom!", + ), + (Exception("Boom!"), "unknown_error", "Unknown error"), + ], +) +async def test_reader_writer_create_partial_backup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + side_effect: Exception, + error_code: str, + error_message: str, +) -> None: + """Test client partial backup error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.side_effect = side_effect + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == error_code + assert response["error"]["message"] == error_message + + assert supervisor_client.backups.partial_backup.call_count == 1 + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_missing_reference_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test missing reference error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + assert supervisor_client.backups.partial_backup.call_count == 1 + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + await hass.async_block_till_done() + + assert supervisor_client.backups.backup_info.call_count == 0 + assert supervisor_client.backups.download_backup.call_count == 0 + assert supervisor_client.backups.remove_backup.call_count == 0 + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) +@pytest.mark.parametrize( + ("method", "download_call_count", "remove_call_count"), + [("download_backup", 1, 1), ("remove_backup", 1, 1)], +) +async def test_reader_writer_create_download_remove_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + exception: Exception, + method: str, + download_call_count: int, + remove_call_count: int, +) -> None: + """Test download and remove error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + method_mock = getattr(supervisor_client.backups, method) + method_mock.side_effect = exception + + remote_agent = BackupAgentTest("remote") + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + assert supervisor_client.backups.partial_backup.call_count == 1 + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + await hass.async_block_till_done() + + assert supervisor_client.backups.backup_info.call_count == 1 + assert supervisor_client.backups.download_backup.call_count == download_call_count + assert supervisor_client.backups.remove_backup.call_count == remove_call_count + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) +async def test_reader_writer_create_info_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + exception: Exception, +) -> None: + """Test backup info error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.side_effect = exception + + remote_agent = BackupAgentTest("remote") + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + assert supervisor_client.backups.partial_backup.call_count == 1 + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + await hass.async_block_till_done() + + assert supervisor_client.backups.backup_info.call_count == 1 + assert supervisor_client.backups.download_backup.call_count == 0 + assert supervisor_client.backups.remove_backup.call_count == 0 + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_reader_writer_create_remote_backup(