Create plugins/invoke interface and move legacy plugins to subprocess runtime

Signed-off-by: George Jenkins <gvjenkins@gmail.com>
pull/31080/head
George Jenkins 2 months ago
parent 98d6d9c1ef
commit ac0fcef641

@ -0,0 +1,74 @@
/*
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 pluginloader // import "helm.sh/helm/v4/internal/plugins/loader"
import (
"helm.sh/helm/v4/internal/plugins"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
)
// FindPlugins returns a list of YAML files that describe plugins
func FindPlugins(pluginsDirs []string, descriptor plugins.PluginDescriptor) ([]plugins.Plugin, error) {
return findPlugins(pluginsDirs, subprocessFindPlugins, makeDescriptorFilter(descriptor))
}
type findFunc func(pluginsDirs string) ([]plugins.Plugin, error)
type filterFunc func(plugins.Plugin) bool
func findPlugins(pluginsDirs []string, findFunc findFunc, filterFunc filterFunc) ([]plugins.Plugin, error) {
found := []plugins.Plugin{}
for _, pluginsDir := range pluginsDirs {
ps, err := findFunc(pluginsDir)
if err != nil {
return nil, err
}
for _, p := range ps {
if filterFunc(p) {
found = append(found, p)
}
}
}
return found, nil
}
func convertSubprocess(subs []*subprocess.Plugin) []plugins.Plugin {
ps := make([]plugins.Plugin, len(subs))
for i, r := range subs {
ps[i] = r
}
return ps
}
func subprocessFindPlugins(pluginsDir string) ([]plugins.Plugin, error) {
ps, err := subprocess.FindPlugins(pluginsDir)
if err != nil {
return nil, err
}
return convertSubprocess(ps), nil
}
func makeDescriptorFilter(descriptor plugins.PluginDescriptor) filterFunc {
return func(p plugins.Plugin) bool {
manifest := p.Manifest()
return manifest.TypeVersion == descriptor.TypeVersion
}
}

@ -0,0 +1,104 @@
/*
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 pluginloader // import "helm.sh/helm/v4/internal/plugins/loader"
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/internal/plugins"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
)
func TestConvertSubprocess(t *testing.T) {
sps := []*subprocess.Plugin{
{
Metadata: &subprocess.Metadata{Name: "test-plugin"},
},
}
ps := convertSubprocess(sps)
require.Equal(t, len(sps), len(ps))
for index := range sps {
assert.Equal(t, sps[index], ps[index].(*subprocess.Plugin))
}
}
func TestFindPlugins(t *testing.T) {
pluginsDirs := []string{"testdata/plugins"}
findFunc := func(pluginsDir string) ([]plugins.Plugin, error) {
return []plugins.Plugin{
&subprocess.Plugin{
Dir: filepath.Join(pluginsDir, "test-plugin"),
Metadata: &subprocess.Metadata{
Name: "test-plugin",
},
},
}, nil
}
filterFunc := func(p plugins.Plugin) bool {
assert.Equal(t, "test-plugin", p.Manifest().Name)
return true
}
ps, err := findPlugins(pluginsDirs, findFunc, filterFunc)
assert.NoError(t, err)
require.Len(t, ps, 1)
assert.Equal(t, "test-plugin", ps[0].Manifest().Name)
}
func TestMakeDescriptorFilter(t *testing.T) {
descriptor := plugins.PluginDescriptor{
TypeVersion: "getter/v1",
}
filterFunc := makeDescriptorFilter(descriptor)
ps := []plugins.Plugin{
&subprocess.Plugin{
Metadata: &subprocess.Metadata{
Name: "test-plugin",
// subprocess plugins classify themselves as "cli" or "downloader" based on presence of Downloaders field
Downloaders: []subprocess.Downloaders{
{
Protocols: []string{"http"},
},
},
},
},
&subprocess.Plugin{
Metadata: &subprocess.Metadata{
Name: "other-plugin",
},
},
}
filtered := []plugins.Plugin{}
for _, p := range ps {
if filterFunc(p) {
filtered = append(filtered, p)
}
}
require.Len(t, filtered, 1)
assert.Equal(t, "test-plugin", filtered[0].Manifest().Name)
}

@ -0,0 +1,72 @@
/*
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 plugins
import (
"context"
)
const PluginFileName = "plugin.yaml"
type PluginDescriptor struct {
TypeVersion string
}
// plugin.yaml definition
type Manifest struct {
// APIVersion of the plugin manifest document
// Currently: 'plugins.helm.sh/v1alpha1'
APIVersion string `json:"apiVersion"`
// Author defined name, version and description of the plugin
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
// Type/version of the plugin: 'getter/v1', 'postrenderer/v1', 'cli/v1', etc
// Describing the situation the plugin is expected to be invoked, and the correspondng message type/version used to invoke
TypeVersion string `json:"typeVersion"`
// Runtime used to execute the plugin
// subprocess, extism/v1, etc
RuntimeClass string `json:"runtimeClass"`
// Additional config associated with the plugin kind: e.g. downloader URI schemes
// (Config is intepreted by the plugin invoker)
Config map[string]any `json:"config,omitempty"`
}
// Input defined the input message and parameters to be passed to the plugin
type Input struct {
// Message represents the type-elided value to be passed to the plugin
// The plugin is expected to interpret the message according to its type/version
// The message object must be JSON-serializable
Message any
}
// Input defined the output message and parameters the passed from the plugin
type Output struct {
// Message represents the type-elided value passed from the plugin
// The invoker is expected to interpret the message according to the plugins type/version
Message any
}
// Plugin defines the "invokable" interface for a plugin, as well a getter for the plugin's describing manifest
// The invoke method can be thought of request/response message passing between the plugin invoker and the plugin itself
type Plugin interface {
Manifest() Manifest
Invoke(ctx context.Context, input *Input) (*Output, error)
}

@ -14,7 +14,7 @@ limitations under the License.
*/
// Package cache provides a key generator for vcs urls.
package cache // import "helm.sh/helm/v4/pkg/plugin/cache"
package cache // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess/cache"
import (
"net/url"

@ -0,0 +1,24 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package subprocess // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
func IsDownloader(p *Plugin) bool {
if p.Metadata == nil {
return false
}
return len(p.Metadata.Downloaders) > 0
}

@ -0,0 +1,64 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package subprocess // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsDownloader(t *testing.T) {
testCases := map[string]struct {
Plugin Plugin
Want bool
}{
"nil metadata": {
Plugin: Plugin{
Metadata: nil,
},
Want: false,
},
"no downloaders": {
Plugin: Plugin{
Metadata: &Metadata{
Downloaders: nil,
},
},
Want: false,
},
"downloader": {
Plugin: Plugin{
Metadata: &Metadata{
Downloaders: []Downloaders{
{
Protocols: []string{"test"},
Command: "foo",
},
},
},
},
Want: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got := IsDownloader(&tc.Plugin)
assert.Equal(t, got, tc.Want)
})
}
}

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package plugin // import "helm.sh/helm/v4/pkg/plugin"
package subprocess // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
// Types of hooks
const (

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"
import (
"path/filepath"

@ -11,7 +11,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"
import (
"testing"

@ -14,4 +14,4 @@ limitations under the License.
*/
// Package installer provides an interface for installing Helm plugins.
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"
import (
"archive/tar"
@ -32,11 +32,11 @@ import (
securejoin "github.com/cyphar/filepath-securejoin"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess/cache"
"helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/helmpath"
"helm.sh/helm/v4/pkg/plugin/cache"
)
// HTTPInstaller installs plugins from an archive served by a web server.

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"
import (
"archive/tar"

@ -22,7 +22,7 @@ import (
"path/filepath"
"strings"
"helm.sh/helm/v4/pkg/plugin"
plugins "helm.sh/helm/v4/internal/plugins"
)
// ErrMissingMetadata indicates that plugin.yaml is missing.
@ -119,6 +119,6 @@ func isRemoteHTTPArchive(source string) bool {
// isPlugin checks if the directory contains a plugin.yaml file.
func isPlugin(dirname string) bool {
_, err := os.Stat(filepath.Join(dirname, plugin.PluginFileName))
_, err := os.Stat(filepath.Join(dirname, plugins.PluginFileName))
return err == nil
}

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"
import (
"errors"

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"
import (
"os"

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"
import (
"errors"
@ -26,9 +26,9 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/Masterminds/vcs"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess/cache"
"helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/pkg/helmpath"
"helm.sh/helm/v4/pkg/plugin/cache"
)
// VCSInstaller installs plugins from remote a repository.

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"
import (
"fmt"

@ -13,11 +13,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package plugin // import "helm.sh/helm/v4/pkg/plugin"
package subprocess // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
@ -26,10 +29,12 @@ import (
"sigs.k8s.io/yaml"
"helm.sh/helm/v4/internal/plugins"
"helm.sh/helm/v4/internal/plugins/schema"
"helm.sh/helm/v4/pkg/cli"
)
const PluginFileName = "plugin.yaml"
const PluginFileName = plugins.PluginFileName
// Downloaders represents the plugins capability if it can retrieve
// charts from special sources
@ -142,6 +147,8 @@ type Plugin struct {
Dir string
}
var _ plugins.Plugin = (*Plugin)(nil)
// Returns command and args strings based on the following rules in priority order:
// - From the PlatformCommand where OS and Arch match the current platform
// - From the PlatformCommand where OS matches the current platform and Arch is empty/unspecified
@ -311,7 +318,7 @@ func LoadDir(dirname string) (*Plugin, error) {
plug := &Plugin{Dir: dirname}
if err := yaml.UnmarshalStrict(data, &plug.Metadata); err != nil {
return nil, fmt.Errorf("failed to load plugin at %q: %w", pluginfile, err)
return nil, fmt.Errorf("failed to load %s at %q: %w", PluginFileName, pluginfile, err)
}
return plug, validatePluginData(plug, pluginfile)
}
@ -368,3 +375,141 @@ func SetupPluginEnv(settings *cli.EnvSettings, name, base string) {
os.Setenv(key, val)
}
}
type pluginExec struct {
command string
argv []string
env []string
}
func convertInputGetterInputV1(p *Plugin, command string, argvBase []string, msg schema.GetterInputV1) (pluginExec, error) {
tmpDir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("helm-plugin-%s-", p.Metadata.Name))
if err != nil {
return pluginExec{}, fmt.Errorf("failed to create temporary directory: %w", err)
}
defer os.RemoveAll(tmpDir)
writeTempFile := func(name string, data []byte) (string, error) {
if len(data) == 0 {
return "", nil
}
tempFile := filepath.Join(tmpDir, name)
err := os.WriteFile(tempFile, msg.Options.Cert, 0o640)
if err != nil {
return "", fmt.Errorf("failed to write temporary file: %w", err)
}
return tempFile, nil
}
certFile, err := writeTempFile("cert", msg.Options.Cert)
if err != nil {
return pluginExec{}, err
}
keyFile, err := writeTempFile("key", msg.Options.Cert)
if err != nil {
return pluginExec{}, err
}
caFile, err := writeTempFile("ca", msg.Options.Cert)
if err != nil {
return pluginExec{}, err
}
argv := append(
argvBase,
certFile,
keyFile,
caFile,
msg.URL)
env := append(
os.Environ(),
fmt.Sprintf("HELM_PLUGIN_USERNAME=%s", msg.Options.Username),
fmt.Sprintf("HELM_PLUGIN_PASSWORD=%s", msg.Options.Password),
fmt.Sprintf("HELM_PLUGIN_PASS_CREDENTIALS_ALL=%t", msg.Options.PassCredentialsAll))
return pluginExec{
command: command,
argv: argv,
env: env,
}, nil
}
func convertInput(p *Plugin, input *plugins.Input) (pluginExec, error) {
command, argv, err := p.PrepareCommand([]string{})
if err != nil {
return pluginExec{}, fmt.Errorf("failed to prepare command for plugin %q: %w", p.Dir, err)
}
switch inputMsg := input.Message.(type) {
case schema.GetterInputV1:
return convertInputGetterInputV1(
p,
command,
argv,
inputMsg)
}
return pluginExec{}, fmt.Errorf("unsupported plugin input type %T", input)
}
func convertOutput(buf *bytes.Buffer) *plugins.Output {
return &plugins.Output{
Message: schema.GetterOutputV1{
Data: buf,
},
}
}
func (p *Plugin) Invoke(_ context.Context, input *plugins.Input) (*plugins.Output, error) {
pluginExec, err := convertInput(p, input)
if err != nil {
return nil, fmt.Errorf("failed to convert plugin input: %w", err)
}
pluginCommand := filepath.Join(p.Dir, pluginExec.command)
prog := exec.Command(
pluginCommand,
pluginExec.argv...)
prog.Env = pluginExec.env
buf := bytes.NewBuffer(nil)
prog.Stdout = buf
prog.Stderr = os.Stderr
if err := prog.Run(); err != nil {
if eerr, ok := err.(*exec.ExitError); ok {
os.Stderr.Write(eerr.Stderr)
return nil, fmt.Errorf("plugin %q exited with error", pluginCommand)
}
return nil, fmt.Errorf("failed to run plugin %q: %w", pluginCommand, err)
}
return convertOutput(buf), nil
}
func (p *Plugin) Manifest() plugins.Manifest {
typeVersion := "cli/v1"
config := map[string]any{}
if IsDownloader(p) {
typeVersion = "getter/v1"
schemes := make([]string, 0, len(p.Metadata.Downloaders))
for _, d := range p.Metadata.Downloaders {
schemes = append(schemes, d.Protocols...)
}
config["downloader_schemes"] = schemes
}
return plugins.Manifest{
APIVersion: "legacy",
Name: p.Metadata.Name,
Version: p.Metadata.Version,
Description: p.Metadata.Description,
TypeVersion: typeVersion,
RuntimeClass: "subprocess",
Config: config,
}
}

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package plugin // import "helm.sh/helm/v4/pkg/plugin"
package subprocess // import "helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
import (
"fmt"

@ -0,0 +1,49 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package schema
import (
"bytes"
"time"
)
// TODO: can we generate these plugin input/outputs?
type GetterOptionsV1 struct {
URL string
Cert []byte
Key []byte
CA []byte
UNTar bool
InsecureSkipVerifyTLS bool
PlainHTTP bool
AcceptHeader string
Username string
Password string
PassCredentialsAll bool
UserAgent string
Version string
Timeout time.Duration
}
type GetterInputV1 struct {
URL string `json:"url"`
Options GetterOptionsV1 `json:"options"`
}
type GetterOutputV1 struct {
Data *bytes.Buffer `json:"data"`
}

@ -31,7 +31,8 @@ import (
"github.com/spf13/cobra"
"sigs.k8s.io/yaml"
"helm.sh/helm/v4/pkg/plugin"
pluginloader "helm.sh/helm/v4/internal/plugins/loader"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
)
const (
@ -55,7 +56,7 @@ func loadPlugins(baseCmd *cobra.Command, out io.Writer) {
return
}
found, err := plugin.FindPlugins(settings.PluginsDirectory)
found, err := pluginloader.FindPlugins([]string{settings.PluginsDirectory}, cliPluginDescriptor)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load plugins: %s\n", err)
return
@ -63,15 +64,17 @@ func loadPlugins(baseCmd *cobra.Command, out io.Writer) {
// Now we create commands for all of these.
for _, plug := range found {
plug := plug
md := plug.Metadata
if md.Usage == "" {
md.Usage = fmt.Sprintf("the %q plugin", md.Name)
splug := plug.(*subprocess.Plugin)
md := splug.Metadata
usage := splug.Metadata.Usage
if usage == "" {
usage = fmt.Sprintf("the %q plugin", md.Name)
}
c := &cobra.Command{
Use: md.Name,
Short: md.Usage,
Short: usage,
Long: md.Description,
RunE: func(cmd *cobra.Command, args []string) error {
u, err := processParent(cmd, args)
@ -82,8 +85,8 @@ func loadPlugins(baseCmd *cobra.Command, out io.Writer) {
// Call setupEnv before PrepareCommand because
// PrepareCommand uses os.ExpandEnv and expects the
// setupEnv vars.
plugin.SetupPluginEnv(settings, md.Name, plug.Dir)
main, argv, prepCmdErr := plug.PrepareCommand(u)
subprocess.SetupPluginEnv(settings, md.Name, splug.Dir)
main, argv, prepCmdErr := splug.PrepareCommand(u)
if prepCmdErr != nil {
os.Stderr.WriteString(prepCmdErr.Error())
return fmt.Errorf("plugin %q exited with error", md.Name)
@ -106,7 +109,7 @@ func loadPlugins(baseCmd *cobra.Command, out io.Writer) {
if (err == nil &&
((subCmd.HasParent() && subCmd.Parent().Name() == "completion") || subCmd.Name() == cobra.ShellCompRequestCmd)) ||
/* for the tests */ subCmd == baseCmd.Root() {
loadCompletionForPlugin(c, plug)
loadCompletionForPlugin(c, splug)
}
}
}
@ -201,7 +204,7 @@ type pluginCommand struct {
// loadCompletionForPlugin will load and parse any completion.yaml provided by the plugin
// and add the dynamic completion hook to call the optional plugin.complete
func loadCompletionForPlugin(pluginCmd *cobra.Command, plugin *plugin.Plugin) {
func loadCompletionForPlugin(pluginCmd *cobra.Command, plugin *subprocess.Plugin) {
// Parse the yaml file providing the plugin's sub-commands and flags
cmds, err := loadFile(strings.Join(
[]string{plugin.Dir, pluginStaticCompletionFile}, string(filepath.Separator)))
@ -223,7 +226,7 @@ func loadCompletionForPlugin(pluginCmd *cobra.Command, plugin *plugin.Plugin) {
// addPluginCommands is a recursive method that adds each different level
// of sub-commands and flags for the plugins that have provided such information
func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *pluginCommand) {
func addPluginCommands(plugin *subprocess.Plugin, baseCmd *cobra.Command, cmds *pluginCommand) {
if cmds == nil {
return
}
@ -320,7 +323,7 @@ func loadFile(path string) (*pluginCommand, error) {
// pluginDynamicComp call the plugin.complete script of the plugin (if available)
// to obtain the dynamic completion choices. It must pass all the flags and sub-commands
// specified in the command-line to the plugin.complete executable (except helm's global flags)
func pluginDynamicComp(plug *plugin.Plugin, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
func pluginDynamicComp(plug *subprocess.Plugin, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
md := plug.Metadata
u, err := processParent(cmd, args)
@ -339,7 +342,7 @@ func pluginDynamicComp(plug *plugin.Plugin, cmd *cobra.Command, args []string, t
argv = append(argv, u...)
argv = append(argv, toComplete)
}
plugin.SetupPluginEnv(settings, md.Name, plug.Dir)
subprocess.SetupPluginEnv(settings, md.Name, plug.Dir)
cobra.CompDebugln(fmt.Sprintf("calling %s with args %v", main, argv), settings.Debug)
buf := new(bytes.Buffer)

@ -24,7 +24,8 @@ import (
"github.com/spf13/cobra"
"helm.sh/helm/v4/pkg/plugin"
"helm.sh/helm/v4/internal/plugins"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
)
const pluginHelp = `
@ -47,20 +48,20 @@ func newPluginCmd(out io.Writer) *cobra.Command {
}
// runHook will execute a plugin hook.
func runHook(p *plugin.Plugin, event string) error {
plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir)
func runHook(p *subprocess.Plugin, event string) error {
subprocess.SetupPluginEnv(settings, p.Metadata.Name, p.Dir)
cmds := p.Metadata.PlatformHooks[event]
expandArgs := true
if len(cmds) == 0 && len(p.Metadata.Hooks) > 0 {
cmd := p.Metadata.Hooks[event]
if len(cmd) > 0 {
cmds = []plugin.PlatformCommand{{Command: "sh", Args: []string{"-c", cmd}}}
cmds = []subprocess.PlatformCommand{{Command: "sh", Args: []string{"-c", cmd}}}
expandArgs = false
}
}
main, argv, err := plugin.PrepareCommands(cmds, expandArgs, []string{})
main, argv, err := subprocess.PrepareCommands(cmds, expandArgs, []string{})
if err != nil {
return nil
}
@ -79,3 +80,7 @@ func runHook(p *plugin.Plugin, event string) error {
}
return nil
}
var cliPluginDescriptor = plugins.PluginDescriptor{
TypeVersion: "cli/v1",
}

@ -22,9 +22,10 @@ import (
"github.com/spf13/cobra"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"
"helm.sh/helm/v4/pkg/cmd/require"
"helm.sh/helm/v4/pkg/plugin"
"helm.sh/helm/v4/pkg/plugin/installer"
)
type pluginInstallOptions struct {
@ -80,12 +81,12 @@ func (o *pluginInstallOptions) run(out io.Writer) error {
}
slog.Debug("loading plugin", "path", i.Path())
p, err := plugin.LoadDir(i.Path())
p, err := subprocess.LoadDir(i.Path())
if err != nil {
return fmt.Errorf("plugin is installed but unusable: %w", err)
}
if err := runHook(p, plugin.Install); err != nil {
if err := runHook(p, subprocess.Install); err != nil {
return err
}

@ -24,7 +24,9 @@ import (
"github.com/gosuri/uitable"
"github.com/spf13/cobra"
"helm.sh/helm/v4/pkg/plugin"
"helm.sh/helm/v4/internal/plugins"
pluginloader "helm.sh/helm/v4/internal/plugins/loader"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
)
func newPluginListCmd(out io.Writer) *cobra.Command {
@ -35,7 +37,9 @@ func newPluginListCmd(out io.Writer) *cobra.Command {
ValidArgsFunction: noMoreArgsCompFunc,
RunE: func(_ *cobra.Command, _ []string) error {
slog.Debug("pluginDirs", "directory", settings.PluginsDirectory)
plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
plugins, err := pluginloader.FindPlugins(
[]string{settings.PluginsDirectory},
cliPluginDescriptor)
if err != nil {
return err
}
@ -43,7 +47,8 @@ func newPluginListCmd(out io.Writer) *cobra.Command {
table := uitable.New()
table.AddRow("NAME", "VERSION", "DESCRIPTION")
for _, p := range plugins {
table.AddRow(p.Metadata.Name, p.Metadata.Version, p.Metadata.Description)
sp := p.(*subprocess.Plugin)
table.AddRow(sp.Metadata.Name, sp.Metadata.Version, sp.Metadata.Description)
}
fmt.Fprintln(out, table)
return nil
@ -53,17 +58,18 @@ func newPluginListCmd(out io.Writer) *cobra.Command {
}
// Returns all plugins from plugins, except those with names matching ignoredPluginNames
func filterPlugins(plugins []*plugin.Plugin, ignoredPluginNames []string) []*plugin.Plugin {
func filterPlugins(ps []plugins.Plugin, ignoredPluginNames []string) []plugins.Plugin {
// if ignoredPluginNames is nil, just return plugins
if ignoredPluginNames == nil {
return plugins
return ps
}
var filteredPlugins []*plugin.Plugin
for _, plugin := range plugins {
found := slices.Contains(ignoredPluginNames, plugin.Metadata.Name)
filteredPlugins := make([]plugins.Plugin, 0, len(ps))
for _, p := range ps {
sp := p.(*subprocess.Plugin)
found := slices.Contains(ignoredPluginNames, sp.Metadata.Name)
if !found {
filteredPlugins = append(filteredPlugins, plugin)
filteredPlugins = append(filteredPlugins, sp)
}
}
@ -73,11 +79,14 @@ func filterPlugins(plugins []*plugin.Plugin, ignoredPluginNames []string) []*plu
// Provide dynamic auto-completion for plugin names
func compListPlugins(_ string, ignoredPluginNames []string) []string {
var pNames []string
plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
plugins, err := pluginloader.FindPlugins(
[]string{settings.PluginsDirectory},
cliPluginDescriptor)
if err == nil && len(plugins) > 0 {
filteredPlugins := filterPlugins(plugins, ignoredPluginNames)
for _, p := range filteredPlugins {
pNames = append(pNames, fmt.Sprintf("%s\t%s", p.Metadata.Name, p.Metadata.Usage))
sp := p.(*subprocess.Plugin)
pNames = append(pNames, fmt.Sprintf("%s\t%s", sp.Metadata.Name, sp.Metadata.Usage))
}
}
return pNames

@ -24,7 +24,9 @@ import (
"github.com/spf13/cobra"
"helm.sh/helm/v4/pkg/plugin"
"helm.sh/helm/v4/internal/plugins"
pluginloader "helm.sh/helm/v4/internal/plugins/loader"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
)
type pluginUninstallOptions struct {
@ -61,7 +63,9 @@ func (o *pluginUninstallOptions) complete(args []string) error {
func (o *pluginUninstallOptions) run(out io.Writer) error {
slog.Debug("loading installer plugins", "dir", settings.PluginsDirectory)
plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
plugins, err := pluginloader.FindPlugins(
[]string{settings.PluginsDirectory},
cliPluginDescriptor)
if err != nil {
return err
}
@ -83,16 +87,18 @@ func (o *pluginUninstallOptions) run(out io.Writer) error {
return nil
}
func uninstallPlugin(p *plugin.Plugin) error {
if err := os.RemoveAll(p.Dir); err != nil {
func uninstallPlugin(p plugins.Plugin) error {
sp := p.(*subprocess.Plugin)
if err := os.RemoveAll(sp.Dir); err != nil {
return err
}
return runHook(p, plugin.Delete)
return runHook(sp, subprocess.Delete)
}
func findPlugin(plugins []*plugin.Plugin, name string) *plugin.Plugin {
for _, p := range plugins {
if p.Metadata.Name == name {
func findPlugin(ps []plugins.Plugin, name string) plugins.Plugin {
for _, p := range ps {
sp := p.(*subprocess.Plugin)
if sp.Metadata.Name == name {
return p
}
}

@ -24,8 +24,11 @@ import (
"github.com/spf13/cobra"
"helm.sh/helm/v4/pkg/plugin"
"helm.sh/helm/v4/pkg/plugin/installer"
"helm.sh/helm/v4/internal/plugins"
pluginloader "helm.sh/helm/v4/internal/plugins/loader"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess"
"helm.sh/helm/v4/internal/plugins/runtimes/subprocess/installer"
)
type pluginUpdateOptions struct {
@ -63,7 +66,9 @@ func (o *pluginUpdateOptions) complete(args []string) error {
func (o *pluginUpdateOptions) run(out io.Writer) error {
installer.Debug = settings.Debug
slog.Debug("loading installed plugins", "path", settings.PluginsDirectory)
plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
plugins, err := pluginloader.FindPlugins(
[]string{settings.PluginsDirectory},
cliPluginDescriptor)
if err != nil {
return err
}
@ -86,8 +91,10 @@ func (o *pluginUpdateOptions) run(out io.Writer) error {
return nil
}
func updatePlugin(p *plugin.Plugin) error {
exactLocation, err := filepath.EvalSymlinks(p.Dir)
func updatePlugin(p plugins.Plugin) error {
sp := p.(*subprocess.Plugin)
exactLocation, err := filepath.EvalSymlinks(sp.Dir)
if err != nil {
return err
}
@ -105,10 +112,10 @@ func updatePlugin(p *plugin.Plugin) error {
}
slog.Debug("loading plugin", "path", i.Path())
updatedPlugin, err := plugin.LoadDir(i.Path())
updatedPlugin, err := subprocess.LoadDir(i.Path())
if err != nil {
return err
}
return runHook(updatedPlugin, plugin.Update)
return runHook(updatedPlugin, subprocess.Update)
}

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

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

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

@ -17,92 +17,136 @@ package getter
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"helm.sh/helm/v4/internal/plugins"
pluginloader "helm.sh/helm/v4/internal/plugins/loader"
"helm.sh/helm/v4/internal/plugins/schema"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/plugin"
)
// collectPlugins scans for getter plugins.
// collectDownloaderPlugins scans for getter plugins.
// This will load plugins according to the cli.
func collectPlugins(settings *cli.EnvSettings) (Providers, error) {
plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
func collectDownloaderPlugins(settings *cli.EnvSettings) (Providers, error) {
d := plugins.PluginDescriptor{
TypeVersion: "getter/v1",
}
plgs, err := pluginloader.FindPlugins([]string{settings.PluginsDirectory}, d)
if err != nil {
return nil, err
}
var result Providers
for _, plugin := range plugins {
for _, downloader := range plugin.Metadata.Downloaders {
result = append(result, Provider{
Schemes: downloader.Protocols,
New: NewPluginGetter(
downloader.Command,
settings,
plugin.Metadata.Name,
plugin.Dir,
),
})
pluginConstructorBuilder := func(plg plugins.Plugin) Constructor {
return func(option ...Option) (Getter, error) {
return &getterPlugin{
options: append([]Option{}, option...),
plg: plg,
}, nil
}
}
return result, nil
}
// pluginGetter is a generic type to invoke custom downloaders,
// implemented in plugins.
type pluginGetter struct {
command string
settings *cli.EnvSettings
name string
base string
opts options
}
results := make([]Provider, 0, len(plgs))
func (p *pluginGetter) setupOptionsEnv(env []string) []string {
env = append(env, fmt.Sprintf("HELM_PLUGIN_USERNAME=%s", p.opts.username))
env = append(env, fmt.Sprintf("HELM_PLUGIN_PASSWORD=%s", p.opts.password))
env = append(env, fmt.Sprintf("HELM_PLUGIN_PASS_CREDENTIALS_ALL=%t", p.opts.passCredentialsAll))
return env
for _, plg := range plgs {
downloaderSchemes, ok := (plg.Manifest().Config["downloader_schemes"]).([]string)
if !ok {
return nil, fmt.Errorf("plugin %q does not have downloader_schemes defined", plg.Manifest().Name)
}
results = append(results, Provider{
Schemes: downloaderSchemes,
New: pluginConstructorBuilder(plg),
})
}
return results, nil
}
// Get runs downloader plugin command
func (p *pluginGetter) Get(href string, options ...Option) (*bytes.Buffer, error) {
func convertOptions(globalOptions, options []Option) (schema.GetterOptionsV1, error) {
opts := getterOptions{}
for _, opt := range globalOptions {
opt(&opts)
}
for _, opt := range options {
opt(&p.opts)
opt(&opts)
}
result := schema.GetterOptionsV1{
URL: opts.url,
// CertFile string
// KeyFile string
// CAFile string
UNTar: opts.unTar,
InsecureSkipVerifyTLS: opts.insecureSkipVerifyTLS,
PlainHTTP: opts.plainHTTP,
AcceptHeader: opts.acceptHeader,
Username: opts.username,
Password: opts.password,
PassCredentialsAll: opts.passCredentialsAll,
UserAgent: opts.userAgent,
Version: opts.version,
Timeout: opts.timeout,
}
commands := strings.Split(p.command, " ")
argv := append(commands[1:], p.opts.certFile, p.opts.keyFile, p.opts.caFile, href)
prog := exec.Command(filepath.Join(p.base, commands[0]), argv...)
plugin.SetupPluginEnv(p.settings, p.name, p.base)
prog.Env = p.setupOptionsEnv(os.Environ())
buf := bytes.NewBuffer(nil)
prog.Stdout = buf
prog.Stderr = os.Stderr
if err := prog.Run(); err != nil {
if eerr, ok := err.(*exec.ExitError); ok {
os.Stderr.Write(eerr.Stderr)
return nil, fmt.Errorf("plugin %q exited with error", p.command)
if opts.caFile != "" {
caData, err := os.ReadFile(opts.caFile)
if err != nil {
return schema.GetterOptionsV1{}, fmt.Errorf("unable to read CA file: %q: %w", opts.caFile, err)
}
return nil, err
result.CA = caData
}
return buf, nil
}
// NewPluginGetter constructs a valid plugin getter
func NewPluginGetter(command string, settings *cli.EnvSettings, name, base string) Constructor {
return func(options ...Option) (Getter, error) {
result := &pluginGetter{
command: command,
settings: settings,
name: name,
base: base,
if opts.certFile != "" || opts.keyFile != "" {
certData, err := os.ReadFile(opts.certFile)
if err != nil {
return schema.GetterOptionsV1{}, fmt.Errorf("unable to read cert file: %q: %w", opts.certFile, err)
}
for _, opt := range options {
opt(&result.opts)
keyData, err := os.ReadFile(opts.keyFile)
if err != nil {
return schema.GetterOptionsV1{}, fmt.Errorf("unable to read key file: %q: %w", opts.keyFile, err)
}
return result, nil
result.Cert = certData
result.Key = keyData
}
return result, nil
}
type getterPlugin struct {
options []Option
plg plugins.Plugin
}
func (g *getterPlugin) Get(url string, options ...Option) (*bytes.Buffer, error) {
opts, err := convertOptions(g.options, options)
if err != nil {
return nil, err
}
input := &plugins.Input{
Message: schema.GetterInputV1{
URL: url,
Options: opts,
},
}
output, err := g.plg.Invoke(context.Background(), input)
if err != nil {
return nil, fmt.Errorf("plugin %q failed to invoke: %w", g.plg, err)
}
outputMessage, ok := output.Message.(schema.GetterOutputV1)
if !ok {
return nil, fmt.Errorf("invalid output message type from plugin %q", g.plg.Manifest().Name)
}
return outputMessage.Data, nil
}

@ -16,8 +16,6 @@ limitations under the License.
package getter
import (
"runtime"
"strings"
"testing"
"helm.sh/helm/v4/pkg/cli"
@ -27,7 +25,7 @@ func TestCollectPlugins(t *testing.T) {
env := cli.New()
env.PluginsDirectory = pluginDir
p, err := collectPlugins(env)
p, err := collectDownloaderPlugins(env)
if err != nil {
t.Fatal(err)
}
@ -49,53 +47,53 @@ func TestCollectPlugins(t *testing.T) {
}
}
func TestPluginGetter(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("TODO: refactor this test to work on windows")
}
env := cli.New()
env.PluginsDirectory = pluginDir
pg := NewPluginGetter("echo", env, "test", ".")
g, err := pg()
if err != nil {
t.Fatal(err)
}
data, err := g.Get("test://foo/bar")
if err != nil {
t.Fatal(err)
}
expect := "test://foo/bar"
got := strings.TrimSpace(data.String())
if got != expect {
t.Errorf("Expected %q, got %q", expect, got)
}
}
func TestPluginSubCommands(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("TODO: refactor this test to work on windows")
}
env := cli.New()
env.PluginsDirectory = pluginDir
pg := NewPluginGetter("echo -n", env, "test", ".")
g, err := pg()
if err != nil {
t.Fatal(err)
}
data, err := g.Get("test://foo/bar")
if err != nil {
t.Fatal(err)
}
expect := " test://foo/bar"
got := data.String()
if got != expect {
t.Errorf("Expected %q, got %q", expect, got)
}
}
//func TestPluginGetter(t *testing.T) {
// if runtime.GOOS == "windows" {
// t.Skip("TODO: refactor this test to work on windows")
// }
//
// env := cli.New()
// env.PluginsDirectory = pluginDir
// pg := NewPluginGetter("echo", env, "test", ".")
// g, err := pg()
// if err != nil {
// t.Fatal(err)
// }
//
// data, err := g.Get("test://foo/bar")
// if err != nil {
// t.Fatal(err)
// }
//
// expect := "test://foo/bar"
// got := strings.TrimSpace(data.String())
// if got != expect {
// t.Errorf("Expected %q, got %q", expect, got)
// }
//}
//func TestPluginSubCommands(t *testing.T) {
// if runtime.GOOS == "windows" {
// t.Skip("TODO: refactor this test to work on windows")
// }
//
// env := cli.New()
// env.PluginsDirectory = pluginDir
//
// pg := NewPluginGetter("echo -n", env, "test", ".")
// g, err := pg()
// if err != nil {
// t.Fatal(err)
// }
//
// data, err := g.Get("test://foo/bar")
// if err != nil {
// t.Fatal(err)
// }
//
// expect := " test://foo/bar"
// got := data.String()
// if got != expect {
// t.Errorf("Expected %q, got %q", expect, got)
// }
//}

Loading…
Cancel
Save