Skip to content

Commit

Permalink
Add handler to restore a backup file with the backup integration (hom…
Browse files Browse the repository at this point in the history
…e-assistant#128365)

* Early pushout of restore handling for core/container

* Adjust after rebase

* Move logging definition, we should only do this if we go ahead with the restore

* First round

* More paths

* Add async_restore_backup to base class

* Block restore of new backup files

* manager tests

* Add websocket test

* Add testing to main

* Add coverage for missing backup file

* Catch FileNotFoundError instead

* Patch Path.read_text instead

* Remove HA_RESTORE from keep

* Use secure paths

* Fix restart test

* extend coverage

* Mock argv

* Adjustments
  • Loading branch information
ludeeus authored Nov 1, 2024
1 parent 4da93f6 commit 31dcc25
Show file tree
Hide file tree
Showing 13 changed files with 481 additions and 1 deletion.
4 changes: 4 additions & 0 deletions homeassistant/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import sys
import threading

from .backup_restore import restore_backup
from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__

FAULT_LOG_FILENAME = "home-assistant.log.fault"
Expand Down Expand Up @@ -182,6 +183,9 @@ def main() -> int:
return scripts.run(args.script)

config_dir = os.path.abspath(os.path.join(os.getcwd(), args.config))
if restore_backup(config_dir):
return RESTART_EXIT_CODE

ensure_config_path(config_dir)

# pylint: disable-next=import-outside-toplevel
Expand Down
126 changes: 126 additions & 0 deletions homeassistant/backup_restore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Home Assistant module to handle restoring backups."""

from dataclasses import dataclass
import json
import logging
from pathlib import Path
import shutil
import sys
from tempfile import TemporaryDirectory

from awesomeversion import AwesomeVersion
import securetar

from .const import __version__ as HA_VERSION

RESTORE_BACKUP_FILE = ".HA_RESTORE"
KEEP_PATHS = ("backups",)

_LOGGER = logging.getLogger(__name__)


@dataclass
class RestoreBackupFileContent:
"""Definition for restore backup file content."""

backup_file_path: Path


def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None:
"""Return the contents of the restore backup file."""
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
try:
instruction_content = instruction_path.read_text(encoding="utf-8")
return RestoreBackupFileContent(
backup_file_path=Path(instruction_content.split(";")[0])
)
except FileNotFoundError:
return None


def _clear_configuration_directory(config_dir: Path) -> None:
"""Delete all files and directories in the config directory except for the backups directory."""
keep_paths = [config_dir.joinpath(path) for path in KEEP_PATHS]
config_contents = sorted(
[entry for entry in config_dir.iterdir() if entry not in keep_paths]
)

for entry in config_contents:
entrypath = config_dir.joinpath(entry)

if entrypath.is_file():
entrypath.unlink()
elif entrypath.is_dir():
shutil.rmtree(entrypath)


def _extract_backup(config_dir: Path, backup_file_path: Path) -> None:
"""Extract the backup file to the config directory."""
with (
TemporaryDirectory() as tempdir,
securetar.SecureTarFile(
backup_file_path,
gzip=False,
mode="r",
) as ostf,
):
ostf.extractall(
path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf),
filter="fully_trusted",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))

if (
backup_meta_version := AwesomeVersion(
backup_meta["homeassistant"]["version"]
)
) > HA_VERSION:
raise ValueError(
f"You need at least Home Assistant version {backup_meta_version} to restore this backup"
)

with securetar.SecureTarFile(
Path(
tempdir,
"extracted",
f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}",
),
gzip=backup_meta["compressed"],
mode="r",
) as istf:
for member in istf.getmembers():
if member.name == "data":
continue
member.name = member.name.replace("data/", "")
_clear_configuration_directory(config_dir)
istf.extractall(
path=config_dir,
members=[
member
for member in securetar.secure_path(istf)
if member.name != "data"
],
filter="fully_trusted",
)


def restore_backup(config_dir_path: str) -> bool:
"""Restore the backup file if any.
Returns True if a restore backup file was found and restored, False otherwise.
"""
config_dir = Path(config_dir_path)
if not (restore_content := restore_backup_file_content(config_dir)):
return False

logging.basicConfig(stream=sys.stdout, level=logging.INFO)
backup_file_path = restore_content.backup_file_path
_LOGGER.info("Restoring %s", backup_file_path)
try:
_extract_backup(config_dir, backup_file_path)
except FileNotFoundError as err:
raise ValueError(f"Backup file {backup_file_path} does not exist") from err
_LOGGER.info("Restore complete, restarting")
return True
1 change: 1 addition & 0 deletions homeassistant/components/backup/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
EXCLUDE_FROM_BACKUP = [
"__pycache__/*",
".DS_Store",
".HA_RESTORE",
"*.db-shm",
"*.log.*",
"*.log",
Expand Down
24 changes: 24 additions & 0 deletions homeassistant/components/backup/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from securetar import SecureTarFile, atomic_contents_add

from homeassistant.backup_restore import RESTORE_BACKUP_FILE
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
Expand Down Expand Up @@ -123,6 +124,10 @@ async def load_platforms(self) -> None:
LOGGER.debug("Loaded %s platforms", len(self.platforms))
self.loaded_platforms = True

@abc.abstractmethod
async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
"""Restpre a backup."""

@abc.abstractmethod
async def async_create_backup(self, **kwargs: Any) -> Backup:
"""Generate a backup."""
Expand Down Expand Up @@ -291,6 +296,25 @@ def _mkdir_and_generate_backup_contents(

return tar_file_path.stat().st_size

async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
"""Restore a backup.
This will write the restore information to .HA_RESTORE which
will be handled during startup by the restore_backup module.
"""
if (backup := await self.async_get_backup(slug=slug)) is None:
raise HomeAssistantError(f"Backup {slug} not found")

def _write_restore_file() -> None:
"""Write the restore file."""
Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text(
f"{backup.path.as_posix()};",
encoding="utf-8",
)

await self.hass.async_add_executor_job(_write_restore_file)
await self.hass.services.async_call("homeassistant", "restart", {})


def _generate_slug(date: str, name: str) -> str:
"""Generate a backup slug."""
Expand Down
19 changes: 19 additions & 0 deletions homeassistant/components/backup/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
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.async_register_command(hass, handle_restore)


@websocket_api.require_admin
Expand Down Expand Up @@ -85,6 +86,24 @@ async def handle_remove(
connection.send_result(msg["id"])


@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/restore",
vol.Required("slug"): str,
}
)
@websocket_api.async_response
async def handle_restore(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Restore a backup."""
await hass.data[DATA_MANAGER].async_restore_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
Expand Down
1 change: 1 addition & 0 deletions homeassistant/package_constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ PyTurboJPEG==1.7.5
pyudev==0.24.1
PyYAML==6.0.2
requests==2.32.3
securetar==2024.2.1
SQLAlchemy==2.0.31
typing-extensions>=4.12.2,<5.0
ulid-transform==1.0.2
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ dependencies = [
"python-slugify==8.0.4",
"PyYAML==6.0.2",
"requests==2.32.3",
"securetar==2024.2.1",
"SQLAlchemy==2.0.31",
"typing-extensions>=4.12.2,<5.0",
"ulid-transform==1.0.2",
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ psutil-home-assistant==0.0.1
python-slugify==8.0.4
PyYAML==6.0.2
requests==2.32.3
securetar==2024.2.1
SQLAlchemy==2.0.31
typing-extensions>=4.12.2,<5.0
ulid-transform==1.0.2
Expand Down
19 changes: 19 additions & 0 deletions tests/components/backup/snapshots/test_websocket.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,22 @@
'type': 'result',
})
# ---
# name: test_restore[with_hassio]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_restore[without_hassio]
dict({
'id': 1,
'result': None,
'success': True,
'type': 'result',
})
# ---
28 changes: 28 additions & 0 deletions tests/components/backup/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,31 @@ async def test_loading_platforms_when_running_async_post_backup_actions(
assert len(manager.platforms) == 1

assert "Loaded 1 platforms" in caplog.text


async def test_async_trigger_restore(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test trigger restore."""
manager = BackupManager(hass)
manager.loaded_backups = True
manager.backups = {TEST_BACKUP.slug: TEST_BACKUP}

with (
patch("pathlib.Path.exists", return_value=True),
patch("pathlib.Path.write_text") as mocked_write_text,
patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call,
):
await manager.async_restore_backup(TEST_BACKUP.slug)
assert mocked_write_text.call_args[0][0] == "abc123.tar;"
assert mocked_service_call.called


async def test_async_trigger_restore_missing_backup(hass: HomeAssistant) -> None:
"""Test trigger restore."""
manager = BackupManager(hass)
manager.loaded_backups = True

with pytest.raises(HomeAssistantError, match="Backup abc123 not found"):
await manager.async_restore_backup(TEST_BACKUP.slug)
26 changes: 26 additions & 0 deletions tests/components/backup/test_websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,32 @@ async def test_generate(
assert snapshot == await client.receive_json()


@pytest.mark.parametrize(
"with_hassio",
[
pytest.param(True, id="with_hassio"),
pytest.param(False, id="without_hassio"),
],
)
async def test_restore(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
with_hassio: bool,
) -> None:
"""Test calling the restore command."""
await setup_backup_integration(hass, with_hassio=with_hassio)

client = await hass_ws_client(hass)
await hass.async_block_till_done()

with patch(
"homeassistant.components.backup.manager.BackupManager.async_restore_backup",
):
await client.send_json_auto_id({"type": "backup/restore", "slug": "abc123"})
assert await client.receive_json() == snapshot


@pytest.mark.parametrize(
"access_token_fixture_name",
["hass_access_token", "hass_supervisor_access_token"],
Expand Down
Loading

0 comments on commit 31dcc25

Please sign in to comment.