navidrome/plugins/manager_loader.go
2025-12-31 17:06:30 -05:00

368 lines
11 KiB
Go

package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/scheduler"
"github.com/tetratelabs/wazero"
"golang.org/x/sync/errgroup"
)
// serviceContext provides dependencies needed by host service factories.
type serviceContext struct {
pluginName string
manager *Manager
permissions *Permissions
}
// hostServiceEntry defines a host service for table-driven registration.
type hostServiceEntry struct {
name string
hasPermission func(*Permissions) bool
registerStubs func() []extism.HostFunction
create func(*serviceContext) ([]extism.HostFunction, io.Closer)
}
// hostServices defines all available host services.
// Adding a new host service only requires adding an entry here.
var hostServices = []hostServiceEntry{
{
name: "SubsonicAPI",
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
registerStubs: func() []extism.HostFunction { return host.RegisterSubsonicAPIHostFunctions(nil) },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
perm := ctx.permissions.Subsonicapi
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, perm)
return host.RegisterSubsonicAPIHostFunctions(service), nil
},
},
{
name: "Scheduler",
hasPermission: func(p *Permissions) bool { return p != nil && p.Scheduler != nil },
registerStubs: func() []extism.HostFunction { return host.RegisterSchedulerHostFunctions(nil) },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newSchedulerService(ctx.pluginName, ctx.manager, scheduler.GetInstance())
return host.RegisterSchedulerHostFunctions(service), service
},
},
{
name: "WebSocket",
hasPermission: func(p *Permissions) bool { return p != nil && p.Websocket != nil },
registerStubs: func() []extism.HostFunction { return host.RegisterWebSocketHostFunctions(nil) },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
perm := ctx.permissions.Websocket
service := newWebSocketService(ctx.pluginName, ctx.manager, perm)
return host.RegisterWebSocketHostFunctions(service), service
},
},
{
name: "Artwork",
hasPermission: func(p *Permissions) bool { return p != nil && p.Artwork != nil },
registerStubs: func() []extism.HostFunction { return host.RegisterArtworkHostFunctions(nil) },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newArtworkService()
return host.RegisterArtworkHostFunctions(service), nil
},
},
{
name: "Cache",
hasPermission: func(p *Permissions) bool { return p != nil && p.Cache != nil },
registerStubs: func() []extism.HostFunction { return host.RegisterCacheHostFunctions(nil) },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newCacheService(ctx.pluginName)
return host.RegisterCacheHostFunctions(service), service
},
},
}
// stubHostFunctions returns the list of stub host functions needed for initial plugin compilation.
func stubHostFunctions() []extism.HostFunction {
var stubs []extism.HostFunction
for _, entry := range hostServices {
stubs = append(stubs, entry.registerStubs()...)
}
return stubs
}
// compiledPluginInfo holds the intermediate compilation result used by both
// extractManifest and loadPluginWithConfig.
type compiledPluginInfo struct {
wasmBytes []byte
sha256 string
manifest *Manifest
compiled *extism.CompiledPlugin
}
// compileAndExtractManifest reads a wasm file, compiles it with cache, and extracts the manifest.
// The caller is responsible for closing the returned compiled plugin when done.
func (m *Manager) compileAndExtractManifest(ctx context.Context, wasmPath string, config map[string]string) (*compiledPluginInfo, error) {
wasmBytes, err := os.ReadFile(wasmPath)
if err != nil {
return nil, fmt.Errorf("reading wasm file: %w", err)
}
// Compute SHA-256 hash
hash := sha256.Sum256(wasmBytes)
hashHex := hex.EncodeToString(hash[:])
// Extract plugin name from path for logging
pluginName := strings.TrimSuffix(filepath.Base(wasmPath), ".wasm")
pluginManifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{Data: wasmBytes, Name: "main"},
},
Config: config,
Timeout: uint64(defaultTimeout.Milliseconds()),
}
extismConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
}
compiled, err := extism.NewCompiledPlugin(ctx, pluginManifest, extismConfig, stubHostFunctions())
if err != nil {
return nil, fmt.Errorf("compiling plugin: %w", err)
}
instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{})
if err != nil {
compiled.Close(ctx)
return nil, fmt.Errorf("creating instance: %w", err)
}
defer instance.Close(ctx)
instance.SetLogger(extismLogger(pluginName))
exit, manifestBytes, err := instance.Call(manifestFunction, nil)
if err != nil {
compiled.Close(ctx)
return nil, fmt.Errorf("calling manifest function: %w", err)
}
if exit != 0 {
compiled.Close(ctx)
return nil, fmt.Errorf("manifest function exited with code %d", exit)
}
var manifest Manifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
compiled.Close(ctx)
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &compiledPluginInfo{
wasmBytes: wasmBytes,
sha256: hashHex,
manifest: &manifest,
compiled: compiled,
}, nil
}
// extractManifest loads a wasm file, computes its SHA-256 hash, extracts the manifest,
// and immediately closes without full plugin initialization.
// This is a lightweight operation used for plugin discovery and change detection.
// The compilation is cached to speed up subsequent EnablePlugin calls.
func (m *Manager) extractManifest(wasmPath string) (*PluginMetadata, error) {
if m.stopped.Load() {
return nil, fmt.Errorf("manager is stopped")
}
info, err := m.compileAndExtractManifest(context.Background(), wasmPath, nil)
if err != nil {
return nil, err
}
defer info.compiled.Close(context.Background())
return &PluginMetadata{
Manifest: info.manifest,
SHA256: info.sha256,
}, nil
}
// loadEnabledPlugins loads all enabled plugins from the database.
func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
if m.ds == nil {
return fmt.Errorf("datastore not configured")
}
adminCtx := adminContext(ctx)
repo := m.ds.Plugin(adminCtx)
plugins, err := repo.GetAll()
if err != nil {
return fmt.Errorf("reading plugins from DB: %w", err)
}
g := errgroup.Group{}
g.SetLimit(maxPluginLoadConcurrency)
for _, p := range plugins {
if !p.Enabled {
continue
}
plugin := p // Capture for goroutine
g.Go(func() error {
start := time.Now()
log.Debug(ctx, "Loading enabled plugin", "plugin", plugin.ID, "path", plugin.Path)
// Panic recovery
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "Panic while loading plugin", "plugin", plugin.ID, "panic", r)
}
}()
if err := m.loadPluginWithConfig(plugin.ID, plugin.Path, plugin.Config); err != nil {
// Store error in DB
plugin.LastError = err.Error()
plugin.Enabled = false
plugin.UpdatedAt = time.Now()
if putErr := repo.Put(&plugin); putErr != nil {
log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr)
}
log.Error(ctx, "Failed to load plugin", "plugin", plugin.ID, err)
return nil
}
// Clear any previous error
if plugin.LastError != "" {
plugin.LastError = ""
plugin.UpdatedAt = time.Now()
if putErr := repo.Put(&plugin); putErr != nil {
log.Error(ctx, "Failed to clear plugin error in DB", "plugin", plugin.ID, putErr)
}
}
m.mu.RLock()
loadedPlugin := m.plugins[plugin.ID]
m.mu.RUnlock()
if loadedPlugin != nil {
log.Info(ctx, "Loaded plugin", "plugin", plugin.ID, "manifest", loadedPlugin.manifest.Name,
"capabilities", loadedPlugin.capabilities, "duration", time.Since(start))
}
return nil
})
}
return g.Wait()
}
// loadPluginWithConfig loads a plugin with configuration from DB.
func (m *Manager) loadPluginWithConfig(name, wasmPath, configJSON string) error {
if m.stopped.Load() {
return fmt.Errorf("manager is stopped")
}
// Track this operation
m.loadWg.Add(1)
defer m.loadWg.Done()
if m.stopped.Load() {
return fmt.Errorf("manager is stopped")
}
// Parse config from JSON
var pluginConfig map[string]string
if configJSON != "" {
if err := json.Unmarshal([]byte(configJSON), &pluginConfig); err != nil {
return fmt.Errorf("parsing plugin config: %w", err)
}
}
// Compile and extract manifest using shared helper
info, err := m.compileAndExtractManifest(m.ctx, wasmPath, pluginConfig)
if err != nil {
return err
}
// Create instance to detect capabilities
instance, err := info.compiled.Instance(m.ctx, extism.PluginInstanceConfig{})
if err != nil {
info.compiled.Close(m.ctx)
return fmt.Errorf("creating instance: %w", err)
}
instance.SetLogger(extismLogger(name))
capabilities := detectCapabilities(instance)
instance.Close(m.ctx)
// Build host functions based on permissions
var hostFunctions []extism.HostFunction
var closers []io.Closer
// Build extism manifest for potential recompilation
pluginManifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{Data: info.wasmBytes, Name: "main"},
},
Config: pluginConfig,
Timeout: uint64(defaultTimeout.Milliseconds()),
}
if hosts := info.manifest.AllowedHosts(); len(hosts) > 0 {
pluginManifest.AllowedHosts = hosts
}
// Register host functions based on permissions using table-driven approach
svcCtx := &serviceContext{
pluginName: name,
manager: m,
permissions: info.manifest.Permissions,
}
for _, entry := range hostServices {
if entry.hasPermission(info.manifest.Permissions) {
funcs, closer := entry.create(svcCtx)
hostFunctions = append(hostFunctions, funcs...)
if closer != nil {
closers = append(closers, closer)
}
}
}
// Check if the plugin needs to be recompiled with real host functions
compiled := info.compiled
needsRecompile := len(pluginManifest.AllowedHosts) > 0 || len(hostFunctions) > 0
// Recompile if needed. It is actually not a "recompile" since the first compilation
// should be cached by wazero. We just need to do it this way to provide the real host functions.
if needsRecompile {
log.Trace(m.ctx, "Recompiling plugin with host functions", "plugin", name)
info.compiled.Close(m.ctx)
extismConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
}
compiled, err = extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
if err != nil {
return err
}
}
m.mu.Lock()
m.plugins[name] = &plugin{
name: name,
path: wasmPath,
manifest: info.manifest,
compiled: compiled,
capabilities: capabilities,
closers: closers,
}
m.mu.Unlock()
// Call plugin init function
callPluginInit(m.ctx, m.plugins[name])
return nil
}