Create plugin manager

Signed-off-by: George Jenkins <gvjenkins@gmail.com>
pull/31226/head
George Jenkins 1 month ago
parent 618b14a772
commit abd4a326f0

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

@ -28,7 +28,7 @@ An example of a plugin invocation:
d := plugin.Descriptor{ d := plugin.Descriptor{
Type: "example/v1", // Type: "example/v1", //
} }
plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d) plgs, err := settings.PluginCatalog.FindPlugins(d)
for _, plg := range plgs { for _, plg := range plgs {
input := &plugin.Input{ input := &plugin.Input{

@ -44,7 +44,7 @@ type HTTPInstaller struct {
} }
// NewHTTPInstaller creates a new HttpInstaller. // NewHTTPInstaller creates a new HttpInstaller.
func NewHTTPInstaller(source string) (*HTTPInstaller, error) { func NewHTTPInstaller(settings *cli.EnvSettings, source string) (*HTTPInstaller, error) {
key, err := cache.Key(source) key, err := cache.Key(source)
if err != nil { if err != nil {
return nil, err return nil, err
@ -55,7 +55,7 @@ func NewHTTPInstaller(source string) (*HTTPInstaller, error) {
return nil, err return nil, err
} }
get, err := getter.All(new(cli.EnvSettings)).ByScheme("http") get, err := getter.All(settings).ByScheme("http")
if err != nil { if err != nil {
return nil, err return nil, err
} }

@ -32,6 +32,7 @@ import (
"testing" "testing"
"helm.sh/helm/v4/internal/test/ensure" "helm.sh/helm/v4/internal/test/ensure"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/helmpath"
) )
@ -81,6 +82,8 @@ func mockArchiveServer() *httptest.Server {
func TestHTTPInstaller(t *testing.T) { func TestHTTPInstaller(t *testing.T) {
ensure.HelmHome(t) ensure.HelmHome(t)
settings := cli.New()
srv := mockArchiveServer() srv := mockArchiveServer()
defer srv.Close() defer srv.Close()
source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz"
@ -89,7 +92,7 @@ func TestHTTPInstaller(t *testing.T) {
t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err) t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err)
} }
i, err := NewForSource(source, "0.0.1") i, err := NewForSource(settings, source, "0.0.1")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
@ -129,6 +132,9 @@ func TestHTTPInstaller(t *testing.T) {
func TestHTTPInstallerNonExistentVersion(t *testing.T) { func TestHTTPInstallerNonExistentVersion(t *testing.T) {
ensure.HelmHome(t) ensure.HelmHome(t)
settings := cli.New()
srv := mockArchiveServer() srv := mockArchiveServer()
defer srv.Close() defer srv.Close()
source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz"
@ -137,7 +143,7 @@ func TestHTTPInstallerNonExistentVersion(t *testing.T) {
t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err) t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err)
} }
i, err := NewForSource(source, "0.0.2") i, err := NewForSource(settings, source, "0.0.2")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
@ -161,16 +167,20 @@ func TestHTTPInstallerNonExistentVersion(t *testing.T) {
} }
func TestHTTPInstallerUpdate(t *testing.T) { func TestHTTPInstallerUpdate(t *testing.T) {
ensure.HelmHome(t)
settings := cli.New()
srv := mockArchiveServer() srv := mockArchiveServer()
defer srv.Close() defer srv.Close()
source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz"
ensure.HelmHome(t)
if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil { if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil {
t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err) t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err)
} }
i, err := NewForSource(source, "0.0.1") i, err := NewForSource(settings, source, "0.0.1")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }

@ -24,6 +24,7 @@ import (
"strings" "strings"
"helm.sh/helm/v4/internal/plugin" "helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/registry" "helm.sh/helm/v4/pkg/registry"
) )
@ -136,7 +137,7 @@ func Update(i Installer) error {
} }
// NewForSource determines the correct Installer for the given source. // NewForSource determines the correct Installer for the given source.
func NewForSource(source, version string) (Installer, error) { func NewForSource(settings *cli.EnvSettings, source, version string) (Installer, error) {
// Check if source is an OCI registry reference // Check if source is an OCI registry reference
if strings.HasPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme)) { if strings.HasPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme)) {
return NewOCIInstaller(source) return NewOCIInstaller(source)
@ -145,7 +146,7 @@ func NewForSource(source, version string) (Installer, error) {
if isLocalReference(source) { if isLocalReference(source) {
return NewLocalInstaller(source) return NewLocalInstaller(source)
} else if isRemoteHTTPArchive(source) { } else if isRemoteHTTPArchive(source) {
return NewHTTPInstaller(source) return NewHTTPInstaller(settings, source)
} }
return NewVCSInstaller(source, version) return NewVCSInstaller(source, version)
} }

