Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MVP implementation of Backup sync agents #126122

Open
wants to merge 26 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions homeassistant/components/backup/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,25 @@
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType

from .const import DATA_MANAGER, DOMAIN, LOGGER
from .const import DOMAIN, LOGGER
from .http import async_register_http_views
from .manager import BackupManager
from .models import BackupSyncMetadata
from .sync_agent import BackupSyncAgent, SyncedBackup
from .websocket import async_register_websocket_handlers

__all__ = [
"BackupSyncAgent",
"BackupSyncMetadata",
"SyncedBackup",
]

CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Backup integration."""
backup_manager = BackupManager(hass)
hass.data[DATA_MANAGER] = backup_manager
hass.data[DOMAIN] = backup_manager = BackupManager(hass)

with_hassio = is_hassio(hass)

Expand Down
5 changes: 3 additions & 2 deletions homeassistant/components/backup/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
from homeassistant.util.hass_dict import HassKey

if TYPE_CHECKING:
from .manager import BackupManager
from .manager import BaseBackupManager
from .models import BaseBackup

DOMAIN = "backup"
DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN)
DATA_MANAGER: HassKey[BaseBackupManager[BaseBackup]] = HassKey(DOMAIN)
LOGGER = getLogger(__package__)

EXCLUDE_FROM_BACKUP = [
Expand Down
101 changes: 82 additions & 19 deletions homeassistant/components/backup/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
import tarfile
from tarfile import TarError
import time
from typing import Any, Protocol, cast
from typing import Any, Generic, Protocol, cast

from securetar import SecureTarFile, atomic_contents_add
from typing_extensions import TypeVar

from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
Expand All @@ -25,19 +26,19 @@
from homeassistant.util.json import json_loads_object

from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
from .models import BaseBackup
from .sync_agent import BackupPlatformAgentProtocol, BackupSyncAgent

BUF_SIZE = 2**20 * 4 # 4MB

_BackupT = TypeVar("_BackupT", bound=BaseBackup, default=BaseBackup)


@dataclass(slots=True)
class Backup:
class Backup(BaseBackup):
"""Backup class."""

slug: str
name: str
date: str
path: Path
size: float

def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
Expand All @@ -54,19 +55,21 @@ async def async_post_backup(self, hass: HomeAssistant) -> None:
"""Perform operations after a backup finishes."""


class BaseBackupManager(abc.ABC):
class BaseBackupManager(abc.ABC, Generic[_BackupT]):
"""Define the format that backup managers can have."""

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager."""
self.hass = hass
self.backing_up = False
self.backups: dict[str, Backup] = {}
self.backups: dict[str, _BackupT] = {}
self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {}
self.sync_agents: dict[str, BackupSyncAgent] = {}
self.syncing = False

