Skip to content

Commit de68868

Browse files
authored
Restore backup from specific location (#5491)
1 parent 90590ae commit de68868

File tree

5 files changed

+124
-46
lines changed

5 files changed

+124
-46
lines changed

supervisor/api/backups.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def _ensure_list(item: Any) -> list:
8383
{
8484
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
8585
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
86+
vol.Optional(ATTR_LOCATION): vol.Maybe(str),
8687
}
8788
)
8889

@@ -379,8 +380,10 @@ async def backup_partial(self, request: web.Request):
379380
async def restore_full(self, request: web.Request):
380381
"""Full restore of a backup."""
381382
backup = self._extract_slug(request)
382-
self._validate_cloud_backup_location(request, backup.location)
383383
body = await api_validate(SCHEMA_RESTORE_FULL, request)
384+
self._validate_cloud_backup_location(
385+
request, body.get(ATTR_LOCATION, backup.location)
386+
)
384387
background = body.pop(ATTR_BACKGROUND)
385388
restore_task, job_id = await self._background_backup_task(
386389
self.sys_backups.do_restore_full, backup, **body
@@ -397,8 +400,10 @@ async def restore_full(self, request: web.Request):
397400
async def restore_partial(self, request: web.Request):
398401
"""Partial restore a backup."""
399402
backup = self._extract_slug(request)
400-
self._validate_cloud_backup_location(request, backup.location)
401403
body = await api_validate(SCHEMA_RESTORE_PARTIAL, request)
404+
self._validate_cloud_backup_location(
405+
request, body.get(ATTR_LOCATION, backup.location)
406+
)
402407
background = body.pop(ATTR_BACKGROUND)
403408
restore_task, job_id = await self._background_backup_task(
404409
self.sys_backups.do_restore_partial, backup, **body

supervisor/backups/backup.py

+49-38
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import asyncio
44
from base64 import b64decode, b64encode
55
from collections import defaultdict
6-
from collections.abc import Awaitable
6+
from collections.abc import AsyncGenerator, Awaitable
7+
from contextlib import asynccontextmanager
78
from copy import deepcopy
89
from datetime import timedelta
910
from functools import cached_property
@@ -12,6 +13,7 @@
1213
import logging
1314
from pathlib import Path
1415
import tarfile
16+
from tarfile import TarFile
1517
from tempfile import TemporaryDirectory
1618
import time
1719
from typing import Any, Self
@@ -56,6 +58,7 @@
5658
from ..utils import remove_folder
5759
from ..utils.dt import parse_datetime, utcnow
5860
from ..utils.json import json_bytes
61+
from ..utils.sentinel import DEFAULT
5962
from .const import BUF_SIZE, LOCATION_CLOUD_BACKUP, BackupType
6063
from .utils import key_to_iv, password_to_key
6164
from .validate import SCHEMA_BACKUP
@@ -86,7 +89,6 @@ def __init__(
8689
self._data: dict[str, Any] = data or {ATTR_SLUG: slug}
8790
self._tmp = None
8891
self._outer_secure_tarfile: SecureTarFile | None = None
89-
self._outer_secure_tarfile_tarfile: tarfile.TarFile | None = None
9092
self._key: bytes | None = None
9193
self._aes: Cipher | None = None
9294
self._locations: dict[str | None, Path] = {location: tar_file}
@@ -375,59 +377,68 @@ def _load_file():
375377

376378
return True
377379

378-
async def __aenter__(self):
379-
"""Async context to open a backup."""
380+
@asynccontextmanager
381+
async def create(self) -> AsyncGenerator[None]:
382+
"""Create new backup file."""
383+
if self.tarfile.is_file():
384+
raise BackupError(
385+
f"Cannot make new backup at {self.tarfile.as_posix()}, file already exists!",
386+
_LOGGER.error,
387+
)
380388

381-
# create a backup
382-
if not self.tarfile.is_file():
383-
self._outer_secure_tarfile = SecureTarFile(
384-
self.tarfile,
385-
"w",
386-
gzip=False,
387-
bufsize=BUF_SIZE,
389+
self._outer_secure_tarfile = SecureTarFile(
390+
self.tarfile,
391+
"w",
392+
gzip=False,
393+
bufsize=BUF_SIZE,
394+
)
395+
try:
396+
with self._outer_secure_tarfile as outer_tarfile:
397+
yield
398+
await self._create_cleanup(outer_tarfile)
399+
finally:
400+
self._outer_secure_tarfile = None
401+
402+
@asynccontextmanager
403+
async def open(self, location: str | None | type[DEFAULT]) -> AsyncGenerator[None]:
404+
"""Open backup for restore."""
405+
if location != DEFAULT and location not in self.all_locations:
406+
raise BackupError(
407+
f"Backup {self.slug} does not exist in location {location}",
408+
_LOGGER.error,
409+
)
410+
411+
backup_tarfile = (
412+
self.tarfile if location == DEFAULT else self.all_locations[location]
413+
)
414+
if not backup_tarfile.is_file():
415+
raise BackupError(
416+
f"Cannot open backup at {backup_tarfile.as_posix()}, file does not exist!",
417+
_LOGGER.error,
388418
)
389-
self._outer_secure_tarfile_tarfile = self._outer_secure_tarfile.__enter__()
390-
return
391419

392420
# extract an existing backup
393-
self._tmp = TemporaryDirectory(dir=str(self.tarfile.parent))
421+
self._tmp = TemporaryDirectory(dir=str(backup_tarfile.parent))
394422

395423
def _extract_backup():
396424
"""Extract a backup."""
397-
with tarfile.open(self.tarfile, "r:") as tar:
425+
with tarfile.open(backup_tarfile, "r:") as tar:
398426
tar.extractall(
399427
path=self._tmp.name,
400428
members=secure_path(tar),
401429
filter="fully_trusted",
402430
)
403431

404-
await self.sys_run_in_executor(_extract_backup)
405-
406-
async def __aexit__(self, exception_type, exception_value, traceback):
407-
"""Async context to close a backup."""
408-
# exists backup or exception on build
409-
try:
410-
await self._aexit(exception_type, exception_value, traceback)
411-
finally:
412-
if self._tmp:
413-
self._tmp.cleanup()
414-
if self._outer_secure_tarfile:
415-
self._outer_secure_tarfile.__exit__(
416-
exception_type, exception_value, traceback
417-
)
418-
self._outer_secure_tarfile = None
419-
self._outer_secure_tarfile_tarfile = None
432+
with self._tmp:
433+
await self.sys_run_in_executor(_extract_backup)
434+
yield
420435

421-
async def _aexit(self, exception_type, exception_value, traceback):
436+
async def _create_cleanup(self, outer_tarfile: TarFile) -> None:
422437
"""Cleanup after backup creation.
423438
424-
This is a separate method to allow it to be called from __aexit__ to ensure
439+
Separate method to be called from create to ensure
425440
that cleanup is always performed, even if an exception is raised.
426441
"""
427-
# If we're not creating a new backup, or if an exception was raised, we're done
428-
if not self._outer_secure_tarfile or exception_type is not None:
429-
return
430-
431442
# validate data
432443
try:
433444
self._data = SCHEMA_BACKUP(self._data)
@@ -445,7 +456,7 @@ def _add_backup_json():
445456
tar_info = tarfile.TarInfo(name="./backup.json")
446457
tar_info.size = len(raw_bytes)
447458
tar_info.mtime = int(time.time())
448-
self._outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj)
459+
outer_tarfile.addfile(tar_info, fileobj=fileobj)
449460

450461
try:
451462
await self.sys_run_in_executor(_add_backup_json)

supervisor/backups/manager.py

+20-5
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ async def _do_backup(
405405
try:
406406
self.sys_core.state = CoreState.FREEZE
407407

408-
async with backup:
408+
async with backup.create():
409409
# HomeAssistant Folder is for v1
410410
if homeassistant:
411411
self._change_stage(BackupJobStage.HOME_ASSISTANT, backup)
@@ -575,6 +575,7 @@ async def _do_restore(
575575
folder_list: list[str],
576576
homeassistant: bool,
577577
replace: bool,
578+
location: str | None | type[DEFAULT],
578579
) -> bool:
579580
"""Restore from a backup.
580581
@@ -585,7 +586,7 @@ async def _do_restore(
585586

586587
try:
587588
task_hass: asyncio.Task | None = None
588-
async with backup:
589+
async with backup.open(location):
589590
# Restore docker config
590591
self._change_stage(RestoreJobStage.DOCKER_CONFIG, backup)
591592
backup.restore_dockerconfig(replace)
@@ -671,7 +672,10 @@ async def _do_restore(
671672
cleanup=False,
672673
)
673674
async def do_restore_full(
674-
self, backup: Backup, password: str | None = None
675+
self,
676+
backup: Backup,
677+
password: str | None = None,
678+
location: str | None | type[DEFAULT] = DEFAULT,
675679
) -> bool:
676680
"""Restore a backup."""
677681
# Add backup ID to job
@@ -702,7 +706,12 @@ async def do_restore_full(
702706
await self.sys_core.shutdown()
703707

704708
success = await self._do_restore(
705-
backup, backup.addon_list, backup.folders, True, True
709+
backup,
710+
backup.addon_list,
711+
backup.folders,
712+
homeassistant=True,
713+
replace=True,
714+
location=location,
706715
)
707716
finally:
708717
self.sys_core.state = CoreState.RUNNING
@@ -731,6 +740,7 @@ async def do_restore_partial(
731740
addons: list[str] | None = None,
732741
folders: list[Path] | None = None,
733742
password: str | None = None,
743+
location: str | None | type[DEFAULT] = DEFAULT,
734744
) -> bool:
735745
"""Restore a backup."""
736746
# Add backup ID to job
@@ -766,7 +776,12 @@ async def do_restore_partial(
766776

767777
try:
768778
success = await self._do_restore(
769-
backup, addon_list, folder_list, homeassistant, False
779+
backup,
780+
addon_list,
781+
folder_list,
782+
homeassistant=homeassistant,
783+
replace=False,
784+
location=location,
770785
)
771786
finally:
772787
self.sys_core.state = CoreState.RUNNING

tests/api/test_backups.py

+47
Original file line numberDiff line numberDiff line change
@@ -810,3 +810,50 @@ async def test_partial_backup_all_addons(
810810
)
811811
assert resp.status == 200
812812
store_addons.assert_called_once_with([install_addon_ssh])
813+
814+
815+
async def test_restore_backup_from_location(
816+
api_client: TestClient, coresys: CoreSys, tmp_supervisor_data: Path
817+
):
818+
"""Test restoring a backup from a specific location."""
819+
coresys.core.state = CoreState.RUNNING
820+
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
821+
822+
# Make a backup and a file to test with
823+
(test_file := coresys.config.path_share / "test.txt").touch()
824+
resp = await api_client.post(
825+
"/backups/new/partial",
826+
json={
827+
"name": "Test",
828+
"folders": ["share"],
829+
"location": [None, ".cloud_backup"],
830+
},
831+
)
832+
assert resp.status == 200
833+
body = await resp.json()
834+
backup = coresys.backups.get(body["data"]["slug"])
835+
assert set(backup.all_locations) == {None, ".cloud_backup"}
836+
837+
# The use case of this is user might want to pick a particular mount if one is flaky
838+
# To simulate this, remove the file from one location and show one works and the other doesn't
839+
assert backup.location is None
840+
backup.all_locations[None].unlink()
841+
test_file.unlink()
842+
843+
resp = await api_client.post(
844+
f"/backups/{backup.slug}/restore/partial",
845+
json={"location": None, "folders": ["share"]},
846+
)
847+
assert resp.status == 400
848+
body = await resp.json()
849+
assert (
850+
body["message"]
851+
== f"Cannot open backup at {backup.all_locations[None].as_posix()}, file does not exist!"
852+
)
853+
854+
resp = await api_client.post(
855+
f"/backups/{backup.slug}/restore/partial",
856+
json={"location": ".cloud_backup", "folders": ["share"]},
857+
)
858+
assert resp.status == 200
859+
assert test_file.is_file()

tests/backups/test_backup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ async def test_new_backup_stays_in_folder(coresys: CoreSys, tmp_path: Path):
1414
backup.new("test", "2023-07-21T21:05:00.000000+00:00", BackupType.FULL)
1515
assert not listdir(tmp_path)
1616

17-
async with backup:
17+
async with backup.create():
1818
assert len(listdir(tmp_path)) == 1
1919
assert backup.tarfile.exists()
2020

0 commit comments

Comments
 (0)