Add Backup integration (#66395)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Joakim Sørensen 2022-03-01 00:48:12 +01:00 committed by GitHub
parent 487f4dcd90
commit 0ed51dae13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 705 additions and 0 deletions

View file

@ -111,6 +111,8 @@ tests/components/azure_devops/* @timmo001
homeassistant/components/azure_event_hub/* @eavanvalkenburg
tests/components/azure_event_hub/* @eavanvalkenburg
homeassistant/components/azure_service_bus/* @hfurubotten
homeassistant/components/backup/* @home-assistant/core
tests/components/backup/* @home-assistant/core
homeassistant/components/balboa/* @garbled1
tests/components/balboa/* @garbled1
homeassistant/components/beewi_smartclim/* @alemuro

View file

@ -0,0 +1,26 @@
"""The Backup integration."""
from homeassistant.components.hassio import is_hassio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, LOGGER
from .http import async_register_http_views
from .manager import BackupManager
from .websocket import async_register_websocket_handlers
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Backup integration."""
if is_hassio(hass):
LOGGER.error(
"The backup integration is not supported on this installation method, "
"please remove it from your configuration"
)
return False
hass.data[DOMAIN] = BackupManager(hass)
async_register_websocket_handlers(hass)
async_register_http_views(hass)
return True

View file

@ -0,0 +1,15 @@
"""Constants for the Backup integration."""
from logging import getLogger
DOMAIN = "backup"
LOGGER = getLogger(__package__)
EXCLUDE_FROM_BACKUP = [
"__pycache__/*",
".DS_Store",
"*.db-shm",
"*.log.*",
"*.log",
"backups/*.tar",
"OZW_Log.txt",
]

View file

@ -0,0 +1,49 @@
"""Http view for the Backup integration."""
from __future__ import annotations
from http import HTTPStatus
from aiohttp.hdrs import CONTENT_DISPOSITION
from aiohttp.web import FileResponse, Request, Response
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify
from .const import DOMAIN
from .manager import BackupManager
@callback
def async_register_http_views(hass: HomeAssistant) -> None:
"""Register the http views."""
hass.http.register_view(DownloadBackupView)
class DownloadBackupView(HomeAssistantView):
"""Generate backup view."""
url = "/api/backup/download/{slug}"
name = "api:backup:download"
async def get( # pylint: disable=no-self-use
self,
request: Request,
slug: str,
) -> FileResponse | Response:
"""Download a backup file."""
if not request["hass_user"].is_admin:
return Response(status=HTTPStatus.UNAUTHORIZED)
manager: BackupManager = request.app["hass"].data[DOMAIN]
backup = await manager.get_backup(slug)
if backup is None or not backup.path.exists():
return Response(status=HTTPStatus.NOT_FOUND)
return FileResponse(
path=backup.path.as_posix(),
headers={
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
},
)

View file

@ -0,0 +1,173 @@
"""Backup manager for the Backup integration."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import hashlib
import json
from pathlib import Path
from tarfile import TarError
from tempfile import TemporaryDirectory
from securetar import SecureTarFile, atomic_contents_add
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt, json as json_util
from .const import EXCLUDE_FROM_BACKUP, LOGGER
@dataclass
class Backup:
"""Backup class."""
slug: str
name: str
date: str
path: Path
size: float
def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
return {**asdict(self), "path": self.path.as_posix()}
class BackupManager:
"""Backup manager for the Backup integration."""
_backups: dict[str, Backup] = {}
_loaded = False
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager."""
self.hass = hass
self.backup_dir = Path(hass.config.path("backups"))
self.backing_up = False
async def load_backups(self) -> None:
"""Load data of stored backup files."""
backups = {}
def _read_backups() -> None:
for backup_path in self.backup_dir.glob("*.tar"):
try:
with SecureTarFile(backup_path, "r", gzip=False) as backup_file:
if data_file := backup_file.extractfile("./backup.json"):
data = json.loads(data_file.read())
backup = Backup(
slug=data["slug"],
name=data["name"],
date=data["date"],
path=backup_path,
size=round(backup_path.stat().st_size / 1_048_576, 2),
)
backups[backup.slug] = backup
except (OSError, TarError, json.JSONDecodeError) as err:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
await self.hass.async_add_executor_job(_read_backups)
LOGGER.debug("Loaded %s backups", len(backups))
self._backups = backups
self._loaded = True
async def get_backups(self) -> dict[str, Backup]:
"""Return backups."""
if not self._loaded:
await self.load_backups()
return self._backups
async def get_backup(self, slug: str) -> Backup | None:
"""Return a backup."""
if not self._loaded:
await self.load_backups()
if not (backup := self._backups.get(slug)):
return None
if not backup.path.exists():
LOGGER.debug(
"Removing tracked backup (%s) that does not exists on the expected path %s",
backup.slug,
backup.path,
)
self._backups.pop(slug)
return None
return backup
async def remove_backup(self, slug: str) -> None:
"""Remove a backup."""
if (backup := await self.get_backup(slug)) is None:
return
await self.hass.async_add_executor_job(backup.path.unlink, True)
LOGGER.debug("Removed backup located at %s", backup.path)
self._backups.pop(slug)
async def generate_backup(self) -> Backup:
"""Generate a backup."""
if self.backing_up:
raise HomeAssistantError("Backup already in progress")
try:
self.backing_up = True
backup_name = f"Core {HAVERSION}"
date_str = dt.now().isoformat()
slug = _generate_slug(date_str, backup_name)
backup_data = {
"slug": slug,
"name": backup_name,
"date": date_str,
"type": "partial",
"folders": ["homeassistant"],
"homeassistant": {"version": HAVERSION},
"compressed": True,
}
tar_file_path = Path(self.backup_dir, f"{slug}.tar")
if not self.backup_dir.exists():
LOGGER.debug("Creating backup directory")
self.hass.async_add_executor_job(self.backup_dir.mkdir)
def _create_backup() -> None:
with TemporaryDirectory() as tmp_dir:
tmp_dir_path = Path(tmp_dir)
json_util.save_json(
tmp_dir_path.joinpath("./backup.json").as_posix(),
backup_data,
)
with SecureTarFile(tar_file_path, "w", gzip=False) as tar_file:
with SecureTarFile(
tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(),
"w",
) as core_tar:
atomic_contents_add(
tar_file=core_tar,
origin_path=Path(self.hass.config.path()),
excludes=EXCLUDE_FROM_BACKUP,
arcname="data",
)
tar_file.add(tmp_dir_path, arcname=".")
await self.hass.async_add_executor_job(_create_backup)
backup = Backup(
slug=slug,
name=backup_name,
date=date_str,
path=tar_file_path,
size=round(tar_file_path.stat().st_size / 1_048_576, 2),
)
if self._loaded:
self._backups[slug] = backup
LOGGER.debug("Generated new backup with slug %s", slug)
return backup
finally:
self.backing_up = False
def _generate_slug(date: str, name: str) -> str:
"""Generate a backup slug."""
return hashlib.sha1(f"{date} - {name}".lower().encode()).hexdigest()[:8]

View file

@ -0,0 +1,17 @@
{
"domain": "backup",
"name": "Backup",
"documentation": "https://www.home-assistant.io/integrations/backup",
"dependencies": [
"http",
"websocket_api"
],
"codeowners": [
"@home-assistant/core"
],
"requirements": [
"securetar==2022.2.0"
],
"iot_class": "calculated",
"quality_scale": "internal"
}

View file

@ -0,0 +1,69 @@
"""Websocket commands for the Backup integration."""
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
from .manager import BackupManager
@callback
def async_register_websocket_handlers(hass: HomeAssistant) -> None:
"""Register websocket commands."""
websocket_api.async_register_command(hass, handle_info)
websocket_api.async_register_command(hass, handle_create)
websocket_api.async_register_command(hass, handle_remove)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/info"})
@websocket_api.async_response
async def handle_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
):
"""List all stored backups."""
manager: BackupManager = hass.data[DOMAIN]
backups = await manager.get_backups()
connection.send_result(
msg["id"],
{
"backups": list(backups),
"backing_up": manager.backing_up,
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/remove",
vol.Required("slug"): str,
}
)
@websocket_api.async_response
async def handle_remove(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
):
"""Remove a backup."""
manager: BackupManager = hass.data[DOMAIN]
await manager.remove_backup(msg["slug"])
connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/generate"})
@websocket_api.async_response
async def handle_create(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
):
"""Generate a backup."""
manager: BackupManager = hass.data[DOMAIN]
backup = await manager.generate_backup()
connection.send_result(msg["id"], backup)

View file

@ -5,6 +5,7 @@ try:
except ImportError:
av = None
from homeassistant.components.hassio import is_hassio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
@ -14,6 +15,9 @@ DOMAIN = "default_config"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize default configuration."""
if not is_hassio(hass):
await async_setup_component(hass, "backup", config)
if av is None:
return True

View file

@ -2110,6 +2110,9 @@ screenlogicpy==0.5.4
# homeassistant.components.scsgate
scsgate==0.1.0
# homeassistant.components.backup
securetar==2022.2.0
# homeassistant.components.sendgrid
sendgrid==6.8.2

View file

@ -1338,6 +1338,9 @@ scapy==2.4.5
# homeassistant.components.screenlogic
screenlogicpy==0.5.4
# homeassistant.components.backup
securetar==2022.2.0
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
sense_energy==0.10.2

View file

@ -0,0 +1 @@
"""Tests for the Backup integration."""

View file

@ -0,0 +1,29 @@
"""Common helpers for the Backup integration tests."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from homeassistant.components.backup import DOMAIN
from homeassistant.components.backup.manager import Backup
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
TEST_BACKUP = Backup(
slug="abc123",
name="Test",
date="1970-01-01T00:00:00.000Z",
path=Path("abc123.tar"),
size=0.0,
)
async def setup_backup_integration(
hass: HomeAssistant,
with_hassio: bool = False,
configuration: ConfigType | None = None,
) -> bool:
"""Set up the Backup integration."""
with patch("homeassistant.components.backup.is_hassio", return_value=with_hassio):
return await async_setup_component(hass, DOMAIN, configuration or {})

View file

@ -0,0 +1,60 @@
"""Tests for the Backup integration."""
from collections.abc import Awaitable, Callable
from unittest.mock import patch
from aiohttp import ClientSession, web
from homeassistant.core import HomeAssistant
from .common import TEST_BACKUP, setup_backup_integration
from tests.common import MockUser
async def test_downloading_backup(
hass: HomeAssistant,
hass_client: Callable[..., Awaitable[ClientSession]],
) -> None:
"""Test downloading a backup file."""
await setup_backup_integration(hass)
client = await hass_client()
with patch(
"homeassistant.components.backup.http.BackupManager.get_backup",
return_value=TEST_BACKUP,
), patch("pathlib.Path.exists", return_value=True), patch(
"homeassistant.components.backup.http.FileResponse",
return_value=web.Response(text=""),
):
resp = await client.get("/api/backup/download/abc123")
assert resp.status == 200
async def test_downloading_backup_not_found(
hass: HomeAssistant,
hass_client: Callable[..., Awaitable[ClientSession]],
) -> None:
"""Test downloading a backup file that does not exist."""
await setup_backup_integration(hass)
client = await hass_client()
resp = await client.get("/api/backup/download/abc123")
assert resp.status == 404
async def test_non_admin(
hass: HomeAssistant,
hass_client: Callable[..., Awaitable[ClientSession]],
hass_admin_user: MockUser,
) -> None:
"""Test downloading a backup file that does not exist."""
hass_admin_user.groups = []
await setup_backup_integration(hass)
client = await hass_client()
resp = await client.get("/api/backup/download/abc123")
assert resp.status == 401

View file

@ -0,0 +1,18 @@
"""Tests for the Backup integration."""
import pytest
from homeassistant.core import HomeAssistant
from .common import setup_backup_integration
async def test_setup_with_hassio(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the setup of the integration with hassio enabled."""
assert not await setup_backup_integration(hass=hass, with_hassio=True)
assert (
"The backup integration is not supported on this installation method, please remove it from your configuration"
in caplog.text
)

View file

@ -0,0 +1,153 @@
"""Tests for the Backup integration."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components.backup import BackupManager
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .common import TEST_BACKUP
async def test_constructor(hass: HomeAssistant) -> None:
"""Test BackupManager constructor."""
manager = BackupManager(hass)
assert manager.backup_dir.as_posix() == hass.config.path("backups")
async def test_load_backups(hass: HomeAssistant) -> None:
"""Test loading backups."""
manager = BackupManager(hass)
with patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]), patch(
"tarfile.open", return_value=MagicMock()
), patch(
"json.loads",
return_value={
"slug": TEST_BACKUP.slug,
"name": TEST_BACKUP.name,
"date": TEST_BACKUP.date,
},
), patch(
"pathlib.Path.stat", return_value=MagicMock(st_size=TEST_BACKUP.size)
):
await manager.load_backups()
backups = await manager.get_backups()
assert backups == {TEST_BACKUP.slug: TEST_BACKUP}
async def test_load_backups_with_exception(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test loading backups with exception."""
manager = BackupManager(hass)
with patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]), patch(
"tarfile.open", side_effect=OSError("Test ecxeption")
):
await manager.load_backups()
backups = await manager.get_backups()
assert f"Unable to read backup {TEST_BACKUP.path}: Test ecxeption" in caplog.text
assert backups == {}
async def test_removing_non_existing_backup(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test removing not existing backup."""
manager = BackupManager(hass)
await manager.remove_backup("non_existing")
assert "Removed backup located at" not in caplog.text
async def test_getting_backup_that_does_not_exist(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
):
"""Test getting backup that does not exist."""
manager = BackupManager(hass)
with patch(
"homeassistant.components.backup.websocket.BackupManager._backups",
{TEST_BACKUP.slug: TEST_BACKUP},
), patch(
"homeassistant.components.backup.websocket.BackupManager._loaded",
True,
), patch(
"pathlib.Path.exists", return_value=False
):
backup = await manager.get_backup(TEST_BACKUP.slug)
assert backup is None
assert (
f"Removing tracked backup ({TEST_BACKUP.slug}) that "
f"does not exists on the expected path {TEST_BACKUP.path}" in caplog.text
)
async def test_generate_backup_when_backing_up(hass: HomeAssistant) -> None:
"""Test generate backup."""
manager = BackupManager(hass)
manager.backing_up = True
with pytest.raises(HomeAssistantError, match="Backup already in progress"):
await manager.generate_backup()
async def test_generate_backup(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test generate backup."""
manager = BackupManager(hass)
def _mock_iterdir(path: Path) -> list[Path]:
if not path.name.endswith("testing_config"):
return []
return [
Path("test.txt"),
Path(".DS_Store"),
Path(".storage"),
]
with patch("tarfile.open", MagicMock()) as mocked_tarfile, patch(
"pathlib.Path.iterdir", _mock_iterdir
), patch("pathlib.Path.stat", MagicMock(st_size=123)), patch(
"pathlib.Path.is_file", lambda x: x.name != ".storage"
), patch(
"pathlib.Path.is_dir",
lambda x: x.name == ".storage",
), patch(
"pathlib.Path.exists",
lambda x: x != manager.backup_dir,
), patch(
"pathlib.Path.is_symlink",
lambda _: False,
), patch(
"pathlib.Path.mkdir",
MagicMock(),
), patch(
"homeassistant.components.backup.manager.json_util.save_json"
) as mocked_json_util, patch(
"homeassistant.components.backup.manager.HAVERSION",
"2025.1.0",
), patch(
"homeassistant.components.backup.websocket.BackupManager._loaded",
True,
):
await manager.generate_backup()
assert mocked_json_util.call_count == 1
assert mocked_json_util.call_args[0][1]["homeassistant"] == {
"version": "2025.1.0"
}
assert (
manager.backup_dir.as_posix()
in mocked_tarfile.call_args_list[0].kwargs["name"]
)
assert "Generated new backup with slug " in caplog.text
assert "Creating backup directory" in caplog.text

View file

@ -0,0 +1,83 @@
"""Tests for the Backup integration."""
from collections.abc import Awaitable, Callable
from unittest.mock import patch
from aiohttp import ClientWebSocketResponse
import pytest
from homeassistant.core import HomeAssistant
from .common import TEST_BACKUP, setup_backup_integration
async def test_info(
hass: HomeAssistant,
hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]],
) -> None:
"""Test getting backup info."""
await setup_backup_integration(hass)
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json({"id": 1, "type": "backup/info"})
msg = await client.receive_json()
assert msg["id"] == 1
assert msg["success"]
assert msg["result"] == {"backing_up": False, "backups": []}
async def test_remove(
hass: HomeAssistant,
hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test removing a backup file."""
await setup_backup_integration(hass)
client = await hass_ws_client(hass)
await hass.async_block_till_done()
with patch(
"homeassistant.components.backup.websocket.BackupManager._backups",
{TEST_BACKUP.slug: TEST_BACKUP},
), patch(
"homeassistant.components.backup.websocket.BackupManager._loaded",
True,
), patch(
"pathlib.Path.unlink"
), patch(
"pathlib.Path.exists", return_value=True
):
await client.send_json({"id": 1, "type": "backup/remove", "slug": "abc123"})
msg = await client.receive_json()
assert msg["id"] == 1
assert msg["success"]
assert f"Removed backup located at {TEST_BACKUP.path}" in caplog.text
async def test_generate(
hass: HomeAssistant,
hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]],
) -> None:
"""Test removing a backup file."""
await setup_backup_integration(hass)
client = await hass_ws_client(hass)
await hass.async_block_till_done()
with patch(
"homeassistant.components.backup.websocket.BackupManager._backups",
{TEST_BACKUP.slug: TEST_BACKUP},
), patch(
"homeassistant.components.backup.websocket.BackupManager.generate_backup",
return_value=TEST_BACKUP,
):
await client.send_json({"id": 1, "type": "backup/generate"})
msg = await client.receive_json()
assert msg["id"] == 1
assert msg["success"]
assert msg["result"] == TEST_BACKUP.as_dict()