From 6f61ed8799014ddb87d34f7e7070958a6fe30f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 15 Mar 2022 22:46:02 +0100 Subject: [PATCH] Add backup platform support (#68182) --- .core_files.yaml | 1 + homeassistant/components/backup/manager.py | 77 +++++++- tests/components/backup/test_manager.py | 200 ++++++++++++++++----- 3 files changed, 226 insertions(+), 52 deletions(-) diff --git a/.core_files.yaml b/.core_files.yaml index ebc3ff376f8ae0..6f7b57c78690f8 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -49,6 +49,7 @@ components: &components - homeassistant/components/alexa/* - homeassistant/components/auth/* - homeassistant/components/automation/* + - homeassistant/components/backup/* - homeassistant/components/cloud/* - homeassistant/components/config/* - homeassistant/components/configurator/* diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 0344ffa4543eff..eee9919e71102e 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1,6 +1,7 @@ """Backup manager for the Backup integration.""" from __future__ import annotations +import asyncio from dataclasses import asdict, dataclass import hashlib import json @@ -8,16 +9,17 @@ import tarfile from tarfile import TarError from tempfile import TemporaryDirectory -from typing import Any +from typing import Any, Protocol from securetar import SecureTarFile, atomic_contents_add from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import integration_platform from homeassistant.util import dt, json as json_util -from .const import EXCLUDE_FROM_BACKUP, LOGGER +from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER @dataclass @@ -35,6 +37,16 @@ def as_dict(self) -> dict: return {**asdict(self), "path": self.path.as_posix()} +class BackupPlatformProtocol(Protocol): + """Define the format that backup platforms can have.""" + + async def async_pre_backup(self, hass: HomeAssistant) -> None: + """Perform operations before a backup starts.""" + + async def async_post_backup(self, hass: HomeAssistant) -> None: + """Perform operations after a backup finishes.""" + + class BackupManager: """Backup manager for the Backup integration.""" @@ -44,14 +56,41 @@ def __init__(self, hass: HomeAssistant) -> None: self.backup_dir = Path(hass.config.path("backups")) self.backing_up = False self.backups: dict[str, Backup] = {} - self.loaded = False + self.platforms: dict[str, BackupPlatformProtocol] = {} + self.loaded_backups = False + self.loaded_platforms = False + + async def _add_platform( + self, + hass: HomeAssistant, + integration_domain: str, + platform: BackupPlatformProtocol, + ) -> None: + """Add a platform to the backup manager.""" + 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 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)) self.backups = backups - self.loaded = True + self.loaded_backups = True + + async def load_platforms(self) -> None: + """Load backup platforms.""" + await integration_platform.async_process_integration_platforms( + self.hass, DOMAIN, self._add_platform + ) + LOGGER.debug("Loaded %s platforms", len(self.platforms)) + self.loaded_platforms = True def _read_backups(self) -> dict[str, Backup]: """Read backups from disk.""" @@ -75,14 +114,14 @@ def _read_backups(self) -> dict[str, Backup]: async def get_backups(self) -> dict[str, Backup]: """Return backups.""" - if not self.loaded: + if not self.loaded_backups: await self.load_backups() return self.backups async def get_backup(self, slug: str) -> Backup | None: """Return a backup.""" - if not self.loaded: + if not self.loaded_backups: await self.load_backups() if not (backup := self.backups.get(slug)): @@ -113,8 +152,22 @@ async def generate_backup(self) -> Backup: if self.backing_up: raise HomeAssistantError("Backup already in progress") + if not self.loaded_platforms: + await self.load_platforms() + try: self.backing_up = True + pre_backup_results = await asyncio.gather( + *( + platform.async_pre_backup(self.hass) + for platform in self.platforms.values() + ), + return_exceptions=True, + ) + for result in pre_backup_results: + if isinstance(result, Exception): + raise result + backup_name = f"Core {HAVERSION}" date_str = dt.now().isoformat() slug = _generate_slug(date_str, backup_name) @@ -146,12 +199,22 @@ async def generate_backup(self) -> Backup: path=tar_file_path, size=round(tar_file_path.stat().st_size / 1_048_576, 2), ) - if self.loaded: + if self.loaded_backups: self.backups[slug] = backup LOGGER.debug("Generated new backup with slug %s", slug) return backup finally: self.backing_up = False + post_backup_results = await asyncio.gather( + *( + platform.async_post_backup(self.hass) + for platform in self.platforms.values() + ), + return_exceptions=True, + ) + for result in post_backup_results: + if isinstance(result, Exception): + raise result def _generate_backup_contents( self, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index b2e8923263db36..7edd64e0cb0982 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -1,15 +1,77 @@ """Tests for the Backup integration.""" +from __future__ import annotations + from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from homeassistant.components.backup import BackupManager +from homeassistant.components.backup.manager import BackupPlatformProtocol from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component from .common import TEST_BACKUP +from tests.common import MockPlatform, mock_platform + + +async def _mock_backup_generation(manager: BackupManager): + """Mock backup generator.""" + + def _mock_iterdir(path: Path) -> list[Path]: + if not path.name.endswith("testing_config"): + return [] + return [ + Path("test.txt"), + Path(".DS_Store"), + Path(".storage"), + ] + + with patch("tarfile.open", MagicMock()) as mocked_tarfile, patch( + "pathlib.Path.iterdir", _mock_iterdir + ), patch("pathlib.Path.stat", MagicMock(st_size=123)), patch( + "pathlib.Path.is_file", lambda x: x.name != ".storage" + ), patch( + "pathlib.Path.is_dir", + lambda x: x.name == ".storage", + ), patch( + "pathlib.Path.exists", + lambda x: x != manager.backup_dir, + ), patch( + "pathlib.Path.is_symlink", + lambda _: False, + ), patch( + "pathlib.Path.mkdir", + MagicMock(), + ), patch( + "homeassistant.components.backup.manager.json_util.save_json" + ) as mocked_json_util, patch( + "homeassistant.components.backup.manager.HAVERSION", + "2025.1.0", + ): + await manager.generate_backup() + + assert mocked_json_util.call_count == 1 + assert mocked_json_util.call_args[0][1]["homeassistant"] == { + "version": "2025.1.0" + } + + assert ( + manager.backup_dir.as_posix() + in mocked_tarfile.call_args_list[0].kwargs["name"] + ) + + +async def _setup_mock_domain( + hass: HomeAssistant, + platform: BackupPlatformProtocol | None = None, +) -> None: + """Set up a mock domain.""" + mock_platform(hass, "some_domain.backup", platform or MockPlatform()) + assert await async_setup_component(hass, "some_domain", {}) + async def test_constructor(hass: HomeAssistant) -> None: """Test BackupManager constructor.""" @@ -59,7 +121,7 @@ async def test_removing_backup( """Test removing backup.""" manager = BackupManager(hass) manager.backups = {TEST_BACKUP.slug: TEST_BACKUP} - manager.loaded = True + manager.loaded_backups = True with patch("pathlib.Path.exists", return_value=True): await manager.remove_backup(TEST_BACKUP.slug) @@ -84,7 +146,7 @@ async def test_getting_backup_that_does_not_exist( """Test getting backup that does not exist.""" manager = BackupManager(hass) manager.backups = {TEST_BACKUP.slug: TEST_BACKUP} - manager.loaded = True + manager.loaded_backups = True with patch("pathlib.Path.exists", return_value=False): backup = await manager.get_backup(TEST_BACKUP.slug) @@ -110,50 +172,98 @@ async def test_generate_backup( ) -> None: """Test generate backup.""" manager = BackupManager(hass) - manager.loaded = True + manager.loaded_backups = True - def _mock_iterdir(path: Path) -> list[Path]: - if not path.name.endswith("testing_config"): - return [] - return [ - Path("test.txt"), - Path(".DS_Store"), - Path(".storage"), - ] + await _mock_backup_generation(manager) - with patch("tarfile.open", MagicMock()) as mocked_tarfile, patch( - "pathlib.Path.iterdir", _mock_iterdir - ), patch("pathlib.Path.stat", MagicMock(st_size=123)), patch( - "pathlib.Path.is_file", lambda x: x.name != ".storage" - ), patch( - "pathlib.Path.is_dir", - lambda x: x.name == ".storage", - ), patch( - "pathlib.Path.exists", - lambda x: x != manager.backup_dir, - ), patch( - "pathlib.Path.is_symlink", - lambda _: False, - ), patch( - "pathlib.Path.mkdir", - MagicMock(), - ), patch( - "homeassistant.components.backup.manager.json_util.save_json" - ) as mocked_json_util, patch( - "homeassistant.components.backup.manager.HAVERSION", - "2025.1.0", - ): - await manager.generate_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 mocked_json_util.call_count == 1 - assert mocked_json_util.call_args[0][1]["homeassistant"] == { - "version": "2025.1.0" - } - assert ( - manager.backup_dir.as_posix() - in mocked_tarfile.call_args_list[0].kwargs["name"] - ) +async def test_loading_platforms( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test loading backup platforms.""" + manager = BackupManager(hass) - assert "Generated new backup with slug " in caplog.text - assert "Creating backup directory" in caplog.text + assert not manager.loaded_platforms + assert not manager.platforms + + await _setup_mock_domain( + hass, + Mock( + async_pre_backup=AsyncMock(), + async_post_backup=AsyncMock(), + ), + ) + await manager.load_platforms() + + assert manager.loaded_platforms + assert len(manager.platforms) == 1 + + assert "Loaded 1 platforms" in caplog.text + + +async def test_not_loading_bad_platforms( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test loading backup platforms.""" + manager = BackupManager(hass) + + assert not manager.loaded_platforms + assert not manager.platforms + + await _setup_mock_domain(hass) + await manager.load_platforms() + + assert manager.loaded_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 + ) + + +async def test_exception_plaform_pre(hass: HomeAssistant) -> None: + """Test exception in pre step.""" + manager = BackupManager(hass) + manager.loaded_backups = True + + async def _mock_step(hass: HomeAssistant) -> None: + raise HomeAssistantError("Test exception") + + await _setup_mock_domain( + hass, + Mock( + async_pre_backup=_mock_step, + async_post_backup=AsyncMock(), + ), + ) + + with pytest.raises(HomeAssistantError): + await _mock_backup_generation(manager) + + +async def test_exception_plaform_post(hass: HomeAssistant) -> None: + """Test exception in post step.""" + manager = BackupManager(hass) + manager.loaded_backups = True + + async def _mock_step(hass: HomeAssistant) -> None: + raise HomeAssistantError("Test exception") + + await _setup_mock_domain( + hass, + Mock( + async_pre_backup=AsyncMock(), + async_post_backup=_mock_step, + ), + ) + + with pytest.raises(HomeAssistantError): + await _mock_backup_generation(manager)