Skip to content

Commit 6e32144

Browse files
authored
Fix and extend cloud backup support (#5464)
* Fix and extend cloud backup support * Clean up task for cloud backup and remove by location * Args to kwargs on backup methods * Fix backup remove error test and typing clean up
1 parent 9b52fee commit 6e32144

File tree

22 files changed

+587
-335
lines changed

22 files changed

+587
-335
lines changed

supervisor/api/backups.py

Lines changed: 98 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Backups RESTful API."""
22

3+
from __future__ import annotations
4+
35
import asyncio
46
from collections.abc import Callable
57
import errno
@@ -14,7 +16,7 @@
1416
import voluptuous as vol
1517

1618
from ..backups.backup import Backup
17-
from ..backups.const import LOCATION_CLOUD_BACKUP
19+
from ..backups.const import LOCATION_CLOUD_BACKUP, LOCATION_TYPE
1820
from ..backups.validate import ALL_FOLDERS, FOLDER_HOMEASSISTANT, days_until_stale
1921
from ..const import (
2022
ATTR_ADDONS,
@@ -23,7 +25,7 @@
2325
ATTR_CONTENT,
2426
ATTR_DATE,
2527
ATTR_DAYS_UNTIL_STALE,
26-
ATTR_FILENAME,
28+
ATTR_EXTRA,
2729
ATTR_FOLDERS,
2830
ATTR_HOMEASSISTANT,
2931
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
@@ -48,7 +50,12 @@
4850
from ..jobs import JobSchedulerOptions
4951
from ..mounts.const import MountUsage
5052
from ..resolution.const import UnhealthyReason
51-
from .const import ATTR_BACKGROUND, ATTR_LOCATIONS, CONTENT_TYPE_TAR
53+
from .const import (
54+
ATTR_ADDITIONAL_LOCATIONS,
55+
ATTR_BACKGROUND,
56+
ATTR_LOCATIONS,
57+
CONTENT_TYPE_TAR,
58+
)
5259
from .utils import api_process, api_validate
5360

5461
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -60,6 +67,14 @@
6067
# Remove: 2022.08
6168
_ALL_FOLDERS = ALL_FOLDERS + [FOLDER_HOMEASSISTANT]
6269

70+
71+
def _ensure_list(item: Any) -> list:
72+
"""Ensure value is a list."""
73+
if not isinstance(item, list):
74+
return [item]
75+
return item
76+
77+
6378
# pylint: disable=no-value-for-parameter
6479
SCHEMA_RESTORE_FULL = vol.Schema(
6580
{
@@ -81,9 +96,12 @@
8196
vol.Optional(ATTR_NAME): str,
8297
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
8398
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
84-
vol.Optional(ATTR_LOCATION): vol.Maybe(str),
99+
vol.Optional(ATTR_LOCATION): vol.All(
100+
_ensure_list, [vol.Maybe(str)], vol.Unique()
101+
),
85102
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): vol.Boolean(),
86103
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
104+
vol.Optional(ATTR_EXTRA): dict,
87105
}
88106
)
89107

@@ -106,12 +124,6 @@
106124
vol.Optional(ATTR_TIMEOUT): vol.All(int, vol.Range(min=1)),
107125
}
108126
)
109-
SCHEMA_RELOAD = vol.Schema(
110-
{
111-
vol.Inclusive(ATTR_LOCATION, "file"): vol.Maybe(str),
112-
vol.Inclusive(ATTR_FILENAME, "file"): vol.Match(RE_BACKUP_FILENAME),
113-
}
114-
)
115127

116128

117129
class APIBackups(CoreSysAttributes):
@@ -177,13 +189,10 @@ async def options(self, request):
177189
self.sys_backups.save_data()
178190

179191
@api_process
180-
async def reload(self, request: web.Request):
192+
async def reload(self, _):
181193
"""Reload backup list."""
182-
body = await api_validate(SCHEMA_RELOAD, request)
183-
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
184-
backup = self._location_to_mount(body)
185-
186-
return await asyncio.shield(self.sys_backups.reload(**backup))
194+
await asyncio.shield(self.sys_backups.reload())
195+
return True
187196

188197
@api_process
189198
async def backup_info(self, request):
@@ -217,27 +226,35 @@ async def backup_info(self, request):
217226
ATTR_REPOSITORIES: backup.repositories,
218227
ATTR_FOLDERS: backup.folders,
219228
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE: backup.homeassistant_exclude_database,
229+
ATTR_EXTRA: backup.extra,
220230
}
221231

222-
def _location_to_mount(self, body: dict[str, Any]) -> dict[str, Any]:
223-
"""Change location field to mount if necessary."""
224-
if not body.get(ATTR_LOCATION) or body[ATTR_LOCATION] == LOCATION_CLOUD_BACKUP:
225-
return body
232+
def _location_to_mount(self, location: str | None) -> LOCATION_TYPE:
233+
"""Convert a single location to a mount if possible."""
234+
if not location or location == LOCATION_CLOUD_BACKUP:
235+
return location
226236

227-
body[ATTR_LOCATION] = self.sys_mounts.get(body[ATTR_LOCATION])
228-
if body[ATTR_LOCATION].usage != MountUsage.BACKUP:
237+
mount = self.sys_mounts.get(location)
238+
if mount.usage != MountUsage.BACKUP:
229239
raise APIError(
230-
f"Mount {body[ATTR_LOCATION].name} is not used for backups, cannot backup to there"
240+
f"Mount {mount.name} is not used for backups, cannot backup to there"
231241
)
232242

243+
return mount
244+
245+
def _location_field_to_mount(self, body: dict[str, Any]) -> dict[str, Any]:
246+
"""Change location field to mount if necessary."""
247+
body[ATTR_LOCATION] = self._location_to_mount(body.get(ATTR_LOCATION))
233248
return body
234249

235250
def _validate_cloud_backup_location(
236-
self, request: web.Request, location: str | None
251+
self, request: web.Request, location: list[str | None] | str | None
237252
) -> None:
238253
"""Cloud backup location is only available to Home Assistant."""
254+
if not isinstance(location, list):
255+
location = [location]
239256
if (
240-
location == LOCATION_CLOUD_BACKUP
257+
LOCATION_CLOUD_BACKUP in location
241258
and request.get(REQUEST_FROM) != self.sys_homeassistant
242259
):
243260
raise APIForbidden(
@@ -278,10 +295,22 @@ async def release_on_freeze(new_state: CoreState):
278295
async def backup_full(self, request: web.Request):
279296
"""Create full backup."""
280297
body = await api_validate(SCHEMA_BACKUP_FULL, request)
281-
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
298+
locations: list[LOCATION_TYPE] | None = None
299+
300+
if ATTR_LOCATION in body:
301+
location_names: list[str | None] = body.pop(ATTR_LOCATION)
302+
self._validate_cloud_backup_location(request, location_names)
303+
304+
locations = [
305+
self._location_to_mount(location) for location in location_names
306+
]
307+
body[ATTR_LOCATION] = locations.pop(0)
308+
if locations:
309+
body[ATTR_ADDITIONAL_LOCATIONS] = locations
310+
282311
background = body.pop(ATTR_BACKGROUND)
283312
backup_task, job_id = await self._background_backup_task(
284-
self.sys_backups.do_backup_full, **self._location_to_mount(body)
313+
self.sys_backups.do_backup_full, **body
285314
)
286315

287316
if background and not backup_task.done():
@@ -299,10 +328,22 @@ async def backup_full(self, request: web.Request):
299328
async def backup_partial(self, request: web.Request):
300329
"""Create a partial backup."""
301330
body = await api_validate(SCHEMA_BACKUP_PARTIAL, request)
302-
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
331+
locations: list[LOCATION_TYPE] | None = None
332+
333+
if ATTR_LOCATION in body:
334+
location_names: list[str | None] = body.pop(ATTR_LOCATION)
335+
self._validate_cloud_backup_location(request, location_names)
336+
337+
locations = [
338+
self._location_to_mount(location) for location in location_names
339+
]
340+
body[ATTR_LOCATION] = locations.pop(0)
341+
if locations:
342+
body[ATTR_ADDITIONAL_LOCATIONS] = locations
343+
303344
background = body.pop(ATTR_BACKGROUND)
304345
backup_task, job_id = await self._background_backup_task(
305-
self.sys_backups.do_backup_partial, **self._location_to_mount(body)
346+
self.sys_backups.do_backup_partial, **body
306347
)
307348

308349
if background and not backup_task.done():
@@ -370,9 +411,11 @@ async def remove(self, request: web.Request):
370411
self._validate_cloud_backup_location(request, backup.location)
371412
return self.sys_backups.remove(backup)
372413

414+
@api_process
373415
async def download(self, request: web.Request):
374416
"""Download a backup file."""
375417
backup = self._extract_slug(request)
418+
self._validate_cloud_backup_location(request, backup.location)
376419

377420
_LOGGER.info("Downloading backup %s", backup.slug)
378421
response = web.FileResponse(backup.tarfile)
@@ -385,7 +428,23 @@ async def download(self, request: web.Request):
385428
@api_process
386429
async def upload(self, request: web.Request):
387430
"""Upload a backup file."""
388-
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp_dir:
431+
location: LOCATION_TYPE = None
432+
locations: list[LOCATION_TYPE] | None = None
433+
tmp_path = self.sys_config.path_tmp
434+
if ATTR_LOCATION in request.query:
435+
location_names: list[str] = request.query.getall(ATTR_LOCATION)
436+
self._validate_cloud_backup_location(request, location_names)
437+
# Convert empty string to None if necessary
438+
locations = [
439+
self._location_to_mount(location) if location else None
440+
for location in location_names
441+
]
442+
location = locations.pop(0)
443+
444+
if location and location != LOCATION_CLOUD_BACKUP:
445+
tmp_path = location.local_where
446+
447+
with TemporaryDirectory(dir=tmp_path.as_posix()) as temp_dir:
389448
tar_file = Path(temp_dir, "backup.tar")
390449
reader = await request.multipart()
391450
contents = await reader.next()
@@ -398,15 +457,22 @@ async def upload(self, request: web.Request):
398457
backup.write(chunk)
399458

400459
except OSError as err:
401-
if err.errno == errno.EBADMSG:
460+
if err.errno == errno.EBADMSG and location in {
461+
LOCATION_CLOUD_BACKUP,
462+
None,
463+
}:
402464
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
403465
_LOGGER.error("Can't write new backup file: %s", err)
404466
return False
405467

406468
except asyncio.CancelledError:
407469
return False
408470

409-
backup = await asyncio.shield(self.sys_backups.import_backup(tar_file))
471+
backup = await asyncio.shield(
472+
self.sys_backups.import_backup(
473+
tar_file, location=location, additional_locations=locations
474+
)
475+
)
410476

411477
if backup:
412478
return {ATTR_SLUG: backup.slug}

supervisor/api/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
COOKIE_INGRESS = "ingress_session"
1414

15+
ATTR_ADDITIONAL_LOCATIONS = "additional_locations"
1516
ATTR_AGENT_VERSION = "agent_version"
1617
ATTR_APPARMOR_VERSION = "apparmor_version"
1718
ATTR_ATTRIBUTES = "attributes"

0 commit comments

Comments
 (0)