diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 79e4a8ff3506d9..02579774a64b23 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -8,7 +8,12 @@ from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType -from .agent import BackupAgent, BackupAgentPlatformProtocol, LocalBackupAgent +from .agent import ( + BackupAgent, + BackupAgentError, + BackupAgentPlatformProtocol, + LocalBackupAgent, +) from .const import DATA_MANAGER, DOMAIN from .http import async_register_http_views from .manager import ( @@ -21,6 +26,7 @@ NewBackup, ) from .models import AddonInfo, AgentBackup, Folder +from .util import read_backup from .websocket import async_register_websocket_handlers __all__ = [ @@ -28,6 +34,7 @@ "AgentBackup", "Backup", "BackupAgent", + "BackupAgentError", "BackupAgentPlatformProtocol", "BackupPlatformProtocol", "BackupProgress", @@ -35,6 +42,7 @@ "Folder", "LocalBackupAgent", "NewBackup", + "read_backup", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py new file mode 100644 index 00000000000000..d29d4663ab5a42 --- /dev/null +++ b/homeassistant/components/cloud/backup.py @@ -0,0 +1,171 @@ +"""Backup platform for the cloud integration.""" + +from __future__ import annotations + +import base64 +import hashlib +from pathlib import Path +from typing import Any + +from aiohttp import ClientResponseError +from hass_nabucasa import Cloud +from hass_nabucasa.cloud_api import ( + async_files_delete_file, + async_files_download_details, + async_files_list, + async_files_upload_details, +) + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.core import HomeAssistant, callback + +from .client import CloudClient +from .const import DATA_CLOUD, DOMAIN + +_STORAGE_BACKUP = "backup" + + +def b64md5(path: Path) -> str: + """Calculate the MD5 hash of a file.""" + with open(path, "rb") as f: + file_hash = hashlib.md5() + while chunk := f.read(8192): + file_hash.update(chunk) + return base64.b64encode(file_hash.digest()).decode() + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return the cloud backup agent.""" + return [CloudBackupAgent(hass=hass, cloud=hass.data[DATA_CLOUD])] + + +class CloudBackupAgent(BackupAgent): + """Cloud backup agent.""" + + name = DOMAIN + + def __init__(self, hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None: + """Initialize the cloud backup sync agent.""" + super().__init__() + self._cloud = cloud + self._hass = hass + + @callback + def _get_backup_filename(self) -> str: + """Return the backup filename.""" + return f"{self._cloud.client.prefs.instance_id}.tar" + + async def async_download_backup( + self, + backup_id: str, + *, + path: Path, + **kwargs: Any, + ) -> None: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :param path: The full file path to download the backup to. + """ + if not self._cloud.is_logged_in: + raise BackupAgentError("Not logged in to cloud") + + if not await self.async_get_backup(backup_id): + raise BackupAgentError("Backup not found") + + details = await async_files_download_details( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=self._get_backup_filename(), + ) + + resp = await self._cloud.websession.get( + details["url"], + raise_for_status=True, + ) + + file = await self._hass.async_add_executor_job(path.open, "wb") + async for chunk, _ in resp.content.iter_chunks(): + await self._hass.async_add_executor_job(file.write, chunk) + + async def async_upload_backup( + self, + *, + path: Path, + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param path: The full file path to the backup that should be uploaded. + :param backup: Metadata about the backup that should be uploaded. + """ + if not backup.protected: + raise BackupAgentError("Cloud backups must be protected") + + if not self._cloud.is_logged_in: + raise BackupAgentError("Not logged in to cloud") + + base64md5hash = await self._hass.async_add_executor_job(b64md5, path) + + details = await async_files_upload_details( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=self._get_backup_filename(), + metadata=backup.as_dict(), + size=backup.size, + base64md5hash=base64md5hash, + ) + + try: + upload_status = await self._cloud.websession.put( + details["url"], + data=await self._hass.async_add_executor_job(path.open, "rb"), + headers=details["headers"], + raise_for_status=True, + ) + + upload_status.raise_for_status() + except ClientResponseError as err: + raise BackupAgentError("Failed to upload backup") from err + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + if not await self.async_get_backup(backup_id): + raise BackupAgentError("Backup not found") + + await async_files_delete_file( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=self._get_backup_filename(), + ) + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP) + + return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + backups = await self.async_list_backups() + + for backup in backups: + if backup.backup_id == backup_id: + return backup + + return None diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 4201cb1b2d44e2..51b0fdeed75c21 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -1,7 +1,12 @@ { "domain": "cloud", "name": "Home Assistant Cloud", - "after_dependencies": ["assist_pipeline", "google_assistant", "alexa"], + "after_dependencies": [ + "alexa", + "assist_pipeline", + "backup", + "google_assistant" + ], "codeowners": ["@home-assistant/cloud"], "dependencies": ["auth", "http", "repairs", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py new file mode 100644 index 00000000000000..2fec90d737beb4 --- /dev/null +++ b/tests/components/cloud/test_backup.py @@ -0,0 +1,203 @@ +"""Test the cloud backup platform.""" + +from collections.abc import AsyncGenerator, Generator +from typing import Any +from unittest.mock import Mock, PropertyMock, patch + +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.components.cloud import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_integration( + hass: HomeAssistant, cloud: MagicMock +) -> AsyncGenerator[None]: + """Set up cloud integration.""" + with patch("homeassistant.components.backup.is_hassio", return_value=False): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local"}, {"agent_id": "cloud.cloud"}], + "syncing": False, + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + mock_list_files: Mock, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + mock_list_files.assert_called_once_with(cloud, storage_type="backup") + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": ["cloud.cloud"], + } + ] + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + ( + "23e64aec", + { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": ["cloud.cloud"], + }, + ), + ( + "12345", + None, + ), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + backup_id: str, + expected_result: dict[str, Any] | None, + mock_list_files: Mock, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + mock_list_files.assert_called_once_with(cloud, storage_type="backup") + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == expected_result + + +async def test_agents_download_not_logged_in( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent download backup, when cloud user is logged in.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/agents/download", + "agent_id": "cloud.cloud", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "backup_agents_download", + "message": "Not logged in to cloud", + } + + +@pytest.fixture +def mock_list_files() -> Generator[MagicMock]: + """Mock list files.""" + with patch( + "homeassistant.components.cloud.backup.async_files_list", spec_set=True + ) as list_files: + list_files.return_value = [ + { + "Key": "462e16810d6841228828d9dd2f9e341e.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", + }, + } + ] + yield list_files + + +@pytest.fixture +def cloud_logged_in(cloud: MagicMock): + """Mock cloud logged in.""" + type(cloud).is_logged_in = PropertyMock(return_value=True) + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_download_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent download backup raises error if not found.""" + client = await hass_ws_client(hass) + backup_id = "1234" + + await client.send_json_auto_id( + { + "type": "backup/agents/download", + "agent_id": "cloud.cloud", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "backup_agents_download", + "message": "Backup not found", + }