@ -24,6 +24,7 @@ import (
"testing" "testing"
"helm.sh/helm/v4/internal/test/ensure" "helm.sh/helm/v4/internal/test/ensure"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/helmpath"
) )
@ -31,14 +32,16 @@ var _ Installer = new(LocalInstaller)
func TestLocalInstaller(t *testing.T) { func TestLocalInstaller(t *testing.T) {
ensure.HelmHome(t) ensure.HelmHome(t)
// Make a temp dir
settings := cli.New()
tdir := t.TempDir() tdir := t.TempDir()
if err := os.WriteFile(filepath.Join(tdir, "plugin.yaml"), []byte{}, 0644); err != nil { if err := os.WriteFile(filepath.Join(tdir, "plugin.yaml"), []byte{}, 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
source := "../testdata/plugdir/good/echo-v1" source := "../testdata/plugdir/good/echo-v1"
i, err := NewForSource(source, "") i, err := NewForSource(settings, source, "")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
@ -54,8 +57,12 @@ func TestLocalInstaller(t *testing.T) {
} }
func TestLocalInstallerNotAFolder(t *testing.T) { func TestLocalInstallerNotAFolder(t *testing.T) {
ensure.HelmHome(t)
settings := cli.New()
source := "../testdata/plugdir/good/echo-v1/plugin.yaml" source := "../testdata/plugdir/good/echo-v1/plugin.yaml"
i, err := NewForSource(source, "") i, err := NewForSource(settings, source, "")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
@ -116,8 +123,10 @@ func TestLocalInstallerTarball(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
settings := cli.New()
// Test installation // Test installation
i, err := NewForSource(tarballPath, "") i, err := NewForSource(settings, tarballPath, "")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }

@ -61,12 +61,12 @@ func validatePluginName(pluginRoot string, expectedName string) error {
} }
// Load plugin.yaml to get the actual name // Load plugin.yaml to get the actual name
p, err := plugin.LoadDir(pluginRoot) pr, err := plugin.LoadDirRaw(pluginRoot)
if err != nil { if err != nil {
return fmt.Errorf("failed to load plugin from %s: %w", pluginRoot, err) return fmt.Errorf("failed to load plugin from %s: %w", pluginRoot, err)
} }
m := p.Metadata() m := pr.Metadata
actualName := m.Name actualName := m.Name
// For now, just log a warning if names don't match // For now, just log a warning if names don't match

@ -25,6 +25,7 @@ import (
"github.com/Masterminds/vcs" "github.com/Masterminds/vcs"
"helm.sh/helm/v4/internal/test/ensure" "helm.sh/helm/v4/internal/test/ensure"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/helmpath"
) )
@ -52,6 +53,8 @@ func (r *testRepo) UpdateVersion(version string) error {
func TestVCSInstaller(t *testing.T) { func TestVCSInstaller(t *testing.T) {
ensure.HelmHome(t) ensure.HelmHome(t)
settings := cli.New()
if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil { if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil {
t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err) t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err)
} }
@ -63,7 +66,7 @@ func TestVCSInstaller(t *testing.T) {
tags: []string{"0.1.0", "0.1.1"}, tags: []string{"0.1.0", "0.1.1"},
} }
i, err := NewForSource(source, "~0.1.0") i, err := NewForSource(settings, source, "~0.1.0")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
@ -106,10 +109,12 @@ func TestVCSInstaller(t *testing.T) {
func TestVCSInstallerNonExistentVersion(t *testing.T) { func TestVCSInstallerNonExistentVersion(t *testing.T) {
ensure.HelmHome(t) ensure.HelmHome(t)
settings := cli.New()
source := "https://github.com/adamreese/helm-env" source := "https://github.com/adamreese/helm-env"
version := "0.2.0" version := "0.2.0"
i, err := NewForSource(source, version) i, err := NewForSource(settings, source, version)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
@ -130,9 +135,11 @@ func TestVCSInstallerNonExistentVersion(t *testing.T) {
func TestVCSInstallerUpdate(t *testing.T) { func TestVCSInstallerUpdate(t *testing.T) {
ensure.HelmHome(t) ensure.HelmHome(t)
settings := cli.New()
source := "https://github.com/adamreese/helm-env" source := "https://github.com/adamreese/helm-env"
i, err := NewForSource(source, "") i, err := NewForSource(settings, source, "")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }

@ -17,18 +17,22 @@ package plugin
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
extism "github.com/extism/go-sdk"
"github.com/tetratelabs/wazero"
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
"helm.sh/helm/v4/pkg/helmpath"
) )
// PluginRaw is an "uninitialized" plugin that has not been bound to a runtime
type PluginRaw struct { //nolint:revive
Metadata Metadata
Dir string
}
func peekAPIVersion(r io.Reader) (string, error) { func peekAPIVersion(r io.Reader) (string, error) {
type apiVersion struct { type apiVersion struct {
APIVersion string `yaml:"apiVersion"` APIVersion string `yaml:"apiVersion"`
@ -101,166 +105,93 @@ func loadMetadata(metadataData []byte) (*Metadata, error) {
return nil, fmt.Errorf("invalid plugin apiVersion: %q", apiVersion) return nil, fmt.Errorf("invalid plugin apiVersion: %q", apiVersion)
} }
type prototypePluginManager struct { func GlobPluginDirs(baseDir string) ([]string, error) {
runtimes map[string]Runtime // We want baseDir/*/plugin.yaml
} scanpath := filepath.Join(baseDir, "*", PluginFileName)
matches, err := filepath.Glob(scanpath)
func newPrototypePluginManager() (*prototypePluginManager, error) {
cc, err := wazero.NewCompilationCacheWithDir(helmpath.CachePath("wazero-build"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create wazero compilation cache: %w", err) return nil, fmt.Errorf("failed to search for plugins in %q: %w", scanpath, err)
}
return &prototypePluginManager{
runtimes: map[string]Runtime{
"subprocess": &RuntimeSubprocess{},
"extism/v1": &RuntimeExtismV1{
HostFunctions: map[string]extism.HostFunction{},
CompilationCache: cc,
},
},
}, nil
}
func (pm *prototypePluginManager) RegisterRuntime(runtimeName string, runtime Runtime) {
pm.runtimes[runtimeName] = runtime
}
func (pm *prototypePluginManager) CreatePlugin(pluginPath string, metadata *Metadata) (Plugin, error) {
rt, ok := pm.runtimes[metadata.Runtime]
if !ok {
return nil, fmt.Errorf("unsupported plugin runtime type: %q", metadata.Runtime)
} }
return rt.CreatePlugin(pluginPath, metadata) return matches, nil
} }
// LoadDir loads a plugin from the given directory. // LoadDir loads a plugin source from the given directory
func LoadDir(dirname string) (Plugin, error) { func LoadDirRaw(pluginDir string) (*PluginRaw, error) {
pluginfile := filepath.Join(dirname, PluginFileName) pluginfile := filepath.Join(pluginDir, PluginFileName)
metadataData, err := os.ReadFile(pluginfile) metadataData, err := os.ReadFile(pluginfile)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read plugin at %q: %w", pluginfile, err) return nil, fmt.Errorf("failed to read plugin at %q: %w", pluginfile, err)
} }
m, err := loadMetadata(metadataData) metadata, err := loadMetadata(metadataData)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load plugin %q: %w", dirname, err) return nil, err
} }
pm, err := newPrototypePluginManager() pluginRaw := PluginRaw{
if err != nil { Metadata: *metadata,
return nil, fmt.Errorf("failed to create plugin manager: %w", err) Dir: pluginDir,
} }
return pm.CreatePlugin(dirname, m)
}
// LoadAll loads all plugins found beneath the base directory. return &pluginRaw, nil
// }
// 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 // findFunc is a function that finds plugin directories
if len(matches) == 0 { type findFunc func(pluginsDir string) ([]string, error)
return plugins, nil
}
for _, yamlFile := range matches { func NewDirLoader(store *Store, findFn findFunc) *DirLoader {
dir := filepath.Dir(yamlFile) return &DirLoader{
p, err := LoadDir(dir) Store: store,
if err != nil { FindFunc: findFn,
return plugins, err
} }
plugins = append(plugins, p)
}
return plugins, detectDuplicates(plugins)
} }
// findFunc is a function that finds plugins in a directory type DirLoader struct {
type findFunc func(pluginsDir string) ([]Plugin, error) Store *Store
FindFunc func(string) ([]string, 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 (l *DirLoader) Load(baseDirs []string) error {
func findPlugins(pluginsDirs []string, findFn findFunc, filterFn filterFunc) ([]Plugin, error) {
var found []Plugin
for _, pluginsDir := range pluginsDirs {
ps, err := findFn(pluginsDir)
if err != nil { store := NewStore()
return nil, err
}
for _, p := range ps { errs := []error{}
if filterFn(p) { for _, baseDir := range baseDirs {
found = append(found, p) slog.Debug("Loading plugins", "directory", baseDir)
} matches, err := l.FindFunc(baseDir)
if err != nil {
errs = append(errs, fmt.Errorf("failed to search for plugins in %q: %w", baseDir, err))
continue
} }
for _, yamlFile := range matches {
dir := filepath.Dir(yamlFile)
plugRaw, err := LoadDirRaw(dir)
if err != nil {
errs = append(errs, fmt.Errorf("failed to load plugin %q: %w", dir, err))
continue
} }
return found, nil actualPlugRaw, loaded := store.LoadOrStore(plugRaw)
} if loaded {
errs = append(errs, fmt.Errorf(
// makeDescriptorFilter creates a filter function from a descriptor "two plugins claim the name %q at %q and %q",
// Additional plugin filter criteria we wish to support can be added here plugRaw.Metadata.Name,
func makeDescriptorFilter(descriptor Descriptor) filterFunc { actualPlugRaw.Dir,
return func(p Plugin) bool { plugRaw.Dir,
// If name is specified, it must match ))
if descriptor.Name != "" && p.Metadata().Name != descriptor.Name { continue
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 { if err := errors.Join(errs...); err != nil {
return plugins[0], nil return err
} }
return nil, fmt.Errorf("plugin: %+v not found", descriptor) // Atomicly replace the store's plugins with the newly loaded plugins
} l.Store.plugins = store.plugins
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 return nil
} }

@ -17,13 +17,10 @@ package plugin
import ( import (
"bytes" "bytes"
"fmt"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"helm.sh/helm/v4/internal/plugin/schema"
) )
func TestPeekAPIVersion(t *testing.T) { func TestPeekAPIVersion(t *testing.T) {
@ -61,210 +58,3 @@ name: "test-plugin"
assert.Error(t, err) 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: &schema.ConfigCLIV1{
Usage: usage,
ShortHelp: "echo hello message",
LongHelp: "description",
IgnoreFlags: true,
},
RuntimeConfig: &RuntimeConfigSubprocess{
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"}},
},
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...\""}},
},
},
expandHookArgs: apiVersion == "legacy",
},
}
}
testCases := map[string]struct {
dirname string
apiVersion string
expect Metadata
}{
"legacy": {
dirname: "testdata/plugdir/good/hello-legacy",
apiVersion: "legacy",
expect: makeMetadata("legacy"),
},
"v1": {
dirname: "testdata/plugdir/good/hello-v1",
apiVersion: "v1",
expect: makeMetadata("v1"),
},
}
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",
"v1": "testdata/plugdir/bad/duplicate-entries-v1",
}
for name, dirname := range testCases {
t.Run(name, func(t *testing.T) {
_, err := LoadDir(dirname)
assert.Error(t, err)
})
}
}
func TestLoadDirGetter(t *testing.T) {
dirname := "testdata/plugdir/good/getter"
expect := Metadata{
Name: "getter",
Version: "1.2.3",
Type: "getter/v1",
APIVersion: "v1",
Runtime: "subprocess",
Config: &schema.ConfigGetterV1{
Protocols: []string{"myprotocol", "myprotocols"},
},
RuntimeConfig: &RuntimeConfigSubprocess{
ProtocolCommands: []SubprocessProtocolCommand{
{
Protocols: []string{"myprotocol", "myprotocols"},
PlatformCommand: []PlatformCommand{{Command: "echo getter"}},
},
},
},
}
plug, err := LoadDir(dirname)
require.NoError(t, err)
assert.Equal(t, dirname, plug.Dir())
assert.Equal(t, expect, plug.Metadata())
}
func TestPostRenderer(t *testing.T) {
dirname := "testdata/plugdir/good/postrenderer-v1"
expect := Metadata{
Name: "postrenderer-v1",
Version: "1.2.3",
Type: "postrenderer/v1",
APIVersion: "v1",
Runtime: "subprocess",
Config: &schema.ConfigPostRendererV1{},
RuntimeConfig: &RuntimeConfigSubprocess{
PlatformCommand: []PlatformCommand{
{
Command: "${HELM_PLUGIN_DIR}/sed-test.sh",
},
},
},
}
plug, err := LoadDir(dirname)
require.NoError(t, err)
assert.Equal(t, dirname, plug.Dir())
assert.Equal(t, expect, plug.Metadata())
}
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, 7)
assert.Contains(t, plugsMap, "downloader")
assert.Contains(t, plugsMap, "echo-legacy")
assert.Contains(t, plugsMap, "echo-v1")
assert.Contains(t, plugsMap, "getter")
assert.Contains(t, plugsMap, "hello-legacy")
assert.Contains(t, plugsMap, "hello-v1")
assert.Contains(t, plugsMap, "postrenderer-v1")
}
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: 7,
},
}
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,216 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package plugin
import (
"errors"
"fmt"
"sync"
"sync/atomic"
)
// 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/v1, getter/v1, postrenderer/v1, etc)
Type string
}
// Catalog is an interface for finding plugins
type Catalog interface {
FindPlugin(Descriptor) (Plugin, error)
FindPlugins(Descriptor) ([]Plugin, error)
}
// NewStore creates a new, empty plugin store
func NewStore() Store {
s := Store{}
s.plugins.Store(&sync.Map{})
return s
}
// Store is a concurrent access safe store for plugins
// Specifically, it is a wrapper around sync.Map for *PluginRaw
// It uses atomic.Value to allow for safe replacement of the underlying sync.Map
// Providing concurrency safe iteration over all plugins (for filtering), and name-based lookup
type Store struct {
plugins atomic.Value
}
func (s *Store) Store(pr *PluginRaw) {
plugins := s.plugins.Load().(*sync.Map)
plugins.Store(pr.Metadata.Name, pr)
}
func (s *Store) LoadOrStore(pr *PluginRaw) (*PluginRaw, bool) {
plugins := s.plugins.Load().(*sync.Map)
actual, loaded := plugins.LoadOrStore(pr.Metadata.Name, pr)
return actual.(*PluginRaw), loaded
}
func (s *Store) Load(name string) *PluginRaw {
plugins := s.plugins.Load().(*sync.Map)
v, ok := plugins.Load(name)
if !ok {
return nil
}
return v.(*PluginRaw)
}
func (s *Store) Range(cb func(*PluginRaw)) {
plugins := s.plugins.Load().(*sync.Map)
plugins.Range(func(_ any, value any) bool {
cb(value.(*PluginRaw))
return true
})
}
func (s *Store) Delete(pluginName string) {
plugins := s.plugins.Load().(*sync.Map)
plugins.Delete(pluginName)
}
type Manager struct {
runtimes map[string]Runtime
Store Store
}
// func NewManager(baseDirs []string) *Manager {
func NewManager() *Manager {
pm := Manager{
//baseDirs: baseDirs,
runtimes: map[string]Runtime{},
}
return &pm
}
func (m *Manager) RegisterRuntime(runtimeName string, runtime Runtime) {
m.runtimes[runtimeName] = runtime
}
func (m *Manager) RetriveRuntime(runtimeName string) Runtime {
return m.runtimes[runtimeName]
}
func (m *Manager) Catalog() Catalog {
return &PluginManagerCatalog{Manager: m}
}
func (m *Manager) FindPluginsRaw(filterFn filterFunc) []*PluginRaw {
results := make([]*PluginRaw, 0, 10)
m.Store.Range(func(pluginRaw *PluginRaw) {
if filterFn(&pluginRaw.Metadata) {
results = append(results, pluginRaw)
}
})
return results
}
func (m *Manager) CreatePlugin(pluginRaw *PluginRaw) (Plugin, error) {
rt, ok := m.runtimes[pluginRaw.Metadata.Runtime]
if !ok {
return nil, fmt.Errorf("unsupported plugin runtime type: %q", pluginRaw.Metadata.Runtime)
}
return rt.CreatePlugin(pluginRaw.Dir, &pluginRaw.Metadata)
}
// filterFunc is a function that filters plugins
type filterFunc func(m *Metadata) bool
// 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(m *Metadata) bool {
// If name is specified, it must match
if descriptor.Name != "" && m.Name != descriptor.Name {
return false
}
// If type is specified, it must match
if descriptor.Type != "" && m.Type != descriptor.Type {
return false
}
return true
}
}
type PluginManagerCatalog struct {
Manager *Manager
}
func (c *PluginManagerCatalog) FindPlugin(d Descriptor) (Plugin, error) {
filterFn := makeDescriptorFilter(d)
pluginsRaw := c.Manager.FindPluginsRaw(filterFn)
if len(pluginsRaw) == 0 {
return nil, nil
}
if len(pluginsRaw) > 1 {
return nil, fmt.Errorf("multiple matching plugins found")
}
return c.Manager.CreatePlugin(pluginsRaw[0])
}
func (c *PluginManagerCatalog) FindPlugins(d Descriptor) ([]Plugin, error) {
filterFn := makeDescriptorFilter(d)
pluginsRaw := make([]*PluginRaw, 0, 10)
c.Manager.Store.Range(func(pluginRaw *PluginRaw) {
if filterFn(&pluginRaw.Metadata) {
pluginsRaw = append(pluginsRaw, pluginRaw)
}
})
results := make([]Plugin, 0, len(pluginsRaw))
errs := []error{}
for _, pr := range pluginsRaw {
p, err := c.Manager.CreatePlugin(pr)
if err != nil {
errs = append(errs, err)
continue
}
results = append(results, p)
}
if err := errors.Join(errs...); err != nil {
return nil, err
}
return results, nil
}
// NewEmptyCatalog returns a Catalog that has no plugins
func NewEmptyCatalog() Catalog {
return &emptyCatalog{}
}
type emptyCatalog struct{}
func (*emptyCatalog) FindPlugin(Descriptor) (Plugin, error) {
return nil, nil
}
func (*emptyCatalog) FindPlugins(Descriptor) ([]Plugin, error) {
return []Plugin{}, nil
}

