Skip to content

Commit

Permalink
Create repair issues when automatic backup fails (home-assistant#133513)
Browse files Browse the repository at this point in the history
* Create repair issues when automatic backup fails

* Improve test coverage

* Adjust issues
  • Loading branch information
emontnemery authored Dec 19, 2024
1 parent cd384ca commit a76f820
Show file tree
Hide file tree
Showing 3 changed files with 261 additions and 1 deletion.
43 changes: 42 additions & 1 deletion homeassistant/components/backup/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
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

Expand Down Expand Up @@ -691,6 +695,8 @@ async def async_initiate_backup(
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(
Expand Down Expand Up @@ -750,6 +756,8 @@ async def _async_finish_backup(
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",
Expand All @@ -772,6 +780,7 @@ async def _async_finish_backup(
# 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
Expand Down Expand Up @@ -878,6 +887,38 @@ def remove_subscription() -> None:
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."""
Expand Down
10 changes: 10 additions & 0 deletions homeassistant/components/backup/strings.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
209 changes: 209 additions & 0 deletions tests/components/backup/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
)
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 (
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit a76f820

Please sign in to comment.