Skip to content

Commit c8f1b22

Browse files
authored
Add sizes per location and support .local (#5581)
1 parent 257e2ce commit c8f1b22

File tree

4 files changed

+105
-59
lines changed

4 files changed

+105
-59
lines changed

supervisor/api/backups.py

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@
5656
from .const import (
5757
ATTR_ADDITIONAL_LOCATIONS,
5858
ATTR_BACKGROUND,
59+
ATTR_LOCATION_ATTRIBUTES,
5960
ATTR_LOCATIONS,
60-
ATTR_PROTECTED_LOCATIONS,
6161
ATTR_SIZE_BYTES,
6262
CONTENT_TYPE_TAR,
6363
)
@@ -67,6 +67,8 @@
6767

6868
ALL_ADDONS_FLAG = "ALL"
6969

70+
LOCATION_LOCAL = ".local"
71+
7072
RE_SLUGIFY_NAME = re.compile(r"[^A-Za-z0-9]+")
7173
RE_BACKUP_FILENAME = re.compile(r"^[^\\\/]+\.tar$")
7274

@@ -82,20 +84,31 @@ def _ensure_list(item: Any) -> list:
8284
return item
8385

8486

87+
def _convert_local_location(item: str | None) -> str | None:
88+
"""Convert local location value."""
89+
if item in {LOCATION_LOCAL, ""}:
90+
return None
91+
return item
92+
93+
8594
# pylint: disable=no-value-for-parameter
95+
SCHEMA_FOLDERS = vol.All([vol.In(_ALL_FOLDERS)], vol.Unique())
96+
SCHEMA_LOCATION = vol.All(vol.Maybe(str), _convert_local_location)
97+
SCHEMA_LOCATION_LIST = vol.All(_ensure_list, [SCHEMA_LOCATION], vol.Unique())
98+
8699
SCHEMA_RESTORE_FULL = vol.Schema(
87100
{
88101
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
89102
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
90-
vol.Optional(ATTR_LOCATION): vol.Maybe(str),
103+
vol.Optional(ATTR_LOCATION): SCHEMA_LOCATION,
91104
}
92105
)
93106

94107
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
95108
{
96109
vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(),
97110
vol.Optional(ATTR_ADDONS): vol.All([str], vol.Unique()),
98-
vol.Optional(ATTR_FOLDERS): vol.All([vol.In(_ALL_FOLDERS)], vol.Unique()),
111+
vol.Optional(ATTR_FOLDERS): SCHEMA_FOLDERS,
99112
}
100113
)
101114

@@ -105,9 +118,7 @@ def _ensure_list(item: Any) -> list:
105118
vol.Optional(ATTR_FILENAME): vol.Match(RE_BACKUP_FILENAME),
106119
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
107120
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
108-
vol.Optional(ATTR_LOCATION): vol.All(
109-
_ensure_list, [vol.Maybe(str)], vol.Unique()
110-
),
121+
vol.Optional(ATTR_LOCATION): SCHEMA_LOCATION_LIST,
111122
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): vol.Boolean(),
112123
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
113124
vol.Optional(ATTR_EXTRA): dict,
@@ -119,30 +130,14 @@ def _ensure_list(item: Any) -> list:
119130
vol.Optional(ATTR_ADDONS): vol.Or(
120131
ALL_ADDONS_FLAG, vol.All([str], vol.Unique())
121132
),
122-
vol.Optional(ATTR_FOLDERS): vol.All([vol.In(_ALL_FOLDERS)], vol.Unique()),
133+
vol.Optional(ATTR_FOLDERS): SCHEMA_FOLDERS,
123134
vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(),
124135
}
125136
)
126137

127-
SCHEMA_OPTIONS = vol.Schema(
128-
{
129-
vol.Optional(ATTR_DAYS_UNTIL_STALE): days_until_stale,
130-
}
131-
)
132-
133-
SCHEMA_FREEZE = vol.Schema(
134-
{
135-
vol.Optional(ATTR_TIMEOUT): vol.All(int, vol.Range(min=1)),
136-
}
137-
)
138-
139-
SCHEMA_REMOVE = vol.Schema(
140-
{
141-
vol.Optional(ATTR_LOCATION): vol.All(
142-
_ensure_list, [vol.Maybe(str)], vol.Unique()
143-
),
144-
}
145-
)
138+
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_DAYS_UNTIL_STALE): days_until_stale})
139+
SCHEMA_FREEZE = vol.Schema({vol.Optional(ATTR_TIMEOUT): vol.All(int, vol.Range(min=1))})
140+
SCHEMA_REMOVE = vol.Schema({vol.Optional(ATTR_LOCATION): SCHEMA_LOCATION_LIST})
146141

