Make local backup a backup agent

This commit is contained in:
Erik 2024-11-14 15:46:19 +01:00
parent e8179f7a73
commit 400f792bff
9 changed files with 372 additions and 216 deletions

View file

@ -32,6 +32,7 @@ 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)
await backup_manager.async_setup()
with_hassio = is_hassio(hass)
@ -49,6 +50,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Service handler for creating backups."""
await backup_manager.async_create_backup(
addons_included=None,
# pylint: disable=fixme
# TODO: Don't forget to remove this when the implementation is complete
agent_ids=[], # TODO: Should we default to local?
database_included=True,
folders_included=None,
name=None,

View file

@ -0,0 +1,146 @@
"""Local backup support for Core and Container installations."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import json
from pathlib import Path
import tarfile
from tarfile import TarError
from typing import Any, cast
from homeassistant.core import HomeAssistant
from homeassistant.util.json import json_loads_object
from .agent import BackupAgent, UploadedBackup
from .const import BUF_SIZE, LOGGER
from .models import BackupUploadMetadata
async def async_get_backup_agents(
hass: HomeAssistant,
**kwargs: Any,
) -> list[BackupAgent]:
"""Register the backup agent."""
return [LocalBackupAgent(hass)]
@dataclass(slots=True)
class LocalBackup(UploadedBackup):
"""Local backup class."""
path: Path
def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
return {**asdict(self), "path": self.path.as_posix()}
class LocalBackupAgent(BackupAgent):
"""Define the format that backup agents can have."""
name = "local"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup agent."""
super().__init__()
self._hass = hass
self.backup_dir = Path(hass.config.path("backups"))
self.backups: dict[str, LocalBackup] = {}
self.loaded_backups = False
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 local backups", len(backups))
self.backups = backups
self.loaded_backups = True
def _read_backups(self) -> dict[str, LocalBackup]:
"""Read backups from disk."""
backups: dict[str, LocalBackup] = {}
for backup_path in self.backup_dir.glob("*.tar"):
try:
with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file:
if data_file := backup_file.extractfile("./backup.json"):
data = json_loads_object(data_file.read())
backup = LocalBackup(
id=cast(str, data["slug"]), # Do we need another ID?
slug=cast(str, data["slug"]),
name=cast(str, data["name"]),
date=cast(str, data["date"]),
path=backup_path,
size=round(backup_path.stat().st_size / 1_048_576, 2),
protected=cast(bool, data.get("protected", False)),
)
backups[backup.slug] = backup
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
return backups
async def async_download_backup(
self,
*,
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."""
self.backups[metadata.slug] = LocalBackup(
id=metadata.slug, # Do we need another ID?
slug=metadata.slug,
name=metadata.name,
date=metadata.date,
path=path,
size=round(path.stat().st_size / 1_048_576, 2),
protected=metadata.protected,
)
async def async_list_backups(self, **kwargs: Any) -> list[UploadedBackup]:
"""List backups."""
if not self.loaded_backups:
await self.load_backups()
return list(self.backups.values())
async def async_get_backup(
self, *, slug: str, **kwargs: Any
) -> UploadedBackup | None:
"""Return a backup."""
if not self.loaded_backups:
await self.load_backups()
if not (backup := self.backups.get(slug)):
return None
if not backup.path.exists():
LOGGER.debug(
(
"Removing tracked backup (%s) that does not exists on the expected"
" path %s"
),
backup.slug,
backup.path,
)
self.backups.pop(slug)
return None
return backup
async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Remove a backup."""
if (backup := await self.async_get_backup(slug=slug)) is None:
return
await self._hass.async_add_executor_job(backup.path.unlink, True) # type: ignore[attr-defined]
LOGGER.debug("Removed backup located at %s", backup.path) # type: ignore[attr-defined]
self.backups.pop(slug)

View file

@ -11,6 +11,7 @@ if TYPE_CHECKING:
from .manager import BaseBackupManager
from .models import BaseBackup
BUF_SIZE = 2**20 * 4 # 4MB
DOMAIN = "backup"
DATA_MANAGER: HassKey[BaseBackupManager[BaseBackup]] = HassKey(DOMAIN)
LOGGER = getLogger(__package__)

View file

@ -13,10 +13,9 @@ from pathlib import Path
from queue import SimpleQueue
import shutil
import tarfile
from tarfile import TarError
from tempfile import TemporaryDirectory
import time
from typing import Any, Generic, Protocol, cast
from typing import Any, Generic, Protocol
import aiohttp
from securetar import SecureTarFile, atomic_contents_add
@ -29,13 +28,22 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import integration_platform
from homeassistant.helpers.json import json_bytes
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_DATABASE_FROM_BACKUP, EXCLUDE_FROM_BACKUP, LOGGER
from .const import (
BUF_SIZE,
DOMAIN,
EXCLUDE_DATABASE_FROM_BACKUP,
EXCLUDE_FROM_BACKUP,
LOGGER,
)
from .models import BackupUploadMetadata, BaseBackup
BUF_SIZE = 2**20 * 4 # 4MB
# pylint: disable=fixme
# TODO: Don't forget to remove this when the implementation is complete
LOCAL_AGENT_ID = f"{DOMAIN}.local"
_BackupT = TypeVar("_BackupT", bound=BaseBackup, default=BaseBackup)
@ -51,6 +59,7 @@ class NewBackup:
class Backup(BaseBackup):
"""Backup class."""
agent_ids: list[str]
path: Path
def as_dict(self) -> dict:
@ -84,20 +93,21 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]):
"""Initialize the backup manager."""
self.hass = hass
self.backup_task: asyncio.Task | None = None
self.backups: dict[str, _BackupT] = {}
self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {}
self.backup_agents: dict[str, BackupAgent] = {}
self.syncing = False
async def async_setup(self) -> None:
"""Set up the backup manager."""
await self.load_platforms()
@callback
def _add_platform_pre_post_handlers(
def _add_platform_pre_post_handler(
self,
hass: HomeAssistant,
integration_domain: str,
platform: BackupPlatformProtocol,
) -> None:
"""Add a platform to the backup manager."""
"""Add a backup platform."""
if not hasattr(platform, "async_pre_backup") or not hasattr(
platform, "async_post_backup"
):
@ -107,7 +117,6 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]):
async def _async_add_platform_agents(
self,
hass: HomeAssistant,
integration_domain: str,
platform: BackupAgentPlatformProtocol,
) -> None:
@ -115,16 +124,23 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]):
if not hasattr(platform, "async_get_backup_agents"):
return
agents = await platform.async_get_backup_agents(hass=hass)
agents = await platform.async_get_backup_agents(self.hass)
self.backup_agents.update(
{f"{integration_domain}.{agent.name}": agent for agent in agents}
)
async def _add_platform(
self,
hass: HomeAssistant,
integration_domain: str,
platform: Any,
) -> None:
"""Add a backup platform manager."""
self._add_platform_pre_post_handler(integration_domain, platform)
await self._async_add_platform_agents(integration_domain, platform)
async def async_pre_backup_actions(self, **kwargs: Any) -> None:
"""Perform pre backup actions."""
if not self.loaded_platforms:
await self.load_platforms()
pre_backup_results = await asyncio.gather(
*(
platform.async_pre_backup(self.hass)
@ -138,9 +154,6 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]):
async def async_post_backup_actions(self, **kwargs: Any) -> None:
"""Perform post backup actions."""
if not self.loaded_platforms:
await self.load_platforms()
post_backup_results = await asyncio.gather(
*(
platform.async_post_backup(self.hass)
@ -154,23 +167,14 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]):
async def load_platforms(self) -> None:
"""Load backup platforms."""
if self.loaded_platforms:
return
await integration_platform.async_process_integration_platforms(
self.hass,
DOMAIN,
self._add_platform_pre_post_handlers,
wait_for_platforms=True,
)
await integration_platform.async_process_integration_platforms(
self.hass,
DOMAIN,
self._async_add_platform_agents,
self._add_platform,
wait_for_platforms=True,
)
LOGGER.debug("Loaded %s platforms", len(self.platforms))
LOGGER.debug("Loaded %s agents", len(self.backup_agents))
self.loaded_platforms = True
@abc.abstractmethod
async def async_restore_backup(
@ -187,6 +191,7 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]):
self,
*,
addons_included: list[str] | None,
agent_ids: list[str],
database_included: bool,
folders_included: list[str] | None,
name: str | None,
@ -236,22 +241,34 @@ class BackupManager(BaseBackupManager[Backup]):
"""Initialize the backup manager."""
super().__init__(hass=hass)
self.backup_dir = Path(hass.config.path("backups"))
self.loaded_backups = False
self.temp_backup_dir = Path(hass.config.path("tmp_backups"))
async def async_upload_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Upload a backup."""
await self.load_platforms()
"""Upload a backup to all agents."""
if not self.backup_agents:
return
if not (backup := await self.async_get_backup(slug=slug)):
return
await self._async_upload_backup(
slug=slug,
backup=backup,
agent_ids=list(self.backup_agents.keys()),
)
async def _async_upload_backup(
self,
*,
slug: str,
backup: Backup,
agent_ids: list[str],
) -> None:
"""Upload a backup to selected agents."""
self.syncing = True
sync_backup_results = await asyncio.gather(
*(
agent.async_upload_backup(
self.backup_agents[agent_id].async_upload_backup(
path=backup.path,
metadata=BackupUploadMetadata(
homeassistant=HAVERSION,
@ -262,80 +279,48 @@ class BackupManager(BaseBackupManager[Backup]):
protected=backup.protected,
),
)
for agent in self.backup_agents.values()
for agent_id in agent_ids
),
return_exceptions=True,
)
for result in sync_backup_results:
if isinstance(result, Exception):
LOGGER.error("Error during backup upload - %s", result)
# TODO: Reset self.syncing in a finally block
self.syncing = False
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 local backups", len(backups))
self.backups = backups
self.loaded_backups = True
def _read_backups(self) -> dict[str, Backup]:
"""Read backups from disk."""
backups: dict[str, Backup] = {}
for backup_path in self.backup_dir.glob("*.tar"):
try:
with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file:
if data_file := backup_file.extractfile("./backup.json"):
data = json_loads_object(data_file.read())
backup = Backup(
slug=cast(str, data["slug"]),
name=cast(str, data["name"]),
date=cast(str, data["date"]),
path=backup_path,
size=round(backup_path.stat().st_size / 1_048_576, 2),
protected=cast(bool, data.get("protected", False)),
)
backups[backup.slug] = backup
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
return backups
async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]:
"""Return backups."""
if not self.loaded_backups:
await self.load_backups()
backups: dict[str, Backup] = {}
for agent_id, agent in self.backup_agents.items():
agent_backups = await agent.async_list_backups()
for agent_backup in agent_backups:
if agent_backup.slug not in backups:
backups[agent_backup.slug] = Backup(
slug=agent_backup.slug,
name=agent_backup.name,
date=agent_backup.date,
agent_ids=[],
# TODO: Do we need to expose the path?
path=agent_backup.path, # type: ignore[attr-defined]
size=agent_backup.size,
protected=agent_backup.protected,
)
backups[agent_backup.slug].agent_ids.append(agent_id)
return self.backups
return backups
async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None:
"""Return a backup."""
if not self.loaded_backups:
await self.load_backups()
if not (backup := self.backups.get(slug)):
return None
if not backup.path.exists():
LOGGER.debug(
(
"Removing tracked backup (%s) that does not exists on the expected"
" path %s"
),
backup.slug,
backup.path,
)
self.backups.pop(slug)
return None
return backup
# TODO: This is not efficient, but it's fine for draft
backups = await self.async_get_backups()
return backups.get(slug)
async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Remove a backup."""
if (backup := await self.async_get_backup(slug=slug)) is None:
return
await self.hass.async_add_executor_job(backup.path.unlink, True)
LOGGER.debug("Removed backup located at %s", backup.path)
self.backups.pop(slug)
# TODO: We should only remove from the agents that have the backup
for agent in self.backup_agents.values():
await agent.async_remove_backup(slug=slug) # type: ignore[attr-defined]
async def async_receive_backup(
self,
@ -392,12 +377,13 @@ class BackupManager(BaseBackupManager[Backup]):
temp_dir_handler.cleanup()
await self.hass.async_add_executor_job(_move_and_cleanup)
await self.load_backups()
# TODO: What do we need to do instead?
async def async_create_backup(
self,
*,
addons_included: list[str] | None,
agent_ids: list[str],
database_included: bool,
folders_included: list[str] | None,
name: str | None,
@ -408,12 +394,17 @@ class BackupManager(BaseBackupManager[Backup]):
"""Initiate generating a backup."""
if self.backup_task:
raise HomeAssistantError("Backup already in progress")
if not agent_ids:
raise HomeAssistantError("At least one agent must be selected")
if any(agent_id not in self.backup_agents for agent_id in agent_ids):
raise HomeAssistantError("Invalid agent selected")
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(
addons_included=addons_included,
agent_ids=agent_ids,
backup_name=backup_name,
database_included=database_included,
date_str=date_str,
@ -431,6 +422,7 @@ class BackupManager(BaseBackupManager[Backup]):
self,
*,
addons_included: list[str] | None,
agent_ids: list[str],
database_included: bool,
backup_name: str,
date_str: str,
@ -441,6 +433,11 @@ class BackupManager(BaseBackupManager[Backup]):
) -> Backup:
"""Generate a backup."""
success = False
if LOCAL_AGENT_ID in agent_ids:
backup_dir = self.backup_dir
else:
backup_dir = self.temp_backup_dir
try:
await self.async_pre_backup_actions()
@ -458,9 +455,10 @@ class BackupManager(BaseBackupManager[Backup]):
"protected": password is not None,
}
tar_file_path = Path(self.backup_dir, f"{backup_data['slug']}.tar")
tar_file_path = Path(backup_dir, f"{backup_data['slug']}.tar")
size_in_bytes = await self.hass.async_add_executor_job(
self._mkdir_and_generate_backup_contents,
backup_dir,
tar_file_path,
backup_data,
database_included,
@ -473,10 +471,19 @@ class BackupManager(BaseBackupManager[Backup]):
path=tar_file_path,
size=round(size_in_bytes / 1_048_576, 2),
protected=password is not None,
agent_ids=agent_ids, # TODO: This should maybe be set after upload
)
if self.loaded_backups:
self.backups[slug] = backup
LOGGER.debug("Generated new backup with slug %s", slug)
# TODO: We should add a cache of the backup metadata
LOGGER.debug(
"Generated new backup with slug %s, uploading to agents %s",
slug,
agent_ids,
)
await self._async_upload_backup(
slug=slug, backup=backup, agent_ids=agent_ids
)
# TODO: Upload to other agents
# TODO: Remove from local store if not uploaded to local agent
success = True
return backup
finally:
@ -487,15 +494,16 @@ class BackupManager(BaseBackupManager[Backup]):
def _mkdir_and_generate_backup_contents(
self,
backup_dir: Path,
tar_file_path: Path,
backup_data: dict[str, Any],
database_included: bool,
password: str | None = None,
) -> int:
"""Generate backup contents and return the size."""
if not self.backup_dir.exists():
LOGGER.debug("Creating backup directory")
self.backup_dir.mkdir()
if not backup_dir.exists():
LOGGER.debug("Creating backup directory %s", backup_dir)
backup_dir.mkdir()
excludes = EXCLUDE_FROM_BACKUP
if not database_included:

View file

@ -120,6 +120,7 @@ async def handle_restore(
{
vol.Required("type"): "backup/generate",
vol.Optional("addons_included"): [str],
vol.Required("agent_ids"): [str],
vol.Optional("database_included", default=True): bool,
vol.Optional("folders_included"): [str],
vol.Optional("name"): str,
@ -139,6 +140,7 @@ async def handle_create(
backup = await hass.data[DATA_MANAGER].async_create_backup(
addons_included=msg.get("addons_included"),
agent_ids=msg["agent_ids"],
database_included=msg["database_included"],
folders_included=msg.get("folders_included"),
name=msg.get("name"),
@ -283,7 +285,7 @@ async def backup_agents_download(
try:
await agent.async_download_backup(
id=msg["backup_id"],
path=Path(hass.config.path("backup"), f"{msg['slug']}.tar"),
path=Path(manager.backup_dir, f"{msg['slug']}.tar"), # type: ignore[attr-defined]
)
except Exception as err: # noqa: BLE001
connection.send_error(msg["id"], "backup_agents_download", str(err))

View file

@ -12,12 +12,14 @@ from homeassistant.components.backup import (
BackupUploadMetadata,
UploadedBackup,
)
from homeassistant.components.backup.manager import Backup
from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.components.backup.manager import LOCAL_AGENT_ID, Backup
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
TEST_BACKUP = Backup(
agent_ids=["backup.local"],
slug="abc123",
name="Test",
date="1970-01-01T00:00:00.000Z",
@ -70,7 +72,16 @@ async def setup_backup_integration(
hass: HomeAssistant,
with_hassio: bool = False,
configuration: ConfigType | None = None,
backups: list[Backup] | None = None,
) -> bool:
"""Set up the Backup integration."""
with patch("homeassistant.components.backup.is_hassio", return_value=with_hassio):
return await async_setup_component(hass, DOMAIN, configuration or {})
result = await async_setup_component(hass, DOMAIN, configuration or {})
if with_hassio or not backups:
return result
local_agent = hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID]
local_agent.backups = {backups.slug: backups for backups in backups}
local_agent.loaded_backups = True
return result

View file

@ -45,6 +45,9 @@
dict({
'agent_id': 'domain.test',
}),
dict({
'agent_id': 'backup.local',
}),
]),
'syncing': False,
}),
@ -60,6 +63,9 @@
dict({
'agent_id': 'domain.test',
}),
dict({
'agent_id': 'backup.local',
}),
]),
'syncing': False,
}),
@ -352,6 +358,9 @@
'id': 1,
'result': dict({
'backup': dict({
'agent_ids': list([
'backup.local',
]),
'date': '1970-01-01T00:00:00.000Z',
'name': 'Test',
'path': 'abc123.tar',
@ -508,6 +517,9 @@
'backing_up': False,
'backups': list([
dict({
'agent_ids': list([
'backup.local',
]),
'date': '1970-01-01T00:00:00.000Z',
'name': 'Test',
'path': 'abc123.tar',

View file

@ -11,12 +11,14 @@ from multidict import CIMultiDict, CIMultiDictProxy
import pytest
from homeassistant.components.backup import (
DOMAIN,
BackupAgentPlatformProtocol,
BackupManager,
BackupPlatformProtocol,
BackupUploadMetadata,
backup as local_backup_platform,
)
from homeassistant.components.backup.manager import BackupProgress
from homeassistant.components.backup.manager import LOCAL_AGENT_ID, BackupProgress
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
@ -52,6 +54,7 @@ async def _mock_backup_generation(
assert manager.backup_task is None
await manager.async_create_backup(
addons_included=[],
agent_ids=[LOCAL_AGENT_ID],
database_included=database_included,
folders_included=[],
name=name,
@ -92,29 +95,36 @@ async def _mock_backup_generation(
return backup
async def _setup_mock_domain(
async def _setup_backup_platform(
hass: HomeAssistant,
*,
domain: str = "some_domain",
platform: BackupPlatformProtocol | BackupAgentPlatformProtocol | 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", {})
mock_platform(hass, f"{domain}.backup", platform or MockPlatform())
assert await async_setup_component(hass, domain, {})
async def test_constructor(hass: HomeAssistant) -> None:
"""Test BackupManager constructor."""
manager = BackupManager(hass)
assert manager.backup_dir.as_posix() == hass.config.path("backups")
assert manager.temp_backup_dir.as_posix() == hass.config.path("tmp_backups")
async def test_load_backups(hass: HomeAssistant) -> None:
"""Test loading backups."""
manager = BackupManager(hass)
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await manager.load_platforms()
with (
patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]),
patch("tarfile.open", return_value=MagicMock()),
patch(
"homeassistant.components.backup.manager.json_loads_object",
"homeassistant.components.backup.backup.json_loads_object",
return_value={
"slug": TEST_BACKUP.slug,
"name": TEST_BACKUP.name,
@ -126,7 +136,7 @@ async def test_load_backups(hass: HomeAssistant) -> None:
return_value=MagicMock(st_size=TEST_BACKUP.size),
),
):
await manager.load_backups()
await manager.backup_agents[LOCAL_AGENT_ID].load_backups()
backups = await manager.async_get_backups()
assert backups == {TEST_BACKUP.slug: TEST_BACKUP}
@ -137,11 +147,15 @@ async def test_load_backups_with_exception(
) -> None:
"""Test loading backups with exception."""
manager = BackupManager(hass)
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await manager.load_platforms()
with (
patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]),
patch("tarfile.open", side_effect=OSError("Test exception")),
):
await manager.load_backups()
await manager.backup_agents[LOCAL_AGENT_ID].load_backups()
backups = await manager.async_get_backups()
assert f"Unable to read backup {TEST_BACKUP.path}: Test exception" in caplog.text
assert backups == {}
@ -153,8 +167,13 @@ async def test_removing_backup(
) -> None:
"""Test removing backup."""
manager = BackupManager(hass)
manager.backups = {TEST_BACKUP.slug: TEST_BACKUP}
manager.loaded_backups = True
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await manager.load_platforms()
local_agent = manager.backup_agents[LOCAL_AGENT_ID]
local_agent.backups = {TEST_BACKUP.slug: TEST_BACKUP}
local_agent.loaded_backups = True
with patch("pathlib.Path.exists", return_value=True):
await manager.async_remove_backup(slug=TEST_BACKUP.slug)
@ -168,18 +187,27 @@ async def test_removing_non_existing_backup(
"""Test removing not existing backup."""
manager = BackupManager(hass)
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await manager.load_platforms()
await manager.async_remove_backup(slug="non_existing")
assert "Removed backup located at" not in caplog.text
@pytest.mark.xfail(reason="Cleanup not implemented in the draft")
async def test_getting_backup_that_does_not_exist(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test getting backup that does not exist."""
manager = BackupManager(hass)
manager.backups = {TEST_BACKUP.slug: TEST_BACKUP}
manager.loaded_backups = True
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await manager.load_platforms()
local_agent = manager.backup_agents[LOCAL_AGENT_ID]
local_agent.backups = {TEST_BACKUP.slug: TEST_BACKUP}
local_agent.loaded_backups = True
with patch("pathlib.Path.exists", return_value=False):
backup = await manager.async_get_backup(slug=TEST_BACKUP.slug)
@ -199,6 +227,7 @@ async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None:
with pytest.raises(HomeAssistantError, match="Backup already in progress"):
await manager.async_create_backup(
addons_included=[],
agent_ids=[LOCAL_AGENT_ID],
database_included=True,
folders_included=[],
name=None,
@ -227,7 +256,12 @@ async def test_async_create_backup(
) -> None:
"""Test generate backup."""
manager = BackupManager(hass)
manager.loaded_backups = True
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await manager.load_platforms()
local_agent = manager.backup_agents[LOCAL_AGENT_ID]
local_agent.loaded_backups = True
await _mock_backup_generation(
hass, manager, mocked_json_bytes, mocked_tarfile, **params
@ -236,10 +270,10 @@ async def test_async_create_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 "Loaded 0 agents" in caplog.text
assert "Loaded 1 agents" in caplog.text
assert len(manager.backups) == 1
backup = list(manager.backups.values())[0]
assert len(local_agent.backups) == 1
backup = list(local_agent.backups.values())[0]
assert backup.protected is bool(params.get("password"))
@ -250,12 +284,11 @@ async def test_loading_platforms(
"""Test loading backup platforms."""
manager = BackupManager(hass)
assert not manager.loaded_platforms
assert not manager.platforms
await _setup_mock_domain(
await _setup_backup_platform(
hass,
Mock(
platform=Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(),
@ -264,7 +297,6 @@ async def test_loading_platforms(
await manager.load_platforms()
await hass.async_block_till_done()
assert manager.loaded_platforms
assert len(manager.platforms) == 1
assert "Loaded 1 platforms" in caplog.text
@ -277,19 +309,17 @@ async def test_loading_agents(
"""Test loading backup agents."""
manager = BackupManager(hass)
assert not manager.loaded_platforms
assert not manager.platforms
await _setup_mock_domain(
await _setup_backup_platform(
hass,
Mock(
platform=Mock(
async_get_backup_agents=AsyncMock(return_value=[BackupAgentTest("test")]),
),
)
await manager.load_platforms()
await hass.async_block_till_done()
assert manager.loaded_platforms
assert len(manager.backup_agents) == 1
assert "Loaded 1 agents" in caplog.text
@ -303,14 +333,12 @@ async def test_not_loading_bad_platforms(
"""Test loading backup platforms."""
manager = BackupManager(hass)
assert not manager.loaded_platforms
assert not manager.platforms
await _setup_mock_domain(hass)
await _setup_backup_platform(hass)
await manager.load_platforms()
await hass.async_block_till_done()
assert manager.loaded_platforms
assert len(manager.platforms) == 0
assert "Loaded 0 platforms" in caplog.text
@ -326,9 +354,10 @@ async def test_syncing_backup(
"""Test syncing a backup."""
manager = BackupManager(hass)
await _setup_mock_domain(
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await _setup_backup_platform(
hass,
Mock(
platform=Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(
@ -387,9 +416,10 @@ async def test_syncing_backup_with_exception(
async def async_upload_backup(self, **kwargs: Any) -> None:
raise HomeAssistantError("Test exception")
await _setup_mock_domain(
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await _setup_backup_platform(
hass,
Mock(
platform=Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(
@ -448,9 +478,10 @@ async def test_syncing_backup_no_agents(
"""Test syncing a backup with no agents."""
manager = BackupManager(hass)
await _setup_mock_domain(
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
await _setup_backup_platform(
hass,
Mock(
platform=Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(return_value=[]),
@ -479,9 +510,9 @@ async def test_exception_plaform_pre(
async def _mock_step(hass: HomeAssistant) -> None:
raise HomeAssistantError("Test exception")
await _setup_mock_domain(
await _setup_backup_platform(
hass,
Mock(
platform=Mock(
async_pre_backup=_mock_step,
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(),
@ -502,9 +533,9 @@ async def test_exception_plaform_post(
async def _mock_step(hass: HomeAssistant) -> None:
raise HomeAssistantError("Test exception")
await _setup_mock_domain(
await _setup_backup_platform(
hass,
Mock(
platform=Mock(
async_pre_backup=AsyncMock(),
async_post_backup=_mock_step,
async_get_backup_agents=AsyncMock(),
@ -515,58 +546,6 @@ async def test_exception_plaform_post(
await _mock_backup_generation(hass, manager, mocked_json_bytes, mocked_tarfile)
async def test_loading_platforms_when_running_async_pre_backup_actions(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test loading backup platforms when running post backup actions."""
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(),
async_get_backup_agents=AsyncMock(),
),
)
await manager.async_pre_backup_actions()
assert manager.loaded_platforms
assert len(manager.platforms) == 1
assert "Loaded 1 platforms" in caplog.text
async def test_loading_platforms_when_running_async_post_backup_actions(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test loading backup platforms when running post backup actions."""
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(),
async_get_backup_agents=AsyncMock(),
),
)
await manager.async_post_backup_actions()
assert manager.loaded_platforms
assert len(manager.platforms) == 1
assert "Loaded 1 platforms" in caplog.text
async def test_async_receive_backup(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
@ -601,6 +580,7 @@ async def test_async_receive_backup(
assert mover_mock.mock_calls[0].args[1].name == "abc123.tar"
@pytest.mark.xfail(reason="Restore not implemented in the draft")
async def test_async_trigger_restore(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
@ -623,6 +603,7 @@ async def test_async_trigger_restore(
assert mocked_service_call.called
@pytest.mark.xfail(reason="Restore not implemented in the draft")
async def test_async_trigger_restore_with_password(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
@ -645,6 +626,7 @@ async def test_async_trigger_restore_with_password(
assert mocked_service_call.called
@pytest.mark.xfail(reason="Restore not implemented in the draft")
async def test_async_trigger_restore_missing_backup(hass: HomeAssistant) -> None:
"""Test trigger restore."""
manager = BackupManager(hass)

View file

@ -2,7 +2,7 @@
from pathlib import Path
from typing import Any
from unittest.mock import ANY, AsyncMock, patch
from unittest.mock import ANY, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
@ -45,31 +45,19 @@ async def test_info(
with_hassio: bool,
) -> None:
"""Test getting backup info."""
await setup_backup_integration(hass, with_hassio=with_hassio)
hass.data[DATA_MANAGER].backups = {TEST_BACKUP.slug: TEST_BACKUP}
await setup_backup_integration(hass, with_hassio=with_hassio, backups=[TEST_BACKUP])
client = await hass_ws_client(hass)
await hass.async_block_till_done()
with (
patch(
"homeassistant.components.backup.manager.BackupManager.load_backups",
AsyncMock(),
),
patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backups",
return_value={TEST_BACKUP.slug: TEST_BACKUP},
),
):
await client.send_json_auto_id({"type": "backup/info"})
assert await client.receive_json() == snapshot
await client.send_json_auto_id({"type": "backup/info"})
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
"backup_content",
[
pytest.param(TEST_BACKUP, id="with_backup_content"),
pytest.param([TEST_BACKUP], id="with_backup_content"),
pytest.param(None, id="without_backup_content"),
],
)
@ -88,17 +76,15 @@ async def test_details(
backup_content: BaseBackup | None,
) -> None:
"""Test getting backup info."""
await setup_backup_integration(hass, with_hassio=with_hassio)
await setup_backup_integration(
hass, with_hassio=with_hassio, backups=backup_content
)
client = await hass_ws_client(hass)
await hass.async_block_till_done()
with patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
return_value=backup_content,
):
await client.send_json_auto_id({"type": "backup/details", "slug": "abc123"})
assert await client.receive_json() == snapshot
await client.send_json_auto_id({"type": "backup/details", "slug": "abc123"})
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
@ -159,7 +145,9 @@ async def test_generate(
freezer.move_to("2024-11-13 12:01:00+01:00")
await hass.async_block_till_done()
await client.send_json_auto_id({"type": "backup/generate", **(data or {})})
await client.send_json_auto_id(
{"type": "backup/generate", **{"agent_ids": ["backup.local"]} | (data or {})}
)
for _ in range(number_of_messages):
assert await client.receive_json() == snapshot
@ -168,16 +156,18 @@ async def test_generate(
@pytest.mark.parametrize(
("params", "expected_extra_call_params"),
[
({}, {}),
({"agent_ids": ["backup.local"]}, {"agent_ids": ["backup.local"]}),
(
{
"addons_included": ["ssl"],
"agent_ids": ["backup.local"],
"database_included": False,
"folders_included": ["media"],
"name": "abc123",
},
{
"addons_included": ["ssl"],
"agent_ids": ["backup.local"],
"database_included": False,
"folders_included": ["media"],
"name": "abc123",
@ -525,7 +515,7 @@ async def test_agents_download(
assert await client.receive_json() == snapshot
assert download_mock.call_args[1] == {
"id": "abc123",
"path": Path(hass.config.path("backup"), "abc123.tar"),
"path": Path(hass.config.path("backups"), "abc123.tar"),
}