Add additional options to WS command backup/generate (#130530)

* Add additional options to WS command backup/generate

* Improve test

* Improve test
This commit is contained in:
Erik Montnemery
2024-11-14 09:31:08 +01:00
committed by GitHub
parent f99b319048
commit 0599983a37
8 changed files with 212 additions and 24 deletions

View File

@@ -39,7 +39,13 @@ 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.async_create_backup(on_progress=None)
await backup_manager.async_create_backup(
addons_included=None,
database_included=True,
folders_included=None,
name=None,
on_progress=None,
)
if backup_task := backup_manager.backup_task:
await backup_task

View File

@@ -26,3 +26,8 @@ EXCLUDE_FROM_BACKUP = [
"OZW_Log.txt",
"tts/*",
]
EXCLUDE_DATABASE_FROM_BACKUP = [
"home-assistant_v2.db",
"home-assistant_v2.db-wal",
]

View File

@@ -32,7 +32,7 @@ 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_FROM_BACKUP, LOGGER
from .const import DOMAIN, EXCLUDE_DATABASE_FROM_BACKUP, EXCLUDE_FROM_BACKUP, LOGGER
from .models import BackupUploadMetadata, BaseBackup
BUF_SIZE = 2**20 * 4 # 4MB
@@ -180,10 +180,18 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]):
async def async_create_backup(
self,
*,
addons_included: list[str] | None,
database_included: bool,
folders_included: list[str] | None,
name: str | None,
on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any,
) -> NewBackup:
"""Generate a backup."""
"""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) -> dict[str, _BackupT]:
@@ -380,17 +388,29 @@ class BackupManager(BaseBackupManager[Backup]):
async def async_create_backup(
self,
*,
addons_included: list[str] | None,
database_included: bool,
folders_included: list[str] | None,
name: str | None,
on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any,
) -> NewBackup:
"""Generate a backup."""
"""Initiate generating a backup."""
if self.backup_task:
raise HomeAssistantError("Backup already in progress")
backup_name = f"Core {HAVERSION}"
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(backup_name, date_str, slug, on_progress),
self._async_create_backup(
addons_included=addons_included,
backup_name=backup_name,
database_included=database_included,
date_str=date_str,
folders_included=folders_included,
on_progress=on_progress,
slug=slug,
),
name="backup_manager_create_backup",
eager_start=False, # To ensure the task is not started before we return
)
@@ -398,10 +418,14 @@ class BackupManager(BaseBackupManager[Backup]):
async def _async_create_backup(
self,
*,
addons_included: list[str] | None,
database_included: bool,
backup_name: str,
date_str: str,
slug: str,
folders_included: list[str] | None,
on_progress: Callable[[BackupProgress], None] | None,
slug: str,
) -> Backup:
"""Generate a backup."""
success = False
@@ -414,7 +438,10 @@ class BackupManager(BaseBackupManager[Backup]):
"date": date_str,
"type": "partial",
"folders": ["homeassistant"],
"homeassistant": {"version": HAVERSION},
"homeassistant": {
"exclude_database": not database_included,
"version": HAVERSION,
},
"compressed": True,
}
tar_file_path = Path(self.backup_dir, f"{backup_data['slug']}.tar")
@@ -422,6 +449,7 @@ class BackupManager(BaseBackupManager[Backup]):
self._mkdir_and_generate_backup_contents,
tar_file_path,
backup_data,
database_included,
)
backup = Backup(
slug=slug,
@@ -445,12 +473,17 @@ class BackupManager(BaseBackupManager[Backup]):
self,
tar_file_path: Path,
backup_data: dict[str, Any],
database_included: bool,
) -> int:
"""Generate backup contents and return the size."""
if not self.backup_dir.exists():
LOGGER.debug("Creating backup directory")
self.backup_dir.mkdir()
excludes = EXCLUDE_FROM_BACKUP
if not database_included:
excludes = excludes + EXCLUDE_DATABASE_FROM_BACKUP
outer_secure_tarfile = SecureTarFile(
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
)
@@ -467,7 +500,7 @@ class BackupManager(BaseBackupManager[Backup]):
atomic_contents_add(
tar_file=core_tar,
origin_path=Path(self.hass.config.path()),
excludes=EXCLUDE_FROM_BACKUP,
excludes=excludes,
arcname="data",
)

View File

@@ -112,7 +112,15 @@ async def handle_restore(
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/generate"})
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/generate",
vol.Optional("addons_included"): [str],
vol.Optional("database_included", default=True): bool,
vol.Optional("folders_included"): [str],
vol.Optional("name"): str,
}
)
@websocket_api.async_response
async def handle_create(
hass: HomeAssistant,
@@ -124,7 +132,13 @@ async def handle_create(
def on_progress(progress: BackupProgress) -> None:
connection.send_message(websocket_api.event_message(msg["id"], progress))
backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress)
backup = await hass.data[DATA_MANAGER].async_create_backup(
addons_included=msg.get("addons_included"),
database_included=msg["database_included"],
folders_included=msg.get("folders_included"),
name=msg.get("name"),
on_progress=on_progress,
)
connection.send_result(msg["id"], backup)

View File

@@ -43,11 +43,12 @@ def mock_backup_generation_fixture(
Path("test.txt"),
Path(".DS_Store"),
Path(".storage"),
Path("home-assistant_v2.db"),
]
with (
patch("pathlib.Path.iterdir", _mock_iterdir),
patch("pathlib.Path.stat", MagicMock(st_size=123)),
patch("pathlib.Path.stat", return_value=MagicMock(st_size=123)),
patch("pathlib.Path.is_file", lambda x: x.name != ".storage"),
patch(
"pathlib.Path.is_dir",

View File

@@ -403,6 +403,26 @@
'type': 'event',
})
# ---
# name: test_generate_without_hassio[params0-expected_extra_call_params0]
dict({
'id': 1,
'result': dict({
'slug': 'abc123',
}),
'success': True,
'type': 'result',
})
# ---
# name: test_generate_without_hassio[params1-expected_extra_call_params1]
dict({
'id': 1,
'result': dict({
'slug': 'abc123',
}),
'success': True,
'type': 'result',
})
# ---
# name: test_info[with_hassio]
dict({
'error': dict({

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, mock_open, patch
import aiohttp
from multidict import CIMultiDict, CIMultiDictProxy
@@ -24,9 +24,20 @@ from .common import TEST_BACKUP, BackupAgentTest
from tests.common import MockPlatform, mock_platform
_EXPECTED_FILES_WITH_DATABASE = {
True: ["test.txt", ".storage", "home-assistant_v2.db"],
False: ["test.txt", ".storage"],
}
async def _mock_backup_generation(
manager: BackupManager, mocked_json_bytes: Mock, mocked_tarfile: Mock
hass: HomeAssistant,
manager: BackupManager,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
*,
database_included: bool = True,
name: str | None = "Core 2025.1.0",
) -> None:
"""Mock backup generator."""
@@ -37,7 +48,13 @@ async def _mock_backup_generation(
progress.append(_progress)
assert manager.backup_task is None
await manager.async_create_backup(on_progress=on_progress)
await manager.async_create_backup(
addons_included=[],
database_included=database_included,
folders_included=[],
name=name,
on_progress=on_progress,
)
assert manager.backup_task is not None
assert progress == []
@@ -47,8 +64,26 @@ async def _mock_backup_generation(
assert mocked_json_bytes.call_count == 1
backup_json_dict = mocked_json_bytes.call_args[0][0]
assert isinstance(backup_json_dict, dict)
assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"}
assert backup_json_dict == {
"compressed": True,
"date": ANY,
"folders": ["homeassistant"],
"homeassistant": {
"exclude_database": not database_included,
"version": "2025.1.0",
},
"name": name,
"slug": ANY,
"type": "partial",
}
assert manager.backup_dir.as_posix() in str(mocked_tarfile.call_args_list[0][0][0])
outer_tar = mocked_tarfile.return_value
core_tar = outer_tar.create_inner_tar.return_value.__enter__.return_value
expected_files = [call(hass.config.path(), arcname="data", recursive=False)] + [
call(file, arcname=f"data/{file}", recursive=False)
for file in _EXPECTED_FILES_WITH_DATABASE[database_included]
]
assert core_tar.add.call_args_list == expected_files
return backup
@@ -158,22 +193,35 @@ async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None:
manager = BackupManager(hass)
manager.backup_task = hass.async_create_task(event.wait())
with pytest.raises(HomeAssistantError, match="Backup already in progress"):
await manager.async_create_backup(on_progress=None)
await manager.async_create_backup(
addons_included=[],
database_included=True,
folders_included=[],
name=None,
on_progress=None,
)
event.set()
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
"params",
[{}, {"database_included": True, "name": "abc123"}, {"database_included": False}],
)
async def test_async_create_backup(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
params: dict,
) -> None:
"""Test generate backup."""
manager = BackupManager(hass)
manager.loaded_backups = True
await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
await _mock_backup_generation(
hass, manager, mocked_json_bytes, mocked_tarfile, **params
)
assert "Generated new backup with slug " in caplog.text
assert "Creating backup directory" in caplog.text
@@ -280,7 +328,9 @@ async def test_syncing_backup(
await manager.load_platforms()
await hass.async_block_till_done()
backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
backup = await _mock_backup_generation(
hass, manager, mocked_json_bytes, mocked_tarfile
)
with (
patch(
@@ -338,7 +388,9 @@ async def test_syncing_backup_with_exception(
await manager.load_platforms()
await hass.async_block_till_done()
backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
backup = await _mock_backup_generation(
hass, manager, mocked_json_bytes, mocked_tarfile
)
with (
patch(
@@ -391,7 +443,9 @@ async def test_syncing_backup_no_agents(
await manager.load_platforms()
await hass.async_block_till_done()
backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
backup = await _mock_backup_generation(
hass, manager, mocked_json_bytes, mocked_tarfile
)
with patch(
"homeassistant.components.backup.agent.BackupAgent.async_upload_backup"
) as mocked_async_upload_backup:
@@ -419,7 +473,7 @@ async def test_exception_plaform_pre(
)
with pytest.raises(HomeAssistantError):
await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
await _mock_backup_generation(hass, manager, mocked_json_bytes, mocked_tarfile)
async def test_exception_plaform_post(
@@ -442,7 +496,7 @@ async def test_exception_plaform_post(
)
with pytest.raises(HomeAssistantError):
await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
await _mock_backup_generation(hass, manager, mocked_json_bytes, mocked_tarfile)
async def test_loading_platforms_when_running_async_pre_backup_actions(

View File

@@ -1,13 +1,14 @@
"""Tests for the Backup integration."""
from pathlib import Path
from unittest.mock import AsyncMock, patch
from unittest.mock import ANY, AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.components.backup.manager import NewBackup
from homeassistant.components.backup.models import BaseBackup
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -153,6 +154,60 @@ async def test_generate(
assert await client.receive_json() == snapshot
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
("params", "expected_extra_call_params"),
[
({}, {}),
(
{
"addons_included": ["ssl"],
"database_included": False,
"folders_included": ["media"],
"name": "abc123",
},
{
"addons_included": ["ssl"],
"database_included": False,
"folders_included": ["media"],
"name": "abc123",
},
),
],
)
async def test_generate_without_hassio(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
params: dict,
expected_extra_call_params: tuple,
) -> None:
"""Test generating a backup."""
await setup_backup_integration(hass, with_hassio=False)
client = await hass_ws_client(hass)
freezer.move_to("2024-11-13 12:01:00+01:00")
await hass.async_block_till_done()
with patch(
"homeassistant.components.backup.manager.BackupManager.async_create_backup",
return_value=NewBackup("abc123"),
) as generate_backup:
await client.send_json_auto_id({"type": "backup/generate"} | params)
assert await client.receive_json() == snapshot
generate_backup.assert_called_once_with(
**{
"addons_included": None,
"database_included": True,
"folders_included": None,
"name": None,
"on_progress": ANY,
}
| expected_extra_call_params
)
@pytest.mark.parametrize(
"with_hassio",
[