Skip to content

Commit

Permalink
Add support for cloud backups in Core (#5438)
Browse files Browse the repository at this point in the history
* Add support for cloud backups in Core

* Test cases and small fixes identified

* Add test for partial reload no file failure
  • Loading branch information
mdegat01 authored Nov 21, 2024
1 parent a45d507 commit 5519f6a
Show file tree
Hide file tree
Showing 28 changed files with 675 additions and 89 deletions.
4 changes: 2 additions & 2 deletions supervisor/addons/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
ATTR_JOURNALD,
ATTR_KERNEL_MODULES,
ATTR_LEGACY,
ATTR_LOCATON,
ATTR_LOCATION,
ATTR_MACHINE,
ATTR_MAP,
ATTR_NAME,
Expand Down Expand Up @@ -581,7 +581,7 @@ def map_volumes(self) -> dict[MappingType, FolderMapping]:
@property
def path_location(self) -> Path:
"""Return path to this add-on."""
return Path(self.data[ATTR_LOCATON])
return Path(self.data[ATTR_LOCATION])

@property
def path_icon(self) -> Path:
Expand Down
4 changes: 2 additions & 2 deletions supervisor/addons/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
ATTR_KERNEL_MODULES,
ATTR_LABELS,
ATTR_LEGACY,
ATTR_LOCATON,
ATTR_LOCATION,
ATTR_MACHINE,
ATTR_MAP,
ATTR_NAME,
Expand Down Expand Up @@ -483,7 +483,7 @@ def _migrate(config: dict[str, Any]):
_migrate_addon_config(),
_SCHEMA_ADDON_CONFIG.extend(
{
vol.Required(ATTR_LOCATON): str,
vol.Required(ATTR_LOCATION): str,
vol.Required(ATTR_REPOSITORY): str,
vol.Required(ATTR_TRANSLATIONS, default=dict): {
str: SCHEMA_ADDON_TRANSLATIONS
Expand Down
78 changes: 56 additions & 22 deletions supervisor/api/backups.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import voluptuous as vol

from ..backups.backup import Backup
from ..backups.const import LOCATION_CLOUD_BACKUP
from ..backups.validate import ALL_FOLDERS, FOLDER_HOMEASSISTANT, days_until_stale
from ..const import (
ATTR_ADDONS,
Expand All @@ -22,10 +23,12 @@
ATTR_CONTENT,
ATTR_DATE,
ATTR_DAYS_UNTIL_STALE,
ATTR_FILENAME,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_LOCATON,
ATTR_JOB_ID,
ATTR_LOCATION,
ATTR_NAME,
ATTR_PASSWORD,
ATTR_PROTECTED,
Expand All @@ -36,20 +39,22 @@
ATTR_TIMEOUT,
ATTR_TYPE,
ATTR_VERSION,
REQUEST_FROM,
BusEvent,
CoreState,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from ..exceptions import APIError, APIForbidden
from ..jobs import JobSchedulerOptions
from ..mounts.const import MountUsage
from ..resolution.const import UnhealthyReason
from .const import ATTR_BACKGROUND, ATTR_JOB_ID, CONTENT_TYPE_TAR
from .const import ATTR_BACKGROUND, ATTR_LOCATIONS, CONTENT_TYPE_TAR
from .utils import api_process, api_validate

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

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

# Backwards compatible
# Remove: 2022.08
Expand All @@ -76,7 +81,7 @@
vol.Optional(ATTR_NAME): str,
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
vol.Optional(ATTR_LOCATON): vol.Maybe(str),
vol.Optional(ATTR_LOCATION): vol.Maybe(str),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): vol.Boolean(),
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
}
Expand All @@ -101,6 +106,12 @@
vol.Optional(ATTR_TIMEOUT): vol.All(int, vol.Range(min=1)),
}
)
SCHEMA_RELOAD = vol.Schema(
{
vol.Inclusive(ATTR_LOCATION, "file"): vol.Maybe(str),
vol.Inclusive(ATTR_FILENAME, "file"): vol.Match(RE_BACKUP_FILENAME),
}
)


class APIBackups(CoreSysAttributes):
Expand All @@ -122,7 +133,8 @@ def _list_backups(self):
ATTR_DATE: backup.date,
ATTR_TYPE: backup.sys_type,
ATTR_SIZE: backup.size,
ATTR_LOCATON: backup.location,
ATTR_LOCATION: backup.location,
ATTR_LOCATIONS: backup.locations,
ATTR_PROTECTED: backup.protected,
ATTR_COMPRESSED: backup.compressed,
ATTR_CONTENT: {
Expand All @@ -132,6 +144,7 @@ def _list_backups(self):
},
}
for backup in self.sys_backups.list_backups
if backup.location != LOCATION_CLOUD_BACKUP
]

