Skip to content

Commit 5519f6a

Browse files
authored
Add support for cloud backups in Core (#5438)
* Add support for cloud backups in Core * Test cases and small fixes identified * Add test for partial reload no file failure
1 parent a45d507 commit 5519f6a

File tree

28 files changed

+675
-89
lines changed

28 files changed

+675
-89
lines changed

supervisor/addons/model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
ATTR_JOURNALD,
4848
ATTR_KERNEL_MODULES,
4949
ATTR_LEGACY,
50-
ATTR_LOCATON,
50+
ATTR_LOCATION,
5151
ATTR_MACHINE,
5252
ATTR_MAP,
5353
ATTR_NAME,
@@ -581,7 +581,7 @@ def map_volumes(self) -> dict[MappingType, FolderMapping]:
581581
@property
582582
def path_location(self) -> Path:
583583
"""Return path to this add-on."""
584-
return Path(self.data[ATTR_LOCATON])
584+
return Path(self.data[ATTR_LOCATION])
585585

586586
@property
587587
def path_icon(self) -> Path:

supervisor/addons/validate.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
ATTR_KERNEL_MODULES,
5656
ATTR_LABELS,
5757
ATTR_LEGACY,
58-
ATTR_LOCATON,
58+
ATTR_LOCATION,
5959
ATTR_MACHINE,
6060
ATTR_MAP,
6161
ATTR_NAME,
@@ -483,7 +483,7 @@ def _migrate(config: dict[str, Any]):
483483
_migrate_addon_config(),
484484
_SCHEMA_ADDON_CONFIG.extend(
485485
{
486-
vol.Required(ATTR_LOCATON): str,
486+
vol.Required(ATTR_LOCATION): str,
487487
vol.Required(ATTR_REPOSITORY): str,
488488
vol.Required(ATTR_TRANSLATIONS, default=dict): {
489489
str: SCHEMA_ADDON_TRANSLATIONS

supervisor/api/backups.py

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import voluptuous as vol
1515

1616
from ..backups.backup import Backup
17+
from ..backups.const import LOCATION_CLOUD_BACKUP
1718
from ..backups.validate import ALL_FOLDERS, FOLDER_HOMEASSISTANT, days_until_stale
1819
from ..const import (
1920
ATTR_ADDONS,
@@ -22,10 +23,12 @@
2223
ATTR_CONTENT,
2324
ATTR_DATE,
2425
ATTR_DAYS_UNTIL_STALE,
26+
ATTR_FILENAME,
2527
ATTR_FOLDERS,
2628
ATTR_HOMEASSISTANT,
2729
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
28-
ATTR_LOCATON,
30+
ATTR_JOB_ID,
31+
ATTR_LOCATION,
2932
ATTR_NAME,
3033
ATTR_PASSWORD,
3134
ATTR_PROTECTED,
@@ -36,20 +39,22 @@
3639
ATTR_TIMEOUT,
3740
ATTR_TYPE,
3841
ATTR_VERSION,
42+
REQUEST_FROM,
3943
BusEvent,
4044
CoreState,
4145
)
4246
from ..coresys import CoreSysAttributes
43-
from ..exceptions import APIError
47+
from ..exceptions import APIError, APIForbidden
4448
from ..jobs import JobSchedulerOptions
4549
from ..mounts.const import MountUsage
4650
from ..resolution.const import UnhealthyReason
47-
from .const import ATTR_BACKGROUND, ATTR_JOB_ID, CONTENT_TYPE_TAR
51+
from .const import ATTR_BACKGROUND, ATTR_LOCATIONS, CONTENT_TYPE_TAR
4852
from .utils import api_process, api_validate
4953

5054
_LOGGER: logging.Logger = logging.getLogger(__name__)
5155

5256
RE_SLUGIFY_NAME = re.compile(r"[^A-Za-z0-9]+")
57+
RE_BACKUP_FILENAME = re.compile(r"^[^\\\/]+\.tar$")
5358

5459
# Backwards compatible
5560
# Remove: 2022.08
@@ -76,7 +81,7 @@
7681
vol.Optional(ATTR_NAME): str,
7782
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
7883
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
79-
vol.Optional(ATTR_LOCATON): vol.Maybe(str),
84+
vol.Optional(ATTR_LOCATION): vol.Maybe(str),
8085
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): vol.Boolean(),
8186
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
8287
}
@@ -101,6 +106,12 @@
101106
vol.Optional(ATTR_TIMEOUT): vol.All(int, vol.Range(min=1)),
102107
}
103108
)
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+
)
104115

105116

