mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Add backup platform support (#68182)
This commit is contained in:
parent
2aaeb1fa99
commit
6f61ed8799
@ -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/*
|
||||
|
@ -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 @@ from pathlib import Path
|
||||
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 @@ class Backup:
|
||||
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 @@ class BackupManager:
|
||||
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 @@ class BackupManager:
|
||||
|
||||
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 @@ class BackupManager:
|
||||
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 @@ class BackupManager:
|
||||
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,
|
||||
|
@ -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"),
|
||||
]
|
||||
|
||||
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"]
|
||||
)
|
||||
await _mock_backup_generation(manager)
|
||||
|
||||
assert "Generated new backup with slug " in caplog.text
|
||||
assert "Creating backup directory" in caplog.text
|
||||
assert "Loaded 0 platforms" in caplog.text
|
||||
|
||||
|
||||
async def test_loading_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,
|
||||
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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user