-
-
Notifications
You must be signed in to change notification settings - Fork 37.2k
Add Backup integration #66395
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Add Backup integration #66395
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
e030f39
Add Backup integration
ludeeus 31fff38
Mark handle_remove WS as async
ludeeus 16c8ac7
Allow symlink
ludeeus 549beaa
Remove _is_excluded_by_filter function
ludeeus 373410a
limit to file
ludeeus 705d833
Store directly instead of scan when generating new
ludeeus 2c109d7
wrap in try/finally
ludeeus f3f65ea
Use debug for logs
ludeeus 3fc7464
other adjustments
ludeeus 75c08b0
Adjust test
ludeeus 6326953
Move dir creation to backup creation
ludeeus 63a48e2
Update homeassistant/components/backup/manager.py
ludeeus e78ba00
adjust
ludeeus e759152
Merge branch 'dev' of github.com:home-assistant/core into backup_inte…
ludeeus 1915492
broken
ludeeus 947c097
working again
ludeeus d90bed0
Merge branch 'dev' of github.com:home-assistant/core into backup_inte…
ludeeus 8bef482
Add to default_config
ludeeus 5918672
Fix hassfest
ludeeus 8a8a555
Load when needed
ludeeus 2858697
error
ludeeus f69374f
remove
ludeeus b59e3c0
change
ludeeus 7d19e55
change import
ludeeus 5526a27
Merge branch 'dev' of github.com:home-assistant/core into backup_inte…
ludeeus 09a217f
Update homeassistant/components/backup/manager.py
ludeeus 7965936
coverage
ludeeus 062f00f
adjust
ludeeus File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 = [ | ||
ludeeus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "__pycache__/*", | ||
| ".DS_Store", | ||
| "*.db-shm", | ||
| "*.log.*", | ||
| "*.log", | ||
| "backups/*.tar", | ||
| "OZW_Log.txt", | ||
| ] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the backup file doesn't exist, should it be removed from |
||
| return Response(status=HTTPStatus.NOT_FOUND) | ||
|
|
||
| return FileResponse( | ||
| path=backup.path.as_posix(), | ||
| headers={ | ||
| CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" | ||
| }, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] = {} | ||
ludeeus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| _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: | ||
ludeeus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ): | ||
ludeeus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.