mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-11-08 18:39:33 +00:00
419 lines
14 KiB
Python
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)
|