refactor(plugins): clean up manifest struct and improve plugin loading logic

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan
2025-12-22 17:40:44 -05:00
parent 58b3eb4600
commit 158d917009
4 changed files with 45 additions and 61 deletions

View File

@@ -14,11 +14,10 @@ import (
)
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description"`
Capabilities []string `json:"capabilities"`
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description"`
}
type ArtistInput struct {
@@ -34,11 +33,10 @@ type BiographyOutput struct {
//go:wasmexport nd_manifest
func ndManifest() int32 {
manifest := Manifest{
Name: "Minimal Example",
Author: "Navidrome",
Version: "1.0.0",
Description: "A minimal example plugin",
Capabilities: []string{"MetadataAgent"},
Name: "Minimal Example",
Author: "Navidrome",
Version: "1.0.0",
Description: "A minimal example plugin",
}
out, err := json.Marshal(manifest)
if err != nil {

View File

@@ -282,7 +282,6 @@ func (m *Manager) discoverPlugins(folder string) error {
// loadPlugin loads a single plugin from a wasm file
func (m *Manager) loadPlugin(name, wasmPath string) error {
// Check if manager is stopped (cache may be closed)
if m.stopped.Load() {
return fmt.Errorf("manager is stopped")
}
@@ -296,81 +295,69 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
return fmt.Errorf("manager is stopped")
}
// Read wasm file
wasmBytes, err := os.ReadFile(wasmPath)
if err != nil {
return err
}
// Get plugin-specific config from conf.Server.PluginConfig
pluginConfig := m.getPluginConfig(name)
// Create Extism manifest for this plugin
// Note: We create a temporary plugin first to get the manifest,
// then we'll create the final one with proper AllowedHosts
tempManifest := extism.Manifest{
pluginManifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{
Data: wasmBytes,
Name: "main",
},
extism.WasmData{Data: wasmBytes, Name: "main"},
},
Config: pluginConfig,
Config: pluginConfig,
Timeout: uint64(defaultTimeout.Milliseconds()),
}
tempConfig := extism.PluginConfig{
extismConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
}
// Create temporary plugin to read manifest and detect capabilities
tempPlugin, err := extism.NewPlugin(m.ctx, tempManifest, tempConfig, nil)
// Create initial compiled plugin (without AllowedHosts)
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, nil)
if err != nil {
return err
}
defer tempPlugin.Close(m.ctx)
tempPlugin.SetLogger(extismLogger(name))
// Call nd_manifest to get plugin manifest
exit, manifestBytes, err := tempPlugin.Call(manifestFunction, nil)
// Create instance to read manifest and detect capabilities
instance, err := compiled.Instance(m.ctx, extism.PluginInstanceConfig{})
if err != nil {
compiled.Close(m.ctx)
return err
}
instance.SetLogger(extismLogger(name))
exit, manifestBytes, err := instance.Call(manifestFunction, nil)
if err != nil {
instance.Close(m.ctx)
compiled.Close(m.ctx)
return err
}
if exit != 0 {
instance.Close(m.ctx)
compiled.Close(m.ctx)
return fmt.Errorf("calling %s: %d", manifestFunction, exit)
}
// Parse manifest (validation happens during unmarshal via generated code)
var manifest Manifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
instance.Close(m.ctx)
compiled.Close(m.ctx)
return fmt.Errorf("invalid plugin manifest: %w", err)
}
// Detect capabilities based on exported functions
capabilities := detectCapabilities(tempPlugin)
// Detect capabilities using the instance before closing it
capabilities := detectCapabilities(instance)
instance.Close(m.ctx)
// Now create the final compiled plugin with proper AllowedHosts
finalManifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{
Data: wasmBytes,
Name: "main",
},
},
Config: pluginConfig,
AllowedHosts: manifest.AllowedHosts(),
Timeout: uint64(defaultTimeout.Milliseconds()),
}
log.Debug(m.ctx, "Loaded plugin", "plugin", name, "name", manifest.Name, "version", manifest.Version, "capabilities", capabilities)
finalConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
}
compiled, err := extism.NewCompiledPlugin(m.ctx, finalManifest, finalConfig, nil)
if err != nil {
return err
// Recompile only if plugin requires HTTP access (AllowedHosts)
if hosts := manifest.AllowedHosts(); len(hosts) > 0 {
compiled.Close(m.ctx)
pluginManifest.AllowedHosts = hosts
compiled, err = extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, nil)
if err != nil {
return err
}
}
m.mu.Lock()
@@ -382,7 +369,6 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
capabilities: capabilities,
}
m.mu.Unlock()
return nil
}
@@ -466,8 +452,6 @@ func (m *Manager) ReloadPlugin(name string) error {
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
}

View File

@@ -10,3 +10,6 @@ func (m *Manifest) AllowedHosts() []string {
}
return m.Permissions.Http.AllowedHosts
}
// TODO: ConfigPermission is defined in the schema but not currently enforced.
// Plugins always receive their config section. Implement permission checking or remove from schema.

View File

@@ -13,8 +13,7 @@ import (
// debounceDuration is the time to wait before acting on file events
// to handle multiple rapid events for the same file.
// This is a var (not const) to allow tests to use a shorter duration.
var debounceDuration = 500 * time.Millisecond
const debounceDuration = 500 * time.Millisecond
// startWatcher starts the file watcher for the plugins folder.
// It watches for CREATE, WRITE, and REMOVE events on .wasm files.