Add plugin delete management endpoint

This commit is contained in:
LTbinglingfeng
2026-06-13 20:52:07 +08:00
parent d6c4fc2d82
commit c5cfdb15e5
4 changed files with 183 additions and 0 deletions

View File

@@ -2,8 +2,10 @@ package management
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"sort"
"strconv"
"strings"
@@ -297,6 +299,87 @@ func (h *Handler) PatchPluginConfig(c *gin.Context) {
h.persistLocked(c)
}
// DeletePlugin removes the selected local plugin file and its saved config.
func (h *Handler) DeletePlugin(c *gin.Context) {
id, okID := pluginIDFromRequest(c)
if !okID {
return
}
if h == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"})
return
}
h.mu.Lock()
if h.cfg == nil {
h.mu.Unlock()
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"})
return
}
pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir)
_, configured := h.cfg.Plugins.Configs[id]
host := h.pluginHost
h.mu.Unlock()
path, errPath := pluginFilePath(pluginsDir, id)
if errPath != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errPath.Error()})
return
}
if path == "" && !configured {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"})
return
}
if pluginLoaded(host, id) && (host == nil || !host.UnloadPlugin(id)) && pluginLoaded(host, id) {
c.JSON(http.StatusConflict, gin.H{
"error": "plugin_delete_requires_restart",
"message": "loaded plugin cannot be deleted while the server is running",
"restart_required": true,
})
return
}
fileDeleted := false
if path != "" {
if errRemove := os.Remove(path); errRemove != nil {
if !errors.Is(errRemove, os.ErrNotExist) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_delete_failed", "message": errRemove.Error()})
return
}
} else {
fileDeleted = true
}
}
h.mu.Lock()
delete(h.cfg.Plugins.Configs, id)
if configured {
if errSave := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); errSave != nil {
h.mu.Unlock()
c.JSON(http.StatusInternalServerError, gin.H{
"error": "config_save_failed",
"message": fmt.Sprintf("plugin deleted but saving config failed: %s", errSave.Error()),
"file_deleted": fileDeleted,
"path": path,
})
return
}
}
reloadCfg := h.cfg
h.mu.Unlock()
h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg)
c.JSON(http.StatusOK, gin.H{
"status": "deleted",
"id": htmlsanitize.String(id),
"path": htmlsanitize.String(path),
"file_deleted": fileDeleted,
"configured_removed": configured,
"restart_required": false,
})
}
func normalizedPluginsDir(dir string) string {
dir = strings.TrimSpace(dir)
if dir == "" {
@@ -337,6 +420,19 @@ func pluginDiscovered(pluginsDir string, id string) (bool, error) {
return false, nil
}
func pluginFilePath(pluginsDir string, id string) (string, error) {
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir)
if errDiscover != nil {
return "", errDiscover
}
for _, file := range files {
if file.ID == id {
return file.Path, nil
}
}
return "", nil
}
func pluginConfigFields(fields []pluginapi.ConfigField) []pluginConfigFieldInfo {
out := make([]pluginConfigFieldInfo, 0, len(fields))
for _, field := range fields {

View File

@@ -2,6 +2,7 @@ package management
import (
"bytes"
"context"
"encoding/json"
"html"
"net/http"
@@ -325,6 +326,80 @@ func TestPatchPluginConfigMergesAndDeletesFields(t *testing.T) {
}
}
func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
pluginsDir := writeManagementPluginFile(t, "sample")
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Dir: pluginsDir,
Configs: map[string]config.PluginInstanceConfig{
"sample": pluginConfigFromYAML(t, "enabled: true\nmode: safe\n"),
},
},
},
configFilePath: writeTestConfigFile(t),
}
reloads := 0
h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) {
reloads++
if cfg != h.cfg {
t.Fatalf("reload config = %p, want handler config %p", cfg, h.cfg)
}
})
path, errPath := pluginFilePath(pluginsDir, "sample")
if errPath != nil {
t.Fatalf("pluginFilePath() error = %v", errPath)
}
if path == "" {
t.Fatal("plugin path is empty")
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "sample"}}
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/sample", nil)
h.DeletePlugin(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if _, ok := h.cfg.Plugins.Configs["sample"]; ok {
t.Fatal("plugin config still exists after delete")
}
if _, errStat := os.Stat(path); !os.IsNotExist(errStat) {
t.Fatalf("plugin file stat error = %v, want not exist", errStat)
}
if reloads != 1 {
t.Fatalf("reloads = %d, want 1", reloads)
}
}
func TestDeletePluginReturnsNotFoundForUnknownPlugin(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "missing"}}
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/missing", nil)
h.DeletePlugin(c)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
}
}
func TestPluginDisplayFieldsEscapeHTML(t *testing.T) {
t.Parallel()

View File

@@ -627,6 +627,7 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/plugins", s.mgmt.ListPlugins)
mgmt.GET("/plugin-store", s.mgmt.ListPluginStore)
mgmt.POST("/plugin-store/:id/install", s.mgmt.InstallPluginFromStore)
mgmt.DELETE("/plugins/:id", s.mgmt.DeletePlugin)
mgmt.PATCH("/plugins/:id/enabled", s.mgmt.PatchPluginEnabled)
mgmt.GET("/plugins/:id/config", s.mgmt.GetPluginConfig)
mgmt.PUT("/plugins/:id/config", s.mgmt.PutPluginConfig)

View File

@@ -216,6 +216,9 @@ func TestManagementPluginsRouteRegistered(t *testing.T) {
server.cfg.Plugins.Configs = map[string]proxyconfig.PluginInstanceConfig{
"sample": {Enabled: &enabled, Priority: 4},
}
if errWrite := os.WriteFile(server.configFilePath, []byte("{}\n"), 0o600); errWrite != nil {
t.Fatalf("failed to write config file: %v", errWrite)
}
req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins", nil)
req.Header.Set("Authorization", "Bearer test-management-key")
@@ -254,6 +257,14 @@ func TestManagementPluginsRouteRegistered(t *testing.T) {
if !configPayload.Enabled || configPayload.Priority != 4 {
t.Fatalf("plugin config = %#v, want enabled true priority 4", configPayload)
}
req = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/sample", nil)
req.Header.Set("Authorization", "Bearer test-management-key")
rr = httptest.NewRecorder()
server.engine.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("delete status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
}
}
func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) {