working commit

This commit is contained in:
2026-03-13 19:02:42 +02:00
parent bebbf79c7a
commit 5c1da77f4c
1329 changed files with 314708 additions and 39 deletions
+53
View File
@@ -0,0 +1,53 @@
/*
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 (
"bytes"
"fmt"
"reflect"
"go.yaml.in/yaml/v3"
)
// Config represents a plugin type specific configuration
// It is expected to type assert (cast) the Config to its expected underlying type (schema.ConfigCLIV1, schema.ConfigGetterV1, etc).
type Config interface {
Validate() error
}
func unmarshalConfig(pluginType string, configData map[string]any) (Config, error) {
pluginTypeMeta, ok := pluginTypesIndex[pluginType]
if !ok {
return nil, fmt.Errorf("unknown plugin type %q", pluginType)
}
// TODO: Avoid (yaml) serialization/deserialization for type conversion here
data, err := yaml.Marshal(configData)
if err != nil {
return nil, fmt.Errorf("failed to marshel config data (plugin type %s): %w", pluginType, err)
}
config := reflect.New(pluginTypeMeta.configType)
d := yaml.NewDecoder(bytes.NewReader(data))
d.KnownFields(true)
if err := d.Decode(config.Interface()); err != nil {
return nil, err
}
return config.Interface().(Config), nil
}
+24
View File
@@ -0,0 +1,24 @@
/*
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
// Descriptor describes a plugin to find
type Descriptor struct {
// Name is the name of the plugin
Name string
// Type is the type of the plugin (cli, getter, postrenderer)
Type string
}
+89
View File
@@ -0,0 +1,89 @@
/*
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.
*/
/*
---
TODO: move this section to public plugin package
Package plugin provides the implementation of the Helm plugin system.
Conceptually, "plugins" enable extending Helm's functionality external to Helm's core codebase. The plugin system allows
code to fetch plugins by type, then invoke the plugin with an input as required by that plugin type. The plugin
returning an output for the caller to consume.
An example of a plugin invocation:
```
d := plugin.Descriptor{
Type: "example/v1", //
}
plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d)
for _, plg := range plgs {
input := &plugin.Input{
Message: schema.InputMessageExampleV1{ // The type of the input message is defined by the plugin's "type" (example/v1 here)
...
},
}
output, err := plg.Invoke(context.Background(), input)
if err != nil {
...
}
// consume the output, using type assertion to convert to the expected output type (as defined by the plugin's "type")
outputMessage, ok := output.Message.(schema.OutputMessageExampleV1)
}
---
Package `plugin` provides the implementation of the Helm plugin system.
Helm plugins are exposed to uses as the "Plugin" type, the basic interface that primarily support the "Invoke" method.
# Plugin Runtimes
Internally, plugins must be implemented by a "runtime" that is responsible for creating the plugin instance, and dispatching the plugin's invocation to the plugin's implementation.
For example:
- forming environment variables and command line args for subprocess execution
- converting input to JSON and invoking a function in a Wasm runtime
Internally, the code structure is:
Runtime.CreatePlugin()
|
| (creates)
|
\---> PluginRuntime
|
| (implements)
v
Plugin.Invoke()
# Plugin Types
Each plugin implements a specific functionality, denoted by the plugin's "type" e.g. "getter/v1". The "type" includes a version, in order to allow a given types messaging schema and invocation options to evolve.
Specifically, the plugin's "type" specifies the contract for the input and output messages that are expected to be passed to the plugin, and returned from the plugin. The plugin's "type" also defines the options that can be passed to the plugin when invoking it.
# Metadata
Each plugin must have a `plugin.yaml`, that defines the plugin's metadata. The metadata includes the plugin's name, version, and other information.
For legacy plugins, the type is inferred by which fields are set on the plugin: a downloader plugin is inferred when metadata contains a "downloaders" yaml node, otherwise it is assumed to define a Helm CLI subcommand.
For v1 plugins, the metadata includes explicit apiVersion and type fields. It will also contain type-specific Config, and RuntimeConfig fields.
# Runtime and type cardinality
From a cardinality perspective, this means there a "few" runtimes, and "many" plugins types. It is also expected that the subprocess runtime will not be extended to support extra plugin types, and deprecated in a future version of Helm.
Future ideas that are intended to be implemented include extending the plugin system to support future Wasm standards. Or allowing Helm SDK user's to inject "plugins" that are actually implemented as native go modules. Or even moving Helm's internal functionality e.g. yaml rendering engine to be used as an "in-built" plugin, along side other plugins that may implement other (non-go template) rendering engines.
*/
package plugin
+29
View File
@@ -0,0 +1,29 @@
/*
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
// InvokeExecError is returned when a plugin invocation returns a non-zero status/exit code
// - subprocess plugin: child process exit code
// - extism plugin: wasm function return code
type InvokeExecError struct {
ExitCode int // Exit code from plugin code execution
Err error // Underlying error
}
// Error implements the error interface
func (e *InvokeExecError) Error() string {
return e.Err.Error()
}
+268
View File
@@ -0,0 +1,268 @@
/*
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 (
"bytes"
"fmt"
"io"
"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) {
type apiVersion struct {
APIVersion string `yaml:"apiVersion"`
}
var v apiVersion
d := yaml.NewDecoder(r)
if err := d.Decode(&v); err != nil {
return "", err
}
return v.APIVersion, nil
}
func loadMetadataLegacy(metadataData []byte) (*Metadata, error) {
var ml MetadataLegacy
d := yaml.NewDecoder(bytes.NewReader(metadataData))
// NOTE: No strict unmarshalling for legacy plugins - maintain backwards compatibility
if err := d.Decode(&ml); err != nil {
return nil, err
}
if err := ml.Validate(); err != nil {
return nil, err
}
m := fromMetadataLegacy(ml)
if err := m.Validate(); err != nil {
return nil, err
}
return m, nil
}
func loadMetadataV1(metadataData []byte) (*Metadata, error) {
var mv1 MetadataV1
d := yaml.NewDecoder(bytes.NewReader(metadataData))
d.KnownFields(true)
if err := d.Decode(&mv1); err != nil {
return nil, err
}
if err := mv1.Validate(); err != nil {
return nil, err
}
m, err := fromMetadataV1(mv1)
if err != nil {
return nil, fmt.Errorf("failed to convert MetadataV1 to Metadata: %w", err)
}
if err := m.Validate(); err != nil {
return nil, err
}
return m, nil
}
func loadMetadata(metadataData []byte) (*Metadata, error) {
apiVersion, err := peekAPIVersion(bytes.NewReader(metadataData))
if err != nil {
return nil, fmt.Errorf("failed to peek %s API version: %w", PluginFileName, err)
}
switch apiVersion {
case "": // legacy
return loadMetadataLegacy(metadataData)
case "v1":
return loadMetadataV1(metadataData)
}
return nil, fmt.Errorf("invalid plugin apiVersion: %q", apiVersion)
}
type prototypePluginManager struct {
runtimes map[string]Runtime
}
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) {
pm.runtimes[runtimeName] = runtime
}
func (pm *prototypePluginManager) CreatePlugin(pluginPath string, metadata *Metadata) (Plugin, error) {
rt, ok := pm.runtimes[metadata.Runtime]
if !ok {
return nil, fmt.Errorf("unsupported plugin runtime type: %q", metadata.Runtime)
}
return rt.CreatePlugin(pluginPath, metadata)
}
// LoadDir loads a plugin from the given directory.
func LoadDir(dirname string) (Plugin, error) {
pluginfile := filepath.Join(dirname, PluginFileName)
metadataData, err := os.ReadFile(pluginfile)
if err != nil {
return nil, fmt.Errorf("failed to read plugin at %q: %w", pluginfile, err)
}
m, err := loadMetadata(metadataData)
if err != nil {
return nil, fmt.Errorf("failed to load plugin %q: %w", dirname, err)
}
pm, err := newPrototypePluginManager()
if err != nil {
return nil, fmt.Errorf("failed to create plugin manager: %w", err)
}
return pm.CreatePlugin(dirname, m)
}
// LoadAll loads all plugins found beneath the base directory.
//
// This scans only one directory level.
func LoadAll(basedir string) ([]Plugin, error) {
var plugins []Plugin
// We want basedir/*/plugin.yaml
scanpath := filepath.Join(basedir, "*", PluginFileName)
matches, err := filepath.Glob(scanpath)
if err != nil {
return nil, fmt.Errorf("failed to search for plugins in %q: %w", scanpath, err)
}
// empty dir should load
if len(matches) == 0 {
return plugins, nil
}
for _, yamlFile := range matches {
dir := filepath.Dir(yamlFile)
p, err := LoadDir(dir)
if err != nil {
return plugins, err
}
plugins = append(plugins, p)
}
return plugins, detectDuplicates(plugins)
}
// findFunc is a function that finds plugins in a directory
type findFunc func(pluginsDir string) ([]Plugin, error)
// filterFunc is a function that filters plugins
type filterFunc func(Plugin) bool
// FindPlugins returns a list of plugins that match the descriptor
func FindPlugins(pluginsDirs []string, descriptor Descriptor) ([]Plugin, error) {
return findPlugins(pluginsDirs, LoadAll, makeDescriptorFilter(descriptor))
}
// findPlugins is the internal implementation that uses the find and filter functions
func findPlugins(pluginsDirs []string, findFn findFunc, filterFn filterFunc) ([]Plugin, error) {
var found []Plugin
for _, pluginsDir := range pluginsDirs {
ps, err := findFn(pluginsDir)
if err != nil {
return nil, err
}
for _, p := range ps {
if filterFn(p) {
found = append(found, p)
}
}
}
return found, nil
}
// makeDescriptorFilter creates a filter function from a descriptor
// Additional plugin filter criteria we wish to support can be added here
func makeDescriptorFilter(descriptor Descriptor) filterFunc {
return func(p Plugin) bool {
// If name is specified, it must match
if descriptor.Name != "" && p.Metadata().Name != descriptor.Name {
return false
}
// If type is specified, it must match
if descriptor.Type != "" && p.Metadata().Type != descriptor.Type {
return false
}
return true
}
}
// FindPlugin returns a single plugin that matches the descriptor
func FindPlugin(dirs []string, descriptor Descriptor) (Plugin, error) {
plugins, err := FindPlugins(dirs, descriptor)
if err != nil {
return nil, err
}
if len(plugins) > 0 {
return plugins[0], nil
}
return nil, fmt.Errorf("plugin: %+v not found", descriptor)
}
func detectDuplicates(plugs []Plugin) error {
names := map[string]string{}
for _, plug := range plugs {
if oldpath, ok := names[plug.Metadata().Name]; ok {
return fmt.Errorf(
"two plugins claim the name %q at %q and %q",
plug.Metadata().Name,
oldpath,
plug.Dir(),
)
}
names[plug.Metadata().Name] = plug.Dir()
}
return nil
}
+216
View File
@@ -0,0 +1,216 @@
/*
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 (
"errors"
"fmt"
"helm.sh/helm/v4/internal/plugin/schema"
)
// Metadata of a plugin, converted from the "on-disk" legacy or v1 plugin.yaml
// Specifically, Config and RuntimeConfig are converted to their respective types based on the plugin type and runtime
type Metadata struct {
// APIVersion specifies the plugin API version
APIVersion string
// Name is the name of the plugin
Name string
// Type of plugin (eg, cli/v1, getter/v1, postrenderer/v1)
Type string
// Runtime specifies the runtime type (subprocess, wasm)
Runtime string
// Version is the SemVer 2 version of the plugin.
Version string
// SourceURL is the URL where this plugin can be found
SourceURL string
// Config contains the type-specific configuration for this plugin
Config Config
// RuntimeConfig contains the runtime-specific configuration
RuntimeConfig RuntimeConfig
}
func (m Metadata) Validate() error {
var errs []error
if !validPluginName.MatchString(m.Name) {
errs = append(errs, fmt.Errorf("invalid plugin name %q: must contain only a-z, A-Z, 0-9, _ and -", m.Name))
}
if m.APIVersion == "" {
errs = append(errs, fmt.Errorf("empty APIVersion"))
}
if m.Type == "" {
errs = append(errs, fmt.Errorf("empty type field"))
}
if m.Runtime == "" {
errs = append(errs, fmt.Errorf("empty runtime field"))
}
if m.Config == nil {
errs = append(errs, fmt.Errorf("missing config field"))
}
if m.RuntimeConfig == nil {
errs = append(errs, fmt.Errorf("missing runtimeConfig field"))
}
// Validate the config itself
if m.Config != nil {
if err := m.Config.Validate(); err != nil {
errs = append(errs, fmt.Errorf("config validation failed: %w", err))
}
}
// Validate the runtime config itself
if m.RuntimeConfig != nil {
if err := m.RuntimeConfig.Validate(); err != nil {
errs = append(errs, fmt.Errorf("runtime config validation failed: %w", err))
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
func fromMetadataLegacy(m MetadataLegacy) *Metadata {
pluginType := "cli/v1"
if len(m.Downloaders) > 0 {
pluginType = "getter/v1"
}
return &Metadata{
APIVersion: "legacy",
Name: m.Name,
Version: m.Version,
Type: pluginType,
Runtime: "subprocess",
Config: buildLegacyConfig(m, pluginType),
RuntimeConfig: buildLegacyRuntimeConfig(m),
}
}
func buildLegacyConfig(m MetadataLegacy, pluginType string) Config {
switch pluginType {
case "getter/v1":
var protocols []string
for _, d := range m.Downloaders {
protocols = append(protocols, d.Protocols...)
}
return &schema.ConfigGetterV1{
Protocols: protocols,
}
case "cli/v1":
return &schema.ConfigCLIV1{
Usage: "", // Legacy plugins don't have Usage field for command syntax
ShortHelp: m.Usage, // Map legacy usage to shortHelp
LongHelp: m.Description, // Map legacy description to longHelp
IgnoreFlags: m.IgnoreFlags,
}
default:
return nil
}
}
func buildLegacyRuntimeConfig(m MetadataLegacy) RuntimeConfig {
var protocolCommands []SubprocessProtocolCommand
if len(m.Downloaders) > 0 {
protocolCommands =
make([]SubprocessProtocolCommand, 0, len(m.Downloaders))
for _, d := range m.Downloaders {
protocolCommands = append(protocolCommands, SubprocessProtocolCommand{
Protocols: d.Protocols,
PlatformCommand: []PlatformCommand{{Command: d.Command}},
})
}
}
platformCommand := m.PlatformCommand
if len(platformCommand) == 0 && len(m.Command) > 0 {
platformCommand = []PlatformCommand{{Command: m.Command}}
}
platformHooks := m.PlatformHooks
expandHookArgs := true
if len(platformHooks) == 0 && len(m.Hooks) > 0 {
platformHooks = make(PlatformHooks, len(m.Hooks))
for hookName, hookCommand := range m.Hooks {
platformHooks[hookName] = []PlatformCommand{{Command: "sh", Args: []string{"-c", hookCommand}}}
expandHookArgs = false
}
}
return &RuntimeConfigSubprocess{
PlatformCommand: platformCommand,
PlatformHooks: platformHooks,
ProtocolCommands: protocolCommands,
expandHookArgs: expandHookArgs,
}
}
func fromMetadataV1(mv1 MetadataV1) (*Metadata, error) {
config, err := unmarshalConfig(mv1.Type, mv1.Config)
if err != nil {
return nil, err
}
runtimeConfig, err := convertMetadataRuntimeConfig(mv1.Runtime, mv1.RuntimeConfig)
if err != nil {
return nil, err
}
return &Metadata{
APIVersion: mv1.APIVersion,
Name: mv1.Name,
Type: mv1.Type,
Runtime: mv1.Runtime,
Version: mv1.Version,
SourceURL: mv1.SourceURL,
Config: config,
RuntimeConfig: runtimeConfig,
}, nil
}
func convertMetadataRuntimeConfig(runtimeType string, runtimeConfigRaw map[string]any) (RuntimeConfig, error) {
var runtimeConfig RuntimeConfig
var err error
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)
}
if err != nil {
return nil, fmt.Errorf("failed to unmarshal runtimeConfig for %s runtime: %w", runtimeType, err)
}
return runtimeConfig, nil
}
@@ -0,0 +1,113 @@
/*
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 (
"fmt"
"strings"
"unicode"
)
// Downloaders represents the plugins capability if it can retrieve
// charts from special sources
type Downloaders struct {
// Protocols are the list of schemes from the charts URL.
Protocols []string `yaml:"protocols"`
// Command is the executable path with which the plugin performs
// the actual download for the corresponding Protocols
Command string `yaml:"command"`
}
// MetadataLegacy is the legacy plugin.yaml format
type MetadataLegacy struct {
// Name is the name of the plugin
Name string `yaml:"name"`
// Version is a SemVer 2 version of the plugin.
Version string `yaml:"version"`
// Usage is the single-line usage text shown in help
Usage string `yaml:"usage"`
// Description is a long description shown in places like `helm help`
Description string `yaml:"description"`
// PlatformCommand is the plugin command, with a platform selector and support for args.
PlatformCommand []PlatformCommand `yaml:"platformCommand"`
// Command is the plugin command, as a single string.
// DEPRECATED: Use PlatformCommand instead. Removed in subprocess/v1 plugins.
Command string `yaml:"command"`
// IgnoreFlags ignores any flags passed in from Helm
IgnoreFlags bool `yaml:"ignoreFlags"`
// PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
PlatformHooks PlatformHooks `yaml:"platformHooks"`
// Hooks are commands that will run on plugin events, as a single string.
// DEPRECATED: Use PlatformHooks instead. Removed in subprocess/v1 plugins.
Hooks Hooks `yaml:"hooks"`
// Downloaders field is used if the plugin supply downloader mechanism
// for special protocols.
Downloaders []Downloaders `yaml:"downloaders"`
}
func (m *MetadataLegacy) Validate() error {
if !validPluginName.MatchString(m.Name) {
return fmt.Errorf("invalid plugin name %q: must contain only a-z, A-Z, 0-9, _ and -", m.Name)
}
m.Usage = sanitizeString(m.Usage)
if len(m.PlatformCommand) > 0 && len(m.Command) > 0 {
return fmt.Errorf("both platformCommand and command are set")
}
if len(m.PlatformHooks) > 0 && len(m.Hooks) > 0 {
return fmt.Errorf("both platformHooks and hooks are set")
}
// Validate downloader plugins
for i, downloader := range m.Downloaders {
if downloader.Command == "" {
return fmt.Errorf("downloader %d has empty command", i)
}
if len(downloader.Protocols) == 0 {
return fmt.Errorf("downloader %d has no protocols", i)
}
for j, protocol := range downloader.Protocols {
if protocol == "" {
return fmt.Errorf("downloader %d has empty protocol at index %d", i, j)
}
}
}
return nil
}
// sanitizeString normalize spaces and removes non-printable characters.
func sanitizeString(str string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return ' '
}
if unicode.IsPrint(r) {
return r
}
return -1
}, str)
}
+67
View File
@@ -0,0 +1,67 @@
/*
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 (
"fmt"
)
// MetadataV1 is the APIVersion V1 plugin.yaml format
type MetadataV1 struct {
// APIVersion specifies the plugin API version
APIVersion string `yaml:"apiVersion"`
// Name is the name of the plugin
Name string `yaml:"name"`
// Type of plugin (eg, cli/v1, getter/v1, postrenderer/v1)
Type string `yaml:"type"`
// Runtime specifies the runtime type (subprocess, wasm)
Runtime string `yaml:"runtime"`
// Version is a SemVer 2 version of the plugin.
Version string `yaml:"version"`
// SourceURL is the URL where this plugin can be found
SourceURL string `yaml:"sourceURL,omitempty"`
// Config contains the type-specific configuration for this plugin
Config map[string]any `yaml:"config"`
// RuntimeConfig contains the runtime-specific configuration
RuntimeConfig map[string]any `yaml:"runtimeConfig"`
}
func (m *MetadataV1) Validate() error {
if !validPluginName.MatchString(m.Name) {
return fmt.Errorf("invalid plugin `name`")
}
if m.APIVersion != "v1" {
return fmt.Errorf("invalid `apiVersion`: %q", m.APIVersion)
}
if m.Type == "" {
return fmt.Errorf("`type` missing")
}
if m.Runtime == "" {
return fmt.Errorf("`runtime` missing")
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
/*
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 "helm.sh/helm/v4/internal/plugin"
import (
"context"
"io"
"regexp"
)
const PluginFileName = "plugin.yaml"
// Plugin defines a plugin instance. The client (Helm codebase) facing type that can be used to introspect and invoke a plugin
type Plugin interface {
// Dir return the plugin directory (as an absolute path) on the filesystem
Dir() string
// Metadata describes the plugin's type, version, etc.
// (This metadata type is the converted and plugin version independented in-memory representation of the plugin.yaml file)
Metadata() Metadata
// Invoke takes the given input, and dispatches the contents to plugin instance
// The input is expected to be a JSON-serializable object, which the plugin will interpret according to its type
// The plugin is expected to return a JSON-serializable object, which the invoker
// will interpret according to the plugin's type
//
// Invoke can be thought of as a request/response mechanism. Similar to e.g. http.RoundTripper
//
// If plugin's execution fails with a non-zero "return code" (this is plugin runtime implementation specific)
// an InvokeExecError is returned
Invoke(ctx context.Context, input *Input) (*Output, error)
}
// PluginHook allows plugins to implement hooks that are invoked on plugin management events (install, upgrade, etc)
type PluginHook interface { //nolint:revive
InvokeHook(event string) error
}
// Input defines the input message and parameters to be passed to the plugin
type Input struct {
// Message represents the type-elided value to be passed to the plugin.
// The plugin is expected to interpret the message according to its type
// The message object must be JSON-serializable
Message any
// Optional: Reader to be consumed plugin's "stdin"
Stdin io.Reader
// Optional: Writers to consume the plugin's "stdout" and "stderr"
Stdout, Stderr io.Writer
// Optional: Env represents the environment as a list of "key=value" strings
// see os.Environ
Env []string
}
// Output defines the output message and parameters the passed from the plugin
type Output struct {
// Message represents the type-elided value returned from the plugin
// The invoker is expected to interpret the message according to the plugin's type
// The message object must be JSON-serializable
Message any
}
// validPluginName is a regular expression that validates plugin names.
//
// Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, _ and -.
var validPluginName = regexp.MustCompile("^[A-Za-z0-9_-]+$")
@@ -0,0 +1,106 @@
/*
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.TypeFor[schema.InputMessageTestV1](),
outputType: reflect.TypeFor[schema.OutputMessageTestV1](),
configType: reflect.TypeFor[schema.ConfigTestV1](),
},
{
pluginType: "cli/v1",
inputType: reflect.TypeFor[schema.InputMessageCLIV1](),
outputType: reflect.TypeFor[schema.OutputMessageCLIV1](),
configType: reflect.TypeFor[schema.ConfigCLIV1](),
},
{
pluginType: "getter/v1",
inputType: reflect.TypeFor[schema.InputMessageGetterV1](),
outputType: reflect.TypeFor[schema.OutputMessageGetterV1](),
configType: reflect.TypeFor[schema.ConfigGetterV1](),
},
{
pluginType: "postrenderer/v1",
inputType: reflect.TypeFor[schema.InputMessagePostRendererV1](),
outputType: reflect.TypeFor[schema.OutputMessagePostRendererV1](),
configType: reflect.TypeFor[schema.ConfigPostRendererV1](),
},
}
var pluginTypesIndex = func() map[string]*pluginTypeMeta {
result := make(map[string]*pluginTypeMeta, len(pluginTypes))
for _, m := range pluginTypes {
result[m.pluginType] = &m
}
return result
}()
+86
View File
@@ -0,0 +1,86 @@
/*
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 (
"fmt"
"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
// TODO: could call this something more like "PluginRuntimeCreator"?
type Runtime interface {
// CreatePlugin creates a plugin instance from the given metadata
CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error)
// TODO: move config unmarshalling to the runtime?
// UnmarshalConfig(runtimeConfigRaw map[string]any) (RuntimeConfig, error)
}
// RuntimeConfig represents the assertable type for a plugin's runtime configuration.
// It is expected to type assert (cast) the a RuntimeConfig to its expected type
type RuntimeConfig interface {
Validate() error
}
func remarshalRuntimeConfig[T RuntimeConfig](runtimeData map[string]any) (RuntimeConfig, error) {
data, err := yaml.Marshal(runtimeData)
if err != nil {
return nil, err
}
var config T
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
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
}
// FormatEnv takes a map[KEY]=value and transforms it into
// a list of "KEY=value" environment variable strings
func FormatEnv(env map[string]string) []string {
result := make([]string, 0, len(env))
for key, value := range env {
result = append(result, fmt.Sprintf("%s=%s", 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{
ExitCode: 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,278 @@
/*
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 (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"maps"
"os"
"os/exec"
"slices"
"helm.sh/helm/v4/internal/plugin/schema"
)
// SubprocessProtocolCommand maps a given protocol to the getter command used to retrieve artifacts for that protocol
type SubprocessProtocolCommand struct {
// Protocols are the list of schemes from the charts URL.
Protocols []string `yaml:"protocols"`
// PlatformCommand is the platform based command which the plugin performs
// to download for the corresponding getter Protocols.
PlatformCommand []PlatformCommand `yaml:"platformCommand"`
}
// RuntimeConfigSubprocess implements RuntimeConfig for RuntimeSubprocess
type RuntimeConfigSubprocess struct {
// PlatformCommand is a list containing a plugin command, with a platform selector and support for args.
PlatformCommand []PlatformCommand `yaml:"platformCommand"`
// PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
PlatformHooks PlatformHooks `yaml:"platformHooks"`
// ProtocolCommands allows the plugin to specify protocol specific commands
//
// Obsolete/deprecated: This is a compatibility hangover from the old plugin downloader mechanism, which was extended
// to support multiple protocols in a given plugin. The command supplied in PlatformCommand should implement protocol
// specific logic by inspecting the download URL
ProtocolCommands []SubprocessProtocolCommand `yaml:"protocolCommands,omitempty"`
expandHookArgs bool
}
var _ RuntimeConfig = (*RuntimeConfigSubprocess)(nil)
func (r *RuntimeConfigSubprocess) GetType() string { return "subprocess" }
func (r *RuntimeConfigSubprocess) Validate() error {
return nil
}
type RuntimeSubprocess struct {
EnvVars map[string]string
}
var _ Runtime = (*RuntimeSubprocess)(nil)
// CreatePlugin implementation for Runtime
func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) {
return &SubprocessPluginRuntime{
metadata: *metadata,
pluginDir: pluginDir,
RuntimeConfig: *(metadata.RuntimeConfig.(*RuntimeConfigSubprocess)),
EnvVars: maps.Clone(r.EnvVars),
}, nil
}
// SubprocessPluginRuntime implements the Plugin interface for subprocess execution
type SubprocessPluginRuntime struct {
metadata Metadata
pluginDir string
RuntimeConfig RuntimeConfigSubprocess
EnvVars map[string]string
}
var _ Plugin = (*SubprocessPluginRuntime)(nil)
func (r *SubprocessPluginRuntime) Dir() string {
return r.pluginDir
}
func (r *SubprocessPluginRuntime) Metadata() Metadata {
return r.metadata
}
func (r *SubprocessPluginRuntime) Invoke(_ context.Context, input *Input) (*Output, error) {
switch input.Message.(type) {
case schema.InputMessageCLIV1:
return r.runCLI(input)
case schema.InputMessageGetterV1:
return r.runGetter(input)
case schema.InputMessagePostRendererV1:
return r.runPostrenderer(input)
default:
return nil, fmt.Errorf("unsupported subprocess plugin type %q", r.metadata.Type)
}
}
// InvokeWithEnv executes a plugin command with custom environment and I/O streams
// This method allows execution with different command/args than the plugin's default
func (r *SubprocessPluginRuntime) InvokeWithEnv(main string, argv []string, env []string, stdin io.Reader, stdout, stderr io.Writer) error {
mainCmdExp := os.ExpandEnv(main)
cmd := exec.Command(mainCmdExp, argv...)
cmd.Env = slices.Clone(os.Environ())
cmd.Env = append(
cmd.Env,
fmt.Sprintf("HELM_PLUGIN_NAME=%s", r.metadata.Name),
fmt.Sprintf("HELM_PLUGIN_DIR=%s", r.pluginDir))
cmd.Env = append(cmd.Env, env...)
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := executeCmd(cmd, r.metadata.Name); err != nil {
return err
}
return nil
}
func (r *SubprocessPluginRuntime) InvokeHook(event string) error {
cmds := r.RuntimeConfig.PlatformHooks[event]
if len(cmds) == 0 {
return nil
}
env := ParseEnv(os.Environ())
maps.Insert(env, maps.All(r.EnvVars))
env["HELM_PLUGIN_NAME"] = r.metadata.Name
env["HELM_PLUGIN_DIR"] = r.pluginDir
main, argv, err := PrepareCommands(cmds, r.RuntimeConfig.expandHookArgs, []string{}, env)
if err != nil {
return err
}
cmd := exec.Command(main, argv...)
cmd.Env = FormatEnv(env)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
slog.Debug("executing plugin hook command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String()))
if err := cmd.Run(); err != nil {
if eerr, ok := err.(*exec.ExitError); ok {
os.Stderr.Write(eerr.Stderr)
return fmt.Errorf("plugin %s hook for %q exited with error", event, r.metadata.Name)
}
return err
}
return nil
}
// TODO decide the best way to handle this code
// right now we implement status and error return in 3 slightly different ways in this file
// then replace the other three with a call to this func
func executeCmd(prog *exec.Cmd, pluginName string) error {
if err := prog.Run(); err != nil {
if eerr, ok := err.(*exec.ExitError); ok {
slog.Debug(
"plugin execution failed",
slog.String("pluginName", pluginName),
slog.String("error", err.Error()),
slog.Int("exitCode", eerr.ExitCode()),
slog.String("stderr", string(bytes.TrimSpace(eerr.Stderr))))
return &InvokeExecError{
Err: fmt.Errorf("plugin %q exited with error", pluginName),
ExitCode: eerr.ExitCode(),
}
}
return err
}
return nil
}
func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) {
if _, ok := input.Message.(schema.InputMessageCLIV1); !ok {
return nil, fmt.Errorf("plugin %q input message does not implement InputMessageCLIV1", r.metadata.Name)
}
extraArgs := input.Message.(schema.InputMessageCLIV1).ExtraArgs
cmds := r.RuntimeConfig.PlatformCommand
env := ParseEnv(os.Environ())
maps.Insert(env, maps.All(r.EnvVars))
maps.Insert(env, maps.All(ParseEnv(input.Env)))
env["HELM_PLUGIN_NAME"] = r.metadata.Name
env["HELM_PLUGIN_DIR"] = r.pluginDir
command, args, err := PrepareCommands(cmds, true, extraArgs, env)
if err != nil {
return nil, fmt.Errorf("failed to prepare plugin command: %w", err)
}
cmd := exec.Command(command, args...)
cmd.Env = FormatEnv(env)
cmd.Stdin = input.Stdin
cmd.Stdout = input.Stdout
cmd.Stderr = input.Stderr
slog.Debug("executing plugin command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String()))
if err := executeCmd(cmd, r.metadata.Name); err != nil {
return nil, err
}
return &Output{
Message: schema.OutputMessageCLIV1{},
}, nil
}
func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) {
if _, ok := input.Message.(schema.InputMessagePostRendererV1); !ok {
return nil, fmt.Errorf("plugin %q input message does not implement InputMessagePostRendererV1", r.metadata.Name)
}
env := ParseEnv(os.Environ())
maps.Insert(env, maps.All(r.EnvVars))
maps.Insert(env, maps.All(ParseEnv(input.Env)))
env["HELM_PLUGIN_NAME"] = r.metadata.Name
env["HELM_PLUGIN_DIR"] = r.pluginDir
msg := input.Message.(schema.InputMessagePostRendererV1)
cmds := r.RuntimeConfig.PlatformCommand
command, args, err := PrepareCommands(cmds, true, msg.ExtraArgs, env)
if err != nil {
return nil, fmt.Errorf("failed to prepare plugin command: %w", err)
}
cmd := exec.Command(
command,
args...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
go func() {
defer stdin.Close()
io.Copy(stdin, msg.Manifests)
}()
postRendered := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd.Env = FormatEnv(env)
cmd.Stdout = postRendered
cmd.Stderr = stderr
slog.Debug("executing plugin command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String()))
if err := executeCmd(cmd, r.metadata.Name); err != nil {
return nil, err
}
return &Output{
Message: schema.OutputMessagePostRendererV1{
Manifests: postRendered,
},
}, nil
}
@@ -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.
*/
package plugin
import (
"bytes"
"fmt"
"log/slog"
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
"helm.sh/helm/v4/internal/plugin/schema"
)
func getProtocolCommand(commands []SubprocessProtocolCommand, protocol string) *SubprocessProtocolCommand {
for _, c := range commands {
if slices.Contains(c.Protocols, protocol) {
return &c
}
}
return nil
}
// TODO can we replace a lot of this func with RuntimeSubprocess.invokeWithEnv?
func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) {
msg, ok := (input.Message).(schema.InputMessageGetterV1)
if !ok {
return nil, fmt.Errorf("expected input type schema.InputMessageGetterV1, got %T", input)
}
tmpDir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("helm-plugin-%s-", r.metadata.Name))
if err != nil {
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
}
defer os.RemoveAll(tmpDir)
d := getProtocolCommand(r.RuntimeConfig.ProtocolCommands, msg.Protocol)
if d == nil {
return nil, fmt.Errorf("no downloader found for protocol %q", msg.Protocol)
}
env := ParseEnv(os.Environ())
maps.Insert(env, maps.All(r.EnvVars))
maps.Insert(env, maps.All(ParseEnv(input.Env)))
env["HELM_PLUGIN_NAME"] = r.metadata.Name
env["HELM_PLUGIN_DIR"] = r.pluginDir
env["HELM_PLUGIN_USERNAME"] = msg.Options.Username
env["HELM_PLUGIN_PASSWORD"] = msg.Options.Password
env["HELM_PLUGIN_PASS_CREDENTIALS_ALL"] = fmt.Sprintf("%t", msg.Options.PassCredentialsAll)
command, args, err := PrepareCommands(d.PlatformCommand, false, []string{}, env)
if err != nil {
return nil, fmt.Errorf("failed to prepare commands for protocol %q: %w", msg.Protocol, err)
}
args = append(
args,
msg.Options.CertFile,
msg.Options.KeyFile,
msg.Options.CAFile,
msg.Href)
buf := bytes.Buffer{} // subprocess getters are expected to write content to stdout
pluginCommand := filepath.Join(r.pluginDir, command)
cmd := exec.Command(
pluginCommand,
args...)
cmd.Env = FormatEnv(env)
cmd.Stdout = &buf
cmd.Stderr = os.Stderr
slog.Debug("executing plugin command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String()))
if err := executeCmd(cmd, r.metadata.Name); err != nil {
return nil, err
}
return &Output{
Message: schema.OutputMessageGetterV1{
Data: buf.Bytes(),
},
}, nil
}
@@ -0,0 +1,32 @@
/*
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 "helm.sh/helm/v4/internal/plugin"
// Types of hooks
const (
// Install is executed after the plugin is added.
Install = "install"
// Delete is executed after the plugin is removed.
Delete = "delete"
// Update is executed after the plugin is updated.
Update = "update"
)
// PlatformHooks is a map of events to a command for a particular operating system and architecture.
type PlatformHooks map[string][]PlatformCommand
// Hooks is a map of events to commands.
type Hooks map[string]string
+45
View File
@@ -0,0 +1,45 @@
/*
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
import (
"bytes"
)
type InputMessageCLIV1 struct {
ExtraArgs []string `json:"extraArgs"`
}
type OutputMessageCLIV1 struct {
Data *bytes.Buffer `json:"data"`
}
// ConfigCLIV1 represents the configuration for CLI plugins
type ConfigCLIV1 struct {
// Usage is the single-line usage text shown in help
// For recommended syntax, see [spf13/cobra.command.Command] Use field comment:
// https://pkg.go.dev/github.com/spf13/cobra#Command
Usage string `yaml:"usage"`
// ShortHelp is the short description shown in the 'helm help' output
ShortHelp string `yaml:"shortHelp"`
// LongHelp is the long message shown in the 'helm help <this-command>' output
LongHelp string `yaml:"longHelp"`
// IgnoreFlags ignores any flags passed in from Helm
IgnoreFlags bool `yaml:"ignoreFlags"`
}
func (c *ConfigCLIV1) Validate() error {
// Config validation for CLI plugins
return nil
}
+18
View File
@@ -0,0 +1,18 @@
/*
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
+66
View File
@@ -0,0 +1,66 @@
/*
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
import (
"fmt"
"time"
)
// TODO: can we generate these plugin input/output messages?
type GetterOptionsV1 struct {
URL string
CertFile string
KeyFile string
CAFile string
UNTar bool
InsecureSkipVerifyTLS bool
PlainHTTP bool
AcceptHeader string
Username string
Password string
PassCredentialsAll bool
UserAgent string
Version string
Timeout time.Duration
}
type InputMessageGetterV1 struct {
Href string `json:"href"`
Protocol string `json:"protocol"`
Options GetterOptionsV1 `json:"options"`
}
type OutputMessageGetterV1 struct {
Data []byte `json:"data"`
}
// ConfigGetterV1 represents the configuration for download plugins
type ConfigGetterV1 struct {
// Protocols are the list of URL schemes supported by this downloader
Protocols []string `yaml:"protocols"`
}
func (c *ConfigGetterV1) Validate() error {
if len(c.Protocols) == 0 {
return fmt.Errorf("getter has no protocols")
}
for i, protocol := range c.Protocols {
if protocol == "" {
return fmt.Errorf("getter has empty protocol at index %d", i)
}
}
return nil
}
@@ -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 schema
import (
"bytes"
)
// InputMessagePostRendererV1 implements Input.Message
type InputMessagePostRendererV1 struct {
Manifests *bytes.Buffer `json:"manifests"`
// from CLI --post-renderer-args
ExtraArgs []string `json:"extraArgs"`
}
type OutputMessagePostRendererV1 struct {
Manifests *bytes.Buffer `json:"manifests"`
}
type ConfigPostRendererV1 struct{}
func (c *ConfigPostRendererV1) Validate() error {
return nil
}
+28
View File
@@ -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
}
+156
View File
@@ -0,0 +1,156 @@
/*
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 (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sigs.k8s.io/yaml"
"helm.sh/helm/v4/pkg/provenance"
)
// SignPlugin signs a plugin using the SHA256 hash of the tarball data.
//
// This is used when packaging and signing a plugin from tarball data.
// It creates a signature that includes the tarball hash and plugin metadata,
// allowing verification of the original tarball later.
func SignPlugin(tarballData []byte, filename string, signer *provenance.Signatory) (string, error) {
// Extract plugin metadata from tarball data
pluginMeta, err := ExtractTgzPluginMetadata(bytes.NewReader(tarballData))
if err != nil {
return "", fmt.Errorf("failed to extract plugin metadata: %w", err)
}
// Marshal plugin metadata to YAML bytes
metadataBytes, err := yaml.Marshal(pluginMeta)
if err != nil {
return "", fmt.Errorf("failed to marshal plugin metadata: %w", err)
}
// Use the generic provenance signing function
return signer.ClearSign(tarballData, filename, metadataBytes)
}
// ExtractTgzPluginMetadata extracts plugin metadata from a gzipped tarball reader
func ExtractTgzPluginMetadata(r io.Reader) (*Metadata, error) {
gzr, err := gzip.NewReader(r)
if err != nil {
return nil, err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
// Look for plugin.yaml file
if filepath.Base(header.Name) == "plugin.yaml" {
data, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
// Parse the plugin metadata
metadata, err := loadMetadata(data)
if err != nil {
return nil, err
}
return metadata, nil
}
}
return nil, errors.New("plugin.yaml not found in tarball")
}
// parsePluginMessageBlock parses a signed message block to extract plugin metadata and checksums
func parsePluginMessageBlock(data []byte) (*Metadata, *provenance.SumCollection, error) {
sc := &provenance.SumCollection{}
// We only need the checksums for verification, not the full metadata
if err := provenance.ParseMessageBlock(data, nil, sc); err != nil {
return nil, sc, err
}
return nil, sc, nil
}
// CreatePluginTarball creates a gzipped tarball from a plugin directory
func CreatePluginTarball(sourceDir, pluginName string, w io.Writer) error {
gzw := gzip.NewWriter(w)
defer gzw.Close()
tw := tar.NewWriter(gzw)
defer tw.Close()
// Use the plugin name as the base directory in the tarball
baseDir := pluginName
// Walk the directory tree
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Create header
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
// Update the name to be relative to the source directory
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
// Include the base directory name in the tarball
header.Name = filepath.Join(baseDir, relPath)
// Write header
if err := tw.WriteHeader(header); err != nil {
return err
}
// If it's a regular file, write its content
if info.Mode().IsRegular() {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(tw, file); err != nil {
return err
}
}
return nil
})
}
+178
View File
@@ -0,0 +1,178 @@
/*
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 (
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/ProtonMail/go-crypto/openpgp/clearsign" //nolint
"helm.sh/helm/v4/pkg/helmpath"
)
// SigningInfo contains information about a plugin's signing status
type SigningInfo struct {
// Status can be:
// - "local dev": Plugin is a symlink (development mode)
// - "unsigned": No provenance file found
// - "invalid provenance": Provenance file is malformed
// - "mismatched provenance": Provenance file does not match the installed tarball
// - "signed": Valid signature exists for the installed tarball
Status string
IsSigned bool // True if plugin has a valid signature (even if not verified against keyring)
}
// GetPluginSigningInfo returns signing information for an installed plugin
func GetPluginSigningInfo(metadata Metadata) (*SigningInfo, error) {
pluginName := metadata.Name
pluginDir := helmpath.DataPath("plugins", pluginName)
// Check if plugin directory exists
fi, err := os.Lstat(pluginDir)
if err != nil {
return nil, fmt.Errorf("plugin %s not found: %w", pluginName, err)
}
// Check if it's a symlink (local development)
if fi.Mode()&os.ModeSymlink != 0 {
return &SigningInfo{
Status: "local dev",
IsSigned: false,
}, nil
}
// Find the exact tarball file for this plugin
pluginsDir := helmpath.DataPath("plugins")
tarballPath := filepath.Join(pluginsDir, fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version))
if _, err := os.Stat(tarballPath); err != nil {
return &SigningInfo{
Status: "unsigned",
IsSigned: false,
}, nil
}
// Check for .prov file associated with the tarball
provFile := tarballPath + ".prov"
provData, err := os.ReadFile(provFile)
if err != nil {
if os.IsNotExist(err) {
return &SigningInfo{
Status: "unsigned",
IsSigned: false,
}, nil
}
return nil, fmt.Errorf("failed to read provenance file: %w", err)
}
// Parse the provenance file to check validity
block, _ := clearsign.Decode(provData)
if block == nil {
return &SigningInfo{
Status: "invalid provenance",
IsSigned: false,
}, nil
}
// Check if provenance matches the actual tarball
blockContent := string(block.Plaintext)
if !validateProvenanceHash(blockContent, tarballPath) {
return &SigningInfo{
Status: "mismatched provenance",
IsSigned: false,
}, nil
}
// We have a provenance file that is valid for this plugin
// Without a keyring, we can't verify the signature, but we know:
// 1. A .prov file exists
// 2. It's a valid clearsigned document (cryptographically signed)
// 3. The provenance contains valid checksums
return &SigningInfo{
Status: "signed",
IsSigned: true,
}, nil
}
func validateProvenanceHash(blockContent string, tarballPath string) bool {
// Parse provenance to get the expected hash
_, sums, err := parsePluginMessageBlock([]byte(blockContent))
if err != nil {
return false
}
// Must have file checksums
if len(sums.Files) == 0 {
return false
}
// Calculate actual hash of the tarball
actualHash, err := calculateFileHash(tarballPath)
if err != nil {
return false
}
// Check if the actual hash matches the expected hash in the provenance
for filename, expectedHash := range sums.Files {
if strings.Contains(filename, filepath.Base(tarballPath)) && expectedHash == actualHash {
return true
}
}
return false
}
// calculateFileHash calculates the SHA256 hash of a file
func calculateFileHash(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", err
}
return fmt.Sprintf("sha256:%x", hasher.Sum(nil)), nil
}
// GetSigningInfoForPlugins returns signing info for multiple plugins
func GetSigningInfoForPlugins(plugins []Plugin) map[string]*SigningInfo {
result := make(map[string]*SigningInfo)
for _, p := range plugins {
m := p.Metadata()
info, err := GetPluginSigningInfo(m)
if err != nil {
// If there's an error, treat as unsigned
result[m.Name] = &SigningInfo{
Status: "unknown",
IsSigned: false,
}
} else {
result[m.Name] = info
}
}
return result
}
@@ -0,0 +1,114 @@
/*
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 (
"fmt"
"os"
"runtime"
"strings"
)
// PlatformCommand represents a command for a particular operating system and architecture
type PlatformCommand struct {
OperatingSystem string `yaml:"os"`
Architecture string `yaml:"arch"`
Command string `yaml:"command"`
Args []string `yaml:"args"`
}
// Returns command and args strings based on the following rules in priority order:
// - From the PlatformCommand where OS and Arch match the current platform
// - From the PlatformCommand where OS matches the current platform and Arch is empty/unspecified
// - From the PlatformCommand where OS is empty/unspecified and Arch matches the current platform
// - From the PlatformCommand where OS and Arch are both empty/unspecified
// - Return nil, nil
func getPlatformCommand(cmds []PlatformCommand) ([]string, []string) {
var command, args []string
found := false
foundOs := false
eq := strings.EqualFold
for _, c := range cmds {
if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) {
// Return early for an exact match
return strings.Split(c.Command, " "), c.Args
}
if (len(c.OperatingSystem) > 0 && !eq(c.OperatingSystem, runtime.GOOS)) || len(c.Architecture) > 0 {
// Skip if OS is not empty and doesn't match or if arch is set as a set arch requires an OS match
continue
}
if !foundOs && len(c.OperatingSystem) > 0 && eq(c.OperatingSystem, runtime.GOOS) {
// First OS match with empty arch, can only be overridden by a direct match
command = strings.Split(c.Command, " ")
args = c.Args
found = true
foundOs = true
} else if !found {
// First empty match, can be overridden by a direct match or an OS match
command = strings.Split(c.Command, " ")
args = c.Args
found = true
}
}
return command, args
}
// PrepareCommands takes a []Plugin.PlatformCommand
// and prepares the command and arguments for execution.
//
// It merges extraArgs into any arguments supplied in the plugin. It
// returns the main command and an args array.
//
// The result is suitable to pass to exec.Command.
func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string, env map[string]string) (string, []string, error) {
cmdParts, args := getPlatformCommand(cmds)
if len(cmdParts) == 0 || cmdParts[0] == "" {
return "", nil, fmt.Errorf("no plugin command is applicable")
}
envMappingFunc := func(key string) string {
return env[key]
}
main := os.Expand(cmdParts[0], envMappingFunc)
baseArgs := []string{}
if len(cmdParts) > 1 {
for _, cmdPart := range cmdParts[1:] {
if expandArgs {
baseArgs = append(baseArgs, os.Expand(cmdPart, envMappingFunc))
} else {
baseArgs = append(baseArgs, cmdPart)
}
}
}
for _, arg := range args {
if expandArgs {
baseArgs = append(baseArgs, os.Expand(arg, envMappingFunc))
} else {
baseArgs = append(baseArgs, arg)
}
}
if len(extraArgs) > 0 {
baseArgs = append(baseArgs, extraArgs...)
}
return main, baseArgs, nil
}
+39
View File
@@ -0,0 +1,39 @@
/*
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 (
"path/filepath"
"helm.sh/helm/v4/pkg/provenance"
)
// VerifyPlugin verifies plugin data against a signature using data in memory.
func VerifyPlugin(archiveData, provData []byte, filename, keyring string) (*provenance.Verification, error) {
// Create signatory from keyring
sig, err := provenance.NewFromKeyring(keyring, "")
if err != nil {
return nil, err
}
// Use the new VerifyData method directly
return sig.Verify(archiveData, provData, filename)
}
// isTarball checks if a file has a tarball extension
func IsTarball(filename string) bool {
return filepath.Ext(filename) == ".gz" || filepath.Ext(filename) == ".tgz"
}