mirror of
https://github.com/home-assistant/core.git
synced 2025-04-26 10:17:51 +00:00
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:
parent
99219a9a73
commit
2005e14d5f
@ -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",
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
@ -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:
|
||||||
|
@ -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]
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user