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: async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups.""" """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: if backup_task := backup_manager.backup_task:
await backup_task await backup_task

View File

@@ -26,3 +26,8 @@ EXCLUDE_FROM_BACKUP = [
"OZW_Log.txt", "OZW_Log.txt",
"tts/*", "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 homeassistant.util.json import json_loads_object
from .agent import BackupAgent, BackupAgentPlatformProtocol 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 from .models import BackupUploadMetadata, BaseBackup
BUF_SIZE = 2**20 * 4 # 4MB BUF_SIZE = 2**20 * 4 # 4MB
@@ -180,10 +180,18 @@ class BaseBackupManager(abc.ABC, Generic[_BackupT]):
async def async_create_backup( async def async_create_backup(
self, self,
*, *,
addons_included: list[str] | None,
database_included: bool,
folders_included: list[str] | None,
name: str | None,
on_progress: Callable[[BackupProgress], None] | None, on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any, **kwargs: Any,
) -> NewBackup: ) -> 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 @abc.abstractmethod
async def async_get_backups(self, **kwargs: Any) -> dict[str, _BackupT]: async def async_get_backups(self, **kwargs: Any) -> dict[str, _BackupT]:
@@ -380,17 +388,29 @@ class BackupManager(BaseBackupManager[Backup]):
async def async_create_backup( async def async_create_backup(
self, self,
*, *,
addons_included: list[str] | None,
database_included: bool,
folders_included: list[str] | None,
name: str | None,
on_progress: Callable[[BackupProgress], None] | None, on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any, **kwargs: Any,
) -> NewBackup: ) -> NewBackup:
"""Generate a backup.""" """Initiate generating a backup."""
if self.backup_task: if self.backup_task:
raise HomeAssistantError("Backup already in progress") raise HomeAssistantError("Backup already in progress")
backup_name = f"Core {HAVERSION}" backup_name = name or f"Core {HAVERSION}"
date_str = dt_util.now().isoformat() date_str = dt_util.now().isoformat()
slug = _generate_slug(date_str, backup_name) slug = _generate_slug(date_str, backup_name)
self.backup_task = self.hass.async_create_task( 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", 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
) )
@@ -398,10 +418,14 @@ class BackupManager(BaseBackupManager[Backup]):
async def _async_create_backup( async def _async_create_backup(
self, self,
*,
addons_included: list[str] | None,
database_included: bool,
backup_name: str, backup_name: str,
date_str: str, date_str: str,
slug: str, folders_included: list[str] | None,
on_progress: Callable[[BackupProgress], None] | None, on_progress: Callable[[BackupProgress], None] | None,
slug: str,
) -> Backup: ) -> Backup:
"""Generate a backup.""" """Generate a backup."""
success = False success = False
@@ -414,7 +438,10 @@ class BackupManager(BaseBackupManager[Backup]):
"date": date_str, "date": date_str,
"type": "partial", "type": "partial",
"folders": ["homeassistant"], "folders": ["homeassistant"],
"homeassistant": {"version": HAVERSION}, "homeassistant": {
"exclude_database": not database_included,
"version": HAVERSION,
},
"compressed": True, "compressed": True,
} }
tar_file_path = Path(self.backup_dir, f"{backup_data['slug']}.tar") 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, self._mkdir_and_generate_backup_contents,
tar_file_path, tar_file_path,
backup_data, backup_data,
database_included,
) )
backup = Backup( backup = Backup(
slug=slug, slug=slug,
@@ -445,12 +473,17 @@ class BackupManager(BaseBackupManager[Backup]):
self, self,
tar_file_path: Path, tar_file_path: Path,
backup_data: dict[str, Any], backup_data: dict[str, Any],
database_included: bool,
) -> int: ) -> int:
"""Generate backup contents and return the size.""" """Generate backup contents and return the size."""
if not self.backup_dir.exists(): if not self.backup_dir.exists():
LOGGER.debug("Creating backup directory") LOGGER.debug("Creating backup directory")
self.backup_dir.mkdir() self.backup_dir.mkdir()
excludes = EXCLUDE_FROM_BACKUP
if not database_included:
excludes = excludes + EXCLUDE_DATABASE_FROM_BACKUP
outer_secure_tarfile = SecureTarFile( outer_secure_tarfile = SecureTarFile(
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
) )
@@ -467,7 +500,7 @@ class BackupManager(BaseBackupManager[Backup]):
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=EXCLUDE_FROM_BACKUP, excludes=excludes,
arcname="data", arcname="data",
) )

View File