106117
class APIBackups(CoreSysAttributes):
@@ -122,7 +133,8 @@ def _list_backups(self):
122133
ATTR_DATE: backup.date,
123134
ATTR_TYPE: backup.sys_type,
124135
ATTR_SIZE: backup.size,
125-
ATTR_LOCATON: backup.location,
136+
ATTR_LOCATION: backup.location,
137+
ATTR_LOCATIONS: backup.locations,
126138
ATTR_PROTECTED: backup.protected,
127139
ATTR_COMPRESSED: backup.compressed,
128140
ATTR_CONTENT: {
@@ -132,6 +144,7 @@ def _list_backups(self):
132144
},
133145
}
134146
for backup in self.sys_backups.list_backups
147+
if backup.location != LOCATION_CLOUD_BACKUP
135148
]
136149

137150
@api_process
@@ -164,10 +177,13 @@ async def options(self, request):
164177
self.sys_backups.save_data()
165178

166179
@api_process
167-
async def reload(self, _):
180+
async def reload(self, request: web.Request):
168181
"""Reload backup list."""
169-
await asyncio.shield(self.sys_backups.reload())
170-
return True
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))
171187

172188
@api_process
173189
async def backup_info(self, request):
@@ -195,7 +211,8 @@ async def backup_info(self, request):
195211
ATTR_PROTECTED: backup.protected,
196212
ATTR_SUPERVISOR_VERSION: backup.supervisor_version,
197213
ATTR_HOMEASSISTANT: backup.homeassistant_version,
198-
ATTR_LOCATON: backup.location,
214+
ATTR_LOCATION: backup.location,
215+
ATTR_LOCATIONS: backup.locations,
199216
ATTR_ADDONS: data_addons,
200217
ATTR_REPOSITORIES: backup.repositories,
201218
ATTR_FOLDERS: backup.folders,
@@ -204,17 +221,29 @@ async def backup_info(self, request):
204221

205222
def _location_to_mount(self, body: dict[str, Any]) -> dict[str, Any]:
206223
"""Change location field to mount if necessary."""
207-
if not body.get(ATTR_LOCATON):
224+
if not body.get(ATTR_LOCATION) or body[ATTR_LOCATION] == LOCATION_CLOUD_BACKUP:
208225
return body
209226

210-
body[ATTR_LOCATON] = self.sys_mounts.get(body[ATTR_LOCATON])
211-
if body[ATTR_LOCATON].usage != MountUsage.BACKUP:
227+
body[ATTR_LOCATION] = self.sys_mounts.get(body[ATTR_LOCATION])
228+
if body[ATTR_LOCATION].usage != MountUsage.BACKUP:
212229
raise APIError(
213-
f"Mount {body[ATTR_LOCATON].name} is not used for backups, cannot backup to there"
230+
f"Mount {body[ATTR_LOCATION].name} is not used for backups, cannot backup to there"
214231
)
215232

216233
return body
217234

235+
def _validate_cloud_backup_location(
236+
self, request: web.Request, location: str | None
237+
) -> None:
238+
"""Cloud backup location is only available to Home Assistant."""
239+
if (
240+
location == LOCATION_CLOUD_BACKUP
241+
and request.get(REQUEST_FROM) != self.sys_homeassistant
242+
):
243+
raise APIForbidden(
244+
f"Location {LOCATION_CLOUD_BACKUP} is only available for Home Assistant"
245+
)
246+
218247
async def _background_backup_task(
219248
self, backup_method: Callable, *args, **kwargs
220249
) -> tuple[asyncio.Task, str]:
@@ -246,9 +275,10 @@ async def release_on_freeze(new_state: CoreState):
246275
self.sys_bus.remove_listener(listener)
247276

248277
@api_process
249-
async def backup_full(self, request):
278+
async def backup_full(self, request: web.Request):
250279
"""Create full backup."""
251280
body = await api_validate(SCHEMA_BACKUP_FULL, request)
281+
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
252282
background = body.pop(ATTR_BACKGROUND)
253283
backup_task, job_id = await self._background_backup_task(
254284
self.sys_backups.do_backup_full, **self._location_to_mount(body)
@@ -266,9 +296,10 @@ async def backup_full(self, request):
266296
)
267297

268298
@api_process
269-
async def backup_partial(self, request):
299+
async def backup_partial(self, request: web.Request):
270300
"""Create a partial backup."""
271301
body = await api_validate(SCHEMA_BACKUP_PARTIAL, request)
302+
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
272303
background = body.pop(ATTR_BACKGROUND)
273304
backup_task, job_id = await self._background_backup_task(
274305
self.sys_backups.do_backup_partial, **self._location_to_mount(body)
@@ -286,9 +317,10 @@ async def backup_partial(self, request):
286317
)
287318

