Skip to content

Commit fcf72c1

Browse files
committed
Add support for cloud backups in Core
1 parent a45d507 commit fcf72c1

File tree

21 files changed

+350
-82
lines changed

21 files changed

+350
-82
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: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,22 @@
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,
21+
ATTR_BACKUP,
2022
ATTR_BACKUPS,
2123
ATTR_COMPRESSED,
2224
ATTR_CONTENT,
2325
ATTR_DATE,
2426
ATTR_DAYS_UNTIL_STALE,
27+
ATTR_FILENAME,
2528
ATTR_FOLDERS,
2629
ATTR_HOMEASSISTANT,
2730
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
28-
ATTR_LOCATON,
31+
ATTR_JOB_ID,
32+
ATTR_LOCATION,
2933
ATTR_NAME,
3034
ATTR_PASSWORD,
3135
ATTR_PROTECTED,
@@ -36,20 +40,22 @@
3640
ATTR_TIMEOUT,
3741
ATTR_TYPE,
3842
ATTR_VERSION,
43+
REQUEST_FROM,
3944
BusEvent,
4045
CoreState,
4146
)
4247
from ..coresys import CoreSysAttributes
43-
from ..exceptions import APIError
48+
from ..exceptions import APIError, APIForbidden
4449
from ..jobs import JobSchedulerOptions
4550
from ..mounts.const import MountUsage
4651
from ..resolution.const import UnhealthyReason
47-
from .const import ATTR_BACKGROUND, ATTR_JOB_ID, CONTENT_TYPE_TAR
52+
from .const import ATTR_BACKGROUND, ATTR_LOCATIONS, CONTENT_TYPE_TAR
4853
from .utils import api_process, api_validate
4954

5055
_LOGGER: logging.Logger = logging.getLogger(__name__)
5156

5257
RE_SLUGIFY_NAME = re.compile(r"[^A-Za-z0-9]+")
58+
RE_FILENAME = re.compile(r"^[^\\\/]+")
5359

5460
# Backwards compatible
5561
# Remove: 2022.08
@@ -76,7 +82,7 @@
7682
vol.Optional(ATTR_NAME): str,
7783
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
7884
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
79-
vol.Optional(ATTR_LOCATON): vol.Maybe(str),
85+
vol.Optional(ATTR_LOCATION): vol.Maybe(str),
8086
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): vol.Boolean(),
8187
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
8288
}
@@ -101,6 +107,16 @@
101107
vol.Optional(ATTR_TIMEOUT): vol.All(int, vol.Range(min=1)),
102108
}
103109
)
110+
SCHEMA_RELOAD = vol.Schema(
111+
{
112+
vol.Optional(ATTR_BACKUP, default={}): vol.Schema(
113+
{
114+
vol.Required(ATTR_LOCATION): vol.Maybe(str),
115+
vol.Required(ATTR_FILENAME): vol.Match(RE_FILENAME),
116+
}
117+
)
118+
}
119+
)
104120

105121

106122
class APIBackups(CoreSysAttributes):
@@ -122,7 +138,8 @@ def _list_backups(self):
122138
ATTR_DATE: backup.date,
123139
ATTR_TYPE: backup.sys_type,
124140
ATTR_SIZE: backup.size,
125-
ATTR_LOCATON: backup.location,
141+
ATTR_LOCATION: backup.location,
142+
ATTR_LOCATIONS: backup.locations,
126143
ATTR_PROTECTED: backup.protected,
127144
ATTR_COMPRESSED: backup.compressed,
128145
ATTR_CONTENT: {
@@ -132,6 +149,7 @@ def _list_backups(self):
132149
},
133150
}
134151
for backup in self.sys_backups.list_backups
152+
if backup.location != LOCATION_CLOUD_BACKUP
135153
]
136154

137155
@api_process
@@ -164,10 +182,12 @@ async def options(self, request):
164182
self.sys_backups.save_data()
165183

166184
@api_process
167-
async def reload(self, _):
185+
async def reload(self, request: web.Request):
168186
"""Reload backup list."""
169-
await asyncio.shield(self.sys_backups.reload())
170-
return True
187+
body = await api_validate(SCHEMA_RELOAD, request)
188+
backup = self._location_to_mount(body[ATTR_BACKUP])
189+
190+
return await asyncio.shield(self.sys_backups.reload(**backup))
171191

172192
@api_process
173193
async def backup_info(self, request):
@@ -195,7 +215,8 @@ async def backup_info(self, request):
195215
ATTR_PROTECTED: backup.protected,
196216
ATTR_SUPERVISOR_VERSION: backup.supervisor_version,
197217
ATTR_HOMEASSISTANT: backup.homeassistant_version,
198-
ATTR_LOCATON: backup.location,
218+
ATTR_LOCATION: backup.location,
219+
ATTR_LOCATIONS: backup.locations,
199220
ATTR_ADDONS: data_addons,
200221
ATTR_REPOSITORIES: backup.repositories,
201222
ATTR_FOLDERS: backup.folders,
@@ -204,17 +225,29 @@ async def backup_info(self, request):
204225

205226
def _location_to_mount(self, body: dict[str, Any]) -> dict[str, Any]:
206227
"""Change location field to mount if necessary."""
207-
if not body.get(ATTR_LOCATON):
228+
if not body.get(ATTR_LOCATION) or body[ATTR_LOCATION] == LOCATION_CLOUD_BACKUP:
208229
return body
209230

210-
body[ATTR_LOCATON] = self.sys_mounts.get(body[ATTR_LOCATON])
211-
if body[ATTR_LOCATON].usage != MountUsage.BACKUP:
231+
body[ATTR_LOCATION] = self.sys_mounts.get(body[ATTR_LOCATION])
232+
if body[ATTR_LOCATION].usage != MountUsage.BACKUP:
212233
raise APIError(
213-
f"Mount {body[ATTR_LOCATON].name} is not used for backups, cannot backup to there"
234+
f"Mount {body[ATTR_LOCATION].name} is not used for backups, cannot backup to there"
214235
)
215236

216237
return body
217238

239+
def _validate_cloud_backup_location(
240+
self, request: web.Request, location: str | None
241+
) -> None:
242+
"""Cloud backup location is only available to Home Assistant."""
243+
if (
244+
location == LOCATION_CLOUD_BACKUP
245+
and request.get(REQUEST_FROM) != self.sys_homeassistant
246+
):
247+
raise APIForbidden(
248+
f"Location {LOCATION_CLOUD_BACKUP} is only available for Home Assistant"
249+
)
250+
218251
async def _background_backup_task(
219252
self, backup_method: Callable, *args, **kwargs
220253
) -> tuple[asyncio.Task, str]:
@@ -246,9 +279,10 @@ async def release_on_freeze(new_state: CoreState):
246279
self.sys_bus.remove_listener(listener)
247280

248281
@api_process
249-
async def backup_full(self, request):
282+
async def backup_full(self, request: web.Request):
250283
"""Create full backup."""
251284
body = await api_validate(SCHEMA_BACKUP_FULL, request)
285+
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
252286
background = body.pop(ATTR_BACKGROUND)
253287
backup_task, job_id = await self._background_backup_task(
254288
self.sys_backups.do_backup_full, **self._location_to_mount(body)
@@ -266,9 +300,10 @@ async def backup_full(self, request):
266300
)
267301

268302
@api_process
269-
async def backup_partial(self, request):
303+
async def backup_partial(self, request: web.Request):
270304
"""Create a partial backup."""
271305
body = await api_validate(SCHEMA_BACKUP_PARTIAL, request)
306+
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
272307
background = body.pop(ATTR_BACKGROUND)
273308
backup_task, job_id = await self._background_backup_task(
274309
self.sys_backups.do_backup_partial, **self._location_to_mount(body)
@@ -286,9 +321,10 @@ async def backup_partial(self, request):
286321
)
287322

288323
@api_process
289-
async def restore_full(self, request):
324+
async def restore_full(self, request: web.Request):
290325
"""Full restore of a backup."""
291326
backup = self._extract_slug(request)
327+
self._validate_cloud_backup_location(request, backup.location)
292328
body = await api_validate(SCHEMA_RESTORE_FULL, request)
293329
background = body.pop(ATTR_BACKGROUND)
294330
restore_task, job_id = await self._background_backup_task(
@@ -303,9 +339,10 @@ async def restore_full(self, request):
303339
)
304340

305341
@api_process
306-
async def restore_partial(self, request):
342+
async def restore_partial(self, request: web.Request):
307343
"""Partial restore a backup."""
308344
backup = self._extract_slug(request)
345+
self._validate_cloud_backup_location(request, backup.location)
309346
body = await api_validate(SCHEMA_RESTORE_PARTIAL, request)
310347
background = body.pop(ATTR_BACKGROUND)
311348
restore_task, job_id = await self._background_backup_task(
@@ -320,23 +357,24 @@ async def restore_partial(self, request):
320357
)
321358

322359
@api_process
323-
async def freeze(self, request):
360+
async def freeze(self, request: web.Request):
324361
"""Initiate manual freeze for external backup."""
325362
body = await api_validate(SCHEMA_FREEZE, request)
326363
await asyncio.shield(self.sys_backups.freeze_all(**body))
327364

328365
@api_process
329-
async def thaw(self, request):
366+
async def thaw(self, request: web.Request):
330367
"""Begin thaw after manual freeze."""
331368
await self.sys_backups.thaw_all()
332369

