Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e030f39
Add Backup integration
ludeeus Feb 12, 2022
31fff38
Mark handle_remove WS as async
ludeeus Feb 13, 2022
16c8ac7
Allow symlink
ludeeus Feb 13, 2022
549beaa
Remove _is_excluded_by_filter function
ludeeus Feb 13, 2022
373410a
limit to file
ludeeus Feb 13, 2022
705d833
Store directly instead of scan when generating new
ludeeus Feb 13, 2022
2c109d7
wrap in try/finally
ludeeus Feb 13, 2022
f3f65ea
Use debug for logs
ludeeus Feb 13, 2022
3fc7464
other adjustments
ludeeus Feb 13, 2022
75c08b0
Adjust test
ludeeus Feb 13, 2022
6326953
Move dir creation to backup creation
ludeeus Feb 14, 2022
63a48e2
Update homeassistant/components/backup/manager.py
ludeeus Feb 14, 2022
e78ba00
adjust
ludeeus Feb 14, 2022
e759152
Merge branch 'dev' of github.com:home-assistant/core into backup_inte…
ludeeus Feb 21, 2022
1915492
broken
ludeeus Feb 21, 2022
947c097
working again
ludeeus Feb 24, 2022
d90bed0
Merge branch 'dev' of github.com:home-assistant/core into backup_inte…
ludeeus Feb 24, 2022
8bef482
Add to default_config
ludeeus Feb 24, 2022
5918672
Fix hassfest
ludeeus Feb 24, 2022
8a8a555
Load when needed
ludeeus Feb 24, 2022
2858697
error
ludeeus Feb 24, 2022
f69374f
remove
ludeeus Feb 24, 2022
b59e3c0
change
ludeeus Feb 24, 2022
7d19e55
change import
ludeeus Feb 24, 2022
5526a27
Merge branch 'dev' of github.com:home-assistant/core into backup_inte…
ludeeus Feb 28, 2022
09a217f
Update homeassistant/components/backup/manager.py
ludeeus Feb 28, 2022
7965936
coverage
ludeeus Feb 28, 2022
062f00f
adjust
ludeeus Feb 28, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions homeassistant/components/backup/__init__.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions homeassistant/components/backup/const.py
Original file line number Diff line number Diff line change
@@ -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",
]
49 changes: 49 additions & 0 deletions homeassistant/components/backup/http.py
Original file line number Diff line number Diff line change
@@ -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():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the backup file doesn't exist, should it be removed from BackupManager._backups ?

return Response(status=HTTPStatus.NOT_FOUND)

return FileResponse(
path=backup.path.as_posix(),
headers={
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
},
)
173 changes: 173 additions & 0 deletions homeassistant/components/backup/manager.py
Original file line number Diff line number Diff line change
@@ -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]
17 changes: 17 additions & 0 deletions homeassistant/components/backup/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
69 changes: 69 additions & 0 deletions homeassistant/components/backup/websocket.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions homeassistant/components/default_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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
Expand All @@ -14,6 +15,9 @@

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

Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2113,6 +2113,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

Expand Down
Loading