From eadc629cd950b852647ea4f3b267effdd15d09e3 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 14 Dec 2021 18:21:52 +0100 Subject: [PATCH] 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 --- supervisor/backups/backup.py | 14 +- supervisor/backups/manager.py | 315 ++++++++++++++++------------------ tests/backups/__init__.py | 1 + tests/backups/conftest.py | 46 +++++ tests/backups/test_manager.py | 205 ++++++++++++++++++++++ tests/conftest.py | 1 + 6 files changed, 404 insertions(+), 178 deletions(-) create mode 100644 tests/backups/__init__.py create mode 100644 tests/backups/conftest.py create mode 100644 tests/backups/test_manager.py diff --git a/supervisor/backups/backup.py b/supervisor/backups/backup.py index e56d0b0e8..5e5c9b2f7 100644 --- a/supervisor/backups/backup.py +++ b/supervisor/backups/backup.py @@ -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.""" diff --git a/supervisor/backups/manager.py b/supervisor/backups/manager.py index 15b7a21f8..a7e10f5fb 100644 --- a/supervisor/backups/manager.py +++ b/supervisor/backups/manager.py @@ -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.""" diff --git a/tests/backups/__init__.py b/tests/backups/__init__.py new file mode 100644 index 000000000..a5158e61e --- /dev/null +++ b/tests/backups/__init__.py @@ -0,0 +1 @@ +"""Backup tests.""" diff --git a/tests/backups/conftest.py b/tests/backups/conftest.py new file mode 100644 index 000000000..0c1fce259 --- /dev/null +++ b/tests/backups/conftest.py @@ -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 diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py new file mode 100644 index 000000000..d32d4d635 --- /dev/null +++ b/tests/backups/test_manager.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index a5124996f..41cd9aba8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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