11"""Backups RESTful API."""
22
3+ from __future__ import annotations
4+
35import asyncio
46from collections .abc import Callable
57import errno
1416import voluptuous as vol
1517
1618from ..backups .backup import Backup
17- from ..backups .const import LOCATION_CLOUD_BACKUP
19+ from ..backups .const import LOCATION_CLOUD_BACKUP , LOCATION_TYPE
1820from ..backups .validate import ALL_FOLDERS , FOLDER_HOMEASSISTANT , days_until_stale
1921from ..const import (
2022 ATTR_ADDONS ,
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 ,
4850from ..jobs import JobSchedulerOptions
4951from ..mounts .const import MountUsage
5052from ..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+ )
5259from .utils import api_process , api_validate
5360
5461_LOGGER : logging .Logger = logging .getLogger (__name__ )
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
6479SCHEMA_RESTORE_FULL = vol .Schema (
6580 {
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
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
117129class 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 }
0 commit comments