mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-10 02:16:49 +08:00
feat(pluginstore, pluginhost): add plugin unload handling and preserve config during plugin updates
- Introduced logic to handle plugin unloading during updates to prevent conflicts with loaded plugins. - Preserved existing plugin configurations during updates, ensuring seamless transitions and maintaining custom fields. - Added support for reloading the configuration after management saves changes. - Enhanced unit tests to validate unloading, configuration preservation, and reloading behaviors.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -50,6 +51,7 @@ type Handler struct {
|
||||
postAuthHook coreauth.PostAuthHook
|
||||
postAuthPersistHook coreauth.PostAuthHook
|
||||
pluginHost *pluginhost.Host
|
||||
configReloadHook func(context.Context, *config.Config)
|
||||
pluginStoreRegistryURL string
|
||||
pluginStoreHTTPClient pluginstore.HTTPDoer
|
||||
}
|
||||
@@ -137,6 +139,33 @@ func (h *Handler) SetPluginHost(host *pluginhost.Host) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetConfigReloadHook updates the callback used after management saves config changes.
|
||||
func (h *Handler) SetConfigReloadHook(hook func(context.Context, *config.Config)) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.configReloadHook = hook
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) reloadConfigAfterManagementSave(ctx context.Context, cfg *config.Config) {
|
||||
if h == nil || cfg == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
hook := h.configReloadHook
|
||||
host := h.pluginHost
|
||||
h.mu.Unlock()
|
||||
if hook != nil {
|
||||
hook(ctx, cfg)
|
||||
return
|
||||
}
|
||||
if host != nil {
|
||||
host.ApplyConfig(ctx, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
|
||||
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginstore"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type pluginStoreListResponse struct {
|
||||
@@ -131,17 +132,41 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
}
|
||||
|
||||
pluginIsLoaded := func() bool { return pluginLoaded(host, id) }
|
||||
unloadedBeforeWrite := false
|
||||
result, errInstall := client.Install(c.Request.Context(), plugin, pluginstore.InstallOptions{
|
||||
PluginsDir: pluginsDir,
|
||||
GOOS: goos,
|
||||
GOARCH: goarch,
|
||||
PluginLoaded: pluginIsLoaded,
|
||||
BeforeWrite: func() error {
|
||||
if !pluginIsLoaded() {
|
||||
return nil
|
||||
}
|
||||
if host == nil {
|
||||
return pluginstore.ErrLoadedPluginLocked
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"plugin_id": id,
|
||||
"version": plugin.Version,
|
||||
}).Info("pluginstore: unloading loaded plugin before install")
|
||||
if !host.UnloadPlugin(id) && pluginIsLoaded() {
|
||||
return pluginstore.ErrLoadedPluginLocked
|
||||
}
|
||||
unloadedBeforeWrite = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if errInstall != nil {
|
||||
if unloadedBeforeWrite {
|
||||
h.mu.Lock()
|
||||
reloadCfg := h.cfg
|
||||
h.mu.Unlock()
|
||||
h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg)
|
||||
}
|
||||
if errors.Is(errInstall, pluginstore.ErrLoadedPluginLocked) {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "plugin_update_requires_restart",
|
||||
"message": "loaded Windows plugins cannot be overwritten while the server is running",
|
||||
"message": "loaded plugin cannot be overwritten while the server is running",
|
||||
"restart_required": true,
|
||||
})
|
||||
return
|
||||
@@ -149,13 +174,11 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_install_failed", "message": errInstall.Error()})
|
||||
return
|
||||
}
|
||||
// Sample after the install so the response reflects the library state at
|
||||
// the time the new file landed on disk.
|
||||
restartRequired := pluginIsLoaded()
|
||||
restartRequired := false
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cfg == nil {
|
||||
h.mu.Unlock()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "config_unavailable",
|
||||
"message": fmt.Sprintf("plugin file installed at %s but config is unavailable to enable it", result.Path),
|
||||
@@ -164,6 +187,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
return
|
||||
}
|
||||
if errEnable := h.enablePluginConfigLocked(id); errEnable != nil {
|
||||
h.mu.Unlock()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "config_update_failed",
|
||||
"message": fmt.Sprintf("plugin file installed at %s but enabling it in config failed: %s", result.Path, errEnable.Error()),
|
||||
@@ -172,6 +196,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
return
|
||||
}
|
||||
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 file installed at %s but saving config failed: %s", result.Path, errSave.Error()),
|
||||
@@ -179,6 +204,16 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
})
|
||||
return
|
||||
}
|
||||
reloadCfg := h.cfg
|
||||
h.mu.Unlock()
|
||||
|
||||
h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg)
|
||||
log.WithFields(log.Fields{
|
||||
"plugin_id": result.ID,
|
||||
"version": result.Version,
|
||||
"path": result.Path,
|
||||
"overwritten": result.Overwritten,
|
||||
}).Info("pluginstore: plugin installed")
|
||||
|
||||
c.JSON(http.StatusOK, pluginInstallResponse{
|
||||
Status: "installed",
|
||||
|
||||
@@ -3,6 +3,7 @@ package management
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -156,6 +157,84 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
pluginsDir := t.TempDir()
|
||||
existingPath := filepath.Join(pluginsDir, "sample-provider"+managementPluginExtension(runtime.GOOS))
|
||||
if errWrite := os.WriteFile(existingPath, []byte("old-library-data"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", existingPath, errWrite)
|
||||
}
|
||||
archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "new-library-data")
|
||||
archiveName := "sample-provider_0.1.0_" + runtime.GOOS + "_" + runtime.GOARCH + ".zip"
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: pluginsDir,
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample-provider": pluginConfigFromYAML(t, "enabled: false\npriority: 5\nmode: fast\nextra: keep\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreRegistryURL: "https://registry.example/registry.json",
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
"https://registry.example/registry.json": registryJSON(t),
|
||||
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.1.0": []byte(`{
|
||||
"tag_name": "v0.1.0",
|
||||
"assets": [
|
||||
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
|
||||
{"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"}
|
||||
]
|
||||
}`),
|
||||
"https://downloads.example/" + archiveName: archiveData,
|
||||
"https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"),
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install", nil)
|
||||
|
||||
h.InstallPluginFromStore(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if reloads != 1 {
|
||||
t.Fatalf("reloads = %d, want 1", reloads)
|
||||
}
|
||||
data, errRead := os.ReadFile(existingPath)
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", existingPath, errRead)
|
||||
}
|
||||
if string(data) != "new-library-data" {
|
||||
t.Fatalf("installed file = %q, want new-library-data", data)
|
||||
}
|
||||
item := h.cfg.Plugins.Configs["sample-provider"]
|
||||
if item.Enabled == nil || !*item.Enabled {
|
||||
t.Fatalf("plugin enabled = %#v, want true", item.Enabled)
|
||||
}
|
||||
if item.Priority != 5 {
|
||||
t.Fatalf("plugin priority = %d, want 5", item.Priority)
|
||||
}
|
||||
raw := marshalPluginRaw(t, item)
|
||||
if !strings.Contains(raw, "mode: fast") || !strings.Contains(raw, "extra: keep") {
|
||||
t.Fatalf("plugin raw config lost custom fields:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnablePluginConfigLockedPreservesExistingFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ type serverOptionConfig struct {
|
||||
postAuthHook auth.PostAuthHook
|
||||
postAuthPersistHook auth.PostAuthHook
|
||||
pluginHost *pluginhost.Host
|
||||
configReloadHook func(context.Context, *config.Config)
|
||||
}
|
||||
|
||||
// ServerOption customises HTTP server construction.
|
||||
@@ -154,6 +155,13 @@ func WithPluginHost(host *pluginhost.Host) ServerOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfigReloadHook registers a callback used after management saves config changes.
|
||||
func WithConfigReloadHook(hook func(context.Context, *config.Config)) ServerOption {
|
||||
return func(cfg *serverOptionConfig) {
|
||||
cfg.configReloadHook = hook
|
||||
}
|
||||
}
|
||||
|
||||
// Server represents the main API server.
|
||||
// It encapsulates the Gin engine, HTTP server, handlers, and configuration.
|
||||
type Server struct {
|
||||
@@ -316,6 +324,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
// Initialize management handler
|
||||
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
|
||||
s.mgmt.SetPluginHost(optionState.pluginHost)
|
||||
s.mgmt.SetConfigReloadHook(optionState.configReloadHook)
|
||||
if optionState.localPassword != "" {
|
||||
s.mgmt.SetLocalPassword(optionState.localPassword)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ type modelExecutor interface {
|
||||
ExecuteModelStream(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage)
|
||||
}
|
||||
|
||||
type pluginUnloadTarget struct {
|
||||
id string
|
||||
path string
|
||||
client pluginClient
|
||||
}
|
||||
|
||||
type Host struct {
|
||||
mu sync.Mutex
|
||||
loader pluginLoader
|
||||
@@ -180,6 +186,10 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
lp = loaded
|
||||
h.loaded[file.ID] = lp
|
||||
log.WithFields(log.Fields{
|
||||
"plugin_id": file.ID,
|
||||
"path": file.Path,
|
||||
}).Info("pluginhost: plugin loaded")
|
||||
}
|
||||
|
||||
plugin, okCall := h.callRegisterLocked(ctx, lp, item)
|
||||
@@ -213,19 +223,60 @@ func (h *Host) loadLocked(file pluginFile) (*loadedPlugin, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UnloadPlugin removes one plugin from the active runtime and closes its dynamic library.
|
||||
func (h *Host) UnloadPlugin(id string) bool {
|
||||
if h == nil {
|
||||
return false
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var target pluginUnloadTarget
|
||||
h.mu.Lock()
|
||||
lp := h.loaded[id]
|
||||
if lp == nil {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
target = pluginUnloadTarget{id: lp.id, path: lp.path, client: lp.client}
|
||||
delete(h.loaded, id)
|
||||
delete(h.fused, id)
|
||||
records, enabled := h.snapshotWithoutPluginLocked(id)
|
||||
h.removePluginRuntimeStateLocked(id)
|
||||
h.snapshot.Store(&Snapshot{enabled: enabled, records: records})
|
||||
h.mu.Unlock()
|
||||
|
||||
h.refreshThinkingProviders(records)
|
||||
h.RegisterFrontendAuthProviders()
|
||||
if target.client != nil {
|
||||
target.client.Shutdown()
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"plugin_id": target.id,
|
||||
"path": target.path,
|
||||
}).Info("pluginhost: plugin unloaded")
|
||||
return true
|
||||
}
|
||||
|
||||
// ShutdownAll removes active plugin capabilities and closes all loaded dynamic libraries.
|
||||
func (h *Host) ShutdownAll() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
||||
clients := make([]pluginClient, 0)
|
||||
targets := make([]pluginUnloadTarget, 0)
|
||||
h.mu.Lock()
|
||||
for _, lp := range h.loaded {
|
||||
if lp == nil || lp.client == nil {
|
||||
continue
|
||||
}
|
||||
clients = append(clients, lp.client)
|
||||
targets = append(targets, pluginUnloadTarget{
|
||||
id: lp.id,
|
||||
path: lp.path,
|
||||
client: lp.client,
|
||||
})
|
||||
}
|
||||
h.loaded = make(map[string]*loadedPlugin)
|
||||
h.modelClientIDs = make(map[string]struct{})
|
||||
@@ -243,11 +294,55 @@ func (h *Host) ShutdownAll() {
|
||||
|
||||
h.refreshThinkingProviders(nil)
|
||||
h.RegisterFrontendAuthProviders()
|
||||
for _, client := range clients {
|
||||
client.Shutdown()
|
||||
for _, target := range targets {
|
||||
target.client.Shutdown()
|
||||
log.WithFields(log.Fields{
|
||||
"plugin_id": target.id,
|
||||
"path": target.path,
|
||||
}).Info("pluginhost: plugin unloaded")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Host) snapshotWithoutPluginLocked(id string) ([]capabilityRecord, bool) {
|
||||
raw := h.snapshot.Load()
|
||||
snap, _ := raw.(*Snapshot)
|
||||
if snap == nil || len(snap.records) == 0 {
|
||||
return nil, snap != nil && snap.enabled
|
||||
}
|
||||
records := make([]capabilityRecord, 0, len(snap.records))
|
||||
for _, record := range snap.records {
|
||||
if record.id == id {
|
||||
continue
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, snap.enabled
|
||||
}
|
||||
|
||||
func (h *Host) removePluginRuntimeStateLocked(id string) {
|
||||
for key, record := range h.managementRoutes {
|
||||
if record.pluginID == id {
|
||||
delete(h.managementRoutes, key)
|
||||
}
|
||||
}
|
||||
for key, record := range h.resourceRoutes {
|
||||
if record.pluginID == id {
|
||||
delete(h.resourceRoutes, key)
|
||||
}
|
||||
}
|
||||
for name, record := range h.commandLineFlags {
|
||||
if record.pluginID == id {
|
||||
delete(h.commandLineFlags, name)
|
||||
delete(h.commandLineHits, name)
|
||||
}
|
||||
}
|
||||
if registration, ok := h.modelRegistrations[id]; ok {
|
||||
delete(h.providerModels, registration.provider)
|
||||
}
|
||||
delete(h.modelProviders, id)
|
||||
delete(h.modelRegistrations, id)
|
||||
}
|
||||
|
||||
func (h *Host) callRegisterLocked(ctx context.Context, lp *loadedPlugin, item runtimeItemConfig) (pluginapi.Plugin, bool) {
|
||||
if lp == nil {
|
||||
return pluginapi.Plugin{}, false
|
||||
|
||||
@@ -113,6 +113,67 @@ func TestPluginLoadedTracksLoadedPluginAfterDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostUnloadPluginTargetsOnlyRequestedPlugin(t *testing.T) {
|
||||
loader := newTestSymbolLoader()
|
||||
alpha := &testPlugin{
|
||||
registerResult: validTestPlugin("alpha"),
|
||||
reconfigureResult: validTestPlugin("alpha"),
|
||||
}
|
||||
bravo := &testPlugin{
|
||||
registerResult: validTestPlugin("bravo"),
|
||||
reconfigureResult: validTestPlugin("bravo"),
|
||||
}
|
||||
alphaLookup := newTestSymbolLookup(alpha)
|
||||
bravoLookup := newTestSymbolLookup(bravo)
|
||||
loader.lookups["alpha"] = alphaLookup
|
||||
loader.lookups["bravo"] = bravoLookup
|
||||
h := NewForTest(loader)
|
||||
t.Cleanup(h.ShutdownAll)
|
||||
cfg := &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: makePluginDir(t, "alpha", "bravo"),
|
||||
},
|
||||
}
|
||||
|
||||
h.ApplyConfig(context.Background(), cfg)
|
||||
|
||||
if !h.UnloadPlugin("alpha") {
|
||||
t.Fatal("UnloadPlugin(alpha) = false, want true")
|
||||
}
|
||||
if h.PluginLoaded("alpha") {
|
||||
t.Fatal("PluginLoaded(alpha) = true, want false after targeted unload")
|
||||
}
|
||||
if !h.PluginLoaded("bravo") {
|
||||
t.Fatal("PluginLoaded(bravo) = false, want true after alpha unload")
|
||||
}
|
||||
if alphaLookup.shutdownCalls != 1 {
|
||||
t.Fatalf("alpha shutdown calls = %d, want 1", alphaLookup.shutdownCalls)
|
||||
}
|
||||
if bravoLookup.shutdownCalls != 0 {
|
||||
t.Fatalf("bravo shutdown calls = %d, want 0", bravoLookup.shutdownCalls)
|
||||
}
|
||||
plugins := h.RegisteredPlugins()
|
||||
if len(plugins) != 1 || plugins[0].ID != "bravo" {
|
||||
t.Fatalf("RegisteredPlugins() = %#v, want only bravo", plugins)
|
||||
}
|
||||
|
||||
h.ApplyConfig(context.Background(), cfg)
|
||||
|
||||
if loader.openCalls != 3 {
|
||||
t.Fatalf("Open calls = %d, want 3", loader.openCalls)
|
||||
}
|
||||
if alpha.registerCalls != 2 {
|
||||
t.Fatalf("alpha register calls = %d, want 2", alpha.registerCalls)
|
||||
}
|
||||
if bravo.registerCalls != 1 {
|
||||
t.Fatalf("bravo register calls = %d, want 1", bravo.registerCalls)
|
||||
}
|
||||
if bravo.reconfigureCalls != 1 {
|
||||
t.Fatalf("bravo reconfigure calls = %d, want 1", bravo.reconfigureCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostApplyConfigRegistersPluginThinkingApplier(t *testing.T) {
|
||||
loader := newTestSymbolLoader()
|
||||
plugin := &testPlugin{
|
||||
|
||||
@@ -34,6 +34,7 @@ func (l *testSymbolLoader) Open(file pluginFile, host *Host) (pluginClient, erro
|
||||
type testSymbolLookup struct {
|
||||
plugin *testPlugin
|
||||
active pluginapi.Plugin
|
||||
shutdownCalls int
|
||||
registerOverride func([]byte) pluginapi.Plugin
|
||||
reconfigureOverride func([]byte) pluginapi.Plugin
|
||||
}
|
||||
@@ -148,7 +149,9 @@ func (l *testSymbolLookup) Call(ctx context.Context, method string, request []by
|
||||
}
|
||||
}
|
||||
|
||||
func (l *testSymbolLookup) Shutdown() {}
|
||||
func (l *testSymbolLookup) Shutdown() {
|
||||
l.shutdownCalls++
|
||||
}
|
||||
|
||||
func (l *testSymbolLookup) callLifecycle(request []byte, reload bool) ([]byte, error) {
|
||||
var req rpcLifecycleRequest
|
||||
|
||||
@@ -22,9 +22,12 @@ type InstallOptions struct {
|
||||
GOOS string
|
||||
GOARCH string
|
||||
// PluginLoaded reports whether the plugin's dynamic library is currently
|
||||
// loaded by the running host. Loaded libraries cannot be overwritten on
|
||||
// Windows, so installs targeting Windows are rejected while it returns true.
|
||||
// loaded by the running host. Windows installs are rejected while it returns
|
||||
// true unless BeforeWrite can unload the plugin before replacement.
|
||||
PluginLoaded func() bool
|
||||
// BeforeWrite runs after the archive has been downloaded and verified, but
|
||||
// before the target plugin file is replaced.
|
||||
BeforeWrite func() error
|
||||
}
|
||||
|
||||
// ErrLoadedPluginLocked is returned when an install would overwrite a plugin
|
||||
@@ -43,7 +46,7 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio
|
||||
return InstallResult{}, errValidate
|
||||
}
|
||||
options = normalizeInstallOptions(options)
|
||||
if loadedPluginInstallBlocked(options) {
|
||||
if loadedPluginInstallBlocked(options) && options.BeforeWrite == nil {
|
||||
return InstallResult{}, ErrLoadedPluginLocked
|
||||
}
|
||||
release, errRelease := c.FetchRelease(ctx, plugin)
|
||||
@@ -100,6 +103,11 @@ func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) (
|
||||
}
|
||||
// Re-check immediately before writing: the plugin may have been loaded
|
||||
// while the archive was being downloaded and verified.
|
||||
if options.BeforeWrite != nil {
|
||||
if errBeforeWrite := options.BeforeWrite(); errBeforeWrite != nil {
|
||||
return InstallResult{}, fmt.Errorf("prepare plugin write: %w", errBeforeWrite)
|
||||
}
|
||||
}
|
||||
if loadedPluginInstallBlocked(options) {
|
||||
return InstallResult{}, ErrLoadedPluginLocked
|
||||
}
|
||||
@@ -250,6 +258,17 @@ func writeFileAtomic(targetPath string, data []byte, mode os.FileMode) error {
|
||||
}
|
||||
closed = true
|
||||
if errRename := os.Rename(tempPath, targetPath); errRename != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
if errRemove := os.Remove(targetPath); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove old plugin file: %w", errRemove)
|
||||
}
|
||||
if errRenameRetry := os.Rename(tempPath, targetPath); errRenameRetry == nil {
|
||||
removeTemp = false
|
||||
return nil
|
||||
} else {
|
||||
return fmt.Errorf("install plugin file: %w", errRenameRetry)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("install plugin file: %w", errRename)
|
||||
}
|
||||
removeTemp = false
|
||||
|
||||
@@ -65,6 +65,52 @@ func TestInstallArchiveBlocksLoadedWindowsPluginBeforeWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallArchivePreparesLoadedWindowsPluginBeforeWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
targetDir := filepath.Join(root, "windows", "amd64")
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
targetPath := filepath.Join(targetDir, "sample-provider.dll")
|
||||
if errWrite := os.WriteFile(targetPath, []byte("old"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
loaded := true
|
||||
prepared := false
|
||||
|
||||
result, errInstall := InstallArchive(makeZip(t, map[string]string{
|
||||
"sample-provider.dll": "new",
|
||||
}), testPlugin(), InstallOptions{
|
||||
PluginsDir: root,
|
||||
GOOS: "windows",
|
||||
GOARCH: "amd64",
|
||||
PluginLoaded: func() bool { return loaded },
|
||||
BeforeWrite: func() error {
|
||||
prepared = true
|
||||
loaded = false
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if errInstall != nil {
|
||||
t.Fatalf("InstallArchive() error = %v", errInstall)
|
||||
}
|
||||
if !prepared {
|
||||
t.Fatal("BeforeWrite was not called")
|
||||
}
|
||||
if !result.Overwritten {
|
||||
t.Fatal("Overwritten = false, want true")
|
||||
}
|
||||
data, errRead := os.ReadFile(targetPath)
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile() error = %v", errRead)
|
||||
}
|
||||
if string(data) != "new" {
|
||||
t.Fatalf("installed data = %q, want new", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallArchiveWritesPlatformPlugin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -286,7 +286,13 @@ func (b *Builder) Build() (*Service, error) {
|
||||
if b.postAuthHook != nil {
|
||||
service.serverOptions = append(service.serverOptions, api.WithPostAuthHook(b.postAuthHook))
|
||||
}
|
||||
service.serverOptions = append(service.serverOptions, api.WithPostAuthPersistHook(service.runtimeAuthSyncHook()), api.WithPluginHost(pluginHost))
|
||||
service.serverOptions = append(service.serverOptions,
|
||||
api.WithPostAuthPersistHook(service.runtimeAuthSyncHook()),
|
||||
api.WithPluginHost(pluginHost),
|
||||
api.WithConfigReloadHook(func(ctx context.Context, cfg *config.Config) {
|
||||
service.applyConfigUpdate(cfg)
|
||||
}),
|
||||
)
|
||||
return service, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user