Add backup platform to the hassio integration

This commit is contained in:
Erik
2024-11-19 21:08:43 +01:00
parent 20488721bd
commit 1bebd26956
7 changed files with 466 additions and 162 deletions

View File

@@ -8,14 +8,17 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
from .agent import BackupAgent, BackupAgentPlatformProtocol
from .const import DOMAIN, LOGGER
from .agent import BackupAgent, BackupAgentPlatformProtocol, LocalBackupAgent
from .const import DATA_MANAGER, DOMAIN
from .http import async_register_http_views
from .manager import (
Backup,
BackupManager,
BackupPlatformProtocol,
BackupProgress,
BackupReaderWriter,
CoreBackupReaderWriter,
NewBackup,
)
from .models import BackupUploadMetadata, BaseBackup
from .websocket import async_register_websocket_handlers
@@ -25,8 +28,12 @@ __all__ = [
"BackupAgent",
"BackupAgentPlatformProtocol",
"BackupPlatformProtocol",
"BackupProgress",
"BackupReaderWriter",
"BackupUploadMetadata",
"BaseBackup",
"LocalBackupAgent",
"NewBackup",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@@ -36,22 +43,20 @@ SERVICE_CREATE_SCHEMA = vol.Schema({vol.Optional(CONF_PASSWORD): str})
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Backup integration."""
hass.data[DOMAIN] = backup_manager = BackupManager(
hass, CoreBackupReaderWriter(hass)
)
await backup_manager.async_setup()
with_hassio = is_hassio(hass)
async_register_websocket_handlers(hass, with_hassio)
if not with_hassio:
backup_manager = BackupManager(hass, CoreBackupReaderWriter(hass))
else:
# pylint: disable-next=import-outside-toplevel, hass-component-root-import
from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter
if with_hassio:
if DOMAIN in config:
LOGGER.error(
"The backup integration is not supported on this installation method, "
"please remove it from your configuration"
)
return True
backup_manager = BackupManager(hass, SupervisorBackupReaderWriter(hass))
hass.data[DATA_MANAGER] = backup_manager
await backup_manager.async_setup()
async_register_websocket_handlers(hass, with_hassio)
async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups."""

View File

@@ -21,7 +21,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
if with_hassio:
websocket_api.async_register_command(hass, handle_backup_end)
websocket_api.async_register_command(hass, handle_backup_start)
return
websocket_api.async_register_command(hass, handle_details)
websocket_api.async_register_command(hass, handle_info)

View File

@@ -0,0 +1,170 @@
"""Backup functionality for supervised installations."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from pathlib import Path
from typing import Any
from aiohasupervisor import backups as supervisor_backups
from homeassistant.components.backup import (
BackupAgent,
BackupProgress,
BackupReaderWriter,
BackupUploadMetadata,
BaseBackup,
LocalBackupAgent,
NewBackup,
)
from homeassistant.core import HomeAssistant
from .handler import get_supervisor_client
async def async_get_backup_agents(
hass: HomeAssistant,
**kwargs: Any,
) -> list[BackupAgent]:
"""Return the hassio backup agents."""
return [SupervisorLocalBackupAgent(hass)]
class SupervisorLocalBackupAgent(LocalBackupAgent):
"""Local backup agent for supervised installations."""
name = "local"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup agent."""
super().__init__()
self._hass = hass
self._backup_dir = Path("/backups")
async def async_download_backup(
self,
backup_id: str,
*,
path: Path,
**kwargs: Any,
) -> None:
"""Download a backup file."""
raise NotImplementedError
async def async_upload_backup(
self,
*,
path: Path,
metadata: BackupUploadMetadata,
**kwargs: Any,
) -> None:
"""Upload a backup."""
await get_supervisor_client(self._hass).backups.reload()
async def async_list_backups(self, **kwargs: Any) -> list[BaseBackup]:
"""List backups."""
return [
BaseBackup(
backup_id=backup.slug,
date=backup.date.isoformat(),
name=backup.name,
protected=backup.protected,
size=backup.size,
)
for backup in await get_supervisor_client(self._hass).backups.list()
]
async def async_get_backup(
self,
backup_id: str,
**kwargs: Any,
) -> BaseBackup | None:
"""Return a backup."""
backups = await self.async_list_backups()
for backup in backups:
if backup.backup_id == backup_id:
return backup
return None
def get_backup_path(self, backup_id: str) -> Path:
"""Return the local path to a backup."""
return self._backup_dir / f"{backup_id}.tar"
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Remove a backup."""
raise NotImplementedError
class SupervisorBackupReaderWriter(BackupReaderWriter):
"""Class for reading and writing backups in supervised installations."""
temp_backup_dir = Path("/cloud_backups")
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup reader/writer."""
self._hass = hass
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."""
addons_included_set = set(addons_included) if addons_included else None
folders_included_set = set(folders_included) if folders_included else None
client = get_supervisor_client(self._hass)
backup = await client.backups.partial_backup(
supervisor_backups.PartialBackupOptions(
addons=addons_included_set,
folders=folders_included_set, # type: ignore[arg-type]
homeassistant=True,
name=backup_name,
password=password,
compressed=True,
location=None,
homeassistant_exclude_database=not database_included,
background=True,
)
)
backup_task = self._hass.async_create_task(
self._async_wait_for_backup(backup),
name="backup_manager_create_backup",
eager_start=False, # To ensure the task is not started before we return
)
return (NewBackup(backup_id=backup.job_id), backup_task)
async def _async_wait_for_backup(
self, backup: supervisor_backups.NewBackup
) -> tuple[BaseBackup, Path]:
"""Wait for a backup to complete."""
raise NotImplementedError
async def async_restore_backup(
self,
backup_id: str,
*,
agent_id: str,
password: str | None,
**kwargs: Any,
) -> None:
"""Restore a backup."""
client = get_supervisor_client(self._hass)
await client.backups.partial_restore(
backup_id,
supervisor_backups.PartialRestoreOptions(
addons=None,
folders=None,
homeassistant=True,
password=password,
background=True,
),
)

View File

@@ -135,10 +135,12 @@ async def setup_backup_integration(
result = await async_setup_component(hass, DOMAIN, configuration or {})
await hass.async_block_till_done()
if with_hassio or not backups:
if not backups:
return result
for agent_id, agent_backups in backups.items():
if with_hassio and agent_id == LOCAL_AGENT_ID:
continue
agent = hass.data[DATA_MANAGER].backup_agents[agent_id]
agent._backups = {backups.backup_id: backups for backups in agent_backups}
if agent_id == LOCAL_AGENT_ID:

View File

@@ -340,166 +340,241 @@
# ---
# name: test_delete[with_hassio-remote_agents0-backups0]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents0-backups0].1
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 2,
'success': False,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents0-backups0].2
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 3,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents1-backups1]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents1-backups1].1
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 2,
'success': False,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents1-backups1].2
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 3,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents2-backups2]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
dict({
'agent_ids': list([
'test.remote',
]),
'backup_id': 'abc123',
'date': '1970-01-01T00:00:00.000Z',
'name': 'Test',
'protected': False,
'size': 0.0,
}),
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents2-backups2].1
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 2,
'success': False,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents2-backups2].2
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 3,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
dict({
'agent_ids': list([
'test.remote',
]),
'backup_id': 'abc123',
'date': '1970-01-01T00:00:00.000Z',
'name': 'Test',
'protected': False,
'size': 0.0,
}),
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents3-backups3]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
dict({
'agent_ids': list([
'test.remote',
]),
'backup_id': 'def456',
'date': '1980-01-01T00:00:00.000Z',
'name': 'Test 2',
'protected': False,
'size': 1.0,
}),
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents3-backups3].1
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 2,
'success': False,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents3-backups3].2
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 3,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
dict({
'agent_ids': list([
'test.remote',
]),
'backup_id': 'def456',
'date': '1980-01-01T00:00:00.000Z',
'name': 'Test 2',
'protected': False,
'size': 1.0,
}),
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents4-backups4]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
dict({
'agent_ids': list([
'test.remote',
]),
'backup_id': 'abc123',
'date': '1970-01-01T00:00:00.000Z',
'name': 'Test',
'protected': False,
'size': 0.0,
}),
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents4-backups4].1
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 2,
'success': False,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_delete[with_hassio-remote_agents4-backups4].2
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 3,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
dict({
'agent_ids': list([
'test.remote',
]),
'backup_id': 'abc123',
'date': '1970-01-01T00:00:00.000Z',
'name': 'Test',
'protected': False,
'size': 0.0,
}),
]),
}),
'success': True,
'type': 'result',
})
# ---
@@ -756,56 +831,79 @@
# ---
# name: test_details[with_hassio-remote_agents0-backups0]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backup': None,
}),
'success': True,
'type': 'result',
})
# ---
# name: test_details[with_hassio-remote_agents1-backups1]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backup': None,
}),
'success': True,
'type': 'result',
})
# ---
# name: test_details[with_hassio-remote_agents2-backups2]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backup': dict({
'agent_ids': list([
'test.remote',
]),
'backup_id': 'abc123',
'date': '1970-01-01T00:00:00.000Z',
'name': 'Test',
'protected': False,
'size': 0.0,
}),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_details[with_hassio-remote_agents3-backups3]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backup': None,
}),
'success': True,
'type': 'result',
})
# ---
# name: test_details[with_hassio-remote_agents4-backups4]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backup': dict({
'agent_ids': list([
'test.remote',
]),
'backup_id': 'abc123',
'date': '1970-01-01T00:00:00.000Z',
'name': 'Test',
'protected': False,
'size': 0.0,
}),
}),
'success': True,
'type': 'result',
})
# ---
@@ -932,8 +1030,8 @@
# name: test_generate[with_hassio-None]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
'code': 'home_assistant_error',
'message': 'Invalid agent selected',
}),
'id': 1,
'success': False,
@@ -943,8 +1041,8 @@
# name: test_generate[with_hassio-data1]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
'code': 'home_assistant_error',
'message': 'Invalid agent selected',
}),
'id': 1,
'success': False,
@@ -954,8 +1052,8 @@
# name: test_generate[with_hassio-data2]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
'code': 'home_assistant_error',
'message': 'Invalid agent selected',
}),
'id': 1,
'success': False,
@@ -1047,45 +1145,77 @@
# ---
# name: test_info[with_hassio-remote_agents0-remote_backups0]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_info[with_hassio-remote_agents1-remote_backups1]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_info[with_hassio-remote_agents2-remote_backups2]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
dict({
'agent_ids': list([
'test.remote',
]),
'backup_id': 'abc123',
'date': '1970-01-01T00:00:00.000Z',
'name': 'Test',
'protected': False,
'size': 0.0,
}),
]),
}),
'success': True,
'type': 'result',
})
# ---
# name: test_info[with_hassio-remote_agents3-remote_backups3]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'result': dict({
'agent_errors': dict({
}),
'backing_up': False,
'backups': list([
dict({
'agent_ids': list([
'test.remote',
]),
'backup_id': 'def456',
'date': '1980-01-01T00:00:00.000Z',
'name': 'Test 2',
'protected': False,
'size': 1.0,
}),
]),
}),
'success': True,
'type': 'result',
})
# ---
@@ -1235,8 +1365,8 @@
# name: test_restore_local_agent[with_hassio-backups0]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
'code': 'unknown_error',
'message': 'Unknown error',
}),
'id': 1,
'success': False,
@@ -1249,8 +1379,8 @@
# name: test_restore_local_agent[with_hassio-backups1]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
'code': 'unknown_error',
'message': 'Unknown error',
}),
'id': 1,
'success': False,
@@ -1288,8 +1418,8 @@
# name: test_restore_remote_agent[with_hassio-remote_agents0-backups0]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
'code': 'home_assistant_error',
'message': 'Backup abc123 not found in agent test.remote',
}),
'id': 1,
'success': False,
@@ -1302,8 +1432,8 @@
# name: test_restore_remote_agent[with_hassio-remote_agents1-backups1]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
'code': 'unknown_error',
'message': 'Unknown error',
}),
'id': 1,
'success': False,

View File

@@ -23,10 +23,6 @@ async def test_setup_with_hassio(
)
manager = hass.data[DATA_MANAGER]
assert not manager.backup_agents
assert (
"The backup integration is not supported on this installation method, please"
" remove it from your configuration"
) in caplog.text
@pytest.mark.parametrize("service_data", [None, {}, {"password": "abc123"}])

View File

@@ -603,17 +603,18 @@ async def test_agents_list_backups(
@pytest.mark.parametrize(
"with_hassio",
("with_hassio", "download_path"),
[
pytest.param(True, id="with_hassio"),
pytest.param(False, id="without_hassio"),
pytest.param(True, "/cloud_backups", id="with_hassio"),
pytest.param(False, "{config_dir}/tmp_backups", id="without_hassio"),
],
)
async def test_agents_download(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
with_hassio: bool,
download_path: str,
snapshot: SnapshotAssertion,
) -> None:
"""Test WS command to start downloading a backup."""
await setup_backup_integration(hass, with_hassio=with_hassio)
@@ -629,12 +630,13 @@ async def test_agents_download(
"backup_id": "abc123",
}
)
expected_path = Path(
download_path.format(config_dir=hass.config.config_dir), "abc123.tar"
)
with patch.object(BackupAgentTest, "async_download_backup") as download_mock:
assert await client.receive_json() == snapshot
assert download_mock.call_args[0] == ("abc123",)
assert download_mock.call_args[1] == {
"path": Path(hass.config.path("tmp_backups"), "abc123.tar"),
}
assert download_mock.call_args[1] == {"path": expected_path}
async def test_agents_download_exception(