Add backup endpoints to the onboarding integration (#136051)

* Add backup endpoints to the onboarding integration

* Add backup as after dependency of onboarding

* Add test snapshots

* Fix stale docstrings

* Add utility function for getting the backup manager instance

* Return backup_id when uploading backup

* Change /api/onboarding/backup/restore to accept a JSON body

* Fix with_backup_manager
This commit is contained in:
Erik Montnemery 2025-01-29 12:32:18 +01:00 committed by GitHub
parent 706a01837c
commit 7249c02655
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 514 additions and 14 deletions

View File

@ -53,6 +53,7 @@ __all__ = [
"NewBackup",
"RestoreBackupEvent",
"WrittenBackup",
"async_get_manager",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)

View File

@ -144,13 +144,17 @@ class DownloadBackupView(HomeAssistantView):
class UploadBackupView(HomeAssistantView):
"""Generate backup view."""
"""Upload backup view."""
url = "/api/backup/upload"
name = "api:backup:upload"
@require_admin
async def post(self, request: Request) -> Response:
"""Upload a backup file."""
return await self._post(request)
async def _post(self, request: Request) -> Response:
"""Upload a backup file."""
try:
agent_ids = request.query.getall("agent_id")
@ -161,7 +165,9 @@ class UploadBackupView(HomeAssistantView):
contents = cast(BodyPartReader, await reader.next())
try:
await manager.async_receive_backup(contents=contents, agent_ids=agent_ids)
backup_id = await manager.async_receive_backup(
contents=contents, agent_ids=agent_ids
)
except OSError as err:
return Response(
body=f"Can't write backup file: {err}",
@ -175,4 +181,4 @@ class UploadBackupView(HomeAssistantView):
except asyncio.CancelledError:
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
return Response(status=HTTPStatus.CREATED)
return self.json({"backup_id": backup_id}, status_code=HTTPStatus.CREATED)

View File

@ -298,6 +298,7 @@ class BackupManager:
# Latest backup event and backup event subscribers
self.last_event: ManagerStateEvent = IdleEvent()
self.last_non_idle_event: ManagerStateEvent | None = None
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
async def async_setup(self) -> None:
@ -620,7 +621,7 @@ class BackupManager:
*,
agent_ids: list[str],
contents: aiohttp.BodyPartReader,
) -> None:
) -> str:
"""Receive and store a backup file from upload."""
if self.state is not BackupManagerState.IDLE:
raise BackupManagerError(f"Backup manager busy: {self.state}")
@ -632,7 +633,9 @@ class BackupManager:
)
)
try:
await self._async_receive_backup(agent_ids=agent_ids, contents=contents)
backup_id = await self._async_receive_backup(
agent_ids=agent_ids, contents=contents
)
except Exception:
self.async_on_backup_event(
ReceiveBackupEvent(
@ -650,6 +653,7 @@ class BackupManager:
state=ReceiveBackupState.COMPLETED,
)
)
return backup_id
finally:
self.async_on_backup_event(IdleEvent())
@ -658,7 +662,7 @@ class BackupManager:
*,
agent_ids: list[str],
contents: aiohttp.BodyPartReader,
) -> None:
) -> str:
"""Receive and store a backup file from upload."""
contents.chunk_size = BUF_SIZE
self.async_on_backup_event(
@ -687,6 +691,7 @@ class BackupManager:
)
await written_backup.release_stream()
self.known_backups.add(written_backup.backup, agent_errors)
return written_backup.backup.backup_id
async def async_create_backup(
self,
@ -1041,6 +1046,8 @@ class BackupManager:
if (current_state := self.state) != (new_state := event.manager_state):
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
self.last_event = event
if not isinstance(event, IdleEvent):
self.last_non_idle_event = event
for subscription in self._backup_event_subscriptions:
subscription(event)

View File

@ -1,7 +1,7 @@
{
"domain": "onboarding",
"name": "Home Assistant Onboarding",
"after_dependencies": ["hassio"],
"after_dependencies": ["backup", "hassio"],
"codeowners": ["@home-assistant/core"],
"dependencies": ["auth", "http", "person"],
"documentation": "https://www.home-assistant.io/integrations/onboarding",

View File

@ -3,9 +3,10 @@
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from collections.abc import Callable, Coroutine
from functools import wraps
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, Concatenate, cast
from aiohttp import web
from aiohttp.web_exceptions import HTTPUnauthorized
@ -15,10 +16,18 @@ from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.providers.homeassistant import HassAuthProvider
from homeassistant.components import person
from homeassistant.components.auth import indieauth
from homeassistant.components.backup import (
BackupManager,
Folder,
IncorrectPasswordError,
async_get_manager as async_get_backup_manager,
http as backup_http,
)
from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.system_info import async_get_system_info
@ -50,6 +59,9 @@ async def async_setup(
hass.http.register_view(CoreConfigOnboardingView(data, store))
hass.http.register_view(IntegrationOnboardingView(data, store))
hass.http.register_view(AnalyticsOnboardingView(data, store))
hass.http.register_view(BackupInfoView(data))
hass.http.register_view(RestoreBackupView(data))
hass.http.register_view(UploadBackupView(data))
class OnboardingView(HomeAssistantView):
@ -312,6 +324,119 @@ class AnalyticsOnboardingView(_BaseOnboardingView):
return self.json({})
class BackupOnboardingView(HomeAssistantView):
"""Backup onboarding view."""
requires_auth = False
def __init__(self, data: OnboardingStoreData) -> None:
"""Initialize the view."""
self._data = data
def with_backup_manager[_ViewT: BackupOnboardingView, **_P](
func: Callable[
Concatenate[_ViewT, BackupManager, web.Request, _P],
Coroutine[Any, Any, web.Response],
],
) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]:
"""Home Assistant API decorator to check onboarding and inject manager."""
@wraps(func)
async def with_backup(
self: _ViewT,
request: web.Request,
*args: _P.args,
**kwargs: _P.kwargs,
) -> web.Response:
"""Check admin and call function."""
if self._data["done"]:
raise HTTPUnauthorized
try:
manager = async_get_backup_manager(request.app[KEY_HASS])
except HomeAssistantError:
return self.json(
{"error": "backup_disabled"},
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)
return await func(self, manager, request, *args, **kwargs)
return with_backup
class BackupInfoView(BackupOnboardingView):
"""Get backup info view."""
url = "/api/onboarding/backup/info"
name = "api:onboarding:backup:info"
@with_backup_manager
async def get(self, manager: BackupManager, request: web.Request) -> web.Response:
"""Return backup info."""
backups, _ = await manager.async_get_backups()
return self.json(
{
"backups": [backup.as_frontend_json() for backup in backups.values()],
"state": manager.state,
"last_non_idle_event": manager.last_non_idle_event,
}
)
class RestoreBackupView(BackupOnboardingView):
"""Restore backup view."""
url = "/api/onboarding/backup/restore"
name = "api:onboarding:backup:restore"
@RequestDataValidator(
vol.Schema(
{
vol.Required("backup_id"): str,
vol.Required("agent_id"): str,
vol.Optional("password"): str,
vol.Optional("restore_addons"): [str],
vol.Optional("restore_database", default=True): bool,
vol.Optional("restore_folders"): [vol.Coerce(Folder)],
}
)
)
@with_backup_manager
async def post(
self, manager: BackupManager, request: web.Request, data: dict[str, Any]
) -> web.Response:
"""Restore a backup."""
try:
await manager.async_restore_backup(
data["backup_id"],
agent_id=data["agent_id"],
password=data.get("password"),
restore_addons=data.get("restore_addons"),
restore_database=data["restore_database"],
restore_folders=data.get("restore_folders"),
restore_homeassistant=True,
)
except IncorrectPasswordError:
return self.json(
{"message": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST
)
return web.Response(status=HTTPStatus.OK)
class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView):
"""Upload backup view."""
url = "/api/onboarding/backup/upload"
name = "api:onboarding:backup:upload"
@with_backup_manager
async def post(self, manager: BackupManager, request: web.Request) -> web.Response:
"""Upload a backup file."""
return await self._post(request)
@callback
def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider:
"""Get the Home Assistant auth provider."""

View File

@ -233,12 +233,14 @@ async def test_uploading_a_backup_file(
with patch(
"homeassistant.components.backup.manager.BackupManager.async_receive_backup",
return_value=TEST_BACKUP_ABC123.backup_id,
) as async_receive_backup_mock:
resp = await client.post(
"/api/backup/upload?agent_id=backup.local",
data={"file": StringIO("test")},
)
assert resp.status == 201
assert await resp.json() == {"backup_id": TEST_BACKUP_ABC123.backup_id}
assert async_receive_backup_mock.called

View File

@ -0,0 +1,58 @@
# serializer version: 1
# name: test_onboarding_backup_info
dict({
'backups': list([
dict({
'addons': list([
dict({
'name': 'Test',
'slug': 'test',
'version': '1.0.0',
}),
]),
'agent_ids': list([
'backup.local',
]),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
'failed_agent_ids': list([
]),
'folders': list([
'media',
'share',
]),
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
'protected': False,
'size': 0,
'with_automatic_settings': True,
}),
dict({
'addons': list([
]),
'agent_ids': list([
'test.remote',
]),
'backup_id': 'def456',
'database_included': False,
'date': '1980-01-01T00:00:00.000Z',
'failed_agent_ids': list([
]),
'folders': list([
'media',
'share',
]),
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test 2',
'protected': False,
'size': 1,
'with_automatic_settings': None,
}),
]),
'last_non_idle_event': None,
'state': 'idle',
})
# ---

View File

@ -3,13 +3,15 @@
import asyncio
from collections.abc import AsyncGenerator
from http import HTTPStatus
from io import StringIO
import os
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import ANY, AsyncMock, Mock, patch
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components import onboarding
from homeassistant.components import backup, onboarding
from homeassistant.components.onboarding import const, views
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar
@ -649,12 +651,28 @@ async def test_onboarding_installation_type(
assert resp_content["installation_type"] == "Home Assistant Core"
async def test_onboarding_installation_type_after_done(
@pytest.mark.parametrize(
("method", "view", "kwargs"),
[
("get", "installation_type", {}),
("get", "backup/info", {}),
(
"post",
"backup/restore",
{"json": {"backup_id": "abc123", "agent_id": "test"}},
),
("post", "backup/upload", {}),
],
)
async def test_onboarding_view_after_done(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_client: ClientSessionGenerator,
method: str,
view: str,
kwargs: dict[str, Any],
) -> None:
"""Test raising for installation type after onboarding."""
"""Test raising after onboarding."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
@ -662,7 +680,7 @@ async def test_onboarding_installation_type_after_done(
client = await hass_client()
resp = await client.get("/api/onboarding/installation_type")
resp = await client.request(method, f"/api/onboarding/{view}", **kwargs)
assert resp.status == 401
@ -726,3 +744,286 @@ async def test_complete_onboarding(
listener_3 = Mock()
onboarding.async_add_listener(hass, listener_3)
listener_3.assert_called_once_with()
@pytest.mark.parametrize(
("method", "view", "kwargs"),
[
("get", "backup/info", {}),
(
"post",
"backup/restore",
{"json": {"backup_id": "abc123", "agent_id": "test"}},
),
("post", "backup/upload", {}),
],
)
async def test_onboarding_backup_view_without_backup(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_client: ClientSessionGenerator,
method: str,
view: str,
kwargs: dict[str, Any],
) -> None:
"""Test interacting with backup wievs when backup integration is missing."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
await hass.async_block_till_done()
client = await hass_client()
resp = await client.request(method, f"/api/onboarding/{view}", **kwargs)
assert resp.status == 500
assert await resp.json() == {"error": "backup_disabled"}
async def test_onboarding_backup_info(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_client: ClientSessionGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test returning installation type during onboarding."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, "backup", {})
await hass.async_block_till_done()
client = await hass_client()
backups = {
"abc123": backup.ManagerBackup(
addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")],
backup_id="abc123",
date="1970-01-01T00:00:00.000Z",
database_included=True,
extra_metadata={"instance_id": "abc123", "with_automatic_settings": True},
folders=[backup.Folder.MEDIA, backup.Folder.SHARE],
homeassistant_included=True,
homeassistant_version="2024.12.0",
name="Test",
protected=False,
size=0,
agent_ids=["backup.local"],
failed_agent_ids=[],
with_automatic_settings=True,
),
"def456": backup.ManagerBackup(
addons=[],
backup_id="def456",
date="1980-01-01T00:00:00.000Z",
database_included=False,
extra_metadata={
"instance_id": "unknown_uuid",
"with_automatic_settings": True,
},
folders=[backup.Folder.MEDIA, backup.Folder.SHARE],
homeassistant_included=True,
homeassistant_version="2024.12.0",
name="Test 2",
protected=False,
size=1,
agent_ids=["test.remote"],
failed_agent_ids=[],
with_automatic_settings=None,
),
}
with patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backups",
return_value=(backups, {}),
):
resp = await client.get("/api/onboarding/backup/info")
assert resp.status == 200
assert await resp.json() == snapshot
@pytest.mark.parametrize(
("params", "expected_kwargs"),
[
(
{"backup_id": "abc123", "agent_id": "backup.local"},
{
"agent_id": "backup.local",
"password": None,
"restore_addons": None,
"restore_database": True,
"restore_folders": None,
"restore_homeassistant": True,
},
),
(
{
"backup_id": "abc123",
"agent_id": "backup.local",
"password": "hunter2",
"restore_addons": ["addon_1"],
"restore_database": True,
"restore_folders": ["media"],
},
{
"agent_id": "backup.local",
"password": "hunter2",
"restore_addons": ["addon_1"],
"restore_database": True,
"restore_folders": [backup.Folder.MEDIA],
"restore_homeassistant": True,
},
),
(
{
"backup_id": "abc123",
"agent_id": "backup.local",
"password": "hunter2",
"restore_addons": ["addon_1", "addon_2"],
"restore_database": False,
"restore_folders": ["media", "share"],
},
{
"agent_id": "backup.local",
"password": "hunter2",
"restore_addons": ["addon_1", "addon_2"],
"restore_database": False,
"restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE],
"restore_homeassistant": True,
},
),
],
)
async def test_onboarding_backup_restore(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_client: ClientSessionGenerator,
params: dict[str, Any],
expected_kwargs: dict[str, Any],
) -> None:
"""Test returning installation type during onboarding."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, "backup", {})
await hass.async_block_till_done()
client = await hass_client()
with patch(
"homeassistant.components.backup.manager.BackupManager.async_restore_backup",
) as mock_restore:
resp = await client.post("/api/onboarding/backup/restore", json=params)
assert resp.status == 200
mock_restore.assert_called_once_with("abc123", **expected_kwargs)
@pytest.mark.parametrize(
("params", "restore_error", "expected_status", "expected_message", "restore_calls"),
[
# Missing agent_id
(
{"backup_id": "abc123"},
None,
400,
"Message format incorrect: required key not provided @ data['agent_id']",
0,
),
# Missing backup_id
(
{"agent_id": "backup.local"},
None,
400,
"Message format incorrect: required key not provided @ data['backup_id']",
0,
),
# Invalid restore_database
(
{
"backup_id": "abc123",
"agent_id": "backup.local",
"restore_database": "yes_please",
},
None,
400,
"Message format incorrect: expected bool for dictionary value @ data['restore_database']",
0,
),
# Invalid folder
(
{
"backup_id": "abc123",
"agent_id": "backup.local",
"restore_folders": ["invalid"],
},
None,
400,
"Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]",
0,
),
# Wrong password
(
{"backup_id": "abc123", "agent_id": "backup.local"},
backup.IncorrectPasswordError,
400,
"incorrect_password",
1,
),
],
)
async def test_onboarding_backup_restore_error(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_client: ClientSessionGenerator,
params: dict[str, Any],
restore_error: Exception | None,
expected_status: int,
expected_message: str,
restore_calls: int,
) -> None:
"""Test returning installation type during onboarding."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, "backup", {})
await hass.async_block_till_done()
client = await hass_client()
with patch(
"homeassistant.components.backup.manager.BackupManager.async_restore_backup",
side_effect=restore_error,
) as mock_restore:
resp = await client.post("/api/onboarding/backup/restore", json=params)
assert resp.status == expected_status
assert await resp.json() == {"message": expected_message}
assert len(mock_restore.mock_calls) == restore_calls
async def test_onboarding_backup_upload(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_client: ClientSessionGenerator,
) -> None:
"""Test returning installation type during onboarding."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, "backup", {})
await hass.async_block_till_done()
client = await hass_client()
with patch(
"homeassistant.components.backup.manager.BackupManager.async_receive_backup",
return_value="abc123",
) as mock_receive:
resp = await client.post(
"/api/onboarding/backup/upload?agent_id=backup.local",
data={"file": StringIO("test")},
)
assert resp.status == 201
assert await resp.json() == {"backup_id": "abc123"}
mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY)