You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
helm/internal/plugin/runtime_extismv1.go

293 lines
8.5 KiB

/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package plugin
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"reflect"
extism "github.com/extism/go-sdk"
"github.com/tetratelabs/wazero"
)
const ExtismV1WasmBinaryFilename = "plugin.wasm"
// RuntimeConfigExtismV1Memory exposes the Wasm/Extism memory options for the plugin
type RuntimeConfigExtismV1Memory struct {
// The max amount of pages the plugin can allocate
// One page is 64Kib. e.g. 16 pages would require 1MiB.
// Default is 4 pages (256KiB)
MaxPages uint32 `yaml:"maxPages,omitempty"`
// The max size of an Extism HTTP response in bytes
// Default is 4096 bytes (4KiB)
MaxHTTPResponseBytes int64 `yaml:"maxHttpResponseBytes,omitempty"`
// The max size of all Extism vars in bytes
// Default is 4096 bytes (4KiB)
MaxVarBytes int64 `yaml:"maxVarBytes,omitempty"`
}
// RuntimeConfigExtismV1FileSystem exposes filesystem options for the configuration
// TODO: should Helm expose AllowedPaths?
type RuntimeConfigExtismV1FileSystem struct {
// If specified, a temporary directory will be created and mapped to /tmp in the plugin's filesystem.
// Data written to the directory will be visible on the host filesystem.
// The directory will be removed when the plugin invocation completes.
CreateTempDir bool `yaml:"createTempDir,omitempty"`
}
// RuntimeConfigExtismV1 defines the user-configurable options the plugin's Extism runtime
// The format loosely follows the Extism Manifest format: https://extism.org/docs/concepts/manifest/
type RuntimeConfigExtismV1 struct {
// Describes the limits on the memory the plugin may be allocated.
Memory RuntimeConfigExtismV1Memory `yaml:"memory"`
// The "config" key is a free-form map that can be passed to the plugin.
// The plugin must interpret arbitrary data this map may contain
Config map[string]string `yaml:"config,omitempty"`
// An optional set of hosts this plugin can communicate with.
// This only has an effect if the plugin makes HTTP requests.
// If not specified, then no hosts are allowed.
AllowedHosts []string `yaml:"allowedHosts,omitempty"`
FileSystem RuntimeConfigExtismV1FileSystem `yaml:"fileSystem,omitempty"`
// The timeout in milliseconds for the plugin to execute
Timeout uint64 `yaml:"timeout,omitempty"`
// HostFunction names exposed in Helm the plugin may access
// see: https://extism.org/docs/concepts/host-functions/
HostFunctions []string `yaml:"hostFunctions,omitempty"`
// The name of entry function name to call in the plugin
// Defaults to "helm_plugin_main".
EntryFuncName string `yaml:"entryFuncName,omitempty"`
}
var _ RuntimeConfig = (*RuntimeConfigExtismV1)(nil)
func (r *RuntimeConfigExtismV1) Validate() error {
// TODO
return nil
}
type RuntimeExtismV1 struct {
HostFunctions map[string]extism.HostFunction
CompilationCache wazero.CompilationCache
}
var _ Runtime = (*RuntimeExtismV1)(nil)
func (r *RuntimeExtismV1) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) {
rc, ok := metadata.RuntimeConfig.(*RuntimeConfigExtismV1)
if !ok {
return nil, fmt.Errorf("invalid extism/v1 plugin runtime config type: %T", metadata.RuntimeConfig)
}
wasmFile := filepath.Join(pluginDir, ExtismV1WasmBinaryFilename)
if _, err := os.Stat(wasmFile); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("wasm binary missing for extism/v1 plugin: %q", wasmFile)
}
return nil, fmt.Errorf("failed to stat extism/v1 plugin wasm binary %q: %w", wasmFile, err)
}
return &ExtismV1PluginRuntime{
metadata: *metadata,
dir: pluginDir,
rc: rc,
r: r,
}, nil
}
type ExtismV1PluginRuntime struct {
metadata Metadata
dir string
rc *RuntimeConfigExtismV1
r *RuntimeExtismV1
}
var _ Plugin = (*ExtismV1PluginRuntime)(nil)
func (p *ExtismV1PluginRuntime) Metadata() Metadata {
return p.metadata
}
func (p *ExtismV1PluginRuntime) Dir() string {
return p.dir
}
func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Output, error) {
var tmpDir string
if p.rc.FileSystem.CreateTempDir {
tmpDirInner, err := os.MkdirTemp(os.TempDir(), "helm-plugin-*")
slog.Debug("created plugin temp dir", slog.String("dir", tmpDirInner), slog.String("plugin", p.metadata.Name))
if err != nil {
return nil, fmt.Errorf("failed to create temp dir for extism compilation cache: %w", err)
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
slog.Warn("failed to remove plugin temp dir", slog.String("dir", tmpDir), slog.String("plugin", p.metadata.Name), slog.String("error", err.Error()))
}
}()
tmpDir = tmpDirInner
}
manifest, err := buildManifest(p.dir, tmpDir, p.rc)
if err != nil {
return nil, err
}
config := buildPluginConfig(input, p.r)
hostFunctions, err := buildHostFunctions(p.r.HostFunctions, p.rc)
if err != nil {
return nil, err
}
pe, err := extism.NewPlugin(ctx, manifest, config, hostFunctions)
if err != nil {
return nil, fmt.Errorf("failed to create existing plugin: %w", err)
}
pe.SetLogger(func(logLevel extism.LogLevel, s string) {
slog.Debug(s, slog.String("level", logLevel.String()), slog.String("plugin", p.metadata.Name))
})
inputData, err := json.Marshal(input.Message)
if err != nil {
return nil, fmt.Errorf("failed to json marshal plugin input message: %T: %w", input.Message, err)
}
slog.Debug("plugin input", slog.String("plugin", p.metadata.Name), slog.String("inputData", string(inputData)))
entryFuncName := p.rc.EntryFuncName
if entryFuncName == "" {
entryFuncName = "helm_plugin_main"
}
exitCode, outputData, err := pe.Call(entryFuncName, inputData)
if err != nil {
return nil, fmt.Errorf("plugin error: %w", err)
}
if exitCode != 0 {
return nil, &InvokeExecError{
Code: int(exitCode),
}
}
slog.Debug("plugin output", slog.String("plugin", p.metadata.Name), slog.Int("exitCode", int(exitCode)), slog.String("outputData", string(outputData)))
outputMessage := reflect.New(pluginTypesIndex[p.metadata.Type].outputType)
if err := json.Unmarshal(outputData, outputMessage.Interface()); err != nil {
return nil, fmt.Errorf("failed to json marshal plugin output message: %T: %w", outputMessage, err)
}
output := &Output{
Message: outputMessage.Elem().Interface(),
}
return output, nil
}
func buildManifest(pluginDir string, tmpDir string, rc *RuntimeConfigExtismV1) (extism.Manifest, error) {
wasmFile := filepath.Join(pluginDir, ExtismV1WasmBinaryFilename)
allowedHosts := rc.AllowedHosts
if allowedHosts == nil {
allowedHosts = []string{}
}
allowedPaths := map[string]string{}
if tmpDir != "" {
allowedPaths[tmpDir] = "/tmp"
}
return extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{
Path: wasmFile,
Name: wasmFile,
},
},
Memory: &extism.ManifestMemory{
MaxPages: rc.Memory.MaxPages,
MaxHttpResponseBytes: rc.Memory.MaxHTTPResponseBytes,
MaxVarBytes: rc.Memory.MaxVarBytes,
},
Config: rc.Config,
AllowedHosts: allowedHosts,
AllowedPaths: allowedPaths,
Timeout: rc.Timeout,
}, nil
}
func buildPluginConfig(input *Input, r *RuntimeExtismV1) extism.PluginConfig {
mc := wazero.NewModuleConfig().
WithSysWalltime()
if input.Stdin != nil {
mc = mc.WithStdin(input.Stdin)
}
if input.Stdout != nil {
mc = mc.WithStdout(input.Stdout)
}
if input.Stderr != nil {
mc = mc.WithStderr(input.Stderr)
}
if len(input.Env) > 0 {
env := parseEnv(input.Env)
for k, v := range env {
mc = mc.WithEnv(k, v)
}
}
config := extism.PluginConfig{
ModuleConfig: mc,
RuntimeConfig: wazero.NewRuntimeConfigCompiler().
WithCloseOnContextDone(true).
WithCompilationCache(r.CompilationCache),
EnableWasi: true,
EnableHttpResponseHeaders: true,
}
return config
}
func buildHostFunctions(hostFunctions map[string]extism.HostFunction, rc *RuntimeConfigExtismV1) ([]extism.HostFunction, error) {
result := make([]extism.HostFunction, len(rc.HostFunctions))
for _, fnName := range rc.HostFunctions {
fn, ok := hostFunctions[fnName]
if !ok {
return nil, fmt.Errorf("plugin requested host function %q not found", fnName)
}
result = append(result, fn)
}
return result, nil
}