@api_process
Expand Down Expand Up @@ -164,10 +177,13 @@ async def options(self, request):
self.sys_backups.save_data()

@api_process
async def reload(self, _):
async def reload(self, request: web.Request):
"""Reload backup list."""
await asyncio.shield(self.sys_backups.reload())
return True
body = await api_validate(SCHEMA_RELOAD, request)
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
backup = self._location_to_mount(body)

return await asyncio.shield(self.sys_backups.reload(**backup))

@api_process
async def backup_info(self, request):
Expand Down Expand Up @@ -195,7 +211,8 @@ async def backup_info(self, request):
ATTR_PROTECTED: backup.protected,
ATTR_SUPERVISOR_VERSION: backup.supervisor_version,
ATTR_HOMEASSISTANT: backup.homeassistant_version,
ATTR_LOCATON: backup.location,
ATTR_LOCATION: backup.location,
ATTR_LOCATIONS: backup.locations,
ATTR_ADDONS: data_addons,
ATTR_REPOSITORIES: backup.repositories,
ATTR_FOLDERS: backup.folders,
Expand All @@ -204,17 +221,29 @@ async def backup_info(self, request):

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

body[ATTR_LOCATON] = self.sys_mounts.get(body[ATTR_LOCATON])
if body[ATTR_LOCATON].usage != MountUsage.BACKUP:
body[ATTR_LOCATION] = self.sys_mounts.get(body[ATTR_LOCATION])
if body[ATTR_LOCATION].usage != MountUsage.BACKUP:
raise APIError(
f"Mount {body[ATTR_LOCATON].name} is not used for backups, cannot backup to there"
f"Mount {body[ATTR_LOCATION].name} is not used for backups, cannot backup to there"
)

return body

def _validate_cloud_backup_location(
self, request: web.Request, location: str | None
) -> None:
"""Cloud backup location is only available to Home Assistant."""
if (
location == LOCATION_CLOUD_BACKUP
and request.get(REQUEST_FROM) != self.sys_homeassistant
):
raise APIForbidden(
f"Location {LOCATION_CLOUD_BACKUP} is only available for Home Assistant"
)

async def _background_backup_task(
self, backup_method: Callable, *args, **kwargs
) -> tuple[asyncio.Task, str]:
Expand Down Expand Up @@ -246,9 +275,10 @@ async def release_on_freeze(new_state: CoreState):
self.sys_bus.remove_listener(listener)

@api_process
async def backup_full(self, request):
async def backup_full(self, request: web.Request):
"""Create full backup."""
body = await api_validate(SCHEMA_BACKUP_FULL, request)
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
background = body.pop(ATTR_BACKGROUND)
backup_task, job_id = await self._background_backup_task(
self.sys_backups.do_backup_full, **self._location_to_mount(body)
Expand All @@ -266,9 +296,10 @@ async def backup_full(self, request):
)

@api_process
async def backup_partial(self, request):
async def backup_partial(self, request: web.Request):
"""Create a partial backup."""
body = await api_validate(SCHEMA_BACKUP_PARTIAL, request)
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
background = body.pop(ATTR_BACKGROUND)
backup_task, job_id = await self._background_backup_task(
self.sys_backups.do_backup_partial, **self._location_to_mount(body)
Expand All @@ -286,9 +317,10 @@ async def backup_partial(self, request):
)

