Skip to content

Commit

Permalink
Add backup platform support (home-assistant#68182)
Browse files Browse the repository at this point in the history
  • Loading branch information
ludeeus authored Mar 15, 2022
1 parent 2aaeb1f commit 6f61ed8
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 52 deletions.
1 change: 1 addition & 0 deletions .core_files.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand Down
77 changes: 70 additions & 7 deletions homeassistant/components/backup/manager.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
"""Backup manager for the Backup integration."""
from __future__ import annotations

import asyncio
from dataclasses import asdict, dataclass
import hashlib
import json
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
Expand All @@ -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."""

Expand All @@ -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."""
Expand All @@ -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)):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
200 changes: 155 additions & 45 deletions tests/components/backup/test_manager.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)

0 comments on commit 6f61ed8

Please sign in to comment.