"""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)