mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* feat(plugins): load plugin agents in CLI commands A CLI that goes through core/agents saw only built-in agents: getEnabledAgentNames asks Manager.PluginNames, which reads a map populated solely by Manager.Start, and only the server calls that. On a plugin-using install the CLI's agent list was quietly short — artwork explain --live could name deezer for an artist whose stored source was external:apple-music, because apple-music was invisible to it. Adds Manager.LoadPlugins, the read-only counterpart to Start: extism/wazero init plus loadEnabledPlugins, without the folder sync, the error clearing, the cache purge or the watcher. It follows what the read-only plugin commands already do — list, info and validate read the DB and never start the manager — except that capabilities are detected from the WASM exports, not declared in the manifest, so instantiating is the only accurate source for what a plugin provides. loadEnabledPlugins disabled a plugin and recorded LastError when a load failed. That is right for the server and wrong for a diagnostic, so it is now gated on the read-only flag: inspecting a plugin must not disable it. No Subsonic router is required. Start log.Fatals without one, but the host function it feeds already nil-checks and reports 'SubsonicAPI router not available' at call time, so a plugin that reaches for it gets an error instead of the process dying. That Fatal's message also claimed the DataStore was missing; it checks the router. * fix(cli): correct the reprocess estimate's plugin caveat imageAgentCount now receives a manager with plugins loaded, so the external estimate already includes plugin image agents — but the disclaimer still said they were not counted, which told operators the opposite of what the number meant. Replaced rather than dropped: loadPluginAgents warns and continues when LoadPlugins fails, and LoadPlugins is a no-op when plugins are disabled or no folder is set, so there are still runs where plugin agents genuinely are not counted. The wording now covers all three cases, and the stale comment above it said the CLI never starts the plugin manager, which is what this branch changed. Found by Codex on 648cf38e9. * fix(plugins): load only the configured agents, and gate plugin init Loading a plugin is not free: the service constructors create a KVStore or TaskQueue database and a Storage directory for any plugin whose manifest declares those permissions, and the plugin's own init then runs arbitrary code. loadEnabledPlugins loads every enabled plugin, so inspecting artwork was starting scrobblers, schedulers and lyrics plugins that could never supply an image. Measured on a copy of a production library: 'artwork explain' created apple-music/kvstore.db, nd-lyrics/kvstore.db and listenbrainz-daily-playlist/taskqueue.db. The last one matters most — CreateQueue resets rows with status='running' to 'pending', which against a live server sets up its in-flight tasks to run twice. LoadPlugins now takes the names to load, and the artwork CLI passes the Agents list: a plugin that is not a configured agent can never win, so there is nothing to gain by instantiating it. The same two runs now create only apple-music, which is a configured agent and therefore the cost of answering the question. Init is gated separately on the caller's intent rather than on read-only. 'explain --live' already means 'reach the provider', so it runs init; plain 'explain' and 'reprocess' promise no external requests and must not. Documented in the --live flag help. Found by Codex on 28eb39ac4. * perf(cli): load plugin agents only when the selection can consult one Explaining disc or media file artwork loaded every configured metadata plugin, though neither resolver ever reaches an agent: resolveMediaFile is embedded-only and discArtworkReader.selectImage refuses external outright. With --live that also ran plugin init for a walk that provably cannot reach the network. The load now sits inside the artist/album branch that already exists, so it is a move rather than a new condition. reprocess did the same for a radio-only selection, whose estimate is unconditionally zero. It is gated on needsImageAgents, which asks exactly what ExternalLookupsPerItem asks, so the two cannot disagree. An artist/album test would look equivalent and would silently zero the playlist estimate, whose generated grid resolves album art through those agents; a test pins that, and reverting the predicate to a whitelist fails it. Found by Codex on b78e67b07. * docs(artwork): trim the comments this branch added to the project budget Six blocks ran past the one-to-two line limit. The LoadPlugins doc was eleven lines over three paragraphs, needsImageAgents spent two of its four explaining an alternative that was rejected, and inspectOpts restated what LoadPlugins already says. What went is reviewer-facing prose that belongs in a commit message: the enumeration of what Start does that this skips, and why an artist/album predicate would have been wrong. What stayed is the reasoning a future reader needs at that line, notably that instantiating a plugin creates its declared services, and that playlists consume the album agent count. * docs(artwork): correct the breaker comment after the recovery ramp It still said a success re-closes the breaker, which stopped being true when closing started requiring breakerRecoveries consecutive answers. * refactor(plugins): rename the scoped-load options to transientLoad inspect claimed the load was only looking, which is false when runInit is true: it instantiates the plugin and runs its init, which may open sockets. transient is accurate for every use of the field, and explains all three behaviours it gates. A load that will not outlive the command has no business persisting findings, instantiating plugins it will never consult, or starting background work it is about to tear down.
514 lines
17 KiB
Go
514 lines
17 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"time"
|
|
|
|
extism "github.com/extism/go-sdk"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"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
|
|
config map[string]string
|
|
allowedUsers []string // User IDs this plugin can access
|
|
allUsers bool // If true, plugin can access all users
|
|
allowedLibraries []int // Library IDs this plugin can access
|
|
allLibraries bool // If true, plugin can access all libraries
|
|
}
|
|
|
|
// baseCtx returns the manager's lifecycle context, for host services that
|
|
// outlive the plugin call that created them. It falls back to
|
|
// context.Background() when the manager was never started, which is the case
|
|
// for CLI commands (e.g. `navidrome plugin enable`) that load plugins without
|
|
// calling Start.
|
|
func (c *serviceContext) baseCtx() context.Context {
|
|
if c.manager.ctx == nil {
|
|
return context.Background()
|
|
}
|
|
return c.manager.ctx
|
|
}
|
|
|
|
// hostServiceEntry defines a host service for table-driven registration.
|
|
type hostServiceEntry struct {
|
|
name string
|
|
hasPermission func(*Permissions) bool
|
|
create func(*serviceContext) ([]extism.HostFunction, io.Closer, error)
|
|
}
|
|
|
|
// hostServices defines all available host services.
|
|
// Adding a new host service only requires adding an entry here.
|
|
var hostServices = []hostServiceEntry{
|
|
{
|
|
name: "Config",
|
|
hasPermission: func(p *Permissions) bool { return true }, // Always available, no permission required
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
service := newConfigService(ctx.pluginName, ctx.config)
|
|
return host.RegisterConfigHostFunctions(service), nil, nil
|
|
},
|
|
},
|
|
{
|
|
name: "SubsonicAPI",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers))
|
|
return host.RegisterSubsonicAPIHostFunctions(service), nil, nil
|
|
},
|
|
},
|
|
{
|
|
name: "Scheduler",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Scheduler != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
service := newSchedulerService(ctx.pluginName, ctx.manager, scheduler.GetInstance())
|
|
return host.RegisterSchedulerHostFunctions(service), service, nil
|
|
},
|
|
},
|
|
{
|
|
name: "WebSocket",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Websocket != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
perm := ctx.permissions.Websocket
|
|
service := newWebSocketService(ctx.baseCtx(), ctx.pluginName, ctx.manager, perm)
|
|
return host.RegisterWebSocketHostFunctions(service), service, nil
|
|
},
|
|
},
|
|
{
|
|
name: "Artwork",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Artwork != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
service := newArtworkService()
|
|
return host.RegisterArtworkHostFunctions(service), nil, nil
|
|
},
|
|
},
|
|
{
|
|
name: "Cache",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Cache != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
service := newCacheService(ctx.pluginName)
|
|
return host.RegisterCacheHostFunctions(service), service, nil
|
|
},
|
|
},
|
|
{
|
|
name: "Library",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
perm := ctx.permissions.Library
|
|
service := newLibraryService(ctx.manager.ds, perm, ctx.allowedLibraries, ctx.allLibraries)
|
|
return host.RegisterLibraryHostFunctions(service), nil, nil
|
|
},
|
|
},
|
|
{
|
|
name: "KVStore",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
perm := ctx.permissions.Kvstore
|
|
service, err := newKVStoreService(ctx.baseCtx(), ctx.pluginName, perm)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return host.RegisterKVStoreHostFunctions(service), service, nil
|
|
},
|
|
},
|
|
{
|
|
name: "Users",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
|
|
return host.RegisterUsersHostFunctions(service), nil, nil
|
|
},
|
|
},
|
|
{
|
|
name: "Matcher",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Matcher != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
hasFilesystemPerm := ctx.permissions.Library != nil && ctx.permissions.Library.Filesystem
|
|
service := newMatcherService(
|
|
ctx.manager.ds, hasFilesystemPerm,
|
|
newUserAccess(ctx.allowedUsers, ctx.allUsers),
|
|
newLibraryAccess(ctx.allowedLibraries, ctx.allLibraries),
|
|
)
|
|
return host.RegisterMatcherHostFunctions(service), nil, nil
|
|
},
|
|
},
|
|
{
|
|
name: "HTTP",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
perm := ctx.permissions.Http
|
|
service := newHTTPService(ctx.pluginName, perm)
|
|
return host.RegisterHTTPHostFunctions(service), nil, nil
|
|
},
|
|
},
|
|
{
|
|
name: "Task",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Taskqueue != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
perm := ctx.permissions.Taskqueue
|
|
maxConcurrency := int32(1)
|
|
if perm.MaxConcurrency > 0 {
|
|
maxConcurrency = int32(perm.MaxConcurrency)
|
|
}
|
|
service, err := newTaskQueueService(ctx.baseCtx(), ctx.pluginName, ctx.manager, maxConcurrency)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return host.RegisterTaskHostFunctions(service), service, nil
|
|
},
|
|
},
|
|
{
|
|
name: "Storage",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Storage != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
service, err := newStorageService(ctx.pluginName)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return host.RegisterStorageHostFunctions(service), nil, nil
|
|
},
|
|
},
|
|
{
|
|
name: "ScrobbleRetriever",
|
|
hasPermission: func(p *Permissions) bool { return p != nil && p.ScrobbleRetriever != nil },
|
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
|
|
service := newScrobbleRetrieverService(ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers))
|
|
return host.RegisterScrobbleRetrieverHostFunctions(service), nil, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
// extractManifest reads manifest from an .ndp package and computes its SHA-256 hash.
|
|
// This is a lightweight operation used for plugin discovery and change detection.
|
|
// Unlike the old implementation, this does NOT compile the wasm - just reads the manifest JSON.
|
|
func (m *Manager) extractManifest(ndpPath string) (*PluginMetadata, error) {
|
|
if m.stopped.Load() {
|
|
return nil, fmt.Errorf("manager is stopped")
|
|
}
|
|
|
|
manifest, err := ReadManifest(ndpPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sha256Hash, err := ComputeFileSHA256(ndpPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("computing hash: %w", err)
|
|
}
|
|
|
|
return &PluginMetadata{
|
|
Manifest: manifest,
|
|
SHA256: sha256Hash,
|
|
}, 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
|
|
}
|
|
// Instantiating a plugin creates its host services, so a transient load takes only the
|
|
// ones it may actually consult.
|
|
if m.transient != nil && !slices.Contains(m.transient.only, p.ID) {
|
|
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); err != nil {
|
|
// A transient load must not disable the user's plugin just for looking at it.
|
|
if m.transient == nil {
|
|
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 != "" && m.transient == nil {
|
|
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.
|
|
// The p.Path should point to an .ndp package file.
|
|
func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
|
// NewContext falls back to context.Background() when m.ctx is nil (unstarted manager)
|
|
ctx := log.NewContext(m.ctx, "plugin", p.ID)
|
|
|
|
if m.stopped.Load() {
|
|
return fmt.Errorf("manager is stopped")
|
|
}
|
|
|
|
if !validPluginID(p.ID) {
|
|
return fmt.Errorf("invalid plugin ID %q", p.ID)
|
|
}
|
|
|
|
// 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
|
|
pluginConfig, err := parsePluginConfig(p.Config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse users from JSON
|
|
var allowedUsers []string
|
|
if p.Users != "" {
|
|
if err := json.Unmarshal([]byte(p.Users), &allowedUsers); err != nil {
|
|
return fmt.Errorf("parsing plugin users: %w", err)
|
|
}
|
|
}
|
|
|
|
// Parse libraries from JSON
|
|
var allowedLibraries []int
|
|
if p.Libraries != "" {
|
|
if err := json.Unmarshal([]byte(p.Libraries), &allowedLibraries); err != nil {
|
|
return fmt.Errorf("parsing plugin libraries: %w", err)
|
|
}
|
|
}
|
|
|
|
// Open the .ndp package to get manifest and wasm bytes
|
|
pkg, err := openPackage(p.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("opening package: %w", err)
|
|
}
|
|
|
|
pluginManifest := buildExtismManifest(pkg, pluginConfig)
|
|
|
|
// Configure filesystem access for library permission, applied per instance
|
|
var fsConfig wazero.FSConfig
|
|
if pkg.Manifest.HasLibraryFilesystemPermission() || pkg.Manifest.HasStoragePermission() {
|
|
mounts := []mount{}
|
|
|
|
if pkg.Manifest.HasLibraryFilesystemPermission() {
|
|
adminCtx := adminContext(ctx)
|
|
libraries, err := m.ds.Library(adminCtx).GetAll()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
|
|
}
|
|
mounts = buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
|
|
}
|
|
|
|
if pkg.Manifest.HasStoragePermission() {
|
|
pluginStore := getHostStoragePath(p.ID)
|
|
log.Info(ctx, "Granting read-write filesystem access to plugin storage", "path", pluginStore, "id", p.ID)
|
|
|
|
mounts = append(mounts, mount{
|
|
hostPath: pluginStore,
|
|
guestPath: storageMount,
|
|
})
|
|
}
|
|
|
|
fsConfig = buildFSConfig(mounts)
|
|
}
|
|
|
|
// Build host functions based on permissions from manifest
|
|
var hostFunctions []extism.HostFunction
|
|
var closers []io.Closer
|
|
loaded := false
|
|
// On success the closers are owned by the registered plugin; on any
|
|
// failure past this point, close them so partially-created services
|
|
// don't leak goroutines or file handles.
|
|
defer func() {
|
|
if !loaded {
|
|
closeAll(closers)
|
|
}
|
|
}()
|
|
|
|
svcCtx := &serviceContext{
|
|
pluginName: p.ID,
|
|
manager: m,
|
|
permissions: pkg.Manifest.Permissions,
|
|
config: pluginConfig,
|
|
allowedUsers: allowedUsers,
|
|
allUsers: p.AllUsers,
|
|
allowedLibraries: allowedLibraries,
|
|
allLibraries: p.AllLibraries,
|
|
}
|
|
for _, entry := range hostServices {
|
|
if entry.hasPermission(pkg.Manifest.Permissions) {
|
|
funcs, closer, err := entry.create(svcCtx)
|
|
if err != nil {
|
|
return fmt.Errorf("creating %s service: %w", entry.name, err)
|
|
}
|
|
hostFunctions = append(hostFunctions, funcs...)
|
|
if closer != nil {
|
|
closers = append(closers, closer)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compile the plugin with all host functions
|
|
runtimeConfig := wazero.NewRuntimeConfig().
|
|
WithCompilationCache(m.cache).
|
|
WithCloseOnContextDone(true)
|
|
|
|
extismConfig := extism.PluginConfig{
|
|
EnableWasi: true,
|
|
RuntimeConfig: runtimeConfig,
|
|
EnableHttpResponseHeaders: true,
|
|
}
|
|
compiled, err := extism.NewCompiledPlugin(ctx, pluginManifest, extismConfig, hostFunctions)
|
|
if err != nil {
|
|
return fmt.Errorf("compiling plugin: %w", err)
|
|
}
|
|
|
|
// Create instance to detect capabilities
|
|
instance, err := compiled.Instance(ctx, instanceConfig(fsConfig))
|
|
if err != nil {
|
|
compiled.Close(ctx)
|
|
return fmt.Errorf("creating instance: %w", err)
|
|
}
|
|
instance.SetLogger(extismLogger(p.ID))
|
|
capabilities := detectCapabilities(instance)
|
|
instance.Close(ctx)
|
|
|
|
// Validate manifest against detected capabilities
|
|
if err := ValidateWithCapabilities(pkg.Manifest, capabilities); err != nil {
|
|
compiled.Close(ctx)
|
|
return fmt.Errorf("manifest validation: %w", err)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.plugins[p.ID] = &plugin{
|
|
name: p.ID,
|
|
path: p.Path,
|
|
manifest: pkg.Manifest,
|
|
compiled: compiled,
|
|
capabilities: capabilities,
|
|
closers: closers,
|
|
metrics: m.metrics,
|
|
allowedUserIDs: allowedUsers,
|
|
allUsers: p.AllUsers,
|
|
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
|
|
fsConfig: fsConfig,
|
|
lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls),
|
|
}
|
|
m.mu.Unlock()
|
|
loaded = true
|
|
|
|
// Init is the plugin's first chance to run arbitrary code: open sockets, create task queues,
|
|
// schedule work. Only a caller that already intends to reach the network asks for it.
|
|
if m.transient == nil || m.transient.runInit {
|
|
callPluginInit(ctx, m.plugins[p.ID])
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// closeAll closes host service closers accumulated before a load failure,
|
|
// so partially-created services don't leak goroutines or file handles.
|
|
func closeAll(closers []io.Closer) {
|
|
for _, c := range closers {
|
|
_ = c.Close()
|
|
}
|
|
}
|
|
|
|
// parsePluginConfig parses a JSON config string into a map of string values.
|
|
// For Extism, all config values must be strings, so non-string values are serialized as JSON.
|
|
func parsePluginConfig(configJSON string) (map[string]string, error) {
|
|
if configJSON == "" {
|
|
return nil, nil
|
|
}
|
|
var rawConfig map[string]any
|
|
if err := json.Unmarshal([]byte(configJSON), &rawConfig); err != nil {
|
|
return nil, fmt.Errorf("parsing plugin config: %w", err)
|
|
}
|
|
pluginConfig := make(map[string]string)
|
|
for key, value := range rawConfig {
|
|
switch v := value.(type) {
|
|
case string:
|
|
pluginConfig[key] = v
|
|
default:
|
|
// Serialize non-string values as JSON
|
|
jsonBytes, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serializing config value %q: %w", key, err)
|
|
}
|
|
pluginConfig[key] = string(jsonBytes)
|
|
}
|
|
}
|
|
return pluginConfig, nil
|
|
}
|
|
|
|
// buildExtismManifest describes the plugin to extism. It must never set
|
|
// AllowedPaths: extism would replace our jailed FSConfig with plain dir mounts.
|
|
func buildExtismManifest(pkg *ndpPackage, pluginConfig map[string]string) extism.Manifest {
|
|
manifest := extism.Manifest{
|
|
Wasm: []extism.Wasm{extism.WasmData{Data: pkg.WasmBytes, Name: "main"}},
|
|
Config: pluginConfig,
|
|
Timeout: uint64(defaultTimeout.Milliseconds()),
|
|
}
|
|
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil {
|
|
if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 {
|
|
manifest.AllowedHosts = hosts
|
|
}
|
|
}
|
|
return manifest
|
|
}
|