@callback
def _add_platform(
def _add_platform_pre_post_handlers(
self,
hass: HomeAssistant,
integration_domain: str,
Expand All @@ -76,13 +79,25 @@ def _add_platform(
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 _async_add_platform_agents(
self,
hass: HomeAssistant,
integration_domain: str,
platform: BackupPlatformAgentProtocol,
) -> None:
"""Add a platform to the backup manager."""
if not hasattr(platform, "async_get_backup_sync_agents"):
return

agents = await platform.async_get_backup_sync_agents(hass=hass)
self.sync_agents.update(
{f"{integration_domain}.{agent.name}": agent for agent in agents}
)

async def async_pre_backup_actions(self, **kwargs: Any) -> None:
"""Perform pre backup actions."""
if not self.loaded_platforms:
Expand Down Expand Up @@ -117,33 +132,49 @@ async def async_post_backup_actions(self, **kwargs: Any) -> None:

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._add_platform, wait_for_platforms=True
self.hass,
DOMAIN,
self._async_add_platform_agents,
wait_for_platforms=True,
)
LOGGER.debug("Loaded %s platforms", len(self.platforms))
LOGGER.debug("Loaded %s agents", len(self.sync_agents))
self.loaded_platforms = True

@abc.abstractmethod
async def async_create_backup(self, **kwargs: Any) -> Backup:
async def async_create_backup(self, **kwargs: Any) -> _BackupT:
"""Generate a backup."""

@abc.abstractmethod
async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]:
async def async_get_backups(self, **kwargs: Any) -> dict[str, _BackupT]:
"""Get backups.

Return a dictionary of Backup instances keyed by their slug.
"""

@abc.abstractmethod
async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None:
async def async_get_backup(self, *, slug: str, **kwargs: Any) -> _BackupT | None:
"""Get a backup."""

@abc.abstractmethod
async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Remove a backup."""

@abc.abstractmethod
async def async_sync_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Sync a backup."""


class BackupManager(BaseBackupManager):
class BackupManager(BaseBackupManager[Backup]):
"""Backup manager for the Backup integration."""

def __init__(self, hass: HomeAssistant) -> None:
Expand All @@ -152,10 +183,42 @@ def __init__(self, hass: HomeAssistant) -> None:
self.backup_dir = Path(hass.config.path("backups"))
self.loaded_backups = False

async def async_sync_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Sync a backup."""
await self.load_platforms()

if not self.sync_agents:
return

if not (backup := await self.async_get_backup(slug=slug)):
return

self.syncing = True
sync_backup_results = await asyncio.gather(
*(
agent.async_upload_backup(
path=backup.path,
metadata={
"homeassistant": HAVERSION,
"size": backup.size,
"date": backup.date,
"slug": backup.slug,
"name": backup.name,
},
)
for agent in self.sync_agents.values()
),
return_exceptions=True,
)
for result in sync_backup_results:
if isinstance(result, Exception):
LOGGER.error("Error during backup sync - %s", result)
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 backups", len(backups))
LOGGER.debug("Loaded %s local backups", len(backups))
self.backups = backups
self.loaded_backups = True

Expand Down
28 changes: 28 additions & 0 deletions homeassistant/components/backup/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Models for the backup integration."""

from dataclasses import asdict, dataclass
from typing import TypedDict


@dataclass()
class BaseBackup:
"""Base backup class."""

date: str
slug: str
size: float
name: str

def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
return asdict(self)


class BackupSyncMetadata(TypedDict):
"""Dictionary type for backup sync metadata."""

date: str # The date the backup was created
slug: str # The slug of the backup
size: float # The size of the backup (in bytes)
name: str # The name of the backup
homeassistant: str # The version of Home Assistant that created the backup
73 changes: 73 additions & 0 deletions homeassistant/components/backup/sync_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Backup sync agents for the Backup integration."""

from __future__ import annotations

import abc
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol

from homeassistant.core import HomeAssistant

from .models import BackupSyncMetadata, BaseBackup


@dataclass(slots=True)
class SyncedBackup(BaseBackup):
"""Synced backup class."""

id: str


class BackupSyncAgent(abc.ABC):
"""Define the format that backup sync agents can have."""

def __init__(self, name: str) -> None:
"""Initialize the backup sync agent."""
self.name = name

@abc.abstractmethod
async def async_download_backup(
self,
*,
id: str,
path: Path,
**kwargs: Any,
) -> None:
"""Download a backup file.

The `id` parameter is the ID of the synced backup that was returned in async_list_backups.

The `path` parameter is the full file path to download the synced backup to.
"""

@abc.abstractmethod
async def async_upload_backup(
self,
*,
path: Path,
metadata: BackupSyncMetadata,
**kwargs: Any,
) -> None:
"""Upload a backup.

The `path` parameter is the full file path to the backup that should be synced.

The `metadata` parameter contains metadata about the backup that should be synced.
"""

@abc.abstractmethod
async def async_list_backups(self, **kwargs: Any) -> list[SyncedBackup]:
"""List backups."""


class BackupPlatformAgentProtocol(Protocol):
"""Define the format that backup platforms can have."""

async def async_get_backup_sync_agents(
self,
*,
hass: HomeAssistant,
**kwargs: Any,
) -> list[BackupSyncAgent]:
"""Register the backup sync agent."""
Loading
Loading