From c5757b57c3f0402b8fa240cae2005c9c36d09c54 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Nov 2024 18:48:21 +0100 Subject: [PATCH] Include backup agent error in response to WS command backup/info (#130884) --- homeassistant/components/backup/agent.py | 11 ++++++ homeassistant/components/backup/manager.py | 36 ++++++++++++++---- homeassistant/components/backup/websocket.py | 5 ++- .../backup/snapshots/test_websocket.ambr | 38 +++++++++++++++++++ tests/components/backup/test_manager.py | 6 ++- tests/components/backup/test_websocket.py | 22 +++++++++++ 6 files changed, 107 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index a1efc4e8ef4..75cce61f19c 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -8,10 +8,21 @@ from pathlib import Path from typing import Any, Protocol from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .models import BackupUploadMetadata, BaseBackup +class BackupAgentError(HomeAssistantError): + """Base class for backup agent errors.""" + + +class BackupAgentUnreachableError(BackupAgentError): + """Raised when the agent can't reach it's API.""" + + _message = "The backup agent is unreachable." + + @dataclass(slots=True) class UploadedBackup(BaseBackup): """Uploaded backup class.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 414df34bee9..aca9f711d01 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -29,7 +29,12 @@ from homeassistant.helpers import integration_platform from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util -from .agent import BackupAgent, BackupAgentPlatformProtocol, LocalBackupAgent +from .agent import ( + BackupAgent, + BackupAgentError, + BackupAgentPlatformProtocol, + LocalBackupAgent, +) from .const import ( BUF_SIZE, DOMAIN, @@ -207,7 +212,9 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]): """ @abc.abstractmethod - async def async_get_backups(self, **kwargs: Any) -> dict[str, _BackupT]: + async def async_get_backups( + self, **kwargs: Any + ) -> tuple[dict[str, Backup], dict[str, Exception]]: """Get backups. Return a dictionary of Backup instances keyed by their slug. @@ -275,12 +282,25 @@ class BackupManager(BaseBackupManager[Backup]): finally: self.syncing = False - async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]: + async def async_get_backups( + self, **kwargs: Any + ) -> tuple[dict[str, Backup], dict[str, Exception]]: """Return backups.""" backups: dict[str, Backup] = {} - for agent_id, agent in self.backup_agents.items(): - agent_backups = await agent.async_list_backups() - for agent_backup in agent_backups: + agent_errors: dict[str, Exception] = {} + agent_ids = list(self.backup_agents.keys()) + + list_backups_results = await asyncio.gather( + *(agent.async_list_backups() for agent in self.backup_agents.values()), + return_exceptions=True, + ) + for idx, result in enumerate(list_backups_results): + if isinstance(result, BackupAgentError): + agent_errors[agent_ids[idx]] = result + continue + if isinstance(result, BaseException): + raise result + for agent_backup in result: if agent_backup.slug not in backups: backups[agent_backup.slug] = Backup( slug=agent_backup.slug, @@ -290,9 +310,9 @@ class BackupManager(BaseBackupManager[Backup]): size=agent_backup.size, protected=agent_backup.protected, ) - backups[agent_backup.slug].agent_ids.append(agent_id) + backups[agent_backup.slug].agent_ids.append(agent_ids[idx]) - return backups + return (backups, agent_errors) async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None: """Return a backup.""" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index be13cc78ac7..8aa5df9441f 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -40,10 +40,13 @@ async def handle_info( ) -> None: """List all stored backups.""" manager = hass.data[DATA_MANAGER] - backups = await manager.async_get_backups() + backups, agent_errors = await manager.async_get_backups() connection.send_result( msg["id"], { + "agent_errors": { + agent_id: str(err) for agent_id, err in agent_errors.items() + }, "backups": [b.as_dict() for b in backups.values()], "backing_up": manager.backup_task is not None, }, diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 5b5c32c83f1..9b285f4c1df 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -439,6 +439,8 @@ dict({ 'id': 1, 'result': dict({ + 'agent_errors': dict({ + }), 'backing_up': False, 'backups': list([ dict({ @@ -457,6 +459,42 @@ 'type': 'result', }) # --- +# name: test_info_with_errors[BackupAgentUnreachableError] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'The backup agent is unreachable.', + }), + 'backing_up': False, + 'backups': list([ + dict({ + 'agent_ids': list([ + 'backup.local', + ]), + 'date': '1970-01-01T00:00:00.000Z', + 'name': 'Test', + 'protected': False, + 'size': 0.0, + 'slug': 'abc123', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info_with_errors[side_effect0] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Boom!', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_remove[with_hassio] dict({ 'error': dict({ diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 1d6028840f0..f60bf588e02 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -143,8 +143,9 @@ async def test_load_backups(hass: HomeAssistant) -> None: ), ): await manager.backup_agents[LOCAL_AGENT_ID].load_backups() - backups = await manager.async_get_backups() + backups, agent_errors = await manager.async_get_backups() assert backups == {TEST_BACKUP.slug: TEST_BACKUP} + assert agent_errors == {} async def test_load_backups_with_exception( @@ -162,9 +163,10 @@ async def test_load_backups_with_exception( patch("tarfile.open", side_effect=OSError("Test exception")), ): await manager.backup_agents[LOCAL_AGENT_ID].load_backups() - backups = await manager.async_get_backups() + backups, agent_errors = await manager.async_get_backups() assert f"Unable to read backup {TEST_BACKUP_PATH}: Test exception" in caplog.text assert backups == {} + assert agent_errors == {} async def test_removing_backup( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index dbf191e1afd..05930c6697f 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -9,6 +9,7 @@ import pytest from syrupy import SnapshotAssertion from homeassistant.components.backup import BaseBackup +from homeassistant.components.backup.agent import BackupAgentUnreachableError from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import NewBackup from homeassistant.core import HomeAssistant @@ -56,6 +57,27 @@ async def test_info( assert await client.receive_json() == snapshot +@pytest.mark.parametrize( + "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] +) +async def test_info_with_errors( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + side_effect: Exception, + snapshot: SnapshotAssertion, +) -> None: + """Test getting backup info with one unavailable agent.""" + await setup_backup_integration(hass, with_hassio=False, backups=[TEST_LOCAL_BACKUP]) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch.object(BackupAgentTest, "async_list_backups", side_effect=side_effect): + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + @pytest.mark.parametrize( "backup_content", [