From abd4a326f07ac02b78fe6af7bf906fcb82309ae0 Mon Sep 17 00:00:00 2001 From: George Jenkins Date: Sun, 24 Aug 2025 21:23:48 -0700 Subject: [PATCH] Create plugin manager Signed-off-by: George Jenkins --- internal/plugin/descriptor.go | 24 -- internal/plugin/doc.go | 2 +- internal/plugin/installer/http_installer.go | 4 +- .../plugin/installer/http_installer_test.go | 18 +- internal/plugin/installer/installer.go | 5 +- .../plugin/installer/local_installer_test.go | 17 +- internal/plugin/installer/plugin_structure.go | 4 +- .../plugin/installer/vcs_installer_test.go | 13 +- internal/plugin/loader.go | 193 +++++----------- internal/plugin/loader_test.go | 210 ----------------- internal/plugin/manager.go | 216 ++++++++++++++++++ internal/plugin/metadata_legacy.go | 14 ++ internal/plugin/schema/cli.go | 5 +- pkg/cli/environment.go | 39 ++++ pkg/cmd/flags_test.go | 7 +- pkg/cmd/load_plugins.go | 6 +- pkg/cmd/plugin.go | 18 +- pkg/cmd/plugin_install.go | 16 +- pkg/cmd/plugin_list.go | 15 +- pkg/cmd/plugin_package.go | 4 +- pkg/cmd/plugin_test.go | 17 +- pkg/cmd/plugin_uninstall.go | 61 +++-- pkg/cmd/plugin_uninstall_test.go | 12 +- pkg/cmd/plugin_update.go | 49 ++-- pkg/cmd/root.go | 3 + pkg/downloader/chart_downloader_test.go | 7 + pkg/getter/getter_test.go | 6 + pkg/getter/plugingetter.go | 2 +- pkg/getter/plugingetter_test.go | 2 + pkg/postrenderer/postrenderer.go | 3 +- pkg/repo/v1/chartrepo_test.go | 12 +- pkg/repo/v1/index_test.go | 5 +- 32 files changed, 529 insertions(+), 480 deletions(-) delete mode 100644 internal/plugin/descriptor.go create mode 100644 internal/plugin/manager.go diff --git a/internal/plugin/descriptor.go b/internal/plugin/descriptor.go deleted file mode 100644 index ba92b3c55..000000000 --- a/internal/plugin/descriptor.go +++ /dev/null @@ -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 -} diff --git a/internal/plugin/doc.go b/internal/plugin/doc.go index 39ba6300b..396103c85 100644 --- a/internal/plugin/doc.go +++ b/internal/plugin/doc.go @@ -28,7 +28,7 @@ An example of a plugin invocation: d := plugin.Descriptor{ Type: "example/v1", // } -plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d) +plgs, err := settings.PluginCatalog.FindPlugins(d) for _, plg := range plgs { input := &plugin.Input{ diff --git a/internal/plugin/installer/http_installer.go b/internal/plugin/installer/http_installer.go index bb96314f4..56be70070 100644 --- a/internal/plugin/installer/http_installer.go +++ b/internal/plugin/installer/http_installer.go @@ -44,7 +44,7 @@ type HTTPInstaller struct { } // 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) if err != nil { return nil, err @@ -55,7 +55,7 @@ func NewHTTPInstaller(source string) (*HTTPInstaller, error) { return nil, err } - get, err := getter.All(new(cli.EnvSettings)).ByScheme("http") + get, err := getter.All(settings).ByScheme("http") if err != nil { return nil, err } diff --git a/internal/plugin/installer/http_installer_test.go b/internal/plugin/installer/http_installer_test.go index be40b1b90..253bd4581 100644 --- a/internal/plugin/installer/http_installer_test.go +++ b/internal/plugin/installer/http_installer_test.go @@ -32,6 +32,7 @@ import ( "testing" "helm.sh/helm/v4/internal/test/ensure" + "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/helmpath" ) @@ -81,6 +82,8 @@ func mockArchiveServer() *httptest.Server { func TestHTTPInstaller(t *testing.T) { ensure.HelmHome(t) + settings := cli.New() + srv := mockArchiveServer() defer srv.Close() 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) } - i, err := NewForSource(source, "0.0.1") + i, err := NewForSource(settings, source, "0.0.1") if err != nil { t.Fatalf("unexpected error: %s", err) } @@ -129,6 +132,9 @@ func TestHTTPInstaller(t *testing.T) { func TestHTTPInstallerNonExistentVersion(t *testing.T) { ensure.HelmHome(t) + + settings := cli.New() + srv := mockArchiveServer() defer srv.Close() 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) } - i, err := NewForSource(source, "0.0.2") + i, err := NewForSource(settings, source, "0.0.2") if err != nil { t.Fatalf("unexpected error: %s", err) } @@ -161,16 +167,20 @@ func TestHTTPInstallerNonExistentVersion(t *testing.T) { } func TestHTTPInstallerUpdate(t *testing.T) { + + ensure.HelmHome(t) + + settings := cli.New() + srv := mockArchiveServer() defer srv.Close() source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" - ensure.HelmHome(t) if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil { 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 { t.Fatalf("unexpected error: %s", err) } diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index b65dac2f4..6741b8466 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -24,6 +24,7 @@ import ( "strings" "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/registry" ) @@ -136,7 +137,7 @@ func Update(i Installer) error { } // 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 if strings.HasPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme)) { return NewOCIInstaller(source) @@ -145,7 +146,7 @@ func NewForSource(source, version string) (Installer, error) { if isLocalReference(source) { return NewLocalInstaller(source) } else if isRemoteHTTPArchive(source) { - return NewHTTPInstaller(source) + return NewHTTPInstaller(settings, source) } return NewVCSInstaller(source, version) } diff --git a/internal/plugin/installer/local_installer_test.go b/internal/plugin/installer/local_installer_test.go index 189108fdb..aa78e414b 100644 --- a/internal/plugin/installer/local_installer_test.go +++ b/internal/plugin/installer/local_installer_test.go @@ -24,6 +24,7 @@ import ( "testing" "helm.sh/helm/v4/internal/test/ensure" + "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/helmpath" ) @@ -31,14 +32,16 @@ var _ Installer = new(LocalInstaller) func TestLocalInstaller(t *testing.T) { ensure.HelmHome(t) - // Make a temp dir + + settings := cli.New() + tdir := t.TempDir() if err := os.WriteFile(filepath.Join(tdir, "plugin.yaml"), []byte{}, 0644); err != nil { t.Fatal(err) } source := "../testdata/plugdir/good/echo-v1" - i, err := NewForSource(source, "") + i, err := NewForSource(settings, source, "") if err != nil { t.Fatalf("unexpected error: %s", err) } @@ -54,8 +57,12 @@ func TestLocalInstaller(t *testing.T) { } func TestLocalInstallerNotAFolder(t *testing.T) { + ensure.HelmHome(t) + + settings := cli.New() + source := "../testdata/plugdir/good/echo-v1/plugin.yaml" - i, err := NewForSource(source, "") + i, err := NewForSource(settings, source, "") if err != nil { t.Fatalf("unexpected error: %s", err) } @@ -116,8 +123,10 @@ func TestLocalInstallerTarball(t *testing.T) { t.Fatal(err) } + settings := cli.New() + // Test installation - i, err := NewForSource(tarballPath, "") + i, err := NewForSource(settings, tarballPath, "") if err != nil { t.Fatalf("unexpected error: %s", err) } diff --git a/internal/plugin/installer/plugin_structure.go b/internal/plugin/installer/plugin_structure.go index 10647141e..53b0b633b 100644 --- a/internal/plugin/installer/plugin_structure.go +++ b/internal/plugin/installer/plugin_structure.go @@ -61,12 +61,12 @@ func validatePluginName(pluginRoot string, expectedName string) error { } // Load plugin.yaml to get the actual name - p, err := plugin.LoadDir(pluginRoot) + pr, err := plugin.LoadDirRaw(pluginRoot) if err != nil { return fmt.Errorf("failed to load plugin from %s: %w", pluginRoot, err) } - m := p.Metadata() + m := pr.Metadata actualName := m.Name // For now, just log a warning if names don't match diff --git a/internal/plugin/installer/vcs_installer_test.go b/internal/plugin/installer/vcs_installer_test.go index d542a0f75..9d7066870 100644 --- a/internal/plugin/installer/vcs_installer_test.go +++ b/internal/plugin/installer/vcs_installer_test.go @@ -25,6 +25,7 @@ import ( "github.com/Masterminds/vcs" "helm.sh/helm/v4/internal/test/ensure" + "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/helmpath" ) @@ -52,6 +53,8 @@ func (r *testRepo) UpdateVersion(version string) error { func TestVCSInstaller(t *testing.T) { ensure.HelmHome(t) + settings := cli.New() + if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil { 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"}, } - i, err := NewForSource(source, "~0.1.0") + i, err := NewForSource(settings, source, "~0.1.0") if err != nil { t.Fatalf("unexpected error: %s", err) } @@ -106,10 +109,12 @@ func TestVCSInstaller(t *testing.T) { func TestVCSInstallerNonExistentVersion(t *testing.T) { ensure.HelmHome(t) + settings := cli.New() + source := "https://github.com/adamreese/helm-env" version := "0.2.0" - i, err := NewForSource(source, version) + i, err := NewForSource(settings, source, version) if err != nil { t.Fatalf("unexpected error: %s", err) } @@ -130,9 +135,11 @@ func TestVCSInstallerNonExistentVersion(t *testing.T) { func TestVCSInstallerUpdate(t *testing.T) { ensure.HelmHome(t) + settings := cli.New() + source := "https://github.com/adamreese/helm-env" - i, err := NewForSource(source, "") + i, err := NewForSource(settings, source, "") if err != nil { t.Fatalf("unexpected error: %s", err) } diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go index a58a84126..e01999f83 100644 --- a/internal/plugin/loader.go +++ b/internal/plugin/loader.go @@ -17,18 +17,22 @@ package plugin import ( "bytes" + "errors" "fmt" "io" + "log/slog" "os" "path/filepath" - extism "github.com/extism/go-sdk" - "github.com/tetratelabs/wazero" "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) { type apiVersion struct { APIVersion string `yaml:"apiVersion"` @@ -101,166 +105,93 @@ func loadMetadata(metadataData []byte) (*Metadata, error) { return nil, fmt.Errorf("invalid plugin apiVersion: %q", apiVersion) } -type prototypePluginManager struct { - runtimes map[string]Runtime -} - -func newPrototypePluginManager() (*prototypePluginManager, error) { - - cc, err := wazero.NewCompilationCacheWithDir(helmpath.CachePath("wazero-build")) +func GlobPluginDirs(baseDir string) ([]string, error) { + // We want baseDir/*/plugin.yaml + scanpath := filepath.Join(baseDir, "*", PluginFileName) + matches, err := filepath.Glob(scanpath) if err != nil { - return nil, fmt.Errorf("failed to create wazero compilation cache: %w", err) - } - - return &prototypePluginManager{ - runtimes: map[string]Runtime{ - "subprocess": &RuntimeSubprocess{}, - "extism/v1": &RuntimeExtismV1{ - HostFunctions: map[string]extism.HostFunction{}, - CompilationCache: cc, - }, - }, - }, nil -} - -func (pm *prototypePluginManager) RegisterRuntime(runtimeName string, runtime Runtime) { - pm.runtimes[runtimeName] = runtime -} - -func (pm *prototypePluginManager) CreatePlugin(pluginPath string, metadata *Metadata) (Plugin, error) { - rt, ok := pm.runtimes[metadata.Runtime] - if !ok { - return nil, fmt.Errorf("unsupported plugin runtime type: %q", metadata.Runtime) + return nil, fmt.Errorf("failed to search for plugins in %q: %w", scanpath, err) } - return rt.CreatePlugin(pluginPath, metadata) + return matches, nil } -// LoadDir loads a plugin from the given directory. -func LoadDir(dirname string) (Plugin, error) { - pluginfile := filepath.Join(dirname, PluginFileName) +// LoadDir loads a plugin source from the given directory +func LoadDirRaw(pluginDir string) (*PluginRaw, error) { + pluginfile := filepath.Join(pluginDir, 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) + metadata, err := loadMetadata(metadataData) if err != nil { - return nil, fmt.Errorf("failed to load plugin %q: %w", dirname, err) + return nil, err } - pm, err := newPrototypePluginManager() - if err != nil { - return nil, fmt.Errorf("failed to create plugin manager: %w", err) + pluginRaw := PluginRaw{ + Metadata: *metadata, + Dir: pluginDir, } - return pm.CreatePlugin(dirname, m) + + return &pluginRaw, nil } -// 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) - } +// findFunc is a function that finds plugin directories +type findFunc func(pluginsDir string) ([]string, error) - // empty dir should load - if len(matches) == 0 { - return plugins, nil +func NewDirLoader(store *Store, findFn findFunc) *DirLoader { + return &DirLoader{ + Store: store, + FindFunc: findFn, } - - 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)) +type DirLoader struct { + Store *Store + FindFunc func(string) ([]string, error) } -// 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) +func (l *DirLoader) Load(baseDirs []string) error { + + store := NewStore() + errs := []error{} + for _, baseDir := range baseDirs { + slog.Debug("Loading plugins", "directory", baseDir) + matches, err := l.FindFunc(baseDir) if err != nil { - return nil, err + errs = append(errs, fmt.Errorf("failed to search for plugins in %q: %w", baseDir, err)) + continue } - for _, p := range ps { - if filterFn(p) { - found = append(found, p) + 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 -} - -// 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 + actualPlugRaw, loaded := store.LoadOrStore(plugRaw) + if loaded { + errs = append(errs, fmt.Errorf( + "two plugins claim the name %q at %q and %q", + plugRaw.Metadata.Name, + actualPlugRaw.Dir, + plugRaw.Dir, + )) + continue + } } - // 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 err := errors.Join(errs...); err != nil { + return 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() - } + // Atomicly replace the store's plugins with the newly loaded plugins + l.Store.plugins = store.plugins return nil } diff --git a/internal/plugin/loader_test.go b/internal/plugin/loader_test.go index 47c214910..2bc3814cf 100644 --- a/internal/plugin/loader_test.go +++ b/internal/plugin/loader_test.go @@ -17,13 +17,10 @@ package plugin import ( "bytes" - "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "helm.sh/helm/v4/internal/plugin/schema" ) func TestPeekAPIVersion(t *testing.T) { @@ -61,210 +58,3 @@ name: "test-plugin" 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)) - }) - } -} diff --git a/internal/plugin/manager.go b/internal/plugin/manager.go new file mode 100644 index 000000000..f8192ea61 --- /dev/null +++ b/internal/plugin/manager.go @@ -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 +} diff --git a/internal/plugin/metadata_legacy.go b/internal/plugin/metadata_legacy.go index a7b245dc0..3c1824afa 100644 --- a/internal/plugin/metadata_legacy.go +++ b/internal/plugin/metadata_legacy.go @@ -46,6 +46,20 @@ type MetadataLegacy struct { Description string `yaml:"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 `yaml:"platformCommand"` // Command is the plugin command, as a single string. diff --git a/internal/plugin/schema/cli.go b/internal/plugin/schema/cli.go index 702b27e45..2282580f5 100644 --- a/internal/plugin/schema/cli.go +++ b/internal/plugin/schema/cli.go @@ -15,13 +15,10 @@ package schema import ( "bytes" - - "helm.sh/helm/v4/pkg/cli" ) type InputMessageCLIV1 struct { - ExtraArgs []string `json:"extraArgs"` - Settings *cli.EnvSettings `json:"settings"` + ExtraArgs []string `json:"extraArgs"` } type OutputMessageCLIV1 struct { diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 106d24336..60df8d7c3 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -25,15 +25,19 @@ package cli import ( "fmt" + "log/slog" "net/http" "os" + "path/filepath" "strconv" "strings" "github.com/spf13/pflag" + "github.com/tetratelabs/wazero" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/rest" + "helm.sh/helm/v4/internal/plugin" "helm.sh/helm/v4/internal/version" "helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/kube" @@ -93,6 +97,8 @@ type EnvSettings struct { ColorMode string // ContentCache is the location where cached charts are stored ContentCache string + // PluginCatalog is the catalog of plugins available + PluginCatalog plugin.Catalog } func New() *EnvSettings { @@ -115,6 +121,7 @@ func New() *EnvSettings { BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit), QPS: envFloat32Or("HELM_QPS", defaultQPS), ColorMode: envColorMode(), + PluginCatalog: plugin.NewEmptyCatalog(), } env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG")) @@ -297,3 +304,35 @@ func (s *EnvSettings) RESTClientGetter() genericclioptions.RESTClientGetter { func (s *EnvSettings) ShouldDisableColor() bool { 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 +} diff --git a/pkg/cmd/flags_test.go b/pkg/cmd/flags_test.go index dce748a6b..736fee685 100644 --- a/pkg/cmd/flags_test.go +++ b/pkg/cmd/flags_test.go @@ -24,6 +24,7 @@ import ( "helm.sh/helm/v4/pkg/action" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/cli" release "helm.sh/helm/v4/pkg/release/v1" helmtime "helm.sh/helm/v4/pkg/time" ) @@ -101,7 +102,11 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) { func TestPostRendererFlagSetOnce(t *testing.T) { cfg := action.Configuration{} client := action.NewInstall(&cfg) + settings := cli.New() settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + err := settings.InitializeDefaultPluginManager() + require.Nil(t, err) + str := postRendererString{ options: &postRendererOptions{ renderer: &client.PostRenderer, @@ -109,7 +114,7 @@ func TestPostRendererFlagSetOnce(t *testing.T) { }, } // Set the plugin name once - err := str.Set("postrenderer-v1") + err = str.Set("postrenderer-v1") require.NoError(t, err) // Set the plugin name again to the same value is not ok diff --git a/pkg/cmd/load_plugins.go b/pkg/cmd/load_plugins.go index c0593f384..3f714e53f 100644 --- a/pkg/cmd/load_plugins.go +++ b/pkg/cmd/load_plugins.go @@ -21,6 +21,7 @@ import ( "fmt" "io" "log" + "log/slog" "os" "path/filepath" "slices" @@ -57,11 +58,10 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) { return } - dirs := filepath.SplitList(settings.PluginsDirectory) descriptor := plugin.Descriptor{ Type: "cli/v1", } - found, err := plugin.FindPlugins(dirs, descriptor) + found, err := settings.PluginCatalog.FindPlugins(descriptor) if err != nil { fmt.Fprintf(os.Stderr, "failed to load plugins: %s\n", err) return @@ -113,7 +113,6 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) { input := &plugin.Input{ Message: schema.InputMessageCLIV1{ ExtraArgs: extraArgs, - Settings: settings, }, Env: env, 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. + slog.Debug("adding plugin command", "name", c.Name(), "path", plug.Dir()) baseCmd.AddCommand(c) // For completion, we try to load more details about the plugins so as to allow for command and diff --git a/pkg/cmd/plugin.go b/pkg/cmd/plugin.go index ba904ef5f..1299fdfde 100644 --- a/pkg/cmd/plugin.go +++ b/pkg/cmd/plugin.go @@ -16,6 +16,7 @@ limitations under the License. package cmd import ( + "fmt" "io" "github.com/spf13/cobra" @@ -44,8 +45,21 @@ func newPluginCmd(out io.Writer) *cobra.Command { return cmd } -// runHook will execute a plugin hook. -func runHook(p plugin.Plugin, event string) error { +// runHook will execute a plugin hook +// 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) if ok { return pluginHook.InvokeHook(event) diff --git a/pkg/cmd/plugin_install.go b/pkg/cmd/plugin_install.go index 0abefa76b..80f93387a 100644 --- a/pkg/cmd/plugin_install.go +++ b/pkg/cmd/plugin_install.go @@ -115,7 +115,7 @@ func (o *pluginInstallOptions) newInstallerForSource() (installer.Installer, err } // 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 { @@ -171,12 +171,22 @@ func (o *pluginInstallOptions) run(out io.Writer) error { } 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 { 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 } diff --git a/pkg/cmd/plugin_list.go b/pkg/cmd/plugin_list.go index 74e969e04..38f92b912 100644 --- a/pkg/cmd/plugin_list.go +++ b/pkg/cmd/plugin_list.go @@ -18,9 +18,8 @@ package cmd import ( "fmt" "io" - "log/slog" - "path/filepath" "slices" + "strings" "github.com/gosuri/uitable" "github.com/spf13/cobra" @@ -37,12 +36,10 @@ func newPluginListCmd(out io.Writer) *cobra.Command { Short: "list installed Helm plugins", ValidArgsFunction: noMoreArgsCompFunc, RunE: func(_ *cobra.Command, _ []string) error { - slog.Debug("pluginDirs", "directory", settings.PluginsDirectory) - dirs := filepath.SplitList(settings.PluginsDirectory) descriptor := plugin.Descriptor{ Type: pluginType, } - plugins, err := plugin.FindPlugins(dirs, descriptor) + plugins, err := settings.PluginCatalog.FindPlugins(descriptor) if err != nil { return err } @@ -97,11 +94,15 @@ func filterPlugins(plugins []plugin.Plugin, ignoredPluginNames []string) []plugi // Provide dynamic auto-completion for plugin names func compListPlugins(_ string, ignoredPluginNames []string) []string { var pNames []string - dirs := filepath.SplitList(settings.PluginsDirectory) descriptor := plugin.Descriptor{ 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 { filteredPlugins := filterPlugins(plugins, ignoredPluginNames) for _, p := range filteredPlugins { diff --git a/pkg/cmd/plugin_package.go b/pkg/cmd/plugin_package.go index 05f8bb5ad..23eb2cce5 100644 --- a/pkg/cmd/plugin_package.go +++ b/pkg/cmd/plugin_package.go @@ -85,7 +85,7 @@ func (o *pluginPackageOptions) run(out io.Writer) error { } // Load and validate plugin metadata - pluginMeta, err := plugin.LoadDir(o.pluginPath) + pr, err := plugin.LoadDirRaw(o.pluginPath) if err != nil { 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) // 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) tarballPath := filepath.Join(o.destination, filename) diff --git a/pkg/cmd/plugin_test.go b/pkg/cmd/plugin_test.go index f7a418569..1fa928d8d 100644 --- a/pkg/cmd/plugin_test.go +++ b/pkg/cmd/plugin_test.go @@ -87,6 +87,8 @@ func TestLoadCLIPlugins(t *testing.T) { settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.RepositoryConfig = "testdata/helmhome/helm/repositories.yaml" settings.RepositoryCache = "testdata/helmhome/helm/repository" + err := settings.InitializeDefaultPluginManager() + require.Nil(t, err) var ( out bytes.Buffer @@ -165,6 +167,8 @@ func TestLoadPluginsWithSpace(t *testing.T) { settings.PluginsDirectory = "testdata/helm home with space/helm/plugins" settings.RepositoryConfig = "testdata/helm home with space/helm/repositories.yaml" settings.RepositoryCache = "testdata/helm home with space/helm/repository" + err := settings.InitializeDefaultPluginManager() + require.Nil(t, err) var ( out bytes.Buffer @@ -243,6 +247,8 @@ type staticCompletionDetails struct { func TestLoadCLIPluginsForCompletion(t *testing.T) { settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + err := settings.InitializeDefaultPluginManager() + require.Nil(t, err) var out bytes.Buffer @@ -330,15 +336,19 @@ func TestPluginDynamicCompletion(t *testing.T) { }} for _, test := range tests { settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + err := settings.InitializeDefaultPluginManager() + require.Nil(t, err) runTestCmd(t, []cmdTestCase{test}) } } func TestLoadCLIPlugins_HelmNoPlugins(t *testing.T) { + t.Setenv("HELM_NO_PLUGINS", "1") + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.RepositoryConfig = "testdata/helmhome/helm/repository" - - t.Setenv("HELM_NO_PLUGINS", "1") + err := settings.InitializeDefaultPluginManager() + require.Nil(t, err) out := bytes.NewBuffer(nil) cmd := &cobra.Command{} @@ -399,6 +409,9 @@ func TestPluginCmdsCompletion(t *testing.T) { }, {}} for _, test := range tests { settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + err := settings.InitializeDefaultPluginManager() + require.Nil(t, err) + runTestCmd(t, []cmdTestCase{test}) } } diff --git a/pkg/cmd/plugin_uninstall.go b/pkg/cmd/plugin_uninstall.go index 85eb46219..6edacf7ad 100644 --- a/pkg/cmd/plugin_uninstall.go +++ b/pkg/cmd/plugin_uninstall.go @@ -61,38 +61,43 @@ 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.LoadAll(settings.PluginsDirectory) - if err != nil { - return err + pmc, ok := settings.PluginCatalog.(*plugin.PluginManagerCatalog) + if !ok { + return nil } - var errorPlugins []error + + pm := pmc.Manager + + var errs []error for _, name := range o.names { - if found := findPlugin(plugins, name); found != nil { - if err := uninstallPlugin(found); err != nil { - errorPlugins = append(errorPlugins, fmt.Errorf("failed to uninstall plugin %s, got error (%v)", name, err)) - } else { - fmt.Fprintf(out, "Uninstalled plugin: %s\n", name) - } - } else { - errorPlugins = append(errorPlugins, fmt.Errorf("plugin: %s not found", name)) + pluginRaw := pm.Store.Load(name) + if pluginRaw == nil { + errs = append(errs, fmt.Errorf("plugin: %s not found", name)) + continue } + + if err := uninstallPlugin(pm, pluginRaw); err != nil { + errs = append(errs, fmt.Errorf("failed to uninstall plugin %s, got error (%v)", name, err)) + continue + } + + fmt.Fprintf(out, "Uninstalled plugin: %s\n", name) } - if len(errorPlugins) > 0 { - return errors.Join(errorPlugins...) - } - return nil + + return errors.Join(errs...) } -func uninstallPlugin(p plugin.Plugin) error { - if err := os.RemoveAll(p.Dir()); err != nil { +func uninstallPlugin(pm *plugin.Manager, pluginRaw *plugin.PluginRaw) error { + pm.Store.Delete(pluginRaw.Metadata.Name) + + if err := os.RemoveAll(pluginRaw.Dir); err != nil { return err } // 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 - pluginName := p.Metadata().Name - pluginVersion := p.Metadata().Version + pluginName := pluginRaw.Metadata.Name + pluginVersion := pluginRaw.Metadata.Version pluginsDir := settings.PluginsDirectory // 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? -func findPlugin(plugins []plugin.Plugin, name string) plugin.Plugin { - for _, p := range plugins { - if p.Metadata().Name == name { - return p - } - } - return nil + // TODO: should the hook be run before deleting the plugin's files? + return runHook(pm, pluginRaw, plugin.Delete) } diff --git a/pkg/cmd/plugin_uninstall_test.go b/pkg/cmd/plugin_uninstall_test.go index 93d4dc8a8..003a55683 100644 --- a/pkg/cmd/plugin_uninstall_test.go +++ b/pkg/cmd/plugin_uninstall_test.go @@ -70,20 +70,20 @@ command: $HELM_PLUGIN_DIR/test-plugin } // Load the plugin - p, err := plugin.LoadDir(pluginDir) + pr, err := plugin.LoadDirRaw(pluginDir) if err != nil { t.Fatal(err) } // Create a test uninstall function that uses our test settings - testUninstallPlugin := func(plugin plugin.Plugin) error { - if err := os.RemoveAll(plugin.Dir()); err != nil { + testUninstallPlugin := func(pluginRaw *plugin.PluginRaw) error { + if err := os.RemoveAll(pluginRaw.Dir); err != nil { return err } // Clean up versioned tarball and provenance files from test HELM_PLUGINS directory - pluginName := plugin.Metadata().Name - pluginVersion := plugin.Metadata().Version + pluginName := pluginRaw.Metadata.Name + pluginVersion := pluginRaw.Metadata.Version testPluginsDir := testSettings.PluginsDirectory // 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 - if err := testUninstallPlugin(p); err != nil { + if err := testUninstallPlugin(pr); err != nil { t.Fatal(err) } diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index c6d4b8530..6e9596b68 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -19,7 +19,6 @@ import ( "errors" "fmt" "io" - "log/slog" "path/filepath" "github.com/spf13/cobra" @@ -61,33 +60,33 @@ 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.LoadAll(settings.PluginsDirectory) - if err != nil { - return err + pmc, ok := settings.PluginCatalog.(*plugin.PluginManagerCatalog) + if !ok { + return nil } - var errorPlugins []error + pm := pmc.Manager + var errs []error for _, name := range o.names { - if found := findPlugin(plugins, name); found != nil { - if err := updatePlugin(found); err != nil { - errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%v)", name, err)) - } else { - fmt.Fprintf(out, "Updated plugin: %s\n", name) - } - } else { - errorPlugins = append(errorPlugins, fmt.Errorf("plugin: %s not found", name)) + pluginRaw := pm.Store.Load(name) + if pluginRaw == nil { + errs = append(errs, fmt.Errorf("plugin: %s not found", name)) + continue } + + if err := updatePlugin(pm, pluginRaw); err != nil { + errs = append(errs, fmt.Errorf("failed to update plugin %s, got error (%v)", name, err)) + continue + } + + fmt.Fprintf(out, "Uninstalled plugin: %s\n", name) } - if len(errorPlugins) > 0 { - return errors.Join(errorPlugins...) - } - return nil + + return errors.Join(errs...) } -func updatePlugin(p plugin.Plugin) error { - exactLocation, err := filepath.EvalSymlinks(p.Dir()) +func updatePlugin(pm *plugin.Manager, pluginRaw *plugin.PluginRaw) error { + exactLocation, err := filepath.EvalSymlinks(pluginRaw.Dir) if err != nil { return err } @@ -104,11 +103,7 @@ func updatePlugin(p plugin.Plugin) error { return err } - slog.Debug("loading plugin", "path", i.Path()) - updatedPlugin, err := plugin.LoadDir(i.Path()) - if err != nil { - return err - } + pm.Store.Store(pluginRaw) - return runHook(updatedPlugin, plugin.Update) + return runHook(pm, pluginRaw, plugin.Update) } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 4753e51fe..3b57d1392 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -102,6 +102,8 @@ By default, the default directories depend on the Operating System. The defaults var settings = cli.New() func NewRootCmd(out io.Writer, args []string, logSetup func(bool)) (*cobra.Command, error) { + settings.InitializeDefaultPluginManager() + actionConfig := new(action.Configuration) cmd, err := newRootCmdWithConfig(actionConfig, out, args, logSetup) if err != nil { @@ -109,6 +111,7 @@ func NewRootCmd(out io.Writer, args []string, logSetup func(bool)) (*cobra.Comma } cobra.OnInitialize(func() { helmDriver := os.Getenv("HELM_DRIVER") + if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), helmDriver); err != nil { log.Fatal(err) } diff --git a/pkg/downloader/chart_downloader_test.go b/pkg/downloader/chart_downloader_test.go index 4349ecef9..375eefec8 100644 --- a/pkg/downloader/chart_downloader_test.go +++ b/pkg/downloader/chart_downloader_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/require" + "helm.sh/helm/v4/internal/plugin" "helm.sh/helm/v4/internal/test/ensure" "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/getter" @@ -79,6 +80,7 @@ func TestResolveChartRef(t *testing.T) { Getters: getter.All(&cli.EnvSettings{ RepositoryConfig: repoConfig, RepositoryCache: repoCache, + PluginCatalog: plugin.NewEmptyCatalog(), }), } @@ -119,6 +121,7 @@ func TestResolveChartOpts(t *testing.T) { Getters: getter.All(&cli.EnvSettings{ RepositoryConfig: repoConfig, RepositoryCache: repoCache, + PluginCatalog: plugin.NewEmptyCatalog(), }), } @@ -215,6 +218,7 @@ func TestDownloadTo(t *testing.T) { RepositoryConfig: repoConfig, RepositoryCache: repoCache, ContentCache: contentCache, + PluginCatalog: plugin.NewEmptyCatalog(), }), Options: []getter.Option{ getter.WithBasicAuth("username", "password"), @@ -271,6 +275,7 @@ func TestDownloadTo_TLS(t *testing.T) { RepositoryConfig: repoConfig, RepositoryCache: repoCache, ContentCache: contentCache, + PluginCatalog: plugin.NewEmptyCatalog(), }), Options: []getter.Option{ getter.WithTLSClientConfig( @@ -327,6 +332,7 @@ func TestDownloadTo_VerifyLater(t *testing.T) { RepositoryConfig: repoConfig, RepositoryCache: repoCache, ContentCache: contentCache, + PluginCatalog: plugin.NewEmptyCatalog(), }), } cname := "/signtest-0.1.0.tgz" @@ -356,6 +362,7 @@ func TestScanReposForURL(t *testing.T) { Getters: getter.All(&cli.EnvSettings{ RepositoryConfig: repoConfig, RepositoryCache: repoCache, + PluginCatalog: plugin.NewEmptyCatalog(), }), } diff --git a/pkg/getter/getter_test.go b/pkg/getter/getter_test.go index 83920e809..dc105a97f 100644 --- a/pkg/getter/getter_test.go +++ b/pkg/getter/getter_test.go @@ -19,6 +19,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "helm.sh/helm/v4/pkg/cli" ) @@ -73,6 +75,8 @@ func TestProvidersWithTimeout(t *testing.T) { func TestAll(t *testing.T) { env := cli.New() env.PluginsDirectory = pluginDir + err := env.InitializeDefaultPluginManager() + require.Nil(t, err) all := All(env) if len(all) != 4 { @@ -87,6 +91,8 @@ func TestAll(t *testing.T) { func TestByScheme(t *testing.T) { env := cli.New() env.PluginsDirectory = pluginDir + err := env.InitializeDefaultPluginManager() + require.Nil(t, err) g := All(env) if _, err := g.ByScheme("test"); err != nil { diff --git a/pkg/getter/plugingetter.go b/pkg/getter/plugingetter.go index 32dbc70c9..10e12048b 100644 --- a/pkg/getter/plugingetter.go +++ b/pkg/getter/plugingetter.go @@ -34,7 +34,7 @@ func collectGetterPlugins(settings *cli.EnvSettings) (Providers, error) { d := plugin.Descriptor{ Type: "getter/v1", } - plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d) + plgs, err := settings.PluginCatalog.FindPlugins(d) if err != nil { return nil, err } diff --git a/pkg/getter/plugingetter_test.go b/pkg/getter/plugingetter_test.go index 8faaf7329..79370baa1 100644 --- a/pkg/getter/plugingetter_test.go +++ b/pkg/getter/plugingetter_test.go @@ -33,6 +33,8 @@ import ( func TestCollectPlugins(t *testing.T) { env := cli.New() env.PluginsDirectory = pluginDir + err := env.InitializeDefaultPluginManager() + require.Nil(t, err) p, err := collectGetterPlugins(env) if err != nil { diff --git a/pkg/postrenderer/postrenderer.go b/pkg/postrenderer/postrenderer.go index 55e6d3adf..82fd4bcb1 100644 --- a/pkg/postrenderer/postrenderer.go +++ b/pkg/postrenderer/postrenderer.go @@ -17,7 +17,6 @@ import ( "bytes" "context" "fmt" - "path/filepath" "helm.sh/helm/v4/internal/plugin/schema" @@ -40,7 +39,7 @@ func NewPostRendererPlugin(settings *cli.EnvSettings, pluginName string, args .. Name: pluginName, Type: "postrenderer/v1", } - p, err := plugin.FindPlugin(filepath.SplitList(settings.PluginsDirectory), descriptor) + p, err := settings.PluginCatalog.FindPlugin(descriptor) if err != nil { return nil, err } diff --git a/pkg/repo/v1/chartrepo_test.go b/pkg/repo/v1/chartrepo_test.go index 05e034dd8..a0797841d 100644 --- a/pkg/repo/v1/chartrepo_test.go +++ b/pkg/repo/v1/chartrepo_test.go @@ -29,6 +29,7 @@ import ( "sigs.k8s.io/yaml" + "helm.sh/helm/v4/internal/plugin" "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/getter" ) @@ -131,7 +132,9 @@ func TestFindChartInAuthAndTLSAndPassRepoURL(t *testing.T) { chartURL, err := FindChartInRepoURL( srv.URL, "nginx", - getter.All(&cli.EnvSettings{}), + getter.All(&cli.EnvSettings{ + PluginCatalog: plugin.NewEmptyCatalog(), + }), WithInsecureSkipTLSverify(true), ) 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". - _, 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 // 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 @@ -163,7 +166,7 @@ func TestFindChartInRepoURL(t *testing.T) { } 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 { t.Fatalf("%v", err) } @@ -171,7 +174,7 @@ func TestFindChartInRepoURL(t *testing.T) { 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 { t.Errorf("%s", err) } @@ -184,6 +187,7 @@ func TestErrorFindChartInRepoURL(t *testing.T) { g := getter.All(&cli.EnvSettings{ RepositoryCache: t.TempDir(), + PluginCatalog: plugin.NewEmptyCatalog(), }) if _, err := FindChartInRepoURL("http://someserver/something", "nginx", g); err == nil { diff --git a/pkg/repo/v1/index_test.go b/pkg/repo/v1/index_test.go index a8aadadec..86d325387 100644 --- a/pkg/repo/v1/index_test.go +++ b/pkg/repo/v1/index_test.go @@ -28,6 +28,7 @@ import ( "strings" "testing" + "helm.sh/helm/v4/internal/plugin" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/getter" @@ -264,7 +265,7 @@ func TestDownloadIndexFile(t *testing.T) { r, err := NewChartRepository(&Entry{ Name: testRepo, URL: srv.URL, - }, getter.All(&cli.EnvSettings{})) + }, getter.All(&cli.EnvSettings{PluginCatalog: plugin.NewEmptyCatalog()})) if err != nil { t.Errorf("Problem creating chart repository from %s: %v", testRepo, err) } @@ -317,7 +318,7 @@ func TestDownloadIndexFile(t *testing.T) { r, err := NewChartRepository(&Entry{ Name: testRepo, URL: srv.URL + chartRepoURLPath, - }, getter.All(&cli.EnvSettings{})) + }, getter.All(&cli.EnvSettings{PluginCatalog: plugin.NewEmptyCatalog()})) if err != nil { t.Errorf("Problem creating chart repository from %s: %v", testRepo, err) }