diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 29dbe6f9e6a..52d68661b88 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -8,14 +8,17 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType -from .agent import BackupAgent, BackupAgentPlatformProtocol -from .const import DOMAIN, LOGGER +from .agent import BackupAgent, BackupAgentPlatformProtocol, LocalBackupAgent +from .const import DATA_MANAGER, DOMAIN from .http import async_register_http_views from .manager import ( Backup, BackupManager, BackupPlatformProtocol, + BackupProgress, + BackupReaderWriter, CoreBackupReaderWriter, + NewBackup, ) from .models import BackupUploadMetadata, BaseBackup from .websocket import async_register_websocket_handlers @@ -25,8 +28,12 @@ __all__ = [ "BackupAgent", "BackupAgentPlatformProtocol", "BackupPlatformProtocol", + "BackupProgress", + "BackupReaderWriter", "BackupUploadMetadata", "BaseBackup", + "LocalBackupAgent", + "NewBackup", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -36,22 +43,20 @@ SERVICE_CREATE_SCHEMA = vol.Schema({vol.Optional(CONF_PASSWORD): str}) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Backup integration.""" - hass.data[DOMAIN] = backup_manager = BackupManager( - hass, CoreBackupReaderWriter(hass) - ) - await backup_manager.async_setup() - with_hassio = is_hassio(hass) - async_register_websocket_handlers(hass, with_hassio) + if not with_hassio: + backup_manager = BackupManager(hass, CoreBackupReaderWriter(hass)) + else: + # pylint: disable-next=import-outside-toplevel, hass-component-root-import + from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter - if with_hassio: - if DOMAIN in config: - LOGGER.error( - "The backup integration is not supported on this installation method, " - "please remove it from your configuration" - ) - return True + backup_manager = BackupManager(hass, SupervisorBackupReaderWriter(hass)) + + hass.data[DATA_MANAGER] = backup_manager + await backup_manager.async_setup() + + async_register_websocket_handlers(hass, with_hassio) async def async_handle_create_service(call: ServiceCall) -> None: """Service handler for creating backups.""" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index d4ca8e1e377..eceb255b8c5 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -21,7 +21,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> if with_hassio: websocket_api.async_register_command(hass, handle_backup_end) websocket_api.async_register_command(hass, handle_backup_start) - return websocket_api.async_register_command(hass, handle_details) websocket_api.async_register_command(hass, handle_info) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py new file mode 100644 index 00000000000..a7ae5ad3ee0 --- /dev/null +++ b/homeassistant/components/hassio/backup.py @@ -0,0 +1,170 @@ +"""Backup functionality for supervised installations.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from aiohasupervisor import backups as supervisor_backups + +from homeassistant.components.backup import ( + BackupAgent, + BackupProgress, + BackupReaderWriter, + BackupUploadMetadata, + BaseBackup, + LocalBackupAgent, + NewBackup, +) +from homeassistant.core import HomeAssistant + +from .handler import get_supervisor_client + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return the hassio backup agents.""" + return [SupervisorLocalBackupAgent(hass)] + + +class SupervisorLocalBackupAgent(LocalBackupAgent): + """Local backup agent for supervised installations.""" + + name = "local" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the backup agent.""" + super().__init__() + self._hass = hass + self._backup_dir = Path("/backups") + + async def async_download_backup( + self, + backup_id: str, + *, + path: Path, + **kwargs: Any, + ) -> None: + """Download a backup file.""" + raise NotImplementedError + + async def async_upload_backup( + self, + *, + path: Path, + metadata: BackupUploadMetadata, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + await get_supervisor_client(self._hass).backups.reload() + + async def async_list_backups(self, **kwargs: Any) -> list[BaseBackup]: + """List backups.""" + return [ + BaseBackup( + backup_id=backup.slug, + date=backup.date.isoformat(), + name=backup.name, + protected=backup.protected, + size=backup.size, + ) + for backup in await get_supervisor_client(self._hass).backups.list() + ] + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> BaseBackup | None: + """Return a backup.""" + backups = await self.async_list_backups() + for backup in backups: + if backup.backup_id == backup_id: + return backup + return None + + def get_backup_path(self, backup_id: str) -> Path: + """Return the local path to a backup.""" + return self._backup_dir / f"{backup_id}.tar" + + async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: + """Remove a backup.""" + raise NotImplementedError + + +class SupervisorBackupReaderWriter(BackupReaderWriter): + """Class for reading and writing backups in supervised installations.""" + + temp_backup_dir = Path("/cloud_backups") + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the backup reader/writer.""" + self._hass = hass + + async def async_create_backup( + self, + *, + addons_included: list[str] | None, + agent_ids: list[str], + database_included: bool, + backup_name: str, + folders_included: list[str] | None, + on_progress: Callable[[BackupProgress], None] | None, + password: str | None, + ) -> tuple[NewBackup, asyncio.Task[tuple[BaseBackup, Path]]]: + """Create a backup.""" + addons_included_set = set(addons_included) if addons_included else None + folders_included_set = set(folders_included) if folders_included else None + + client = get_supervisor_client(self._hass) + backup = await client.backups.partial_backup( + supervisor_backups.PartialBackupOptions( + addons=addons_included_set, + folders=folders_included_set, # type: ignore[arg-type] + homeassistant=True, + name=backup_name, + password=password, + compressed=True, + location=None, + homeassistant_exclude_database=not database_included, + background=True, + ) + ) + backup_task = self._hass.async_create_task( + self._async_wait_for_backup(backup), + name="backup_manager_create_backup", + eager_start=False, # To ensure the task is not started before we return + ) + + return (NewBackup(backup_id=backup.job_id), backup_task) + + async def _async_wait_for_backup( + self, backup: supervisor_backups.NewBackup + ) -> tuple[BaseBackup, Path]: + """Wait for a backup to complete.""" + raise NotImplementedError + + async def async_restore_backup( + self, + backup_id: str, + *, + agent_id: str, + password: str | None, + **kwargs: Any, + ) -> None: + """Restore a backup.""" + client = get_supervisor_client(self._hass) + await client.backups.partial_restore( + backup_id, + supervisor_backups.PartialRestoreOptions( + addons=None, + folders=None, + homeassistant=True, + password=password, + background=True, + ), + ) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 80c2b73a3de..5405744582f 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -135,10 +135,12 @@ async def setup_backup_integration( result = await async_setup_component(hass, DOMAIN, configuration or {}) await hass.async_block_till_done() - if with_hassio or not backups: + if not backups: return result for agent_id, agent_backups in backups.items(): + if with_hassio and agent_id == LOCAL_AGENT_ID: + continue agent = hass.data[DATA_MANAGER].backup_agents[agent_id] agent._backups = {backups.backup_id: backups for backups in agent_backups} if agent_id == LOCAL_AGENT_ID: diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 09ae86bda25..4a98891b812 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -340,166 +340,241 @@ # --- # name: test_delete[with_hassio-remote_agents0-backups0] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents0-backups0].1 dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 2, - 'success': False, + 'result': None, + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents0-backups0].2 dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 3, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents1-backups1] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents1-backups1].1 dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 2, - 'success': False, + 'result': None, + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents1-backups1].2 dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 3, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents2-backups2] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + dict({ + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'date': '1970-01-01T00:00:00.000Z', + 'name': 'Test', + 'protected': False, + 'size': 0.0, + }), + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents2-backups2].1 dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 2, - 'success': False, + 'result': None, + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents2-backups2].2 dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 3, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + dict({ + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'date': '1970-01-01T00:00:00.000Z', + 'name': 'Test', + 'protected': False, + 'size': 0.0, + }), + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents3-backups3] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + dict({ + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'date': '1980-01-01T00:00:00.000Z', + 'name': 'Test 2', + 'protected': False, + 'size': 1.0, + }), + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents3-backups3].1 dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 2, - 'success': False, + 'result': None, + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents3-backups3].2 dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 3, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + dict({ + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'date': '1980-01-01T00:00:00.000Z', + 'name': 'Test 2', + 'protected': False, + 'size': 1.0, + }), + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents4-backups4] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + dict({ + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'date': '1970-01-01T00:00:00.000Z', + 'name': 'Test', + 'protected': False, + 'size': 0.0, + }), + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents4-backups4].1 dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 2, - 'success': False, + 'result': None, + 'success': True, 'type': 'result', }) # --- # name: test_delete[with_hassio-remote_agents4-backups4].2 dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 3, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + dict({ + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'date': '1970-01-01T00:00:00.000Z', + 'name': 'Test', + 'protected': False, + 'size': 0.0, + }), + ]), + }), + 'success': True, 'type': 'result', }) # --- @@ -756,56 +831,79 @@ # --- # name: test_details[with_hassio-remote_agents0-backups0] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': None, + }), + 'success': True, 'type': 'result', }) # --- # name: test_details[with_hassio-remote_agents1-backups1] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': None, + }), + 'success': True, 'type': 'result', }) # --- # name: test_details[with_hassio-remote_agents2-backups2] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': dict({ + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'date': '1970-01-01T00:00:00.000Z', + 'name': 'Test', + 'protected': False, + 'size': 0.0, + }), + }), + 'success': True, 'type': 'result', }) # --- # name: test_details[with_hassio-remote_agents3-backups3] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': None, + }), + 'success': True, 'type': 'result', }) # --- # name: test_details[with_hassio-remote_agents4-backups4] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': dict({ + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'date': '1970-01-01T00:00:00.000Z', + 'name': 'Test', + 'protected': False, + 'size': 0.0, + }), + }), + 'success': True, 'type': 'result', }) # --- @@ -932,8 +1030,8 @@ # name: test_generate[with_hassio-None] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'home_assistant_error', + 'message': 'Invalid agent selected', }), 'id': 1, 'success': False, @@ -943,8 +1041,8 @@ # name: test_generate[with_hassio-data1] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'home_assistant_error', + 'message': 'Invalid agent selected', }), 'id': 1, 'success': False, @@ -954,8 +1052,8 @@ # name: test_generate[with_hassio-data2] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'home_assistant_error', + 'message': 'Invalid agent selected', }), 'id': 1, 'success': False, @@ -1047,45 +1145,77 @@ # --- # name: test_info[with_hassio-remote_agents0-remote_backups0] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_info[with_hassio-remote_agents1-remote_backups1] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_info[with_hassio-remote_agents2-remote_backups2] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + dict({ + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'date': '1970-01-01T00:00:00.000Z', + 'name': 'Test', + 'protected': False, + 'size': 0.0, + }), + ]), + }), + 'success': True, 'type': 'result', }) # --- # name: test_info[with_hassio-remote_agents3-remote_backups3] dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backing_up': False, + 'backups': list([ + dict({ + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'date': '1980-01-01T00:00:00.000Z', + 'name': 'Test 2', + 'protected': False, + 'size': 1.0, + }), + ]), + }), + 'success': True, 'type': 'result', }) # --- @@ -1235,8 +1365,8 @@ # name: test_restore_local_agent[with_hassio-backups0] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'unknown_error', + 'message': 'Unknown error', }), 'id': 1, 'success': False, @@ -1249,8 +1379,8 @@ # name: test_restore_local_agent[with_hassio-backups1] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'unknown_error', + 'message': 'Unknown error', }), 'id': 1, 'success': False, @@ -1288,8 +1418,8 @@ # name: test_restore_remote_agent[with_hassio-remote_agents0-backups0] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', }), 'id': 1, 'success': False, @@ -1302,8 +1432,8 @@ # name: test_restore_remote_agent[with_hassio-remote_agents1-backups1] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'unknown_error', + 'message': 'Unknown error', }), 'id': 1, 'success': False, diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index 9bef2b2b8d2..0056f711c56 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -23,10 +23,6 @@ async def test_setup_with_hassio( ) manager = hass.data[DATA_MANAGER] assert not manager.backup_agents - assert ( - "The backup integration is not supported on this installation method, please" - " remove it from your configuration" - ) in caplog.text @pytest.mark.parametrize("service_data", [None, {}, {"password": "abc123"}]) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 2cc028da78b..022d8ee7ba6 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -603,17 +603,18 @@ async def test_agents_list_backups( @pytest.mark.parametrize( - "with_hassio", + ("with_hassio", "download_path"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + pytest.param(True, "/cloud_backups", id="with_hassio"), + pytest.param(False, "{config_dir}/tmp_backups", id="without_hassio"), ], ) async def test_agents_download( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, with_hassio: bool, + download_path: str, + snapshot: SnapshotAssertion, ) -> None: """Test WS command to start downloading a backup.""" await setup_backup_integration(hass, with_hassio=with_hassio) @@ -629,12 +630,13 @@ async def test_agents_download( "backup_id": "abc123", } ) + expected_path = Path( + download_path.format(config_dir=hass.config.config_dir), "abc123.tar" + ) with patch.object(BackupAgentTest, "async_download_backup") as download_mock: assert await client.receive_json() == snapshot assert download_mock.call_args[0] == ("abc123",) - assert download_mock.call_args[1] == { - "path": Path(hass.config.path("tmp_backups"), "abc123.tar"), - } + assert download_mock.call_args[1] == {"path": expected_path} async def test_agents_download_exception(