From e1adf73ad595d9b8107aed8954da54688ef6ab9f Mon Sep 17 00:00:00 2001 From: "yunlu.wen" Date: Mon, 9 Mar 2026 15:31:56 +0800 Subject: [PATCH] feat: add enterprise pre uninstall hook for plugin --- api/configs/enterprise/__init__.py | 4 + api/core/plugin/entities/base.py | 1 + .../enterprise/plugin_manager_service.py | 27 ++++++ api/services/plugin/plugin_service.py | 11 +++ .../enterprise/test_plugin_manager_service.py | 84 +++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 api/tests/unit_tests/services/enterprise/test_plugin_manager_service.py diff --git a/api/configs/enterprise/__init__.py b/api/configs/enterprise/__init__.py index 11a71e1537..d56160b7e8 100644 --- a/api/configs/enterprise/__init__.py +++ b/api/configs/enterprise/__init__.py @@ -64,3 +64,7 @@ class EnterpriseTelemetryConfig(BaseSettings): description="Sampling rate for enterprise traces (0.0 to 1.0, default 1.0 = 100%).", default=1.0, ) + + ENTERPRISE_REQUEST_TIMEOUT: int = Field( + ge=1, description="Maximum timeout in seconds for enterprise requests", default=5 + ) \ No newline at end of file diff --git a/api/core/plugin/entities/base.py b/api/core/plugin/entities/base.py index bfec0d4302..d62084f295 100644 --- a/api/core/plugin/entities/base.py +++ b/api/core/plugin/entities/base.py @@ -7,3 +7,4 @@ class BasePluginEntity(BaseModel): id: str created_at: datetime updated_at: datetime + plugin_unique_identifier: str diff --git a/api/services/enterprise/plugin_manager_service.py b/api/services/enterprise/plugin_manager_service.py index 817dbd95f8..77013b097f 100644 --- a/api/services/enterprise/plugin_manager_service.py +++ b/api/services/enterprise/plugin_manager_service.py @@ -3,6 +3,7 @@ import logging from pydantic import BaseModel +from configs import dify_config from services.enterprise.base import EnterprisePluginManagerRequest from services.errors.base import BaseServiceError @@ -28,6 +29,11 @@ class CheckCredentialPolicyComplianceRequest(BaseModel): return data +class PreUninstallPluginRequest(BaseModel): + tenant_id: str + plugin_unique_identifier: str + + class CredentialPolicyViolationError(BaseServiceError): pass @@ -55,3 +61,24 @@ class PluginManagerService: body.dify_credential_id, ret.get("result", False), ) + + @classmethod + def try_pre_uninstall_plugin(cls, body: PreUninstallPluginRequest): + try: + # the invocation must be synchronous. + EnterprisePluginManagerRequest.send_request( # pyright: ignore[reportUnknownMemberType] + "POST", + "/pre-uninstall-plugin", + json=body.model_dump(), # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] + raise_for_status=True, + timeout=dify_config.ENTERPRISE_REQUEST_TIMEOUT, + ) + except Exception as e: + logger.exception( + """ + failed to perform pre uninstall plugin hook. tenant_id: %s, plugin_unique_identifier: %s, + this may cause plugin %s to be automatically garbage collected + """, + body.tenant_id, + body.plugin_unique_identifier, + ) diff --git a/api/services/plugin/plugin_service.py b/api/services/plugin/plugin_service.py index 411c335c17..91dd54bf38 100644 --- a/api/services/plugin/plugin_service.py +++ b/api/services/plugin/plugin_service.py @@ -30,6 +30,10 @@ from extensions.ext_database import db from extensions.ext_redis import redis_client from models.provider import ProviderCredential from models.provider_ids import GenericProviderID +from services.enterprise.plugin_manager_service import ( + PluginManagerService, + PreUninstallPluginRequest, +) from services.errors.plugin import PluginInstallationForbiddenError from services.feature_service import FeatureService, PluginInstallationScope @@ -518,6 +522,13 @@ class PluginService: if plugin: plugin_id = plugin.plugin_id logger.info("Deleting credentials for plugin: %s", plugin_id) + if dify_config.ENTERPRISE_ENABLED: + PluginManagerService.try_pre_uninstall_plugin( + PreUninstallPluginRequest( + tenant_id=tenant_id, + plugin_unique_identifier=plugin.plugin_unique_identifier, + ) + ) # Delete provider credentials that match this plugin credentials = db.session.scalars( diff --git a/api/tests/unit_tests/services/enterprise/test_plugin_manager_service.py b/api/tests/unit_tests/services/enterprise/test_plugin_manager_service.py new file mode 100644 index 0000000000..5a96977299 --- /dev/null +++ b/api/tests/unit_tests/services/enterprise/test_plugin_manager_service.py @@ -0,0 +1,84 @@ +"""Unit tests for PluginManagerService. + +This module covers the pre-uninstall plugin hook behavior: +- Successful API call: no exception raised, correct request sent +- API failure: soft-fail (logs and does not re-raise) +""" + +from unittest.mock import patch + +from httpx import HTTPStatusError + +from services.enterprise.plugin_manager_service import ( + PluginManagerService, + PreUninstallPluginRequest, +) + + +class TestTryPreUninstallPlugin: + def test_try_pre_uninstall_plugin_success(self): + body = PreUninstallPluginRequest( + tenant_id="tenant-123", + plugin_unique_identifier="com.example.my_plugin", + ) + + with patch( + "services.enterprise.plugin_manager_service.EnterprisePluginManagerRequest.send_request" + ) as mock_send_request: + mock_send_request.return_value = {} + + PluginManagerService.try_pre_uninstall_plugin(body) + + mock_send_request.assert_called_once_with( + "POST", + "/pre-uninstall-plugin", + json={"tenant_id": "tenant-123", "plugin_unique_identifier": "com.example.my_plugin"}, + raise_for_status=True, + ) + + def test_try_pre_uninstall_plugin_http_error_soft_fails(self): + body = PreUninstallPluginRequest( + tenant_id="tenant-456", + plugin_unique_identifier="com.example.other_plugin", + ) + + with ( + patch( + "services.enterprise.plugin_manager_service.EnterprisePluginManagerRequest.send_request" + ) as mock_send_request, + patch("services.enterprise.plugin_manager_service.logger") as mock_logger, + ): + mock_send_request.side_effect = HTTPStatusError( + "502 Bad Gateway", + request=None, + response=None, + ) + + PluginManagerService.try_pre_uninstall_plugin(body) + + mock_send_request.assert_called_once_with( + "POST", + "/pre-uninstall-plugin", + json={"tenant_id": "tenant-456", "plugin_unique_identifier": "com.example.other_plugin"}, + raise_for_status=True, + ) + mock_logger.exception.assert_called_once() + + def test_try_pre_uninstall_plugin_generic_exception_soft_fails(self): + body = PreUninstallPluginRequest( + tenant_id="tenant-789", + plugin_unique_identifier="com.example.failing_plugin", + ) + + with ( + patch( + "services.enterprise.plugin_manager_service.EnterprisePluginManagerRequest.send_request" + ) as mock_send_request, + patch("services.enterprise.plugin_manager_service.logger") as mock_logger, + ): + mock_send_request.side_effect = ConnectionError("network unreachable") + + PluginManagerService.try_pre_uninstall_plugin(body) + + mock_send_request.assert_called_once() + mock_logger.exception.assert_called_once()