@api_process
async def restore_full(self, request):
async def restore_full(self, request: web.Request):
"""Full restore of a backup."""
backup = self._extract_slug(request)
self._validate_cloud_backup_location(request, backup.location)
body = await api_validate(SCHEMA_RESTORE_FULL, request)
background = body.pop(ATTR_BACKGROUND)
restore_task, job_id = await self._background_backup_task(
Expand All @@ -303,9 +335,10 @@ async def restore_full(self, request):
)

@api_process
async def restore_partial(self, request):
async def restore_partial(self, request: web.Request):
"""Partial restore a backup."""
backup = self._extract_slug(request)
self._validate_cloud_backup_location(request, backup.location)
body = await api_validate(SCHEMA_RESTORE_PARTIAL, request)
background = body.pop(ATTR_BACKGROUND)
restore_task, job_id = await self._background_backup_task(
Expand All @@ -320,23 +353,24 @@ async def restore_partial(self, request):
)

@api_process
async def freeze(self, request):
async def freeze(self, request: web.Request):
"""Initiate manual freeze for external backup."""
body = await api_validate(SCHEMA_FREEZE, request)
await asyncio.shield(self.sys_backups.freeze_all(**body))

@api_process
async def thaw(self, request):
async def thaw(self, request: web.Request):
"""Begin thaw after manual freeze."""
await self.sys_backups.thaw_all()

@api_process
async def remove(self, request):
async def remove(self, request: web.Request):
"""Remove a backup."""
backup = self._extract_slug(request)
self._validate_cloud_backup_location(request, backup.location)
return self.sys_backups.remove(backup)

async def download(self, request):
async def download(self, request: web.Request):
"""Download a backup file."""
backup = self._extract_slug(request)

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

@api_process
async def upload(self, request):
async def upload(self, request: web.Request):
"""Upload a backup file."""
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp_dir:
tar_file = Path(temp_dir, "backup.tar")
Expand Down
3 changes: 2 additions & 1 deletion supervisor/api/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@
ATTR_IDENTIFIERS = "identifiers"
ATTR_IS_ACTIVE = "is_active"
ATTR_IS_OWNER = "is_owner"
ATTR_JOB_ID = "job_id"
ATTR_JOBS = "jobs"
ATTR_LLMNR = "llmnr"
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
ATTR_LOCAL_ONLY = "local_only"
ATTR_LOCATIONS = "locations"
ATTR_MDNS = "mdns"
ATTR_MODEL = "model"
ATTR_MOUNTS = "mounts"
Expand All @@ -68,6 +68,7 @@
ATTR_USAGE = "usage"
ATTR_USE_NTP = "use_ntp"
ATTR_USERS = "users"
ATTR_USER_PATH = "user_path"
ATTR_VENDOR = "vendor"
ATTR_VIRTUALIZATION = "virtualization"

Expand Down
8 changes: 6 additions & 2 deletions supervisor/api/mounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from ..mounts.const import ATTR_DEFAULT_BACKUP_MOUNT, MountUsage
from ..mounts.mount import Mount
from ..mounts.validate import SCHEMA_MOUNT_CONFIG
from .const import ATTR_MOUNTS
from .const import ATTR_MOUNTS, ATTR_USER_PATH
from .utils import api_process, api_validate

SCHEMA_OPTIONS = vol.Schema(
Expand All @@ -32,7 +32,11 @@ async def info(self, request: web.Request) -> dict[str, Any]:
if self.sys_mounts.default_backup_mount
else None,
ATTR_MOUNTS: [
mount.to_dict() | {ATTR_STATE: mount.state}
mount.to_dict()
| {
ATTR_STATE: mount.state,
ATTR_USER_PATH: mount.container_where.as_posix(),
}
for mount in self.sys_mounts.mounts
],
}
Expand Down
Loading

0 comments on commit 5519f6a

Please sign in to comment.