mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
feat(plugins): add auto-reload functionality for plugins with file watcher support
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -226,9 +226,10 @@ type inspectOptions struct {
|
||||
}
|
||||
|
||||
type pluginsOptions struct {
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
}
|
||||
|
||||
type extAuthOptions struct {
|
||||
@@ -634,6 +635,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("plugins.folder", "")
|
||||
viper.SetDefault("plugins.enabled", false)
|
||||
viper.SetDefault("plugins.cachesize", "100MB")
|
||||
viper.SetDefault("plugins.autoreload", false)
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
|
||||
@@ -172,6 +172,50 @@ if !ok {
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime Loading
|
||||
|
||||
Navidrome supports loading, unloading, and reloading plugins at runtime without restarting the server.
|
||||
|
||||
### Auto-Reload (File Watcher)
|
||||
|
||||
Enable automatic plugin reloading when files change:
|
||||
|
||||
```toml
|
||||
[Plugins]
|
||||
Enabled = true
|
||||
AutoReload = true # Default: false
|
||||
```
|
||||
|
||||
When enabled, Navidrome watches the plugins folder and automatically:
|
||||
- **Loads** new `.wasm` files when they are created
|
||||
- **Reloads** plugins when their `.wasm` file is modified
|
||||
- **Unloads** plugins when their `.wasm` file is removed
|
||||
|
||||
This is especially useful during plugin development - just rebuild your plugin and it will be automatically reloaded.
|
||||
|
||||
### Programmatic API
|
||||
|
||||
The plugin Manager exposes methods for runtime plugin management:
|
||||
|
||||
```go
|
||||
manager := plugins.GetManager()
|
||||
|
||||
// Load a new plugin (file must exist at <plugins_folder>/<name>.wasm)
|
||||
err := manager.LoadPlugin("my-plugin")
|
||||
|
||||
// Unload a running plugin
|
||||
err := manager.UnloadPlugin("my-plugin")
|
||||
|
||||
// Reload a plugin (unload + load)
|
||||
err := manager.ReloadPlugin("my-plugin")
|
||||
```
|
||||
|
||||
### Notes on Runtime Loading
|
||||
|
||||
- **In-flight requests**: When a plugin is unloaded, existing plugin instances continue working until their request completes. New requests use the reloaded version.
|
||||
- **Config changes**: Plugin configuration (`PluginConfig.<name>`) is read at load time. Changes require a reload.
|
||||
- **Failed reloads**: If loading fails after unloading, the plugin remains unloaded. Check logs for errors.
|
||||
|
||||
## Security
|
||||
|
||||
Plugins run in a secure WebAssembly sandbox with these restrictions:
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/rjeczalik/notify"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
@@ -36,6 +37,12 @@ type Manager struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
cache wazero.CompilationCache
|
||||
|
||||
// File watcher fields (used when AutoReload is enabled)
|
||||
watcherEvents chan notify.EventInfo
|
||||
watcherDone chan struct{}
|
||||
debounceTimers map[string]*time.Timer
|
||||
debounceMu sync.Mutex
|
||||
}
|
||||
|
||||
// pluginInstance represents a loaded plugin
|
||||
@@ -90,11 +97,22 @@ func (m *Manager) Start(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start file watcher if auto-reload is enabled
|
||||
if autoReloadEnabled() {
|
||||
if err := m.startWatcher(); err != nil {
|
||||
log.Error(ctx, "Failed to start plugin file watcher", err)
|
||||
// Non-fatal - plugins are still loaded, just no auto-reload
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts down the plugin manager and releases all resources.
|
||||
func (m *Manager) Stop() error {
|
||||
// Stop file watcher first
|
||||
m.stopWatcher()
|
||||
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
@@ -273,7 +291,7 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
|
||||
return err
|
||||
}
|
||||
if exit != 0 {
|
||||
return err
|
||||
return fmt.Errorf("calling %s: %d", ManifestFunction, exit)
|
||||
}
|
||||
|
||||
// Parse manifest (validation happens during unmarshal via generated code)
|
||||
@@ -342,6 +360,83 @@ func (m *Manager) createMetadataAgent(instance *pluginInstance) (*MetadataAgent,
|
||||
return NewMetadataAgent(instance.name, plugin), nil
|
||||
}
|
||||
|
||||
// UnloadPlugin removes a plugin from the manager and closes its resources.
|
||||
// Returns an error if the plugin is not found.
|
||||
func (m *Manager) UnloadPlugin(name string) error {
|
||||
m.mu.Lock()
|
||||
instance, ok := m.plugins[name]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
delete(m.plugins, name)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Close the compiled plugin outside the lock with a grace period
|
||||
// to allow in-flight requests to complete
|
||||
if instance.compiled != nil {
|
||||
// Use a brief timeout for cleanup
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := instance.compiled.Close(ctx); err != nil {
|
||||
log.Error("Error closing plugin during unload", "plugin", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info(m.ctx, "Unloaded plugin", "plugin", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadPlugin loads a new plugin by name from the plugins folder.
|
||||
// The plugin file must exist at <plugins_folder>/<name>.wasm.
|
||||
// Returns an error if the plugin is already loaded or fails to load.
|
||||
func (m *Manager) LoadPlugin(name string) error {
|
||||
m.mu.RLock()
|
||||
_, exists := m.plugins[name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return fmt.Errorf("plugin %q is already loaded", name)
|
||||
}
|
||||
|
||||
folder := m.pluginsFolder()
|
||||
if folder == "" {
|
||||
return fmt.Errorf("no plugins folder configured")
|
||||
}
|
||||
|
||||
wasmPath := filepath.Join(folder, name+".wasm")
|
||||
if _, err := os.Stat(wasmPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("plugin file not found: %s", wasmPath)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.loadPlugin(name, wasmPath); err != nil {
|
||||
return fmt.Errorf("failed to load plugin %q: %w", name, err)
|
||||
}
|
||||
|
||||
log.Info(m.ctx, "Loaded plugin", "plugin", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadPlugin unloads and reloads a plugin by name.
|
||||
// If the plugin was loaded and unload succeeds but reload fails,
|
||||
// the plugin remains unloaded and the error is returned.
|
||||
func (m *Manager) ReloadPlugin(name string) error {
|
||||
if err := m.UnloadPlugin(name); err != nil {
|
||||
return fmt.Errorf("failed to unload plugin %q: %w", name, err)
|
||||
}
|
||||
|
||||
if err := m.LoadPlugin(name); err != nil {
|
||||
log.Error(m.ctx, "Failed to reload plugin, plugin remains unloaded", "plugin", name, err)
|
||||
return fmt.Errorf("failed to reload plugin %q: %w", name, err)
|
||||
}
|
||||
|
||||
log.Info(m.ctx, "Reloaded plugin", "plugin", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify interface implementations at compile time
|
||||
var (
|
||||
_ agents.PluginLoader = (*Manager)(nil)
|
||||
|
||||
160
plugins/manager_test.go
Normal file
160
plugins/manager_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Manager", func() {
|
||||
var (
|
||||
manager *Manager
|
||||
ctx context.Context
|
||||
testdataDir string
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
// Get testdata directory
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
Expect(ok).To(BeTrue())
|
||||
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
|
||||
|
||||
// Create temp dir for plugins
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "plugins-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Setup config
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
|
||||
// Create a fresh manager for each test
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*pluginInstance),
|
||||
}
|
||||
err = manager.Start(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
copyTestPlugin := func(destName string) string {
|
||||
srcPath := filepath.Join(testdataDir, "test-plugin.wasm")
|
||||
destPath := filepath.Join(tmpDir, destName+".wasm")
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return destPath
|
||||
}
|
||||
|
||||
Describe("LoadPlugin", func() {
|
||||
It("loads a new plugin by name", func() {
|
||||
copyTestPlugin("new-plugin")
|
||||
|
||||
err := manager.LoadPlugin("new-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
Expect(names).To(ContainElement("new-plugin"))
|
||||
})
|
||||
|
||||
It("returns error when plugin file does not exist", func() {
|
||||
err := manager.LoadPlugin("nonexistent")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("plugin file not found"))
|
||||
})
|
||||
|
||||
It("returns error when plugin is already loaded", func() {
|
||||
copyTestPlugin("duplicate")
|
||||
|
||||
err := manager.LoadPlugin("duplicate")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = manager.LoadPlugin("duplicate")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("already loaded"))
|
||||
})
|
||||
|
||||
It("returns error when plugins folder is not configured", func() {
|
||||
conf.Server.Plugins.Folder = ""
|
||||
conf.Server.DataFolder = ""
|
||||
|
||||
err := manager.LoadPlugin("test")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("no plugins folder configured"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("UnloadPlugin", func() {
|
||||
It("removes a loaded plugin", func() {
|
||||
copyTestPlugin("to-unload")
|
||||
err := manager.LoadPlugin("to-unload")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = manager.UnloadPlugin("to-unload")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
Expect(names).ToNot(ContainElement("to-unload"))
|
||||
})
|
||||
|
||||
It("returns error when plugin not found", func() {
|
||||
err := manager.UnloadPlugin("nonexistent")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not found"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReloadPlugin", func() {
|
||||
It("unloads and reloads a plugin", func() {
|
||||
copyTestPlugin("to-reload")
|
||||
err := manager.LoadPlugin("to-reload")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = manager.ReloadPlugin("to-reload")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
Expect(names).To(ContainElement("to-reload"))
|
||||
})
|
||||
|
||||
It("returns error when plugin not found", func() {
|
||||
err := manager.ReloadPlugin("nonexistent")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to unload"))
|
||||
})
|
||||
|
||||
It("keeps plugin unloaded if reload fails", func() {
|
||||
copyTestPlugin("fail-reload")
|
||||
err := manager.LoadPlugin("fail-reload")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Remove the wasm file so reload will fail
|
||||
wasmPath := filepath.Join(tmpDir, "fail-reload.wasm")
|
||||
err = os.Remove(wasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = manager.ReloadPlugin("fail-reload")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to reload"))
|
||||
|
||||
// Plugin should no longer be loaded
|
||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
Expect(names).ToNot(ContainElement("fail-reload"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ var _ = Describe("MetadataAgent", func() {
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
// Load the test plugin
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
|
||||
153
plugins/watcher.go
Normal file
153
plugins/watcher.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/rjeczalik/notify"
|
||||
)
|
||||
|
||||
const (
|
||||
// debounceDuration is the time to wait before acting on file events
|
||||
// to handle multiple rapid events for the same file
|
||||
debounceDuration = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// startWatcher starts the file watcher for the plugins folder.
|
||||
// It watches for CREATE, WRITE, and REMOVE events on .wasm files.
|
||||
func (m *Manager) startWatcher() error {
|
||||
folder := m.pluginsFolder()
|
||||
if folder == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.watcherEvents = make(chan notify.EventInfo, 10)
|
||||
m.watcherDone = make(chan struct{})
|
||||
m.debounceTimers = make(map[string]*time.Timer)
|
||||
m.debounceMu = sync.Mutex{}
|
||||
|
||||
// Watch the plugins folder (not recursive)
|
||||
// We filter for .wasm files in the event handler
|
||||
if err := notify.Watch(folder, m.watcherEvents, notify.Create, notify.Write, notify.Remove, notify.Rename); err != nil {
|
||||
close(m.watcherEvents)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info(m.ctx, "Started plugin file watcher", "folder", folder)
|
||||
|
||||
go m.watcherLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopWatcher stops the file watcher
|
||||
func (m *Manager) stopWatcher() {
|
||||
if m.watcherEvents == nil {
|
||||
return
|
||||
}
|
||||
|
||||
notify.Stop(m.watcherEvents)
|
||||
close(m.watcherDone)
|
||||
|
||||
// Cancel any pending debounce timers
|
||||
m.debounceMu.Lock()
|
||||
for _, timer := range m.debounceTimers {
|
||||
timer.Stop()
|
||||
}
|
||||
m.debounceTimers = nil
|
||||
m.debounceMu.Unlock()
|
||||
|
||||
log.Debug(m.ctx, "Stopped plugin file watcher")
|
||||
}
|
||||
|
||||
// watcherLoop processes file watcher events
|
||||
func (m *Manager) watcherLoop() {
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-m.watcherEvents:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m.handleWatcherEvent(event)
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case <-m.watcherDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleWatcherEvent processes a single file watcher event with debouncing
|
||||
func (m *Manager) handleWatcherEvent(event notify.EventInfo) {
|
||||
path := event.Path()
|
||||
|
||||
// Only process .wasm files
|
||||
if !strings.HasSuffix(path, ".wasm") {
|
||||
return
|
||||
}
|
||||
|
||||
pluginName := strings.TrimSuffix(filepath.Base(path), ".wasm")
|
||||
|
||||
log.Debug(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path)
|
||||
|
||||
// Debounce: cancel any pending timer for this plugin and start a new one
|
||||
m.debounceMu.Lock()
|
||||
if timer, exists := m.debounceTimers[pluginName]; exists {
|
||||
timer.Stop()
|
||||
}
|
||||
|
||||
eventType := event.Event()
|
||||
m.debounceTimers[pluginName] = time.AfterFunc(debounceDuration, func() {
|
||||
m.processPluginEvent(pluginName, eventType)
|
||||
})
|
||||
m.debounceMu.Unlock()
|
||||
}
|
||||
|
||||
// processPluginEvent handles the actual plugin load/unload/reload after debouncing
|
||||
func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event) {
|
||||
// Clean up debounce timer entry
|
||||
m.debounceMu.Lock()
|
||||
delete(m.debounceTimers, pluginName)
|
||||
m.debounceMu.Unlock()
|
||||
|
||||
switch {
|
||||
case eventType¬ify.Remove != 0 || eventType¬ify.Rename != 0:
|
||||
// File removed or renamed away - unload if loaded
|
||||
if err := m.UnloadPlugin(pluginName); err != nil {
|
||||
// Plugin may not have been loaded, that's okay
|
||||
log.Debug(m.ctx, "Plugin not loaded, skipping unload", "plugin", pluginName, err)
|
||||
}
|
||||
|
||||
case eventType¬ify.Create != 0:
|
||||
// New file - load it
|
||||
if err := m.LoadPlugin(pluginName); err != nil {
|
||||
log.Error(m.ctx, "Failed to load new plugin", "plugin", pluginName, err)
|
||||
}
|
||||
|
||||
case eventType¬ify.Write != 0:
|
||||
// File modified - check if it's loaded and reload
|
||||
m.mu.RLock()
|
||||
_, isLoaded := m.plugins[pluginName]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if isLoaded {
|
||||
if err := m.ReloadPlugin(pluginName); err != nil {
|
||||
log.Error(m.ctx, "Failed to reload plugin", "plugin", pluginName, err)
|
||||
}
|
||||
} else {
|
||||
// Not loaded yet, try to load it (might be a new file that was written after create)
|
||||
if err := m.LoadPlugin(pluginName); err != nil {
|
||||
log.Error(m.ctx, "Failed to load plugin", "plugin", pluginName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// autoReloadEnabled returns true if auto-reload is enabled
|
||||
func autoReloadEnabled() bool {
|
||||
return conf.Server.Plugins.AutoReload
|
||||
}
|
||||
150
plugins/watcher_test.go
Normal file
150
plugins/watcher_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Watcher", func() {
|
||||
var (
|
||||
manager *Manager
|
||||
ctx context.Context
|
||||
testdataDir string
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
// Get testdata directory
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
Expect(ok).To(BeTrue())
|
||||
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
|
||||
|
||||
// Create temp dir for plugins
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "plugins-watcher-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Setup config with AutoReload enabled
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = true
|
||||
|
||||
// Create a fresh manager for each test
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*pluginInstance),
|
||||
}
|
||||
err = manager.Start(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
copyTestPlugin := func(destName string) string {
|
||||
srcPath := filepath.Join(testdataDir, "test-plugin.wasm")
|
||||
destPath := filepath.Join(tmpDir, destName+".wasm")
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return destPath
|
||||
}
|
||||
|
||||
Describe("Auto-reload via file watcher", func() {
|
||||
It("loads a plugin when a new wasm file is created", func() {
|
||||
// Copy plugin file to trigger CREATE event
|
||||
copyTestPlugin("watch-create")
|
||||
|
||||
// Wait for debounce + processing
|
||||
Eventually(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 2*time.Second, 100*time.Millisecond).Should(ContainElement("watch-create"))
|
||||
})
|
||||
|
||||
It("reloads a plugin when the wasm file is modified", func() {
|
||||
// First, load a plugin
|
||||
copyTestPlugin("watch-modify")
|
||||
|
||||
// Wait for it to be loaded
|
||||
Eventually(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 2*time.Second, 100*time.Millisecond).Should(ContainElement("watch-modify"))
|
||||
|
||||
// Get the original plugin info
|
||||
originalInfo := manager.GetPluginInfo()["watch-modify"]
|
||||
Expect(originalInfo.Name).ToNot(BeEmpty())
|
||||
|
||||
// Modify the file (re-copy to trigger WRITE event)
|
||||
wasmPath := filepath.Join(tmpDir, "watch-modify.wasm")
|
||||
data, err := os.ReadFile(wasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Touch the file to trigger write event
|
||||
err = os.WriteFile(wasmPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for reload - the plugin should still be there
|
||||
// We can't easily verify it was reloaded without adding tracking,
|
||||
// but at least verify it's still loaded
|
||||
Consistently(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 1*time.Second, 100*time.Millisecond).Should(ContainElement("watch-modify"))
|
||||
})
|
||||
|
||||
It("unloads a plugin when the wasm file is removed", func() {
|
||||
// First, load a plugin
|
||||
wasmPath := copyTestPlugin("watch-remove")
|
||||
|
||||
// Wait for it to be loaded
|
||||
Eventually(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 2*time.Second, 100*time.Millisecond).Should(ContainElement("watch-remove"))
|
||||
|
||||
// Remove the file
|
||||
err := os.Remove(wasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for it to be unloaded
|
||||
Eventually(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 2*time.Second, 100*time.Millisecond).ShouldNot(ContainElement("watch-remove"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Watcher disabled", func() {
|
||||
BeforeEach(func() {
|
||||
// Stop existing manager and create one without auto-reload
|
||||
_ = manager.Stop()
|
||||
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*pluginInstance),
|
||||
}
|
||||
err := manager.Start(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("does not auto-load plugins when AutoReload is disabled", func() {
|
||||
// Copy plugin file
|
||||
copyTestPlugin("no-watch")
|
||||
|
||||
// Wait a bit and verify plugin is NOT loaded
|
||||
Consistently(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 1*time.Second, 100*time.Millisecond).ShouldNot(ContainElement("no-watch"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user