147142

148143
class APIBackups(CoreSysAttributes):
@@ -155,6 +150,16 @@ def _extract_slug(self, request):
155150
raise APINotFound("Backup does not exist")
156151
return backup
157152

153+
def _make_location_attributes(self, backup: Backup) -> dict[str, dict[str, Any]]:
154+
"""Make location attributes dictionary."""
155+
return {
156+
loc if loc else LOCATION_LOCAL: {
157+
ATTR_PROTECTED: backup.all_locations[loc][ATTR_PROTECTED],
158+
ATTR_SIZE_BYTES: backup.location_size(loc),
159+
}
160+
for loc in backup.locations
161+
}
162+
158163
def _list_backups(self):
159164
"""Return list of backups."""
160165
return [
@@ -168,11 +173,7 @@ def _list_backups(self):
168173
ATTR_LOCATION: backup.location,
169174
ATTR_LOCATIONS: backup.locations,
170175
ATTR_PROTECTED: backup.protected,
171-
ATTR_PROTECTED_LOCATIONS: [
172-
loc
173-
for loc in backup.locations
174-
if backup.all_locations[loc][ATTR_PROTECTED]
175-
],
176+
ATTR_LOCATION_ATTRIBUTES: self._make_location_attributes(backup),
176177
ATTR_COMPRESSED: backup.compressed,
177178
ATTR_CONTENT: {
178179
ATTR_HOMEASSISTANT: backup.homeassistant_version is not None,
@@ -244,11 +245,7 @@ async def backup_info(self, request):
244245
ATTR_SIZE_BYTES: backup.size_bytes,
245246
ATTR_COMPRESSED: backup.compressed,
246247
ATTR_PROTECTED: backup.protected,
247-
ATTR_PROTECTED_LOCATIONS: [
248-
loc
249-
for loc in backup.locations
250-
if backup.all_locations[loc][ATTR_PROTECTED]
251-
],
248+
ATTR_LOCATION_ATTRIBUTES: self._make_location_attributes(backup),
252249
ATTR_SUPERVISOR_VERSION: backup.supervisor_version,
253250
ATTR_HOMEASSISTANT: backup.homeassistant_version,
254251
ATTR_LOCATION: backup.location,
@@ -467,7 +464,9 @@ async def download(self, request: web.Request):
467464
"""Download a backup file."""
468465
backup = self._extract_slug(request)
469466
# Query will give us '' for /backups, convert value to None
470-
location = request.query.get(ATTR_LOCATION, backup.location) or None
467+
location = _convert_local_location(
468+
request.query.get(ATTR_LOCATION, backup.location)
469+
)
471470
self._validate_cloud_backup_location(request, location)
472471
if location not in backup.all_locations:
473472
raise APIError(f"Backup {backup.slug} is not in location {location}")
@@ -496,7 +495,9 @@ async def upload(self, request: web.Request):
496495
self._validate_cloud_backup_location(request, location_names)
497496
# Convert empty string to None if necessary
498497
locations = [
499-
self._location_to_mount(location) if location else None
498+
self._location_to_mount(location)
499+
if _convert_local_location(location)
500+
else None
500501
for location in location_names
501502
]
502503
location = locations.pop(0)

supervisor/api/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@
4747
ATTR_LLMNR = "llmnr"
4848
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
4949
ATTR_LOCAL_ONLY = "local_only"
50+
ATTR_LOCATION_ATTRIBUTES = "location_attributes"
5051
ATTR_LOCATIONS = "locations"
5152
ATTR_MDNS = "mdns"
5253
ATTR_MODEL = "model"
5354
ATTR_MOUNTS = "mounts"
5455
ATTR_MOUNT_POINTS = "mount_points"
5556
ATTR_PANEL_PATH = "panel_path"
56-
ATTR_PROTECTED_LOCATIONS = "protected_locations"
5757
ATTR_REMOVABLE = "removable"
5858
ATTR_REMOVE_CONFIG = "remove_config"
5959
ATTR_REVISION = "revision"

supervisor/backups/backup.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from contextlib import asynccontextmanager
88
from copy import deepcopy
99
from datetime import timedelta
10-
from functools import cached_property
10+
from functools import lru_cache
1111
import io
1212
import json
1313
import logging
@@ -67,6 +67,12 @@
6767
_LOGGER: logging.Logger = logging.getLogger(__name__)
6868

6969

70+
@lru_cache
71+
def _backup_file_size(backup: Path) -> int:
72+
"""Get backup file size."""
73+
return backup.stat().st_size if backup.is_file() else 0
74+
75+
7076
def location_sort_key(value: str | None) -> str:
7177
"""Sort locations, None is always first else alphabetical."""
7278
return value if value else ""
@@ -222,17 +228,15 @@ def locations(self) -> list[str | None]:
222228
key=location_sort_key,
223229
)
224230

225-
@cached_property
231+
@property
226232
def size(self) -> float:
227233
"""Return backup size."""
228234
return round(self.size_bytes / 1048576, 2) # calc mbyte
229235

230-
@cached_property
236+
@property
231237
def size_bytes(self) -> int:
232238
"""Return backup size in bytes."""
233-
if not self.tarfile.is_file():
234-
return 0
235-
return self.tarfile.stat().st_size
239+
return self.location_size(self.location)
236240

237241
@property
238242
def is_new(self) -> bool:
@@ -256,6 +260,14 @@ def data(self) -> dict[str, Any]:
256260
"""Returns a copy of the data."""
257261
return deepcopy(self._data)
258262

263+
def location_size(self, location: str | None) -> int:
264+
"""Get size of backup in a location."""
265+
if location not in self.all_locations:
266+
return 0
267+
268+
backup = self.all_locations[location][ATTR_PATH]
269+
return _backup_file_size(backup)
270+
259271
def __eq__(self, other: Any) -> bool:
260272
"""Return true if backups have same metadata."""
261273
if not isinstance(other, Backup):

tests/api/test_backups.py

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,21 @@ async def test_options(api_client: TestClient, coresys: CoreSys):
100100

101101

102102
@pytest.mark.parametrize(
103-
"location,backup_dir",
104-
[("backup_test", PurePath("mounts", "backup_test")), (None, PurePath("backup"))],
103+
("location", "backup_dir", "api_location"),
104+
[
105+
("backup_test", PurePath("mounts", "backup_test"), "backup_test"),
106+
(None, PurePath("backup"), None),
107+
("", PurePath("backup"), None),
108+
(".local", PurePath("backup"), None),
109+
],
105110
)
106111
@pytest.mark.usefixtures("path_extern", "mount_propagation", "mock_is_mount")
107112
async def test_backup_to_location(
108113
api_client: TestClient,
109114
coresys: CoreSys,
110115
location: str | None,
111116
backup_dir: PurePath,
117+
api_location: str | None,
112118
tmp_supervisor_data: Path,
113119
):
114120
"""Test making a backup to a specific location with default mount."""
@@ -145,7 +151,7 @@ async def test_backup_to_location(
145151
resp = await api_client.get(f"/backups/{slug}/info")
146152
result = await resp.json()
147153
assert result["result"] == "ok"
148-
assert result["data"]["location"] == location
154+
assert result["data"]["location"] == api_location
149155

150156

151157
@pytest.mark.usefixtures(
@@ -661,14 +667,18 @@ async def test_backup_with_extras(
661667

662668

663669
@pytest.mark.usefixtures("tmp_supervisor_data")
664-
async def test_upload_to_multiple_locations(api_client: TestClient, coresys: CoreSys):
670+
@pytest.mark.parametrize("local_location", ["", ".local"])
671+
async def test_upload_to_multiple_locations(
672+
api_client: TestClient, coresys: CoreSys, local_location: str
673+
):
665674
"""Test uploading a backup to multiple locations."""
666675
backup_file = get_fixture_path("backup_example.tar")
667676

668677
with backup_file.open("rb") as file, MultipartWriter("form-data") as mp:
669678
mp.append(file)
670679
resp = await api_client.post(
671-
"/backups/new/upload?location=&location=.cloud_backup", data=mp
680+
f"/backups/new/upload?location={local_location}&location=.cloud_backup",
681+
data=mp,
672682
)
673683

674684
assert resp.status == 200
@@ -798,8 +808,12 @@ async def test_remove_backup_from_location(api_client: TestClient, coresys: Core
798808
assert backup.all_locations == {None: {"path": location_1, "protected": False}}
799809

800810

811+
@pytest.mark.parametrize("local_location", ["", ".local"])
801812
async def test_download_backup_from_location(
802-
api_client: TestClient, coresys: CoreSys, tmp_supervisor_data: Path
813+
api_client: TestClient,
814+
coresys: CoreSys,
815+
tmp_supervisor_data: Path,
816+
local_location: str,
803817
):
804818
"""Test downloading a backup from a specific location."""
805819
backup_file = get_fixture_path("backup_example.tar")
@@ -816,12 +830,12 @@ async def test_download_backup_from_location(
816830
# The use case of this is user might want to pick a particular mount if one is flaky
817831
# To simulate this, remove the file from one location and show one works and the other doesn't
818832
assert backup.location is None
819-
location_1.unlink()
833+
location_2.unlink()
820834

821-
resp = await api_client.get("/backups/7fed74c8/download?location=")
835+
resp = await api_client.get("/backups/7fed74c8/download?location=.cloud_backup")
822836
assert resp.status == 404
823837

824-
resp = await api_client.get("/backups/7fed74c8/download?location=.cloud_backup")
838+
resp = await api_client.get(f"/backups/7fed74c8/download?location={local_location}")
825839
assert resp.status == 200
826840
out_file = tmp_supervisor_data / "backup_example.tar"
827841
with out_file.open("wb") as out:
@@ -859,8 +873,12 @@ async def test_partial_backup_all_addons(
859873
store_addons.assert_called_once_with([install_addon_ssh])
860874

861875

876+
@pytest.mark.parametrize("local_location", [None, "", ".local"])
862877
async def test_restore_backup_from_location(
863-
api_client: TestClient, coresys: CoreSys, tmp_supervisor_data: Path
878+
api_client: TestClient,
879+
coresys: CoreSys,
880+
tmp_supervisor_data: Path,
881+
local_location: str | None,
864882
):
865883
"""Test restoring a backup from a specific location."""
866884
coresys.core.state = CoreState.RUNNING
@@ -889,7 +907,7 @@ async def test_restore_backup_from_location(
889907

890908
resp = await api_client.post(
891909
f"/backups/{backup.slug}/restore/partial",
892-
json={"location": None, "folders": ["share"]},
910+
json={"location": local_location, "folders": ["share"]},
893911
)
894912
assert resp.status == 400
895913
body = await resp.json()
@@ -983,7 +1001,12 @@ async def test_backup_mixed_encryption(api_client: TestClient, coresys: CoreSys)
9831001
assert body["data"]["backups"][0]["location"] is None
9841002
assert body["data"]["backups"][0]["locations"] == [None]
9851003
assert body["data"]["backups"][0]["protected"] is True
986-
assert body["data"]["backups"][0]["protected_locations"] == [None]
1004+
assert body["data"]["backups"][0]["location_attributes"] == {
1005+
".local": {
1006+
"protected": True,
1007+
"size_bytes": 10240,
1008+
}
1009+
}
9871010

9881011

9891012
@pytest.mark.parametrize(
@@ -1012,12 +1035,22 @@ async def test_protected_backup(
10121035
assert body["data"]["backups"][0]["location"] is None
10131036
assert body["data"]["backups"][0]["locations"] == [None]
10141037
assert body["data"]["backups"][0]["protected"] is True
1015-
assert body["data"]["backups"][0]["protected_locations"] == [None]
1038+
assert body["data"]["backups"][0]["location_attributes"] == {
1039+
".local": {
1040+
"protected": True,
1041+
"size_bytes": 10240,
1042+
}
1043+
}
10161044

10171045
resp = await api_client.get(f"/backups/{slug}/info")
10181046
assert resp.status == 200
10191047
body = await resp.json()
10201048
assert body["data"]["location"] is None
10211049
assert body["data"]["locations"] == [None]
10221050
assert body["data"]["protected"] is True
1023-
assert body["data"]["protected_locations"] == [None]
1051+
assert body["data"]["location_attributes"] == {
1052+
".local": {
1053+
"protected": True,
1054+
"size_bytes": 10240,
1055+
}
1056+
}

0 commit comments

Comments
 (0)