diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fa9ca956c22..24639ad4008 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, replace from enum import StrEnum import hashlib import io +from itertools import chain import json from pathlib import Path, PurePath import shutil @@ -827,7 +828,7 @@ class BackupManager: password=None, ) await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors) + self.known_backups.add(written_backup.backup, agent_errors, []) return written_backup.backup.backup_id async def async_create_backup( @@ -951,12 +952,23 @@ class BackupManager: with_automatic_settings: bool, ) -> NewBackup: """Initiate generating a backup.""" - if not agent_ids: - raise BackupManagerError("At least one agent must be selected") - if invalid_agents := [ + unavailable_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 not ( + available_agents := [ + agent_id for agent_id in agent_ids if agent_id in self.backup_agents + ] + ): + raise BackupManagerError( + f"At least one available backup agent must be selected, got {agent_ids}" + ) + if unavailable_agents: + LOGGER.warning( + "Backup agents %s are not available, will backupp to %s", + unavailable_agents, + available_agents, + ) if include_all_addons and include_addons: raise BackupManagerError( "Cannot include all addons and specify specific addons" @@ -973,7 +985,7 @@ class BackupManager: new_backup, self._backup_task, ) = await self._reader_writer.async_create_backup( - agent_ids=agent_ids, + agent_ids=available_agents, backup_name=backup_name, extra_metadata=extra_metadata | { @@ -992,7 +1004,9 @@ class BackupManager: 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, password), + self._async_finish_backup( + available_agents, unavailable_agents, with_automatic_settings, password + ), name="backup_manager_finish_backup", ) if not raise_task_error: @@ -1009,7 +1023,11 @@ class BackupManager: return new_backup async def _async_finish_backup( - self, agent_ids: list[str], with_automatic_settings: bool, password: str | None + self, + available_agents: list[str], + unavailable_agents: list[str], + with_automatic_settings: bool, + password: str | None, ) -> None: """Finish a backup.""" if TYPE_CHECKING: @@ -1028,7 +1046,7 @@ class BackupManager: LOGGER.debug( "Generated new backup with backup_id %s, uploading to agents %s", written_backup.backup.backup_id, - agent_ids, + available_agents, ) self.async_on_backup_event( CreateBackupEvent( @@ -1041,13 +1059,15 @@ class BackupManager: try: agent_errors = await self._async_upload_backup( backup=written_backup.backup, - agent_ids=agent_ids, + agent_ids=available_agents, open_stream=written_backup.open_stream, password=password, ) finally: await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors) + self.known_backups.add( + written_backup.backup, agent_errors, unavailable_agents + ) if not agent_errors: if with_automatic_settings: # create backup was successful, update last_completed_automatic_backup @@ -1056,7 +1076,7 @@ class BackupManager: backup_success = True if with_automatic_settings: - self._update_issue_after_agent_upload(agent_errors) + self._update_issue_after_agent_upload(agent_errors, unavailable_agents) # delete old backups more numerous than copies # try this regardless of agent errors above await delete_backups_exceeding_configured_count(self) @@ -1216,10 +1236,10 @@ class BackupManager: ) def _update_issue_after_agent_upload( - self, agent_errors: dict[str, Exception] + self, agent_errors: dict[str, Exception], unavailable_agents: list[str] ) -> None: """Update issue registry after a backup is uploaded to agents.""" - if not agent_errors: + if not agent_errors and not unavailable_agents: ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") return ir.async_create_issue( @@ -1233,7 +1253,13 @@ class BackupManager: translation_key="automatic_backup_failed_upload_agents", translation_placeholders={ "failed_agents": ", ".join( - self.backup_agents[agent_id].name for agent_id in agent_errors + chain( + ( + self.backup_agents[agent_id].name + for agent_id in agent_errors + ), + unavailable_agents, + ) ) }, ) @@ -1302,11 +1328,12 @@ class KnownBackups: self, backup: AgentBackup, agent_errors: dict[str, Exception], + unavailable_agents: list[str], ) -> None: """Add a backup.""" self._backups[backup.backup_id] = KnownBackup( backup_id=backup.backup_id, - failed_agent_ids=list(agent_errors), + failed_agent_ids=list(chain(agent_errors, unavailable_agents)), ) self._manager.store.save() diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 57f11ed4708..aa7d7ebd95c 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -359,8 +359,14 @@ async def test_create_backup_when_busy( @pytest.mark.parametrize( ("parameters", "expected_error"), [ - ({"agent_ids": []}, "At least one agent must be selected"), - ({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"), + ( + {"agent_ids": []}, + "At least one available backup agent must be selected, got []", + ), + ( + {"agent_ids": ["non_existing"]}, + "At least one available backup agent must be selected, got ['non_existing']", + ), ( {"include_addons": ["ssl"], "include_all_addons": True}, "Cannot include all addons and specify specific addons", @@ -410,6 +416,8 @@ async def test_create_backup_wrong_parameters( "name", "expected_name", "expected_filename", + "expected_agent_ids", + "expected_failed_agent_ids", "temp_file_unlink_call_count", ), [ @@ -419,6 +427,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], 0, ), ( @@ -427,6 +437,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], 1, ), ( @@ -435,6 +447,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], 0, ), ( @@ -443,6 +457,8 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], 0, ), ( @@ -451,6 +467,8 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], 1, ), ( @@ -459,6 +477,19 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], + 0, + ), + ( + # Test we create a backup when at least one agent is available + [LOCAL_AGENT_ID, "test.unavailable"], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + ["test.unavailable"], 0, ), ], @@ -486,6 +517,8 @@ async def test_initiate_backup( name: str | None, expected_name: str, expected_filename: str, + expected_agent_ids: list[str], + expected_failed_agent_ids: list[str], temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" @@ -620,13 +653,13 @@ async def test_initiate_backup( "addons": [], "agents": { agent_id: {"protected": bool(password), "size": ANY} - for agent_id in agent_ids + for agent_id in expected_agent_ids }, "backup_id": backup_id, "database_included": include_database, "date": ANY, "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, - "failed_agent_ids": [], + "failed_agent_ids": expected_failed_agent_ids, "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", @@ -959,6 +992,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: @pytest.mark.parametrize( ( + "automatic_agents", "create_backup_command", "create_backup_side_effect", "agent_upload_side_effect", @@ -968,6 +1002,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: [ # No error ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, None, @@ -975,14 +1010,38 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, None, True, {}, ), + # One agent unavailable + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate", "agent_ids": ["test.remote", "test.unknown"]}, + None, + None, + True, + {}, + ), + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate_with_automatic_settings"}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_upload_agents", + "translation_placeholders": {"failed_agents": "test.unknown"}, + } + }, + ), # Error raised in async_initiate_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, Exception("Boom!"), None, @@ -990,6 +1049,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, Exception("Boom!"), None, @@ -1003,6 +1063,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ), # Error raised when awaiting the backup task ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, delayed_boom, None, @@ -1010,6 +1071,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, delayed_boom, None, @@ -1023,6 +1085,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ), # Error raised in async_upload_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, Exception("Boom!"), @@ -1030,6 +1093,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, Exception("Boom!"), @@ -1047,6 +1111,7 @@ async def test_create_backup_failure_raises_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, create_backup: AsyncMock, + automatic_agents: list[str], create_backup_command: dict[str, Any], create_backup_side_effect: Exception | None, agent_upload_side_effect: Exception | None, @@ -1077,7 +1142,7 @@ async def test_create_backup_failure_raises_issue( await ws_client.send_json_auto_id( { "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.remote"]}, + "create_backup": {"agent_ids": automatic_agents}, } ) result = await ws_client.receive_json()