[HIP-0026] Plugin runtime interface (#31145)

* Runtime abstraction to encapsulate subprocess code and enable future runtimes

Also fix race condition in TestPrepareCommandExtraArgs by replacing the shared variable modification with a local copy

Co-authored-by: George Jenkins <gvjenkins@gmail.com>
Signed-off-by: Scott Rigby <scott@r6by.com>

* Remove commented out code

Co-authored-by: Joe Julian <me@joejulian.name>
Signed-off-by: Scott Rigby <scott@r6by.com>

* Check test failure string

Co-authored-by: Jesse Simpson <jesse.simpson36@gmail.com>
Signed-off-by: Scott Rigby <scott@r6by.com>

---------

Signed-off-by: Scott Rigby <scott@r6by.com>
Co-authored-by: George Jenkins <gvjenkins@gmail.com>
Co-authored-by: Joe Julian <me@joejulian.name>
Co-authored-by: Jesse Simpson <jesse.simpson36@gmail.com>
pull/31146/head
Scott Rigby 2 weeks ago committed by GitHub
parent 0f1b410f14
commit be74ab72a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -25,6 +25,7 @@ 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/image-spec v1.1.1
github.com/rubenv/sql-migrate v1.8.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
@ -35,6 +36,7 @@ require (
golang.org/x/crypto v0.41.0
golang.org/x/term v0.34.0
golang.org/x/text v0.28.0
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.33.4
k8s.io/apiextensions-apiserver v0.33.4
k8s.io/apimachinery v0.33.4
@ -114,7 +116,6 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/onsi/gomega v1.37.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@ -169,7 +170,6 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/component-base v0.33.4 // indirect
k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911 // indirect
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect

@ -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 plugin
import (
"fmt"
)
// Config interface defines the methods that all plugin type configurations must implement
type Config interface {
GetType() string
Validate() error
}
// ConfigCLI represents the configuration for CLI plugins
type ConfigCLI 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"`
}
// ConfigGetter represents the configuration for download plugins
type ConfigGetter struct {
// Protocols are the list of URL schemes supported by this downloader
Protocols []string `yaml:"protocols"`
}
func (c *ConfigCLI) GetType() string { return "cli/v1" }
func (c *ConfigGetter) GetType() string { return "getter/v1" }
func (c *ConfigCLI) Validate() error {
// Config validation for CLI plugins
return nil
}
func (c *ConfigGetter) 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,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
}

@ -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 future runtime (eg, Wasm)
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 future plugin api versions, the metadata will include explicit apiVersion and type fields. It will also contain type and runtime 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

@ -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 {
Err error // Underlying error
Code int // Exeit code from plugin code execution
}
// Error implements the error interface
func (e *InvokeExecError) Error() string {
return e.Err.Error()
}