333370
@api_process
334-
async def remove(self, request):
371+
async def remove(self, request: web.Request):
335372
"""Remove a backup."""
336373
backup = self._extract_slug(request)
374+
self._validate_cloud_backup_location(request, backup.location)
337375
return self.sys_backups.remove(backup)
338376

339-
async def download(self, request):
377+
async def download(self, request: web.Request):
340378
"""Download a backup file."""
341379
backup = self._extract_slug(request)
342380

@@ -349,7 +387,7 @@ async def download(self, request):
349387
return response
350388

351389
@api_process
352-
async def upload(self, request):
390+
async def upload(self, request: web.Request):
353391
"""Upload a backup file."""
354392
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp_dir:
355393
tar_file = Path(temp_dir, "backup.tar")

supervisor/api/const.py

Lines changed: 1 addition & 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"

supervisor/backups/backup.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
import io
1111
import json
1212
import logging
13-
from pathlib import Path
13+
from pathlib import Path, PurePath
1414
import tarfile
1515
from tempfile import TemporaryDirectory
1616
import time
17-
from typing import Any
17+
from typing import Any, Literal
1818

1919
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
2020
from cryptography.hazmat.backends import default_backend
@@ -48,14 +48,15 @@
4848
CRYPTO_AES128,
4949
)
5050
from ..coresys import CoreSys
51+
from ..docker.const import PATH_BACKUP, PATH_CLOUD_BACKUP
5152
from ..exceptions import AddonsError, BackupError, BackupInvalidError
5253
from ..jobs.const import JOB_GROUP_BACKUP
5354
from ..jobs.decorator import Job
5455
from ..jobs.job_group import JobGroup
5556
from ..utils import remove_folder
5657
from ..utils.dt import parse_datetime, utcnow
5758
from ..utils.json import json_bytes
58-
from .const import BUF_SIZE, BackupType
59+
from .const import BUF_SIZE, LOCATION_CLOUD_BACKUP, BackupType
5960
from .utils import key_to_iv, password_to_key
6061
from .validate import SCHEMA_BACKUP
6162

@@ -70,6 +71,7 @@ def __init__(
7071
coresys: CoreSys,
7172
tar_file: Path,
7273
slug: str,
74+
location: str | None,
7375
data: dict[str, Any] | None = None,
7476
):
7577
"""Initialize a backup."""
@@ -83,6 +85,8 @@ def __init__(
8385
self._outer_secure_tarfile_tarfile: tarfile.TarFile | None = None
8486
self._key: bytes | None = None
8587
self._aes: Cipher | None = None
88+
# Order is maintained in dict keys so this is effectively an ordered set
89+
self._locations: dict[str | None, Literal[None]] = {location: None}
8690

8791
@property
8892
def version(self) -> int:
@@ -178,12 +182,41 @@ def docker(self, value: dict[str, Any]) -> None:
178182
"""Set the Docker config data."""
179183
self._data[ATTR_DOCKER] = value
180184

181-
@cached_property
185+
@property
182186
def location(self) -> str | None:
183187
"""Return the location of the backup."""
184-
for backup_mount in self.sys_mounts.backup_mounts:
185-
if self.tarfile.is_relative_to(backup_mount.local_where):
186-
return backup_mount.name
188+
return self.locations[0]
189+
190+
@property
191+
def all_locations(self) -> set[str | None]:
192+
"""Return all locations this backup was found in."""
193+
return self._locations.keys()
194+
195+
@property
196+
def locations(self) -> list[str | None]:
197+
"""Return locations this backup was found in except cloud backup (unless that's the only one)."""
198+
if len(self._locations) == 1:
199+
return list(self._locations)
200+
return [
201+
location
202+
for location in self._locations
203+
if location != LOCATION_CLOUD_BACKUP
204+
]
205+
206+
@cached_property
207+
def container_path(self) -> PurePath | None:
208+
"""Return where this is made available in managed containers (core, addons, etc.).
209+
210+
This returns none if the tarfile is not in a place mapped into other containers.
211+
"""
212+
path_map: dict[Path, PurePath] = {
213+
self.sys_config.path_backup: PATH_BACKUP,
214+
self.sys_config.path_core_backup: PATH_CLOUD_BACKUP,
215+
}
216+
for source, target in path_map.items():
217+
if self.tarfile.is_relative_to(source):
218+
return target / self.tarfile.relative_to(source)
219+
187220
return None
188221

189222
@property
@@ -215,6 +248,10 @@ def data(self) -> dict[str, Any]:
215248
"""Returns a copy of the data."""
216249
return deepcopy(self._data)
217250

251+
def add_location(self, location: str | None) -> None:
252+
"""Add a location the backup exists."""
253+
self._locations[location] = None
254+
218255
def new(
219256
self,
220257
name: str,

0 commit comments

Comments
 (0)