Skip to content

Commit 31dcc25

Browse files
authored
Add handler to restore a backup file with the backup integration (#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
1 parent 4da93f6 commit 31dcc25

File tree

13 files changed

+481
-1
lines changed

13 files changed

+481
-1
lines changed

homeassistant/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import sys
1010
import threading
1111

12+
from .backup_restore import restore_backup
1213
from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__
1314

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

184185
config_dir = os.path.abspath(os.path.join(os.getcwd(), args.config))
186+
if restore_backup(config_dir):
187+
return RESTART_EXIT_CODE
188+
185189
ensure_config_path(config_dir)
186190

187191
# pylint: disable-next=import-outside-toplevel

homeassistant/backup_restore.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Home Assistant module to handle restoring backups."""
2+
3+
from dataclasses import dataclass
4+
import json
5+
import logging
6+
from pathlib import Path
7+
import shutil
8+
import sys
9+
from tempfile import TemporaryDirectory
10+
11+
from awesomeversion import AwesomeVersion
12+
import securetar
13+
14+
from .const import __version__ as HA_VERSION
15+
16+
RESTORE_BACKUP_FILE = ".HA_RESTORE"
17+
KEEP_PATHS = ("backups",)
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
22+
@dataclass
23+
class RestoreBackupFileContent:
24+
"""Definition for restore backup file content."""
25+
26+
backup_file_path: Path
27+
28+
29+
def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None:
30+
"""Return the contents of the restore backup file."""
31+
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
32+
try:
33+
instruction_content = instruction_path.read_text(encoding="utf-8")
34+
return RestoreBackupFileContent(
35+
backup_file_path=Path(instruction_content.split(";")[0])
36+
)
37+
except FileNotFoundError:
38+
return None
39+
40+
41+
def _clear_configuration_directory(config_dir: Path) -> None:
42+
"""Delete all files and directories in the config directory except for the backups directory."""
43+
keep_paths = [config_dir.joinpath(path) for path in KEEP_PATHS]
44+
config_contents = sorted(
45+
[entry for entry in config_dir.iterdir() if entry not in keep_paths]
46+
)
47+
48+
for entry in config_contents:
49+
entrypath = config_dir.joinpath(entry)
50+
51+
if entrypath.is_file():
52+
entrypath.unlink()
53+
elif entrypath.is_dir():
54+
shutil.rmtree(entrypath)
55+
56+
57+
def _extract_backup(config_dir: Path, backup_file_path: Path) -> None:
58+
"""Extract the backup file to the config directory."""
59+
with (
60+
TemporaryDirectory() as tempdir,
61+
securetar.SecureTarFile(
62+
backup_file_path,
63+
gzip=False,
64+
mode="r",
65+
) as ostf,
66+
):
67+
ostf.extractall(
68+
path=Path(tempdir, "extracted"),
69+
members=securetar.secure_path(ostf),
70+
filter="fully_trusted",
71+
)
72+
backup_meta_file = Path(tempdir, "extracted", "backup.json")
73+
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
74+
75+
if (
76+
backup_meta_version := AwesomeVersion(
77+
backup_meta["homeassistant"]["version"]
78+
)
79+
) > HA_VERSION:
80+
raise ValueError(
81+
f"You need at least Home Assistant version {backup_meta_version} to restore this backup"
82+
)
83+
84+
with securetar.SecureTarFile(
85+
Path(
86+
tempdir,
87+
"extracted",
88+
f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}",
89+
),
90+
gzip=backup_meta["compressed"],
91+
mode="r",
92+
) as istf:
93+
for member in istf.getmembers():
94+
if member.name == "data":
95+
continue
96+
member.name = member.name.replace("data/", "")
97+
_clear_configuration_directory(config_dir)
98+
istf.extractall(
99+
path=config_dir,
100+
members=[
101+
member
102+
for member in securetar.secure_path(istf)
103+
if member.name != "data"
104+
],
105+
filter="fully_trusted",
106+
)
107+
108+
109+
def restore_backup(config_dir_path: str) -> bool:
110+
"""Restore the backup file if any.
111+
112+
Returns True if a restore backup file was found and restored, False otherwise.
113+
"""
114+
config_dir = Path(config_dir_path)
115+
if not (restore_content := restore_backup_file_content(config_dir)):
116+
return False
117+
118+
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
119+
backup_file_path = restore_content.backup_file_path
120+
_LOGGER.info("Restoring %s", backup_file_path)
121+
try:
122+
_extract_backup(config_dir, backup_file_path)
123+
except FileNotFoundError as err:
124+
raise ValueError(f"Backup file {backup_file_path} does not exist") from err
125+
_LOGGER.info("Restore complete, restarting")
126+
return True

homeassistant/components/backup/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
EXCLUDE_FROM_BACKUP = [
1818
"__pycache__/*",
1919
".DS_Store",
20+
".HA_RESTORE",
2021
"*.db-shm",
2122
"*.log.*",
2223
"*.log",

homeassistant/components/backup/manager.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from securetar import SecureTarFile, atomic_contents_add
1818

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

127+
@abc.abstractmethod
128+
async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
129+
"""Restpre a backup."""
130+
126131
@abc.abstractmethod
127132
async def async_create_backup(self, **kwargs: Any) -> Backup:
128133
"""Generate a backup."""
@@ -291,6 +296,25 @@ def _mkdir_and_generate_backup_contents(
291296

292297
return tar_file_path.stat().st_size
293298

299+
async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
300+
"""Restore a backup.
301+
302+
This will write the restore information to .HA_RESTORE which
303+
will be handled during startup by the restore_backup module.
304+
"""
305+
if (backup := await self.async_get_backup(slug=slug)) is None:
306+
raise HomeAssistantError(f"Backup {slug} not found")
307+
308+
def _write_restore_file() -> None:
309+
"""Write the restore file."""
310+
Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text(
311+
f"{backup.path.as_posix()};",
312+
encoding="utf-8",
313+
)
314+
315+
await self.hass.async_add_executor_job(_write_restore_file)
316+
await self.hass.services.async_call("homeassistant", "restart", {})
317+
294318

295319
def _generate_slug(date: str, name: str) -> str:
296320
"""Generate a backup slug."""

homeassistant/components/backup/websocket.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
2222
websocket_api.async_register_command(hass, handle_info)
2323
websocket_api.async_register_command(hass, handle_create)
2424
websocket_api.async_register_command(hass, handle_remove)
25+
websocket_api.async_register_command(hass, handle_restore)
2526

2627

2728
@websocket_api.require_admin
@@ -85,6 +86,24 @@ async def handle_remove(
8586
connection.send_result(msg["id"])
8687

8788

89+
@websocket_api.require_admin
90+
@websocket_api.websocket_command(
91+
{
92+
vol.Required("type"): "backup/restore",
93+
vol.Required("slug"): str,
94+
}
95+
)
96+
@websocket_api.async_response
97+
async def handle_restore(
98+
hass: HomeAssistant,
99+
connection: websocket_api.ActiveConnection,
100+
msg: dict[str, Any],
101+
) -> None:
102+
"""Restore a backup."""
103+
await hass.data[DATA_MANAGER].async_restore_backup(msg["slug"])
104+
connection.send_result(msg["id"])
105+
106+
88107
@websocket_api.require_admin
89108
@websocket_api.websocket_command({vol.Required("type"): "backup/generate"})
90109
@websocket_api.async_response

homeassistant/package_constraints.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ PyTurboJPEG==1.7.5
5757
pyudev==0.24.1
5858
PyYAML==6.0.2
5959
requests==2.32.3
60+
securetar==2024.2.1
6061
SQLAlchemy==2.0.31
6162
typing-extensions>=4.12.2,<5.0
6263
ulid-transform==1.0.2

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ dependencies = [
6363
"python-slugify==8.0.4",
6464
"PyYAML==6.0.2",
6565
"requests==2.32.3",
66+
"securetar==2024.2.1",
6667
"SQLAlchemy==2.0.31",
6768
"typing-extensions>=4.12.2,<5.0",
6869
"ulid-transform==1.0.2",

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ psutil-home-assistant==0.0.1
3535
python-slugify==8.0.4
3636
PyYAML==6.0.2
3737
requests==2.32.3
38+
securetar==2024.2.1
3839
SQLAlchemy==2.0.31
3940
typing-extensions>=4.12.2,<5.0
4041
ulid-transform==1.0.2

tests/components/backup/snapshots/test_websocket.ambr

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,3 +269,22 @@
269269
'type': 'result',
270270
})
271271
# ---
272+
# name: test_restore[with_hassio]
273+
dict({
274+
'error': dict({
275+
'code': 'unknown_command',
276+
'message': 'Unknown command.',
277+
}),
278+
'id': 1,
279+
'success': False,
280+
'type': 'result',
281+
})
282+
# ---
283+
# name: test_restore[without_hassio]
284+
dict({
285+
'id': 1,
286+
'result': None,
287+
'success': True,
288+
'type': 'result',
289+
})
290+
# ---

tests/components/backup/test_manager.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,31 @@ async def test_loading_platforms_when_running_async_post_backup_actions(
333333
assert len(manager.platforms) == 1
334334

335335
assert "Loaded 1 platforms" in caplog.text
336+
337+
338+
async def test_async_trigger_restore(
339+
hass: HomeAssistant,
340+
caplog: pytest.LogCaptureFixture,
341+
) -> None:
342+
"""Test trigger restore."""
343+
manager = BackupManager(hass)
344+
manager.loaded_backups = True
345+
manager.backups = {TEST_BACKUP.slug: TEST_BACKUP}
346+
347+
with (
348+
patch("pathlib.Path.exists", return_value=True),
349+
patch("pathlib.Path.write_text") as mocked_write_text,
350+
patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call,
351+
):
352+
await manager.async_restore_backup(TEST_BACKUP.slug)
353+
assert mocked_write_text.call_args[0][0] == "abc123.tar;"
354+
assert mocked_service_call.called
355+
356+
357+
async def test_async_trigger_restore_missing_backup(hass: HomeAssistant) -> None:
358+
"""Test trigger restore."""
359+
manager = BackupManager(hass)
360+
manager.loaded_backups = True
361+
362+
with pytest.raises(HomeAssistantError, match="Backup abc123 not found"):
363+
await manager.async_restore_backup(TEST_BACKUP.slug)

0 commit comments

Comments
 (0)