@@ -112,7 +112,15 @@ async def handle_restore(
@websocket_api.require_admin @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 @websocket_api.async_response
async def handle_create( async def handle_create(
hass: HomeAssistant, hass: HomeAssistant,
@@ -124,7 +132,13 @@ async def handle_create(
def on_progress(progress: BackupProgress) -> None: def on_progress(progress: BackupProgress) -> None:
connection.send_message(websocket_api.event_message(msg["id"], progress)) 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) connection.send_result(msg["id"], backup)

View File

@@ -43,11 +43,12 @@ def mock_backup_generation_fixture(
Path("test.txt"), Path("test.txt"),
Path(".DS_Store"), Path(".DS_Store"),
Path(".storage"), Path(".storage"),
Path("home-assistant_v2.db"),
] ]
with ( with (
patch("pathlib.Path.iterdir", _mock_iterdir), 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_file", lambda x: x.name != ".storage"),
patch( patch(
"pathlib.Path.is_dir", "pathlib.Path.is_dir",

View File

@@ -403,6 +403,26 @@
'type': 'event', '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] # name: test_info[with_hassio]
dict({ dict({
'error': dict({ 'error': dict({

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from typing import Any 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 import aiohttp
from multidict import CIMultiDict, CIMultiDictProxy from multidict import CIMultiDict, CIMultiDictProxy
@@ -24,9 +24,20 @@ from .common import TEST_BACKUP, BackupAgentTest
from tests.common import MockPlatform, mock_platform 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( 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: ) -> None:
"""Mock backup generator.""" """Mock backup generator."""
@@ -37,7 +48,13 @@ async def _mock_backup_generation(
progress.append(_progress) progress.append(_progress)
assert manager.backup_task is None 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 manager.backup_task is not None
assert progress == [] assert progress == []
@@ -47,8 +64,26 @@ async def _mock_backup_generation(
assert mocked_json_bytes.call_count == 1 assert mocked_json_bytes.call_count == 1
backup_json_dict = mocked_json_bytes.call_args[0][0] backup_json_dict = mocked_json_bytes.call_args[0][0]
assert isinstance(backup_json_dict, dict) 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]) 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 return backup
@@ -158,22 +193,35 @@ async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None:
manager = BackupManager(hass) manager = BackupManager(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(on_progress=None) await manager.async_create_backup(
addons_included=[],
database_included=True,
folders_included=[],
name=None,
on_progress=None,
)
event.set() event.set()
@pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
"params",
[{}, {"database_included": True, "name": "abc123"}, {"database_included": False}],
)
async def test_async_create_backup( async def test_async_create_backup(
hass: HomeAssistant, hass: HomeAssistant,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
mocked_json_bytes: Mock, mocked_json_bytes: Mock,
mocked_tarfile: Mock, mocked_tarfile: Mock,
params: dict,
) -> None: ) -> None:
"""Test generate backup.""" """Test generate backup."""
manager = BackupManager(hass) manager = BackupManager(hass)
manager.loaded_backups = True 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 "Generated new backup with slug " in caplog.text
assert "Creating backup directory" in caplog.text assert "Creating backup directory" in caplog.text
@@ -280,7 +328,9 @@ async def test_syncing_backup(
await manager.load_platforms() await manager.load_platforms()
await hass.async_block_till_done() 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 ( with (
patch( patch(
@@ -338,7 +388,9 @@ async def test_syncing_backup_with_exception(
await manager.load_platforms() await manager.load_platforms()
await hass.async_block_till_done() 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 ( with (
patch( patch(
@@ -391,7 +443,9 @@ async def test_syncing_backup_no_agents(
await manager.load_platforms() await manager.load_platforms()
await hass.async_block_till_done() 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( with patch(
"homeassistant.components.backup.agent.BackupAgent.async_upload_backup" "homeassistant.components.backup.agent.BackupAgent.async_upload_backup"
) as mocked_async_upload_backup: ) as mocked_async_upload_backup:
@@ -419,7 +473,7 @@ async def test_exception_plaform_pre(
) )
with pytest.raises(HomeAssistantError): 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( async def test_exception_plaform_post(
@@ -442,7 +496,7 @@ async def test_exception_plaform_post(
) )
with pytest.raises(HomeAssistantError): 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( async def test_loading_platforms_when_running_async_pre_backup_actions(

View File

@@ -1,13 +1,14 @@
"""Tests for the Backup integration.""" """Tests for the Backup integration."""
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import ANY, AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.components.backup.manager import NewBackup
from homeassistant.components.backup.models import BaseBackup from homeassistant.components.backup.models import BaseBackup
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@@ -153,6 +154,60 @@ async def test_generate(
assert await client.receive_json() == snapshot 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( @pytest.mark.parametrize(
"with_hassio", "with_hassio",
[ [