diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 907fda4c7f8..b479f33422d 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -5,18 +5,25 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType -from .const import DATA_MANAGER, DOMAIN, LOGGER +from .agent import BackupAgent, UploadedBackup +from .const import DOMAIN, LOGGER from .http import async_register_http_views from .manager import BackupManager +from .models import BackupUploadMetadata from .websocket import async_register_websocket_handlers +__all__ = [ + "BackupAgent", + "BackupUploadMetadata", + "UploadedBackup", +] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Backup integration.""" - backup_manager = BackupManager(hass) - hass.data[DATA_MANAGER] = backup_manager + hass.data[DOMAIN] = backup_manager = BackupManager(hass) with_hassio = is_hassio(hass) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py new file mode 100644 index 00000000000..ee636b244cd --- /dev/null +++ b/homeassistant/components/backup/agent.py @@ -0,0 +1,73 @@ +"""Backup agents for the Backup integration.""" + +from __future__ import annotations + +import abc +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +from homeassistant.core import HomeAssistant + +from .models import BackupUploadMetadata, BaseBackup + + +@dataclass(slots=True) +class UploadedBackup(BaseBackup): + """Uploaded backup class.""" + + id: str + + +class BackupAgent(abc.ABC): + """Define the format that backup agents can have.""" + + def __init__(self, name: str) -> None: + """Initialize the backup agent.""" + self.name = name + + @abc.abstractmethod + async def async_download_backup( + self, + *, + id: str, + path: Path, + **kwargs: Any, + ) -> None: + """Download a backup file. + + The `id` parameter is the ID of the backup that was returned in async_list_backups. + + The `path` parameter is the full file path to download the backup to. + """ + + @abc.abstractmethod + async def async_upload_backup( + self, + *, + path: Path, + metadata: BackupUploadMetadata, + **kwargs: Any, + ) -> None: + """Upload a backup. + + The `path` parameter is the full file path to the backup that should be uploaded. + + The `metadata` parameter contains metadata about the backup that should be uploaded. + """ + + @abc.abstractmethod + async def async_list_backups(self, **kwargs: Any) -> list[UploadedBackup]: + """List backups.""" + + +class BackupAgentPlatformProtocol(Protocol): + """Define the format that backup platforms can have.""" + + async def async_get_backup_agents( + self, + *, + hass: HomeAssistant, + **kwargs: Any, + ) -> list[BackupAgent]: + """Register the backup agent.""" diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index f613f7cc352..a10fec80360 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -8,10 +8,11 @@ from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: - from .manager import BackupManager + from .manager import BaseBackupManager + from .models import BaseBackup DOMAIN = "backup" -DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN) +DATA_MANAGER: HassKey[BaseBackupManager[BaseBackup]] = HassKey(DOMAIN) LOGGER = getLogger(__package__) EXCLUDE_FROM_BACKUP = [ diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 42693035bd3..da8c65098d0 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify from .const import DATA_MANAGER +from .manager import BackupManager @callback @@ -39,7 +40,7 @@ class DownloadBackupView(HomeAssistantView): if not request["hass_user"].is_admin: return Response(status=HTTPStatus.UNAUTHORIZED) - manager = request.app[KEY_HASS].data[DATA_MANAGER] + manager = cast(BackupManager, request.app[KEY_HASS].data[DATA_MANAGER]) backup = await manager.async_get_backup(slug=slug) if backup is None or not backup.path.exists(): diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index ddc0a1eac3f..c0c033cd1fe 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -16,10 +16,11 @@ import tarfile from tarfile import TarError from tempfile import TemporaryDirectory import time -from typing import Any, Protocol, cast +from typing import Any, Generic, Protocol, cast import aiohttp from securetar import SecureTarFile, atomic_contents_add +from typing_extensions import TypeVar from homeassistant.backup_restore import RESTORE_BACKUP_FILE from homeassistant.const import __version__ as HAVERSION @@ -30,10 +31,14 @@ from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads_object +from .agent import BackupAgent, BackupAgentPlatformProtocol from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER +from .models import BackupUploadMetadata, BaseBackup BUF_SIZE = 2**20 * 4 # 4MB +_BackupT = TypeVar("_BackupT", bound=BaseBackup, default=BaseBackup) + @dataclass(slots=True) class NewBackup: @@ -43,14 +48,10 @@ class NewBackup: @dataclass(slots=True) -class Backup: +class Backup(BaseBackup): """Backup class.""" - slug: str - name: str - date: str path: Path - size: float def as_dict(self) -> dict: """Return a dict representation of this backup.""" @@ -76,19 +77,21 @@ class BackupPlatformProtocol(Protocol): """Perform operations after a backup finishes.""" -class BaseBackupManager(abc.ABC): +class BaseBackupManager(abc.ABC, Generic[_BackupT]): """Define the format that backup managers can have.""" def __init__(self, hass: HomeAssistant) -> None: """Initialize the backup manager.""" self.hass = hass self.backup_task: asyncio.Task | None = None - self.backups: dict[str, Backup] = {} + self.backups: dict[str, _BackupT] = {} self.loaded_platforms = False self.platforms: dict[str, BackupPlatformProtocol] = {} + self.backup_agents: dict[str, BackupAgent] = {} + self.syncing = False @callback - def _add_platform( + def _add_platform_pre_post_handlers( self, hass: HomeAssistant, integration_domain: str, @@ -98,13 +101,25 @@ class BaseBackupManager(abc.ABC): if not hasattr(platform, "async_pre_backup") or not hasattr( platform, "async_post_backup" ): - LOGGER.warning( - "%s does not implement required functions for the backup platform", - integration_domain, - ) return + self.platforms[integration_domain] = platform + async def _async_add_platform_agents( + self, + hass: HomeAssistant, + integration_domain: str, + platform: BackupAgentPlatformProtocol, + ) -> None: + """Add a platform to the backup manager.""" + if not hasattr(platform, "async_get_backup_agents"): + return + + agents = await platform.async_get_backup_agents(hass=hass) + self.backup_agents.update( + {f"{integration_domain}.{agent.name}": agent for agent in agents} + ) + async def async_pre_backup_actions(self, **kwargs: Any) -> None: """Perform pre backup actions.""" if not self.loaded_platforms: @@ -139,10 +154,22 @@ class BaseBackupManager(abc.ABC): async def load_platforms(self) -> None: """Load backup platforms.""" + if self.loaded_platforms: + return await integration_platform.async_process_integration_platforms( - self.hass, DOMAIN, self._add_platform, wait_for_platforms=True + self.hass, + DOMAIN, + self._add_platform_pre_post_handlers, + wait_for_platforms=True, + ) + await integration_platform.async_process_integration_platforms( + self.hass, + DOMAIN, + self._async_add_platform_agents, + wait_for_platforms=True, ) LOGGER.debug("Loaded %s platforms", len(self.platforms)) + LOGGER.debug("Loaded %s agents", len(self.backup_agents)) self.loaded_platforms = True @abc.abstractmethod @@ -159,14 +186,14 @@ class BaseBackupManager(abc.ABC): """Generate a backup.""" @abc.abstractmethod - async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]: + async def async_get_backups(self, **kwargs: Any) -> dict[str, _BackupT]: """Get backups. Return a dictionary of Backup instances keyed by their slug. """ @abc.abstractmethod - async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None: + async def async_get_backup(self, *, slug: str, **kwargs: Any) -> _BackupT | None: """Get a backup.""" @abc.abstractmethod @@ -182,8 +209,12 @@ class BaseBackupManager(abc.ABC): ) -> None: """Receive and store a backup file from upload.""" + @abc.abstractmethod + async def async_upload_backup(self, *, slug: str, **kwargs: Any) -> None: + """Upload a backup.""" -class BackupManager(BaseBackupManager): + +class BackupManager(BaseBackupManager[Backup]): """Backup manager for the Backup integration.""" def __init__(self, hass: HomeAssistant) -> None: @@ -192,10 +223,42 @@ class BackupManager(BaseBackupManager): self.backup_dir = Path(hass.config.path("backups")) self.loaded_backups = False + async def async_upload_backup(self, *, slug: str, **kwargs: Any) -> None: + """Upload a backup.""" + await self.load_platforms() + + if not self.backup_agents: + return + + if not (backup := await self.async_get_backup(slug=slug)): + return + + self.syncing = True + sync_backup_results = await asyncio.gather( + *( + agent.async_upload_backup( + path=backup.path, + metadata=BackupUploadMetadata( + homeassistant=HAVERSION, + size=backup.size, + date=backup.date, + slug=backup.slug, + name=backup.name, + ), + ) + for agent in self.backup_agents.values() + ), + return_exceptions=True, + ) + for result in sync_backup_results: + if isinstance(result, Exception): + LOGGER.error("Error during backup upload - %s", result) + self.syncing = False + async def load_backups(self) -> None: """Load data of stored backup files.""" backups = await self.hass.async_add_executor_job(self._read_backups) - LOGGER.debug("Loaded %s backups", len(backups)) + LOGGER.debug("Loaded %s local backups", len(backups)) self.backups = backups self.loaded_backups = True diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py new file mode 100644 index 00000000000..1007a233923 --- /dev/null +++ b/homeassistant/components/backup/models.py @@ -0,0 +1,28 @@ +"""Models for the backup integration.""" + +from dataclasses import asdict, dataclass + + +@dataclass() +class BaseBackup: + """Base backup class.""" + + date: str + slug: str + size: float + name: str + + def as_dict(self) -> dict: + """Return a dict representation of this backup.""" + return asdict(self) + + +@dataclass() +class BackupUploadMetadata: + """Backup upload metadata.""" + + date: str # The date the backup was created + slug: str # The slug of the backup + size: float # The size of the backup (in bytes) + name: str # The name of the backup + homeassistant: str # The version of Home Assistant that created the backup diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index a7c61b7c66c..d574116e927 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -1,5 +1,6 @@ """Websocket commands for the Backup integration.""" +from pathlib import Path from typing import Any import voluptuous as vol @@ -14,9 +15,14 @@ from .manager import BackupProgress @callback def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> None: """Register websocket commands.""" + websocket_api.async_register_command(hass, backup_agents_download) + websocket_api.async_register_command(hass, backup_agents_info) + websocket_api.async_register_command(hass, backup_agents_list_backups) + if with_hassio: websocket_api.async_register_command(hass, handle_backup_end) websocket_api.async_register_command(hass, handle_backup_start) + websocket_api.async_register_command(hass, handle_backup_upload) return websocket_api.async_register_command(hass, handle_details) @@ -40,7 +46,7 @@ async def handle_info( connection.send_result( msg["id"], { - "backups": list(backups.values()), + "backups": [b.as_dict() for b in backups.values()], "backing_up": manager.backup_task is not None, }, ) @@ -162,3 +168,105 @@ async def handle_backup_end( return connection.send_result(msg["id"]) + + +@websocket_api.ws_require_user(only_supervisor=True) +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/agents/upload", + vol.Required("data"): { + vol.Required("slug"): str, + }, + } +) +@websocket_api.async_response +async def handle_backup_upload( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Backup upload.""" + LOGGER.debug("Backup upload notification") + data = msg["data"] + + try: + await hass.data[DATA_MANAGER].async_upload_backup(slug=data["slug"]) + except Exception as err: # noqa: BLE001 + connection.send_error(msg["id"], "backup_upload_failed", str(err)) + return + + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/agents/info"}) +@websocket_api.async_response +async def backup_agents_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return backup agents info.""" + manager = hass.data[DATA_MANAGER] + await manager.load_platforms() + connection.send_result( + msg["id"], + { + "agents": [{"id": agent_id} for agent_id in manager.backup_agents], + "syncing": manager.syncing, + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/agents/list_backups"}) +@websocket_api.async_response +async def backup_agents_list_backups( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return a list of uploaded backups.""" + manager = hass.data[DATA_MANAGER] + backups: list[dict[str, Any]] = [] + await manager.load_platforms() + for agent_id, agent in manager.backup_agents.items(): + _listed_backups = await agent.async_list_backups() + backups.extend({**b.as_dict(), "agent_id": agent_id} for b in _listed_backups) + connection.send_result(msg["id"], backups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/agents/download", + vol.Required("agent"): str, + vol.Required("backup_id"): str, + vol.Required("slug"): str, + } +) +@websocket_api.async_response +async def backup_agents_download( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Download an uploaded backup.""" + manager = hass.data[DATA_MANAGER] + await manager.load_platforms() + + if not (agent := manager.backup_agents.get(msg["agent"])): + connection.send_error( + msg["id"], "unknown_agent", f"Agent {msg['agent']} not found" + ) + return + try: + await agent.async_download_backup( + id=msg["backup_id"], + path=Path(hass.config.path("backup"), f"{msg['slug']}.tar"), + ) + except Exception as err: # noqa: BLE001 + connection.send_error(msg["id"], "backup_agents_download", str(err)) + return + + connection.send_result(msg["id"]) diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py new file mode 100644 index 00000000000..92d1859dbe6 --- /dev/null +++ b/homeassistant/components/kitchen_sink/backup.py @@ -0,0 +1,74 @@ +"""Backup platform for the kitchen_sink integration.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from homeassistant.components.backup import ( + BackupAgent, + BackupUploadMetadata, + UploadedBackup, +) +from homeassistant.core import HomeAssistant + +LOGGER = logging.getLogger(__name__) + + +async def async_get_backup_sync_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Register the backup agents.""" + return [KitchenSinkBackupAgent("syncer")] + + +class KitchenSinkBackupAgent(BackupAgent): + """Kitchen sink backup agent.""" + + def __init__(self, name: str) -> None: + """Initialize the kitchen sink backup sync agent.""" + super().__init__(name) + self._uploads = [ + UploadedBackup( + id="def456", + name="Kitchen sink syncer", + slug="abc123", + size=1234, + date="1970-01-01T00:00:00Z", + ) + ] + + async def async_download_backup( + self, + *, + id: str, + path: Path, + **kwargs: Any, + ) -> None: + """Download a backup file.""" + LOGGER.info("Downloading backup %s to %s", id, path) + + async def async_upload_backup( + self, + *, + path: Path, + metadata: BackupUploadMetadata, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + LOGGER.info("Uploading backup %s %s", path.name, metadata) + self._uploads.append( + UploadedBackup( + id=uuid4().hex, + name=metadata.name, + slug=metadata.slug, + size=metadata.size, + date=metadata.date, + ) + ) + + async def async_list_backups(self, **kwargs: Any) -> list[UploadedBackup]: + """List synced backups.""" + return self._uploads diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 70b33d2de3f..2af5c76236f 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -3,10 +3,13 @@ from __future__ import annotations from pathlib import Path +from typing import Any from unittest.mock import patch from homeassistant.components.backup import DOMAIN +from homeassistant.components.backup.agent import BackupAgent, UploadedBackup from homeassistant.components.backup.manager import Backup +from homeassistant.components.backup.models import BackupUploadMetadata from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component @@ -20,6 +23,40 @@ TEST_BACKUP = Backup( ) +class BackupAgentTest(BackupAgent): + """Test backup agent.""" + + async def async_download_backup( + self, + *, + id: str, + path: Path, + **kwargs: Any, + ) -> None: + """Download a backup file.""" + + async def async_upload_backup( + self, + *, + path: Path, + metadata: BackupUploadMetadata, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + + async def async_list_backups(self, **kwargs: Any) -> list[UploadedBackup]: + """List backups.""" + return [ + UploadedBackup( + id="abc123", + name="Test", + slug="abc123", + size=13.37, + date="1970-01-01T00:00:00Z", + ) + ] + + async def setup_backup_integration( hass: HomeAssistant, with_hassio: bool = False, diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 42eb524e529..9edd1216203 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -1,4 +1,106 @@ # serializer version: 1 +# name: test_agents_download[with_hassio] + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_agents_download[without_hassio] + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_agents_download_exception + dict({ + 'error': dict({ + 'code': 'backup_agents_download', + 'message': 'Boom', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_agents_download_unknown_agent + dict({ + 'error': dict({ + 'code': 'unknown_agent', + 'message': 'Agent domain.test not found', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_agents_info[with_hassio] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'id': 'domain.test', + }), + ]), + 'syncing': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_agents_info[without_hassio] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'id': 'domain.test', + }), + ]), + 'syncing': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_agents_list_backups[with_hassio] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'agent_id': 'domain.test', + 'date': '1970-01-01T00:00:00Z', + 'id': 'abc123', + 'name': 'Test', + 'size': 13.37, + 'slug': 'abc123', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_agents_list_backups[without_hassio] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'agent_id': 'domain.test', + 'date': '1970-01-01T00:00:00Z', + 'id': 'abc123', + 'name': 'Test', + 'size': 13.37, + 'slug': 'abc123', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- # name: test_backup_end[with_hassio-hass_access_token] dict({ 'error': dict({ @@ -40,7 +142,7 @@ 'type': 'result', }) # --- -# name: test_backup_end_excepion[exception0] +# name: test_backup_end_exception[exception0] dict({ 'error': dict({ 'code': 'post_backup_actions_failed', @@ -51,7 +153,7 @@ 'type': 'result', }) # --- -# name: test_backup_end_excepion[exception1] +# name: test_backup_end_exception[exception1] dict({ 'error': dict({ 'code': 'post_backup_actions_failed', @@ -62,7 +164,7 @@ 'type': 'result', }) # --- -# name: test_backup_end_excepion[exception2] +# name: test_backup_end_exception[exception2] dict({ 'error': dict({ 'code': 'post_backup_actions_failed', @@ -114,7 +216,7 @@ 'type': 'result', }) # --- -# name: test_backup_start_excepion[exception0] +# name: test_backup_start_exception[exception0] dict({ 'error': dict({ 'code': 'pre_backup_actions_failed', @@ -125,7 +227,7 @@ 'type': 'result', }) # --- -# name: test_backup_start_excepion[exception1] +# name: test_backup_start_exception[exception1] dict({ 'error': dict({ 'code': 'pre_backup_actions_failed', @@ -136,7 +238,7 @@ 'type': 'result', }) # --- -# name: test_backup_start_excepion[exception2] +# name: test_backup_start_exception[exception2] dict({ 'error': dict({ 'code': 'pre_backup_actions_failed', @@ -147,6 +249,80 @@ 'type': 'result', }) # --- +# name: test_backup_upload[with_hassio-hass_access_token] + dict({ + 'error': dict({ + 'code': 'only_supervisor', + 'message': 'Only allowed as Supervisor', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_backup_upload[with_hassio-hass_supervisor_access_token] + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_backup_upload[without_hassio-hass_access_token] + dict({ + 'error': dict({ + 'code': 'unknown_command', + 'message': 'Unknown command.', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_backup_upload[without_hassio-hass_supervisor_access_token] + dict({ + 'error': dict({ + 'code': 'unknown_command', + 'message': 'Unknown command.', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_backup_upload_exception[exception0] + dict({ + 'error': dict({ + 'code': 'backup_upload_failed', + 'message': '', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_backup_upload_exception[exception1] + dict({ + 'error': dict({ + 'code': 'backup_upload_failed', + 'message': 'Boom', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_backup_upload_exception[exception2] + dict({ + 'error': dict({ + 'code': 'backup_upload_failed', + 'message': 'Boom', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_details[with_hassio-with_backup_content] dict({ 'error': dict({ diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 9d24964aedf..9cc0067cf1a 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -3,13 +3,15 @@ from __future__ import annotations import asyncio +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch import aiohttp from multidict import CIMultiDict, CIMultiDictProxy import pytest -from homeassistant.components.backup import BackupManager +from homeassistant.components.backup import BackupManager, BackupUploadMetadata +from homeassistant.components.backup.agent import BackupAgentPlatformProtocol from homeassistant.components.backup.manager import ( BackupPlatformProtocol, BackupProgress, @@ -18,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP +from .common import TEST_BACKUP, BackupAgentTest from tests.common import MockPlatform, mock_platform @@ -39,7 +41,7 @@ async def _mock_backup_generation( assert manager.backup_task is not None assert progress == [] - await manager.backup_task + backup = await manager.backup_task assert progress == [BackupProgress(done=True, stage=None, success=True)] assert mocked_json_bytes.call_count == 1 @@ -48,10 +50,12 @@ async def _mock_backup_generation( assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} assert manager.backup_dir.as_posix() in str(mocked_tarfile.call_args_list[0][0][0]) + return backup + async def _setup_mock_domain( hass: HomeAssistant, - platform: BackupPlatformProtocol | None = None, + platform: BackupPlatformProtocol | BackupAgentPlatformProtocol | None = None, ) -> None: """Set up a mock domain.""" mock_platform(hass, "some_domain.backup", platform or MockPlatform()) @@ -174,6 +178,7 @@ async def test_async_create_backup( assert "Generated new backup with slug " in caplog.text assert "Creating backup directory" in caplog.text assert "Loaded 0 platforms" in caplog.text + assert "Loaded 0 agents" in caplog.text async def test_loading_platforms( @@ -191,6 +196,7 @@ async def test_loading_platforms( Mock( async_pre_backup=AsyncMock(), async_post_backup=AsyncMock(), + async_get_backup_agents=AsyncMock(), ), ) await manager.load_platforms() @@ -202,6 +208,32 @@ async def test_loading_platforms( assert "Loaded 1 platforms" in caplog.text +async def test_loading_agents( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test loading backup agents.""" + manager = BackupManager(hass) + + assert not manager.loaded_platforms + assert not manager.platforms + + await _setup_mock_domain( + hass, + Mock( + async_get_backup_agents=AsyncMock(return_value=[BackupAgentTest("test")]), + ), + ) + await manager.load_platforms() + await hass.async_block_till_done() + + assert manager.loaded_platforms + assert len(manager.backup_agents) == 1 + + assert "Loaded 1 agents" in caplog.text + assert "some_domain.test" in manager.backup_agents + + async def test_not_loading_bad_platforms( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -220,10 +252,151 @@ async def test_not_loading_bad_platforms( assert len(manager.platforms) == 0 assert "Loaded 0 platforms" in caplog.text - assert ( - "some_domain does not implement required functions for the backup platform" - in caplog.text + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_syncing_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, +) -> None: + """Test syncing a backup.""" + manager = BackupManager(hass) + + await _setup_mock_domain( + hass, + Mock( + async_pre_backup=AsyncMock(), + async_post_backup=AsyncMock(), + async_get_backup_agents=AsyncMock( + return_value=[ + BackupAgentTest("agent1"), + BackupAgentTest("agent2"), + ] + ), + ), ) + await manager.load_platforms() + await hass.async_block_till_done() + + backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=backup, + ), + patch.object(BackupAgentTest, "async_upload_backup") as mocked_upload, + patch( + "homeassistant.components.backup.manager.HAVERSION", + "2025.1.0", + ), + ): + await manager.async_upload_backup(slug=backup.slug) + assert mocked_upload.call_count == 2 + first_call = mocked_upload.call_args_list[0] + assert first_call[1]["path"] == backup.path + assert first_call[1]["metadata"] == BackupUploadMetadata( + date=backup.date, + homeassistant="2025.1.0", + name=backup.name, + size=backup.size, + slug=backup.slug, + ) + + assert "Error during backup upload" not in caplog.text + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_syncing_backup_with_exception( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, +) -> None: + """Test syncing a backup with exception.""" + manager = BackupManager(hass) + + class ModifiedBackupSyncAgentTest(BackupAgentTest): + async def async_upload_backup(self, **kwargs: Any) -> None: + raise HomeAssistantError("Test exception") + + await _setup_mock_domain( + hass, + Mock( + async_pre_backup=AsyncMock(), + async_post_backup=AsyncMock(), + async_get_backup_agents=AsyncMock( + return_value=[ + ModifiedBackupSyncAgentTest("agent1"), + ModifiedBackupSyncAgentTest("agent2"), + ] + ), + ), + ) + await manager.load_platforms() + await hass.async_block_till_done() + + backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=backup, + ), + patch.object( + ModifiedBackupSyncAgentTest, + "async_upload_backup", + ) as mocked_upload, + patch( + "homeassistant.components.backup.manager.HAVERSION", + "2025.1.0", + ), + ): + mocked_upload.side_effect = HomeAssistantError("Test exception") + await manager.async_upload_backup(slug=backup.slug) + assert mocked_upload.call_count == 2 + first_call = mocked_upload.call_args_list[0] + assert first_call[1]["path"] == backup.path + assert first_call[1]["metadata"] == BackupUploadMetadata( + date=backup.date, + homeassistant="2025.1.0", + name=backup.name, + size=backup.size, + slug=backup.slug, + ) + + assert "Error during backup upload - Test exception" in caplog.text + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_syncing_backup_no_agents( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, +) -> None: + """Test syncing a backup with no agents.""" + manager = BackupManager(hass) + + await _setup_mock_domain( + hass, + Mock( + async_pre_backup=AsyncMock(), + async_post_backup=AsyncMock(), + async_get_backup_agents=AsyncMock(return_value=[]), + ), + ) + await manager.load_platforms() + await hass.async_block_till_done() + + backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + with patch( + "homeassistant.components.backup.agent.BackupAgent.async_upload_backup" + ) as mocked_async_upload_backup: + await manager.async_upload_backup(slug=backup.slug) + assert mocked_async_upload_backup.call_count == 0 async def test_exception_plaform_pre( @@ -241,6 +414,7 @@ async def test_exception_plaform_pre( Mock( async_pre_backup=_mock_step, async_post_backup=AsyncMock(), + async_get_backup_agents=AsyncMock(), ), ) @@ -263,6 +437,7 @@ async def test_exception_plaform_post( Mock( async_pre_backup=AsyncMock(), async_post_backup=_mock_step, + async_get_backup_agents=AsyncMock(), ), ) @@ -285,6 +460,7 @@ async def test_loading_platforms_when_running_async_pre_backup_actions( Mock( async_pre_backup=AsyncMock(), async_post_backup=AsyncMock(), + async_get_backup_agents=AsyncMock(), ), ) await manager.async_pre_backup_actions() @@ -310,6 +486,7 @@ async def test_loading_platforms_when_running_async_post_backup_actions( Mock( async_pre_backup=AsyncMock(), async_post_backup=AsyncMock(), + async_get_backup_agents=AsyncMock(), ), ) await manager.async_post_backup_actions() diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 3e031f172ae..92467a0ba76 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1,16 +1,18 @@ """Tests for the Backup integration.""" -from unittest.mock import patch +from pathlib import Path +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup.manager import Backup +from homeassistant.components.backup.const import DATA_MANAGER +from homeassistant.components.backup.models import BaseBackup from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .common import TEST_BACKUP, setup_backup_integration +from .common import TEST_BACKUP, BackupAgentTest, setup_backup_integration from tests.typing import WebSocketGenerator @@ -43,15 +45,23 @@ async def test_info( """Test getting backup info.""" await setup_backup_integration(hass, with_hassio=with_hassio) + hass.data[DATA_MANAGER].backups = {TEST_BACKUP.slug: TEST_BACKUP} + client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backups", - return_value={TEST_BACKUP.slug: TEST_BACKUP}, + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.load_backups", + AsyncMock(), + ), + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value={TEST_BACKUP.slug: TEST_BACKUP}, + ), ): await client.send_json_auto_id({"type": "backup/info"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -73,7 +83,7 @@ async def test_details( hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, with_hassio: bool, - backup_content: Backup | None, + backup_content: BaseBackup | None, ) -> None: """Test getting backup info.""" await setup_backup_integration(hass, with_hassio=with_hassio) @@ -112,7 +122,7 @@ async def test_remove( "homeassistant.components.backup.manager.BackupManager.async_remove_backup", ): await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -140,7 +150,7 @@ async def test_generate( await client.send_json_auto_id({"type": "backup/generate"}) for _ in range(number_of_messages): - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -199,7 +209,7 @@ async def test_backup_end( "homeassistant.components.backup.manager.BackupManager.async_post_backup_actions", ): await client.send_json_auto_id({"type": "backup/end"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -232,7 +242,47 @@ async def test_backup_start( "homeassistant.components.backup.manager.BackupManager.async_pre_backup_actions", ): await client.send_json_auto_id({"type": "backup/start"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "access_token_fixture_name", + ["hass_access_token", "hass_supervisor_access_token"], +) +@pytest.mark.parametrize( + ("with_hassio"), + [ + pytest.param(True, id="with_hassio"), + pytest.param(False, id="without_hassio"), + ], +) +async def test_backup_upload( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + sync_access_token_proxy: str, + *, + access_token_fixture_name: str, + with_hassio: bool, +) -> None: + """Test backup upload from a WS command.""" + await setup_backup_integration(hass, with_hassio=with_hassio) + + client = await hass_ws_client(hass, sync_access_token_proxy) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_upload_backup", + ): + await client.send_json_auto_id( + { + "type": "backup/agents/upload", + "data": { + "slug": "abc123", + }, + } + ) + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -243,7 +293,7 @@ async def test_backup_start( Exception("Boom"), ], ) -async def test_backup_end_excepion( +async def test_backup_end_exception( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, @@ -261,7 +311,7 @@ async def test_backup_end_excepion( side_effect=exception, ): await client.send_json_auto_id({"type": "backup/end"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -272,7 +322,43 @@ async def test_backup_end_excepion( Exception("Boom"), ], ) -async def test_backup_start_excepion( +async def test_backup_upload_exception( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + hass_supervisor_access_token: str, + exception: Exception, +) -> None: + """Test exception handling while running backup upload from a WS command.""" + await setup_backup_integration(hass, with_hassio=True) + + client = await hass_ws_client(hass, hass_supervisor_access_token) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_upload_backup", + side_effect=exception, + ): + await client.send_json_auto_id( + { + "type": "backup/agents/upload", + "data": { + "slug": "abc123", + }, + } + ) + assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "exception", + [ + TimeoutError(), + HomeAssistantError("Boom"), + Exception("Boom"), + ], +) +async def test_backup_start_exception( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, @@ -290,4 +376,135 @@ async def test_backup_start_excepion( side_effect=exception, ): await client.send_json_auto_id({"type": "backup/start"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "with_hassio", + [ + pytest.param(True, id="with_hassio"), + pytest.param(False, id="without_hassio"), + ], +) +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + with_hassio: bool, +) -> None: + """Test getting backup agents info.""" + await setup_backup_integration(hass, with_hassio=with_hassio) + hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")} + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "with_hassio", + [ + pytest.param(True, id="with_hassio"), + pytest.param(False, id="without_hassio"), + ], +) +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + with_hassio: bool, +) -> None: + """Test backup agents list backups details.""" + await setup_backup_integration(hass, with_hassio=with_hassio) + hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")} + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/list_backups"}) + assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "with_hassio", + [ + pytest.param(True, id="with_hassio"), + pytest.param(False, id="without_hassio"), + ], +) +async def test_agents_download( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + with_hassio: bool, +) -> None: + """Test WS command to start downloading a backup.""" + await setup_backup_integration(hass, with_hassio=with_hassio) + hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")} + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id( + { + "type": "backup/agents/download", + "slug": "abc123", + "agent": "domain.test", + "backup_id": "abc123", + } + ) + with patch.object(BackupAgentTest, "async_download_backup") as download_mock: + assert await client.receive_json() == snapshot + assert download_mock.call_args[1] == { + "id": "abc123", + "path": Path(hass.config.path("backup"), "abc123.tar"), + } + + +async def test_agents_download_exception( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test WS command to start downloading a backup throwing an exception.""" + await setup_backup_integration(hass) + hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")} + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id( + { + "type": "backup/agents/download", + "slug": "abc123", + "agent": "domain.test", + "backup_id": "abc123", + } + ) + with patch.object(BackupAgentTest, "async_download_backup") as download_mock: + download_mock.side_effect = Exception("Boom") + assert await client.receive_json() == snapshot + + +async def test_agents_download_unknown_agent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test downloading a backup with an unknown agent.""" + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id( + { + "type": "backup/agents/download", + "slug": "abc123", + "agent": "domain.test", + "backup_id": "abc123", + } + ) + assert await client.receive_json() == snapshot