Skip to content

Commit

Permalink
Add additional options to WS command backup/generate (#130530)
Browse files Browse the repository at this point in the history
* Add additional options to WS command backup/generate

* Improve test

* Improve test
  • Loading branch information
emontnemery authored Nov 14, 2024
1 parent f99b319 commit 0599983
Show file tree
Hide file tree
Showing 8 changed files with 212 additions and 24 deletions.
8 changes: 7 additions & 1 deletion homeassistant/components/backup/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/backup/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,8 @@
"OZW_Log.txt",
"tts/*",
]

EXCLUDE_DATABASE_FROM_BACKUP = [
"home-assistant_v2.db",
"home-assistant_v2.db-wal",
]
49 changes: 41 additions & 8 deletions homeassistant/components/backup/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
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
Expand Down Expand Up @@ -180,10 +180,18 @@ async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
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]:
Expand Down Expand Up @@ -380,28 +388,44 @@ def _move_and_cleanup() -> None:
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
)
return NewBackup(slug=slug)

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
Expand All @@ -414,14 +438,18 @@ async def _async_create_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")
size_in_bytes = await self.hass.async_add_executor_job(
self._mkdir_and_generate_backup_contents,
tar_file_path,
backup_data,
database_included,
)
backup = Backup(
slug=slug,
Expand All @@ -445,12 +473,17 @@ def _mkdir_and_generate_backup_contents(
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
)
Expand All @@ -467,7 +500,7 @@ def _mkdir_and_generate_backup_contents(
atomic_contents_add(
tar_file=core_tar,
origin_path=Path(self.hass.config.path()),
excludes=EXCLUDE_FROM_BACKUP,
excludes=excludes,
arcname="data",
)

Expand Down
18 changes: 16 additions & 2 deletions homeassistant/components/backup/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)


Expand Down
3 changes: 2 additions & 1 deletion tests/components/backup/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,12 @@ def _mock_iterdir(path: Path) -> list[Path]:
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",
Expand Down
20 changes: 20 additions & 0 deletions tests/components/backup/snapshots/test_websocket.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
76 changes: 65 additions & 11 deletions tests/components/backup/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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
Expand All @@ -24,9 +24,20 @@

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."""

Expand All @@ -37,7 +48,13 @@ def on_progress(_progress: BackupProgress) -> None:
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 == []

Expand All @@ -47,8 +64,26 @@ def on_progress(_progress: BackupProgress) -> None:
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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -338,7 +388,9 @@ async def async_upload_backup(self, **kwargs: Any) -> None:
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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -419,7 +473,7 @@ async def _mock_step(hass: HomeAssistant) -> None:
)

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(
Expand All @@ -442,7 +496,7 @@ async def _mock_step(hass: HomeAssistant) -> None:
)

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(
Expand Down
Loading

0 comments on commit 0599983

Please sign in to comment.