diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 206606782..d29275f13 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -687,7 +687,7 @@ class Addon(AddonModel): limit=JobExecutionLimit.GROUP_ONCE, on_condition=AddonsJobError, ) - async def uninstall(self) -> None: + async def uninstall(self, *, remove_config: bool) -> None: """Uninstall and cleanup this addon.""" try: await self.instance.remove() @@ -698,6 +698,10 @@ class Addon(AddonModel): await self.unload() + # Remove config if present and requested + if self.addon_config_used and remove_config: + await remove_data(self.path_config) + # Cleanup audio settings if self.path_pulse.exists(): with suppress(OSError): diff --git a/supervisor/addons/manager.py b/supervisor/addons/manager.py index baa395aad..b15a9cc81 100644 --- a/supervisor/addons/manager.py +++ b/supervisor/addons/manager.py @@ -173,13 +173,13 @@ class AddonManager(CoreSysAttributes): _LOGGER.info("Add-on '%s' successfully installed", slug) - async def uninstall(self, slug: str) -> None: + async def uninstall(self, slug: str, *, remove_config: bool = False) -> None: """Remove an add-on.""" if slug not in self.local: _LOGGER.warning("Add-on %s is not installed", slug) return - await self.local[slug].uninstall() + await self.local[slug].uninstall(remove_config=remove_config) _LOGGER.info("Add-on '%s' successfully removed", slug) diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index 075ab8928..388f97d25 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -106,7 +106,7 @@ from ..exceptions import ( PwnedSecret, ) from ..validate import docker_ports -from .const import ATTR_SIGNED, CONTENT_TYPE_BINARY +from .const import ATTR_REMOVE_CONFIG, ATTR_SIGNED, CONTENT_TYPE_BINARY from .utils import api_process, api_process_raw, api_validate, json_loads _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -126,9 +126,13 @@ SCHEMA_OPTIONS = vol.Schema( } ) -# pylint: disable=no-value-for-parameter SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()}) +SCHEMA_UNINSTALL = vol.Schema( + {vol.Optional(ATTR_REMOVE_CONFIG, default=False): vol.Boolean()} +) +# pylint: enable=no-value-for-parameter + class APIAddons(CoreSysAttributes): """Handle RESTful API for add-on functions.""" @@ -385,10 +389,15 @@ class APIAddons(CoreSysAttributes): } @api_process - def uninstall(self, request: web.Request) -> Awaitable[None]: + async def uninstall(self, request: web.Request) -> Awaitable[None]: """Uninstall add-on.""" addon = self._extract_addon(request) - return asyncio.shield(self.sys_addons.uninstall(addon.slug)) + body: dict[str, Any] = await api_validate(SCHEMA_UNINSTALL, request) + return await asyncio.shield( + self.sys_addons.uninstall( + addon.slug, remove_config=body[ATTR_REMOVE_CONFIG] + ) + ) @api_process async def start(self, request: web.Request) -> None: diff --git a/supervisor/api/const.py b/supervisor/api/const.py index 73e65f54a..e958448af 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -46,6 +46,7 @@ ATTR_MOUNTS = "mounts" ATTR_MOUNT_POINTS = "mount_points" ATTR_PANEL_PATH = "panel_path" ATTR_REMOVABLE = "removable" +ATTR_REMOVE_CONFIG = "remove_config" ATTR_REVISION = "revision" ATTR_SEAT = "seat" ATTR_SIGNED = "signed" diff --git a/tests/api/test_addons.py b/tests/api/test_addons.py index f391a5545..c73a839a6 100644 --- a/tests/api/test_addons.py +++ b/tests/api/test_addons.py @@ -220,3 +220,45 @@ async def test_api_addon_rebuild_healthcheck( assert state_changes == [AddonState.STOPPED, AddonState.STARTUP] assert install_addon_ssh.state == AddonState.STARTED assert resp.status == 200 + + +async def test_api_addon_uninstall( + api_client: TestClient, + coresys: CoreSys, + install_addon_example: Addon, + tmp_supervisor_data, + path_extern, +): + """Test uninstall.""" + install_addon_example.data["map"].append( + {"type": "addon_config", "read_only": False} + ) + install_addon_example.path_config.mkdir() + (test_file := install_addon_example.path_config / "test.txt").touch() + + resp = await api_client.post("/addons/local_example/uninstall") + assert resp.status == 200 + assert not coresys.addons.get("local_example", local_only=True) + assert test_file.exists() + + +async def test_api_addon_uninstall_remove_config( + api_client: TestClient, + coresys: CoreSys, + install_addon_example: Addon, + tmp_supervisor_data, + path_extern, +): + """Test uninstall and remove config.""" + install_addon_example.data["map"].append( + {"type": "addon_config", "read_only": False} + ) + (test_folder := install_addon_example.path_config).mkdir() + (install_addon_example.path_config / "test.txt").touch() + + resp = await api_client.post( + "/addons/local_example/uninstall", json={"remove_config": True} + ) + assert resp.status == 200 + assert not coresys.addons.get("local_example", local_only=True) + assert not test_folder.exists()