diff --git a/go.mod b/go.mod index c28405240..8cff102c9 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f4f54ecdc..9b41a7c39 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/plugin/config.go b/internal/plugin/config.go index 812dba7f6..83a2e0b25 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -23,7 +23,6 @@ import ( // Config interface defines the methods that all plugin type configurations must implement type Config interface { - GetType() string Validate() error } diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go index eb05cb722..a58a84126 100644 --- a/internal/plugin/loader.go +++ b/internal/plugin/loader.go @@ -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) } diff --git a/internal/plugin/metadata.go b/internal/plugin/metadata.go index 48741474e..bb7e9409f 100644 --- a/internal/plugin/metadata.go +++ b/internal/plugin/metadata.go @@ -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) } diff --git a/internal/plugin/plugin_type_registry.go b/internal/plugin/plugin_type_registry.go new file mode 100644 index 000000000..63450b823 --- /dev/null +++ b/internal/plugin/plugin_type_registry.go @@ -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 +}() diff --git a/internal/plugin/plugin_type_registry_test.go b/internal/plugin/plugin_type_registry_test.go new file mode 100644 index 000000000..ee8a44bb6 --- /dev/null +++ b/internal/plugin/plugin_type_registry_test.go @@ -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) +} diff --git a/internal/plugin/runtime.go b/internal/plugin/runtime.go index 8add92dea..a9c01a380 100644 --- a/internal/plugin/runtime.go +++ b/internal/plugin/runtime.go @@ -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 +} diff --git a/internal/plugin/runtime_extismv1.go b/internal/plugin/runtime_extismv1.go new file mode 100644 index 000000000..d3ecff182 --- /dev/null +++ b/internal/plugin/runtime_extismv1.go @@ -0,0 +1,297 @@ +/* +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 ExtistmV1WasmBinaryFilename = "plugin.wasm" + +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"` +} + +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"` + + // // An optional set of mappings between the host's filesystem and the paths a plugin can access. + // TODO: shuld Helm expose this? + //AllowedPaths map[string]string `yaml:"allowedPaths,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) + } + + fmt.Printf("Creating extism/v1 plugin %q with config: %+v\n", metadata.Name, rc) + + wasmFile := filepath.Join(pluginDir, ExtistmV1WasmBinaryFilename) + 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 { + tmpDir, err := os.MkdirTemp(os.TempDir(), "helm-plugin-*") + slog.Debug("created plugin temp dir", slog.String("dir", tmpDir), 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())) + } + }() + } + + manifest, err := buildManifest(p.dir, tmpDir, p.rc) + if err != nil { + return nil, err + } + + config, err := buildPluginConfig(input, p.r) + if err != nil { + return nil, err + } + + 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 marshel 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 marshel 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, ExtistmV1WasmBinaryFilename) + + 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, error) { + + 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, nil +} + +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 +} diff --git a/internal/plugin/runtime_extismv1_test.go b/internal/plugin/runtime_extismv1_test.go new file mode 100644 index 000000000..8d9c55195 --- /dev/null +++ b/internal/plugin/runtime_extismv1_test.go @@ -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) +} diff --git a/internal/plugin/runtime_test.go b/internal/plugin/runtime_test.go new file mode 100644 index 000000000..8b72648b2 --- /dev/null +++ b/internal/plugin/runtime_test.go @@ -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) + }) + } +} diff --git a/internal/plugin/schema/test.go b/internal/plugin/schema/test.go new file mode 100644 index 000000000..97efa0fde --- /dev/null +++ b/internal/plugin/schema/test.go @@ -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 +} diff --git a/internal/plugin/testdata/src/extismv1-test/.gitignore b/internal/plugin/testdata/src/extismv1-test/.gitignore new file mode 100644 index 000000000..ef7d91fbb --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/.gitignore @@ -0,0 +1 @@ +plugin.wasm diff --git a/internal/plugin/testdata/src/extismv1-test/Makefile b/internal/plugin/testdata/src/extismv1-test/Makefile new file mode 100644 index 000000000..24da1f371 --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/Makefile @@ -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 ./... diff --git a/internal/plugin/testdata/src/extismv1-test/go.mod b/internal/plugin/testdata/src/extismv1-test/go.mod new file mode 100644 index 000000000..baed75fab --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/go.mod @@ -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 diff --git a/internal/plugin/testdata/src/extismv1-test/go.sum b/internal/plugin/testdata/src/extismv1-test/go.sum new file mode 100644 index 000000000..c15d38292 --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/go.sum @@ -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= diff --git a/internal/plugin/testdata/src/extismv1-test/main.go b/internal/plugin/testdata/src/extismv1-test/main.go new file mode 100644 index 000000000..40311329d --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/main.go @@ -0,0 +1,61 @@ +package main + +import ( + _ "embed" + "fmt" + + 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 + return &OutputMessageTestV1{ + Greeting: fmt.Sprintf("Hello, %s! (%d)", name, len(name)), + }, 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() {} diff --git a/internal/plugin/testdata/src/extismv1-test/plugin.yaml b/internal/plugin/testdata/src/extismv1-test/plugin.yaml new file mode 100644 index 000000000..2d3694fe6 --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/plugin.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +type: test/v1 +name: extismv1-test +version: 0.1.0 +runtime: extism/v1 \ No newline at end of file