mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
679 lines
20 KiB
Go
679 lines
20 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
extism "github.com/extism/go-sdk"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/lyrics"
|
|
"github.com/navidrome/navidrome/core/scrobbler"
|
|
"github.com/navidrome/navidrome/core/sonic"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server/events"
|
|
"github.com/navidrome/navidrome/utils/singleton"
|
|
"github.com/rjeczalik/notify"
|
|
"github.com/tetratelabs/wazero"
|
|
)
|
|
|
|
const (
|
|
// defaultTimeout is the default timeout for plugin function calls
|
|
defaultTimeout = 30 * time.Second
|
|
|
|
// maxPluginLoadConcurrency is the maximum number of plugins that can be
|
|
// compiled/loaded in parallel during startup
|
|
maxPluginLoadConcurrency = 3
|
|
)
|
|
|
|
// SubsonicRouter is an http.Handler that serves Subsonic API requests.
|
|
type SubsonicRouter = http.Handler
|
|
|
|
// PluginMetricsRecorder is an interface for recording plugin metrics.
|
|
// This is satisfied by core/metrics.Metrics but defined here to avoid import cycles.
|
|
type PluginMetricsRecorder interface {
|
|
RecordPluginRequest(ctx context.Context, plugin, method string, ok bool, elapsed int64)
|
|
}
|
|
|
|
// Manager manages loading and lifecycle of WebAssembly plugins.
|
|
// It implements both agents.PluginLoader and scrobbler.PluginLoader interfaces.
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
plugins map[string]*plugin
|
|
ctx context.Context //nolint:containedctx // manager lifecycle ctx, cancelled by Stop
|
|
cancel context.CancelFunc
|
|
cache wazero.CompilationCache
|
|
stopped atomic.Bool // Set to true when Stop() is called
|
|
loadWg sync.WaitGroup // Tracks in-flight plugin load operations
|
|
|
|
// File watcher fields (used when AutoReload is enabled)
|
|
watcherEvents chan notify.EventInfo
|
|
watcherDone chan struct{}
|
|
debounceTimers map[string]*time.Timer
|
|
debounceMu sync.Mutex
|
|
|
|
// transient is set by LoadPlugins, and nil for a server Start.
|
|
transient *transientLoad
|
|
|
|
// SubsonicAPI host function dependencies (set once before Start, not modified after)
|
|
subsonicRouter SubsonicRouter
|
|
ds model.DataStore
|
|
broker events.Broker
|
|
metrics PluginMetricsRecorder
|
|
}
|
|
|
|
// GetManager returns a singleton instance of the plugin manager.
|
|
// The manager is not started automatically; call Start() to begin loading plugins.
|
|
func GetManager(ds model.DataStore, broker events.Broker, m PluginMetricsRecorder) *Manager {
|
|
return singleton.GetInstance(func() *Manager {
|
|
return &Manager{
|
|
ds: ds,
|
|
broker: broker,
|
|
metrics: m,
|
|
plugins: make(map[string]*plugin),
|
|
}
|
|
})
|
|
}
|
|
|
|
// sendPluginRefreshEvent broadcasts a refresh event for the plugin resource.
|
|
// This notifies connected UI clients that plugin data has changed.
|
|
func (m *Manager) sendPluginRefreshEvent(ctx context.Context, pluginIDs ...string) {
|
|
if m.broker == nil {
|
|
return
|
|
}
|
|
event := (&events.RefreshResource{}).With("plugin", pluginIDs...)
|
|
m.broker.SendBroadcastMessage(ctx, event)
|
|
}
|
|
|
|
// SetSubsonicRouter sets the Subsonic router for SubsonicAPI host functions.
|
|
// This should be called after the subsonic router is created but before plugins
|
|
// that require SubsonicAPI access are loaded.
|
|
func (m *Manager) SetSubsonicRouter(router SubsonicRouter) {
|
|
m.subsonicRouter = router
|
|
}
|
|
|
|
// Start initializes the plugin manager and loads plugins from the configured folder.
|
|
// It should be called once during application startup when plugins are enabled.
|
|
// The startup flow is:
|
|
// 1. Sync plugins folder with DB (discover new, update changed, remove deleted)
|
|
// 2. Load only enabled plugins from DB
|
|
func (m *Manager) Start(ctx context.Context) error {
|
|
if !conf.Server.Plugins.Enabled {
|
|
log.Debug(ctx, "Plugin system is disabled")
|
|
return nil
|
|
}
|
|
|
|
if m.subsonicRouter == nil {
|
|
log.Fatal(ctx, "Plugin manager requires the SubsonicAPI router to be configured")
|
|
}
|
|
|
|
cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins")
|
|
purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize)
|
|
|
|
if err := m.initRuntime(ctx, cacheDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
if conf.Server.Plugins.Folder.String() == "" {
|
|
log.Debug(ctx, "No plugins folder configured")
|
|
return nil
|
|
}
|
|
|
|
folder := conf.Server.Plugins.Folder.MustPath()
|
|
|
|
log.Info(ctx, "Starting plugin manager", "folder", folder)
|
|
|
|
// Clear previous error states so plugins can be retried on restart
|
|
adminCtx := adminContext(ctx)
|
|
if err := m.ds.Plugin().ClearErrors(adminCtx); err != nil {
|
|
log.Error(ctx, "Error clearing plugin errors", err)
|
|
}
|
|
|
|
// Sync plugins folder with DB
|
|
if err := m.syncPlugins(ctx, folder); err != nil {
|
|
log.Error(ctx, "Error syncing plugins with DB", err)
|
|
// Continue - we can still try to load plugins
|
|
}
|
|
|
|
// Load enabled plugins from DB
|
|
if err := m.loadEnabledPlugins(ctx); err != nil {
|
|
log.Error(ctx, "Error loading enabled plugins", err)
|
|
return fmt.Errorf("loading enabled plugins: %w", err)
|
|
}
|
|
|
|
// Start file watcher if auto-reload is enabled
|
|
if conf.Server.Plugins.AutoReload {
|
|
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
|
|
}
|
|
|
|
// initRuntime prepares the extism/wazero runtime that instantiating a plugin needs.
|
|
func (m *Manager) initRuntime(ctx context.Context, cacheDir string) error {
|
|
pluginLogLevel := conf.Server.Plugins.LogLevel
|
|
if pluginLogLevel == "" {
|
|
pluginLogLevel = conf.Server.LogLevel
|
|
}
|
|
extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel)))
|
|
|
|
m.ctx, m.cancel = context.WithCancel(ctx)
|
|
|
|
var err error
|
|
m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir)
|
|
if err != nil {
|
|
log.Error(ctx, "Failed to create wazero compilation cache", err)
|
|
return fmt.Errorf("creating wazero compilation cache: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// transientLoad scopes a load that will not outlive the command asking for it; see LoadPlugins.
|
|
type transientLoad struct {
|
|
only []string
|
|
runInit bool
|
|
}
|
|
|
|
// LoadPlugins loads the plugins named in only, so a CLI sees the agents a server would. Each is
|
|
// instantiated, creating any KVStore, TaskQueue or Storage it declares; call Stop when done.
|
|
func (m *Manager) LoadPlugins(ctx context.Context, only []string, runInit bool) error {
|
|
if !conf.Server.Plugins.Enabled || conf.Server.Plugins.Folder.String() == "" || len(only) == 0 {
|
|
return nil
|
|
}
|
|
m.transient = &transientLoad{only: only, runInit: runInit}
|
|
|
|
cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins")
|
|
if err := m.initRuntime(ctx, cacheDir); err != nil {
|
|
return err
|
|
}
|
|
if err := m.loadEnabledPlugins(ctx); err != nil {
|
|
return fmt.Errorf("loading enabled plugins: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Stop shuts down the plugin manager and releases all resources.
|
|
func (m *Manager) Stop() error {
|
|
// Mark as stopped first to prevent new operations
|
|
m.stopped.Store(true)
|
|
|
|
// Cancel context to signal all goroutines to stop
|
|
if m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
|
|
// Stop file watcher
|
|
m.stopWatcher()
|
|
|
|
// Wait for all in-flight plugin load operations to complete
|
|
// This is critical to avoid races with cache.Close()
|
|
m.loadWg.Wait()
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Close all plugins
|
|
for name, plugin := range m.plugins {
|
|
err := plugin.Close()
|
|
if err != nil {
|
|
log.Error("Error during plugin cleanup", "plugin", name, err)
|
|
}
|
|
if plugin.compiled != nil {
|
|
if err := plugin.compiled.Close(context.Background()); err != nil {
|
|
log.Error("Error closing plugin", "plugin", name, err)
|
|
}
|
|
}
|
|
}
|
|
m.plugins = make(map[string]*plugin)
|
|
|
|
// Close compilation cache
|
|
if m.cache != nil {
|
|
if err := m.cache.Close(context.Background()); err != nil {
|
|
log.Error("Error closing wazero cache", err)
|
|
}
|
|
m.cache = nil
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PluginNames returns the names of all plugins that implement a particular capability.
|
|
// This is used by both agents and scrobbler systems to discover available plugins.
|
|
// Capabilities are auto-detected from the plugin's exported functions.
|
|
func (m *Manager) PluginNames(capability string) []string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
var names []string
|
|
cap := Capability(capability)
|
|
for name, plugin := range m.plugins {
|
|
if hasCapability(plugin.capabilities, cap) {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
|
|
return loadPlugin(m, name, CapabilityMetadataAgent, newMetadataAgent)
|
|
}
|
|
|
|
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
|
return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin)
|
|
}
|
|
|
|
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
|
|
return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin)
|
|
}
|
|
|
|
func (m *Manager) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
|
|
return loadPlugin(m, name, CapabilitySonicSimilarity, newSonicSimilarityPlugin)
|
|
}
|
|
|
|
func loadPlugin[T any](m *Manager, name string, cap Capability, newAdapter func(*plugin) T) (T, bool) {
|
|
m.mu.RLock()
|
|
p, ok := m.plugins[name]
|
|
m.mu.RUnlock()
|
|
|
|
var zero T
|
|
if !ok || !hasCapability(p.capabilities, cap) {
|
|
return zero, false
|
|
}
|
|
return newAdapter(p), true
|
|
}
|
|
|
|
// PluginInfo contains basic information about a plugin for metrics/insights.
|
|
type PluginInfo struct {
|
|
Name string
|
|
Version string
|
|
}
|
|
|
|
// GetPluginInfo returns information about all loaded plugins.
|
|
func (m *Manager) GetPluginInfo() map[string]PluginInfo {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
info := make(map[string]PluginInfo, len(m.plugins))
|
|
for name, plugin := range m.plugins {
|
|
info[name] = PluginInfo{
|
|
Name: plugin.manifest.Name,
|
|
Version: plugin.manifest.Version,
|
|
}
|
|
}
|
|
return info
|
|
}
|
|
|
|
// EnablePlugin enables a plugin by loading it and updating the DB.
|
|
// Returns an error if the plugin is not found in DB or fails to load.
|
|
func (m *Manager) EnablePlugin(ctx context.Context, id string) error {
|
|
if m.ds == nil {
|
|
return fmt.Errorf("datastore not configured")
|
|
}
|
|
|
|
adminCtx := adminContext(ctx)
|
|
repo := m.ds.Plugin()
|
|
|
|
plugin, err := repo.Get(adminCtx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("getting plugin from DB: %w", err)
|
|
}
|
|
|
|
if plugin.Enabled {
|
|
return nil // Already enabled
|
|
}
|
|
|
|
// Check permission gates before enabling
|
|
if err := m.checkPermissionGates(plugin); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Try to load the plugin
|
|
if err := m.loadPluginWithConfig(plugin); err != nil {
|
|
// Store error and return
|
|
plugin.LastError = err.Error()
|
|
plugin.UpdatedAt = time.Now()
|
|
_ = repo.Put(adminCtx, plugin)
|
|
return fmt.Errorf("loading plugin: %w", err)
|
|
}
|
|
|
|
// Update DB
|
|
plugin.Enabled = true
|
|
plugin.LastError = ""
|
|
plugin.UpdatedAt = time.Now()
|
|
if err := repo.Put(adminCtx, plugin); err != nil {
|
|
// Unload since we couldn't update DB
|
|
_ = m.unloadPlugin(id)
|
|
return fmt.Errorf("updating plugin in DB: %w", err)
|
|
}
|
|
|
|
log.Info(ctx, "Enabled plugin", "plugin", id)
|
|
m.sendPluginRefreshEvent(ctx, id)
|
|
return nil
|
|
}
|
|
|
|
// DisablePlugin disables a plugin by unloading it and updating the DB.
|
|
// Returns an error if the plugin is not found in DB.
|
|
func (m *Manager) DisablePlugin(ctx context.Context, id string) error {
|
|
if m.ds == nil {
|
|
return fmt.Errorf("datastore not configured")
|
|
}
|
|
|
|
adminCtx := adminContext(ctx)
|
|
repo := m.ds.Plugin()
|
|
|
|
plugin, err := repo.Get(adminCtx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("getting plugin from DB: %w", err)
|
|
}
|
|
|
|
if !plugin.Enabled {
|
|
return nil // Already disabled
|
|
}
|
|
|
|
// Unload the plugin
|
|
if err := m.unloadPlugin(id); err != nil {
|
|
log.Debug(ctx, "Plugin was not loaded", "plugin", id)
|
|
}
|
|
|
|
// Update DB
|
|
plugin.Enabled = false
|
|
plugin.UpdatedAt = time.Now()
|
|
if err := repo.Put(adminCtx, plugin); err != nil {
|
|
return fmt.Errorf("updating plugin in DB: %w", err)
|
|
}
|
|
|
|
log.Info(ctx, "Disabled plugin", "plugin", id)
|
|
m.sendPluginRefreshEvent(ctx, id)
|
|
return nil
|
|
}
|
|
|
|
// ValidatePluginConfig validates a config JSON string against the plugin's config schema.
|
|
// If the plugin has no config schema defined, it returns an error.
|
|
// Returns nil if validation passes, or an error describing the validation failure.
|
|
func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON string) error {
|
|
if m.ds == nil {
|
|
return fmt.Errorf("datastore not configured")
|
|
}
|
|
|
|
adminCtx := adminContext(ctx)
|
|
repo := m.ds.Plugin()
|
|
|
|
plugin, err := repo.Get(adminCtx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("getting plugin from DB: %w", err)
|
|
}
|
|
|
|
manifest, err := ReadManifest(plugin.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("reading manifest: %w", err)
|
|
}
|
|
|
|
return ValidateConfig(manifest, configJSON)
|
|
}
|
|
|
|
// UpdatePluginConfig updates the configuration for a plugin.
|
|
// If the plugin is enabled, it will be reloaded with the new config.
|
|
func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string) error {
|
|
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
|
|
p.Config = configJSON
|
|
})
|
|
}
|
|
|
|
// UpdatePluginUsers updates the users permission settings for a plugin.
|
|
// If the plugin is enabled, it will be reloaded with the new settings.
|
|
// If the plugin requires users permission and no users are configured (and allUsers is false),
|
|
// the plugin will be automatically disabled.
|
|
func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error {
|
|
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
|
|
p.Users = usersJSON
|
|
p.AllUsers = allUsers
|
|
})
|
|
}
|
|
|
|
// UpdatePluginLibraries updates the libraries permission settings for a plugin.
|
|
// If the plugin is enabled, it will be reloaded with the new settings.
|
|
// If the plugin requires library permission and no libraries are configured (and allLibraries is false),
|
|
// the plugin will be automatically disabled.
|
|
func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error {
|
|
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
|
|
p.Libraries = librariesJSON
|
|
p.AllLibraries = allLibraries
|
|
p.AllowWriteAccess = allowWriteAccess
|
|
})
|
|
}
|
|
|
|
// RescanPlugins triggers a manual rescan of the plugins folder.
|
|
// This synchronizes the database with the filesystem, discovering new plugins,
|
|
// updating changed ones, and removing deleted ones.
|
|
func (m *Manager) RescanPlugins(ctx context.Context) error {
|
|
folder := conf.Server.Plugins.Folder.String()
|
|
if folder == "" {
|
|
return fmt.Errorf("plugins folder not configured")
|
|
}
|
|
log.Info(ctx, "Manual plugin rescan requested", "folder", folder)
|
|
return m.syncPlugins(ctx, folder)
|
|
}
|
|
|
|
// updatePluginSettings is a common implementation for updating plugin settings.
|
|
// The updateFn is called to apply the specific field updates to the plugin.
|
|
// If the plugin is enabled, it will be reloaded. If users permission is required
|
|
// but no longer satisfied, the plugin will be disabled.
|
|
func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn func(*model.Plugin)) error {
|
|
if m.ds == nil {
|
|
return fmt.Errorf("datastore not configured")
|
|
}
|
|
|
|
adminCtx := adminContext(ctx)
|
|
repo := m.ds.Plugin()
|
|
|
|
plugin, err := repo.Get(adminCtx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("getting plugin from DB: %w", err)
|
|
}
|
|
|
|
wasEnabled := plugin.Enabled
|
|
|
|
// Apply the specific updates
|
|
updateFn(plugin)
|
|
plugin.UpdatedAt = time.Now()
|
|
|
|
// Check if plugin requires permission and if it's still satisfied
|
|
shouldDisable := false
|
|
disableReason := ""
|
|
if wasEnabled {
|
|
manifest, err := ReadManifest(plugin.Path)
|
|
if err == nil && manifest.Permissions != nil {
|
|
if manifest.Permissions.Users != nil && !hasValidUsersConfig(plugin.Users, plugin.AllUsers) {
|
|
shouldDisable = true
|
|
disableReason = "users permission removal"
|
|
}
|
|
if manifest.Permissions.Library != nil && !hasValidLibrariesConfig(plugin.Libraries, plugin.AllLibraries) {
|
|
shouldDisable = true
|
|
disableReason = "library permission removal"
|
|
}
|
|
}
|
|
}
|
|
|
|
if shouldDisable {
|
|
// Disable the plugin since permission is no longer satisfied
|
|
if err := m.unloadPlugin(id); err != nil {
|
|
log.Debug(ctx, "Plugin was not loaded", "plugin", id)
|
|
}
|
|
plugin.Enabled = false
|
|
if err := repo.Put(adminCtx, plugin); err != nil {
|
|
return fmt.Errorf("updating plugin in DB: %w", err)
|
|
}
|
|
log.Info(ctx, "Disabled plugin due to "+disableReason, "plugin", id)
|
|
m.sendPluginRefreshEvent(ctx, id)
|
|
return nil
|
|
}
|
|
|
|
if err := repo.Put(adminCtx, plugin); err != nil {
|
|
return fmt.Errorf("updating plugin in DB: %w", err)
|
|
}
|
|
|
|
// Reload if enabled
|
|
if wasEnabled {
|
|
if err := m.unloadPlugin(id); err != nil {
|
|
log.Debug(ctx, "Plugin was not loaded", "plugin", id)
|
|
}
|
|
if err := m.loadPluginWithConfig(plugin); err != nil {
|
|
plugin.LastError = err.Error()
|
|
plugin.Enabled = false
|
|
_ = repo.Put(adminCtx, plugin)
|
|
return fmt.Errorf("reloading plugin: %w", err)
|
|
}
|
|
}
|
|
|
|
log.Info(ctx, "Updated plugin settings", "plugin", id)
|
|
m.sendPluginRefreshEvent(ctx, id)
|
|
return 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()
|
|
plugin, ok := m.plugins[name]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("plugin %q not found", name)
|
|
}
|
|
delete(m.plugins, name)
|
|
m.mu.Unlock()
|
|
|
|
// Run cleanup functions
|
|
err := plugin.Close()
|
|
if err != nil {
|
|
log.Error("Error during plugin cleanup", "plugin", name, err)
|
|
}
|
|
|
|
// Close the compiled plugin outside the lock with a grace period
|
|
// to allow in-flight requests to complete
|
|
if plugin.compiled != nil {
|
|
// Use a brief timeout for cleanup
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := plugin.compiled.Close(ctx); err != nil {
|
|
log.Error("Error closing plugin during unload", "plugin", name, err)
|
|
}
|
|
}
|
|
|
|
runtime.GC()
|
|
log.Info(m.ctx, "Unloaded plugin", "plugin", name)
|
|
return nil
|
|
}
|
|
|
|
// UnloadDisabledPlugins checks for plugins that are disabled in the database
|
|
// but still loaded in memory, and unloads them. This is called after user or
|
|
// library deletion to clean up plugins that were auto-disabled due to
|
|
// permission loss.
|
|
func (m *Manager) UnloadDisabledPlugins(ctx context.Context) {
|
|
if m.ds == nil {
|
|
return
|
|
}
|
|
|
|
adminCtx := adminContext(ctx)
|
|
repo := m.ds.Plugin()
|
|
|
|
// Get all disabled plugins from the database
|
|
plugins, err := repo.GetAll(adminCtx, model.QueryOptions{
|
|
Filters: squirrel.Eq{"enabled": false},
|
|
})
|
|
if err != nil {
|
|
log.Error(ctx, "Failed to get disabled plugins", err)
|
|
return
|
|
}
|
|
|
|
// Check each disabled plugin and unload if still in memory
|
|
var unloaded []string
|
|
for _, p := range plugins {
|
|
m.mu.RLock()
|
|
_, loaded := m.plugins[p.ID]
|
|
m.mu.RUnlock()
|
|
|
|
if loaded {
|
|
if err := m.unloadPlugin(p.ID); err != nil {
|
|
log.Warn(ctx, "Failed to unload disabled plugin", "plugin", p.ID, err)
|
|
} else {
|
|
unloaded = append(unloaded, p.ID)
|
|
log.Info(ctx, "Unloaded disabled plugin", "plugin", p.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send refresh events for unloaded plugins
|
|
if len(unloaded) > 0 {
|
|
m.sendPluginRefreshEvent(ctx, unloaded...)
|
|
}
|
|
}
|
|
|
|
// checkPermissionGates validates that all permission-based requirements are met
|
|
// before a plugin can be enabled. Returns an error if any gate condition fails.
|
|
func (m *Manager) checkPermissionGates(p *model.Plugin) error {
|
|
// Parse manifest to check permissions
|
|
manifest, err := ReadManifest(p.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("reading manifest: %w", err)
|
|
}
|
|
|
|
// Check users permission gate
|
|
if manifest.Permissions != nil && manifest.Permissions.Users != nil {
|
|
if !hasValidUsersConfig(p.Users, p.AllUsers) {
|
|
return fmt.Errorf("users permission requires configuration: select users or enable 'all users' access")
|
|
}
|
|
}
|
|
|
|
// Check library permission gate
|
|
if manifest.Permissions != nil && manifest.Permissions.Library != nil {
|
|
if !hasValidLibrariesConfig(p.Libraries, p.AllLibraries) {
|
|
return fmt.Errorf("library permission requires configuration: select libraries or enable 'all libraries' access")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// hasValidUsersConfig checks if a plugin has valid users configuration.
|
|
// Returns true if allUsers is true, or if usersJSON contains at least one user.
|
|
func hasValidUsersConfig(usersJSON string, allUsers bool) bool {
|
|
if allUsers {
|
|
return true
|
|
}
|
|
if usersJSON == "" {
|
|
return false
|
|
}
|
|
var users []string
|
|
if err := json.Unmarshal([]byte(usersJSON), &users); err != nil {
|
|
return false
|
|
}
|
|
return len(users) > 0
|
|
}
|
|
|
|
// hasValidLibrariesConfig checks if a plugin has valid libraries configuration.
|
|
// Returns true if allLibraries is true, or if librariesJSON contains at least one library.
|
|
func hasValidLibrariesConfig(librariesJSON string, allLibraries bool) bool {
|
|
if allLibraries {
|
|
return true
|
|
}
|
|
if librariesJSON == "" {
|
|
return false
|
|
}
|
|
var libraries []int
|
|
if err := json.Unmarshal([]byte(librariesJSON), &libraries); err != nil {
|
|
return false
|
|
}
|
|
return len(libraries) > 0
|
|
}
|