mirror of
https://github.com/home-assistant/core.git
synced 2025-08-09 23:55:07 +02:00
Refactor BackupManager (#130947)
* Refactor BackupManager * Adjust * Adjust backup creation * Copy in executor
This commit is contained in:
@@ -11,7 +11,12 @@ from homeassistant.helpers.typing import ConfigType
|
|||||||
from .agent import BackupAgent, BackupAgentPlatformProtocol
|
from .agent import BackupAgent, BackupAgentPlatformProtocol
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import DOMAIN, LOGGER
|
||||||
from .http import async_register_http_views
|
from .http import async_register_http_views
|
||||||
from .manager import Backup, BackupManager, BackupPlatformProtocol
|
from .manager import (
|
||||||
|
Backup,
|
||||||
|
BackupManager,
|
||||||
|
BackupPlatformProtocol,
|
||||||
|
CoreBackupReaderWriter,
|
||||||
|
)
|
||||||
from .models import BackupUploadMetadata, BaseBackup
|
from .models import BackupUploadMetadata, BaseBackup
|
||||||
from .websocket import async_register_websocket_handlers
|
from .websocket import async_register_websocket_handlers
|
||||||
|
|
||||||
@@ -31,7 +36,9 @@ SERVICE_CREATE_SCHEMA = vol.Schema({vol.Optional(CONF_PASSWORD): str})
|
|||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the Backup integration."""
|
"""Set up the Backup integration."""
|
||||||
hass.data[DOMAIN] = backup_manager = BackupManager(hass)
|
hass.data[DOMAIN] = backup_manager = BackupManager(
|
||||||
|
hass, CoreBackupReaderWriter(hass)
|
||||||
|
)
|
||||||
await backup_manager.async_setup()
|
await backup_manager.async_setup()
|
||||||
|
|
||||||
with_hassio = is_hassio(hass)
|
with_hassio = is_hassio(hass)
|
||||||
|
@@ -8,12 +8,11 @@ from typing import TYPE_CHECKING
|
|||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .manager import BaseBackupManager
|
from .manager import BackupManager
|
||||||
from .models import BaseBackup
|
|
||||||
|
|
||||||
BUF_SIZE = 2**20 * 4 # 4MB
|
BUF_SIZE = 2**20 * 4 # 4MB
|
||||||
DOMAIN = "backup"
|
DOMAIN = "backup"
|
||||||
DATA_MANAGER: HassKey[BaseBackupManager[BaseBackup]] = HassKey(DOMAIN)
|
DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN)
|
||||||
LOGGER = getLogger(__package__)
|
LOGGER = getLogger(__package__)
|
||||||
|
|
||||||
EXCLUDE_FROM_BACKUP = [
|
EXCLUDE_FROM_BACKUP = [
|
||||||
|
@@ -15,7 +15,6 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.util import slugify
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
from .const import DATA_MANAGER
|
from .const import DATA_MANAGER
|
||||||
from .manager import BackupManager
|
|
||||||
|
|
||||||
# pylint: disable=fixme
|
# pylint: disable=fixme
|
||||||
# TODO: Don't forget to remove this when the implementation is complete
|
# TODO: Don't forget to remove this when the implementation is complete
|
||||||
@@ -47,7 +46,7 @@ class DownloadBackupView(HomeAssistantView):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return Response(status=HTTPStatus.BAD_REQUEST)
|
return Response(status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
manager = cast(BackupManager, request.app[KEY_HASS].data[DATA_MANAGER])
|
manager = request.app[KEY_HASS].data[DATA_MANAGER]
|
||||||
if agent_id not in manager.backup_agents:
|
if agent_id not in manager.backup_agents:
|
||||||
return Response(status=HTTPStatus.BAD_REQUEST)
|
return Response(status=HTTPStatus.BAD_REQUEST)
|
||||||
agent = manager.backup_agents[agent_id]
|
agent = manager.backup_agents[agent_id]
|
||||||
|
@@ -15,11 +15,10 @@ import shutil
|
|||||||
import tarfile
|
import tarfile
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
import time
|
import time
|
||||||
from typing import Any, Generic, Protocol
|
from typing import TYPE_CHECKING, Any, Protocol
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from securetar import SecureTarFile, atomic_contents_add
|
from securetar import SecureTarFile, atomic_contents_add
|
||||||
from typing_extensions import TypeVar
|
|
||||||
|
|
||||||
from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key
|
from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key
|
||||||
from homeassistant.const import __version__ as HAVERSION
|
from homeassistant.const import __version__ as HAVERSION
|
||||||
@@ -38,6 +37,7 @@ from .agent import (
|
|||||||
from .config import BackupConfig
|
from .config import BackupConfig
|
||||||
from .const import (
|
from .const import (
|
||||||
BUF_SIZE,
|
BUF_SIZE,
|
||||||
|
DATA_MANAGER,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
EXCLUDE_DATABASE_FROM_BACKUP,
|
EXCLUDE_DATABASE_FROM_BACKUP,
|
||||||
EXCLUDE_FROM_BACKUP,
|
EXCLUDE_FROM_BACKUP,
|
||||||
@@ -46,8 +46,6 @@ from .const import (
|
|||||||
from .models import BackupUploadMetadata, BaseBackup
|
from .models import BackupUploadMetadata, BaseBackup
|
||||||
from .util import read_backup
|
from .util import read_backup
|
||||||
|
|
||||||
_BackupT = TypeVar("_BackupT", bound=BaseBackup, default=BaseBackup)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class NewBackup:
|
class NewBackup:
|
||||||
@@ -82,24 +80,62 @@ class BackupPlatformProtocol(Protocol):
|
|||||||
"""Perform operations after a backup finishes."""
|
"""Perform operations after a backup finishes."""
|
||||||
|
|
||||||
|
|
||||||
class BaseBackupManager(abc.ABC, Generic[_BackupT]):
|
class BackupReaderWriter(abc.ABC):
|
||||||
|
"""Abstract class for reading and writing backups."""
|
||||||
|
|
||||||
|
temp_backup_dir: Path
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def async_create_backup(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
addons_included: list[str] | None,
|
||||||
|
agent_ids: list[str],
|
||||||
|
database_included: bool,
|
||||||
|
backup_name: str,
|
||||||
|
folders_included: list[str] | None,
|
||||||
|
on_progress: Callable[[BackupProgress], None] | None,
|
||||||
|
password: str | None,
|
||||||
|
) -> tuple[NewBackup, asyncio.Task[tuple[BaseBackup, Path]]]:
|
||||||
|
"""Create a backup."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def async_restore_backup(
|
||||||
|
self,
|
||||||
|
backup_id: str,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
password: str | None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Restore a backup."""
|
||||||
|
|
||||||
|
|
||||||
|
class BackupManager:
|
||||||
"""Define the format that backup managers can have."""
|
"""Define the format that backup managers can have."""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
def __init__(self, hass: HomeAssistant, reader_writer: BackupReaderWriter) -> None:
|
||||||
"""Initialize the backup manager."""
|
"""Initialize the backup manager."""
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.backup_task: asyncio.Task | None = None
|
self.backup_task: asyncio.Task[tuple[BaseBackup, Path]] | None = None
|
||||||
|
self.finish_backup_task: asyncio.Task[None] | None = None
|
||||||
self.platforms: dict[str, BackupPlatformProtocol] = {}
|
self.platforms: dict[str, BackupPlatformProtocol] = {}
|
||||||
self.backup_agents: dict[str, BackupAgent] = {}
|
self.backup_agents: dict[str, BackupAgent] = {}
|
||||||
self.local_backup_agents: dict[str, LocalBackupAgent] = {}
|
self.local_backup_agents: dict[str, LocalBackupAgent] = {}
|
||||||
self.config = BackupConfig(hass)
|
self.config = BackupConfig(hass)
|
||||||
self.syncing = False
|
self.syncing = False
|
||||||
|
self._reader_writer = reader_writer
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
async def async_setup(self) -> None:
|
||||||
"""Set up the backup manager."""
|
"""Set up the backup manager."""
|
||||||
await self.config.load()
|
await self.config.load()
|
||||||
await self.load_platforms()
|
await self.load_platforms()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def temp_backup_dir(self) -> Path:
|
||||||
|
"""Return the temporary backup directory."""
|
||||||
|
return self._reader_writer.temp_backup_dir
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _add_platform_pre_post_handler(
|
def _add_platform_pre_post_handler(
|
||||||
self,
|
self,
|
||||||
@@ -182,74 +218,6 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]):
|
|||||||
LOGGER.debug("Loaded %s platforms", len(self.platforms))
|
LOGGER.debug("Loaded %s platforms", len(self.platforms))
|
||||||
LOGGER.debug("Loaded %s agents", len(self.backup_agents))
|
LOGGER.debug("Loaded %s agents", len(self.backup_agents))
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def async_restore_backup(
|
|
||||||
self,
|
|
||||||
backup_id: str,
|
|
||||||
*,
|
|
||||||
agent_id: str,
|
|
||||||
password: str | None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Restore a backup."""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
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,
|
|
||||||
on_progress: Callable[[BackupProgress], None] | None,
|
|
||||||
password: str | None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> NewBackup:
|
|
||||||
"""Initiate generating a backup.
|
|
||||||
|
|
||||||
:param on_progress: A callback that will be called with the progress of the
|
|
||||||
backup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def async_get_backups(
|
|
||||||
self, **kwargs: Any
|
|
||||||
) -> tuple[dict[str, Backup], dict[str, Exception]]:
|
|
||||||
"""Get backups.
|
|
||||||
|
|
||||||
Return a dictionary of Backup instances keyed by their ID.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def async_get_backup(
|
|
||||||
self, backup_id: str, **kwargs: Any
|
|
||||||
) -> tuple[_BackupT | None, dict[str, Exception]]:
|
|
||||||
"""Get a backup."""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
|
||||||
"""Delete a backup."""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def async_receive_backup(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
agent_ids: list[str],
|
|
||||||
contents: aiohttp.BodyPartReader,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Receive and store a backup file from upload."""
|
|
||||||
|
|
||||||
|
|
||||||
class BackupManager(BaseBackupManager[Backup]):
|
|
||||||
"""Backup manager for the Backup integration."""
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
|
||||||
"""Initialize the backup manager."""
|
|
||||||
super().__init__(hass=hass)
|
|
||||||
self.temp_backup_dir = Path(hass.config.path("tmp_backups"))
|
|
||||||
|
|
||||||
async def _async_upload_backup(
|
async def _async_upload_backup(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -288,7 +256,10 @@ class BackupManager(BaseBackupManager[Backup]):
|
|||||||
async def async_get_backups(
|
async def async_get_backups(
|
||||||
self, **kwargs: Any
|
self, **kwargs: Any
|
||||||
) -> tuple[dict[str, Backup], dict[str, Exception]]:
|
) -> tuple[dict[str, Backup], dict[str, Exception]]:
|
||||||
"""Return backups."""
|
"""Get backups.
|
||||||
|
|
||||||
|
Return a dictionary of Backup instances keyed by their ID.
|
||||||
|
"""
|
||||||
backups: dict[str, Backup] = {}
|
backups: dict[str, Backup] = {}
|
||||||
agent_errors: dict[str, Exception] = {}
|
agent_errors: dict[str, Exception] = {}
|
||||||
agent_ids = list(self.backup_agents)
|
agent_ids = list(self.backup_agents)
|
||||||
@@ -320,7 +291,7 @@ class BackupManager(BaseBackupManager[Backup]):
|
|||||||
async def async_get_backup(
|
async def async_get_backup(
|
||||||
self, backup_id: str, **kwargs: Any
|
self, backup_id: str, **kwargs: Any
|
||||||
) -> tuple[Backup | None, dict[str, Exception]]:
|
) -> tuple[Backup | None, dict[str, Exception]]:
|
||||||
"""Return a backup."""
|
"""Get a backup."""
|
||||||
backup: Backup | None = None
|
backup: Backup | None = None
|
||||||
agent_errors: dict[str, Exception] = {}
|
agent_errors: dict[str, Exception] = {}
|
||||||
agent_ids = list(self.backup_agents.keys())
|
agent_ids = list(self.backup_agents.keys())
|
||||||
@@ -453,7 +424,11 @@ class BackupManager(BaseBackupManager[Backup]):
|
|||||||
password: str | None,
|
password: str | None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> NewBackup:
|
) -> NewBackup:
|
||||||
"""Initiate generating a backup."""
|
"""Initiate generating a backup.
|
||||||
|
|
||||||
|
:param on_progress: A callback that will be called with the progress of the
|
||||||
|
backup.
|
||||||
|
"""
|
||||||
if self.backup_task:
|
if self.backup_task:
|
||||||
raise HomeAssistantError("Backup already in progress")
|
raise HomeAssistantError("Backup already in progress")
|
||||||
if not agent_ids:
|
if not agent_ids:
|
||||||
@@ -461,9 +436,118 @@ class BackupManager(BaseBackupManager[Backup]):
|
|||||||
if any(agent_id not in self.backup_agents for agent_id in agent_ids):
|
if any(agent_id not in self.backup_agents for agent_id in agent_ids):
|
||||||
raise HomeAssistantError("Invalid agent selected")
|
raise HomeAssistantError("Invalid agent selected")
|
||||||
backup_name = name or f"Core {HAVERSION}"
|
backup_name = name or f"Core {HAVERSION}"
|
||||||
|
new_backup, self.backup_task = await self._reader_writer.async_create_backup(
|
||||||
|
addons_included=addons_included,
|
||||||
|
agent_ids=agent_ids,
|
||||||
|
backup_name=backup_name,
|
||||||
|
database_included=database_included,
|
||||||
|
folders_included=folders_included,
|
||||||
|
on_progress=on_progress,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
self.finish_backup_task = self.hass.async_create_task(
|
||||||
|
self._async_finish_backup(agent_ids),
|
||||||
|
name="backup_manager_finish_backup",
|
||||||
|
)
|
||||||
|
return new_backup
|
||||||
|
|
||||||
|
async def _async_finish_backup(self, agent_ids: list[str]) -> None:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert self.backup_task is not None
|
||||||
|
try:
|
||||||
|
backup, tar_file_path = await self.backup_task
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
LOGGER.debug("Backup upload failed", exc_info=err)
|
||||||
|
else:
|
||||||
|
LOGGER.debug(
|
||||||
|
"Generated new backup with backup_id %s, uploading to agents %s",
|
||||||
|
backup.backup_id,
|
||||||
|
agent_ids,
|
||||||
|
)
|
||||||
|
local_file_paths = [
|
||||||
|
self.local_backup_agents[agent_id].get_backup_path(backup.backup_id)
|
||||||
|
for agent_id in agent_ids
|
||||||
|
if agent_id in self.local_backup_agents
|
||||||
|
]
|
||||||
|
keep_path = False
|
||||||
|
for local_path in local_file_paths:
|
||||||
|
if local_path == tar_file_path:
|
||||||
|
keep_path = True
|
||||||
|
continue
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
shutil.copy, tar_file_path, local_path
|
||||||
|
)
|
||||||
|
await self._async_upload_backup(
|
||||||
|
backup=backup, agent_ids=agent_ids, path=tar_file_path
|
||||||
|
)
|
||||||
|
if not keep_path:
|
||||||
|
await self.hass.async_add_executor_job(tar_file_path.unlink, True)
|
||||||
|
finally:
|
||||||
|
self.backup_task = None
|
||||||
|
self.finish_backup_task = None
|
||||||
|
|
||||||
|
async def async_restore_backup(
|
||||||
|
self,
|
||||||
|
backup_id: str,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
password: str | None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Initiate restoring a backup.
|
||||||
|
|
||||||
|
:param on_progress: A callback that will be called with the progress of the
|
||||||
|
restore. Home Assistant Core may need to be restarted during the backup
|
||||||
|
restore process, which means the restore process may not be able to report
|
||||||
|
when it's done.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if agent_id in self.local_backup_agents:
|
||||||
|
local_agent = self.local_backup_agents[agent_id]
|
||||||
|
if not await local_agent.async_get_backup(backup_id):
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"Backup {backup_id} not found in agent {agent_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
path = self.temp_backup_dir / f"{backup_id}.tar"
|
||||||
|
agent = self.backup_agents[agent_id]
|
||||||
|
if not await agent.async_get_backup(backup_id):
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"Backup {backup_id} not found in agent {agent_id}"
|
||||||
|
)
|
||||||
|
await agent.async_download_backup(backup_id, path=path)
|
||||||
|
|
||||||
|
await self._reader_writer.async_restore_backup(
|
||||||
|
backup_id=backup_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CoreBackupReaderWriter(BackupReaderWriter):
|
||||||
|
"""Class for reading and writing backups in core and container installations."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Initialize the backup reader/writer."""
|
||||||
|
self._hass = hass
|
||||||
|
self.temp_backup_dir = Path(hass.config.path("tmp_backups"))
|
||||||
|
|
||||||
|
async def async_create_backup(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
addons_included: list[str] | None,
|
||||||
|
agent_ids: list[str],
|
||||||
|
database_included: bool,
|
||||||
|
backup_name: str,
|
||||||
|
folders_included: list[str] | None,
|
||||||
|
on_progress: Callable[[BackupProgress], None] | None,
|
||||||
|
password: str | None,
|
||||||
|
) -> tuple[NewBackup, asyncio.Task[tuple[BaseBackup, Path]]]:
|
||||||
|
"""Initiate generating a backup."""
|
||||||
date_str = dt_util.now().isoformat()
|
date_str = dt_util.now().isoformat()
|
||||||
backup_id = _generate_backup_id(date_str, backup_name)
|
backup_id = _generate_backup_id(date_str, backup_name)
|
||||||
self.backup_task = self.hass.async_create_task(
|
|
||||||
|
backup_task = self._hass.async_create_task(
|
||||||
self._async_create_backup(
|
self._async_create_backup(
|
||||||
addons_included=addons_included,
|
addons_included=addons_included,
|
||||||
agent_ids=agent_ids,
|
agent_ids=agent_ids,
|
||||||
@@ -478,32 +562,35 @@ class BackupManager(BaseBackupManager[Backup]):
|
|||||||
name="backup_manager_create_backup",
|
name="backup_manager_create_backup",
|
||||||
eager_start=False, # To ensure the task is not started before we return
|
eager_start=False, # To ensure the task is not started before we return
|
||||||
)
|
)
|
||||||
return NewBackup(backup_id=backup_id)
|
|
||||||
|
return (NewBackup(backup_id=backup_id), backup_task)
|
||||||
|
|
||||||
async def _async_create_backup(
|
async def _async_create_backup(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
addons_included: list[str] | None,
|
addons_included: list[str] | None,
|
||||||
agent_ids: list[str],
|
agent_ids: list[str],
|
||||||
|
backup_id: str,
|
||||||
database_included: bool,
|
database_included: bool,
|
||||||
backup_name: str,
|
backup_name: str,
|
||||||
date_str: str,
|
date_str: str,
|
||||||
folders_included: list[str] | None,
|
folders_included: list[str] | None,
|
||||||
on_progress: Callable[[BackupProgress], None] | None,
|
on_progress: Callable[[BackupProgress], None] | None,
|
||||||
password: str | None,
|
password: str | None,
|
||||||
backup_id: str,
|
) -> tuple[BaseBackup, Path]:
|
||||||
) -> BaseBackup:
|
|
||||||
"""Generate a backup."""
|
"""Generate a backup."""
|
||||||
|
manager = self._hass.data[DATA_MANAGER]
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
local_file_paths = [
|
suggested_tar_file_path = None
|
||||||
self.local_backup_agents[agent_id].get_backup_path(backup_id)
|
for agent_id in agent_ids:
|
||||||
for agent_id in agent_ids
|
if local_agent := manager.local_backup_agents.get(agent_id):
|
||||||
if agent_id in self.local_backup_agents
|
suggested_tar_file_path = local_agent.get_backup_path(backup_id)
|
||||||
]
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.async_pre_backup_actions()
|
# Inform integrations a backup is about to be made
|
||||||
|
await manager.async_pre_backup_actions()
|
||||||
|
|
||||||
backup_data = {
|
backup_data = {
|
||||||
"compressed": True,
|
"compressed": True,
|
||||||
@@ -519,12 +606,12 @@ class BackupManager(BaseBackupManager[Backup]):
|
|||||||
"type": "partial",
|
"type": "partial",
|
||||||
}
|
}
|
||||||
|
|
||||||
tar_file_path, size_in_bytes = await self.hass.async_add_executor_job(
|
tar_file_path, size_in_bytes = await self._hass.async_add_executor_job(
|
||||||
self._mkdir_and_generate_backup_contents,
|
self._mkdir_and_generate_backup_contents,
|
||||||
local_file_paths,
|
|
||||||
backup_data,
|
backup_data,
|
||||||
database_included,
|
database_included,
|
||||||
password,
|
password,
|
||||||
|
suggested_tar_file_path,
|
||||||
)
|
)
|
||||||
backup = BaseBackup(
|
backup = BaseBackup(
|
||||||
backup_id=backup_id,
|
backup_id=backup_id,
|
||||||
@@ -533,35 +620,23 @@ class BackupManager(BaseBackupManager[Backup]):
|
|||||||
protected=password is not None,
|
protected=password is not None,
|
||||||
size=round(size_in_bytes / 1_048_576, 2),
|
size=round(size_in_bytes / 1_048_576, 2),
|
||||||
)
|
)
|
||||||
LOGGER.debug(
|
|
||||||
"Generated new backup with backup_id %s, uploading to agents %s",
|
|
||||||
backup_id,
|
|
||||||
agent_ids,
|
|
||||||
)
|
|
||||||
await self._async_upload_backup(
|
|
||||||
backup=backup, agent_ids=agent_ids, path=tar_file_path
|
|
||||||
)
|
|
||||||
if not local_file_paths:
|
|
||||||
await self.hass.async_add_executor_job(tar_file_path.unlink, True)
|
|
||||||
success = True
|
success = True
|
||||||
return backup
|
return (backup, tar_file_path)
|
||||||
finally:
|
finally:
|
||||||
if on_progress:
|
if on_progress:
|
||||||
on_progress(BackupProgress(done=True, stage=None, success=success))
|
on_progress(BackupProgress(done=True, stage=None, success=success))
|
||||||
self.backup_task = None
|
# Inform integrations the backup is done
|
||||||
await self.async_post_backup_actions()
|
await manager.async_post_backup_actions()
|
||||||
|
|
||||||
def _mkdir_and_generate_backup_contents(
|
def _mkdir_and_generate_backup_contents(
|
||||||
self,
|
self,
|
||||||
tar_file_paths: list[Path],
|
|
||||||
backup_data: dict[str, Any],
|
backup_data: dict[str, Any],
|
||||||
database_included: bool,
|
database_included: bool,
|
||||||
password: str | None,
|
password: str | None,
|
||||||
|
tar_file_path: Path | None,
|
||||||
) -> tuple[Path, int]:
|
) -> tuple[Path, int]:
|
||||||
"""Generate backup contents and return the size."""
|
"""Generate backup contents and return the size."""
|
||||||
if tar_file_paths:
|
if not tar_file_path:
|
||||||
tar_file_path = tar_file_paths[0]
|
|
||||||
else:
|
|
||||||
tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar"
|
tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar"
|
||||||
if not (backup_dir := tar_file_path.parent).exists():
|
if not (backup_dir := tar_file_path.parent).exists():
|
||||||
LOGGER.debug("Creating backup directory %s", backup_dir)
|
LOGGER.debug("Creating backup directory %s", backup_dir)
|
||||||
@@ -588,12 +663,10 @@ class BackupManager(BaseBackupManager[Backup]):
|
|||||||
) as core_tar:
|
) as core_tar:
|
||||||
atomic_contents_add(
|
atomic_contents_add(
|
||||||
tar_file=core_tar,
|
tar_file=core_tar,
|
||||||
origin_path=Path(self.hass.config.path()),
|
origin_path=Path(self._hass.config.path()),
|
||||||
excludes=excludes,
|
excludes=excludes,
|
||||||
arcname="data",
|
arcname="data",
|
||||||
)
|
)
|
||||||
for local_path in tar_file_paths[1:]:
|
|
||||||
shutil.copy(tar_file_path, local_path)
|
|
||||||
return (tar_file_path, tar_file_path.stat().st_size)
|
return (tar_file_path, tar_file_path.stat().st_size)
|
||||||
|
|
||||||
async def async_restore_backup(
|
async def async_restore_backup(
|
||||||
@@ -610,31 +683,22 @@ class BackupManager(BaseBackupManager[Backup]):
|
|||||||
will be handled during startup by the restore_backup module.
|
will be handled during startup by the restore_backup module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if agent_id in self.local_backup_agents:
|
manager = self._hass.data[DATA_MANAGER]
|
||||||
local_agent = self.local_backup_agents[agent_id]
|
if agent_id in manager.local_backup_agents:
|
||||||
if not await local_agent.async_get_backup(backup_id):
|
local_agent = manager.local_backup_agents[agent_id]
|
||||||
raise HomeAssistantError(
|
|
||||||
f"Backup {backup_id} not found in agent {agent_id}"
|
|
||||||
)
|
|
||||||
path = local_agent.get_backup_path(backup_id)
|
path = local_agent.get_backup_path(backup_id)
|
||||||
else:
|
else:
|
||||||
path = self.temp_backup_dir / f"{backup_id}.tar"
|
path = self.temp_backup_dir / f"{backup_id}.tar"
|
||||||
agent = self.backup_agents[agent_id]
|
|
||||||
if not await agent.async_get_backup(backup_id):
|
|
||||||
raise HomeAssistantError(
|
|
||||||
f"Backup {backup_id} not found in agent {agent_id}"
|
|
||||||
)
|
|
||||||
await agent.async_download_backup(backup_id, path=path)
|
|
||||||
|
|
||||||
def _write_restore_file() -> None:
|
def _write_restore_file() -> None:
|
||||||
"""Write the restore file."""
|
"""Write the restore file."""
|
||||||
Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text(
|
Path(self._hass.config.path(RESTORE_BACKUP_FILE)).write_text(
|
||||||
json.dumps({"path": path.as_posix(), "password": password}),
|
json.dumps({"path": path.as_posix(), "password": password}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.hass.async_add_executor_job(_write_restore_file)
|
await self._hass.async_add_executor_job(_write_restore_file)
|
||||||
await self.hass.services.async_call("homeassistant", "restart", {})
|
await self._hass.services.async_call("homeassistant", "restart", {})
|
||||||
|
|
||||||
|
|
||||||
def _generate_backup_id(date: str, name: str) -> str:
|
def _generate_backup_id(date: str, name: str) -> str:
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
"""Websocket commands for the Backup integration."""
|
"""Websocket commands for the Backup integration."""
|
||||||
|
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ from homeassistant.components import websocket_api
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
from .const import DATA_MANAGER, LOGGER
|
from .const import DATA_MANAGER, LOGGER
|
||||||
from .manager import BackupManager, BackupProgress
|
from .manager import BackupProgress
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@@ -254,7 +254,7 @@ async def backup_agents_download(
|
|||||||
msg: dict[str, Any],
|
msg: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Download an uploaded backup."""
|
"""Download an uploaded backup."""
|
||||||
manager = cast(BackupManager, hass.data[DATA_MANAGER])
|
manager = hass.data[DATA_MANAGER]
|
||||||
if not (agent := manager.backup_agents.get(msg["agent_id"])):
|
if not (agent := manager.backup_agents.get(msg["agent_id"])):
|
||||||
connection.send_error(
|
connection.send_error(
|
||||||
msg["id"], "unknown_agent", f"Agent {msg['agent_id']} not found"
|
msg["id"], "unknown_agent", f"Agent {msg['agent_id']} not found"
|
||||||
|
@@ -18,7 +18,11 @@ from homeassistant.components.backup import (
|
|||||||
BaseBackup,
|
BaseBackup,
|
||||||
backup as local_backup_platform,
|
backup as local_backup_platform,
|
||||||
)
|
)
|
||||||
from homeassistant.components.backup.manager import BackupProgress
|
from homeassistant.components.backup.const import DATA_MANAGER
|
||||||
|
from homeassistant.components.backup.manager import (
|
||||||
|
BackupProgress,
|
||||||
|
CoreBackupReaderWriter,
|
||||||
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
@@ -71,7 +75,8 @@ async def _mock_backup_generation(
|
|||||||
assert manager.backup_task is not None
|
assert manager.backup_task is not None
|
||||||
assert progress == []
|
assert progress == []
|
||||||
|
|
||||||
backup = await manager.backup_task
|
backup, _ = await manager.backup_task
|
||||||
|
await manager.finish_backup_task
|
||||||
assert progress == [BackupProgress(done=True, stage=None, success=True)]
|
assert progress == [BackupProgress(done=True, stage=None, success=True)]
|
||||||
|
|
||||||
assert mocked_json_bytes.call_count == 1
|
assert mocked_json_bytes.call_count == 1
|
||||||
@@ -132,13 +137,13 @@ async def _setup_backup_platform(
|
|||||||
|
|
||||||
async def test_constructor(hass: HomeAssistant) -> None:
|
async def test_constructor(hass: HomeAssistant) -> None:
|
||||||
"""Test BackupManager constructor."""
|
"""Test BackupManager constructor."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
assert manager.temp_backup_dir.as_posix() == hass.config.path("tmp_backups")
|
assert manager.temp_backup_dir.as_posix() == hass.config.path("tmp_backups")
|
||||||
|
|
||||||
|
|
||||||
async def test_load_backups(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
|
async def test_load_backups(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
|
||||||
"""Test loading backups."""
|
"""Test loading backups."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
|
||||||
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
||||||
await manager.load_platforms()
|
await manager.load_platforms()
|
||||||
@@ -170,7 +175,7 @@ async def test_load_backups_with_exception(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test loading backups with exception."""
|
"""Test loading backups with exception."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
|
||||||
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
||||||
await manager.load_platforms()
|
await manager.load_platforms()
|
||||||
@@ -194,7 +199,7 @@ async def test_deleting_backup(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test deleting backup."""
|
"""Test deleting backup."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
|
||||||
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
||||||
await manager.load_platforms()
|
await manager.load_platforms()
|
||||||
@@ -213,7 +218,7 @@ async def test_deleting_non_existing_backup(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test deleting not existing backup."""
|
"""Test deleting not existing backup."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
|
||||||
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
||||||
await manager.load_platforms()
|
await manager.load_platforms()
|
||||||
@@ -227,7 +232,7 @@ async def test_getting_backup_that_does_not_exist(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test getting backup that does not exist."""
|
"""Test getting backup that does not exist."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
|
||||||
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
||||||
await manager.load_platforms()
|
await manager.load_platforms()
|
||||||
@@ -253,7 +258,7 @@ async def test_getting_backup_that_does_not_exist(
|
|||||||
async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None:
|
async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None:
|
||||||
"""Test generate backup."""
|
"""Test generate backup."""
|
||||||
event = asyncio.Event()
|
event = asyncio.Event()
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
manager.backup_task = hass.async_create_task(event.wait())
|
manager.backup_task = hass.async_create_task(event.wait())
|
||||||
with pytest.raises(HomeAssistantError, match="Backup already in progress"):
|
with pytest.raises(HomeAssistantError, match="Backup already in progress"):
|
||||||
await manager.async_create_backup(
|
await manager.async_create_backup(
|
||||||
@@ -279,7 +284,7 @@ async def test_async_create_backup_wrong_agent_id(
|
|||||||
hass: HomeAssistant, agent_ids: list[str], expected_error: str
|
hass: HomeAssistant, agent_ids: list[str], expected_error: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test generate backup."""
|
"""Test generate backup."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
with pytest.raises(HomeAssistantError, match=expected_error):
|
with pytest.raises(HomeAssistantError, match=expected_error):
|
||||||
await manager.async_create_backup(
|
await manager.async_create_backup(
|
||||||
addons_included=[],
|
addons_included=[],
|
||||||
@@ -320,7 +325,8 @@ async def test_async_create_backup(
|
|||||||
backup_directory: str,
|
backup_directory: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test generate backup."""
|
"""Test generate backup."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
hass.data[DATA_MANAGER] = manager
|
||||||
|
|
||||||
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
||||||
await _setup_backup_platform(
|
await _setup_backup_platform(
|
||||||
@@ -358,7 +364,7 @@ async def test_loading_platforms(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test loading backup platforms."""
|
"""Test loading backup platforms."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
|
||||||
assert not manager.platforms
|
assert not manager.platforms
|
||||||
|
|
||||||
@@ -383,7 +389,7 @@ async def test_loading_agents(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test loading backup agents."""
|
"""Test loading backup agents."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
|
||||||
assert not manager.platforms
|
assert not manager.platforms
|
||||||
|
|
||||||
@@ -407,7 +413,7 @@ async def test_not_loading_bad_platforms(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test loading backup platforms."""
|
"""Test loading backup platforms."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
|
||||||
assert not manager.platforms
|
assert not manager.platforms
|
||||||
|
|
||||||
@@ -424,7 +430,7 @@ async def test_exception_plaform_pre(
|
|||||||
hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
|
hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test exception in pre step."""
|
"""Test exception in pre step."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
manager.loaded_backups = True
|
manager.loaded_backups = True
|
||||||
|
|
||||||
async def _mock_step(hass: HomeAssistant) -> None:
|
async def _mock_step(hass: HomeAssistant) -> None:
|
||||||
@@ -447,7 +453,7 @@ async def test_exception_plaform_post(
|
|||||||
hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
|
hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test exception in post step."""
|
"""Test exception in post step."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
manager.loaded_backups = True
|
manager.loaded_backups = True
|
||||||
|
|
||||||
async def _mock_step(hass: HomeAssistant) -> None:
|
async def _mock_step(hass: HomeAssistant) -> None:
|
||||||
@@ -471,7 +477,7 @@ async def test_async_receive_backup(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test receiving a backup file."""
|
"""Test receiving a backup file."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
|
||||||
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
||||||
await manager.load_platforms()
|
await manager.load_platforms()
|
||||||
@@ -516,7 +522,8 @@ async def test_async_trigger_restore(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test trigger restore."""
|
"""Test trigger restore."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
hass.data[DATA_MANAGER] = manager
|
||||||
|
|
||||||
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
||||||
await manager.load_platforms()
|
await manager.load_platforms()
|
||||||
@@ -545,7 +552,8 @@ async def test_async_trigger_restore_with_password(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test trigger restore."""
|
"""Test trigger restore."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
hass.data[DATA_MANAGER] = manager
|
||||||
|
|
||||||
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
||||||
await manager.load_platforms()
|
await manager.load_platforms()
|
||||||
@@ -573,7 +581,7 @@ async def test_async_trigger_restore_with_password(
|
|||||||
|
|
||||||
async def test_async_trigger_restore_missing_backup(hass: HomeAssistant) -> None:
|
async def test_async_trigger_restore_missing_backup(hass: HomeAssistant) -> None:
|
||||||
"""Test trigger restore."""
|
"""Test trigger restore."""
|
||||||
manager = BackupManager(hass)
|
manager = BackupManager(hass, CoreBackupReaderWriter(hass))
|
||||||
|
|
||||||
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform)
|
||||||
await manager.load_platforms()
|
await manager.load_platforms()
|
||||||
|
Reference in New Issue
Block a user