diff --git a/supervisor/backups/backup.py b/supervisor/backups/backup.py index 05ece7e90..b2cb5686b 100644 --- a/supervisor/backups/backup.py +++ b/supervisor/backups/backup.py @@ -63,6 +63,8 @@ from .const import BUF_SIZE, LOCATION_CLOUD_BACKUP, BackupType from .utils import password_to_key from .validate import SCHEMA_BACKUP +IGNORED_COMPARISON_FIELDS = {ATTR_PROTECTED, ATTR_CRYPTO, ATTR_DOCKER} + _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -265,7 +267,7 @@ class Backup(JobGroup): # Compare all fields except ones about protection. Current encryption status does not affect equality keys = self._data.keys() | other._data.keys() - for k in keys - {ATTR_PROTECTED, ATTR_CRYPTO, ATTR_DOCKER}: + for k in keys - IGNORED_COMPARISON_FIELDS: if ( k not in self._data or k not in other._data @@ -577,13 +579,21 @@ class Backup(JobGroup): @Job(name="backup_addon_save", cleanup=False) async def _addon_save(self, addon: Addon) -> asyncio.Task | None: """Store an add-on into backup.""" - self.sys_jobs.current.reference = addon.slug + self.sys_jobs.current.reference = slug = addon.slug if not self._outer_secure_tarfile: raise RuntimeError( "Cannot backup components without initializing backup tar" ) - tar_name = f"{addon.slug}.tar{'.gz' if self.compressed else ''}" + # Ensure it is still installed and get current data before proceeding + if not (curr_addon := self.sys_addons.get_local_only(slug)): + _LOGGER.warning( + "Skipping backup of add-on %s because it has been uninstalled", + slug, + ) + return None + + tar_name = f"{slug}.tar{'.gz' if self.compressed else ''}" addon_file = self._outer_secure_tarfile.create_inner_tar( f"./{tar_name}", @@ -592,16 +602,16 @@ class Backup(JobGroup): ) # Take backup try: - start_task = await addon.backup(addon_file) + start_task = await curr_addon.backup(addon_file) except AddonsError as err: raise BackupError(str(err)) from err # Store to config self._data[ATTR_ADDONS].append( { - ATTR_SLUG: addon.slug, - ATTR_NAME: addon.name, - ATTR_VERSION: addon.version, + ATTR_SLUG: slug, + ATTR_NAME: curr_addon.name, + ATTR_VERSION: curr_addon.version, # Bug - addon_file.size used to give us this information # It always returns 0 in current securetar. Skipping until fixed ATTR_SIZE: 0, diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py index 7cad66f93..38e032778 100644 --- a/tests/backups/test_manager.py +++ b/tests/backups/test_manager.py @@ -2244,3 +2244,33 @@ async def test_get_upload_path_for_mount_location( result = await manager.get_upload_path_for_location(mount) assert result == mount.local_where + + +@pytest.mark.usefixtures( + "supervisor_internet", "tmp_supervisor_data", "path_extern", "install_addon_example" +) +async def test_backup_addon_skips_uninstalled( + coresys: CoreSys, caplog: pytest.LogCaptureFixture +): + """Test restore installing new addon.""" + await coresys.core.set_state(CoreState.RUNNING) + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + assert "local_example" in coresys.addons.local + orig_store_addons = Backup.store_addons + + async def mock_store_addons(*args, **kwargs): + # Mock an uninstall during the backup process + await coresys.addons.uninstall("local_example") + await orig_store_addons(*args, **kwargs) + + with patch.object(Backup, "store_addons", new=mock_store_addons): + backup: Backup = await coresys.backups.do_backup_partial( + addons=["local_example"], folders=["ssl"] + ) + + assert "local_example" not in coresys.addons.local + assert not backup.addons + assert ( + "Skipping backup of add-on local_example because it has been uninstalled" + in caplog.text + )