Merge pull request #31194 from gjenkins8/gjenkins/plugin-integration/wasm_runtime

[HIP-0026] Plugin extism/v1 runtime
pull/31210/head
George Jenkins 6 days ago committed by GitHub
commit 892e86182f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -14,6 +14,7 @@ require (
github.com/cyphar/filepath-securejoin v0.4.1
github.com/distribution/distribution/v3 v3.0.0
github.com/evanphx/json-patch/v5 v5.9.11
github.com/extism/go-sdk v1.7.1
github.com/fatih/color v1.18.0
github.com/fluxcd/cli-utils v0.36.0-flux.14
github.com/foxcpp/go-mockdns v1.1.0
@ -25,13 +26,14 @@ require (
github.com/mattn/go-shellwords v1.0.12
github.com/mitchellh/copystructure v1.2.0
github.com/moby/term v0.5.2
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/rubenv/sql-migrate v1.8.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.7
github.com/stretchr/testify v1.11.0
github.com/tetratelabs/wazero v1.9.0
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/crypto v0.41.0
golang.org/x/term v0.34.0
@ -71,6 +73,7 @@ require (
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@ -95,6 +98,7 @@ require (
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@ -130,6 +134,7 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect

@ -77,12 +77,16 @@ github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@ -164,6 +168,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvH
github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw=
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
@ -311,6 +317,10 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=

@ -23,7 +23,6 @@ import (
// Config interface defines the methods that all plugin type configurations must implement
type Config interface {
GetType() string
Validate() error
}

@ -22,7 +22,11 @@ import (
"os"
"path/filepath"
extism "github.com/extism/go-sdk"
"github.com/tetratelabs/wazero"
"go.yaml.in/yaml/v3"
"helm.sh/helm/v4/pkg/helmpath"
)
func peekAPIVersion(r io.Reader) (string, error) {
@ -101,12 +105,22 @@ type prototypePluginManager struct {
runtimes map[string]Runtime
}
func newPrototypePluginManager() *prototypePluginManager {
func newPrototypePluginManager() (*prototypePluginManager, error) {
cc, err := wazero.NewCompilationCacheWithDir(helmpath.CachePath("wazero-build"))
if err != nil {
return nil, fmt.Errorf("failed to create wazero compilation cache: %w", err)
}
return &prototypePluginManager{
runtimes: map[string]Runtime{
"subprocess": &RuntimeSubprocess{},
"extism/v1": &RuntimeExtismV1{
HostFunctions: map[string]extism.HostFunction{},
CompilationCache: cc,
},
},
}
}, nil
}
func (pm *prototypePluginManager) RegisterRuntime(runtimeName string, runtime Runtime) {
@ -135,7 +149,10 @@ func LoadDir(dirname string) (Plugin, error) {
return nil, fmt.Errorf("failed to load plugin %q: %w", dirname, err)
}
pm := newPrototypePluginManager()
pm, err := newPrototypePluginManager()
if err != nil {
return nil, fmt.Errorf("failed to create plugin manager: %w", err)
}
return pm.CreatePlugin(dirname, m)
}

@ -18,6 +18,8 @@ package plugin
import (
"errors"
"fmt"
"helm.sh/helm/v4/internal/plugin/schema"
)
// Metadata of a plugin, converted from the "on-disk" legacy or v1 plugin.yaml
@ -183,6 +185,8 @@ func convertMetadataConfig(pluginType string, configRaw map[string]any) (Config,
var config Config
switch pluginType {
case "test/v1":
config, err = remarshalConfig[*schema.ConfigTestV1](configRaw)
case "cli/v1":
config, err = remarshalConfig[*ConfigCLI](configRaw)
case "getter/v1":
@ -205,6 +209,8 @@ func convertMetdataRuntimeConfig(runtimeType string, runtimeConfigRaw map[string
switch runtimeType {
case "subprocess":
runtimeConfig, err = remarshalRuntimeConfig[*RuntimeConfigSubprocess](runtimeConfigRaw)
case "extism/v1":
runtimeConfig, err = remarshalRuntimeConfig[*RuntimeConfigExtismV1](runtimeConfigRaw)
default:
return nil, fmt.Errorf("unsupported plugin runtime type: %q", runtimeType)
}

@ -0,0 +1,100 @@
/*
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.
*/
/*
This file contains a "registry" of supported plugin types.
It enables "dyanmic" operations on the go type associated with a given plugin type (see: `helm.sh/helm/v4/internal/plugin/schema` package)
Examples:
```
// Create a new instance of the output message type for a given plugin type:
pluginType := "cli/v1" // for example
ptm, ok := pluginTypesIndex[pluginType]
if !ok {
return fmt.Errorf("unknown plugin type %q", pluginType)
}
outputMessageType := reflect.Zero(ptm.outputType).Interface()
```
```
// Create a new instance of the config type for a given plugin type
pluginType := "cli/v1" // for example
ptm, ok := pluginTypesIndex[pluginType]
if !ok {
return nil
}
config := reflect.New(ptm.configType).Interface().(Config) // `config` is variable of type `Config`, with
// validate
err := config.Validate()
if err != nil { // handle error }
// assert to concrete type if needed
cliConfig := config.(*schema.ConfigCLIV1)
```
*/
package plugin
import (
"reflect"
"helm.sh/helm/v4/internal/plugin/schema"
)
type pluginTypeMeta struct {
pluginType string
inputType reflect.Type
outputType reflect.Type
configType reflect.Type
}
var pluginTypes = []pluginTypeMeta{
{
pluginType: "test/v1",
inputType: reflect.TypeOf(schema.InputMessageTestV1{}),
outputType: reflect.TypeOf(schema.OutputMessageTestV1{}),
configType: reflect.TypeOf(schema.ConfigTestV1{}),
},
{
pluginType: "cli/v1",
inputType: reflect.TypeOf(schema.InputMessageCLIV1{}),
outputType: reflect.TypeOf(schema.OutputMessageCLIV1{}),
configType: reflect.TypeOf(ConfigCLI{}),
},
{
pluginType: "getter/v1",
inputType: reflect.TypeOf(schema.InputMessageGetterV1{}),
outputType: reflect.TypeOf(schema.OutputMessageGetterV1{}),
configType: reflect.TypeOf(ConfigGetter{}),
},
}
var pluginTypesIndex = func() map[string]*pluginTypeMeta {
result := make(map[string]*pluginTypeMeta, len(pluginTypes))
for _, m := range pluginTypes {
result[m.pluginType] = &m
}
return result
}()

@ -0,0 +1,38 @@
/*
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 (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v4/internal/plugin/schema"
)
func TestMakeOutputMessage(t *testing.T) {
ptm := pluginTypesIndex["getter/v1"]
outputType := reflect.Zero(ptm.outputType).Interface()
assert.IsType(t, schema.OutputMessageGetterV1{}, outputType)
}
func TestMakeConfig(t *testing.T) {
ptm := pluginTypesIndex["getter/v1"]
config := reflect.New(ptm.configType).Interface().(Config)
assert.IsType(t, &ConfigGetter{}, config)
}

@ -15,7 +15,11 @@ limitations under the License.
package plugin
import "go.yaml.in/yaml/v3"
import (
"strings"
"go.yaml.in/yaml/v3"
)
// Runtime represents a plugin runtime (subprocess, extism, etc) ie. how a plugin should be executed
// Runtime is responsible for instantiating plugins that implement the runtime
@ -47,3 +51,25 @@ func remarshalRuntimeConfig[T RuntimeConfig](runtimeData map[string]any) (Runtim
return config, nil
}
// parseEnv takes a list of "KEY=value" environment variable strings
// and transforms the result into a map[KEY]=value
//
// - empty input strings are ignored
// - input strings with no value are stored as empty strings
// - duplicate keys overwrite earlier values
func parseEnv(env []string) map[string]string {
result := make(map[string]string, len(env))
for _, envVar := range env {
parts := strings.SplitN(envVar, "=", 2)
if len(parts) > 0 && parts[0] != "" {
key := parts[0]
var value string
if len(parts) > 1 {
value = parts[1]
}
result[key] = value
}
}
return result
}

@ -0,0 +1,292 @@
/*
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
}

@ -0,0 +1,124 @@
/*
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 (
"os"
"os/exec"
"path/filepath"
"testing"
extism "github.com/extism/go-sdk"
"helm.sh/helm/v4/internal/plugin/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type pluginRaw struct {
Metadata Metadata
Dir string
}
func buildLoadExtismPlugin(t *testing.T, dir string) pluginRaw {
t.Helper()
pluginFile := filepath.Join(dir, PluginFileName)
metadataData, err := os.ReadFile(pluginFile)
require.NoError(t, err)
m, err := loadMetadata(metadataData)
require.NoError(t, err)
require.Equal(t, "extism/v1", m.Runtime, "expected plugin runtime to be extism/v1")
cmd := exec.Command("make", "-C", dir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
require.NoError(t, cmd.Run(), "failed to build plugin in %q", dir)
return pluginRaw{
Metadata: *m,
Dir: dir,
}
}
func TestRuntimeConfigExtismV1Validate(t *testing.T) {
rc := RuntimeConfigExtismV1{}
err := rc.Validate()
assert.NoError(t, err, "expected no error for empty RuntimeConfigExtismV1")
}
func TestRuntimeExtismV1InvokePlugin(t *testing.T) {
r := RuntimeExtismV1{}
pr := buildLoadExtismPlugin(t, "testdata/src/extismv1-test")
require.Equal(t, "test/v1", pr.Metadata.Type)
p, err := r.CreatePlugin(pr.Dir, &pr.Metadata)
assert.NoError(t, err, "expected no error creating plugin")
assert.NotNil(t, p, "expected plugin to be created")
output, err := p.Invoke(t.Context(), &Input{
Message: schema.InputMessageTestV1{
Name: "Phippy",
},
})
require.Nil(t, err)
msg := output.Message.(schema.OutputMessageTestV1)
assert.Equal(t, "Hello, Phippy! (6)", msg.Greeting)
}
func TestBuildManifest(t *testing.T) {
rc := &RuntimeConfigExtismV1{
Memory: RuntimeConfigExtismV1Memory{
MaxPages: 8,
MaxHTTPResponseBytes: 81920,
MaxVarBytes: 8192,
},
FileSystem: RuntimeConfigExtismV1FileSystem{
CreateTempDir: true,
},
Config: map[string]string{"CONFIG_KEY": "config_value"},
AllowedHosts: []string{"example.com", "api.example.com"},
Timeout: 5000,
}
expected := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{
Path: "/path/to/plugin/plugin.wasm",
Name: "/path/to/plugin/plugin.wasm",
},
},
Memory: &extism.ManifestMemory{
MaxPages: 8,
MaxHttpResponseBytes: 81920,
MaxVarBytes: 8192,
},
Config: map[string]string{"CONFIG_KEY": "config_value"},
AllowedHosts: []string{"example.com", "api.example.com"},
AllowedPaths: map[string]string{"/tmp/foo": "/tmp"},
Timeout: 5000,
}
manifest, err := buildManifest("/path/to/plugin", "/tmp/foo", rc)
require.NoError(t, err)
assert.Equal(t, expected, manifest)
}

@ -212,7 +212,7 @@ func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) {
}
return &Output{
Message: &schema.OutputMessageCLIV1{},
Message: schema.OutputMessageCLIV1{},
}, nil
}

@ -85,7 +85,7 @@ func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) {
}
return &Output{
Message: &schema.OutputMessageGetterV1{
Message: schema.OutputMessageGetterV1{
Data: buf.Bytes(),
},
}, nil

@ -0,0 +1,63 @@
/*
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 (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseEnv(t *testing.T) {
type testCase struct {
env []string
expected map[string]string
}
testCases := map[string]testCase{
"empty": {
env: []string{},
expected: map[string]string{},
},
"single": {
env: []string{"KEY=value"},
expected: map[string]string{"KEY": "value"},
},
"multiple": {
env: []string{"KEY1=value1", "KEY2=value2"},
expected: map[string]string{"KEY1": "value1", "KEY2": "value2"},
},
"no_value": {
env: []string{"KEY1=value1", "KEY2="},
expected: map[string]string{"KEY1": "value1", "KEY2": ""},
},
"duplicate_keys": {
env: []string{"KEY=value1", "KEY=value2"},
expected: map[string]string{"KEY": "value2"}, // last value should overwrite
},
"empty_strings": {
env: []string{"", "KEY=value", ""},
expected: map[string]string{"KEY": "value"},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
result := parseEnv(tc.env)
assert.Equal(t, tc.expected, result)
})
}
}

@ -0,0 +1,28 @@
/*
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 schema
type InputMessageTestV1 struct {
Name string
}
type OutputMessageTestV1 struct {
Greeting string
}
type ConfigTestV1 struct{}
func (c *ConfigTestV1) Validate() error {
return nil
}

@ -0,0 +1,12 @@
.DEFAULT: build
.PHONY: build test vet
.PHONY: plugin.wasm
plugin.wasm:
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
build: plugin.wasm
vet:
GOOS=wasip1 GOARCH=wasm go vet ./...

@ -0,0 +1,5 @@
module helm.sh/helm/v4/internal/plugin/src/extismv1-test
go 1.25.0
require github.com/extism/go-pdk v1.1.3

@ -0,0 +1,2 @@
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=

@ -0,0 +1,68 @@
package main
import (
_ "embed"
"fmt"
"os"
pdk "github.com/extism/go-pdk"
)
type InputMessageTestV1 struct {
Name string
}
type OutputMessageTestV1 struct {
Greeting string
}
type ConfigTestV1 struct{}
func runGetterPluginImpl(input InputMessageTestV1) (*OutputMessageTestV1, error) {
name := input.Name
greeting := fmt.Sprintf("Hello, %s! (%d)", name, len(name))
err := os.WriteFile("/tmp/greeting.txt", []byte(greeting), 0o600)
if err != nil {
return nil, fmt.Errorf("failed to write temp file: %w", err)
}
return &OutputMessageTestV1{
Greeting: greeting,
}, nil
}
func RunGetterPlugin() error {
var input InputMessageTestV1
if err := pdk.InputJSON(&input); err != nil {
return fmt.Errorf("failed to parse input json: %w", err)
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received input: %+v", input))
output, err := runGetterPluginImpl(input)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("failed: %s", err.Error()))
return err
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("Sending output: %+v", output))
if err := pdk.OutputJSON(output); err != nil {
return fmt.Errorf("failed to write output json: %w", err)
}
return nil
}
//go:wasmexport helm_plugin_main
func HelmPlugin() uint32 {
pdk.Log(pdk.LogDebug, "running example-extism-getter plugin")
if err := RunGetterPlugin(); err != nil {
pdk.Log(pdk.LogError, err.Error())
pdk.SetError(err)
return 1
}
return 0
}
func main() {}

@ -0,0 +1,9 @@
---
apiVersion: v1
type: test/v1
name: extismv1-test
version: 0.1.0
runtime: extism/v1
runtimeConfig:
fileSystem:
createTempDir: true

@ -116,7 +116,7 @@ func (g *getterPlugin) Get(href string, options ...Option) (*bytes.Buffer, error
return nil, fmt.Errorf("plugin %q failed to invoke: %w", g.plg, err)
}
outputMessage, ok := output.Message.(*schema.OutputMessageGetterV1)
outputMessage, ok := output.Message.(schema.OutputMessageGetterV1)
if !ok {
return nil, fmt.Errorf("invalid output message type from plugin %q", g.plg.Metadata().Name)
}

@ -95,16 +95,16 @@ func TestConvertOptions(t *testing.T) {
assert.Equal(t, expected, opts)
}
type TestPlugin struct {
type testPlugin struct {
t *testing.T
dir string
}
func (t *TestPlugin) Dir() string {
func (t *testPlugin) Dir() string {
return t.dir
}
func (t *TestPlugin) Metadata() plugin.Metadata {
func (t *testPlugin) Metadata() plugin.Metadata {
return plugin.Metadata{
Name: "fake-plugin",
Type: "cli/v1",
@ -121,22 +121,22 @@ func (t *TestPlugin) Metadata() plugin.Metadata {
}
}
func (t *TestPlugin) Invoke(_ context.Context, _ *plugin.Input) (*plugin.Output, error) {
func (t *testPlugin) Invoke(_ context.Context, _ *plugin.Input) (*plugin.Output, error) {
// Simulate a plugin invocation
output := &plugin.Output{
Message: &schema.OutputMessageGetterV1{
Message: schema.OutputMessageGetterV1{
Data: []byte("fake-plugin output"),
},
}
return output, nil
}
var _ plugin.Plugin = (*TestPlugin)(nil)
var _ plugin.Plugin = (*testPlugin)(nil)
func TestGetterPlugin(t *testing.T) {
gp := getterPlugin{
options: []Option{},
plg: &TestPlugin{t: t, dir: "fake/dir"},
plg: &testPlugin{t: t, dir: "fake/dir"},
}
buf, err := gp.Get("test://example.com", WithTimeout(5*time.Second))

Loading…
Cancel
Save