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 18 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
11 changes: 10 additions & 1 deletion homeassistant/components/backup/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@
from .const import DATA_MANAGER, 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)


Expand All @@ -32,7 +40,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:

async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups."""
await backup_manager.generate_backup()
backup = await backup_manager.generate_backup()
await backup_manager.sync_backup(backup=backup)

hass.services.async_register(DOMAIN, "create", async_handle_create_service)

Expand Down
77 changes: 65 additions & 12 deletions homeassistant/components/backup/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,17 @@
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


@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 @@ -63,11 +61,13 @@ def __init__(self, hass: HomeAssistant) -> None:
self.backing_up = False
self.backups: dict[str, Backup] = {}
self.platforms: dict[str, BackupPlatformProtocol] = {}
self.sync_agents: dict[str, BackupSyncAgent] = {}
self.syncing = False
self.loaded_backups = False
self.loaded_platforms = False

@callback
def _add_platform(
def _add_platform_pre_post_handlers(
self,
hass: HomeAssistant,
integration_domain: str,
Expand All @@ -77,13 +77,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 pre_backup_actions(self) -> None:
"""Perform pre backup actions."""
if not self.loaded_platforms:
Expand Down Expand Up @@ -116,19 +128,60 @@ async def post_backup_actions(self) -> None:
if isinstance(result, Exception):
raise result

async def sync_backup(self, backup: Backup) -> None:
"""Sync a backup."""
await self.load_platforms()

if not self.sync_agents:
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

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

def _read_backups(self) -> dict[str, Backup]:
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."""
85 changes: 83 additions & 2 deletions homeassistant/components/backup/websocket.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Websocket commands for the Backup integration."""

from pathlib import Path
from typing import Any

import voluptuous as vol
Expand All @@ -13,6 +14,10 @@
@callback
def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> None:
"""Register websocket commands."""
websocket_api.async_register_command(hass, backup_agents_download)
websocket_api.async_register_command(hass, backup_agents_info)
websocket_api.async_register_command(hass, backup_agents_list_synced_backups)

if with_hassio:
websocket_api.async_register_command(hass, handle_backup_end)
websocket_api.async_register_command(hass, handle_backup_start)
Expand All @@ -37,7 +42,7 @@ async def handle_info(
connection.send_result(
msg["id"],
{
"backups": list(backups.values()),
"backups": [b.as_dict() for b in backups.values()],
"backing_up": manager.backing_up,
},
)
Expand Down Expand Up @@ -70,8 +75,10 @@ async def handle_create(
msg: dict[str, Any],
) -> None:
"""Generate a backup."""
backup = await hass.data[DATA_MANAGER].generate_backup()
manager = hass.data[DATA_MANAGER]
backup = await manager.generate_backup()
connection.send_result(msg["id"], backup)
await manager.sync_backup(backup=backup)


@websocket_api.ws_require_user(only_supervisor=True)
Expand Down Expand Up @@ -116,3 +123,77 @@ async def handle_backup_end(
return

connection.send_result(msg["id"])


@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/agents/info"})
@websocket_api.async_response
async def backup_agents_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return backup agents info."""
manager = hass.data[DATA_MANAGER]
await manager.load_platforms()
connection.send_result(
msg["id"],
{
"agents": list(manager.sync_agents),
"syncing": manager.syncing,
},
)


@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/agents/synced"})
@websocket_api.async_response
async def backup_agents_list_synced_backups(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return a list of synced backups."""
manager = hass.data[DATA_MANAGER]
backups: list[dict[str, Any]] = []
await manager.load_platforms()
for agent_id, agent in manager.sync_agents.items():
_listed_backups = await agent.async_list_backups()
backups.extend({**b.as_dict(), "agent_id": agent_id} for b in _listed_backups)
connection.send_result(msg["id"], backups)


@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/agents/download",
vol.Required("agent"): str,
ludeeus marked this conversation as resolved.
Show resolved Hide resolved
vol.Required("sync_id"): str,
vol.Required("slug"): str,
}
)
@websocket_api.async_response
async def backup_agents_download(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Download a synced backup."""
manager = hass.data[DATA_MANAGER]
await manager.load_platforms()

if not (agent := manager.sync_agents.get(msg["agent"])):
connection.send_error(
msg["id"], "unknown_agent", f"Agent {msg['agent']} not found"
)
return
try:
await agent.async_download_backup(
id=msg["sync_id"],
path=Path(hass.config.path("backup"), f"{msg['slug']}.tar"),
)
except Exception as err: # noqa: BLE001
connection.send_error(msg["id"], "backup_agents_download", str(err))
return

connection.send_result(msg["id"])
Loading
Loading