Files
supervisor/supervisor/backups/manager.py
Mike Degatano 14fcda5d78 Sentry only loaded when diagnostics on (#3993)
* Sentry only loaded when diagnostics on

* Logging when sentry is closed
2022-11-13 21:23:52 +01:00

419 lines
14 KiB
Python

"""Backup manager."""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from ..addons.addon import Addon
from ..const import (
ATTR_DAYS_UNTIL_STALE,
FILE_HASSIO_BACKUPS,
FOLDER_HOMEASSISTANT,
CoreState,
)
from ..coresys import CoreSysAttributes
from ..exceptions import AddonsError
from ..jobs.decorator import Job, JobCondition
from ..utils.common import FileConfiguration
from ..utils.dt import utcnow
from ..utils.sentry import capture_exception
from .backup import Backup
from .const import BackupType
from .utils import create_slug
from .validate import ALL_FOLDERS, SCHEMA_BACKUPS_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__)
class BackupManager(FileConfiguration, CoreSysAttributes):
"""Manage backups."""
def __init__(self, coresys):
"""Initialize a backup manager."""
super().__init__(FILE_HASSIO_BACKUPS, SCHEMA_BACKUPS_CONFIG)
self.coresys = coresys
self._backups = {}
self.lock = asyncio.Lock()
@property
def list_backups(self) -> set[Backup]:
"""Return a list of all backup objects."""
return set(self._backups.values())
@property
def days_until_stale(self) -> int:
"""Get days until backup is considered stale."""
return self._data[ATTR_DAYS_UNTIL_STALE]
@days_until_stale.setter
def days_until_stale(self, value: int) -> None:
"""Set days until backup is considered stale."""
self._data[ATTR_DAYS_UNTIL_STALE] = value
def get(self, slug):
"""Return backup object."""
return self._backups.get(slug)
def _create_backup(
self,
name: str,
sys_type: BackupType,
password: str | None,
compressed: bool = True,
) -> Backup:
"""Initialize a new backup object from name."""
date_str = utcnow().isoformat()
slug = create_slug(name, date_str)
tar_file = Path(self.sys_config.path_backup, f"{slug}.tar")
# init object
backup = Backup(self.coresys, tar_file)
backup.new(slug, name, date_str, sys_type, password, compressed)
backup.store_repositories()
backup.store_dockerconfig()
return backup
def load(self):
"""Load exists backups data.
Return a coroutine.
"""
return self.reload()
async def reload(self):
"""Load exists backups."""
self._backups = {}
async def _load_backup(tar_file):
"""Load the backup."""
backup = Backup(self.coresys, tar_file)
if await backup.load():
self._backups[backup.slug] = backup
tasks = [
_load_backup(tar_file)
for tar_file in self.sys_config.path_backup.glob("*.tar")
]
_LOGGER.info("Found %d backup files", len(tasks))
if tasks:
await asyncio.wait(tasks)
def remove(self, backup):
"""Remove a backup."""
try:
backup.tarfile.unlink()
self._backups.pop(backup.slug, None)
_LOGGER.info("Removed backup file %s", backup.slug)
except OSError as err:
_LOGGER.error("Can't remove backup %s: %s", backup.slug, err)
return False
return True
async def import_backup(self, tar_file):
"""Check backup tarfile and import it."""
backup = Backup(self.coresys, tar_file)
# Read meta data
if not await backup.load():
return None
# Already exists?
if backup.slug in self._backups:
_LOGGER.warning("Backup %s already exists! overwriting", backup.slug)
self.remove(self.get(backup.slug))
# Move backup to backup
tar_origin = Path(self.sys_config.path_backup, f"{backup.slug}.tar")
try:
backup.tarfile.rename(tar_origin)
except OSError as err:
_LOGGER.error("Can't move backup file to storage: %s", err)
return None
# Load new backup
backup = Backup(self.coresys, tar_origin)
if not await backup.load():
return None
_LOGGER.info("Successfully imported %s", backup.slug)
self._backups[backup.slug] = backup
return backup
async def _do_backup(
self,
backup: Backup,
addon_list: list[Addon],
folder_list: list[str],
homeassistant: bool,
):
try:
self.sys_core.state = CoreState.FREEZE
async with backup:
# Backup add-ons
if addon_list:
_LOGGER.info("Backing up %s store Add-ons", backup.slug)
await backup.store_addons(addon_list)
# HomeAssistant Folder is for v1
if homeassistant:
await backup.store_homeassistant()
# Backup folders
if folder_list:
_LOGGER.info("Backing up %s store folders", backup.slug)
await backup.store_folders(folder_list)
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Backup %s error", backup.slug)
capture_exception(err)
return None
else:
self._backups[backup.slug] = backup
return backup
finally:
self.sys_core.state = CoreState.RUNNING
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING])
async def do_backup_full(self, name="", password=None, compressed=True):
"""Create a full backup."""
if self.lock.locked():
_LOGGER.error("A backup/restore process is already running")
return None
backup = self._create_backup(name, BackupType.FULL, password, compressed)
_LOGGER.info("Creating new full backup with slug %s", backup.slug)
async with self.lock:
backup = await self._do_backup(
backup, self.sys_addons.installed, ALL_FOLDERS, True
)
if backup:
_LOGGER.info("Creating full backup with slug %s completed", backup.slug)
return backup
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING])
async def do_backup_partial(
self,
name: str = "",
addons: list[str] | None = None,
folders: list[str] | None = None,
password: str | None = None,
homeassistant: bool = False,
compressed: bool = True,
):
"""Create a partial backup."""
if self.lock.locked():
_LOGGER.error("A backup/restore process is already running")
return None
addons = addons or []
folders = folders or []
# HomeAssistant Folder is for v1
if FOLDER_HOMEASSISTANT in folders:
folders.remove(FOLDER_HOMEASSISTANT)
homeassistant = True
if len(addons) == 0 and len(folders) == 0 and not homeassistant:
_LOGGER.error("Nothing to create backup for")
backup = self._create_backup(name, BackupType.PARTIAL, password, compressed)
_LOGGER.info("Creating new partial backup with slug %s", backup.slug)
async with self.lock:
addon_list = []
for addon_slug in addons:
addon = self.sys_addons.get(addon_slug)
if addon and addon.is_installed:
addon_list.append(addon)
continue
_LOGGER.warning("Add-on %s not found/installed", addon_slug)
backup = await self._do_backup(backup, addon_list, folders, homeassistant)
if backup:
_LOGGER.info(
"Creating partial backup with slug %s completed", backup.slug
)
return backup
async def _do_restore(
self,
backup: Backup,
addon_list: list[str],
folder_list: list[str],
homeassistant: bool,
replace: bool,
):
try:
task_hass: asyncio.Task | None = None
async with backup:
# Restore docker config
_LOGGER.info("Restoring %s Docker config", backup.slug)
backup.restore_dockerconfig(replace)
# Process folders
if folder_list:
_LOGGER.info("Restoring %s folders", backup.slug)
await backup.restore_folders(folder_list)
# Process Home-Assistant
if homeassistant:
_LOGGER.info("Restoring %s Home Assistant Core", backup.slug)
task_hass = await backup.restore_homeassistant()
# Delete delta add-ons
if replace:
_LOGGER.info("Removing Add-ons not in the backup %s", backup.slug)
for addon in self.sys_addons.installed:
if addon.slug in backup.addon_list:
continue
# Remove Add-on because it's not a part of the new env
# Do it sequential avoid issue on slow IO
try:
await addon.uninstall()
except AddonsError:
_LOGGER.warning("Can't uninstall Add-on %s", addon.slug)
if addon_list:
_LOGGER.info("Restoring %s Repositories", backup.slug)
await backup.restore_repositories(replace)
_LOGGER.info("Restoring %s Add-ons", backup.slug)
await backup.restore_addons(addon_list)
# Wait for Home Assistant Core update/downgrade
if task_hass:
_LOGGER.info("Restore %s wait for Home-Assistant", backup.slug)
await task_hass
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Restore %s error", backup.slug)
capture_exception(err)
return False
else:
return True
finally:
# Do we need start Home Assistant Core?
if not await self.sys_homeassistant.core.is_running():
await self.sys_homeassistant.core.start()
# Check If we can access to API / otherwise restart
if not await self.sys_homeassistant.api.check_api_state():
_LOGGER.warning("Need restart HomeAssistant for API")
await self.sys_homeassistant.core.restart()
@Job(
conditions=[
JobCondition.FREE_SPACE,
JobCondition.HEALTHY,
JobCondition.INTERNET_HOST,
JobCondition.INTERNET_SYSTEM,
JobCondition.RUNNING,
]
)
async def do_restore_full(self, backup: Backup, password=None):
"""Restore a backup."""
if self.lock.locked():
_LOGGER.error("A backup/restore process is already running")
return False
if backup.sys_type != BackupType.FULL:
_LOGGER.error("%s is only a partial backup!", backup.slug)
return False
if backup.protected and not backup.set_password(password):
_LOGGER.error("Invalid password for backup %s", backup.slug)
return False
if backup.supervisor_version > self.sys_supervisor.version:
_LOGGER.error(
"Backup was made on supervisor version %s, can't restore on %s. Must update supervisor first.",
backup.supervisor_version,
self.sys_supervisor.version,
)
return False
_LOGGER.info("Full-Restore %s start", backup.slug)
async with self.lock:
self.sys_core.state = CoreState.FREEZE
# Stop Home-Assistant / Add-ons
await self.sys_core.shutdown()
success = await self._do_restore(
backup, backup.addon_list, backup.folders, True, True
)
self.sys_core.state = CoreState.RUNNING
if success:
_LOGGER.info("Full-Restore %s done", backup.slug)
@Job(
conditions=[
JobCondition.FREE_SPACE,
JobCondition.HEALTHY,
JobCondition.INTERNET_HOST,
JobCondition.INTERNET_SYSTEM,
JobCondition.RUNNING,
]
)
async def do_restore_partial(
self,
backup: Backup,
homeassistant: bool = False,
addons: list[str] | None = None,
folders: list[Path] | None = None,
password: str | None = None,
):
"""Restore a backup."""
if self.lock.locked():
_LOGGER.error("A backup/restore process is already running")
return False
addon_list = addons or []
folder_list = folders or []
# Version 1
if FOLDER_HOMEASSISTANT in folder_list:
folder_list.remove(FOLDER_HOMEASSISTANT)
homeassistant = True
if backup.protected and not backup.set_password(password):
_LOGGER.error("Invalid password for backup %s", backup.slug)
return False
if backup.homeassistant is None and homeassistant:
_LOGGER.error("No Home Assistant Core data inside the backup")
return False
if backup.supervisor_version > self.sys_supervisor.version:
_LOGGER.error(
"Backup was made on supervisor version %s, can't restore on %s. Must update supervisor first.",
backup.supervisor_version,
self.sys_supervisor.version,
)
return False
_LOGGER.info("Partial-Restore %s start", backup.slug)
async with self.lock:
self.sys_core.state = CoreState.FREEZE
success = await self._do_restore(
backup, addon_list, folder_list, homeassistant, False
)
self.sys_core.state = CoreState.RUNNING
if success:
_LOGGER.info("Partial-Restore %s done", backup.slug)