Skip to content

Commit d6520de

Browse files
committed
Backup protected status can vary per location
1 parent 805017e commit d6520de

File tree

9 files changed

+221
-38
lines changed

9 files changed

+221
-38
lines changed

supervisor/api/backups.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
ATTR_LOCATION,
3434
ATTR_NAME,
3535
ATTR_PASSWORD,
36+
ATTR_PATH,
3637
ATTR_PROTECTED,
3738
ATTR_REPOSITORIES,
3839
ATTR_SIZE,
@@ -54,6 +55,7 @@
5455
ATTR_ADDITIONAL_LOCATIONS,
5556
ATTR_BACKGROUND,
5657
ATTR_LOCATIONS,
58+
ATTR_PROTECTED_LOCATIONS,
5759
ATTR_SIZE_BYTES,
5860
CONTENT_TYPE_TAR,
5961
)
@@ -163,6 +165,11 @@ def _list_backups(self):
163165
ATTR_LOCATION: backup.location,
164166
ATTR_LOCATIONS: backup.locations,
165167
ATTR_PROTECTED: backup.protected,
168+
ATTR_PROTECTED_LOCATIONS: [
169+
loc
170+
for loc in backup.locations
171+
if backup.all_locations[loc][ATTR_PROTECTED]
172+
],
166173
ATTR_COMPRESSED: backup.compressed,
167174
ATTR_CONTENT: {
168175
ATTR_HOMEASSISTANT: backup.homeassistant_version is not None,
@@ -234,6 +241,11 @@ async def backup_info(self, request):
234241
ATTR_SIZE_BYTES: backup.size_bytes,
235242
ATTR_COMPRESSED: backup.compressed,
236243
ATTR_PROTECTED: backup.protected,
244+
ATTR_PROTECTED_LOCATIONS: [
245+
loc
246+
for loc in backup.locations
247+
if backup.all_locations[loc][ATTR_PROTECTED]
248+
],
237249
ATTR_SUPERVISOR_VERSION: backup.supervisor_version,
238250
ATTR_HOMEASSISTANT: backup.homeassistant_version,
239251
ATTR_LOCATION: backup.location,
@@ -458,7 +470,7 @@ async def download(self, request: web.Request):
458470
raise APIError(f"Backup {backup.slug} is not in location {location}")
459471

460472
_LOGGER.info("Downloading backup %s", backup.slug)
461-
response = web.FileResponse(backup.all_locations[location])
473+
response = web.FileResponse(backup.all_locations[location][ATTR_PATH])
462474
response.content_type = CONTENT_TYPE_TAR
463475
response.headers[CONTENT_DISPOSITION] = (
464476
f"attachment; filename={RE_SLUGIFY_NAME.sub('_', backup.name)}.tar"

supervisor/api/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
ATTR_MOUNTS = "mounts"
5454
ATTR_MOUNT_POINTS = "mount_points"
5555
ATTR_PANEL_PATH = "panel_path"
56+
ATTR_PROTECTED_LOCATIONS = "protected_locations"
5657
ATTR_REMOVABLE = "removable"
5758
ATTR_REMOVE_CONFIG = "remove_config"
5859
ATTR_REVISION = "revision"

supervisor/backups/backup.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
ATTR_HOMEASSISTANT,
4040
ATTR_NAME,
4141
ATTR_PASSWORD,
42+
ATTR_PATH,
4243
ATTR_PROTECTED,
4344
ATTR_REGISTRIES,
4445
ATTR_REPOSITORIES,
@@ -91,7 +92,12 @@ def __init__(
9192
self._outer_secure_tarfile: SecureTarFile | None = None
9293
self._key: bytes | None = None
9394
self._aes: Cipher | None = None
94-
self._locations: dict[str | None, Path] = {location: tar_file}
95+
self._locations: dict[str | None, dict[str, Path | bool]] = {
96+
location: {
97+
ATTR_PATH: tar_file,
98+
ATTR_PROTECTED: data.get(ATTR_PROTECTED, False) if data else False,
99+
}
100+
}
95101

96102
@property
97103
def version(self) -> int:
@@ -121,7 +127,7 @@ def date(self) -> str:
121127
@property
122128
def protected(self) -> bool:
123129
"""Return backup date."""
124-
return self._data[ATTR_PROTECTED]
130+
return self._locations[self.location][ATTR_PROTECTED]
125131

126132
@property
127133
def compressed(self) -> bool:
@@ -198,7 +204,7 @@ def location(self) -> str | None:
198204
return self.locations[0]
199205

200206
@property
201-
def all_locations(self) -> dict[str | None, Path]:
207+
def all_locations(self) -> dict[str | None, dict[str, Path | bool]]:
202208
"""Return all locations this backup was found in."""
203209
return self._locations
204210

@@ -236,7 +242,7 @@ def is_new(self) -> bool:
236242
@property
237243
def tarfile(self) -> Path:
238244
"""Return path to backup tarfile."""
239-
return self._locations[self.location]
245+
return self._locations[self.location][ATTR_PATH]
240246

241247
@property
242248
def is_current(self) -> bool:
@@ -252,7 +258,27 @@ def data(self) -> dict[str, Any]:
252258

253259
def __eq__(self, other: Any) -> bool:
254260
"""Return true if backups have same metadata."""
255-
return isinstance(other, Backup) and self._data == other._data
261+
if not isinstance(other, Backup):
262+
return False
263+
264+
# Compare all fields except ones about protection. Current encryption status does not affect equality
265+
keys = self._data.keys() | other._data.keys()
266+
for k in keys - {ATTR_PROTECTED, ATTR_CRYPTO}:
267+
if (
268+
k not in self._data
269+
or k not in other._data
270+
or self._data[k] != other._data[k]
271+
):
272+
_LOGGER.debug(
273+
"Backup %s and %s not equal because %s field has different value: %s and %s",
274+
self.slug,
275+
other.slug,
276+
k,
277+
self._data.get(k),
278+
other._data.get(k),
279+
)
280+
return False
281+
return True
256282

257283
def consolidate(self, backup: Self) -> None:
258284
"""Consolidate two backups with same slug in different locations."""
@@ -264,6 +290,20 @@ def consolidate(self, backup: Self) -> None:
264290
raise BackupInvalidError(
265291
f"Backup in {backup.location} and {self.location} both have slug {self.slug} but are not the same!"
266292
)
293+
294+
# In case of conflict we always ignore the ones from the first one. But log them to let the user know
295+
296+
if conflict := {
297+
loc: val[ATTR_PATH]
298+
for loc, val in self.all_locations.items()
299+
if loc in backup.all_locations and backup.all_locations[loc] != val
300+
}:
301+
_LOGGER.warning(
302+
"Backup %s exists in two files in locations %s. Ignoring %s",
303+
self.slug,
304+
", ".join(str(loc) for loc in conflict),
305+
", ".join([path.as_posix() for path in conflict.values()]),
306+
)
267307
self._locations.update(backup.all_locations)
268308

269309
def new(
@@ -292,6 +332,7 @@ def new(
292332
self._init_password(password)
293333
self._data[ATTR_PROTECTED] = True
294334
self._data[ATTR_CRYPTO] = CRYPTO_AES128
335+
self._locations[self.location][ATTR_PROTECTED] = True
295336

296337
if not compressed:
297338
self._data[ATTR_COMPRESSED] = False
@@ -418,6 +459,9 @@ def _load_file():
418459
)
419460
return False
420461

462+
if self._data[ATTR_PROTECTED]:
463+
self._locations[self.location][ATTR_PROTECTED] = True
464+
421465
return True
422466

423467
@asynccontextmanager
@@ -452,7 +496,9 @@ async def open(self, location: str | None | type[DEFAULT]) -> AsyncGenerator[Non
452496
)
453497

454498
backup_tarfile = (
455-
self.tarfile if location == DEFAULT else self.all_locations[location]
499+
self.tarfile
500+
if location == DEFAULT
501+
else self.all_locations[location][ATTR_PATH]
456502
)
457503
if not backup_tarfile.is_file():
458504
raise BackupError(

supervisor/backups/manager.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from ..addons.addon import Addon
1313
from ..const import (
1414
ATTR_DAYS_UNTIL_STALE,
15+
ATTR_PATH,
16+
ATTR_PROTECTED,
1517
FILE_HASSIO_BACKUPS,
1618
FOLDER_HOMEASSISTANT,
1719
CoreState,
@@ -286,7 +288,7 @@ def remove(
286288
)
287289
for location in targets:
288290
try:
289-
backup.all_locations[location].unlink()
291+
backup.all_locations[location][ATTR_PATH].unlink()
290292
del backup.all_locations[location]
291293
except OSError as err:
292294
if err.errno == errno.EBADMSG and location in {
@@ -340,13 +342,20 @@ def copy_to_additional_locations() -> dict[str | None, Path]:
340342
return all_locations
341343

342344
try:
343-
backup.all_locations.update(
344-
await self.sys_run_in_executor(copy_to_additional_locations)
345+
all_new_locations = await self.sys_run_in_executor(
346+
copy_to_additional_locations
345347
)
346348
except BackupDataDiskBadMessageError:
347349
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
348350
raise
349351

352+
backup.all_locations.update(
353+
{
354+
loc: {ATTR_PATH: path, ATTR_PROTECTED: backup.protected}
355+
for loc, path in all_new_locations.items()
356+
}
357+
)
358+
350359
@Job(name="backup_manager_import_backup")
351360
async def import_backup(
352361
self,
@@ -669,6 +678,30 @@ async def _do_restore(
669678
_job_override__cleanup=False
670679
)
671680

681+
async def _validate_location_password(
682+
self,
683+
backup: Backup,
684+
password: str | None = None,
685+
location: str | None | type[DEFAULT] = DEFAULT,
686+
) -> None:
687+
"""Validate location and password for backup, raise if invalid."""
688+
if location != DEFAULT and location not in backup.all_locations:
689+
raise BackupInvalidError(
690+
f"Backup {backup.slug} does not exist in {location}", _LOGGER.error
691+
)
692+
693+
if (
694+
location == DEFAULT
695+
and backup.protected
696+
or location != DEFAULT
697+
and backup.all_locations[location][ATTR_PROTECTED]
698+
):
699+
backup.set_password(password)
700+
if not await backup.validate_password():
701+
raise BackupInvalidError(
702+
f"Invalid password for backup {backup.slug}", _LOGGER.error
703+
)
704+
672705
@Job(
673706
name=JOB_FULL_RESTORE,
674707
conditions=[
@@ -697,12 +730,7 @@ async def do_restore_full(
697730
f"{backup.slug} is only a partial backup!", _LOGGER.error
698731
)
699732

700-
if backup.protected:
701-
backup.set_password(password)
702-
if not await backup.validate_password():
703-
raise BackupInvalidError(
704-
f"Invalid password for backup {backup.slug}", _LOGGER.error
705-
)
733+
await self._validate_location_password(backup, password, location)
706734

707735
if backup.supervisor_version > self.sys_supervisor.version:
708736
raise BackupInvalidError(
@@ -767,12 +795,7 @@ async def do_restore_partial(
767795
folder_list.remove(FOLDER_HOMEASSISTANT)
768796
homeassistant = True
769797

770-
if backup.protected:
771-
backup.set_password(password)
772-
if not await backup.validate_password():
773-
raise BackupInvalidError(
774-
f"Invalid password for backup {backup.slug}", _LOGGER.error
775-
)
798+
await self._validate_location_password(backup, password, location)
776799

777800
if backup.homeassistant is None and homeassistant:
778801
raise BackupInvalidError(

0 commit comments

Comments
 (0)