@ -46,6 +46,20 @@ type MetadataLegacy struct {
Description string `yaml:"description"` Description string `yaml:"description"`
// PlatformCommand is the plugin command, with a platform selector and support for args. // 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 `yaml:"platformCommand"` PlatformCommand []PlatformCommand `yaml:"platformCommand"`
// Command is the plugin command, as a single string. // Command is the plugin command, as a single string.

@ -15,13 +15,10 @@ package schema
import ( import (
"bytes" "bytes"
"helm.sh/helm/v4/pkg/cli"
) )
type InputMessageCLIV1 struct { type InputMessageCLIV1 struct {
ExtraArgs []string `json:"extraArgs"` ExtraArgs []string `json:"extraArgs"`
Settings *cli.EnvSettings `json:"settings"`
} }
type OutputMessageCLIV1 struct { type OutputMessageCLIV1 struct {

@ -25,15 +25,19 @@ package cli
import ( import (
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/tetratelabs/wazero"
"k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/internal/version" "helm.sh/helm/v4/internal/version"
"helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/helmpath"
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
@ -93,6 +97,8 @@ type EnvSettings struct {
ColorMode string ColorMode string
// ContentCache is the location where cached charts are stored // ContentCache is the location where cached charts are stored
ContentCache string ContentCache string
// PluginCatalog is the catalog of plugins available
PluginCatalog plugin.Catalog
} }
func New() *EnvSettings { func New() *EnvSettings {
@ -115,6 +121,7 @@ func New() *EnvSettings {
BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit), BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit),
QPS: envFloat32Or("HELM_QPS", defaultQPS), QPS: envFloat32Or("HELM_QPS", defaultQPS),
ColorMode: envColorMode(), ColorMode: envColorMode(),
PluginCatalog: plugin.NewEmptyCatalog(),
} }
env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG")) env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG"))
@ -297,3 +304,35 @@ func (s *EnvSettings) RESTClientGetter() genericclioptions.RESTClientGetter {
func (s *EnvSettings) ShouldDisableColor() bool { func (s *EnvSettings) ShouldDisableColor() bool {
return s.ColorMode == "never" return s.ColorMode == "never"
} }
func (s *EnvSettings) InitializeDefaultPluginManager() error {
// If HELM_NO_PLUGINS is set to 1, do not load plugins.
if os.Getenv("HELM_NO_PLUGINS") != "" {
slog.Debug("HELM_NO_PLUGINS set, skipping plugin initialization")
return nil
}
pm := plugin.NewManager()
// register subprocess runtime
pm.RegisterRuntime("subprocess", &plugin.RuntimeSubprocess{})
// configure and register extism/v1 runtime
wazeroCompilationCache, err := wazero.NewCompilationCacheWithDir(helmpath.CachePath("wazero-build"))
if err != nil {
return fmt.Errorf("failed to create wazero compilation cache: %w", err)
}
pm.RegisterRuntime("extism/v1", &plugin.RuntimeExtismV1{
CompilationCache: wazeroCompilationCache,
})
pluginsDirs := filepath.SplitList(s.PluginsDirectory)
if err := plugin.NewDirLoader(&pm.Store, plugin.GlobPluginDirs).Load(pluginsDirs); err != nil {
return fmt.Errorf("failed to load plugins: %w", err)
}
s.PluginCatalog = pm.Catalog()
return nil
}

@ -24,6 +24,7 @@ import (
"helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/action"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/cli"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
helmtime "helm.sh/helm/v4/pkg/time" helmtime "helm.sh/helm/v4/pkg/time"
) )
@ -101,7 +102,11 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) {
func TestPostRendererFlagSetOnce(t *testing.T) { func TestPostRendererFlagSetOnce(t *testing.T) {
cfg := action.Configuration{} cfg := action.Configuration{}
client := action.NewInstall(&cfg) client := action.NewInstall(&cfg)
settings := cli.New()
settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
err := settings.InitializeDefaultPluginManager()
require.Nil(t, err)
str := postRendererString{ str := postRendererString{
options: &postRendererOptions{ options: &postRendererOptions{
renderer: &client.PostRenderer, renderer: &client.PostRenderer,
@ -109,7 +114,7 @@ func TestPostRendererFlagSetOnce(t *testing.T) {
}, },
} }
// Set the plugin name once // Set the plugin name once
err := str.Set("postrenderer-v1") err = str.Set("postrenderer-v1")
require.NoError(t, err) require.NoError(t, err)
// Set the plugin name again to the same value is not ok // Set the plugin name again to the same value is not ok

@ -21,6 +21,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@ -57,11 +58,10 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) {
return return
} }
dirs := filepath.SplitList(settings.PluginsDirectory)
descriptor := plugin.Descriptor{ descriptor := plugin.Descriptor{
Type: "cli/v1", Type: "cli/v1",
} }
found, err := plugin.FindPlugins(dirs, descriptor) found, err := settings.PluginCatalog.FindPlugins(descriptor)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to load plugins: %s\n", err) fmt.Fprintf(os.Stderr, "failed to load plugins: %s\n", err)
return return
@ -113,7 +113,6 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) {
input := &plugin.Input{ input := &plugin.Input{
Message: schema.InputMessageCLIV1{ Message: schema.InputMessageCLIV1{
ExtraArgs: extraArgs, ExtraArgs: extraArgs,
Settings: settings,
}, },
Env: env, Env: env,
Stdin: os.Stdin, Stdin: os.Stdin,
@ -134,6 +133,7 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) {
} }
// TODO: Make sure a command with this name does not already exist. // TODO: Make sure a command with this name does not already exist.
slog.Debug("adding plugin command", "name", c.Name(), "path", plug.Dir())
baseCmd.AddCommand(c) baseCmd.AddCommand(c)
// For completion, we try to load more details about the plugins so as to allow for command and // For completion, we try to load more details about the plugins so as to allow for command and

@ -16,6 +16,7 @@ limitations under the License.
package cmd package cmd
import ( import (
"fmt"
"io" "io"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -44,8 +45,21 @@ func newPluginCmd(out io.Writer) *cobra.Command {
return cmd return cmd
} }
// runHook will execute a plugin hook. // runHook will execute a plugin hook
func runHook(p plugin.Plugin, event string) error { // currently this function assumes/requires only subprocess plugins can have hooks
func runHook(pm *plugin.Manager, pluginRaw *plugin.PluginRaw, event string) error {
if pluginRaw.Metadata.Runtime != "subprocess" {
return nil
}
pm.Store.Store(pluginRaw)
p, err := pm.CreatePlugin(pluginRaw)
if err != nil {
return fmt.Errorf("plugin is installed but unusable: %w", err)
}
pluginHook, ok := p.(plugin.PluginHook) pluginHook, ok := p.(plugin.PluginHook)
if ok { if ok {
return pluginHook.InvokeHook(event) return pluginHook.InvokeHook(event)

@ -115,7 +115,7 @@ func (o *pluginInstallOptions) newInstallerForSource() (installer.Installer, err
} }
// For non-OCI sources, use the original logic // For non-OCI sources, use the original logic
return installer.NewForSource(o.source, o.version) return installer.NewForSource(settings, o.source, o.version)
} }
func (o *pluginInstallOptions) run(out io.Writer) error { func (o *pluginInstallOptions) run(out io.Writer) error {
@ -171,12 +171,22 @@ func (o *pluginInstallOptions) run(out io.Writer) error {
} }
slog.Debug("loading plugin", "path", i.Path()) slog.Debug("loading plugin", "path", i.Path())
p, err := plugin.LoadDir(i.Path()) pluginRaw, err := plugin.LoadDirRaw(i.Path())
pmc, ok := settings.PluginCatalog.(*plugin.PluginManagerCatalog)
if !ok {
return fmt.Errorf("plugin is installed but unusable: %w", err)
}
pm := pmc.Manager
pm.Store.Store(pluginRaw)
p, err := pm.CreatePlugin(pluginRaw)
if err != nil { if err != nil {
return fmt.Errorf("plugin is installed but unusable: %w", err) return fmt.Errorf("plugin is installed but unusable: %w", err)
} }
if err := runHook(p, plugin.Install); err != nil { if err := runHook(pm, pluginRaw, plugin.Install); err != nil {
return err return err
} }

@ -18,9 +18,8 @@ package cmd
import ( import (
"fmt" "fmt"
"io" "io"
"log/slog"
"path/filepath"
"slices" "slices"
"strings"
"github.com/gosuri/uitable" "github.com/gosuri/uitable"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -37,12 +36,10 @@ func newPluginListCmd(out io.Writer) *cobra.Command {
Short: "list installed Helm plugins", Short: "list installed Helm plugins",
ValidArgsFunction: noMoreArgsCompFunc, ValidArgsFunction: noMoreArgsCompFunc,
RunE: func(_ *cobra.Command, _ []string) error { RunE: func(_ *cobra.Command, _ []string) error {
slog.Debug("pluginDirs", "directory", settings.PluginsDirectory)
dirs := filepath.SplitList(settings.PluginsDirectory)
descriptor := plugin.Descriptor{ descriptor := plugin.Descriptor{
Type: pluginType, Type: pluginType,
} }
plugins, err := plugin.FindPlugins(dirs, descriptor) plugins, err := settings.PluginCatalog.FindPlugins(descriptor)
if err != nil { if err != nil {
return err return err
} }
@ -97,11 +94,15 @@ func filterPlugins(plugins []plugin.Plugin, ignoredPluginNames []string) []plugi
// Provide dynamic auto-completion for plugin names // Provide dynamic auto-completion for plugin names
func compListPlugins(_ string, ignoredPluginNames []string) []string { func compListPlugins(_ string, ignoredPluginNames []string) []string {
var pNames []string var pNames []string
dirs := filepath.SplitList(settings.PluginsDirectory)
descriptor := plugin.Descriptor{ descriptor := plugin.Descriptor{
Type: "cli/v1", Type: "cli/v1",
} }
plugins, err := plugin.FindPlugins(dirs, descriptor) plugins, err := settings.PluginCatalog.FindPlugins(descriptor)
slices.SortFunc(plugins, func(i, j plugin.Plugin) int {
return strings.Compare(i.Metadata().Name, j.Metadata().Name)
})
if err == nil && len(plugins) > 0 { if err == nil && len(plugins) > 0 {
filteredPlugins := filterPlugins(plugins, ignoredPluginNames) filteredPlugins := filterPlugins(plugins, ignoredPluginNames)
for _, p := range filteredPlugins { for _, p := range filteredPlugins {

@ -85,7 +85,7 @@ func (o *pluginPackageOptions) run(out io.Writer) error {
} }
// Load and validate plugin metadata // Load and validate plugin metadata
pluginMeta, err := plugin.LoadDir(o.pluginPath) pr, err := plugin.LoadDirRaw(o.pluginPath)
if err != nil { if err != nil {
return fmt.Errorf("invalid plugin directory: %w", err) return fmt.Errorf("invalid plugin directory: %w", err)
} }
@ -124,7 +124,7 @@ func (o *pluginPackageOptions) run(out io.Writer) error {
// Now create the tarball (only after signing prerequisites are met) // Now create the tarball (only after signing prerequisites are met)
// Use plugin metadata for filename: PLUGIN_NAME-SEMVER.tgz // Use plugin metadata for filename: PLUGIN_NAME-SEMVER.tgz
metadata := pluginMeta.Metadata() metadata := pr.Metadata
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version) filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
tarballPath := filepath.Join(o.destination, filename) tarballPath := filepath.Join(o.destination, filename)

@ -87,6 +87,8 @@ func TestLoadCLIPlugins(t *testing.T) {
settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
settings.RepositoryConfig = "testdata/helmhome/helm/repositories.yaml" settings.RepositoryConfig = "testdata/helmhome/helm/repositories.yaml"
settings.RepositoryCache = "testdata/helmhome/helm/repository" settings.RepositoryCache = "testdata/helmhome/helm/repository"
err := settings.InitializeDefaultPluginManager()
require.Nil(t, err)
var ( var (
out bytes.Buffer out bytes.Buffer
@ -165,6 +167,8 @@ func TestLoadPluginsWithSpace(t *testing.T) {
settings.PluginsDirectory = "testdata/helm home with space/helm/plugins" settings.PluginsDirectory = "testdata/helm home with space/helm/plugins"
settings.RepositoryConfig = "testdata/helm home with space/helm/repositories.yaml" settings.RepositoryConfig = "testdata/helm home with space/helm/repositories.yaml"
settings.RepositoryCache = "testdata/helm home with space/helm/repository" settings.RepositoryCache = "testdata/helm home with space/helm/repository"
err := settings.InitializeDefaultPluginManager()
require.Nil(t, err)
var ( var (
out bytes.Buffer out bytes.Buffer
@ -243,6 +247,8 @@ type staticCompletionDetails struct {
func TestLoadCLIPluginsForCompletion(t *testing.T) { func TestLoadCLIPluginsForCompletion(t *testing.T) {
settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
err := settings.InitializeDefaultPluginManager()
require.Nil(t, err)
var out bytes.Buffer var out bytes.Buffer
@ -330,15 +336,19 @@ func TestPluginDynamicCompletion(t *testing.T) {
}} }}
for _, test := range tests { for _, test := range tests {
settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
err := settings.InitializeDefaultPluginManager()
require.Nil(t, err)
runTestCmd(t, []cmdTestCase{test}) runTestCmd(t, []cmdTestCase{test})
} }
} }
func TestLoadCLIPlugins_HelmNoPlugins(t *testing.T) { func TestLoadCLIPlugins_HelmNoPlugins(t *testing.T) {
t.Setenv("HELM_NO_PLUGINS", "1")
settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
settings.RepositoryConfig = "testdata/helmhome/helm/repository" settings.RepositoryConfig = "testdata/helmhome/helm/repository"
err := settings.InitializeDefaultPluginManager()
t.Setenv("HELM_NO_PLUGINS", "1") require.Nil(t, err)
out := bytes.NewBuffer(nil) out := bytes.NewBuffer(nil)
cmd := &cobra.Command{} cmd := &cobra.Command{}
@ -399,6 +409,9 @@ func TestPluginCmdsCompletion(t *testing.T) {
}, {}} }, {}}
for _, test := range tests { for _, test := range tests {
settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
err := settings.InitializeDefaultPluginManager()
require.Nil(t, err)
runTestCmd(t, []cmdTestCase{test}) runTestCmd(t, []cmdTestCase{test})
} }
} }

@ -61,38 +61,43 @@ func (o *pluginUninstallOptions) complete(args []string) error {
} }
func (o *pluginUninstallOptions) run(out io.Writer) error { func (o *pluginUninstallOptions) run(out io.Writer) error {
slog.Debug("loading installer plugins", "dir", settings.PluginsDirectory) pmc, ok := settings.PluginCatalog.(*plugin.PluginManagerCatalog)
plugins, err := plugin.LoadAll(settings.PluginsDirectory) if !ok {
if err != nil { return nil
return err
} }
var errorPlugins []error
pm := pmc.Manager
var errs []error
for _, name := range o.names { for _, name := range o.names {
if found := findPlugin(plugins, name); found != nil { pluginRaw := pm.Store.Load(name)
if err := uninstallPlugin(found); err != nil { if pluginRaw == nil {
errorPlugins = append(errorPlugins, fmt.Errorf("failed to uninstall plugin %s, got error (%v)", name, err)) errs = append(errs, fmt.Errorf("plugin: %s not found", name))
} else { continue
fmt.Fprintf(out, "Uninstalled plugin: %s\n", name)
}
} else {
errorPlugins = append(errorPlugins, fmt.Errorf("plugin: %s not found", name))
} }
if err := uninstallPlugin(pm, pluginRaw); err != nil {
errs = append(errs, fmt.Errorf("failed to uninstall plugin %s, got error (%v)", name, err))
continue
} }
if len(errorPlugins) > 0 {
return errors.Join(errorPlugins...) fmt.Fprintf(out, "Uninstalled plugin: %s\n", name)
} }
return nil
return errors.Join(errs...)
} }
func uninstallPlugin(p plugin.Plugin) error { func uninstallPlugin(pm *plugin.Manager, pluginRaw *plugin.PluginRaw) error {
if err := os.RemoveAll(p.Dir()); err != nil { pm.Store.Delete(pluginRaw.Metadata.Name)
if err := os.RemoveAll(pluginRaw.Dir); err != nil {
return err return err
} }
// Clean up versioned tarball and provenance files from HELM_PLUGINS directory // Clean up versioned tarball and provenance files from HELM_PLUGINS directory
// These files are saved with pattern: PLUGIN_NAME-VERSION.tgz and PLUGIN_NAME-VERSION.tgz.prov // These files are saved with pattern: PLUGIN_NAME-VERSION.tgz and PLUGIN_NAME-VERSION.tgz.prov
pluginName := p.Metadata().Name pluginName := pluginRaw.Metadata.Name
pluginVersion := p.Metadata().Version pluginVersion := pluginRaw.Metadata.Version
pluginsDir := settings.PluginsDirectory pluginsDir := settings.PluginsDirectory
// Remove versioned files: plugin-name-version.tgz and plugin-name-version.tgz.prov // Remove versioned files: plugin-name-version.tgz and plugin-name-version.tgz.prov
@ -118,15 +123,9 @@ func uninstallPlugin(p plugin.Plugin) error {
} }
} }
return runHook(p, plugin.Delete) // Ensure a concurrent store reload doesn't accidentally race the os.RemoveAll and read the plugin back into memory
} pm.Store.Delete(pluginRaw.Metadata.Name)
// TODO should this be in pkg/plugin/loader.go? // TODO: should the hook be run before deleting the plugin's files?
func findPlugin(plugins []plugin.Plugin, name string) plugin.Plugin { return runHook(pm, pluginRaw, plugin.Delete)
for _, p := range plugins {
if p.Metadata().Name == name {
return p
}
}
return nil
} }

@ -70,20 +70,20 @@ command: $HELM_PLUGIN_DIR/test-plugin
} }
// Load the plugin // Load the plugin
p, err := plugin.LoadDir(pluginDir) pr, err := plugin.LoadDirRaw(pluginDir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Create a test uninstall function that uses our test settings // Create a test uninstall function that uses our test settings
testUninstallPlugin := func(plugin plugin.Plugin) error { testUninstallPlugin := func(pluginRaw *plugin.PluginRaw) error {
if err := os.RemoveAll(plugin.Dir()); err != nil { if err := os.RemoveAll(pluginRaw.Dir); err != nil {
return err return err
} }
// Clean up versioned tarball and provenance files from test HELM_PLUGINS directory // Clean up versioned tarball and provenance files from test HELM_PLUGINS directory
pluginName := plugin.Metadata().Name pluginName := pluginRaw.Metadata.Name
pluginVersion := plugin.Metadata().Version pluginVersion := pluginRaw.Metadata.Version
testPluginsDir := testSettings.PluginsDirectory testPluginsDir := testSettings.PluginsDirectory
// Remove versioned files: plugin-name-version.tgz and plugin-name-version.tgz.prov // Remove versioned files: plugin-name-version.tgz and plugin-name-version.tgz.prov
@ -123,7 +123,7 @@ command: $HELM_PLUGIN_DIR/test-plugin
} }
// Uninstall the plugin // Uninstall the plugin
if err := testUninstallPlugin(p); err != nil { if err := testUninstallPlugin(pr); err != nil {
t.Fatal(err) t.Fatal(err)
} }

@ -19,7 +19,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"path/filepath" "path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -61,33 +60,33 @@ func (o *pluginUpdateOptions) complete(args []string) error {
} }
func (o *pluginUpdateOptions) run(out io.Writer) error { func (o *pluginUpdateOptions) run(out io.Writer) error {
installer.Debug = settings.Debug pmc, ok := settings.PluginCatalog.(*plugin.PluginManagerCatalog)
slog.Debug("loading installed plugins", "path", settings.PluginsDirectory) if !ok {
plugins, err := plugin.LoadAll(settings.PluginsDirectory) return nil
if err != nil {
return err
} }
var errorPlugins []error pm := pmc.Manager
var errs []error
for _, name := range o.names { for _, name := range o.names {
if found := findPlugin(plugins, name); found != nil { pluginRaw := pm.Store.Load(name)
if err := updatePlugin(found); err != nil { if pluginRaw == nil {
errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%v)", name, err)) errs = append(errs, fmt.Errorf("plugin: %s not found", name))
} else { continue
fmt.Fprintf(out, "Updated plugin: %s\n", name)
}
} else {
errorPlugins = append(errorPlugins, fmt.Errorf("plugin: %s not found", name))
} }
if err := updatePlugin(pm, pluginRaw); err != nil {
errs = append(errs, fmt.Errorf("failed to update plugin %s, got error (%v)", name, err))
continue
} }
if len(errorPlugins) > 0 {
return errors.Join(errorPlugins...) fmt.Fprintf(out, "Uninstalled plugin: %s\n", name)
} }
return nil
return errors.Join(errs...)
} }
func updatePlugin(p plugin.Plugin) error { func updatePlugin(pm *plugin.Manager, pluginRaw *plugin.PluginRaw) error {
exactLocation, err := filepath.EvalSymlinks(p.Dir()) exactLocation, err := filepath.EvalSymlinks(pluginRaw.Dir)
if err != nil { if err != nil {
return err return err
} }
@ -104,11 +103,7 @@ func updatePlugin(p plugin.Plugin) error {
return err return err
} }
slog.Debug("loading plugin", "path", i.Path()) pm.Store.Store(pluginRaw)
updatedPlugin, err := plugin.LoadDir(i.Path())
if err != nil {
return err
}
return runHook(updatedPlugin, plugin.Update) return runHook(pm, pluginRaw, plugin.Update)
} }

@ -102,6 +102,8 @@ By default, the default directories depend on the Operating System. The defaults
var settings = cli.New() var settings = cli.New()
func NewRootCmd(out io.Writer, args []string, logSetup func(bool)) (*cobra.Command, error) { func NewRootCmd(out io.Writer, args []string, logSetup func(bool)) (*cobra.Command, error) {
settings.InitializeDefaultPluginManager()
actionConfig := new(action.Configuration) actionConfig := new(action.Configuration)
cmd, err := newRootCmdWithConfig(actionConfig, out, args, logSetup) cmd, err := newRootCmdWithConfig(actionConfig, out, args, logSetup)
if err != nil { if err != nil {
@ -109,6 +111,7 @@ func NewRootCmd(out io.Writer, args []string, logSetup func(bool)) (*cobra.Comma
} }
cobra.OnInitialize(func() { cobra.OnInitialize(func() {
helmDriver := os.Getenv("HELM_DRIVER") helmDriver := os.Getenv("HELM_DRIVER")
if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), helmDriver); err != nil { if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), helmDriver); err != nil {
log.Fatal(err) log.Fatal(err)
} }

@ -24,6 +24,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/internal/test/ensure" "helm.sh/helm/v4/internal/test/ensure"
"helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/getter"
@ -79,6 +80,7 @@ func TestResolveChartRef(t *testing.T) {
Getters: getter.All(&cli.EnvSettings{ Getters: getter.All(&cli.EnvSettings{
RepositoryConfig: repoConfig, RepositoryConfig: repoConfig,
RepositoryCache: repoCache, RepositoryCache: repoCache,
PluginCatalog: plugin.NewEmptyCatalog(),
}), }),
} }
@ -119,6 +121,7 @@ func TestResolveChartOpts(t *testing.T) {
Getters: getter.All(&cli.EnvSettings{ Getters: getter.All(&cli.EnvSettings{
RepositoryConfig: repoConfig, RepositoryConfig: repoConfig,
RepositoryCache: repoCache, RepositoryCache: repoCache,
PluginCatalog: plugin.NewEmptyCatalog(),
}), }),
} }
@ -215,6 +218,7 @@ func TestDownloadTo(t *testing.T) {
RepositoryConfig: repoConfig, RepositoryConfig: repoConfig,
RepositoryCache: repoCache, RepositoryCache: repoCache,
ContentCache: contentCache, ContentCache: contentCache,
PluginCatalog: plugin.NewEmptyCatalog(),
}), }),
Options: []getter.Option{ Options: []getter.Option{
getter.WithBasicAuth("username", "password"), getter.WithBasicAuth("username", "password"),
@ -271,6 +275,7 @@ func TestDownloadTo_TLS(t *testing.T) {
RepositoryConfig: repoConfig, RepositoryConfig: repoConfig,
RepositoryCache: repoCache, RepositoryCache: repoCache,
ContentCache: contentCache, ContentCache: contentCache,
PluginCatalog: plugin.NewEmptyCatalog(),
}), }),
Options: []getter.Option{ Options: []getter.Option{
getter.WithTLSClientConfig( getter.WithTLSClientConfig(
@ -327,6 +332,7 @@ func TestDownloadTo_VerifyLater(t *testing.T) {
RepositoryConfig: repoConfig, RepositoryConfig: repoConfig,
RepositoryCache: repoCache, RepositoryCache: repoCache,
ContentCache: contentCache, ContentCache: contentCache,
PluginCatalog: plugin.NewEmptyCatalog(),
}), }),
} }
cname := "/signtest-0.1.0.tgz" cname := "/signtest-0.1.0.tgz"
@ -356,6 +362,7 @@ func TestScanReposForURL(t *testing.T) {
Getters: getter.All(&cli.EnvSettings{ Getters: getter.All(&cli.EnvSettings{
RepositoryConfig: repoConfig, RepositoryConfig: repoConfig,
RepositoryCache: repoCache, RepositoryCache: repoCache,
PluginCatalog: plugin.NewEmptyCatalog(),
}), }),
} }

@ -19,6 +19,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/cli"
) )
@ -73,6 +75,8 @@ func TestProvidersWithTimeout(t *testing.T) {
func TestAll(t *testing.T) { func TestAll(t *testing.T) {
env := cli.New() env := cli.New()
env.PluginsDirectory = pluginDir env.PluginsDirectory = pluginDir
err := env.InitializeDefaultPluginManager()
require.Nil(t, err)
all := All(env) all := All(env)
if len(all) != 4 { if len(all) != 4 {
@ -87,6 +91,8 @@ func TestAll(t *testing.T) {
func TestByScheme(t *testing.T) { func TestByScheme(t *testing.T) {
env := cli.New() env := cli.New()
env.PluginsDirectory = pluginDir env.PluginsDirectory = pluginDir
err := env.InitializeDefaultPluginManager()
require.Nil(t, err)
g := All(env) g := All(env)
if _, err := g.ByScheme("test"); err != nil { if _, err := g.ByScheme("test"); err != nil {

@ -34,7 +34,7 @@ func collectGetterPlugins(settings *cli.EnvSettings) (Providers, error) {
d := plugin.Descriptor{ d := plugin.Descriptor{
Type: "getter/v1", Type: "getter/v1",
} }
plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d) plgs, err := settings.PluginCatalog.FindPlugins(d)
if err != nil { if err != nil {
return nil, err return nil, err
} }

@ -33,6 +33,8 @@ import (
func TestCollectPlugins(t *testing.T) { func TestCollectPlugins(t *testing.T) {
env := cli.New() env := cli.New()
env.PluginsDirectory = pluginDir env.PluginsDirectory = pluginDir
err := env.InitializeDefaultPluginManager()
require.Nil(t, err)
p, err := collectGetterPlugins(env) p, err := collectGetterPlugins(env)
if err != nil { if err != nil {

@ -17,7 +17,6 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"path/filepath"
"helm.sh/helm/v4/internal/plugin/schema" "helm.sh/helm/v4/internal/plugin/schema"
@ -40,7 +39,7 @@ func NewPostRendererPlugin(settings *cli.EnvSettings, pluginName string, args ..
Name: pluginName, Name: pluginName,
Type: "postrenderer/v1", Type: "postrenderer/v1",
} }
p, err := plugin.FindPlugin(filepath.SplitList(settings.PluginsDirectory), descriptor) p, err := settings.PluginCatalog.FindPlugin(descriptor)
if err != nil { if err != nil {
return nil, err return nil, err
} }

@ -29,6 +29,7 @@ import (
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/getter"
) )
@ -131,7 +132,9 @@ func TestFindChartInAuthAndTLSAndPassRepoURL(t *testing.T) {
chartURL, err := FindChartInRepoURL( chartURL, err := FindChartInRepoURL(
srv.URL, srv.URL,
"nginx", "nginx",
getter.All(&cli.EnvSettings{}), getter.All(&cli.EnvSettings{
PluginCatalog: plugin.NewEmptyCatalog(),
}),
WithInsecureSkipTLSverify(true), WithInsecureSkipTLSverify(true),
) )
if err != nil { if err != nil {
@ -142,7 +145,7 @@ func TestFindChartInAuthAndTLSAndPassRepoURL(t *testing.T) {
} }
// If the insecureSkipTLSVerify is false, it will return an error that contains "x509: certificate signed by unknown authority". // If the insecureSkipTLSVerify is false, it will return an error that contains "x509: certificate signed by unknown authority".
_, err = FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{}), WithChartVersion("0.1.0")) _, err = FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{PluginCatalog: plugin.NewEmptyCatalog()}), WithChartVersion("0.1.0"))
// Go communicates with the platform and different platforms return different messages. Go itself tests darwin // Go communicates with the platform and different platforms return different messages. Go itself tests darwin
// differently for its message. On newer versions of Darwin the message includes the "Acme Co" portion while older // differently for its message. On newer versions of Darwin the message includes the "Acme Co" portion while older
// versions of Darwin do not. As there are people developing Helm using both old and new versions of Darwin we test // versions of Darwin do not. As there are people developing Helm using both old and new versions of Darwin we test
@ -163,7 +166,7 @@ func TestFindChartInRepoURL(t *testing.T) {
} }
defer srv.Close() defer srv.Close()
chartURL, err := FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{})) chartURL, err := FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{PluginCatalog: plugin.NewEmptyCatalog()}))
if err != nil { if err != nil {
t.Fatalf("%v", err) t.Fatalf("%v", err)
} }
@ -171,7 +174,7 @@ func TestFindChartInRepoURL(t *testing.T) {
t.Errorf("%s is not the valid URL", chartURL) t.Errorf("%s is not the valid URL", chartURL)
} }
chartURL, err = FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{}), WithChartVersion("0.1.0")) chartURL, err = FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{PluginCatalog: plugin.NewEmptyCatalog()}), WithChartVersion("0.1.0"))
if err != nil { if err != nil {
t.Errorf("%s", err) t.Errorf("%s", err)
} }
@ -184,6 +187,7 @@ func TestErrorFindChartInRepoURL(t *testing.T) {
g := getter.All(&cli.EnvSettings{ g := getter.All(&cli.EnvSettings{
RepositoryCache: t.TempDir(), RepositoryCache: t.TempDir(),
PluginCatalog: plugin.NewEmptyCatalog(),
}) })
if _, err := FindChartInRepoURL("http://someserver/something", "nginx", g); err == nil { if _, err := FindChartInRepoURL("http://someserver/something", "nginx", g); err == nil {

@ -28,6 +28,7 @@ import (
"strings" "strings"
"testing" "testing"
"helm.sh/helm/v4/internal/plugin"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/getter"
@ -264,7 +265,7 @@ func TestDownloadIndexFile(t *testing.T) {
r, err := NewChartRepository(&Entry{ r, err := NewChartRepository(&Entry{
Name: testRepo, Name: testRepo,
URL: srv.URL, URL: srv.URL,
}, getter.All(&cli.EnvSettings{})) }, getter.All(&cli.EnvSettings{PluginCatalog: plugin.NewEmptyCatalog()}))
if err != nil { if err != nil {
t.Errorf("Problem creating chart repository from %s: %v", testRepo, err) t.Errorf("Problem creating chart repository from %s: %v", testRepo, err)
} }
@ -317,7 +318,7 @@ func TestDownloadIndexFile(t *testing.T) {
r, err := NewChartRepository(&Entry{ r, err := NewChartRepository(&Entry{
Name: testRepo, Name: testRepo,
URL: srv.URL + chartRepoURLPath, URL: srv.URL + chartRepoURLPath,
}, getter.All(&cli.EnvSettings{})) }, getter.All(&cli.EnvSettings{PluginCatalog: plugin.NewEmptyCatalog()}))
if err != nil { if err != nil {
t.Errorf("Problem creating chart repository from %s: %v", testRepo, err) t.Errorf("Problem creating chart repository from %s: %v", testRepo, err)
} }

Loading…
Cancel
Save