diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index b479f33422d..26cadb6c7f2 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -39,7 +39,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_handle_create_service(call: ServiceCall) -> None: """Service handler for creating backups.""" - await backup_manager.async_create_backup(on_progress=None) + await backup_manager.async_create_backup( + addons_included=None, + database_included=True, + folders_included=None, + name=None, + on_progress=None, + ) if backup_task := backup_manager.backup_task: await backup_task diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index a10fec80360..4f916d94650 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -26,3 +26,8 @@ EXCLUDE_FROM_BACKUP = [ "OZW_Log.txt", "tts/*", ] + +EXCLUDE_DATABASE_FROM_BACKUP = [ + "home-assistant_v2.db", + "home-assistant_v2.db-wal", +] diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index c0c033cd1fe..671edb933ae 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -32,7 +32,7 @@ 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 .const import DOMAIN, EXCLUDE_DATABASE_FROM_BACKUP, EXCLUDE_FROM_BACKUP, LOGGER from .models import BackupUploadMetadata, BaseBackup BUF_SIZE = 2**20 * 4 # 4MB @@ -180,10 +180,18 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]): async def async_create_backup( self, *, + addons_included: list[str] | None, + database_included: bool, + folders_included: list[str] | None, + name: str | None, on_progress: Callable[[BackupProgress], None] | None, **kwargs: Any, ) -> NewBackup: - """Generate a backup.""" + """Initiate generating a backup. + + :param on_progress: A callback that will be called with the progress of the + backup. + """ @abc.abstractmethod async def async_get_backups(self, **kwargs: Any) -> dict[str, _BackupT]: @@ -380,17 +388,29 @@ class BackupManager(BaseBackupManager[Backup]): async def async_create_backup( self, *, + addons_included: list[str] | None, + database_included: bool, + folders_included: list[str] | None, + name: str | None, on_progress: Callable[[BackupProgress], None] | None, **kwargs: Any, ) -> NewBackup: - """Generate a backup.""" + """Initiate generating a backup.""" if self.backup_task: raise HomeAssistantError("Backup already in progress") - backup_name = f"Core {HAVERSION}" + backup_name = name or f"Core {HAVERSION}" date_str = dt_util.now().isoformat() slug = _generate_slug(date_str, backup_name) self.backup_task = self.hass.async_create_task( - self._async_create_backup(backup_name, date_str, slug, on_progress), + self._async_create_backup( + addons_included=addons_included, + backup_name=backup_name, + database_included=database_included, + date_str=date_str, + folders_included=folders_included, + on_progress=on_progress, + slug=slug, + ), name="backup_manager_create_backup", eager_start=False, # To ensure the task is not started before we return ) @@ -398,10 +418,14 @@ class BackupManager(BaseBackupManager[Backup]): async def _async_create_backup( self, + *, + addons_included: list[str] | None, + database_included: bool, backup_name: str, date_str: str, - slug: str, + folders_included: list[str] | None, on_progress: Callable[[BackupProgress], None] | None, + slug: str, ) -> Backup: """Generate a backup.""" success = False @@ -414,7 +438,10 @@ class BackupManager(BaseBackupManager[Backup]): "date": date_str, "type": "partial", "folders": ["homeassistant"], - "homeassistant": {"version": HAVERSION}, + "homeassistant": { + "exclude_database": not database_included, + "version": HAVERSION, + }, "compressed": True, } tar_file_path = Path(self.backup_dir, f"{backup_data['slug']}.tar") @@ -422,6 +449,7 @@ class BackupManager(BaseBackupManager[Backup]): self._mkdir_and_generate_backup_contents, tar_file_path, backup_data, + database_included, ) backup = Backup( slug=slug, @@ -445,12 +473,17 @@ class BackupManager(BaseBackupManager[Backup]): self, tar_file_path: Path, backup_data: dict[str, Any], + database_included: bool, ) -> int: """Generate backup contents and return the size.""" if not self.backup_dir.exists(): LOGGER.debug("Creating backup directory") self.backup_dir.mkdir() + excludes = EXCLUDE_FROM_BACKUP + if not database_included: + excludes = excludes + EXCLUDE_DATABASE_FROM_BACKUP + outer_secure_tarfile = SecureTarFile( tar_file_path, "w", gzip=False, bufsize=BUF_SIZE ) @@ -467,7 +500,7 @@ class BackupManager(BaseBackupManager[Backup]): atomic_contents_add( tar_file=core_tar, origin_path=Path(self.hass.config.path()), - excludes=EXCLUDE_FROM_BACKUP, + excludes=excludes, arcname="data", ) diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 876f6c2bff5..04b16bad304 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -112,7 +112,15 @@ async def handle_restore( @websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "backup/generate"}) +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/generate", + vol.Optional("addons_included"): [str], + vol.Optional("database_included", default=True): bool, + vol.Optional("folders_included"): [str], + vol.Optional("name"): str, + } +) @websocket_api.async_response async def handle_create( hass: HomeAssistant, @@ -124,7 +132,13 @@ async def handle_create( def on_progress(progress: BackupProgress) -> None: connection.send_message(websocket_api.event_message(msg["id"], progress)) - backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress) + backup = await hass.data[DATA_MANAGER].async_create_backup( + addons_included=msg.get("addons_included"), + database_included=msg["database_included"], + folders_included=msg.get("folders_included"), + name=msg.get("name"), + on_progress=on_progress, + ) connection.send_result(msg["id"], backup) diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index 631c774e63c..aeb5f76234a 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -43,11 +43,12 @@ def mock_backup_generation_fixture( Path("test.txt"), Path(".DS_Store"), Path(".storage"), + Path("home-assistant_v2.db"), ] with ( patch("pathlib.Path.iterdir", _mock_iterdir), - patch("pathlib.Path.stat", MagicMock(st_size=123)), + patch("pathlib.Path.stat", return_value=MagicMock(st_size=123)), patch("pathlib.Path.is_file", lambda x: x.name != ".storage"), patch( "pathlib.Path.is_dir", diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 9edd1216203..427748083a2 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -403,6 +403,26 @@ 'type': 'event', }) # --- +# name: test_generate_without_hassio[params0-expected_extra_call_params0] + dict({ + 'id': 1, + 'result': dict({ + 'slug': 'abc123', + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate_without_hassio[params1-expected_extra_call_params1] + dict({ + 'id': 1, + 'result': dict({ + 'slug': 'abc123', + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_info[with_hassio] dict({ 'error': dict({ diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 9cc0067cf1a..5b3396291ad 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, mock_open, patch import aiohttp from multidict import CIMultiDict, CIMultiDictProxy @@ -24,9 +24,20 @@ from .common import TEST_BACKUP, BackupAgentTest from tests.common import MockPlatform, mock_platform +_EXPECTED_FILES_WITH_DATABASE = { + True: ["test.txt", ".storage", "home-assistant_v2.db"], + False: ["test.txt", ".storage"], +} + async def _mock_backup_generation( - manager: BackupManager, mocked_json_bytes: Mock, mocked_tarfile: Mock + hass: HomeAssistant, + manager: BackupManager, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, + *, + database_included: bool = True, + name: str | None = "Core 2025.1.0", ) -> None: """Mock backup generator.""" @@ -37,7 +48,13 @@ async def _mock_backup_generation( progress.append(_progress) assert manager.backup_task is None - await manager.async_create_backup(on_progress=on_progress) + await manager.async_create_backup( + addons_included=[], + database_included=database_included, + folders_included=[], + name=name, + on_progress=on_progress, + ) assert manager.backup_task is not None assert progress == [] @@ -47,8 +64,26 @@ async def _mock_backup_generation( assert mocked_json_bytes.call_count == 1 backup_json_dict = mocked_json_bytes.call_args[0][0] assert isinstance(backup_json_dict, dict) - assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} + assert backup_json_dict == { + "compressed": True, + "date": ANY, + "folders": ["homeassistant"], + "homeassistant": { + "exclude_database": not database_included, + "version": "2025.1.0", + }, + "name": name, + "slug": ANY, + "type": "partial", + } assert manager.backup_dir.as_posix() in str(mocked_tarfile.call_args_list[0][0][0]) + outer_tar = mocked_tarfile.return_value + core_tar = outer_tar.create_inner_tar.return_value.__enter__.return_value + expected_files = [call(hass.config.path(), arcname="data", recursive=False)] + [ + call(file, arcname=f"data/{file}", recursive=False) + for file in _EXPECTED_FILES_WITH_DATABASE[database_included] + ] + assert core_tar.add.call_args_list == expected_files return backup @@ -158,22 +193,35 @@ async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None: manager = BackupManager(hass) manager.backup_task = hass.async_create_task(event.wait()) with pytest.raises(HomeAssistantError, match="Backup already in progress"): - await manager.async_create_backup(on_progress=None) + await manager.async_create_backup( + addons_included=[], + database_included=True, + folders_included=[], + name=None, + on_progress=None, + ) event.set() @pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "params", + [{}, {"database_included": True, "name": "abc123"}, {"database_included": False}], +) async def test_async_create_backup( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mocked_json_bytes: Mock, mocked_tarfile: Mock, + params: dict, ) -> None: """Test generate backup.""" manager = BackupManager(hass) manager.loaded_backups = True - await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + await _mock_backup_generation( + hass, manager, mocked_json_bytes, mocked_tarfile, **params + ) assert "Generated new backup with slug " in caplog.text assert "Creating backup directory" in caplog.text @@ -280,7 +328,9 @@ async def test_syncing_backup( await manager.load_platforms() await hass.async_block_till_done() - backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + backup = await _mock_backup_generation( + hass, manager, mocked_json_bytes, mocked_tarfile + ) with ( patch( @@ -338,7 +388,9 @@ async def test_syncing_backup_with_exception( await manager.load_platforms() await hass.async_block_till_done() - backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + backup = await _mock_backup_generation( + hass, manager, mocked_json_bytes, mocked_tarfile + ) with ( patch( @@ -391,7 +443,9 @@ async def test_syncing_backup_no_agents( await manager.load_platforms() await hass.async_block_till_done() - backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + backup = await _mock_backup_generation( + hass, manager, mocked_json_bytes, mocked_tarfile + ) with patch( "homeassistant.components.backup.agent.BackupAgent.async_upload_backup" ) as mocked_async_upload_backup: @@ -419,7 +473,7 @@ async def test_exception_plaform_pre( ) with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + await _mock_backup_generation(hass, manager, mocked_json_bytes, mocked_tarfile) async def test_exception_plaform_post( @@ -442,7 +496,7 @@ async def test_exception_plaform_post( ) with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + await _mock_backup_generation(hass, manager, mocked_json_bytes, mocked_tarfile) async def test_loading_platforms_when_running_async_pre_backup_actions( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 68b6666264f..265de55f855 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1,13 +1,14 @@ """Tests for the Backup integration.""" from pathlib import Path -from unittest.mock import AsyncMock, patch +from unittest.mock import ANY, AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from homeassistant.components.backup.const import DATA_MANAGER +from homeassistant.components.backup.manager import NewBackup from homeassistant.components.backup.models import BaseBackup from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -153,6 +154,60 @@ async def test_generate( assert await client.receive_json() == snapshot +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("params", "expected_extra_call_params"), + [ + ({}, {}), + ( + { + "addons_included": ["ssl"], + "database_included": False, + "folders_included": ["media"], + "name": "abc123", + }, + { + "addons_included": ["ssl"], + "database_included": False, + "folders_included": ["media"], + "name": "abc123", + }, + ), + ], +) +async def test_generate_without_hassio( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + params: dict, + expected_extra_call_params: tuple, +) -> None: + """Test generating a 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.async_block_till_done() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + return_value=NewBackup("abc123"), + ) as generate_backup: + await client.send_json_auto_id({"type": "backup/generate"} | params) + assert await client.receive_json() == snapshot + generate_backup.assert_called_once_with( + **{ + "addons_included": None, + "database_included": True, + "folders_included": None, + "name": None, + "on_progress": ANY, + } + | expected_extra_call_params + ) + + @pytest.mark.parametrize( "with_hassio", [