De-duplicate Backup/Restore logic (#3311)

* De-duplicate Backup/Restore logic

Create internal _do_backup()/_do_restore() method which de-duplicates
some of the backup/restore logic previously part of full/partial backup/restore.

* Add Backup/Restore test coverage
This commit is contained in:
Stefan Agner 2021-12-14 18:21:52 +01:00 committed by GitHub
parent cde45e2e7a
commit eadc629cd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 404 additions and 178 deletions

View File

@ -48,7 +48,7 @@ from ..exceptions import AddonsError
from ..utils.json import write_json_file
from ..utils.tar import SecureTarFile, atomic_contents_add, secure_path
from .utils import key_to_iv, password_for_validating, password_to_key, remove_folder
from .validate import ALL_FOLDERS, SCHEMA_BACKUP
from .validate import SCHEMA_BACKUP
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -311,9 +311,8 @@ class Backup(CoreSysAttributes):
finally:
self._tmp.cleanup()
async def store_addons(self, addon_list: Optional[list[Addon]] = None):
async def store_addons(self, addon_list: list[str]):
"""Add a list of add-ons into backup."""
addon_list: list[Addon] = addon_list or self.sys_addons.installed
async def _addon_save(addon: Addon):
"""Task to store an add-on into backup."""
@ -346,9 +345,8 @@ class Backup(CoreSysAttributes):
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't save Add-on %s: %s", addon.slug, err)
async def restore_addons(self, addon_list: Optional[list[str]] = None):
async def restore_addons(self, addon_list: list[str]):
"""Restore a list add-on from backup."""
addon_list: list[str] = addon_list or self.addon_list
async def _addon_restore(addon_slug: str):
"""Task to restore an add-on into backup."""
@ -375,9 +373,8 @@ class Backup(CoreSysAttributes):
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't restore Add-on %s: %s", slug, err)
async def store_folders(self, folder_list: Optional[list[str]] = None):
async def store_folders(self, folder_list: list[str]):
"""Backup Supervisor data into backup."""
folder_list: set[str] = set(folder_list or ALL_FOLDERS)
def _folder_save(name: str):
"""Take backup of a folder."""
@ -414,9 +411,8 @@ class Backup(CoreSysAttributes):
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't save folder %s: %s", folder, err)
async def restore_folders(self, folder_list: Optional[list[str]] = None):
async def restore_folders(self, folder_list: list[str]):
"""Backup Supervisor data into backup."""
folder_list: set[str] = set(folder_list or self.folders)
def _folder_restore(name: str):
"""Intenal function to restore a folder."""

View File

@ -7,6 +7,9 @@ from typing import Awaitable
from awesomeversion.awesomeversion import AwesomeVersion
from awesomeversion.exceptions import AwesomeVersionCompareException
from supervisor.addons.addon import Addon
from supervisor.backups.validate import ALL_FOLDERS
from ..const import FOLDER_HOMEASSISTANT, CoreState
from ..coresys import CoreSysAttributes
from ..exceptions import AddonsError
@ -126,6 +129,38 @@ class BackupManager(CoreSysAttributes):
self._backups[backup.slug] = backup
return backup
async def _do_backup(
self,
backup: Backup,
addon_list: list[Addon],
folder_list: list[str],
):
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)
# 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)
self.sys_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):
"""Create a full backup."""
@ -134,34 +169,16 @@ class BackupManager(CoreSysAttributes):
return None
backup = self._create_backup(name, BackupType.FULL, password)
_LOGGER.info("Creating new full backup with slug %s", backup.slug)
try:
self.sys_core.state = CoreState.FREEZE
await self.lock.acquire()
async with backup:
# Backup add-ons
_LOGGER.info("Backing up %s store Add-ons", backup.slug)
await backup.store_addons()
# Backup folders
_LOGGER.info("Backing up %s store folders", backup.slug)
await backup.store_folders()
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Backup %s error", backup.slug)
self.sys_capture_exception(err)
return None
else:
_LOGGER.info("Creating full backup with slug %s completed", backup.slug)
self._backups[backup.slug] = backup
async with self.lock:
backup = await self._do_backup(
backup, self.sys_addons.installed, ALL_FOLDERS
)
if backup:
_LOGGER.info("Creating full backup with slug %s completed", backup.slug)
return backup
finally:
self.sys_core.state = CoreState.RUNNING
self.lock.release()
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING])
async def do_backup_partial(
self, name="", addons=None, folders=None, password=None, homeassistant=True
@ -176,47 +193,99 @@ class BackupManager(CoreSysAttributes):
if len(addons) == 0 and len(folders) == 0 and not homeassistant:
_LOGGER.error("Nothing to create backup for")
return
backup = self._create_backup(name, BackupType.PARTIAL, password, homeassistant)
_LOGGER.info("Creating new partial backup with slug %s", backup.slug)
try:
self.sys_core.state = CoreState.FREEZE
await self.lock.acquire()
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)
async with backup:
# Backup add-ons
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)
if addon_list:
_LOGGER.info("Backing up %s store Add-ons", backup.slug)
await backup.store_addons(addon_list)
# Backup folders
if folders:
_LOGGER.info("Backing up %s store folders", backup.slug)
await backup.store_folders(folders)
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Backup %s error", backup.slug)
self.sys_capture_exception(err)
return None
else:
_LOGGER.info("Creating partial backup with slug %s completed", backup.slug)
self._backups[backup.slug] = backup
backup = await self._do_backup(backup, addon_list, folders)
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[Addon],
folder_list: list[str],
homeassistant: bool,
remove_other_addons: bool,
):
try:
# Stop Home Assistant Core if we restore the version or config directory
if FOLDER_HOMEASSISTANT in folder_list or homeassistant:
await self.sys_homeassistant.core.stop()
async with backup:
# Restore docker config
_LOGGER.info("Restoring %s Docker config", backup.slug)
backup.restore_dockerconfig()
if FOLDER_HOMEASSISTANT in folder_list:
backup.restore_homeassistant()
# Process folders
if folder_list:
_LOGGER.info("Restoring %s folders", backup.slug)
await backup.restore_folders(folder_list)
# Process Home-Assistant
task_hass = None
if homeassistant:
_LOGGER.info("Restoring %s Home Assistant Core", backup.slug)
task_hass = self._update_core_task(backup.homeassistant_version)
if addon_list:
_LOGGER.info("Restoring %s Repositories", backup.slug)
await backup.restore_repositories()
_LOGGER.info("Restoring %s Add-ons", backup.slug)
await backup.restore_addons(addon_list)
# Delete delta add-ons
if remove_other_addons:
_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)
# 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)
self.sys_capture_exception(err)
return False
else:
return True
finally:
self.sys_core.state = CoreState.RUNNING
self.lock.release()
# 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=[
@ -227,7 +296,7 @@ class BackupManager(CoreSysAttributes):
JobCondition.RUNNING,
]
)
async def do_restore_full(self, backup, password=None):
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")
@ -242,65 +311,20 @@ class BackupManager(CoreSysAttributes):
return False
_LOGGER.info("Full-Restore %s start", backup.slug)
try:
async with self.lock:
self.sys_core.state = CoreState.FREEZE
await self.lock.acquire()
async with backup:
# Stop Home-Assistant / Add-ons
await self.sys_core.shutdown()
# Stop Home-Assistant / Add-ons
await self.sys_core.shutdown()
# Restore folders
_LOGGER.info("Restoring %s folders", backup.slug)
await backup.restore_folders()
success = await self._do_restore(
backup, backup.addon_list, backup.folders, True, True
)
# Restore docker config
_LOGGER.info("Restoring %s Docker Config", backup.slug)
backup.restore_dockerconfig()
# Start homeassistant restore
_LOGGER.info("Restoring %s Home-Assistant", backup.slug)
backup.restore_homeassistant()
task_hass = self._update_core_task(backup.homeassistant_version)
# Restore repositories
_LOGGER.info("Restoring %s Repositories", backup.slug)
await backup.restore_repositories()
# Delete delta add-ons
_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)
# Restore add-ons
_LOGGER.info("Restore %s old add-ons", backup.slug)
await backup.restore_addons()
# finish homeassistant task
_LOGGER.info("Restore %s wait until homeassistant ready", backup.slug)
await task_hass
await self.sys_homeassistant.core.start()
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Restore %s error", backup.slug)
self.sys_capture_exception(err)
return False
else:
_LOGGER.info("Full-Restore %s done", backup.slug)
return True
finally:
self.sys_core.state = CoreState.RUNNING
self.lock.release()
if success:
_LOGGER.info("Full-Restore %s done", backup.slug)
@Job(
conditions=[
@ -323,68 +347,21 @@ class BackupManager(CoreSysAttributes):
_LOGGER.error("Invalid password for backup %s", backup.slug)
return False
addons = addons or []
folders = folders or []
addon_list = addons or []
folder_list = folders or []
_LOGGER.info("Partial-Restore %s start", backup.slug)
try:
async with self.lock:
self.sys_core.state = CoreState.FREEZE
await self.lock.acquire()
async with backup:
# Restore docker config
_LOGGER.info("Restoring %s Docker Config", backup.slug)
backup.restore_dockerconfig()
success = await self._do_restore(
backup, addon_list, folder_list, homeassistant, False
)
# Stop Home-Assistant for config restore
if FOLDER_HOMEASSISTANT in folders:
await self.sys_homeassistant.core.stop()
backup.restore_homeassistant()
# Process folders
if folders:
_LOGGER.info("Restoring %s folders", backup.slug)
await backup.restore_folders(folders)
# Process Home-Assistant
task_hass = None
if homeassistant:
_LOGGER.info("Restoring %s Home-Assistant", backup.slug)
task_hass = self._update_core_task(backup.homeassistant_version)
if addons:
_LOGGER.info("Restoring %s Repositories", backup.slug)
await backup.restore_repositories()
_LOGGER.info("Restoring %s old add-ons", backup.slug)
await backup.restore_addons(addons)
# Make sure homeassistant run agen
if task_hass:
_LOGGER.info("Restore %s wait for Home-Assistant", backup.slug)
await task_hass
# Do we need start HomeAssistant?
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()
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Restore %s error", backup.slug)
self.sys_capture_exception(err)
return False
else:
_LOGGER.info("Partial-Restore %s done", backup.slug)
return True
finally:
self.sys_core.state = CoreState.RUNNING
self.lock.release()
if success:
_LOGGER.info("Partial-Restore %s done", backup.slug)
def _update_core_task(self, version: AwesomeVersion) -> Awaitable[None]:
"""Process core update if needed and make awaitable object."""

View File

@ -0,0 +1 @@
"""Backup tests."""

46
tests/backups/conftest.py Normal file
View File

@ -0,0 +1,46 @@
"""Mock test."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from supervisor.backups.const import BackupType
from supervisor.backups.validate import ALL_FOLDERS
from tests.const import TEST_ADDON_SLUG
@pytest.fixture(name="backup_mock")
def fixture_backup_mock():
"""Backup class mock."""
with patch("supervisor.backups.manager.Backup") as backup_mock:
backup_instance = MagicMock()
backup_mock.return_value = backup_instance
backup_instance.store_addons = AsyncMock(return_value=None)
backup_instance.store_folders = AsyncMock(return_value=None)
backup_instance.restore_addons = AsyncMock(return_value=None)
backup_instance.restore_folders = AsyncMock(return_value=None)
backup_instance.restore_repositories = AsyncMock(return_value=None)
yield backup_mock
@pytest.fixture
def partial_backup_mock(backup_mock):
"""Partial backup mock."""
backup_instance = backup_mock.return_value
backup_instance.sys_type = BackupType.PARTIAL
backup_instance.folders = []
backup_instance.addon_list = [TEST_ADDON_SLUG]
yield backup_mock
@pytest.fixture
def full_backup_mock(backup_mock):
"""Full backup mock."""
backup_instance = backup_mock.return_value
backup_instance.sys_type = BackupType.FULL
backup_instance.folders = ALL_FOLDERS
backup_instance.addon_list = [TEST_ADDON_SLUG]
yield backup_mock

View File

@ -0,0 +1,205 @@
"""Test BackupManager class."""
from unittest.mock import AsyncMock, MagicMock
from supervisor.backups.const import BackupType
from supervisor.backups.manager import BackupManager
from supervisor.const import FOLDER_HOMEASSISTANT, CoreState
from supervisor.coresys import CoreSys
from tests.const import TEST_ADDON_SLUG
async def test_do_backup_full(coresys: CoreSys, backup_mock, install_addon_ssh):
"""Test creating Backup."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
manager = BackupManager(coresys)
# backup_mock fixture causes Backup() to be a MagicMock
backup_instance: MagicMock = await manager.do_backup_full()
# Check Backup has been created without password
assert backup_instance.new.call_args[0][3] == BackupType.FULL
assert backup_instance.new.call_args[0][4] is None
backup_instance.store_homeassistant.assert_called_once()
backup_instance.store_repositories.assert_called_once()
backup_instance.store_dockerconfig.assert_called_once()
backup_instance.store_addons.assert_called_once()
assert install_addon_ssh in backup_instance.store_addons.call_args[0][0]
backup_instance.store_folders.assert_called_once()
assert len(backup_instance.store_folders.call_args[0][0]) == 5
assert coresys.core.state == CoreState.RUNNING
async def test_do_backup_partial_minimal(
coresys: CoreSys, backup_mock, install_addon_ssh
):
"""Test creating minimal partial Backup."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
manager = BackupManager(coresys)
# backup_mock fixture causes Backup() to be a MagicMock
backup_instance: MagicMock = await manager.do_backup_partial(homeassistant=False)
# Check Backup has been created without password
assert backup_instance.new.call_args[0][3] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][4] is None
backup_instance.store_homeassistant.assert_not_called()
backup_instance.store_repositories.assert_called_once()
backup_instance.store_dockerconfig.assert_called_once()
backup_instance.store_addons.assert_not_called()
backup_instance.store_folders.assert_not_called()
assert coresys.core.state == CoreState.RUNNING
async def test_do_backup_partial_maximal(
coresys: CoreSys, backup_mock, install_addon_ssh
):
"""Test creating maximal partial Backup."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
manager = BackupManager(coresys)
# backup_mock fixture causes Backup() to be a MagicMock
backup_instance: MagicMock = await manager.do_backup_partial(
addons=[TEST_ADDON_SLUG], folders=["/test"], homeassistant=True
)
# Check Backup has been created without password
assert backup_instance.new.call_args[0][3] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][4] is None
backup_instance.store_homeassistant.assert_called_once()
backup_instance.store_repositories.assert_called_once()
backup_instance.store_dockerconfig.assert_called_once()
backup_instance.store_addons.assert_called_once()
assert install_addon_ssh in backup_instance.store_addons.call_args[0][0]
backup_instance.store_folders.assert_called_once()
assert len(backup_instance.store_folders.call_args[0][0]) == 1
assert coresys.core.state == CoreState.RUNNING
async def test_do_restore_full(coresys: CoreSys, full_backup_mock, install_addon_ssh):
"""Test restoring full Backup."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.homeassistant.core.start = AsyncMock(return_value=None)
coresys.homeassistant.core.stop = AsyncMock(return_value=None)
coresys.homeassistant.core.update = AsyncMock(return_value=None)
install_addon_ssh.uninstall = AsyncMock(return_value=None)
manager = BackupManager(coresys)
backup_instance = full_backup_mock.return_value
await manager.do_restore_full(backup_instance)
backup_instance.restore_homeassistant.assert_called_once()
backup_instance.restore_repositories.assert_called_once()
backup_instance.restore_dockerconfig.assert_called_once()
backup_instance.restore_addons.assert_called_once()
install_addon_ssh.uninstall.assert_not_called()
backup_instance.restore_folders.assert_called_once()
assert coresys.core.state == CoreState.RUNNING
async def test_do_restore_full_different_addon(
coresys: CoreSys, full_backup_mock, install_addon_ssh
):
"""Test restoring full Backup with different addons than installed."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.homeassistant.core.start = AsyncMock(return_value=None)
coresys.homeassistant.core.stop = AsyncMock(return_value=None)
coresys.homeassistant.core.update = AsyncMock(return_value=None)
install_addon_ssh.uninstall = AsyncMock(return_value=None)
manager = BackupManager(coresys)
backup_instance = full_backup_mock.return_value
backup_instance.addon_list = ["differentslug"]
await manager.do_restore_full(backup_instance)
backup_instance.restore_homeassistant.assert_called_once()
backup_instance.restore_repositories.assert_called_once()
backup_instance.restore_dockerconfig.assert_called_once()
backup_instance.restore_addons.assert_called_once()
install_addon_ssh.uninstall.assert_called_once()
backup_instance.restore_folders.assert_called_once()
assert coresys.core.state == CoreState.RUNNING
async def test_do_restore_partial_minimal(
coresys: CoreSys, partial_backup_mock, install_addon_ssh
):
"""Test restoring partial Backup minimal."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.homeassistant.core.start = AsyncMock(return_value=None)
coresys.homeassistant.core.stop = AsyncMock(return_value=None)
coresys.homeassistant.core.update = AsyncMock(return_value=None)
manager = BackupManager(coresys)
backup_instance = partial_backup_mock.return_value
await manager.do_restore_partial(backup_instance, homeassistant=False)
backup_instance.restore_homeassistant.assert_not_called()
backup_instance.restore_repositories.assert_not_called()
backup_instance.restore_dockerconfig.assert_called_once()
backup_instance.restore_addons.assert_not_called()
backup_instance.restore_folders.assert_not_called()
assert coresys.core.state == CoreState.RUNNING
async def test_do_restore_partial_maximal(coresys: CoreSys, partial_backup_mock):
"""Test restoring partial Backup minimal."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.homeassistant.core.start = AsyncMock(return_value=None)
coresys.homeassistant.core.stop = AsyncMock(return_value=None)
coresys.homeassistant.core.update = AsyncMock(return_value=None)
manager = BackupManager(coresys)
backup_instance = partial_backup_mock.return_value
await manager.do_restore_partial(
backup_instance,
addons=[TEST_ADDON_SLUG],
folders=[FOLDER_HOMEASSISTANT],
homeassistant=True,
)
backup_instance.restore_homeassistant.assert_called_once()
backup_instance.restore_repositories.assert_called_once()
backup_instance.restore_dockerconfig.assert_called_once()
backup_instance.restore_addons.assert_called_once()
backup_instance.restore_folders.assert_called_once()
assert coresys.core.state == CoreState.RUNNING

View File

@ -288,3 +288,4 @@ def install_addon_ssh(coresys: CoreSys, repository):
coresys.addons.data.install(store)
addon = Addon(coresys, store.slug)
coresys.addons.local[addon.slug] = addon
yield addon