Skip to content

Commit 02ceb71

Browse files
authored
Add location to backup download and remove APIs (#5482)
1 parent 774aef7 commit 02ceb71

File tree

3 files changed

+90
-10
lines changed

3 files changed

+90
-10
lines changed

supervisor/api/backups.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ def _ensure_list(item: Any) -> list:
126126
}
127127
)
128128

129+
SCHEMA_REMOVE = vol.Schema(
130+
{
131+
vol.Optional(ATTR_LOCATION): vol.All(
132+
_ensure_list, [vol.Maybe(str)], vol.Unique()
133+
),
134+
}
135+
)
136+
129137

130138
class APIBackups(CoreSysAttributes):
131139
"""Handle RESTful API for backups functions."""
@@ -411,17 +419,29 @@ async def thaw(self, request: web.Request):
411419
async def remove(self, request: web.Request):
412420
"""Remove a backup."""
413421
backup = self._extract_slug(request)
414-
self._validate_cloud_backup_location(request, backup.location)
415-
return self.sys_backups.remove(backup)
422+
body = await api_validate(SCHEMA_REMOVE, request)
423+
locations: list[LOCATION_TYPE] | None = None
424+
425+
if ATTR_LOCATION in body:
426+
self._validate_cloud_backup_location(request, body[ATTR_LOCATION])
427+
locations = [self._location_to_mount(name) for name in body[ATTR_LOCATION]]
428+
else:
429+
self._validate_cloud_backup_location(request, backup.location)
430+
431+
return self.sys_backups.remove(backup, locations=locations)
416432

417433
@api_process
418434
async def download(self, request: web.Request):
419435
"""Download a backup file."""
420436
backup = self._extract_slug(request)
421-
self._validate_cloud_backup_location(request, backup.location)
437+
# Query will give us '' for /backups, convert value to None
438+
location = request.query.get(ATTR_LOCATION, backup.location) or None
439+
self._validate_cloud_backup_location(request, location)
440+
if location not in backup.all_locations:
441+
raise APIError(f"Backup {backup.slug} is not in location {location}")
422442

423443
_LOGGER.info("Downloading backup %s", backup.slug)
424-
response = web.FileResponse(backup.tarfile)
444+
response = web.FileResponse(backup.all_locations[location])
425445
response.content_type = CONTENT_TYPE_TAR
426446
response.headers[CONTENT_DISPOSITION] = (
427447
f"attachment; filename={RE_SLUGIFY_NAME.sub('_', backup.name)}.tar"

tests/api/test_backups.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,3 +747,65 @@ async def test_backup_not_found(api_client: TestClient, method: str, url: str):
747747
assert resp.status == 404
748748
resp = await resp.json()
749749
assert resp["message"] == "Backup does not exist"
750+
751+
752+
@pytest.mark.usefixtures("tmp_supervisor_data")
753+
async def test_remove_backup_from_location(api_client: TestClient, coresys: CoreSys):
754+
"""Test removing a backup from one location of multiple."""
755+
backup_file = get_fixture_path("backup_example.tar")
756+
location_1 = Path(copy(backup_file, coresys.config.path_backup))
757+
location_2 = Path(copy(backup_file, coresys.config.path_core_backup))
758+
759+
await coresys.backups.reload()
760+
assert (backup := coresys.backups.get("7fed74c8"))
761+
assert backup.all_locations == {None: location_1, ".cloud_backup": location_2}
762+
763+
resp = await api_client.delete(
764+
"/backups/7fed74c8", json={"location": ".cloud_backup"}
765+
)
766+
assert resp.status == 200
767+
768+
assert location_1.exists()
769+
assert not location_2.exists()
770+
assert coresys.backups.get("7fed74c8")
771+
assert backup.all_locations == {None: location_1}
772+
773+
774+
async def test_download_backup_from_location(
775+
api_client: TestClient, coresys: CoreSys, tmp_supervisor_data: Path
776+
):
777+
"""Test downloading a backup from a specific location."""
778+
backup_file = get_fixture_path("backup_example.tar")
779+
location_1 = Path(copy(backup_file, coresys.config.path_backup))
780+
location_2 = Path(copy(backup_file, coresys.config.path_core_backup))
781+
782+
await coresys.backups.reload()
783+
assert (backup := coresys.backups.get("7fed74c8"))
784+
assert backup.all_locations == {None: location_1, ".cloud_backup": location_2}
785+
786+
# The use case of this is user might want to pick a particular mount if one is flaky
787+
# To simulate this, remove the file from one location and show one works and the other doesn't
788+
assert backup.location is None
789+
location_1.unlink()
790+
791+
resp = await api_client.get("/backups/7fed74c8/download?location=")
792+
assert resp.status == 404
793+
794+
resp = await api_client.get("/backups/7fed74c8/download?location=.cloud_backup")
795+
assert resp.status == 200
796+
out_file = tmp_supervisor_data / "backup_example.tar"
797+
with out_file.open("wb") as out:
798+
out.write(await resp.read())
799+
800+
out_backup = Backup(coresys, out_file, "out", None)
801+
await out_backup.load()
802+
assert backup == out_backup
803+
804+
805+
@pytest.mark.usefixtures("mock_full_backup")
806+
async def test_download_backup_from_invalid_location(api_client: TestClient):
807+
"""Test error for invalid download location."""
808+
resp = await api_client.get("/backups/test/download?location=.cloud_backup")
809+
assert resp.status == 400
810+
body = await resp.json()
811+
assert body["message"] == "Backup test is not in location .cloud_backup"

tests/backups/test_manager.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1977,9 +1977,8 @@ async def test_partial_reload_multiple_locations(
19771977
assert backup.all_locations.keys() == {".cloud_backup", None, "backup_test"}
19781978

19791979

1980-
async def test_backup_remove_multiple_locations(
1981-
coresys: CoreSys, tmp_supervisor_data: Path
1982-
):
1980+
@pytest.mark.usefixtures("tmp_supervisor_data")
1981+
async def test_backup_remove_multiple_locations(coresys: CoreSys):
19831982
"""Test removing a backup that exists in multiple locations."""
19841983
backup_file = get_fixture_path("backup_example.tar")
19851984
location_1 = Path(copy(backup_file, coresys.config.path_backup))
@@ -1995,9 +1994,8 @@ async def test_backup_remove_multiple_locations(
19951994
assert not coresys.backups.get("7fed74c8")
19961995

19971996

1998-
async def test_backup_remove_one_location_of_multiple(
1999-
coresys: CoreSys, tmp_supervisor_data: Path
2000-
):
1997+
@pytest.mark.usefixtures("tmp_supervisor_data")
1998+
async def test_backup_remove_one_location_of_multiple(coresys: CoreSys):
20011999
"""Test removing a backup that exists in multiple locations from one location."""
20022000
backup_file = get_fixture_path("backup_example.tar")
20032001
location_1 = Path(copy(backup_file, coresys.config.path_backup))

0 commit comments

Comments
 (0)