From 986b92aee439695937c7457e9a4fa116fd80fb50 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 21 Aug 2024 05:14:57 -0400 Subject: [PATCH] Keep shared images on addon uninstall (#5259) * Keep shared images on addon uninstall * Add missing step for mocking new addon in store --- supervisor/addons/addon.py | 6 ++++-- supervisor/addons/manager.py | 10 ++++++++- supervisor/docker/interface.py | 10 +++++---- tests/addons/test_manager.py | 37 ++++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index dc9b92ae1..8ac72e565 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -763,10 +763,12 @@ class Addon(AddonModel): limit=JobExecutionLimit.GROUP_ONCE, on_condition=AddonsJobError, ) - async def uninstall(self, *, remove_config: bool) -> None: + async def uninstall( + self, *, remove_config: bool, remove_image: bool = True + ) -> None: """Uninstall and cleanup this addon.""" try: - await self.instance.remove() + await self.instance.remove(remove_image=remove_image) except DockerError as err: raise AddonsError() from err diff --git a/supervisor/addons/manager.py b/supervisor/addons/manager.py index 6e228ddfa..1f2554149 100644 --- a/supervisor/addons/manager.py +++ b/supervisor/addons/manager.py @@ -185,7 +185,15 @@ class AddonManager(CoreSysAttributes): _LOGGER.warning("Add-on %s is not installed", slug) return - await self.local[slug].uninstall(remove_config=remove_config) + shared_image = any( + self.local[slug].image == addon.image + and self.local[slug].version == addon.version + for addon in self.installed + if addon.slug != slug + ) + await self.local[slug].uninstall( + remove_config=remove_config, remove_image=not shared_image + ) _LOGGER.info("Add-on '%s' successfully removed", slug) diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 409ef207e..b400c6a97 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -429,15 +429,17 @@ class DockerInterface(JobGroup): limit=JobExecutionLimit.GROUP_ONCE, on_condition=DockerJobError, ) - async def remove(self) -> None: + async def remove(self, *, remove_image: bool = True) -> None: """Remove Docker images.""" # Cleanup container with suppress(DockerError): await self.stop() - await self.sys_run_in_executor( - self.sys_docker.remove_image, self.image, self.version - ) + if remove_image: + await self.sys_run_in_executor( + self.sys_docker.remove_image, self.image, self.version + ) + self._meta = None @Job( diff --git a/tests/addons/test_manager.py b/tests/addons/test_manager.py index 2c2fe45a6..32cc0fb74 100644 --- a/tests/addons/test_manager.py +++ b/tests/addons/test_manager.py @@ -1,6 +1,7 @@ """Test addon manager.""" import asyncio +from copy import deepcopy from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch @@ -24,6 +25,7 @@ from supervisor.exceptions import ( DockerNotFound, ) from supervisor.plugins.dns import PluginDns +from supervisor.store.addon import AddonStore from supervisor.store.repository import Repository from supervisor.utils import check_exception_chain from supervisor.utils.common import write_json_file @@ -454,3 +456,38 @@ async def test_watchdog_runs_during_update( await asyncio.sleep(0) start.assert_called_once() restart.assert_not_called() + + +async def test_shared_image_kept_on_uninstall( + coresys: CoreSys, install_addon_example: Addon +): + """Test if two addons share an image it is not removed on uninstall.""" + # Clone example to a new mock copy so two share an image + store_data = deepcopy(coresys.addons.store["local_example"].data) + store = AddonStore(coresys, "local_example2", store_data) + coresys.addons.store["local_example2"] = store + coresys.addons.data.install(store) + # pylint: disable-next=protected-access + coresys.addons.data._data = coresys.addons.data._schema(coresys.addons.data._data) + + example_2 = Addon(coresys, store.slug) + coresys.addons.local[example_2.slug] = example_2 + + image = f"{install_addon_example.image}:{install_addon_example.version}" + latest = f"{install_addon_example.image}:latest" + + await coresys.addons.uninstall("local_example2") + coresys.docker.images.remove.assert_not_called() + assert not coresys.addons.get("local_example2", local_only=True) + + await coresys.addons.uninstall("local_example") + assert coresys.docker.images.remove.call_count == 2 + assert coresys.docker.images.remove.call_args_list[0].kwargs == { + "image": latest, + "force": True, + } + assert coresys.docker.images.remove.call_args_list[1].kwargs == { + "image": image, + "force": True, + } + assert not coresys.addons.get("local_example", local_only=True)