Improve error handling when supervisor backups are deleted (#137331)

* Improve error handling when supervisor backups are deleted

* Move exception definitions
This commit is contained in:
Erik Montnemery 2025-02-04 19:49:55 +01:00 committed by Bram Kragten
parent 99219a9a73
commit 2005e14d5f
12 changed files with 195 additions and 49 deletions

View File

@ -36,7 +36,7 @@ from .manager import (
RestoreBackupState, RestoreBackupState,
WrittenBackup, WrittenBackup,
) )
from .models import AddonInfo, AgentBackup, Folder from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
from .util import suggested_filename, suggested_filename_from_name_date from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers from .websocket import async_register_websocket_handlers
@ -47,6 +47,7 @@ __all__ = [
"BackupAgentError", "BackupAgentError",
"BackupAgentPlatformProtocol", "BackupAgentPlatformProtocol",
"BackupManagerError", "BackupManagerError",
"BackupNotFound",
"BackupPlatformProtocol", "BackupPlatformProtocol",
"BackupReaderWriter", "BackupReaderWriter",
"BackupReaderWriterError", "BackupReaderWriterError",

View File

@ -11,13 +11,7 @@ from propcache.api import cached_property
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from .models import AgentBackup, BackupError from .models import AgentBackup, BackupAgentError
class BackupAgentError(BackupError):
"""Base class for backup agent errors."""
error_code = "backup_agent_error"
class BackupAgentUnreachableError(BackupAgentError): class BackupAgentUnreachableError(BackupAgentError):
@ -27,12 +21,6 @@ class BackupAgentUnreachableError(BackupAgentError):
_message = "The backup agent is unreachable." _message = "The backup agent is unreachable."
class BackupNotFound(BackupAgentError):
"""Raised when a backup is not found."""
error_code = "backup_not_found"
class BackupAgent(abc.ABC): class BackupAgent(abc.ABC):
"""Backup agent interface.""" """Backup agent interface."""

View File

@ -11,9 +11,9 @@ from typing import Any
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .agent import BackupAgent, LocalBackupAgent
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
from .models import AgentBackup from .models import AgentBackup, BackupNotFound
from .util import read_backup, suggested_filename from .util import read_backup, suggested_filename

View File

@ -21,6 +21,7 @@ from . import util
from .agent import BackupAgent from .agent import BackupAgent
from .const import DATA_MANAGER from .const import DATA_MANAGER
from .manager import BackupManager from .manager import BackupManager
from .models import BackupNotFound
@callback @callback
@ -69,6 +70,7 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
} }
try:
if not password or not backup.protected: if not password or not backup.protected:
return await self._send_backup_no_password( return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager request, headers, backup_id, agent_id, agent, manager
@ -76,6 +78,8 @@ class DownloadBackupView(HomeAssistantView):
return await self._send_backup_with_password( return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager hass, request, headers, backup_id, agent_id, password, agent, manager
) )
except BackupNotFound:
return Response(status=HTTPStatus.NOT_FOUND)
async def _send_backup_no_password( async def _send_backup_no_password(
self, self,

View File

@ -50,7 +50,14 @@ from .const import (
EXCLUDE_FROM_BACKUP, EXCLUDE_FROM_BACKUP,
LOGGER, LOGGER,
) )
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder from .models import (
AgentBackup,
BackupError,
BackupManagerError,
BackupReaderWriterError,
BaseBackup,
Folder,
)
from .store import BackupStore from .store import BackupStore
from .util import ( from .util import (
AsyncIteratorReader, AsyncIteratorReader,
@ -274,12 +281,6 @@ class BackupReaderWriter(abc.ABC):
"""Get restore events after core restart.""" """Get restore events after core restart."""
class BackupReaderWriterError(BackupError):
"""Backup reader/writer error."""
error_code = "backup_reader_writer_error"
class IncorrectPasswordError(BackupReaderWriterError): class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect.""" """Raised when the password is incorrect."""

View File

@ -77,7 +77,25 @@ class BackupError(HomeAssistantError):
error_code = "unknown" error_code = "unknown"
class BackupAgentError(BackupError):
"""Base class for backup agent errors."""
error_code = "backup_agent_error"
class BackupManagerError(BackupError): class BackupManagerError(BackupError):
"""Backup manager error.""" """Backup manager error."""
error_code = "backup_manager_error" error_code = "backup_manager_error"
class BackupReaderWriterError(BackupError):
"""Backup reader/writer error."""
error_code = "backup_reader_writer_error"
class BackupNotFound(BackupAgentError, BackupManagerError):
"""Raised when a backup is not found."""
error_code = "backup_not_found"

View File

@ -15,7 +15,7 @@ from .manager import (
IncorrectPasswordError, IncorrectPasswordError,
ManagerStateEvent, ManagerStateEvent,
) )
from .models import Folder from .models import BackupNotFound, Folder
@callback @callback
@ -151,6 +151,8 @@ async def handle_restore(
restore_folders=msg.get("restore_folders"), restore_folders=msg.get("restore_folders"),
restore_homeassistant=msg["restore_homeassistant"], restore_homeassistant=msg["restore_homeassistant"],
) )
except BackupNotFound:
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError: except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password") connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
else: else:
@ -179,6 +181,8 @@ async def handle_can_decrypt_on_download(
agent_id=msg["agent_id"], agent_id=msg["agent_id"],
password=msg.get("password"), password=msg.get("password"),
) )
except BackupNotFound:
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError: except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password") connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
except DecryptOnDowloadNotSupported: except DecryptOnDowloadNotSupported:

View File

@ -27,6 +27,7 @@ from homeassistant.components.backup import (
AgentBackup, AgentBackup,
BackupAgent, BackupAgent,
BackupManagerError, BackupManagerError,
BackupNotFound,
BackupReaderWriter, BackupReaderWriter,
BackupReaderWriterError, BackupReaderWriterError,
CreateBackupEvent, CreateBackupEvent,
@ -161,10 +162,15 @@ class SupervisorBackupAgent(BackupAgent):
**kwargs: Any, **kwargs: Any,
) -> AsyncIterator[bytes]: ) -> AsyncIterator[bytes]:
"""Download a backup file.""" """Download a backup file."""
try:
return await self._client.backups.download_backup( return await self._client.backups.download_backup(
backup_id, backup_id,
options=supervisor_backups.DownloadBackupOptions(location=self.location), options=supervisor_backups.DownloadBackupOptions(
location=self.location
),
) )
except SupervisorNotFoundError as err:
raise BackupNotFound from err
async def async_upload_backup( async def async_upload_backup(
self, self,
@ -527,6 +533,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
location=restore_location, location=restore_location,
), ),
) )
except SupervisorNotFoundError as err:
raise BackupNotFound from err
except SupervisorBadRequestError as err: except SupervisorBadRequestError as err:
# Supervisor currently does not transmit machine parsable error types # Supervisor currently does not transmit machine parsable error types
message = err.args[0] message = err.args[0]

View File

@ -229,6 +229,28 @@
'type': 'result', 'type': 'result',
}) })
# --- # ---
# name: test_can_decrypt_on_download_with_agent_error[BackupAgentError]
dict({
'error': dict({
'code': 'home_assistant_error',
'message': 'Unknown error',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_can_decrypt_on_download_with_agent_error[BackupNotFound]
dict({
'error': dict({
'code': 'backup_not_found',
'message': 'Backup not found',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_config_info[storage_data0] # name: test_config_info[storage_data0]
dict({ dict({
'id': 1, 'id': 1,

View File

@ -11,7 +11,13 @@ from unittest.mock import patch
from aiohttp import web from aiohttp import web
import pytest import pytest
from homeassistant.components.backup import AddonInfo, AgentBackup, Folder from homeassistant.components.backup import (
AddonInfo,
AgentBackup,
BackupAgentError,
BackupNotFound,
Folder,
)
from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -141,6 +147,50 @@ async def test_downloading_remote_encrypted_backup(
await _test_downloading_encrypted_backup(hass_client, "domain.test") await _test_downloading_encrypted_backup(hass_client, "domain.test")
@pytest.mark.parametrize(
("error", "status"),
[
(BackupAgentError, 500),
(BackupNotFound, 404),
],
)
@patch.object(BackupAgentTest, "async_download_backup")
async def test_downloading_remote_encrypted_backup_with_error(
download_mock,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
error: Exception,
status: int,
) -> None:
"""Test downloading a local backup file."""
await setup_backup_integration(hass)
hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest(
"test",
[
AgentBackup(
addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
backup_id="abc123",
database_included=True,
date="1970-01-01T00:00:00Z",
extra_metadata={},
folders=[Folder.MEDIA, Folder.SHARE],
homeassistant_included=True,
homeassistant_version="2024.12.0",
name="Test",
protected=True,
size=13,
)
],
)
download_mock.side_effect = error
client = await hass_client()
resp = await client.get(
"/api/backup/download/abc123?agent_id=domain.test&password=blah"
)
assert resp.status == status
async def _test_downloading_encrypted_backup( async def _test_downloading_encrypted_backup(
hass_client: ClientSessionGenerator, hass_client: ClientSessionGenerator,
agent_id: str, agent_id: str,

View File

@ -12,6 +12,7 @@ from homeassistant.components.backup import (
AgentBackup, AgentBackup,
BackupAgentError, BackupAgentError,
BackupAgentPlatformProtocol, BackupAgentPlatformProtocol,
BackupNotFound,
BackupReaderWriterError, BackupReaderWriterError,
Folder, Folder,
store, store,
@ -2967,3 +2968,39 @@ async def test_can_decrypt_on_download(
} }
) )
assert await client.receive_json() == snapshot assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
"error",
[
BackupAgentError,
BackupNotFound,
],
)
@pytest.mark.usefixtures("mock_backups")
async def test_can_decrypt_on_download_with_agent_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
error: Exception,
) -> None:
"""Test can decrypt on download."""
await setup_backup_integration(
hass,
with_hassio=False,
backups={"test.remote": [TEST_BACKUP_ABC123]},
remote_agents=["remote"],
)
client = await hass_ws_client(hass)
with patch.object(BackupAgentTest, "async_download_backup", side_effect=error):
await client.send_json_auto_id(
{
"type": "backup/can_decrypt_on_download",
"backup_id": TEST_BACKUP_ABC123.backup_id,
"agent_id": "test.remote",
"password": "hunter2",
}
)
assert await client.receive_json() == snapshot

View File

@ -584,22 +584,29 @@ async def test_agent_download(
) )
@pytest.mark.parametrize(
("backup_info", "backup_id", "agent_id"),
[
(TEST_BACKUP_DETAILS_3, "unknown", "hassio.local"),
(TEST_BACKUP_DETAILS_3, TEST_BACKUP_DETAILS_3.slug, "hassio.local"),
(TEST_BACKUP_DETAILS, TEST_BACKUP_DETAILS_3.slug, "hassio.local"),
],
)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_agent_download_unavailable_backup( async def test_agent_download_unavailable_backup(
hass: HomeAssistant, hass: HomeAssistant,
hass_client: ClientSessionGenerator, hass_client: ClientSessionGenerator,
supervisor_client: AsyncMock, supervisor_client: AsyncMock,
agent_id: str,
backup_id: str,
backup_info: supervisor_backups.BackupComplete,
) -> None: ) -> None:
"""Test agent download backup which does not exist.""" """Test agent download backup which does not exist."""
client = await hass_client() client = await hass_client()
backup_id = "abc123" supervisor_client.backups.backup_info.return_value = backup_info
supervisor_client.backups.list.return_value = [TEST_BACKUP_3] supervisor_client.backups.download_backup.side_effect = SupervisorNotFoundError
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_3
supervisor_client.backups.download_backup.return_value.__aiter__.return_value = (
iter((b"backup data",))
)
resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=hassio.local") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id={agent_id}")
assert resp.status == 404 assert resp.status == 404
@ -2026,14 +2033,22 @@ async def test_reader_writer_restore(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("supervisor_error_string", "expected_error_code", "expected_reason"), ("supervisor_error", "expected_error_code", "expected_reason"),
[ [
("Invalid password for backup", "password_incorrect", "password_incorrect"),
( (
"Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.", SupervisorBadRequestError("Invalid password for backup"),
"password_incorrect",
"password_incorrect",
),
(
SupervisorBadRequestError(
"Backup was made on supervisor version 2025.12.0, can't "
"restore on 2024.12.0. Must update supervisor first."
),
"home_assistant_error", "home_assistant_error",
"unknown_error", "unknown_error",
), ),
(SupervisorNotFoundError(), "backup_not_found", "backup_not_found"),
], ],
) )
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@ -2041,15 +2056,13 @@ async def test_reader_writer_restore_error(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock, supervisor_client: AsyncMock,
supervisor_error_string: str, supervisor_error: Exception,
expected_error_code: str, expected_error_code: str,
expected_reason: str, expected_reason: str,
) -> None: ) -> None:
"""Test restoring a backup.""" """Test restoring a backup."""
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
supervisor_client.backups.partial_restore.side_effect = SupervisorBadRequestError( supervisor_client.backups.partial_restore.side_effect = supervisor_error
supervisor_error_string
)
supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.list.return_value = [TEST_BACKUP]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS