From a76f82080bd7ebabb8f502f2c71e6141efa1ac17 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 19 Dec 2024 10:40:07 +0100 Subject: [PATCH] Create repair issues when automatic backup fails (#133513) * Create repair issues when automatic backup fails * Improve test coverage * Adjust issues --- homeassistant/components/backup/manager.py | 43 +++- homeassistant/components/backup/strings.json | 10 + tests/components/backup/test_manager.py | 209 +++++++++++++++++++ 3 files changed, 261 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 99373b1942a..4a0b8553f1c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -23,7 +23,11 @@ from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import instance_id, integration_platform +from homeassistant.helpers import ( + instance_id, + integration_platform, + issue_registry as ir, +) from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util @@ -691,6 +695,8 @@ class BackupManager: CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) ) self.async_on_backup_event(IdleEvent()) + if with_automatic_settings: + self._update_issue_backup_failed() raise async def _async_create_backup( @@ -750,6 +756,8 @@ class BackupManager: self.async_on_backup_event( CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) ) + if with_automatic_settings: + self._update_issue_backup_failed() else: LOGGER.debug( "Generated new backup with backup_id %s, uploading to agents %s", @@ -772,6 +780,7 @@ class BackupManager: # 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) # delete old backups more numerous than copies @@ -878,6 +887,38 @@ class BackupManager: self._backup_event_subscriptions.append(on_event) return remove_subscription + def _update_issue_backup_failed(self) -> None: + """Update issue registry when a backup fails.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "automatic_backup_failed", + is_fixable=False, + is_persistent=True, + learn_more_url="homeassistant://config/backup", + severity=ir.IssueSeverity.WARNING, + translation_key="automatic_backup_failed_create", + ) + + def _update_issue_after_agent_upload( + self, agent_errors: dict[str, Exception] + ) -> None: + """Update issue registry after a backup is uploaded to agents.""" + if not agent_errors: + ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") + return + ir.async_create_issue( + self.hass, + DOMAIN, + "automatic_backup_failed", + is_fixable=False, + is_persistent=True, + learn_more_url="homeassistant://config/backup", + severity=ir.IssueSeverity.WARNING, + translation_key="automatic_backup_failed_upload_agents", + translation_placeholders={"failed_agents": ", ".join(agent_errors)}, + ) + class KnownBackups: """Track known backups.""" diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index 6ad3416b1b9..d9de2bff861 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -1,4 +1,14 @@ { + "issues": { + "automatic_backup_failed_create": { + "title": "Automatic backup could not be created", + "description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." + }, + "automatic_backup_failed_upload_agents": { + "title": "Automatic backup could not be uploaded to agents", + "description": "The automatic backup could not be uploaded to agents {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." + } + }, "services": { "create": { "name": "Create backup", diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 5795309501d..e976ad0c099 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -34,6 +34,7 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .common import ( @@ -534,6 +535,214 @@ async def test_async_initiate_backup_with_agent_error( ] +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("create_backup_command", "issues_after_create_backup"), + [ + ( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]}, + {(DOMAIN, "automatic_backup_failed")}, + ), + ( + {"type": "backup/generate_with_automatic_settings"}, + set(), + ), + ], +) +async def test_create_backup_success_clears_issue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + create_backup_command: dict[str, Any], + issues_after_create_backup: set[tuple[str, str]], +) -> None: + """Test backup issue is cleared after backup is created.""" + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Create a backup issue + ir.async_create_issue( + hass, + DOMAIN, + "automatic_backup_failed", + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="automatic_backup_failed_create", + ) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": [LOCAL_AGENT_ID]}, + } + ) + result = await ws_client.receive_json() + assert result["success"] is True + + await ws_client.send_json_auto_id(create_backup_command) + result = await ws_client.receive_json() + assert result["success"] is True + + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert set(issue_registry.issues) == issues_after_create_backup + + +async def delayed_boom(*args, **kwargs) -> None: + """Raise an exception after a delay.""" + + async def delayed_boom() -> None: + await asyncio.sleep(0) + raise Exception("Boom!") # noqa: TRY002 + + return (NewBackup(backup_job_id="abc123"), delayed_boom()) + + +@pytest.mark.parametrize( + ( + "create_backup_command", + "create_backup_side_effect", + "agent_upload_side_effect", + "create_backup_result", + "issues_after_create_backup", + ), + [ + # No error + ( + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + None, + None, + True, + {}, + ), + ( + {"type": "backup/generate_with_automatic_settings"}, + None, + None, + True, + {}, + ), + # Error raised in async_initiate_backup + ( + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + Exception("Boom!"), + None, + False, + {}, + ), + ( + {"type": "backup/generate_with_automatic_settings"}, + Exception("Boom!"), + None, + False, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_create", + "translation_placeholders": None, + } + }, + ), + # Error raised when awaiting the backup task + ( + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + delayed_boom, + None, + True, + {}, + ), + ( + {"type": "backup/generate_with_automatic_settings"}, + delayed_boom, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_create", + "translation_placeholders": None, + } + }, + ), + # Error raised in async_upload_backup + ( + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + None, + Exception("Boom!"), + True, + {}, + ), + ( + {"type": "backup/generate_with_automatic_settings"}, + None, + Exception("Boom!"), + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_upload_agents", + "translation_placeholders": {"failed_agents": "test.remote"}, + } + }, + ), + ], +) +async def test_create_backup_failure_raises_issue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + create_backup: AsyncMock, + create_backup_command: dict[str, Any], + create_backup_side_effect: Exception | None, + agent_upload_side_effect: Exception | None, + create_backup_result: bool, + issues_after_create_backup: dict[tuple[str, str], dict[str, Any]], +) -> None: + """Test backup issue is cleared after backup is created.""" + remote_agent = BackupAgentTest("remote", backups=[]) + + 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, + ), + ) + + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + create_backup.side_effect = create_backup_side_effect + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.remote"]}, + } + ) + result = await ws_client.receive_json() + assert result["success"] is True + + with patch.object( + remote_agent, "async_upload_backup", side_effect=agent_upload_side_effect + ): + await ws_client.send_json_auto_id(create_backup_command) + result = await ws_client.receive_json() + assert result["success"] == create_backup_result + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert set(issue_registry.issues) == set(issues_after_create_backup) + for issue_id, issue_data in issues_after_create_backup.items(): + issue = issue_registry.issues[issue_id] + assert issue.translation_key == issue_data["translation_key"] + assert issue.translation_placeholders == issue_data["translation_placeholders"] + + async def test_loading_platforms( hass: HomeAssistant, caplog: pytest.LogCaptureFixture,