288319
@api_process
289-
async def restore_full(self, request):
320+
async def restore_full(self, request: web.Request):
290321
"""Full restore of a backup."""
291322
backup = self._extract_slug(request)
323+
self._validate_cloud_backup_location(request, backup.location)
292324
body = await api_validate(SCHEMA_RESTORE_FULL, request)
293325
background = body.pop(ATTR_BACKGROUND)
294326
restore_task, job_id = await self._background_backup_task(
@@ -303,9 +335,10 @@ async def restore_full(self, request):
303335
)
304336

305337
@api_process
306-
async def restore_partial(self, request):
338+
async def restore_partial(self, request: web.Request):
307339
"""Partial restore a backup."""
308340
backup = self._extract_slug(request)
341+
self._validate_cloud_backup_location(request, backup.location)
309342
body = await api_validate(SCHEMA_RESTORE_PARTIAL, request)
310343
background = body.pop(ATTR_BACKGROUND)
311344
restore_task, job_id = await self._background_backup_task(
@@ -320,23 +353,24 @@ async def restore_partial(self, request):
320353
)
321354

322355
@api_process
323-
async def freeze(self, request):
356+
async def freeze(self, request: web.Request):
324357
"""Initiate manual freeze for external backup."""
325358
body = await api_validate(SCHEMA_FREEZE, request)
326359
await asyncio.shield(self.sys_backups.freeze_all(**body))
327360

328361
@api_process
329-
async def thaw(self, request):
362+
async def thaw(self, request: web.Request):
330363
"""Begin thaw after manual freeze."""
331364
await self.sys_backups.thaw_all()
332365

333366
@api_process
334-
async def remove(self, request):
367+
async def remove(self, request: web.Request):
335368
"""Remove a backup."""
336369
backup = self._extract_slug(request)
370+
self._validate_cloud_backup_location(request, backup.location)
337371
return self.sys_backups.remove(backup)
338372

339-
async def download(self, request):
373+
async def download(self, request: web.Request):
340374
"""Download a backup file."""
341375
backup = self._extract_slug(request)
342376

@@ -349,7 +383,7 @@ async def download(self, request):
349383
return response
350384

351385
@api_process
352-
async def upload(self, request):
386+
async def upload(self, request: web.Request):
353387
"""Upload a backup file."""
354388
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp_dir:
355389
tar_file = Path(temp_dir, "backup.tar")

supervisor/api/const.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@
4242
ATTR_IDENTIFIERS = "identifiers"
4343
ATTR_IS_ACTIVE = "is_active"
4444
ATTR_IS_OWNER = "is_owner"
45-
ATTR_JOB_ID = "job_id"
4645
ATTR_JOBS = "jobs"
4746
ATTR_LLMNR = "llmnr"
4847
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
4948
ATTR_LOCAL_ONLY = "local_only"
49+
ATTR_LOCATIONS = "locations"
5050
ATTR_MDNS = "mdns"
5151
ATTR_MODEL = "model"
5252
ATTR_MOUNTS = "mounts"
@@ -68,6 +68,7 @@
6868
ATTR_USAGE = "usage"
6969
ATTR_USE_NTP = "use_ntp"
7070
ATTR_USERS = "users"
71+
ATTR_USER_PATH = "user_path"
7172
ATTR_VENDOR = "vendor"
7273
ATTR_VIRTUALIZATION = "virtualization"
7374

supervisor/api/mounts.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from ..mounts.const import ATTR_DEFAULT_BACKUP_MOUNT, MountUsage
1212
from ..mounts.mount import Mount
1313
from ..mounts.validate import SCHEMA_MOUNT_CONFIG
14-
from .const import ATTR_MOUNTS
14+
from .const import ATTR_MOUNTS, ATTR_USER_PATH
1515
from .utils import api_process, api_validate
1616

1717
SCHEMA_OPTIONS = vol.Schema(
@@ -32,7 +32,11 @@ async def info(self, request: web.Request) -> dict[str, Any]:
3232
if self.sys_mounts.default_backup_mount
3333
else None,
3434
ATTR_MOUNTS: [
35-
mount.to_dict() | {ATTR_STATE: mount.state}
35+
mount.to_dict()
36+
| {
37+
ATTR_STATE: mount.state,
38+
ATTR_USER_PATH: mount.container_where.as_posix(),
39+
}
3640
for mount in self.sys_mounts.mounts
3741
],
3842
}

0 commit comments

Comments
 (0)