Handle backup errors more consistently (#133522)

* Add backup manager and read writer errors

* Clean up not needed default argument

* Clean up todo comment

* Trap agent bugs during upload

* Always release stream

* Clean up leftover

* Update test for backup with automatic settings

* Fix use of vol.Any

* Refactor test helper

* Only update successful timestamp if completed event is sent

* Always delete surplus copies

* Fix after rebase

* Fix after rebase

* Revert "Fix use of vol.Any"

This reverts commit 28fd7a544899bb6ed05f771e9e608bc5b41d2b5e.

* Inherit BackupReaderWriterError in IncorrectPasswordError

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Martin Hjelmare 2025-01-02 15:45:46 +01:00 committed by GitHub
parent aa9e721e8b
commit a329828bdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1152 additions and 200 deletions

View File

@ -21,6 +21,7 @@ from .manager import (
BackupManager,
BackupPlatformProtocol,
BackupReaderWriter,
BackupReaderWriterError,
CoreBackupReaderWriter,
CreateBackupEvent,
IncorrectPasswordError,
@ -40,6 +41,7 @@ __all__ = [
"BackupAgentPlatformProtocol",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
"CreateBackupEvent",
"Folder",
"IncorrectPasswordError",

View File

@ -17,7 +17,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
from .const import LOGGER
from .models import Folder
from .models import BackupManagerError, Folder
if TYPE_CHECKING:
from .manager import BackupManager, ManagerBackup
@ -318,9 +318,9 @@ class BackupSchedule:
password=config_data.create_backup.password,
with_automatic_settings=True,
)
except BackupManagerError as err:
LOGGER.error("Error creating backup: %s", err)
except Exception: # noqa: BLE001
# another more specific exception will be added
# and handled in the future
LOGGER.exception("Unexpected error creating automatic backup")
manager.remove_next_backup_event = async_track_point_in_time(

View File

@ -46,15 +46,11 @@ from .const import (
EXCLUDE_FROM_BACKUP,
LOGGER,
)
from .models import AgentBackup, Folder
from .models import AgentBackup, BackupManagerError, Folder
from .store import BackupStore
from .util import make_backup_dir, read_backup, validate_password
class IncorrectPasswordError(HomeAssistantError):
"""Raised when the password is incorrect."""
@dataclass(frozen=True, kw_only=True, slots=True)
class NewBackup:
"""New backup class."""
@ -245,6 +241,14 @@ class BackupReaderWriter(abc.ABC):
"""Restore a backup."""
class BackupReaderWriterError(HomeAssistantError):
"""Backup reader/writer error."""
class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect."""
class BackupManager:
"""Define the format that backup managers can have."""
@ -373,7 +377,9 @@ class BackupManager:
)
for result in pre_backup_results:
if isinstance(result, Exception):
raise result
raise BackupManagerError(
f"Error during pre-backup: {result}"
) from result
async def async_post_backup_actions(self) -> None:
"""Perform post backup actions."""
@ -386,7 +392,9 @@ class BackupManager:
)
for result in post_backup_results:
if isinstance(result, Exception):
raise result
raise BackupManagerError(
f"Error during post-backup: {result}"
) from result
async def load_platforms(self) -> None:
"""Load backup platforms."""
@ -422,11 +430,21 @@ class BackupManager:
return_exceptions=True,
)
for idx, result in enumerate(sync_backup_results):
if isinstance(result, Exception):
if isinstance(result, BackupReaderWriterError):
# writer errors will affect all agents
# no point in continuing
raise BackupManagerError(str(result)) from result
if isinstance(result, BackupAgentError):
agent_errors[agent_ids[idx]] = result
LOGGER.exception(
"Error during backup upload - %s", result, exc_info=result
)
continue
if isinstance(result, Exception):
# trap bugs from agents
agent_errors[agent_ids[idx]] = result
LOGGER.error("Unexpected error: %s", result, exc_info=result)
continue
if isinstance(result, BaseException):
raise result
return agent_errors
async def async_get_backups(
@ -449,7 +467,7 @@ class BackupManager:
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, BaseException):
raise result
raise result # unexpected error
for agent_backup in result:
if (backup_id := agent_backup.backup_id) not in backups:
if known_backup := self.known_backups.get(backup_id):
@ -499,7 +517,7 @@ class BackupManager:
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, BaseException):
raise result
raise result # unexpected error
if not result:
continue
if backup is None:
@ -563,7 +581,7 @@ class BackupManager:
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, BaseException):
raise result
raise result # unexpected error
if not agent_errors:
self.known_backups.remove(backup_id)
@ -578,7 +596,7 @@ class BackupManager:
) -> None:
"""Receive and store a backup file from upload."""
if self.state is not BackupManagerState.IDLE:
raise HomeAssistantError(f"Backup manager busy: {self.state}")
raise BackupManagerError(f"Backup manager busy: {self.state}")
self.async_on_backup_event(
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS)
)
@ -652,6 +670,7 @@ class BackupManager:
include_homeassistant=include_homeassistant,
name=name,
password=password,
raise_task_error=True,
with_automatic_settings=with_automatic_settings,
)
assert self._backup_finish_task
@ -669,11 +688,12 @@ class BackupManager:
include_homeassistant: bool,
name: str | None,
password: str | None,
raise_task_error: bool = False,
with_automatic_settings: bool = False,
) -> NewBackup:
"""Initiate generating a backup."""
if self.state is not BackupManagerState.IDLE:
raise HomeAssistantError(f"Backup manager busy: {self.state}")
raise BackupManagerError(f"Backup manager busy: {self.state}")
if with_automatic_settings:
self.config.data.last_attempted_automatic_backup = dt_util.now()
@ -692,6 +712,7 @@ class BackupManager:
include_homeassistant=include_homeassistant,
name=name,
password=password,
raise_task_error=raise_task_error,
with_automatic_settings=with_automatic_settings,
)
except Exception:
@ -714,15 +735,18 @@ class BackupManager:
include_homeassistant: bool,
name: str | None,
password: str | None,
raise_task_error: bool,
with_automatic_settings: bool,
) -> NewBackup:
"""Initiate generating a backup."""
if not agent_ids:
raise HomeAssistantError("At least one agent must be selected")
if any(agent_id not in self.backup_agents for agent_id in agent_ids):
raise HomeAssistantError("Invalid agent selected")
raise BackupManagerError("At least one agent must be selected")
if invalid_agents := [
agent_id for agent_id in agent_ids if agent_id not in self.backup_agents
]:
raise BackupManagerError(f"Invalid agents selected: {invalid_agents}")
if include_all_addons and include_addons:
raise HomeAssistantError(
raise BackupManagerError(
"Cannot include all addons and specify specific addons"
)
@ -730,41 +754,64 @@ class BackupManager:
name
or f"{"Automatic" if with_automatic_settings else "Custom"} backup {HAVERSION}"
)
new_backup, self._backup_task = await self._reader_writer.async_create_backup(
agent_ids=agent_ids,
backup_name=backup_name,
extra_metadata={
"instance_id": await instance_id.async_get(self.hass),
"with_automatic_settings": with_automatic_settings,
},
include_addons=include_addons,
include_all_addons=include_all_addons,
include_database=include_database,
include_folders=include_folders,
include_homeassistant=include_homeassistant,
on_progress=self.async_on_backup_event,
password=password,
)
self._backup_finish_task = self.hass.async_create_task(
try:
(
new_backup,
self._backup_task,
) = await self._reader_writer.async_create_backup(
agent_ids=agent_ids,
backup_name=backup_name,
extra_metadata={
"instance_id": await instance_id.async_get(self.hass),
"with_automatic_settings": with_automatic_settings,
},
include_addons=include_addons,
include_all_addons=include_all_addons,
include_database=include_database,
include_folders=include_folders,
include_homeassistant=include_homeassistant,
on_progress=self.async_on_backup_event,
password=password,
)
except BackupReaderWriterError as err:
raise BackupManagerError(str(err)) from err
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
self._async_finish_backup(agent_ids, with_automatic_settings),
name="backup_manager_finish_backup",
)
if not raise_task_error:
def log_finish_task_error(task: asyncio.Task[None]) -> None:
if task.done() and not task.cancelled() and (err := task.exception()):
if isinstance(err, BackupManagerError):
LOGGER.error("Error creating backup: %s", err)
else:
LOGGER.error("Unexpected error: %s", err, exc_info=err)
backup_finish_task.add_done_callback(log_finish_task_error)
return new_backup
async def _async_finish_backup(
self, agent_ids: list[str], with_automatic_settings: bool
) -> None:
"""Finish a backup."""
if TYPE_CHECKING:
assert self._backup_task is not None
try:
written_backup = await self._backup_task
except Exception as err: # noqa: BLE001
LOGGER.debug("Generating backup failed", exc_info=err)
except Exception as err:
self.async_on_backup_event(
CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
)
if with_automatic_settings:
self._update_issue_backup_failed()
if isinstance(err, BackupReaderWriterError):
raise BackupManagerError(str(err)) from err
raise # unexpected error
else:
LOGGER.debug(
"Generated new backup with backup_id %s, uploading to agents %s",
@ -777,25 +824,47 @@ class BackupManager:
state=CreateBackupState.IN_PROGRESS,
)
)
agent_errors = await self._async_upload_backup(
backup=written_backup.backup,
agent_ids=agent_ids,
open_stream=written_backup.open_stream,
)
await written_backup.release_stream()
if with_automatic_settings:
# 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)
try:
agent_errors = await self._async_upload_backup(
backup=written_backup.backup,
agent_ids=agent_ids,
open_stream=written_backup.open_stream,
)
except BaseException:
self.async_on_backup_event(
CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
)
raise # manager or unexpected error
finally:
try:
await written_backup.release_stream()
except Exception:
self.async_on_backup_event(
CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
)
raise
self.known_backups.add(written_backup.backup, agent_errors)
if agent_errors:
self.async_on_backup_event(
CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
)
else:
if with_automatic_settings:
# create backup was successful, update last_completed_automatic_backup
self.config.data.last_completed_automatic_backup = dt_util.now()
self.store.save()
self.async_on_backup_event(
CreateBackupEvent(stage=None, state=CreateBackupState.COMPLETED)
)
if with_automatic_settings:
self._update_issue_after_agent_upload(agent_errors)
# delete old backups more numerous than copies
# try this regardless of agent errors above
await delete_backups_exceeding_configured_count(self)
self.async_on_backup_event(
CreateBackupEvent(stage=None, state=CreateBackupState.COMPLETED)
)
finally:
self._backup_task = None
self._backup_finish_task = None
@ -814,7 +883,7 @@ class BackupManager:
) -> None:
"""Initiate restoring a backup."""
if self.state is not BackupManagerState.IDLE:
raise HomeAssistantError(f"Backup manager busy: {self.state}")
raise BackupManagerError(f"Backup manager busy: {self.state}")
self.async_on_backup_event(
RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS)
@ -854,7 +923,7 @@ class BackupManager:
"""Initiate restoring a backup."""
agent = self.backup_agents[agent_id]
if not await agent.async_get_backup(backup_id):
raise HomeAssistantError(
raise BackupManagerError(
f"Backup {backup_id} not found in agent {agent_id}"
)
@ -1027,11 +1096,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
backup_id = _generate_backup_id(date_str, backup_name)
if include_addons or include_all_addons or include_folders:
raise HomeAssistantError(
raise BackupReaderWriterError(
"Addons and folders are not supported by core backup"
)
if not include_homeassistant:
raise HomeAssistantError("Home Assistant must be included in backup")
raise BackupReaderWriterError("Home Assistant must be included in backup")
backup_task = self._hass.async_create_task(
self._async_create_backup(
@ -1102,6 +1171,13 @@ class CoreBackupReaderWriter(BackupReaderWriter):
password,
local_agent_tar_file_path,
)
except (BackupManagerError, OSError, tarfile.TarError, ValueError) as err:
# BackupManagerError from async_pre_backup_actions
# OSError from file operations
# TarError from tarfile
# ValueError from json_bytes
raise BackupReaderWriterError(str(err)) from err
else:
backup = AgentBackup(
addons=[],
backup_id=backup_id,
@ -1119,12 +1195,15 @@ class CoreBackupReaderWriter(BackupReaderWriter):
async_add_executor_job = self._hass.async_add_executor_job
async def send_backup() -> AsyncIterator[bytes]:
f = await async_add_executor_job(tar_file_path.open, "rb")
try:
while chunk := await async_add_executor_job(f.read, 2**20):
yield chunk
finally:
await async_add_executor_job(f.close)
f = await async_add_executor_job(tar_file_path.open, "rb")
try:
while chunk := await async_add_executor_job(f.read, 2**20):
yield chunk
finally:
await async_add_executor_job(f.close)
except OSError as err:
raise BackupReaderWriterError(str(err)) from err
async def open_backup() -> AsyncIterator[bytes]:
return send_backup()
@ -1132,14 +1211,20 @@ class CoreBackupReaderWriter(BackupReaderWriter):
async def remove_backup() -> None:
if local_agent_tar_file_path:
return
await async_add_executor_job(tar_file_path.unlink, True)
try:
await async_add_executor_job(tar_file_path.unlink, True)
except OSError as err:
raise BackupReaderWriterError(str(err)) from err
return WrittenBackup(
backup=backup, open_stream=open_backup, release_stream=remove_backup
)
finally:
# Inform integrations the backup is done
await manager.async_post_backup_actions()
try:
await manager.async_post_backup_actions()
except BackupManagerError as err:
raise BackupReaderWriterError(str(err)) from err
def _mkdir_and_generate_backup_contents(
self,
@ -1252,11 +1337,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
"""
if restore_addons or restore_folders:
raise HomeAssistantError(
raise BackupReaderWriterError(
"Addons and folders are not supported in core restore"
)
if not restore_homeassistant and not restore_database:
raise HomeAssistantError(
raise BackupReaderWriterError(
"Home Assistant or database must be included in restore"
)

View File

@ -6,6 +6,8 @@ from dataclasses import asdict, dataclass
from enum import StrEnum
from typing import Any, Self
from homeassistant.exceptions import HomeAssistantError
@dataclass(frozen=True, kw_only=True)
class AddonInfo:
@ -67,3 +69,7 @@ class AgentBackup:
protected=data["protected"],
size=data["size"],
)
class BackupManagerError(HomeAssistantError):
"""Backup manager error."""

View File

@ -10,6 +10,7 @@ from typing import Any, cast
from aiohasupervisor.exceptions import (
SupervisorBadRequestError,
SupervisorError,
SupervisorNotFoundError,
)
from aiohasupervisor.models import (
@ -23,6 +24,7 @@ from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupReaderWriter,
BackupReaderWriterError,
CreateBackupEvent,
Folder,
IncorrectPasswordError,
@ -234,20 +236,23 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
]
locations = [agent.location for agent in hassio_agents]
backup = await self._client.backups.partial_backup(
supervisor_backups.PartialBackupOptions(
addons=include_addons_set,
folders=include_folders_set,
homeassistant=include_homeassistant,
name=backup_name,
password=password,
compressed=True,
location=locations or LOCATION_CLOUD_BACKUP,
homeassistant_exclude_database=not include_database,
background=True,
extra=extra_metadata,
try:
backup = await self._client.backups.partial_backup(
supervisor_backups.PartialBackupOptions(
addons=include_addons_set,
folders=include_folders_set,
homeassistant=include_homeassistant,
name=backup_name,
password=password,
compressed=True,
location=locations or LOCATION_CLOUD_BACKUP,
homeassistant_exclude_database=not include_database,
background=True,
extra=extra_metadata,
)
)
)
except SupervisorError as err:
raise BackupReaderWriterError(f"Error creating backup: {err}") from err
backup_task = self._hass.async_create_task(
self._async_wait_for_backup(
backup, remove_after_upload=not bool(locations)
@ -279,22 +284,35 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
finally:
unsub()
if not backup_id:
raise HomeAssistantError("Backup failed")
raise BackupReaderWriterError("Backup failed")
async def open_backup() -> AsyncIterator[bytes]:
return await self._client.backups.download_backup(backup_id)
try:
return await self._client.backups.download_backup(backup_id)
except SupervisorError as err:
raise BackupReaderWriterError(
f"Error downloading backup: {err}"
) from err
async def remove_backup() -> None:
if not remove_after_upload:
return
await self._client.backups.remove_backup(
backup_id,
options=supervisor_backups.RemoveBackupOptions(
location={LOCATION_CLOUD_BACKUP}
),
)
try:
await self._client.backups.remove_backup(
backup_id,
options=supervisor_backups.RemoveBackupOptions(
location={LOCATION_CLOUD_BACKUP}
),
)
except SupervisorError as err:
raise BackupReaderWriterError(f"Error removing backup: {err}") from err
details = await self._client.backups.backup_info(backup_id)
try:
details = await self._client.backups.backup_info(backup_id)
except SupervisorError as err:
raise BackupReaderWriterError(
f"Error getting backup details: {err}"
) from err
return WrittenBackup(
backup=_backup_details_to_agent_backup(details),

View File

@ -166,3 +166,15 @@ async def setup_backup_integration(
agent._loaded_backups = True
return result
async def setup_backup_platform(
hass: HomeAssistant,
*,
domain: str,
platform: Any,
) -> None:
"""Set up a mock domain."""
mock_platform(hass, f"{domain}.backup", platform)
assert await async_setup_component(hass, domain, {})
await hass.async_block_till_done()

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Generator
from dataclasses import replace
from io import StringIO
import json
from pathlib import Path
@ -17,13 +18,15 @@ from homeassistant.components.backup import (
AgentBackup,
BackupAgentPlatformProtocol,
BackupManager,
BackupPlatformProtocol,
BackupReaderWriterError,
Folder,
LocalBackupAgent,
backup as local_backup_platform,
)
from homeassistant.components.backup.agent import BackupAgentError
from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.components.backup.manager import (
BackupManagerError,
BackupManagerState,
CoreBackupReaderWriter,
CreateBackupEvent,
@ -42,9 +45,9 @@ from .common import (
TEST_BACKUP_ABC123,
TEST_BACKUP_DEF456,
BackupAgentTest,
setup_backup_platform,
)
from tests.common import MockPlatform, mock_platform
from tests.typing import ClientSessionGenerator, WebSocketGenerator
_EXPECTED_FILES = [
@ -61,18 +64,6 @@ _EXPECTED_FILES_WITH_DATABASE = {
}
async def _setup_backup_platform(
hass: HomeAssistant,
*,
domain: str = "some_domain",
platform: BackupPlatformProtocol | BackupAgentPlatformProtocol | None = None,
) -> None:
"""Set up a mock domain."""
mock_platform(hass, f"{domain}.backup", platform or MockPlatform())
assert await async_setup_component(hass, domain, {})
await hass.async_block_till_done()
@pytest.fixture(autouse=True)
def mock_delay_save() -> Generator[None]:
"""Mock the delay save constant."""
@ -159,12 +150,15 @@ async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None:
("parameters", "expected_error"),
[
({"agent_ids": []}, "At least one agent must be selected"),
({"agent_ids": ["non_existing"]}, "Invalid agent selected"),
({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"),
(
{"include_addons": ["ssl"], "include_all_addons": True},
"Cannot include all addons and specify specific addons",
),
({"include_homeassistant": False}, "Home Assistant must be included in backup"),
(
{"include_homeassistant": False},
"Home Assistant must be included in backup",
),
],
)
async def test_create_backup_wrong_parameters(
@ -242,7 +236,7 @@ async def test_async_initiate_backup(
core_get_backup_agents.return_value = [local_agent]
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await _setup_backup_platform(
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
@ -393,19 +387,96 @@ async def test_async_initiate_backup(
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")])
async def test_async_initiate_backup_with_agent_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
generate_backup_id: MagicMock,
path_glob: MagicMock,
hass_storage: dict[str, Any],
exception: Exception,
) -> None:
"""Test generate backup."""
"""Test agent upload error during backup generation."""
agent_ids = [LOCAL_AGENT_ID, "test.remote"]
local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
remote_agent = BackupAgentTest("remote", backups=[])
backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id
backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id
backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id
backups_info: list[dict[str, Any]] = [
{
"addons": [
{
"name": "Test",
"slug": "test",
"version": "1.0.0",
},
],
"agent_ids": [
"test.remote",
],
"backup_id": "backup1",
"database_included": True,
"date": "1970-01-01T00:00:00.000Z",
"failed_agent_ids": [],
"folders": [
"media",
"share",
],
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"protected": False,
"size": 0,
"with_automatic_settings": True,
},
{
"addons": [],
"agent_ids": [
"test.remote",
],
"backup_id": "backup2",
"database_included": False,
"date": "1980-01-01T00:00:00.000Z",
"failed_agent_ids": [],
"folders": [
"media",
"share",
],
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test 2",
"protected": False,
"size": 1,
"with_automatic_settings": None,
},
{
"addons": [
{
"name": "Test",
"slug": "test",
"version": "1.0.0",
},
],
"agent_ids": [
"test.remote",
],
"backup_id": "backup3",
"database_included": True,
"date": "1970-01-01T00:00:00.000Z",
"failed_agent_ids": [],
"folders": [
"media",
"share",
],
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"protected": False,
"size": 0,
"with_automatic_settings": True,
},
]
remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3])
with patch(
"homeassistant.components.backup.backup.async_get_backup_agents"
@ -413,7 +484,7 @@ async def test_async_initiate_backup_with_agent_error(
core_get_backup_agents.return_value = [local_agent]
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await _setup_backup_platform(
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
@ -431,12 +502,18 @@ async def test_async_initiate_backup_with_agent_error(
assert result["success"] is True
assert result["result"] == {
"backups": [],
"backups": backups_info,
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
}
await ws_client.send_json_auto_id(
{"type": "backup/config/update", "retention": {"copies": 1, "days": None}}
)
result = await ws_client.receive_json()
assert result["success"]
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
result = await ws_client.receive_json()
@ -445,11 +522,16 @@ async def test_async_initiate_backup_with_agent_error(
result = await ws_client.receive_json()
assert result["success"] is True
delete_backup = AsyncMock()
with (
patch("pathlib.Path.open", mock_open(read_data=b"test")),
patch.object(
remote_agent, "async_upload_backup", side_effect=Exception("Test exception")
remote_agent,
"async_upload_backup",
side_effect=exception,
),
patch.object(remote_agent, "async_delete_backup", delete_backup),
):
await ws_client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": agent_ids}
@ -486,13 +568,13 @@ async def test_async_initiate_backup_with_agent_error(
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": None,
"state": CreateBackupState.COMPLETED,
"state": CreateBackupState.FAILED,
}
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
expected_backup_data = {
new_expected_backup_data = {
"addons": [],
"agent_ids": ["backup.local"],
"backup_id": "abc123",
@ -508,20 +590,14 @@ async def test_async_initiate_backup_with_agent_error(
"with_automatic_settings": False,
}
await ws_client.send_json_auto_id(
{"type": "backup/details", "backup_id": backup_id}
)
result = await ws_client.receive_json()
assert result["result"] == {
"agent_errors": {},
"backup": expected_backup_data,
}
await ws_client.send_json_auto_id({"type": "backup/info"})
result = await ws_client.receive_json()
backups_response = result["result"].pop("backups")
assert len(backups_response) == 4
assert new_expected_backup_data in backups_response
assert result["result"] == {
"agent_errors": {},
"backups": [expected_backup_data],
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
}
@ -534,6 +610,9 @@ async def test_async_initiate_backup_with_agent_error(
}
]
# one of the two matching backups with the remote agent should have been deleted
assert delete_backup.call_count == 1
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
@ -702,7 +781,7 @@ async def test_create_backup_failure_raises_issue(
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await _setup_backup_platform(
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
@ -743,6 +822,337 @@ async def test_create_backup_failure_raises_issue(
assert issue.translation_placeholders == issue_data["translation_placeholders"]
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
"exception", [BackupReaderWriterError("Boom!"), BaseException("Boom!")]
)
async def test_async_initiate_backup_non_agent_upload_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
path_glob: MagicMock,
hass_storage: dict[str, Any],
exception: Exception,
) -> None:
"""Test an unknown or writer upload error during backup generation."""
hass_storage[DOMAIN] = {
"data": {},
"key": DOMAIN,
"version": 1,
}
agent_ids = [LOCAL_AGENT_ID, "test.remote"]
local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
remote_agent = BackupAgentTest("remote", backups=[])
with patch(
"homeassistant.components.backup.backup.async_get_backup_agents"
) as core_get_backup_agents:
core_get_backup_agents.return_value = [local_agent]
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,
),
)
ws_client = await hass_ws_client(hass)
path_glob.return_value = []
await ws_client.send_json_auto_id({"type": "backup/info"})
result = await ws_client.receive_json()
assert result["success"] is True
assert result["result"] == {
"backups": [],
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
}
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
result = await ws_client.receive_json()
assert result["success"] is True
with (
patch("pathlib.Path.open", mock_open(read_data=b"test")),
patch.object(
remote_agent,
"async_upload_backup",
side_effect=exception,
),
):
await ws_client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": agent_ids}
)
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": None,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["success"] is True
backup_id = result["result"]["backup_job_id"]
assert backup_id == generate_backup_id.return_value
await hass.async_block_till_done()
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": CreateBackupStage.HOME_ASSISTANT,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": CreateBackupStage.UPLOAD_TO_AGENTS,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": None,
"state": CreateBackupState.FAILED,
}
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
assert not hass_storage[DOMAIN]["data"]
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
"exception", [BackupReaderWriterError("Boom!"), Exception("Boom!")]
)
async def test_async_initiate_backup_with_task_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
path_glob: MagicMock,
create_backup: AsyncMock,
exception: Exception,
) -> None:
"""Test backup task error during backup generation."""
backup_task: asyncio.Future[Any] = asyncio.Future()
backup_task.set_exception(exception)
create_backup.return_value = (NewBackup(backup_job_id="abc123"), backup_task)
agent_ids = [LOCAL_AGENT_ID, "test.remote"]
local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
remote_agent = BackupAgentTest("remote", backups=[])
with patch(
"homeassistant.components.backup.backup.async_get_backup_agents"
) as core_get_backup_agents:
core_get_backup_agents.return_value = [local_agent]
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,
),
)
ws_client = await hass_ws_client(hass)
path_glob.return_value = []
await ws_client.send_json_auto_id({"type": "backup/info"})
result = await ws_client.receive_json()
assert result["success"] is True
assert result["result"] == {
"backups": [],
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
}
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
result = await ws_client.receive_json()
assert result["success"] is True
await ws_client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": agent_ids}
)
await hass.async_block_till_done()
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": None,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": None,
"state": CreateBackupState.FAILED,
}
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
result = await ws_client.receive_json()
assert result["success"] is True
backup_id = result["result"]["backup_job_id"]
assert backup_id == generate_backup_id.return_value
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
(
"open_call_count",
"open_exception",
"read_call_count",
"read_exception",
"close_call_count",
"close_exception",
"unlink_call_count",
"unlink_exception",
),
[
(1, OSError("Boom!"), 0, None, 0, None, 1, None),
(1, None, 1, OSError("Boom!"), 1, None, 1, None),
(1, None, 1, None, 1, OSError("Boom!"), 1, None),
(1, None, 1, None, 1, None, 1, OSError("Boom!")),
],
)
async def test_initiate_backup_file_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
path_glob: MagicMock,
open_call_count: int,
open_exception: Exception | None,
read_call_count: int,
read_exception: Exception | None,
close_call_count: int,
close_exception: Exception | None,
unlink_call_count: int,
unlink_exception: Exception | None,
) -> None:
"""Test file error during generate backup."""
agent_ids = ["test.remote"]
local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
remote_agent = BackupAgentTest("remote", backups=[])
with patch(
"homeassistant.components.backup.backup.async_get_backup_agents"
) as core_get_backup_agents:
core_get_backup_agents.return_value = [local_agent]
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,
),
)
ws_client = await hass_ws_client(hass)
path_glob.return_value = []
await ws_client.send_json_auto_id({"type": "backup/info"})
result = await ws_client.receive_json()
assert result["success"] is True
assert result["result"] == {
"backups": [],
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
}
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
result = await ws_client.receive_json()
assert result["success"] is True
open_mock = mock_open(read_data=b"test")
open_mock.side_effect = open_exception
open_mock.return_value.read.side_effect = read_exception
open_mock.return_value.close.side_effect = close_exception
with (
patch("pathlib.Path.open", open_mock),
patch("pathlib.Path.unlink", side_effect=unlink_exception) as unlink_mock,
):
await ws_client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": agent_ids}
)
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": None,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["success"] is True
backup_id = result["result"]["backup_job_id"]
assert backup_id == generate_backup_id.return_value
await hass.async_block_till_done()
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": CreateBackupStage.HOME_ASSISTANT,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": CreateBackupStage.UPLOAD_TO_AGENTS,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"stage": None,
"state": CreateBackupState.FAILED,
}
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
assert open_mock.call_count == open_call_count
assert open_mock.return_value.read.call_count == read_call_count
assert open_mock.return_value.close.call_count == close_call_count
assert unlink_mock.call_count == unlink_call_count
async def test_loading_platforms(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
@ -754,8 +1164,9 @@ async def test_loading_platforms(
get_agents_mock = AsyncMock(return_value=[])
await _setup_backup_platform(
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
@ -776,7 +1187,7 @@ class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent):
def get_backup_path(self, backup_id: str) -> Path:
"""Return the local path to a backup."""
return "test.tar"
return Path("test.tar")
@pytest.mark.parametrize(
@ -797,7 +1208,7 @@ async def test_loading_platform_with_listener(
get_agents_mock = AsyncMock(return_value=[agent_class("remote1", backups=[])])
register_listener_mock = Mock()
await _setup_backup_platform(
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
@ -846,7 +1257,7 @@ async def test_not_loading_bad_platforms(
platform_mock: Mock,
) -> None:
"""Test not loading bad backup platforms."""
await _setup_backup_platform(
await setup_backup_platform(
hass,
domain="test",
platform=platform_mock,
@ -857,16 +1268,14 @@ async def test_not_loading_bad_platforms(
assert platform_mock.mock_calls == []
async def test_exception_platform_pre(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
async def test_exception_platform_pre(hass: HomeAssistant) -> None:
"""Test exception in pre step."""
async def _mock_step(hass: HomeAssistant) -> None:
raise HomeAssistantError("Test exception")
remote_agent = BackupAgentTest("remote", backups=[])
await _setup_backup_platform(
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
@ -878,28 +1287,25 @@ async def test_exception_platform_pre(
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await hass.services.async_call(
DOMAIN,
"create",
blocking=True,
)
with pytest.raises(BackupManagerError) as err:
await hass.services.async_call(
DOMAIN,
"create",
blocking=True,
)
assert "Generating backup failed" in caplog.text
assert "Test exception" in caplog.text
assert str(err.value) == "Error during pre-backup: Test exception"
@pytest.mark.usefixtures("mock_backup_generation")
async def test_exception_platform_post(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
async def test_exception_platform_post(hass: HomeAssistant) -> None:
"""Test exception in post step."""
async def _mock_step(hass: HomeAssistant) -> None:
raise HomeAssistantError("Test exception")
remote_agent = BackupAgentTest("remote", backups=[])
await _setup_backup_platform(
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
@ -911,14 +1317,14 @@ async def test_exception_platform_post(
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await hass.services.async_call(
DOMAIN,
"create",
blocking=True,
)
with pytest.raises(BackupManagerError) as err:
await hass.services.async_call(
DOMAIN,
"create",
blocking=True,
)
assert "Generating backup failed" in caplog.text
assert "Test exception" in caplog.text
assert str(err.value) == "Error during post-backup: Test exception"
@pytest.mark.parametrize(
@ -974,7 +1380,7 @@ async def test_receive_backup(
) -> None:
"""Test receive backup and upload to the local and a remote agent."""
remote_agent = BackupAgentTest("remote", backups=[])
await _setup_backup_platform(
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
@ -1098,8 +1504,8 @@ async def test_async_trigger_restore(
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
hass.data[DATA_MANAGER] = manager
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await _setup_backup_platform(
await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
@ -1156,8 +1562,8 @@ async def test_async_trigger_restore_wrong_password(hass: HomeAssistant) -> None
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
hass.data[DATA_MANAGER] = manager
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await _setup_backup_platform(
await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
@ -1228,7 +1634,7 @@ async def test_async_trigger_restore_wrong_parameters(
"""Test trigger restore."""
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await manager.load_platforms()
local_agent = manager.backup_agents[LOCAL_AGENT_ID]

View File

@ -2,13 +2,19 @@
from collections.abc import Generator
from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.backup import AgentBackup, BackupAgentError
from homeassistant.components.backup import (
AgentBackup,
BackupAgentError,
BackupAgentPlatformProtocol,
BackupReaderWriterError,
Folder,
)
from homeassistant.components.backup.agent import BackupAgentUnreachableError
from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN
from homeassistant.components.backup.manager import (
@ -19,6 +25,7 @@ from homeassistant.components.backup.manager import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from .common import (
LOCAL_AGENT_ID,
@ -26,6 +33,7 @@ from .common import (
TEST_BACKUP_DEF456,
BackupAgentTest,
setup_backup_integration,
setup_backup_platform,
)
from tests.common import async_fire_time_changed, async_mock_service
@ -472,27 +480,45 @@ async def test_generate_calls_create(
)
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
("create_backup_settings", "expected_call_params"),
(
"create_backup_settings",
"expected_call_params",
"side_effect",
"last_completed_automatic_backup",
),
[
(
{},
{
"agent_ids": [],
"agent_ids": ["test.remote"],
"include_addons": None,
"include_all_addons": False,
"include_database": True,
"include_folders": None,
"name": None,
"password": None,
},
{
"agent_ids": ["test.remote"],
"backup_name": ANY,
"extra_metadata": {
"instance_id": ANY,
"with_automatic_settings": True,
},
"include_addons": None,
"include_all_addons": False,
"include_database": True,
"include_folders": None,
"include_homeassistant": True,
"name": None,
"on_progress": ANY,
"password": None,
"with_automatic_settings": True,
},
None,
"2024-11-13T12:01:01+01:00",
),
(
{
"agent_ids": ["test-agent"],
"agent_ids": ["test.remote"],
"include_addons": ["test-addon"],
"include_all_addons": False,
"include_database": True,
@ -501,32 +527,78 @@ async def test_generate_calls_create(
"password": "test-password",
},
{
"agent_ids": ["test-agent"],
"agent_ids": ["test.remote"],
"backup_name": "test-name",
"extra_metadata": {
"instance_id": ANY,
"with_automatic_settings": True,
},
"include_addons": ["test-addon"],
"include_all_addons": False,
"include_database": True,
"include_folders": ["media"],
"include_folders": [Folder.MEDIA],
"include_homeassistant": True,
"name": "test-name",
"on_progress": ANY,
"password": "test-password",
"with_automatic_settings": True,
},
None,
"2024-11-13T12:01:01+01:00",
),
(
{
"agent_ids": ["test.remote"],
"include_addons": None,
"include_all_addons": False,
"include_database": True,
"include_folders": None,
"name": None,
"password": None,
},
{
"agent_ids": ["test.remote"],
"backup_name": ANY,
"extra_metadata": {
"instance_id": ANY,
"with_automatic_settings": True,
},
"include_addons": None,
"include_all_addons": False,
"include_database": True,
"include_folders": None,
"include_homeassistant": True,
"on_progress": ANY,
"password": None,
},
BackupAgentError("Boom!"),
None,
),
],
)
async def test_generate_with_default_settings_calls_create(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
create_backup: AsyncMock,
create_backup_settings: dict[str, Any],
expected_call_params: dict[str, Any],
side_effect: Exception | None,
last_completed_automatic_backup: str,
) -> None:
"""Test backup/generate_with_automatic_settings calls async_initiate_backup."""
await setup_backup_integration(hass, with_hassio=False)
client = await hass_ws_client(hass)
freezer.move_to("2024-11-13 12:01:00+01:00")
await hass.config.async_set_time_zone("Europe/Amsterdam")
freezer.move_to("2024-11-13T12:01:00+01:00")
remote_agent = BackupAgentTest("remote", backups=[])
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()
await client.send_json_auto_id(
@ -535,17 +607,47 @@ async def test_generate_with_default_settings_calls_create(
result = await client.receive_json()
assert result["success"]
with patch(
"homeassistant.components.backup.manager.BackupManager.async_initiate_backup",
return_value=NewBackup(backup_job_id="abc123"),
) as generate_backup:
freezer.tick()
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass_storage[DOMAIN]["data"]["config"]["create_backup"]
== create_backup_settings
)
assert (
hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"]
is None
)
assert (
hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"]
is None
)
with patch.object(remote_agent, "async_upload_backup", side_effect=side_effect):
await client.send_json_auto_id(
{"type": "backup/generate_with_automatic_settings"}
)
result = await client.receive_json()
assert result["success"]
assert result["result"] == {"backup_job_id": "abc123"}
generate_backup.assert_called_once_with(**expected_call_params)
await hass.async_block_till_done()
create_backup.assert_called_once_with(**expected_call_params)
freezer.tick()
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"]
== "2024-11-13T12:01:01+01:00"
)
assert (
hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"]
== last_completed_automatic_backup
)
@pytest.mark.parametrize(
@ -1193,7 +1295,23 @@ async def test_config_update_errors(
1,
2,
BACKUP_CALL,
[Exception("Boom"), None],
[BackupReaderWriterError("Boom"), None],
),
(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test.test-agent"]},
"schedule": "daily",
},
"2024-11-11T04:45:00+01:00",
"2024-11-12T04:45:00+01:00",
"2024-11-13T04:45:00+01:00",
"2024-11-12T04:45:00+01:00", # attempted to create backup but failed
"2024-11-11T04:45:00+01:00",
1,
2,
BACKUP_CALL,
[Exception("Boom"), None], # unknown error
),
],
)
@ -2272,7 +2390,7 @@ async def test_subscribe_event(
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test generating a backup."""
"""Test subscribe event."""
await setup_backup_integration(hass, with_hassio=False)
manager = hass.data[DATA_MANAGER]

View File

@ -35,7 +35,10 @@ async def setup_integration(
cloud_logged_in: None,
) -> AsyncGenerator[None]:
"""Set up cloud integration."""
with patch("homeassistant.components.backup.is_hassio", return_value=False):
with (
patch("homeassistant.components.backup.is_hassio", return_value=False),
patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0),
):
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
@ -345,7 +348,7 @@ async def test_agents_upload(
homeassistant_version="2024.12.0",
name="Test",
protected=True,
size=0.0,
size=0,
)
aioclient_mock.put(mock_get_upload_details.return_value["url"])
@ -382,7 +385,7 @@ async def test_agents_upload(
async def test_agents_upload_fail_put(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
caplog: pytest.LogCaptureFixture,
hass_storage: dict[str, Any],
aioclient_mock: AiohttpClientMocker,
mock_get_upload_details: Mock,
put_mock_kwargs: dict[str, Any],
@ -401,7 +404,7 @@ async def test_agents_upload_fail_put(
homeassistant_version="2024.12.0",
name="Test",
protected=True,
size=0.0,
size=0,
)
aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs)
@ -421,9 +424,14 @@ async def test_agents_upload_fail_put(
"/api/backup/upload?agent_id=cloud.cloud",
data={"file": StringIO("test")},
)
await hass.async_block_till_done()
assert resp.status == 201
assert "Error during backup upload - Failed to upload backup" in caplog.text
store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"]
assert len(store_backups) == 1
stored_backup = store_backups[0]
assert stored_backup["backup_id"] == backup_id
assert stored_backup["failed_agent_ids"] == ["cloud.cloud"]
@pytest.mark.parametrize("side_effect", [ClientError, CloudError])
@ -431,9 +439,9 @@ async def test_agents_upload_fail_put(
async def test_agents_upload_fail_cloud(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_storage: dict[str, Any],
mock_get_upload_details: Mock,
side_effect: Exception,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test agent upload backup, when cloud user is logged in."""
client = await hass_client()
@ -450,7 +458,7 @@ async def test_agents_upload_fail_cloud(
homeassistant_version="2024.12.0",
name="Test",
protected=True,
size=0.0,
size=0,
)
with (
patch(
@ -468,15 +476,20 @@ async def test_agents_upload_fail_cloud(
"/api/backup/upload?agent_id=cloud.cloud",
data={"file": StringIO("test")},
)
await hass.async_block_till_done()
assert resp.status == 201
assert "Error during backup upload - Failed to get upload details" in caplog.text
store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"]
assert len(store_backups) == 1
stored_backup = store_backups[0]
assert stored_backup["backup_id"] == backup_id
assert stored_backup["failed_agent_ids"] == ["cloud.cloud"]
async def test_agents_upload_not_protected(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
caplog: pytest.LogCaptureFixture,
hass_storage: dict[str, Any],
) -> None:
"""Test agent upload backup, when cloud user is logged in."""
client = await hass_client()
@ -492,7 +505,7 @@ async def test_agents_upload_not_protected(
homeassistant_version="2024.12.0",
name="Test",
protected=False,
size=0.0,
size=0,
)
with (
patch("pathlib.Path.open"),
@ -505,9 +518,14 @@ async def test_agents_upload_not_protected(
"/api/backup/upload?agent_id=cloud.cloud",
data={"file": StringIO("test")},
)
await hass.async_block_till_done()
assert resp.status == 201
assert "Error during backup upload - Cloud backups must be protected" in caplog.text
store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"]
assert len(store_backups) == 1
stored_backup = store_backups[0]
assert stored_backup["backup_id"] == backup_id
assert stored_backup["failed_agent_ids"] == ["cloud.cloud"]
@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")

View File

@ -16,6 +16,7 @@ from unittest.mock import ANY, AsyncMock, Mock, patch
from aiohasupervisor.exceptions import (
SupervisorBadRequestError,
SupervisorError,
SupervisorNotFoundError,
)
from aiohasupervisor.models import (
@ -46,7 +47,7 @@ TEST_BACKUP = supervisor_backups.Backup(
compressed=False,
content=supervisor_backups.BackupContent(
addons=["ssl"],
folders=["share"],
folders=[supervisor_backups.Folder.SHARE],
homeassistant=True,
),
date=datetime.fromisoformat("1970-01-01T00:00:00Z"),
@ -71,7 +72,7 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete(
compressed=TEST_BACKUP.compressed,
date=TEST_BACKUP.date,
extra=None,
folders=["share"],
folders=[supervisor_backups.Folder.SHARE],
homeassistant_exclude_database=False,
homeassistant="2024.12.0",
location=TEST_BACKUP.location,
@ -197,7 +198,7 @@ async def hassio_enabled(
@pytest.fixture
async def setup_integration(
hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock
) -> AsyncGenerator[None]:
) -> None:
"""Set up Backup integration."""
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
await hass.async_block_till_done()
@ -451,7 +452,7 @@ async def test_agent_upload(
homeassistant_version="2024.12.0",
name="Test",
protected=False,
size=0.0,
size=0,
)
supervisor_client.backups.reload.assert_not_called()
@ -732,6 +733,292 @@ async def test_reader_writer_create(
supervisor_client.backups.download_backup.assert_not_called()
supervisor_client.backups.remove_backup.assert_not_called()
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client", "setup_integration")
@pytest.mark.parametrize(
("side_effect", "error_code", "error_message"),
[
(
SupervisorError("Boom!"),
"home_assistant_error",
"Error creating backup: Boom!",
),
(Exception("Boom!"), "unknown_error", "Unknown error"),
],
)
async def test_reader_writer_create_partial_backup_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
side_effect: Exception,
error_code: str,
error_message: str,
) -> None:
"""Test client partial backup error when generating a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_backup.side_effect = side_effect
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"stage": None,
"state": "failed",
}
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == error_code
assert response["error"]["message"] == error_message
assert supervisor_client.backups.partial_backup.call_count == 1
@pytest.mark.usefixtures("hassio_client", "setup_integration")
async def test_reader_writer_create_missing_reference_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test missing reference error when generating a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_backup.return_value.job_id = "abc123"
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": "abc123"}
assert supervisor_client.backups.partial_backup.call_count == 1
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {
"event": "job",
"data": {"done": True, "uuid": "abc123"},
},
}
)
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"stage": None,
"state": "failed",
}
await hass.async_block_till_done()
assert supervisor_client.backups.backup_info.call_count == 0
assert supervisor_client.backups.download_backup.call_count == 0
assert supervisor_client.backups.remove_backup.call_count == 0
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client", "setup_integration")
@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")])
@pytest.mark.parametrize(
("method", "download_call_count", "remove_call_count"),
[("download_backup", 1, 1), ("remove_backup", 1, 1)],
)
async def test_reader_writer_create_download_remove_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
exception: Exception,
method: str,
download_call_count: int,
remove_call_count: int,
) -> None:
"""Test download and remove error when generating a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_backup.return_value.job_id = "abc123"
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
method_mock = getattr(supervisor_client.backups, method)
method_mock.side_effect = exception
remote_agent = BackupAgentTest("remote")
await _setup_backup_platform(
hass,
domain="test",
platform=Mock(
async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
spec_set=BackupAgentPlatformProtocol,
),
)
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": "abc123"}
assert supervisor_client.backups.partial_backup.call_count == 1
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {
"event": "job",
"data": {"done": True, "uuid": "abc123", "reference": "test_slug"},
},
}
)
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"stage": "upload_to_agents",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"stage": None,
"state": "failed",
}
await hass.async_block_till_done()
assert supervisor_client.backups.backup_info.call_count == 1
assert supervisor_client.backups.download_backup.call_count == download_call_count
assert supervisor_client.backups.remove_backup.call_count == remove_call_count
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client", "setup_integration")
@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")])
async def test_reader_writer_create_info_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
exception: Exception,
) -> None:
"""Test backup info error when generating a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_backup.return_value.job_id = "abc123"
supervisor_client.backups.backup_info.side_effect = exception
remote_agent = BackupAgentTest("remote")
await _setup_backup_platform(
hass,
domain="test",
platform=Mock(
async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
spec_set=BackupAgentPlatformProtocol,
),
)
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": "abc123"}
assert supervisor_client.backups.partial_backup.call_count == 1
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {
"event": "job",
"data": {"done": True, "uuid": "abc123", "reference": "test_slug"},
},
}
)
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"stage": None,
"state": "failed",
}
await hass.async_block_till_done()
assert supervisor_client.backups.backup_info.call_count == 1
assert supervisor_client.backups.download_backup.call_count == 0
assert supervisor_client.backups.remove_backup.call_count == 0
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client", "setup_integration")
async def test_reader_writer_create_remote_backup(