@ -34,7 +34,7 @@ func TestLocalInstaller(t *testing.T) {
t.Fatal(err)
}
source := "../testdata/plugdir/good/echo"
source := "../testdata/plugdir/good/echo-legacy"
i, err := NewForSource(source, "")
if err != nil {
t.Fatalf("unexpected error: %s", err)
@ -44,14 +44,14 @@ func TestLocalInstaller(t *testing.T) {
t.Fatal(err)
}
if i.Path() != helmpath.DataPath("plugins", "echo") {
if i.Path() != helmpath.DataPath("plugins", "echo-legacy") {
t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/helm-env', got %q", i.Path())
}
defer os.RemoveAll(filepath.Dir(helmpath.DataPath())) // helmpath.DataPath is like /tmp/helm013130971/helm
}
func TestLocalInstallerNotAFolder(t *testing.T) {
source := "../testdata/plugdir/good/echo/plugin.yaml"
source := "../testdata/plugdir/good/echo-legacy/plugin.yaml"
i, err := NewForSource(source, "")
if err != nil {
t.Fatalf("unexpected error: %s", err)

@ -57,7 +57,7 @@ func TestVCSInstaller(t *testing.T) {
}
source := "https://github.com/adamreese/helm-env"
testRepoPath, _ := filepath.Abs("../testdata/plugdir/good/echo")
testRepoPath, _ := filepath.Abs("../testdata/plugdir/good/echo-legacy")
repo := &testRepo{
local: testRepoPath,
tags: []string{"0.1.0", "0.1.1"},

@ -0,0 +1,224 @@
/*
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"
"go.yaml.in/yaml/v3"
)
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))
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 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)
}
return nil, fmt.Errorf("invalid plugin apiVersion: %q", apiVersion)
}
type prototypePluginManager struct {
runtimes map[string]Runtime
}
func newPrototypePluginManager() *prototypePluginManager {
return &prototypePluginManager{
runtimes: map[string]Runtime{
"subprocess": &RuntimeSubprocess{},
},
}
}
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 := newPrototypePluginManager()
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
}

@ -0,0 +1,197 @@
/*
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"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPeekAPIVersion(t *testing.T) {
testCases := map[string]struct {
data []byte
expected string
}{
"legacy": { // No apiVersion field
data: []byte(`---
name: "test-plugin"
`),
expected: "",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
version, err := peekAPIVersion(bytes.NewReader(tc.data))
require.NoError(t, err)
assert.Equal(t, tc.expected, version)
})
}
// invalid yaml
{
data := []byte(`bad yaml`)
_, err := peekAPIVersion(bytes.NewReader(data))
assert.Error(t, err)
}
}
func TestLoadDir(t *testing.T) {
makeMetadata := func(apiVersion string) Metadata {
usage := "hello [params]..."
if apiVersion == "legacy" {
usage = "" // Legacy plugins don't have Usage field for command syntax
}
return Metadata{
APIVersion: apiVersion,
Name: fmt.Sprintf("hello-%s", apiVersion),
Version: "0.1.0",
Type: "cli/v1",
Runtime: "subprocess",
Config: &ConfigCLI{
Usage: usage,
ShortHelp: "echo hello message",
LongHelp: "description",
IgnoreFlags: true,
},
RuntimeConfig: &RuntimeConfigSubprocess{
PlatformCommands: []PlatformCommand{
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.sh"}},
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.ps1"}},
},
PlatformHooks: map[string][]PlatformCommand{
Install: {
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
},
},
},
}
}
testCases := map[string]struct {
dirname string
apiVersion string
expect Metadata
}{
"legacy": {
dirname: "testdata/plugdir/good/hello-legacy",
apiVersion: "legacy",
expect: makeMetadata("legacy"),
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
plug, err := LoadDir(tc.dirname)
require.NoError(t, err, "error loading plugin from %s", tc.dirname)
assert.Equal(t, tc.dirname, plug.Dir())
assert.EqualValues(t, tc.expect, plug.Metadata())
})
}
}
func TestLoadDirDuplicateEntries(t *testing.T) {
testCases := map[string]string{
"legacy": "testdata/plugdir/bad/duplicate-entries-legacy",
}
for name, dirname := range testCases {
t.Run(name, func(t *testing.T) {
_, err := LoadDir(dirname)
assert.Error(t, err)
})
}
}
func TestDetectDuplicates(t *testing.T) {
plugs := []Plugin{
mockSubprocessCLIPlugin(t, "foo"),
mockSubprocessCLIPlugin(t, "bar"),
}
if err := detectDuplicates(plugs); err != nil {
t.Error("no duplicates in the first set")
}
plugs = append(plugs, mockSubprocessCLIPlugin(t, "foo"))
if err := detectDuplicates(plugs); err == nil {
t.Error("duplicates in the second set")
}
}
func TestLoadAll(t *testing.T) {
// Verify that empty dir loads:
{
plugs, err := LoadAll("testdata")
require.NoError(t, err)
assert.Len(t, plugs, 0)
}
basedir := "testdata/plugdir/good"
plugs, err := LoadAll(basedir)
require.NoError(t, err)
require.NotEmpty(t, plugs, "expected plugins to be loaded from %s", basedir)
plugsMap := map[string]Plugin{}
for _, p := range plugs {
plugsMap[p.Metadata().Name] = p
}
assert.Len(t, plugsMap, 3)
assert.Contains(t, plugsMap, "downloader")
assert.Contains(t, plugsMap, "echo-legacy")
assert.Contains(t, plugsMap, "hello-legacy")
}
func TestFindPlugins(t *testing.T) {
cases := []struct {
name string
plugdirs string
expected int
}{
{
name: "plugdirs is empty",
plugdirs: "",
expected: 0,
},
{
name: "plugdirs isn't dir",
plugdirs: "./plugin_test.go",
expected: 0,
},
{
name: "plugdirs doesn't have plugin",
plugdirs: ".",
expected: 0,
},
{
name: "normal",
plugdirs: "./testdata/plugdir/good",
expected: 3,
},
}
for _, c := range cases {
t.Run(t.Name(), func(t *testing.T) {
plugin, err := LoadAll(c.plugdirs)
require.NoError(t, err)
assert.Len(t, plugin, c.expected, "expected %d plugins, got %d", c.expected, len(plugin))
})
}
}

@ -0,0 +1,155 @@
/*
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"
)
// Metadata of a plugin, converted from the "on-disk" 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)
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 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 &ConfigGetter{
Protocols: protocols,
}
case "cli/v1":
return &ConfigCLI{
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(d))
}
}
return &RuntimeConfigSubprocess{
PlatformCommands: m.PlatformCommands,
Command: m.Command,
PlatformHooks: m.PlatformHooks,
Hooks: m.Hooks,
ProtocolCommands: protocolCommands,
}
}

@ -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"`
// PlatformCommands is the plugin command, with a platform selector and support for args.
PlatformCommands []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")
}
m.Usage = sanitizeString(m.Usage)
if len(m.PlatformCommands) > 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)
}

@ -0,0 +1,141 @@
/*
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 (
"strings"
"testing"
)
func TestValidatePluginData(t *testing.T) {
// A mock plugin with no commands
mockNoCommand := mockSubprocessCLIPlugin(t, "foo")
mockNoCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
PlatformCommands: []PlatformCommand{},
PlatformHooks: map[string][]PlatformCommand{},
}
// A mock plugin with legacy commands
mockLegacyCommand := mockSubprocessCLIPlugin(t, "foo")
mockLegacyCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
PlatformCommands: []PlatformCommand{},
Command: "echo \"mock plugin\"",
PlatformHooks: map[string][]PlatformCommand{},
Hooks: map[string]string{
Install: "echo installing...",
},
}
// A mock plugin with a command also set
mockWithCommand := mockSubprocessCLIPlugin(t, "foo")
mockWithCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
PlatformCommands: []PlatformCommand{
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
},
Command: "echo \"mock plugin\"",
}
// A mock plugin with a hooks also set
mockWithHooks := mockSubprocessCLIPlugin(t, "foo")
mockWithHooks.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
PlatformCommands: []PlatformCommand{
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
},
PlatformHooks: map[string][]PlatformCommand{
Install: {
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
},
},
Hooks: map[string]string{
Install: "echo installing...",
},
}
for i, item := range []struct {
pass bool
plug Plugin
errString string
}{
{true, mockSubprocessCLIPlugin(t, "abcdefghijklmnopqrstuvwxyz0123456789_-ABC"), ""},
{true, mockSubprocessCLIPlugin(t, "foo-bar-FOO-BAR_1234"), ""},
{false, mockSubprocessCLIPlugin(t, "foo -bar"), "invalid name"},
{false, mockSubprocessCLIPlugin(t, "$foo -bar"), "invalid name"}, // Test leading chars
{false, mockSubprocessCLIPlugin(t, "foo -bar "), "invalid name"}, // Test trailing chars
{false, mockSubprocessCLIPlugin(t, "foo\nbar"), "invalid name"}, // Test newline
{true, mockNoCommand, ""}, // Test no command metadata works
{true, mockLegacyCommand, ""}, // Test legacy command metadata works
{false, mockWithCommand, "runtime config validation failed: both platformCommand and command are set"}, // Test platformCommand and command both set fails
{false, mockWithHooks, "runtime config validation failed: both platformHooks and hooks are set"}, // Test platformHooks and hooks both set fails
} {
err := item.plug.Metadata().Validate()
if item.pass && err != nil {
t.Errorf("failed to validate case %d: %s", i, err)
} else if !item.pass && err == nil {
t.Errorf("expected case %d to fail", i)
}
if !item.pass && err.Error() != item.errString {
t.Errorf("index [%d]: expected the following error: %s, but got: %s", i, item.errString, err.Error())
}
}
}
func TestMetadataValidateMultipleErrors(t *testing.T) {
// Create metadata with multiple validation issues
metadata := Metadata{
Name: "invalid name with spaces", // Invalid name
APIVersion: "", // Empty API version
Type: "", // Empty type
Runtime: "", // Empty runtime
Config: nil, // Missing config
RuntimeConfig: nil, // Missing runtime config
}
err := metadata.Validate()
if err == nil {
t.Fatal("expected validation to fail with multiple errors")
}
errStr := err.Error()
// Check that all expected errors are present in the joined error
expectedErrors := []string{
"invalid name",
"empty APIVersion",
"empty type field",
"empty runtime field",
"missing config field",
"missing runtimeConfig field",
}
for _, expectedErr := range expectedErrors {
if !strings.Contains(errStr, expectedErr) {
t.Errorf("expected error to contain %q, but got: %v", expectedErr, errStr)
}
}
// Verify that the error contains the correct number of error messages
errorCount := 0
for _, expectedErr := range expectedErrors {
if strings.Contains(errStr, expectedErr) {
errorCount++
}
}
if errorCount < len(expectedErrors) {
t.Errorf("expected %d errors, but only found %d in: %v", len(expectedErrors), errorCount, errStr)
}
}

@ -13,359 +13,69 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package plugin // import "helm.sh/helm/v4/pkg/plugin"
package plugin // import "helm.sh/helm/v4/internal/plugin"
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"context"
"io"
"regexp"
"runtime"
"strings"
"unicode"
"sigs.k8s.io/yaml"
"helm.sh/helm/v4/pkg/cli"
)
const PluginFileName = "plugin.yaml"
// 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 `json:"protocols"`
// Command is the executable path with which the plugin performs
// the actual download for the corresponding Protocols
Command string `json:"command"`
}
// PlatformCommand represents a command for a particular operating system and architecture
type PlatformCommand struct {
OperatingSystem string `json:"os"`
Architecture string `json:"arch"`
Command string `json:"command"`
Args []string `json:"args"`
}
// Metadata describes a plugin.
//
// This is the plugin equivalent of a chart.Metadata.
type Metadata struct {
// Name is the name of the plugin
Name string `json:"name"`
// Version is a SemVer 2 version of the plugin.
Version string `json:"version"`
// 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
// Usage is the single-line usage text shown in help
Usage string `json:"usage"`
// 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
// Description is a long description shown in places like `helm help`
Description string `json:"description"`
// PlatformCommand is the plugin command, with a platform selector and support for args.
//
// The command and args will be passed through environment expansion, so env vars can
// be present in this command. Unless IgnoreFlags is set, this will
// also merge the flags passed from Helm.
//
// Note that the command is not executed in a shell. To do so, we suggest
// pointing the command to a shell script.
//
// The following rules will apply to processing platform commands:
// - If PlatformCommand is present, it will be used
// - If both OS and Arch match the current platform, search will stop and the command will be executed
// - If OS matches and Arch is empty, the command will be executed
// - If no OS/Arch match is found, the default command will be executed
// - If no matches are found in platformCommand, Helm will exit with an error
PlatformCommand []PlatformCommand `json:"platformCommand"`
// Command is the plugin command, as a single string.
// Providing Command and PlatformCommand will result in a warning being emitted (PlatformCommand takes precedence).
//
// The command will be passed through environment expansion, so env vars can
// be present in this command. Unless IgnoreFlags is set, this will
// also merge the flags passed from Helm.
// 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
//
// Note that command is not executed in a shell. To do so, we suggest
// pointing the command to a shell script.
//
// DEPRECATED: Use PlatformCommand instead
Command string `json:"command"`
// IgnoreFlags ignores any flags passed in from Helm
// Invoke can be thought of as a request/response mechanism. Similar to e.g. http.RoundTripper
//
// For example, if the plugin is invoked as `helm --debug myplugin`, if this
// is false, `--debug` will be appended to `--command`. If this is true,
// the `--debug` flag will be discarded.
IgnoreFlags bool `json:"ignoreFlags"`
// PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
//
// The command and args will be passed through environment expansion, so env vars can
// be present in the command.
//
// Note that the command is not executed in a shell. To do so, we suggest
// pointing the command to a shell script.
//
// The following rules will apply to processing platform hooks:
// - If PlatformHooks is present, it will be used
// - If both OS and Arch match the current platform, search will stop and the command will be executed
// - If OS matches and Arch is empty, the command will be executed
// - If no OS/Arch match is found, the default command will be executed
// - If no matches are found in platformHooks, Helm will skip the event
PlatformHooks PlatformHooks `json:"platformHooks"`
// Hooks are commands that will run on plugin events, as a single string.
// Providing Hook and PlatformHooks will result in a warning being emitted (PlatformHooks takes precedence).
//
// The command will be passed through environment expansion, so env vars can
// be present in this command.
//
// Note that the command is executed in the sh shell.
//
// DEPRECATED: Use PlatformHooks instead
Hooks Hooks
// Downloaders field is used if the plugin supply downloader mechanism
// for special protocols.
Downloaders []Downloaders `json:"downloaders"`
}
// Plugin represents a plugin.
type Plugin struct {
// Metadata is a parsed representation of a plugin.yaml
Metadata *Metadata
// Dir is the string path to the directory that holds the plugin.
Dir string
// 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)
}
// 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
// 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
}
// 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) (string, []string, error) {
cmdParts, args := getPlatformCommand(cmds)
if len(cmdParts) == 0 || cmdParts[0] == "" {
return "", nil, fmt.Errorf("no plugin command is applicable")
}
// 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
main := os.ExpandEnv(cmdParts[0])
baseArgs := []string{}
if len(cmdParts) > 1 {
for _, cmdPart := range cmdParts[1:] {
if expandArgs {
baseArgs = append(baseArgs, os.ExpandEnv(cmdPart))
} else {
baseArgs = append(baseArgs, cmdPart)
}
}
}
// Optional: Reader to be consumed plugin's "stdin"
Stdin io.Reader
for _, arg := range args {
if expandArgs {
baseArgs = append(baseArgs, os.ExpandEnv(arg))
} else {
baseArgs = append(baseArgs, arg)
}
}
// Optional: Writers to consume the plugin's "stdout" and "stderr"
Stdout, Stderr io.Writer
if len(extraArgs) > 0 {
baseArgs = append(baseArgs, extraArgs...)
}
return main, baseArgs, nil
// Optional: Env represents the environment as a list of "key=value" strings
// see os.Environ
Env []string
}
// PrepareCommand gets the correct command and arguments for a plugin.
//
// It merges extraArgs into any arguments supplied in the plugin. It returns the name of the command and an args array.
//
// The result is suitable to pass to exec.Command.
func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) {
var extraArgsIn []string
if !p.Metadata.IgnoreFlags {
extraArgsIn = extraArgs
}
cmds := p.Metadata.PlatformCommand
if len(cmds) == 0 && len(p.Metadata.Command) > 0 {
cmds = []PlatformCommand{{Command: p.Metadata.Command}}
}
return PrepareCommands(cmds, true, extraArgsIn)
// 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_-]+$")
// validatePluginData validates a plugin's YAML data.
func validatePluginData(plug *Plugin, filepath string) error {
// When metadata section missing, initialize with no data
if plug.Metadata == nil {
plug.Metadata = &Metadata{}
}
if !validPluginName.MatchString(plug.Metadata.Name) {
return fmt.Errorf("invalid plugin name at %q", filepath)
}
plug.Metadata.Usage = sanitizeString(plug.Metadata.Usage)
if len(plug.Metadata.PlatformCommand) > 0 && len(plug.Metadata.Command) > 0 {
slog.Warn("both 'platformCommand' and 'command' are set (this will become an error in a future Helm version)", slog.String("filepath", filepath))
}
if len(plug.Metadata.PlatformHooks) > 0 && len(plug.Metadata.Hooks) > 0 {
slog.Warn("both 'platformHooks' and 'hooks' are set (this will become an error in a future Helm version)", slog.String("filepath", filepath))
}
// We could also validate SemVer, executable, and other fields should we so choose.
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)
}
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
}
// LoadDir loads a plugin from the given directory.
func LoadDir(dirname string) (*Plugin, error) {
pluginfile := filepath.Join(dirname, PluginFileName)
data, err := os.ReadFile(pluginfile)
if err != nil {
return nil, fmt.Errorf("failed to read plugin at %q: %w", pluginfile, err)
}
plug := &Plugin{Dir: dirname}
if err := yaml.UnmarshalStrict(data, &plug.Metadata); err != nil {
return nil, fmt.Errorf("failed to load plugin at %q: %w", pluginfile, err)
}
return plug, validatePluginData(plug, pluginfile)
}
// LoadAll loads all plugins found beneath the base directory.
//
// This scans only one directory level.
func LoadAll(basedir string) ([]*Plugin, error) {
plugins := []*Plugin{}
// We want basedir/*/plugin.yaml
scanpath := filepath.Join(basedir, "*", PluginFileName)
matches, err := filepath.Glob(scanpath)
if err != nil {
return plugins, fmt.Errorf("failed to find plugins in %q: %w", scanpath, err)
}
if matches == nil {
return plugins, nil
}
for _, yaml := range matches {
dir := filepath.Dir(yaml)
p, err := LoadDir(dir)
if err != nil {
return plugins, err
}
plugins = append(plugins, p)
}
return plugins, detectDuplicates(plugins)
}
// FindPlugins returns a list of YAML files that describe plugins.
func FindPlugins(plugdirs string) ([]*Plugin, error) {
found := []*Plugin{}
// Let's get all UNIXy and allow path separators
for _, p := range filepath.SplitList(plugdirs) {
matches, err := LoadAll(p)
if err != nil {
return matches, err
}
found = append(found, matches...)
}
return found, nil
}
// SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because
// the plugin subsystem itself needs access to the environment variables
// created here.
func SetupPluginEnv(settings *cli.EnvSettings, name, base string) {
env := settings.EnvVars()
env["HELM_PLUGIN_NAME"] = name
env["HELM_PLUGIN_DIR"] = base
for key, val := range env {
os.Setenv(key, val)
}
}

@ -13,290 +13,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package plugin // import "helm.sh/helm/v4/pkg/plugin"
package plugin
import (
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"helm.sh/helm/v4/pkg/cli"
)
func TestPrepareCommand(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
p := &Plugin{
Dir: "/tmp", // Unused
Metadata: &Metadata{
Name: "test",
Command: "echo \"error\"",
PlatformCommand: []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
},
},
}
cmd, args, err := p.PrepareCommand([]string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}
func TestPrepareCommandExtraArgs(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
extraArgs := []string{"--debug", "--foo", "bar"}
p := &Plugin{
Dir: "/tmp", // Unused
Metadata: &Metadata{
Name: "test",
Command: "echo \"error\"",
PlatformCommand: []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
},
},
}
expectedArgs := append(cmdArgs, extraArgs...)
cmd, args, err := p.PrepareCommand(extraArgs)
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Fatalf("Expected %v, got %v", expectedArgs, args)
}
}
func TestPrepareCommandExtraArgsIgnored(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
extraArgs := []string{"--debug", "--foo", "bar"}
p := &Plugin{
Dir: "/tmp", // Unused
Metadata: &Metadata{
Name: "test",
Command: "echo \"error\"",
PlatformCommand: []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
},
IgnoreFlags: true,
},
}
cmd, args, err := p.PrepareCommand(extraArgs)
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}
func TestPrepareCommands(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}
cmd, args, err := PrepareCommands(cmds, true, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}
func TestPrepareCommandsExtraArgs(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
extraArgs := []string{"--debug", "--foo", "bar"}
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}
expectedArgs := append(cmdArgs, extraArgs...)
cmd, args, err := PrepareCommands(cmds, true, extraArgs)
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Fatalf("Expected %v, got %v", expectedArgs, args)
}
}
func TestPrepareCommandsNoArch(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}
cmd, args, err := PrepareCommands(cmds, true, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}
func TestPrepareCommandsNoOsNoArch(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: "", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}
cmd, args, err := PrepareCommands(cmds, true, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}
func TestPrepareCommandsNoMatch(t *testing.T) {
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
}
if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
t.Fatalf("Expected error to be returned")
}
}
func TestPrepareCommandsNoCommands(t *testing.T) {
cmds := []PlatformCommand{}
if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
t.Fatalf("Expected error to be returned")
}
}
func TestPrepareCommandsExpand(t *testing.T) {
t.Setenv("TEST", "test")
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"${TEST}\""}
cmds := []PlatformCommand{
{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
}
expectedArgs := []string{"-c", "echo \"test\""}
cmd, args, err := PrepareCommands(cmds, true, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Fatalf("Expected %v, got %v", expectedArgs, args)
}
}
func TestPrepareCommandsNoExpand(t *testing.T) {
t.Setenv("TEST", "test")
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"${TEST}\""}
cmds := []PlatformCommand{
{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
}
cmd, args, err := PrepareCommands(cmds, false, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}
func TestLoadDir(t *testing.T) {
dirname := "testdata/plugdir/good/hello"
plug, err := LoadDir(dirname)
if err != nil {
t.Fatalf("error loading Hello plugin: %s", err)
}
if plug.Dir != dirname {
t.Fatalf("Expected dir %q, got %q", dirname, plug.Dir)
}
func mockSubprocessCLIPlugin(t *testing.T, pluginName string) *SubprocessPluginRuntime {
t.Helper()
expect := &Metadata{
Name: "hello",
Version: "0.1.0",
Usage: "usage",
Description: "description",
PlatformCommand: []PlatformCommand{
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.sh"}},
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.ps1"}},
rc := RuntimeConfigSubprocess{
PlatformCommands: []PlatformCommand{
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"mock plugin\""}},
},
IgnoreFlags: true,
PlatformHooks: map[string][]PlatformCommand{
Install: {
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
@ -305,241 +35,24 @@ func TestLoadDir(t *testing.T) {
},
}
if !reflect.DeepEqual(expect, plug.Metadata) {
t.Fatalf("Expected plugin metadata %v, got %v", expect, plug.Metadata)
}
}
func TestLoadDirDuplicateEntries(t *testing.T) {
dirname := "testdata/plugdir/bad/duplicate-entries"
if _, err := LoadDir(dirname); err == nil {
t.Errorf("successfully loaded plugin with duplicate entries when it should've failed")
}
}
pluginDir := t.TempDir()
func TestDownloader(t *testing.T) {
dirname := "testdata/plugdir/good/downloader"
plug, err := LoadDir(dirname)
if err != nil {
t.Fatalf("error loading Hello plugin: %s", err)
}
if plug.Dir != dirname {
t.Fatalf("Expected dir %q, got %q", dirname, plug.Dir)
}
expect := &Metadata{
Name: "downloader",
Version: "1.2.3",
Usage: "usage",
Description: "download something",
Command: "echo Hello",
Downloaders: []Downloaders{
{
Protocols: []string{"myprotocol", "myprotocols"},
Command: "echo Download",
},
},
}
if !reflect.DeepEqual(expect, plug.Metadata) {
t.Fatalf("Expected metadata %v, got %v", expect, plug.Metadata)
}
}
func TestLoadAll(t *testing.T) {
// Verify that empty dir loads:
if plugs, err := LoadAll("testdata"); err != nil {
t.Fatalf("error loading dir with no plugins: %s", err)
} else if len(plugs) > 0 {
t.Fatalf("expected empty dir to have 0 plugins")
}
basedir := "testdata/plugdir/good"
plugs, err := LoadAll(basedir)
if err != nil {
t.Fatalf("Could not load %q: %s", basedir, err)
}
if l := len(plugs); l != 3 {
t.Fatalf("expected 3 plugins, found %d", l)
}
if plugs[0].Metadata.Name != "downloader" {
t.Errorf("Expected first plugin to be echo, got %q", plugs[0].Metadata.Name)
}
if plugs[1].Metadata.Name != "echo" {
t.Errorf("Expected first plugin to be echo, got %q", plugs[0].Metadata.Name)
}
if plugs[2].Metadata.Name != "hello" {
t.Errorf("Expected second plugin to be hello, got %q", plugs[1].Metadata.Name)
}
}
func TestFindPlugins(t *testing.T) {
cases := []struct {
name string
plugdirs string
expected int
}{
{
name: "plugdirs is empty",
plugdirs: "",
expected: 0,
},
{
name: "plugdirs isn't dir",
plugdirs: "./plugin_test.go",
expected: 0,
},
{
name: "plugdirs doesn't have plugin",
plugdirs: ".",
expected: 0,
},
{
name: "normal",
plugdirs: "./testdata/plugdir/good",
expected: 3,
},
}
for _, c := range cases {
t.Run(t.Name(), func(t *testing.T) {
plugin, _ := FindPlugins(c.plugdirs)
if len(plugin) != c.expected {
t.Errorf("expected: %v, got: %v", c.expected, len(plugin))
}
})
}
}
func TestSetupEnv(t *testing.T) {
name := "pequod"
base := filepath.Join("testdata/helmhome/helm/plugins", name)
s := cli.New()
s.PluginsDirectory = "testdata/helmhome/helm/plugins"
SetupPluginEnv(s, name, base)
for _, tt := range []struct {
name, expect string
}{
{"HELM_PLUGIN_NAME", name},
{"HELM_PLUGIN_DIR", base},
} {
if got := os.Getenv(tt.name); got != tt.expect {
t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
}
}
}
func TestSetupEnvWithSpace(t *testing.T) {
name := "sureshdsk"
base := filepath.Join("testdata/helm home/helm/plugins", name)
s := cli.New()
s.PluginsDirectory = "testdata/helm home/helm/plugins"
SetupPluginEnv(s, name, base)
for _, tt := range []struct {
name, expect string
}{
{"HELM_PLUGIN_NAME", name},
{"HELM_PLUGIN_DIR", base},
} {
if got := os.Getenv(tt.name); got != tt.expect {
t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
}
}
}
func TestValidatePluginData(t *testing.T) {
// A mock plugin missing any metadata.
mockMissingMeta := &Plugin{
Dir: "no-such-dir",
}
// A mock plugin with no commands
mockNoCommand := mockPlugin("foo")
mockNoCommand.Metadata.PlatformCommand = []PlatformCommand{}
mockNoCommand.Metadata.PlatformHooks = map[string][]PlatformCommand{}
// A mock plugin with legacy commands
mockLegacyCommand := mockPlugin("foo")
mockLegacyCommand.Metadata.PlatformCommand = []PlatformCommand{}
mockLegacyCommand.Metadata.Command = "echo \"mock plugin\""
mockLegacyCommand.Metadata.PlatformHooks = map[string][]PlatformCommand{}
mockLegacyCommand.Metadata.Hooks = map[string]string{
Install: "echo installing...",
}
// A mock plugin with a command also set
mockWithCommand := mockPlugin("foo")
mockWithCommand.Metadata.Command = "echo \"mock plugin\""
// A mock plugin with a hooks also set
mockWithHooks := mockPlugin("foo")
mockWithHooks.Metadata.Hooks = map[string]string{
Install: "echo installing...",
}
for i, item := range []struct {
pass bool
plug *Plugin
}{
{true, mockPlugin("abcdefghijklmnopqrstuvwxyz0123456789_-ABC")},
{true, mockPlugin("foo-bar-FOO-BAR_1234")},
{false, mockPlugin("foo -bar")},
{false, mockPlugin("$foo -bar")}, // Test leading chars
{false, mockPlugin("foo -bar ")}, // Test trailing chars
{false, mockPlugin("foo\nbar")}, // Test newline
{false, mockMissingMeta}, // Test if the metadata section missing
{true, mockNoCommand}, // Test no command metadata works
{true, mockLegacyCommand}, // Test legacy command metadata works
{true, mockWithCommand}, // Test platformCommand and command both set works
{true, mockWithHooks}, // Test platformHooks and hooks both set works
} {
err := validatePluginData(item.plug, fmt.Sprintf("test-%d", i))
if item.pass && err != nil {
t.Errorf("failed to validate case %d: %s", i, err)
} else if !item.pass && err == nil {
t.Errorf("expected case %d to fail", i)
}
}
}
func TestDetectDuplicates(t *testing.T) {
plugs := []*Plugin{
mockPlugin("foo"),
mockPlugin("bar"),
}
if err := detectDuplicates(plugs); err != nil {
t.Error("no duplicates in the first set")
}
plugs = append(plugs, mockPlugin("foo"))
if err := detectDuplicates(plugs); err == nil {
t.Error("duplicates in the second set")
}
}
func mockPlugin(name string) *Plugin {
return &Plugin{
Metadata: &Metadata{
Name: name,
Version: "v0.1.2",
Usage: "Mock plugin",
Description: "Mock plugin for testing",
PlatformCommand: []PlatformCommand{
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"mock plugin\""}},
},
PlatformHooks: map[string][]PlatformCommand{
Install: {
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
},
return &SubprocessPluginRuntime{
metadata: Metadata{
Name: pluginName,
Version: "v0.1.2",
Type: "cli/v1",
APIVersion: "legacy",
Runtime: "subprocess",
Config: &ConfigCLI{
Usage: "Mock plugin",
ShortHelp: "Mock plugin",
LongHelp: "Mock plugin for testing",
IgnoreFlags: false,
},
RuntimeConfig: &rc,
},
Dir: "no-such-dir",
pluginDir: pluginDir, // NOTE: dir is empty (ie. plugin.yaml is not present)
RuntimeConfig: rc,
}
}

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

@ -0,0 +1,229 @@
/*
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"
"fmt"
"io"
"os"
"os/exec"
"syscall"
"helm.sh/helm/v4/internal/plugin/schema"
"helm.sh/helm/v4/pkg/cli"
)
// SubprocessProtocolCommand maps a given protocol to the getter command used to retrieve artifacts for that protcol
type SubprocessProtocolCommand 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"`
}
// RuntimeConfigSubprocess represents configuration for subprocess runtime
type RuntimeConfigSubprocess struct {
// PlatformCommand is a list containing a plugin command, with a platform selector and support for args.
PlatformCommands []PlatformCommand `yaml:"platformCommand"`
// Command is the plugin command, as a single string.
// DEPRECATED: Use PlatformCommand instead. Remove in Helm 4.
Command string `yaml:"command"`
// 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. Remove in Helm 4.
Hooks Hooks `yaml:"hooks"`
// ProtocolCommands field is used if the plugin supply downloader mechanism
// for special protocols.
// (This is a compatibility hangover from the old plugin downloader mechanism, which was extended to support multiple
// protocols in a given plugin)
ProtocolCommands []SubprocessProtocolCommand `yaml:"protocolCommands,omitempty"`
}
var _ RuntimeConfig = (*RuntimeConfigSubprocess)(nil)
func (r *RuntimeConfigSubprocess) GetType() string { return "subprocess" }
func (r *RuntimeConfigSubprocess) Validate() error {
if len(r.PlatformCommands) > 0 && len(r.Command) > 0 {
return fmt.Errorf("both platformCommand and command are set")
}
if len(r.PlatformHooks) > 0 && len(r.Hooks) > 0 {
return fmt.Errorf("both platformHooks and hooks are set")
}
return nil
}
type RuntimeSubprocess struct{}
var _ Runtime = (*RuntimeSubprocess)(nil)
// CreateRuntime implementation for RuntimeConfig
func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) {
return &SubprocessPluginRuntime{
metadata: *metadata,
pluginDir: pluginDir,
RuntimeConfig: *(metadata.RuntimeConfig.(*RuntimeConfigSubprocess)),
}, nil
}
// RuntimeSubprocess implements the Runtime interface for subprocess execution
type SubprocessPluginRuntime struct {
metadata Metadata
pluginDir string
RuntimeConfig RuntimeConfigSubprocess
}
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)
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)
prog := exec.Command(mainCmdExp, argv...)
prog.Env = env
prog.Stdin = stdin
prog.Stdout = stdout
prog.Stderr = stderr
if err := prog.Run(); err != nil {
if eerr, ok := err.(*exec.ExitError); ok {
os.Stderr.Write(eerr.Stderr)
status := eerr.Sys().(syscall.WaitStatus)
return &InvokeExecError{
Err: fmt.Errorf("plugin %q exited with error", r.metadata.Name),
Code: status.ExitStatus(),
}
}
}
return nil
}
func (r *SubprocessPluginRuntime) InvokeHook(event string) error {
// Get hook commands for the event
var cmds []PlatformCommand
expandArgs := true
cmds = r.RuntimeConfig.PlatformHooks[event]
if len(cmds) == 0 && len(r.RuntimeConfig.Hooks) > 0 {
cmd := r.RuntimeConfig.Hooks[event]
if len(cmd) > 0 {
cmds = []PlatformCommand{{Command: "sh", Args: []string{"-c", cmd}}}
expandArgs = false
}
}
// If no hook commands are defined, just return successfully
if len(cmds) == 0 {
return nil
}
main, argv, err := PrepareCommands(cmds, expandArgs, []string{})
if err != nil {
return err
}
prog := exec.Command(main, argv...)
prog.Stdout, prog.Stderr = os.Stdout, os.Stderr
if err := prog.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 {
os.Stderr.Write(eerr.Stderr)
return &InvokeExecError{
Err: fmt.Errorf("plugin %q exited with error", pluginName),
Code: 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.PlatformCommands
if len(cmds) == 0 && len(r.RuntimeConfig.Command) > 0 {
cmds = []PlatformCommand{{Command: r.RuntimeConfig.Command}}
}
command, args, err := PrepareCommands(cmds, true, extraArgs)
if err != nil {
return nil, fmt.Errorf("failed to prepare plugin command: %w", err)
}
err2 := r.InvokeWithEnv(command, args, input.Env, input.Stdin, input.Stdout, input.Stderr)
if err2 != nil {
return nil, err2
}
return &Output{
Message: &schema.OutputMessageCLIV1{},
}, nil
}
// SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because
// the plugin subsystem itself needs access to the environment variables
// created here.
func SetupPluginEnv(settings *cli.EnvSettings, name, base string) { // TODO: remove
env := settings.EnvVars()
env["HELM_PLUGIN_NAME"] = name
env["HELM_PLUGIN_DIR"] = base
for key, val := range env {
os.Setenv(key, val)
}
}

@ -0,0 +1,92 @@
/*
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"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"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)
}
commands := strings.Split(d.Command, " ")
args := append(
commands[1:],
msg.Options.CertFile,
msg.Options.KeyFile,
msg.Options.CAFile,
msg.Href)
// TODO should we append to input.Env too?
env := append(
os.Environ(),
fmt.Sprintf("HELM_PLUGIN_USERNAME=%s", msg.Options.Username),
fmt.Sprintf("HELM_PLUGIN_PASSWORD=%s", msg.Options.Password),
fmt.Sprintf("HELM_PLUGIN_PASS_CREDENTIALS_ALL=%t", msg.Options.PassCredentialsAll))
// TODO should we pass along input.Stdout?
buf := bytes.Buffer{} // subprocess getters are expected to write content to stdout
pluginCommand := filepath.Join(r.pluginDir, commands[0])
prog := exec.Command(
pluginCommand,
args...)
prog.Env = env
prog.Stdout = &buf
prog.Stderr = os.Stderr
if err := executeCmd(prog, r.metadata.Name); err != nil {
return nil, err
}
return &Output{
Message: &schema.OutputMessageGetterV1{
Data: buf.Bytes(),
},
}, nil
}

@ -0,0 +1,64 @@
/*
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"
"path/filepath"
"testing"
"helm.sh/helm/v4/pkg/cli"
)
func TestSetupEnv(t *testing.T) {
name := "pequod"
base := filepath.Join("testdata/helmhome/helm/plugins", name)
s := cli.New()
s.PluginsDirectory = "testdata/helmhome/helm/plugins"
SetupPluginEnv(s, name, base)
for _, tt := range []struct {
name, expect string
}{
{"HELM_PLUGIN_NAME", name},
{"HELM_PLUGIN_DIR", base},
} {
if got := os.Getenv(tt.name); got != tt.expect {
t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
}
}
}
func TestSetupEnvWithSpace(t *testing.T) {
name := "sureshdsk"
base := filepath.Join("testdata/helm home/helm/plugins", name)
s := cli.New()
s.PluginsDirectory = "testdata/helm home/helm/plugins"
SetupPluginEnv(s, name, base)
for _, tt := range []struct {
name, expect string
}{
{"HELM_PLUGIN_NAME", name},
{"HELM_PLUGIN_DIR", base},
} {
if got := os.Getenv(tt.name); got != tt.expect {
t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
}
}
}

@ -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 schema
import (
"bytes"
"helm.sh/helm/v4/pkg/cli"
)
type InputMessageCLIV1 struct {
ExtraArgs []string `json:"extraArgs"`
Settings *cli.EnvSettings `json:"settings"`
}
type OutputMessageCLIV1 struct {
Data *bytes.Buffer `json:"data"`
}

@ -0,0 +1,47 @@
/*
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 (
"time"
)
// TODO: can we generate these plugin input/outputs?
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"`
}

@ -0,0 +1,111 @@
/*
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) (string, []string, error) {
cmdParts, args := getPlatformCommand(cmds)
if len(cmdParts) == 0 || cmdParts[0] == "" {
return "", nil, fmt.Errorf("no plugin command is applicable")
}
main := os.ExpandEnv(cmdParts[0])
baseArgs := []string{}
if len(cmdParts) > 1 {
for _, cmdPart := range cmdParts[1:] {
if expandArgs {
baseArgs = append(baseArgs, os.ExpandEnv(cmdPart))
} else {
baseArgs = append(baseArgs, cmdPart)
}
}
}
for _, arg := range args {
if expandArgs {
baseArgs = append(baseArgs, os.ExpandEnv(arg))
} else {
baseArgs = append(baseArgs, arg)
}
}
if len(extraArgs) > 0 {
baseArgs = append(baseArgs, extraArgs...)
}
return main, baseArgs, nil
}

@ -0,0 +1,259 @@
/*
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"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPrepareCommand(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
platformCommands := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
}
cmd, args, err := PrepareCommands(platformCommands, true, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}
func TestPrepareCommandExtraArgs(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
platformCommands := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}
extraArgs := []string{"--debug", "--foo", "bar"}
type testCaseExpected struct {
cmdMain string
args []string
}
testCases := map[string]struct {
ignoreFlags bool
expected testCaseExpected
}{
"ignoreFlags false": {
ignoreFlags: false,
expected: testCaseExpected{
cmdMain: cmdMain,
args: []string{"-c", "echo \"test\"", "--debug", "--foo", "bar"},
},
},
"ignoreFlags true": {
ignoreFlags: true,
expected: testCaseExpected{
cmdMain: cmdMain,
args: []string{"-c", "echo \"test\""},
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
//expectedArgs := append(cmdArgs, extraArgs...)
// extra args are expected when ignoreFlags is unset or false
testExtraArgs := extraArgs
if tc.ignoreFlags {
testExtraArgs = []string{}
}
cmd, args, err := PrepareCommands(platformCommands, true, testExtraArgs)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, tc.expected.cmdMain, cmd, "Expected command to match")
assert.Equal(t, tc.expected.args, args, "Expected args to match")
})
}
}
func TestPrepareCommands(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}
cmd, args, err := PrepareCommands(cmds, true, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}
func TestPrepareCommandsExtraArgs(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
extraArgs := []string{"--debug", "--foo", "bar"}
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}
expectedArgs := append(cmdArgs, extraArgs...)
cmd, args, err := PrepareCommands(cmds, true, extraArgs)
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Fatalf("Expected %v, got %v", expectedArgs, args)
}
}
func TestPrepareCommandsNoArch(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}
cmd, args, err := PrepareCommands(cmds, true, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}
func TestPrepareCommandsNoOsNoArch(t *testing.T) {
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"test\""}
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: "", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}
cmd, args, err := PrepareCommands(cmds, true, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}
func TestPrepareCommandsNoMatch(t *testing.T) {
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
}
if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
t.Fatalf("Expected error to be returned")
}
}
func TestPrepareCommandsNoCommands(t *testing.T) {
cmds := []PlatformCommand{}
if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
t.Fatalf("Expected error to be returned")
}
}
func TestPrepareCommandsExpand(t *testing.T) {
t.Setenv("TEST", "test")
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"${TEST}\""}
cmds := []PlatformCommand{
{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
}
expectedArgs := []string{"-c", "echo \"test\""}
cmd, args, err := PrepareCommands(cmds, true, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Fatalf("Expected %v, got %v", expectedArgs, args)
}
}
func TestPrepareCommandsNoExpand(t *testing.T) {
t.Setenv("TEST", "test")
cmdMain := "sh"
cmdArgs := []string{"-c", "echo \"${TEST}\""}
cmds := []PlatformCommand{
{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
}
cmd, args, err := PrepareCommands(cmds, false, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmdMain {
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
}
if !reflect.DeepEqual(args, cmdArgs) {
t.Fatalf("Expected %v, got %v", cmdArgs, args)
}
}

@ -1,3 +1,4 @@
---
name: "downloader"
version: "1.2.3"
usage: "usage"

@ -1,4 +1,5 @@
name: "echo"
---
name: "echo-legacy"
version: "1.2.3"
usage: "echo something"
description: |-

@ -1,25 +1,22 @@
name: "hello"
---
name: "hello-legacy"
version: "0.1.0"
usage: "usage"
usage: "echo hello message"
description: |-
description
platformCommand:
- os: linux
arch:
command: "sh"
args: ["-c", "${HELM_PLUGIN_DIR}/hello.sh"]
- os: windows
arch:
command: "pwsh"
args: ["-c", "${HELM_PLUGIN_DIR}/hello.ps1"]
ignoreFlags: true
platformHooks:
install:
- os: linux
arch: ""
command: "sh"
args: ["-c", 'echo "installing..."']
- os: windows
arch: ""
command: "pwsh"
args: ["-c", 'echo "installing..."']

@ -177,7 +177,7 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) {
//
// This code has to do with writing files to disk.
func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) {
hs := []*release.Hook{}
var hs []*release.Hook
b := bytes.NewBuffer(nil)
caps, err := cfg.getCapabilities()

@ -237,7 +237,7 @@ func (i *Install) Run(chrt *chart.Chart, vals map[string]interface{}) (*release.
return i.RunWithContext(ctx, chrt, vals)
}
// Run executes the installation with Context
// RunWithContext executes the installation with Context
//
// When the task is cancelled through ctx, the function returns and the install
// proceeds in the background.

@ -27,6 +27,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"k8s.io/klog/v2"
"helm.sh/helm/v4/pkg/action"
@ -163,6 +164,7 @@ func (o *outputValue) Set(s string) error {
return nil
}
// TODO there is probably a better way to pass cobra settings than as a param
func bindPostRenderFlag(cmd *cobra.Command, varRef *postrender.PostRenderer) {
p := &postRendererOptions{varRef, "", []string{}}
cmd.Flags().Var(&postRendererString{p}, postRenderFlag, "the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path")

@ -104,6 +104,10 @@ func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string)
root.SetArgs(args)
oldStdin := os.Stdin
defer func() {
os.Stdin = oldStdin
}()
if in != nil {
root.SetIn(in)
os.Stdin = in
@ -116,8 +120,6 @@ func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string)
result := buf.String()
os.Stdin = oldStdin
return c, result, err
}

@ -17,16 +17,17 @@ package cmd
import (
"bytes"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"helm.sh/helm/v4/internal/plugin/schema"
"github.com/spf13/cobra"
"sigs.k8s.io/yaml"
@ -34,6 +35,12 @@ import (
"helm.sh/helm/v4/internal/plugin"
)
// TODO: move pluginDynamicCompletionExecutable pkg/plugin/runtime_subprocess.go
// any references to executables should be for [plugin.SubprocessPluginRuntime] only
// this should also be for backwards compatibility in [plugin.Legacy] only
//
// TODO: for v1 make this configurable with a new CompletionCommand field for
// [plugin.RuntimeConfigSubprocess]
const (
pluginStaticCompletionFile = "completion.yaml"
pluginDynamicCompletionExecutable = "plugin.complete"
@ -44,18 +51,22 @@ type PluginError struct {
Code int
}
// loadPlugins loads plugins into the command list.
// loadCLIPlugins loads CLI plugins into the command list.
//
// This follows a different pattern than the other commands because it has
// to inspect its environment and then add commands to the base command
// as it finds them.
func loadPlugins(baseCmd *cobra.Command, out io.Writer) {
func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) {
// If HELM_NO_PLUGINS is set to 1, do not load plugins.
if os.Getenv("HELM_NO_PLUGINS") == "1" {
return
}
found, err := plugin.FindPlugins(settings.PluginsDirectory)
dirs := filepath.SplitList(settings.PluginsDirectory)
descriptor := plugin.Descriptor{
Type: "cli/v1",
}
found, err := plugin.FindPlugins(dirs, descriptor)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load plugins: %s\n", err)
return
@ -63,32 +74,69 @@ func loadPlugins(baseCmd *cobra.Command, out io.Writer) {
// Now we create commands for all of these.
for _, plug := range found {
md := plug.Metadata
if md.Usage == "" {
md.Usage = fmt.Sprintf("the %q plugin", md.Name)
var use, short, long string
var ignoreFlags bool
if cliConfig, ok := plug.Metadata().Config.(*plugin.ConfigCLI); ok {
use = cliConfig.Usage
short = cliConfig.ShortHelp
long = cliConfig.LongHelp
ignoreFlags = cliConfig.IgnoreFlags
}
// Set defaults
if use == "" {
use = plug.Metadata().Name
}
if short == "" {
short = fmt.Sprintf("the %q plugin", plug.Metadata().Name)
}
// long has no default, empty is ok
c := &cobra.Command{
Use: md.Name,
Short: md.Usage,
Long: md.Description,
Use: use,
Short: short,
Long: long,
RunE: func(cmd *cobra.Command, args []string) error {
u, err := processParent(cmd, args)
if err != nil {
return err
}
// Setup plugin environment
plugin.SetupPluginEnv(settings, plug.Metadata().Name, plug.Dir())
// For CLI plugin types runtime, set extra args and settings
extraArgs := []string{}
if !ignoreFlags {
extraArgs = u
}
// Call setupEnv before PrepareCommand because
// PrepareCommand uses os.ExpandEnv and expects the
// setupEnv vars.
plugin.SetupPluginEnv(settings, md.Name, plug.Dir)
main, argv, prepCmdErr := plug.PrepareCommand(u)
if prepCmdErr != nil {
os.Stderr.WriteString(prepCmdErr.Error())
return fmt.Errorf("plugin %q exited with error", md.Name)
// Prepare environment
env := os.Environ()
for k, v := range settings.EnvVars() {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
return callPluginExecutable(md.Name, main, argv, out)
// Invoke plugin
input := &plugin.Input{
Message: schema.InputMessageCLIV1{
ExtraArgs: extraArgs,
Settings: settings,
},
Env: env,
Stdin: os.Stdin,
Stdout: out,
Stderr: os.Stderr,
}
_, err = plug.Invoke(context.Background(), input)
// TODO do we want to keep execErr here?
if execErr, ok := err.(*plugin.InvokeExecError); ok {
// TODO can we replace cmd.PluginError with plugin.Error?
return PluginError{
error: execErr.Err,
Code: execErr.Code,
}
}
return err
},
// This passes all the flags to the subcommand.
DisableFlagParsing: true,
@ -118,34 +166,6 @@ func processParent(cmd *cobra.Command, args []string) ([]string, error) {
return u, nil
}
// This function is used to setup the environment for the plugin and then
// call the executable specified by the parameter 'main'
func callPluginExecutable(pluginName string, main string, argv []string, out io.Writer) error {
env := os.Environ()
for k, v := range settings.EnvVars() {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
mainCmdExp := os.ExpandEnv(main)
prog := exec.Command(mainCmdExp, argv...)
prog.Env = env
prog.Stdin = os.Stdin
prog.Stdout = out
prog.Stderr = os.Stderr
if err := prog.Run(); err != nil {
if eerr, ok := err.(*exec.ExitError); ok {
os.Stderr.Write(eerr.Stderr)
status := eerr.Sys().(syscall.WaitStatus)
return PluginError{
error: fmt.Errorf("plugin %q exited with error", pluginName),
Code: status.ExitStatus(),
}
}
return err
}
return nil
}
// manuallyProcessArgs processes an arg array, removing special args.
//
// Returns two sets of args: known and unknown (in that order)
@ -200,10 +220,10 @@ type pluginCommand struct {
// loadCompletionForPlugin will load and parse any completion.yaml provided by the plugin
// and add the dynamic completion hook to call the optional plugin.complete
func loadCompletionForPlugin(pluginCmd *cobra.Command, plugin *plugin.Plugin) {
func loadCompletionForPlugin(pluginCmd *cobra.Command, plug plugin.Plugin) {
// Parse the yaml file providing the plugin's sub-commands and flags
cmds, err := loadFile(strings.Join(
[]string{plugin.Dir, pluginStaticCompletionFile}, string(filepath.Separator)))
[]string{plug.Dir(), pluginStaticCompletionFile}, string(filepath.Separator)))
if err != nil {
// The file could be missing or invalid. No static completion for this plugin.
@ -217,12 +237,12 @@ func loadCompletionForPlugin(pluginCmd *cobra.Command, plugin *plugin.Plugin) {
// Preserve the Usage string specified for the plugin
cmds.Name = pluginCmd.Use
addPluginCommands(plugin, pluginCmd, cmds)
addPluginCommands(plug, pluginCmd, cmds)
}
// addPluginCommands is a recursive method that adds each different level
// of sub-commands and flags for the plugins that have provided such information
func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *pluginCommand) {
func addPluginCommands(plug plugin.Plugin, baseCmd *cobra.Command, cmds *pluginCommand) {
if cmds == nil {
return
}
@ -245,7 +265,7 @@ func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *plug
// calling plugin.complete at every completion, which greatly simplifies
// development of plugin.complete for plugin developers.
baseCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return pluginDynamicComp(plugin, cmd, args, toComplete)
return pluginDynamicComp(plug, cmd, args, toComplete)
}
}
@ -300,7 +320,7 @@ func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *plug
Run: func(_ *cobra.Command, _ []string) {},
}
baseCmd.AddCommand(subCmd)
addPluginCommands(plugin, subCmd, &cmd)
addPluginCommands(plug, subCmd, &cmd)
}
}
@ -319,8 +339,19 @@ func loadFile(path string) (*pluginCommand, error) {
// pluginDynamicComp call the plugin.complete script of the plugin (if available)
// to obtain the dynamic completion choices. It must pass all the flags and sub-commands
// specified in the command-line to the plugin.complete executable (except helm's global flags)
func pluginDynamicComp(plug *plugin.Plugin, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
md := plug.Metadata
func pluginDynamicComp(plug plugin.Plugin, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
subprocessPlug, ok := plug.(*plugin.SubprocessPluginRuntime)
if !ok {
// Completion only supported for subprocess plugins (TODO: fix this)
cobra.CompDebugln(fmt.Sprintf("Unsupported plugin runtime: %q", plug.Metadata().Runtime), settings.Debug)
return nil, cobra.ShellCompDirectiveDefault
}
var ignoreFlags bool
if cliConfig, ok := subprocessPlug.Metadata().Config.(*plugin.ConfigCLI); ok {
ignoreFlags = cliConfig.IgnoreFlags
}
u, err := processParent(cmd, args)
if err != nil {
@ -328,21 +359,29 @@ func pluginDynamicComp(plug *plugin.Plugin, cmd *cobra.Command, args []string, t
}
// We will call the dynamic completion script of the plugin
main := strings.Join([]string{plug.Dir, pluginDynamicCompletionExecutable}, string(filepath.Separator))
main := strings.Join([]string{plug.Dir(), pluginDynamicCompletionExecutable}, string(filepath.Separator))
// We must include all sub-commands passed on the command-line.
// To do that, we pass-in the entire CommandPath, except the first two elements
// which are 'helm' and 'pluginName'.
argv := strings.Split(cmd.CommandPath(), " ")[2:]
if !md.IgnoreFlags {
if !ignoreFlags {
argv = append(argv, u...)
argv = append(argv, toComplete)
}
plugin.SetupPluginEnv(settings, md.Name, plug.Dir)
plugin.SetupPluginEnv(settings, plug.Metadata().Name, plug.Dir())
cobra.CompDebugln(fmt.Sprintf("calling %s with args %v", main, argv), settings.Debug)
buf := new(bytes.Buffer)
if err := callPluginExecutable(md.Name, main, argv, buf); err != nil {
// Prepare environment
env := os.Environ()
for k, v := range settings.EnvVars() {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
// For subprocess runtime, use InvokeWithEnv for dynamic completion
if err := subprocessPlug.InvokeWithEnv(main, argv, env, nil, buf, buf); err != nil {
// The dynamic completion file is optional for a plugin, so this error is ok.
cobra.CompDebugln(fmt.Sprintf("Unable to call %s: %v", main, err.Error()), settings.Debug)
return nil, cobra.ShellCompDirectiveDefault

@ -16,11 +16,7 @@ limitations under the License.
package cmd
import (
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"github.com/spf13/cobra"
@ -47,35 +43,12 @@ func newPluginCmd(out io.Writer) *cobra.Command {
}
// runHook will execute a plugin hook.
func runHook(p *plugin.Plugin, event string) error {
plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir)
cmds := p.Metadata.PlatformHooks[event]
expandArgs := true
if len(cmds) == 0 && len(p.Metadata.Hooks) > 0 {
cmd := p.Metadata.Hooks[event]
if len(cmd) > 0 {
cmds = []plugin.PlatformCommand{{Command: "sh", Args: []string{"-c", cmd}}}
expandArgs = false
}
}
main, argv, err := plugin.PrepareCommands(cmds, expandArgs, []string{})
if err != nil {
return nil
func runHook(p plugin.Plugin, event string) error {
pluginHook, ok := p.(plugin.PluginHook)
if ok {
plugin.SetupPluginEnv(settings, p.Metadata().Name, p.Dir())
return pluginHook.InvokeHook(event)
}
prog := exec.Command(main, argv...)
slog.Debug("running hook", "event", event, "program", prog)
prog.Stdout, prog.Stderr = os.Stdout, os.Stderr
if err := prog.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, p.Metadata.Name)
}
return err
}
return nil
}

@ -89,6 +89,6 @@ func (o *pluginInstallOptions) run(out io.Writer) error {
return err
}
fmt.Fprintf(out, "Installed plugin: %s\n", p.Metadata.Name)
fmt.Fprintf(out, "Installed plugin: %s\n", p.Metadata().Name)
return nil
}

@ -19,6 +19,7 @@ import (
"fmt"
"io"
"log/slog"
"path/filepath"
"slices"
"github.com/gosuri/uitable"
@ -28,6 +29,7 @@ import (
)
func newPluginListCmd(out io.Writer) *cobra.Command {
var pluginType string
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
@ -35,33 +37,46 @@ func newPluginListCmd(out io.Writer) *cobra.Command {
ValidArgsFunction: noMoreArgsCompFunc,
RunE: func(_ *cobra.Command, _ []string) error {
slog.Debug("pluginDirs", "directory", settings.PluginsDirectory)
plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
dirs := filepath.SplitList(settings.PluginsDirectory)
descriptor := plugin.Descriptor{
Type: pluginType,
}
plugins, err := plugin.FindPlugins(dirs, descriptor)
if err != nil {
return err
}
table := uitable.New()
table.AddRow("NAME", "VERSION", "DESCRIPTION")
table.AddRow("NAME", "VERSION", "TYPE", "APIVERSION", "SOURCE")
for _, p := range plugins {
table.AddRow(p.Metadata.Name, p.Metadata.Version, p.Metadata.Description)
m := p.Metadata()
sourceURL := m.SourceURL
if sourceURL == "" {
sourceURL = "unknown"
}
table.AddRow(m.Name, m.Version, m.Type, m.APIVersion, sourceURL)
}
fmt.Fprintln(out, table)
return nil
},
}
f := cmd.Flags()
f.StringVar(&pluginType, "type", "", "Plugin type")
return cmd
}
// Returns all plugins from plugins, except those with names matching ignoredPluginNames
func filterPlugins(plugins []*plugin.Plugin, ignoredPluginNames []string) []*plugin.Plugin {
// if ignoredPluginNames is nil, just return plugins
if ignoredPluginNames == nil {
func filterPlugins(plugins []plugin.Plugin, ignoredPluginNames []string) []plugin.Plugin {
// if ignoredPluginNames is nil or empty, just return plugins
if len(ignoredPluginNames) == 0 {
return plugins
}
var filteredPlugins []*plugin.Plugin
var filteredPlugins []plugin.Plugin
for _, plugin := range plugins {
found := slices.Contains(ignoredPluginNames, plugin.Metadata.Name)
found := slices.Contains(ignoredPluginNames, plugin.Metadata().Name)
if !found {
filteredPlugins = append(filteredPlugins, plugin)
}
@ -73,11 +88,20 @@ func filterPlugins(plugins []*plugin.Plugin, ignoredPluginNames []string) []*plu
// Provide dynamic auto-completion for plugin names
func compListPlugins(_ string, ignoredPluginNames []string) []string {
var pNames []string
plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
dirs := filepath.SplitList(settings.PluginsDirectory)
descriptor := plugin.Descriptor{
Type: "cli/v1",
}
plugins, err := plugin.FindPlugins(dirs, descriptor)
if err == nil && len(plugins) > 0 {
filteredPlugins := filterPlugins(plugins, ignoredPluginNames)
for _, p := range filteredPlugins {
pNames = append(pNames, fmt.Sprintf("%s\t%s", p.Metadata.Name, p.Metadata.Usage))
m := p.Metadata()
var shortHelp string
if config, ok := m.Config.(*plugin.ConfigCLI); ok {
shortHelp = config.ShortHelp
}
pNames = append(pNames, fmt.Sprintf("%s\t%s", p.Metadata().Name, shortHelp))
}
}
return pNames

@ -19,12 +19,13 @@ import (
"bytes"
"os"
"runtime"
"sort"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
release "helm.sh/helm/v4/pkg/release/v1"
)
@ -81,7 +82,7 @@ func TestManuallyProcessArgs(t *testing.T) {
}
}
func TestLoadPlugins(t *testing.T) {
func TestLoadCLIPlugins(t *testing.T) {
settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
settings.RepositoryConfig = "testdata/helmhome/helm/repositories.yaml"
settings.RepositoryCache = "testdata/helmhome/helm/repository"
@ -90,7 +91,7 @@ func TestLoadPlugins(t *testing.T) {
out bytes.Buffer
cmd cobra.Command
)
loadPlugins(&cmd, &out)
loadCLIPlugins(&cmd, &out)
envs := strings.Join([]string{
"fullenv",
@ -119,9 +120,7 @@ func TestLoadPlugins(t *testing.T) {
plugins := cmd.Commands()
if len(plugins) != len(tests) {
t.Fatalf("Expected %d plugins, got %d", len(tests), len(plugins))
}
require.Len(t, plugins, len(tests), "Expected %d plugins, got %d", len(tests), len(plugins))
for i := 0; i < len(plugins); i++ {
out.Reset()
@ -153,9 +152,7 @@ func TestLoadPlugins(t *testing.T) {
t.Errorf("Error running %s: %+v", tt.use, err)
}
}
if out.String() != tt.expect {
t.Errorf("Expected %s to output:\n%s\ngot\n%s", tt.use, tt.expect, out.String())
}
assert.Equal(t, tt.expect, out.String(), "expected output for %s", tt.use)
}
}
}
@ -169,7 +166,7 @@ func TestLoadPluginsWithSpace(t *testing.T) {
out bytes.Buffer
cmd cobra.Command
)
loadPlugins(&cmd, &out)
loadCLIPlugins(&cmd, &out)
envs := strings.Join([]string{
"fullenv",
@ -228,9 +225,7 @@ func TestLoadPluginsWithSpace(t *testing.T) {
t.Errorf("Error running %s: %+v", tt.use, err)
}
}
if out.String() != tt.expect {
t.Errorf("Expected %s to output:\n%s\ngot\n%s", tt.use, tt.expect, out.String())
}
assert.Equal(t, tt.expect, out.String(), "expected output for %s", tt.use)
}
}
}
@ -242,7 +237,7 @@ type staticCompletionDetails struct {
next []staticCompletionDetails
}
func TestLoadPluginsForCompletion(t *testing.T) {
func TestLoadCLIPluginsForCompletion(t *testing.T) {
settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
var out bytes.Buffer
@ -250,8 +245,7 @@ func TestLoadPluginsForCompletion(t *testing.T) {
cmd := &cobra.Command{
Use: "completion",
}
loadPlugins(cmd, &out)
loadCLIPlugins(cmd, &out)
tests := []staticCompletionDetails{
{"args", []string{}, []string{}, []staticCompletionDetails{}},
@ -276,30 +270,17 @@ func TestLoadPluginsForCompletion(t *testing.T) {
func checkCommand(t *testing.T, plugins []*cobra.Command, tests []staticCompletionDetails) {
t.Helper()
if len(plugins) != len(tests) {
t.Fatalf("Expected commands %v, got %v", tests, plugins)
}
require.Len(t, plugins, len(tests), "Expected commands %v, got %v", tests, plugins)
for i := 0; i < len(plugins); i++ {
is := assert.New(t)
for i := range plugins {
pp := plugins[i]
tt := tests[i]
if pp.Use != tt.use {
t.Errorf("%s: Expected Use=%q, got %q", pp.Name(), tt.use, pp.Use)
}
is.Equal(pp.Use, tt.use, "Expected Use=%q, got %q", tt.use, pp.Use)
targs := tt.validArgs
pargs := pp.ValidArgs
if len(targs) != len(pargs) {
t.Fatalf("%s: expected args %v, got %v", pp.Name(), targs, pargs)
}
sort.Strings(targs)
sort.Strings(pargs)
for j := range targs {
if targs[j] != pargs[j] {
t.Errorf("%s: expected validArg=%q, got %q", pp.Name(), targs[j], pargs[j])
}
}
is.ElementsMatch(targs, pargs)
tflags := tt.flags
var pflags []string
@ -309,17 +290,8 @@ func checkCommand(t *testing.T, plugins []*cobra.Command, tests []staticCompleti
pflags = append(pflags, flag.Shorthand)
}
})
if len(tflags) != len(pflags) {
t.Fatalf("%s: expected flags %v, got %v", pp.Name(), tflags, pflags)
}
is.ElementsMatch(tflags, pflags)
sort.Strings(tflags)
sort.Strings(pflags)
for j := range tflags {
if tflags[j] != pflags[j] {
t.Errorf("%s: expected flag=%q, got %q", pp.Name(), tflags[j], pflags[j])
}
}
// Check the next level
checkCommand(t, pp.Commands(), tt.next)
}
@ -358,7 +330,7 @@ func TestPluginDynamicCompletion(t *testing.T) {
}
}
func TestLoadPlugins_HelmNoPlugins(t *testing.T) {
func TestLoadCLIPlugins_HelmNoPlugins(t *testing.T) {
settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
settings.RepositoryConfig = "testdata/helmhome/helm/repository"
@ -366,7 +338,7 @@ func TestLoadPlugins_HelmNoPlugins(t *testing.T) {
out := bytes.NewBuffer(nil)
cmd := &cobra.Command{}
loadPlugins(cmd, out)
loadCLIPlugins(cmd, out)
plugins := cmd.Commands()
if len(plugins) != 0 {

@ -61,7 +61,7 @@ func (o *pluginUninstallOptions) complete(args []string) error {
func (o *pluginUninstallOptions) run(out io.Writer) error {
slog.Debug("loading installer plugins", "dir", settings.PluginsDirectory)
plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
plugins, err := plugin.LoadAll(settings.PluginsDirectory)
if err != nil {
return err
}
@ -83,16 +83,17 @@ func (o *pluginUninstallOptions) run(out io.Writer) error {
return nil
}
func uninstallPlugin(p *plugin.Plugin) error {
if err := os.RemoveAll(p.Dir); err != nil {
func uninstallPlugin(p plugin.Plugin) error {
if err := os.RemoveAll(p.Dir()); err != nil {
return err
}
return runHook(p, plugin.Delete)
}
func findPlugin(plugins []*plugin.Plugin, name string) *plugin.Plugin {
// TODO should this be in pkg/plugin/loader.go?
func findPlugin(plugins []plugin.Plugin, name string) plugin.Plugin {
for _, p := range plugins {
if p.Metadata.Name == name {
if p.Metadata().Name == name {
return p
}
}

@ -63,7 +63,7 @@ func (o *pluginUpdateOptions) complete(args []string) error {
func (o *pluginUpdateOptions) run(out io.Writer) error {
installer.Debug = settings.Debug
slog.Debug("loading installed plugins", "path", settings.PluginsDirectory)
plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
plugins, err := plugin.LoadAll(settings.PluginsDirectory)
if err != nil {
return err
}
@ -86,8 +86,8 @@ func (o *pluginUpdateOptions) run(out io.Writer) error {
return nil
}
func updatePlugin(p *plugin.Plugin) error {
exactLocation, err := filepath.EvalSymlinks(p.Dir)
func updatePlugin(p plugin.Plugin) error {
exactLocation, err := filepath.EvalSymlinks(p.Dir())
if err != nil {
return err
}

@ -291,8 +291,8 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg
newPushCmd(actionConfig, out),
)
// Find and add plugins
loadPlugins(cmd, out)
// Find and add CLI plugins
loadCLIPlugins(cmd, out)
// Check for expired repositories
checkForExpiredRepos(settings.RepositoryConfig)

@ -1,4 +0,0 @@
name: testplugin
usage: "echo test"
description: "This echos test"
command: "echo test"

@ -27,10 +27,11 @@ import (
"helm.sh/helm/v4/pkg/registry"
)
// options are generic parameters to be provided to the getter during instantiation.
// getterOptions are generic parameters to be provided to the getter during instantiation.
//
// Getters may or may not ignore these parameters as they are passed in.
type options struct {
// TODO what is the difference between this and schema.GetterOptionsV1?
type getterOptions struct {
url string
certFile string
keyFile string
@ -51,54 +52,54 @@ type options struct {
// Option allows specifying various settings configurable by the user for overriding the defaults
// used when performing Get operations with the Getter.
type Option func(*options)
type Option func(*getterOptions)
// WithURL informs the getter the server name that will be used when fetching objects. Used in conjunction with
// WithTLSClientConfig to set the TLSClientConfig's server name.
func WithURL(url string) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.url = url
}
}
// WithAcceptHeader sets the request's Accept header as some REST APIs serve multiple content types
func WithAcceptHeader(header string) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.acceptHeader = header
}
}
// WithBasicAuth sets the request's Authorization header to use the provided credentials
func WithBasicAuth(username, password string) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.username = username
opts.password = password
}
}
func WithPassCredentialsAll(pass bool) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.passCredentialsAll = pass
}
}
// WithUserAgent sets the request's User-Agent header to use the provided agent name.
func WithUserAgent(userAgent string) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.userAgent = userAgent
}
}
// WithInsecureSkipVerifyTLS determines if a TLS Certificate will be checked
func WithInsecureSkipVerifyTLS(insecureSkipVerifyTLS bool) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.insecureSkipVerifyTLS = insecureSkipVerifyTLS
}
}
// WithTLSClientConfig sets the client auth with the provided credentials.
func WithTLSClientConfig(certFile, keyFile, caFile string) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.certFile = certFile
opts.keyFile = keyFile
opts.caFile = caFile
@ -106,39 +107,39 @@ func WithTLSClientConfig(certFile, keyFile, caFile string) Option {
}
func WithPlainHTTP(plainHTTP bool) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.plainHTTP = plainHTTP
}
}
// WithTimeout sets the timeout for requests
func WithTimeout(timeout time.Duration) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.timeout = timeout
}
}
func WithTagName(tagname string) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.version = tagname
}
}
func WithRegistryClient(client *registry.Client) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.registryClient = client
}
}
func WithUntar() Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.unTar = true
}
}
// WithTransport sets the http.Transport to allow overwriting the HTTPGetter default.
func WithTransport(transport *http.Transport) Option {
return func(opts *options) {
return func(opts *getterOptions) {
opts.transport = transport
}
}
@ -217,7 +218,7 @@ func Getters(extraOpts ...Option) Providers {
// notations are collected.
func All(settings *cli.EnvSettings, opts ...Option) Providers {
result := Getters(opts...)
pluginDownloaders, _ := collectPlugins(settings)
pluginDownloaders, _ := collectGetterPlugins(settings)
result = append(result, pluginDownloaders...)
return result
}

@ -30,7 +30,7 @@ import (
// HTTPGetter is the default HTTP(/S) backend handler
type HTTPGetter struct {
opts options
opts getterOptions
transport *http.Transport
once sync.Once
}

@ -50,7 +50,7 @@ func TestHTTPGetter(t *testing.T) {
timeout := time.Second * 5
transport := &http.Transport{}
// Test with options
// Test with getterOptions
g, err = NewHTTPGetter(
WithBasicAuth("I", "Am"),
WithPassCredentialsAll(false),

@ -33,7 +33,7 @@ import (
// OCIGetter is the default HTTP(/S) backend handler
type OCIGetter struct {
opts options
opts getterOptions
transport *http.Transport
once sync.Once
}
@ -63,6 +63,8 @@ func (g *OCIGetter) get(href string) (*bytes.Buffer, error) {
if version := g.opts.version; version != "" && !strings.Contains(path.Base(ref), ":") {
ref = fmt.Sprintf("%s:%s", ref, version)
}
// Default to chart behavior for backward compatibility
var pullOpts []registry.PullOption
requestingProv := strings.HasSuffix(ref, ".prov")
if requestingProv {

@ -42,7 +42,7 @@ func TestOCIGetter(t *testing.T) {
insecureSkipVerifyTLS := false
plainHTTP := false
// Test with options
// Test with getterOptions
g, err = NewOCIGetter(
WithBasicAuth("I", "Am"),
WithTLSClientConfig(pub, priv, ca),

@ -17,92 +17,109 @@ package getter
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"net/url"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/internal/plugin/schema"
"helm.sh/helm/v4/pkg/cli"
)
// collectPlugins scans for getter plugins.
// collectGetterPlugins scans for getter plugins.
// This will load plugins according to the cli.
func collectPlugins(settings *cli.EnvSettings) (Providers, error) {
plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
func collectGetterPlugins(settings *cli.EnvSettings) (Providers, error) {
d := plugin.Descriptor{
Type: "getter/v1",
}
plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d)
if err != nil {
return nil, err
}
var result Providers
for _, plugin := range plugins {
for _, downloader := range plugin.Metadata.Downloaders {
result = append(result, Provider{
Schemes: downloader.Protocols,
New: NewPluginGetter(
downloader.Command,
settings,
plugin.Metadata.Name,
plugin.Dir,
),
pluginConstructorBuilder := func(plg plugin.Plugin) Constructor {
return func(option ...Option) (Getter, error) {
return &getterPlugin{
options: append([]Option{}, option...),
plg: plg,
}, nil
}
}
results := make([]Provider, 0, len(plgs))
for _, plg := range plgs {
if c, ok := plg.Metadata().Config.(*plugin.ConfigGetter); ok {
results = append(results, Provider{
Schemes: c.Protocols,
New: pluginConstructorBuilder(plg),
})
}
}
return result, nil
return results, nil
}
// pluginGetter is a generic type to invoke custom downloaders,
// implemented in plugins.
type pluginGetter struct {
command string
settings *cli.EnvSettings
name string
base string
opts options
func convertOptions(globalOptions, options []Option) schema.GetterOptionsV1 {
opts := getterOptions{}
for _, opt := range globalOptions {
opt(&opts)
}
for _, opt := range options {
opt(&opts)
}
result := schema.GetterOptionsV1{
URL: opts.url,
CertFile: opts.certFile,
KeyFile: opts.keyFile,
CAFile: opts.caFile,
UNTar: opts.unTar,
InsecureSkipVerifyTLS: opts.insecureSkipVerifyTLS,
PlainHTTP: opts.plainHTTP,
AcceptHeader: opts.acceptHeader,
Username: opts.username,
Password: opts.password,
PassCredentialsAll: opts.passCredentialsAll,
UserAgent: opts.userAgent,
Version: opts.version,
Timeout: opts.timeout,
}
return result
}
func (p *pluginGetter) setupOptionsEnv(env []string) []string {
env = append(env, fmt.Sprintf("HELM_PLUGIN_USERNAME=%s", p.opts.username))
env = append(env, fmt.Sprintf("HELM_PLUGIN_PASSWORD=%s", p.opts.password))
env = append(env, fmt.Sprintf("HELM_PLUGIN_PASS_CREDENTIALS_ALL=%t", p.opts.passCredentialsAll))
return env
type getterPlugin struct {
options []Option
plg plugin.Plugin
}
// Get runs downloader plugin command
func (p *pluginGetter) Get(href string, options ...Option) (*bytes.Buffer, error) {
for _, opt := range options {
opt(&p.opts)
}
commands := strings.Split(p.command, " ")
argv := append(commands[1:], p.opts.certFile, p.opts.keyFile, p.opts.caFile, href)
prog := exec.Command(filepath.Join(p.base, commands[0]), argv...)
plugin.SetupPluginEnv(p.settings, p.name, p.base)
prog.Env = p.setupOptionsEnv(os.Environ())
buf := bytes.NewBuffer(nil)
prog.Stdout = buf
prog.Stderr = os.Stderr
if err := prog.Run(); err != nil {
if eerr, ok := err.(*exec.ExitError); ok {
os.Stderr.Write(eerr.Stderr)
return nil, fmt.Errorf("plugin %q exited with error", p.command)
}
func (g *getterPlugin) Get(href string, options ...Option) (*bytes.Buffer, error) {
opts := convertOptions(g.options, options)
// TODO optimization: pass this along to Get() instead of re-parsing here
u, err := url.Parse(href)
if err != nil {
return nil, err
}
return buf, nil
}
// NewPluginGetter constructs a valid plugin getter
func NewPluginGetter(command string, settings *cli.EnvSettings, name, base string) Constructor {
return func(options ...Option) (Getter, error) {
result := &pluginGetter{
command: command,
settings: settings,
name: name,
base: base,
}
for _, opt := range options {
opt(&result.opts)
}
return result, nil
input := &plugin.Input{
Message: schema.InputMessageGetterV1{
Href: href,
Options: opts,
Protocol: u.Scheme,
},
// TODO should we pass Stdin, Stdout, and Stderr through Input here to getter plugins?
//Stdout: os.Stdout,
}
output, err := g.plg.Invoke(context.Background(), input)
if err != nil {
return nil, fmt.Errorf("plugin %q failed to invoke: %w", g.plg, err)
}
outputMessage, ok := output.Message.(*schema.OutputMessageGetterV1)
if !ok {
return nil, fmt.Errorf("invalid output message type from plugin %q", g.plg.Metadata().Name)
}
return bytes.NewBuffer(outputMessage.Data), nil
}

@ -16,9 +16,16 @@ limitations under the License.
package getter
import (
"runtime"
"strings"
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/internal/plugin/schema"
"helm.sh/helm/v4/pkg/cli"
)
@ -27,7 +34,7 @@ func TestCollectPlugins(t *testing.T) {
env := cli.New()
env.PluginsDirectory = pluginDir
p, err := collectPlugins(env)
p, err := collectGetterPlugins(env)
if err != nil {
t.Fatal(err)
}
@ -49,53 +56,88 @@ func TestCollectPlugins(t *testing.T) {
}
}
func TestPluginGetter(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("TODO: refactor this test to work on windows")
func TestConvertOptions(t *testing.T) {
opts := convertOptions(
[]Option{
WithURL("example://foo"),
WithAcceptHeader("Accept-Header"),
WithBasicAuth("username", "password"),
WithPassCredentialsAll(true),
WithUserAgent("User-agent"),
WithInsecureSkipVerifyTLS(true),
WithTLSClientConfig("certFile.pem", "keyFile.pem", "caFile.pem"),
WithPlainHTTP(true),
WithTimeout(10),
WithTagName("1.2.3"),
WithUntar(),
},
[]Option{
WithTimeout(20),
},
)
expected := schema.GetterOptionsV1{
URL: "example://foo",
CertFile: "certFile.pem",
KeyFile: "keyFile.pem",
CAFile: "caFile.pem",
UNTar: true,
Timeout: 20,
InsecureSkipVerifyTLS: true,
PlainHTTP: true,
AcceptHeader: "Accept-Header",
Username: "username",
Password: "password",
PassCredentialsAll: true,
UserAgent: "User-agent",
Version: "1.2.3",
}
assert.Equal(t, expected, opts)
}
env := cli.New()
env.PluginsDirectory = pluginDir
pg := NewPluginGetter("echo", env, "test", ".")
g, err := pg()
if err != nil {
t.Fatal(err)
}
type TestPlugin struct {
t *testing.T
dir string
}
data, err := g.Get("test://foo/bar")
if err != nil {
t.Fatal(err)
}
func (t *TestPlugin) Dir() string {
return t.dir
}
expect := "test://foo/bar"
got := strings.TrimSpace(data.String())
if got != expect {
t.Errorf("Expected %q, got %q", expect, got)
func (t *TestPlugin) Metadata() plugin.Metadata {
return plugin.Metadata{
Name: "fake-plugin",
Config: &plugin.ConfigGetter{},
RuntimeConfig: &plugin.RuntimeConfigSubprocess{
PlatformCommands: []plugin.PlatformCommand{
{
Command: "echo fake-plugin",
},
},
},
}
}
func TestPluginSubCommands(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("TODO: refactor this test to work on windows")
func (t *TestPlugin) Invoke(_ context.Context, _ *plugin.Input) (*plugin.Output, error) {
// Simulate a plugin invocation
output := &plugin.Output{
Message: &schema.OutputMessageGetterV1{
Data: []byte("fake-plugin output"),
},
}
return output, nil
}
env := cli.New()
env.PluginsDirectory = pluginDir
var _ plugin.Plugin = (*TestPlugin)(nil)
pg := NewPluginGetter("echo -n", env, "test", ".")
g, err := pg()
if err != nil {
t.Fatal(err)
func TestGetterPlugin(t *testing.T) {
gp := getterPlugin{
options: []Option{},
plg: &TestPlugin{t: t, dir: "fake/dir"},
}
data, err := g.Get("test://foo/bar")
if err != nil {
t.Fatal(err)
}
buf, err := gp.Get("test://example.com", WithTimeout(5*time.Second))
require.NoError(t, err)
expect := " test://foo/bar"
got := data.String()
if got != expect {
t.Errorf("Expected %q, got %q", expect, got)
}
assert.Equal(t, "fake-plugin output", buf.String())
}

@ -1,8 +0,0 @@
#!/bin/bash
echo ENVIRONMENT
env
echo ""
echo ARGUMENTS
echo $@

@ -1,15 +1,6 @@
name: "testgetter"
version: "0.1.0"
usage: "Fetch a package from a test:// source"
description: |-
Print the environment that the plugin was given, then exit.
This registers the test:// protocol.
command: "$HELM_PLUGIN_DIR/get.sh"
ignoreFlags: true
downloaders:
#- command: "$HELM_PLUGIN_DIR/get.sh"
- command: "echo"
protocols:
- "test"
- command: "echo"
protocols:
- "test"

@ -1,8 +0,0 @@
#!/bin/bash
echo ENVIRONMENT
env
echo ""
echo ARGUMENTS
echo $@

@ -1,10 +1,6 @@
name: "testgetter2"
version: "0.1.0"
usage: "Fetch a different package from a test2:// source"
description: "Handle test2 scheme"
command: "$HELM_PLUGIN_DIR/get.sh"
ignoreFlags: true
downloaders:
- command: "echo"
protocols:
- "test2"
- command: "echo"
protocols:
- "test2"

Loading…
Cancel
Save