mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-02 06:24:14 +00:00
368 lines
11 KiB
Go
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
|
|
}
|