mirror of https://github.com/helm/helm
[HIP-0026] Plugin runtime interface (#31145)
* Runtime abstraction to encapsulate subprocess code and enable future runtimes Also fix race condition in TestPrepareCommandExtraArgs by replacing the shared variable modification with a local copy Co-authored-by: George Jenkins <gvjenkins@gmail.com> Signed-off-by: Scott Rigby <scott@r6by.com> * Remove commented out code Co-authored-by: Joe Julian <me@joejulian.name> Signed-off-by: Scott Rigby <scott@r6by.com> * Check test failure string Co-authored-by: Jesse Simpson <jesse.simpson36@gmail.com> Signed-off-by: Scott Rigby <scott@r6by.com> --------- Signed-off-by: Scott Rigby <scott@r6by.com> Co-authored-by: George Jenkins <gvjenkins@gmail.com> Co-authored-by: Joe Julian <me@joejulian.name> Co-authored-by: Jesse Simpson <jesse.simpson36@gmail.com>pull/31146/head
parent
0f1b410f14
commit
be74ab72a0
@ -0,0 +1,66 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Config interface defines the methods that all plugin type configurations must implement
|
||||
type Config interface {
|
||||
GetType() string
|
||||
Validate() error
|
||||
}
|
||||
|
||||
// ConfigCLI represents the configuration for CLI plugins
|
||||
type ConfigCLI struct {
|
||||
// Usage is the single-line usage text shown in help
|
||||
// For recommended syntax, see [spf13/cobra.command.Command] Use field comment:
|
||||
// https://pkg.go.dev/github.com/spf13/cobra#Command
|
||||
Usage string `yaml:"usage"`
|
||||
// ShortHelp is the short description shown in the 'helm help' output
|
||||
ShortHelp string `yaml:"shortHelp"`
|
||||
// LongHelp is the long message shown in the 'helm help <this-command>' output
|
||||
LongHelp string `yaml:"longHelp"`
|
||||
// IgnoreFlags ignores any flags passed in from Helm
|
||||
IgnoreFlags bool `yaml:"ignoreFlags"`
|
||||
}
|
||||
|
||||
// ConfigGetter represents the configuration for download plugins
|
||||
type ConfigGetter struct {
|
||||
// Protocols are the list of URL schemes supported by this downloader
|
||||
Protocols []string `yaml:"protocols"`
|
||||
}
|
||||
|
||||
func (c *ConfigCLI) GetType() string { return "cli/v1" }
|
||||
func (c *ConfigGetter) GetType() string { return "getter/v1" }
|
||||
|
||||
func (c *ConfigCLI) Validate() error {
|
||||
// Config validation for CLI plugins
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ConfigGetter) Validate() error {
|
||||
if len(c.Protocols) == 0 {
|
||||
return fmt.Errorf("getter has no protocols")
|
||||
}
|
||||
for i, protocol := range c.Protocols {
|
||||
if protocol == "" {
|
||||
return fmt.Errorf("getter has empty protocol at index %d", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
// Descriptor describes a plugin to find
|
||||
type Descriptor struct {
|
||||
// Name is the name of the plugin
|
||||
Name string
|
||||
// Type is the type of the plugin (cli, getter, postrenderer)
|
||||
Type string
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
---
|
||||
TODO: move this section to public plugin package
|
||||
|
||||
Package plugin provides the implementation of the Helm plugin system.
|
||||
|
||||
Conceptually, "plugins" enable extending Helm's functionality external to Helm's core codebase. The plugin system allows
|
||||
code to fetch plugins by type, then invoke the plugin with an input as required by that plugin type. The plugin
|
||||
returning an output for the caller to consume.
|
||||
|
||||
An example of a plugin invocation:
|
||||
```
|
||||
d := plugin.Descriptor{
|
||||
Type: "example/v1", //
|
||||
}
|
||||
plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d)
|
||||
|
||||
for _, plg := range plgs {
|
||||
input := &plugin.Input{
|
||||
Message: schema.InputMessageExampleV1{ // The type of the input message is defined by the plugin's "type" (example/v1 here)
|
||||
...
|
||||
},
|
||||
}
|
||||
output, err := plg.Invoke(context.Background(), input)
|
||||
if err != nil {
|
||||
...
|
||||
}
|
||||
|
||||
// consume the output, using type assertion to convert to the expected output type (as defined by the plugin's "type")
|
||||
outputMessage, ok := output.Message.(schema.OutputMessageExampleV1)
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
Package `plugin` provides the implementation of the Helm plugin system.
|
||||
|
||||
Helm plugins are exposed to uses as the "Plugin" type, the basic interface that primarily support the "Invoke" method.
|
||||
|
||||
# Plugin Runtimes
|
||||
Internally, plugins must be implemented by a "runtime" that is responsible for creating the plugin instance, and dispatching the plugin's invocation to the plugin's implementation.
|
||||
For example:
|
||||
- forming environment variables and command line args for subprocess execution
|
||||
- converting input to JSON and invoking a function in a future runtime (eg, Wasm)
|
||||
|
||||
Internally, the code structure is:
|
||||
Runtime.CreatePlugin()
|
||||
|
|
||||
| (creates)
|
||||
|
|
||||
\---> PluginRuntime
|
||||
|
|
||||
| (implements)
|
||||
v
|
||||
Plugin.Invoke()
|
||||
|
||||
# Plugin Types
|
||||
Each plugin implements a specific functionality, denoted by the plugin's "type" e.g. "getter/v1". The "type" includes a version, in order to allow a given types messaging schema and invocation options to evolve.
|
||||
|
||||
Specifically, the plugin's "type" specifies the contract for the input and output messages that are expected to be passed to the plugin, and returned from the plugin. The plugin's "type" also defines the options that can be passed to the plugin when invoking it.
|
||||
|
||||
# Metadata
|
||||
Each plugin must have a `plugin.yaml`, that defines the plugin's metadata. The metadata includes the plugin's name, version, and other information.
|
||||
|
||||
For legacy plugins, the type is inferred by which fields are set on the plugin: a downloader plugin is inferred when metadata contains a "downloaders" yaml node, otherwise it is assumed to define a Helm CLI subcommand.
|
||||
|
||||
For future plugin api versions, the metadata will include explicit apiVersion and type fields. It will also contain type and runtime specific Config and RuntimeConfig fields.
|
||||
|
||||
# Runtime and type cardinality
|
||||
From a cardinality perspective, this means there a "few" runtimes, and "many" plugins types. It is also expected that the subprocess runtime will not be extended to support extra plugin types, and deprecated in a future version of Helm.
|
||||
|
||||
Future ideas that are intended to be implemented include extending the plugin system to support future Wasm standards. Or allowing Helm SDK user's to inject "plugins" that are actually implemented as native go modules. Or even moving Helm's internal functionality e.g. yaml rendering engine to be used as an "in-built" plugin, along side other plugins that may implement other (non-go template) rendering engines.
|
||||
*/
|
||||
|
||||
package plugin
|
@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
// InvokeExecError is returned when a plugin invocation returns a non-zero status/exit code
|
||||
// - subprocess plugin: child process exit code
|
||||
// - extism plugin: wasm function return code
|
||||
type InvokeExecError struct {
|
||||
Err error // Underlying error
|
||||
Code int // Exeit code from plugin code execution
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *InvokeExecError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
@ -0,0 +1,224 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
func peekAPIVersion(r io.Reader) (string, error) {
|
||||
type apiVersion struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
}
|
||||
|
||||
var v apiVersion
|
||||
d := yaml.NewDecoder(r)
|
||||
if err := d.Decode(&v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.APIVersion, nil
|
||||
}
|
||||
|
||||
func loadMetadataLegacy(metadataData []byte) (*Metadata, error) {
|
||||
|
||||
var ml MetadataLegacy
|
||||
d := yaml.NewDecoder(bytes.NewReader(metadataData))
|
||||
if err := d.Decode(&ml); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ml.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := fromMetadataLegacy(ml)
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func loadMetadata(metadataData []byte) (*Metadata, error) {
|
||||
apiVersion, err := peekAPIVersion(bytes.NewReader(metadataData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to peek %s API version: %w", PluginFileName, err)
|
||||
}
|
||||
|
||||
switch apiVersion {
|
||||
case "": // legacy
|
||||
return loadMetadataLegacy(metadataData)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid plugin apiVersion: %q", apiVersion)
|
||||
}
|
||||
|
||||
type prototypePluginManager struct {
|
||||
runtimes map[string]Runtime
|
||||
}
|
||||
|
||||
func newPrototypePluginManager() *prototypePluginManager {
|
||||
return &prototypePluginManager{
|
||||
runtimes: map[string]Runtime{
|
||||
"subprocess": &RuntimeSubprocess{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *prototypePluginManager) RegisterRuntime(runtimeName string, runtime Runtime) {
|
||||
pm.runtimes[runtimeName] = runtime
|
||||
}
|
||||
|
||||
func (pm *prototypePluginManager) CreatePlugin(pluginPath string, metadata *Metadata) (Plugin, error) {
|
||||
rt, ok := pm.runtimes[metadata.Runtime]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported plugin runtime type: %q", metadata.Runtime)
|
||||
}
|
||||
|
||||
return rt.CreatePlugin(pluginPath, metadata)
|
||||
}
|
||||
|
||||
// LoadDir loads a plugin from the given directory.
|
||||
func LoadDir(dirname string) (Plugin, error) {
|
||||
pluginfile := filepath.Join(dirname, PluginFileName)
|
||||
metadataData, err := os.ReadFile(pluginfile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read plugin at %q: %w", pluginfile, err)
|
||||
}
|
||||
|
||||
m, err := loadMetadata(metadataData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load plugin %q: %w", dirname, err)
|
||||
}
|
||||
|
||||
pm := newPrototypePluginManager()
|
||||
return pm.CreatePlugin(dirname, m)
|
||||
}
|
||||
|
||||
// LoadAll loads all plugins found beneath the base directory.
|
||||
//
|
||||
// This scans only one directory level.
|
||||
func LoadAll(basedir string) ([]Plugin, error) {
|
||||
var plugins []Plugin
|
||||
// We want basedir/*/plugin.yaml
|
||||
scanpath := filepath.Join(basedir, "*", PluginFileName)
|
||||
matches, err := filepath.Glob(scanpath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search for plugins in %q: %w", scanpath, err)
|
||||
}
|
||||
|
||||
// empty dir should load
|
||||
if len(matches) == 0 {
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
for _, yamlFile := range matches {
|
||||
dir := filepath.Dir(yamlFile)
|
||||
p, err := LoadDir(dir)
|
||||
if err != nil {
|
||||
return plugins, err
|
||||
}
|
||||
plugins = append(plugins, p)
|
||||
}
|
||||
return plugins, detectDuplicates(plugins)
|
||||
}
|
||||
|
||||
// findFunc is a function that finds plugins in a directory
|
||||
type findFunc func(pluginsDir string) ([]Plugin, error)
|
||||
|
||||
// filterFunc is a function that filters plugins
|
||||
type filterFunc func(Plugin) bool
|
||||
|
||||
// FindPlugins returns a list of plugins that match the descriptor
|
||||
func FindPlugins(pluginsDirs []string, descriptor Descriptor) ([]Plugin, error) {
|
||||
return findPlugins(pluginsDirs, LoadAll, makeDescriptorFilter(descriptor))
|
||||
}
|
||||
|
||||
// findPlugins is the internal implementation that uses the find and filter functions
|
||||
func findPlugins(pluginsDirs []string, findFn findFunc, filterFn filterFunc) ([]Plugin, error) {
|
||||
var found []Plugin
|
||||
for _, pluginsDir := range pluginsDirs {
|
||||
ps, err := findFn(pluginsDir)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range ps {
|
||||
if filterFn(p) {
|
||||
found = append(found, p)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// makeDescriptorFilter creates a filter function from a descriptor
|
||||
// Additional plugin filter criteria we wish to support can be added here
|
||||
func makeDescriptorFilter(descriptor Descriptor) filterFunc {
|
||||
return func(p Plugin) bool {
|
||||
// If name is specified, it must match
|
||||
if descriptor.Name != "" && p.Metadata().Name != descriptor.Name {
|
||||
return false
|
||||
|
||||
}
|
||||
// If type is specified, it must match
|
||||
if descriptor.Type != "" && p.Metadata().Type != descriptor.Type {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// FindPlugin returns a single plugin that matches the descriptor
|
||||
func FindPlugin(dirs []string, descriptor Descriptor) (Plugin, error) {
|
||||
plugins, err := FindPlugins(dirs, descriptor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(plugins) > 0 {
|
||||
return plugins[0], nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("plugin: %+v not found", descriptor)
|
||||
}
|
||||
|
||||
func detectDuplicates(plugs []Plugin) error {
|
||||
names := map[string]string{}
|
||||
|
||||
for _, plug := range plugs {
|
||||
if oldpath, ok := names[plug.Metadata().Name]; ok {
|
||||
return fmt.Errorf(
|
||||
"two plugins claim the name %q at %q and %q",
|
||||
plug.Metadata().Name,
|
||||
oldpath,
|
||||
plug.Dir(),
|
||||
)
|
||||
}
|
||||
names[plug.Metadata().Name] = plug.Dir()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,197 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPeekAPIVersion(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
data []byte
|
||||
expected string
|
||||
}{
|
||||
"legacy": { // No apiVersion field
|
||||
data: []byte(`---
|
||||
name: "test-plugin"
|
||||
`),
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
version, err := peekAPIVersion(bytes.NewReader(tc.data))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, version)
|
||||
})
|
||||
}
|
||||
|
||||
// invalid yaml
|
||||
{
|
||||
data := []byte(`bad yaml`)
|
||||
_, err := peekAPIVersion(bytes.NewReader(data))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDir(t *testing.T) {
|
||||
|
||||
makeMetadata := func(apiVersion string) Metadata {
|
||||
usage := "hello [params]..."
|
||||
if apiVersion == "legacy" {
|
||||
usage = "" // Legacy plugins don't have Usage field for command syntax
|
||||
}
|
||||
return Metadata{
|
||||
APIVersion: apiVersion,
|
||||
Name: fmt.Sprintf("hello-%s", apiVersion),
|
||||
Version: "0.1.0",
|
||||
Type: "cli/v1",
|
||||
Runtime: "subprocess",
|
||||
Config: &ConfigCLI{
|
||||
Usage: usage,
|
||||
ShortHelp: "echo hello message",
|
||||
LongHelp: "description",
|
||||
IgnoreFlags: true,
|
||||
},
|
||||
RuntimeConfig: &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.sh"}},
|
||||
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.ps1"}},
|
||||
},
|
||||
PlatformHooks: map[string][]PlatformCommand{
|
||||
Install: {
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
|
||||
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
testCases := map[string]struct {
|
||||
dirname string
|
||||
apiVersion string
|
||||
expect Metadata
|
||||
}{
|
||||
"legacy": {
|
||||
dirname: "testdata/plugdir/good/hello-legacy",
|
||||
apiVersion: "legacy",
|
||||
expect: makeMetadata("legacy"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
plug, err := LoadDir(tc.dirname)
|
||||
require.NoError(t, err, "error loading plugin from %s", tc.dirname)
|
||||
|
||||
assert.Equal(t, tc.dirname, plug.Dir())
|
||||
assert.EqualValues(t, tc.expect, plug.Metadata())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDirDuplicateEntries(t *testing.T) {
|
||||
testCases := map[string]string{
|
||||
"legacy": "testdata/plugdir/bad/duplicate-entries-legacy",
|
||||
}
|
||||
for name, dirname := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := LoadDir(dirname)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectDuplicates(t *testing.T) {
|
||||
plugs := []Plugin{
|
||||
mockSubprocessCLIPlugin(t, "foo"),
|
||||
mockSubprocessCLIPlugin(t, "bar"),
|
||||
}
|
||||
if err := detectDuplicates(plugs); err != nil {
|
||||
t.Error("no duplicates in the first set")
|
||||
}
|
||||
plugs = append(plugs, mockSubprocessCLIPlugin(t, "foo"))
|
||||
if err := detectDuplicates(plugs); err == nil {
|
||||
t.Error("duplicates in the second set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAll(t *testing.T) {
|
||||
// Verify that empty dir loads:
|
||||
{
|
||||
plugs, err := LoadAll("testdata")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, plugs, 0)
|
||||
}
|
||||
|
||||
basedir := "testdata/plugdir/good"
|
||||
plugs, err := LoadAll(basedir)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, plugs, "expected plugins to be loaded from %s", basedir)
|
||||
|
||||
plugsMap := map[string]Plugin{}
|
||||
for _, p := range plugs {
|
||||
plugsMap[p.Metadata().Name] = p
|
||||
}
|
||||
|
||||
assert.Len(t, plugsMap, 3)
|
||||
assert.Contains(t, plugsMap, "downloader")
|
||||
assert.Contains(t, plugsMap, "echo-legacy")
|
||||
assert.Contains(t, plugsMap, "hello-legacy")
|
||||
}
|
||||
|
||||
func TestFindPlugins(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
plugdirs string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "plugdirs is empty",
|
||||
plugdirs: "",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "plugdirs isn't dir",
|
||||
plugdirs: "./plugin_test.go",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "plugdirs doesn't have plugin",
|
||||
plugdirs: ".",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "normal",
|
||||
plugdirs: "./testdata/plugdir/good",
|
||||
expected: 3,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(t.Name(), func(t *testing.T) {
|
||||
plugin, err := LoadAll(c.plugdirs)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, plugin, c.expected, "expected %d plugins, got %d", c.expected, len(plugin))
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,155 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Metadata of a plugin, converted from the "on-disk" plugin.yaml
|
||||
// Specifically, Config and RuntimeConfig are converted to their respective types based on the plugin type and runtime
|
||||
type Metadata struct {
|
||||
// APIVersion specifies the plugin API version
|
||||
APIVersion string
|
||||
|
||||
// Name is the name of the plugin
|
||||
Name string
|
||||
|
||||
// Type of plugin (eg, cli/v1, getter/v1)
|
||||
Type string
|
||||
|
||||
// Runtime specifies the runtime type (subprocess, wasm)
|
||||
Runtime string
|
||||
|
||||
// Version is the SemVer 2 version of the plugin.
|
||||
Version string
|
||||
|
||||
// SourceURL is the URL where this plugin can be found
|
||||
SourceURL string
|
||||
|
||||
// Config contains the type-specific configuration for this plugin
|
||||
Config Config
|
||||
|
||||
// RuntimeConfig contains the runtime-specific configuration
|
||||
RuntimeConfig RuntimeConfig
|
||||
}
|
||||
|
||||
func (m Metadata) Validate() error {
|
||||
var errs []error
|
||||
|
||||
if !validPluginName.MatchString(m.Name) {
|
||||
errs = append(errs, fmt.Errorf("invalid name"))
|
||||
}
|
||||
|
||||
if m.APIVersion == "" {
|
||||
errs = append(errs, fmt.Errorf("empty APIVersion"))
|
||||
}
|
||||
|
||||
if m.Type == "" {
|
||||
errs = append(errs, fmt.Errorf("empty type field"))
|
||||
}
|
||||
|
||||
if m.Runtime == "" {
|
||||
errs = append(errs, fmt.Errorf("empty runtime field"))
|
||||
}
|
||||
|
||||
if m.Config == nil {
|
||||
errs = append(errs, fmt.Errorf("missing config field"))
|
||||
}
|
||||
|
||||
if m.RuntimeConfig == nil {
|
||||
errs = append(errs, fmt.Errorf("missing runtimeConfig field"))
|
||||
}
|
||||
|
||||
// Validate the config itself
|
||||
if m.Config != nil {
|
||||
if err := m.Config.Validate(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("config validation failed: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the runtime config itself
|
||||
if m.RuntimeConfig != nil {
|
||||
if err := m.RuntimeConfig.Validate(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("runtime config validation failed: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fromMetadataLegacy(m MetadataLegacy) *Metadata {
|
||||
pluginType := "cli/v1"
|
||||
|
||||
if len(m.Downloaders) > 0 {
|
||||
pluginType = "getter/v1"
|
||||
}
|
||||
|
||||
return &Metadata{
|
||||
APIVersion: "legacy",
|
||||
Name: m.Name,
|
||||
Version: m.Version,
|
||||
Type: pluginType,
|
||||
Runtime: "subprocess",
|
||||
Config: buildLegacyConfig(m, pluginType),
|
||||
RuntimeConfig: buildLegacyRuntimeConfig(m),
|
||||
}
|
||||
}
|
||||
|
||||
func buildLegacyConfig(m MetadataLegacy, pluginType string) Config {
|
||||
switch pluginType {
|
||||
case "getter/v1":
|
||||
var protocols []string
|
||||
for _, d := range m.Downloaders {
|
||||
protocols = append(protocols, d.Protocols...)
|
||||
}
|
||||
return &ConfigGetter{
|
||||
Protocols: protocols,
|
||||
}
|
||||
case "cli/v1":
|
||||
return &ConfigCLI{
|
||||
Usage: "", // Legacy plugins don't have Usage field for command syntax
|
||||
ShortHelp: m.Usage, // Map legacy usage to shortHelp
|
||||
LongHelp: m.Description, // Map legacy description to longHelp
|
||||
IgnoreFlags: m.IgnoreFlags,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildLegacyRuntimeConfig(m MetadataLegacy) RuntimeConfig {
|
||||
var protocolCommands []SubprocessProtocolCommand
|
||||
if len(m.Downloaders) > 0 {
|
||||
protocolCommands =
|
||||
make([]SubprocessProtocolCommand, 0, len(m.Downloaders))
|
||||
for _, d := range m.Downloaders {
|
||||
protocolCommands = append(protocolCommands, SubprocessProtocolCommand(d))
|
||||
}
|
||||
}
|
||||
return &RuntimeConfigSubprocess{
|
||||
PlatformCommands: m.PlatformCommands,
|
||||
Command: m.Command,
|
||||
PlatformHooks: m.PlatformHooks,
|
||||
Hooks: m.Hooks,
|
||||
ProtocolCommands: protocolCommands,
|
||||
}
|
||||
}
|
@ -0,0 +1,113 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Downloaders represents the plugins capability if it can retrieve
|
||||
// charts from special sources
|
||||
type Downloaders struct {
|
||||
// Protocols are the list of schemes from the charts URL.
|
||||
Protocols []string `yaml:"protocols"`
|
||||
// Command is the executable path with which the plugin performs
|
||||
// the actual download for the corresponding Protocols
|
||||
Command string `yaml:"command"`
|
||||
}
|
||||
|
||||
// MetadataLegacy is the legacy plugin.yaml format
|
||||
type MetadataLegacy struct {
|
||||
// Name is the name of the plugin
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// Version is a SemVer 2 version of the plugin.
|
||||
Version string `yaml:"version"`
|
||||
|
||||
// Usage is the single-line usage text shown in help
|
||||
Usage string `yaml:"usage"`
|
||||
|
||||
// Description is a long description shown in places like `helm help`
|
||||
Description string `yaml:"description"`
|
||||
|
||||
// PlatformCommands is the plugin command, with a platform selector and support for args.
|
||||
PlatformCommands []PlatformCommand `yaml:"platformCommand"`
|
||||
|
||||
// Command is the plugin command, as a single string.
|
||||
// DEPRECATED: Use PlatformCommand instead. Removed in subprocess/v1 plugins.
|
||||
Command string `yaml:"command"`
|
||||
|
||||
// IgnoreFlags ignores any flags passed in from Helm
|
||||
IgnoreFlags bool `yaml:"ignoreFlags"`
|
||||
|
||||
// PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
|
||||
PlatformHooks PlatformHooks `yaml:"platformHooks"`
|
||||
|
||||
// Hooks are commands that will run on plugin events, as a single string.
|
||||
// DEPRECATED: Use PlatformHooks instead. Removed in subprocess/v1 plugins.
|
||||
Hooks Hooks `yaml:"hooks"`
|
||||
|
||||
// Downloaders field is used if the plugin supply downloader mechanism
|
||||
// for special protocols.
|
||||
Downloaders []Downloaders `yaml:"downloaders"`
|
||||
}
|
||||
|
||||
func (m *MetadataLegacy) Validate() error {
|
||||
if !validPluginName.MatchString(m.Name) {
|
||||
return fmt.Errorf("invalid plugin name")
|
||||
}
|
||||
m.Usage = sanitizeString(m.Usage)
|
||||
|
||||
if len(m.PlatformCommands) > 0 && len(m.Command) > 0 {
|
||||
return fmt.Errorf("both platformCommand and command are set")
|
||||
}
|
||||
|
||||
if len(m.PlatformHooks) > 0 && len(m.Hooks) > 0 {
|
||||
return fmt.Errorf("both platformHooks and hooks are set")
|
||||
}
|
||||
|
||||
// Validate downloader plugins
|
||||
for i, downloader := range m.Downloaders {
|
||||
if downloader.Command == "" {
|
||||
return fmt.Errorf("downloader %d has empty command", i)
|
||||
}
|
||||
if len(downloader.Protocols) == 0 {
|
||||
return fmt.Errorf("downloader %d has no protocols", i)
|
||||
}
|
||||
for j, protocol := range downloader.Protocols {
|
||||
if protocol == "" {
|
||||
return fmt.Errorf("downloader %d has empty protocol at index %d", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeString normalize spaces and removes non-printable characters.
|
||||
func sanitizeString(str string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) {
|
||||
return ' '
|
||||
}
|
||||
if unicode.IsPrint(r) {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, str)
|
||||
}
|
@ -0,0 +1,141 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePluginData(t *testing.T) {
|
||||
|
||||
// A mock plugin with no commands
|
||||
mockNoCommand := mockSubprocessCLIPlugin(t, "foo")
|
||||
mockNoCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{},
|
||||
PlatformHooks: map[string][]PlatformCommand{},
|
||||
}
|
||||
|
||||
// A mock plugin with legacy commands
|
||||
mockLegacyCommand := mockSubprocessCLIPlugin(t, "foo")
|
||||
mockLegacyCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{},
|
||||
Command: "echo \"mock plugin\"",
|
||||
PlatformHooks: map[string][]PlatformCommand{},
|
||||
Hooks: map[string]string{
|
||||
Install: "echo installing...",
|
||||
},
|
||||
}
|
||||
|
||||
// A mock plugin with a command also set
|
||||
mockWithCommand := mockSubprocessCLIPlugin(t, "foo")
|
||||
mockWithCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
|
||||
},
|
||||
Command: "echo \"mock plugin\"",
|
||||
}
|
||||
|
||||
// A mock plugin with a hooks also set
|
||||
mockWithHooks := mockSubprocessCLIPlugin(t, "foo")
|
||||
mockWithHooks.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
|
||||
},
|
||||
PlatformHooks: map[string][]PlatformCommand{
|
||||
Install: {
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
|
||||
},
|
||||
},
|
||||
Hooks: map[string]string{
|
||||
Install: "echo installing...",
|
||||
},
|
||||
}
|
||||
|
||||
for i, item := range []struct {
|
||||
pass bool
|
||||
plug Plugin
|
||||
errString string
|
||||
}{
|
||||
{true, mockSubprocessCLIPlugin(t, "abcdefghijklmnopqrstuvwxyz0123456789_-ABC"), ""},
|
||||
{true, mockSubprocessCLIPlugin(t, "foo-bar-FOO-BAR_1234"), ""},
|
||||
{false, mockSubprocessCLIPlugin(t, "foo -bar"), "invalid name"},
|
||||
{false, mockSubprocessCLIPlugin(t, "$foo -bar"), "invalid name"}, // Test leading chars
|
||||
{false, mockSubprocessCLIPlugin(t, "foo -bar "), "invalid name"}, // Test trailing chars
|
||||
{false, mockSubprocessCLIPlugin(t, "foo\nbar"), "invalid name"}, // Test newline
|
||||
{true, mockNoCommand, ""}, // Test no command metadata works
|
||||
{true, mockLegacyCommand, ""}, // Test legacy command metadata works
|
||||
{false, mockWithCommand, "runtime config validation failed: both platformCommand and command are set"}, // Test platformCommand and command both set fails
|
||||
{false, mockWithHooks, "runtime config validation failed: both platformHooks and hooks are set"}, // Test platformHooks and hooks both set fails
|
||||
} {
|
||||
err := item.plug.Metadata().Validate()
|
||||
if item.pass && err != nil {
|
||||
t.Errorf("failed to validate case %d: %s", i, err)
|
||||
} else if !item.pass && err == nil {
|
||||
t.Errorf("expected case %d to fail", i)
|
||||
}
|
||||
if !item.pass && err.Error() != item.errString {
|
||||
t.Errorf("index [%d]: expected the following error: %s, but got: %s", i, item.errString, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataValidateMultipleErrors(t *testing.T) {
|
||||
// Create metadata with multiple validation issues
|
||||
metadata := Metadata{
|
||||
Name: "invalid name with spaces", // Invalid name
|
||||
APIVersion: "", // Empty API version
|
||||
Type: "", // Empty type
|
||||
Runtime: "", // Empty runtime
|
||||
Config: nil, // Missing config
|
||||
RuntimeConfig: nil, // Missing runtime config
|
||||
}
|
||||
|
||||
err := metadata.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation to fail with multiple errors")
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// Check that all expected errors are present in the joined error
|
||||
expectedErrors := []string{
|
||||
"invalid name",
|
||||
"empty APIVersion",
|
||||
"empty type field",
|
||||
"empty runtime field",
|
||||
"missing config field",
|
||||
"missing runtimeConfig field",
|
||||
}
|
||||
|
||||
for _, expectedErr := range expectedErrors {
|
||||
if !strings.Contains(errStr, expectedErr) {
|
||||
t.Errorf("expected error to contain %q, but got: %v", expectedErr, errStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the error contains the correct number of error messages
|
||||
errorCount := 0
|
||||
for _, expectedErr := range expectedErrors {
|
||||
if strings.Contains(errStr, expectedErr) {
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
|
||||
if errorCount < len(expectedErrors) {
|
||||
t.Errorf("expected %d errors, but only found %d in: %v", len(expectedErrors), errorCount, errStr)
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
// Runtime represents a plugin runtime (subprocess, extism, etc) ie. how a plugin should be executed
|
||||
// Runtime is responsible for instantiating plugins that implement the runtime
|
||||
// TODO: could call this something more like "PluginRuntimeCreator"?
|
||||
type Runtime interface {
|
||||
// CreatePlugin creates a plugin instance from the given metadata
|
||||
CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error)
|
||||
|
||||
// TODO: move config unmarshalling to the runtime?
|
||||
// UnmarshalConfig(runtimeConfigRaw map[string]any) (RuntimeConfig, error)
|
||||
}
|
||||
|
||||
// RuntimeConfig represents the assertable type for a plugin's runtime configuration.
|
||||
// It is expected to type assert (cast) the a RuntimeConfig to its expected type
|
||||
type RuntimeConfig interface {
|
||||
Validate() error
|
||||
}
|
@ -0,0 +1,229 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
)
|
||||
|
||||
// SubprocessProtocolCommand maps a given protocol to the getter command used to retrieve artifacts for that protcol
|
||||
type SubprocessProtocolCommand struct {
|
||||
// Protocols are the list of schemes from the charts URL.
|
||||
Protocols []string `yaml:"protocols"`
|
||||
// Command is the executable path with which the plugin performs
|
||||
// the actual download for the corresponding Protocols
|
||||
Command string `yaml:"command"`
|
||||
}
|
||||
|
||||
// RuntimeConfigSubprocess represents configuration for subprocess runtime
|
||||
type RuntimeConfigSubprocess struct {
|
||||
// PlatformCommand is a list containing a plugin command, with a platform selector and support for args.
|
||||
PlatformCommands []PlatformCommand `yaml:"platformCommand"`
|
||||
// Command is the plugin command, as a single string.
|
||||
// DEPRECATED: Use PlatformCommand instead. Remove in Helm 4.
|
||||
Command string `yaml:"command"`
|
||||
// PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
|
||||
PlatformHooks PlatformHooks `yaml:"platformHooks"`
|
||||
// Hooks are commands that will run on plugin events, as a single string.
|
||||
// DEPRECATED: Use PlatformHooks instead. Remove in Helm 4.
|
||||
Hooks Hooks `yaml:"hooks"`
|
||||
// ProtocolCommands field is used if the plugin supply downloader mechanism
|
||||
// for special protocols.
|
||||
// (This is a compatibility hangover from the old plugin downloader mechanism, which was extended to support multiple
|
||||
// protocols in a given plugin)
|
||||
ProtocolCommands []SubprocessProtocolCommand `yaml:"protocolCommands,omitempty"`
|
||||
}
|
||||
|
||||
var _ RuntimeConfig = (*RuntimeConfigSubprocess)(nil)
|
||||
|
||||
func (r *RuntimeConfigSubprocess) GetType() string { return "subprocess" }
|
||||
|
||||
func (r *RuntimeConfigSubprocess) Validate() error {
|
||||
if len(r.PlatformCommands) > 0 && len(r.Command) > 0 {
|
||||
return fmt.Errorf("both platformCommand and command are set")
|
||||
}
|
||||
if len(r.PlatformHooks) > 0 && len(r.Hooks) > 0 {
|
||||
return fmt.Errorf("both platformHooks and hooks are set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type RuntimeSubprocess struct{}
|
||||
|
||||
var _ Runtime = (*RuntimeSubprocess)(nil)
|
||||
|
||||
// CreateRuntime implementation for RuntimeConfig
|
||||
func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) {
|
||||
return &SubprocessPluginRuntime{
|
||||
metadata: *metadata,
|
||||
pluginDir: pluginDir,
|
||||
RuntimeConfig: *(metadata.RuntimeConfig.(*RuntimeConfigSubprocess)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RuntimeSubprocess implements the Runtime interface for subprocess execution
|
||||
type SubprocessPluginRuntime struct {
|
||||
metadata Metadata
|
||||
pluginDir string
|
||||
RuntimeConfig RuntimeConfigSubprocess
|
||||
}
|
||||
|
||||
var _ Plugin = (*SubprocessPluginRuntime)(nil)
|
||||
|
||||
func (r *SubprocessPluginRuntime) Dir() string {
|
||||
return r.pluginDir
|
||||
}
|
||||
|
||||
func (r *SubprocessPluginRuntime) Metadata() Metadata {
|
||||
return r.metadata
|
||||
}
|
||||
|
||||
func (r *SubprocessPluginRuntime) Invoke(_ context.Context, input *Input) (*Output, error) {
|
||||
switch input.Message.(type) {
|
||||
case schema.InputMessageCLIV1:
|
||||
return r.runCLI(input)
|
||||
case schema.InputMessageGetterV1:
|
||||
return r.runGetter(input)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported subprocess plugin type %q", r.metadata.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// InvokeWithEnv executes a plugin command with custom environment and I/O streams
|
||||
// This method allows execution with different command/args than the plugin's default
|
||||
func (r *SubprocessPluginRuntime) InvokeWithEnv(main string, argv []string, env []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||
mainCmdExp := os.ExpandEnv(main)
|
||||
prog := exec.Command(mainCmdExp, argv...)
|
||||
prog.Env = env
|
||||
prog.Stdin = stdin
|
||||
prog.Stdout = stdout
|
||||
prog.Stderr = stderr
|
||||
|
||||
if err := prog.Run(); err != nil {
|
||||
if eerr, ok := err.(*exec.ExitError); ok {
|
||||
os.Stderr.Write(eerr.Stderr)
|
||||
status := eerr.Sys().(syscall.WaitStatus)
|
||||
return &InvokeExecError{
|
||||
Err: fmt.Errorf("plugin %q exited with error", r.metadata.Name),
|
||||
Code: status.ExitStatus(),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SubprocessPluginRuntime) InvokeHook(event string) error {
|
||||
// Get hook commands for the event
|
||||
var cmds []PlatformCommand
|
||||
expandArgs := true
|
||||
|
||||
cmds = r.RuntimeConfig.PlatformHooks[event]
|
||||
if len(cmds) == 0 && len(r.RuntimeConfig.Hooks) > 0 {
|
||||
cmd := r.RuntimeConfig.Hooks[event]
|
||||
if len(cmd) > 0 {
|
||||
cmds = []PlatformCommand{{Command: "sh", Args: []string{"-c", cmd}}}
|
||||
expandArgs = false
|
||||
}
|
||||
}
|
||||
|
||||
// If no hook commands are defined, just return successfully
|
||||
if len(cmds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
main, argv, err := PrepareCommands(cmds, expandArgs, []string{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prog := exec.Command(main, argv...)
|
||||
prog.Stdout, prog.Stderr = os.Stdout, os.Stderr
|
||||
|
||||
if err := prog.Run(); err != nil {
|
||||
if eerr, ok := err.(*exec.ExitError); ok {
|
||||
os.Stderr.Write(eerr.Stderr)
|
||||
return fmt.Errorf("plugin %s hook for %q exited with error", event, r.metadata.Name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO decide the best way to handle this code
|
||||
// right now we implement status and error return in 3 slightly different ways in this file
|
||||
// then replace the other three with a call to this func
|
||||
func executeCmd(prog *exec.Cmd, pluginName string) error {
|
||||
if err := prog.Run(); err != nil {
|
||||
if eerr, ok := err.(*exec.ExitError); ok {
|
||||
os.Stderr.Write(eerr.Stderr)
|
||||
return &InvokeExecError{
|
||||
Err: fmt.Errorf("plugin %q exited with error", pluginName),
|
||||
Code: eerr.ExitCode(),
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) {
|
||||
if _, ok := input.Message.(schema.InputMessageCLIV1); !ok {
|
||||
return nil, fmt.Errorf("plugin %q input message does not implement InputMessageCLIV1", r.metadata.Name)
|
||||
}
|
||||
|
||||
extraArgs := input.Message.(schema.InputMessageCLIV1).ExtraArgs
|
||||
|
||||
cmds := r.RuntimeConfig.PlatformCommands
|
||||
if len(cmds) == 0 && len(r.RuntimeConfig.Command) > 0 {
|
||||
cmds = []PlatformCommand{{Command: r.RuntimeConfig.Command}}
|
||||
}
|
||||
|
||||
command, args, err := PrepareCommands(cmds, true, extraArgs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare plugin command: %w", err)
|
||||
}
|
||||
|
||||
err2 := r.InvokeWithEnv(command, args, input.Env, input.Stdin, input.Stdout, input.Stderr)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
|
||||
return &Output{
|
||||
Message: &schema.OutputMessageCLIV1{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because
|
||||
// the plugin subsystem itself needs access to the environment variables
|
||||
// created here.
|
||||
func SetupPluginEnv(settings *cli.EnvSettings, name, base string) { // TODO: remove
|
||||
env := settings.EnvVars()
|
||||
env["HELM_PLUGIN_NAME"] = name
|
||||
env["HELM_PLUGIN_DIR"] = base
|
||||
for key, val := range env {
|
||||
os.Setenv(key, val)
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
)
|
||||
|
||||
func getProtocolCommand(commands []SubprocessProtocolCommand, protocol string) *SubprocessProtocolCommand {
|
||||
for _, c := range commands {
|
||||
if slices.Contains(c.Protocols, protocol) {
|
||||
return &c
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO can we replace a lot of this func with RuntimeSubprocess.invokeWithEnv?
|
||||
func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) {
|
||||
msg, ok := (input.Message).(schema.InputMessageGetterV1)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected input type schema.InputMessageGetterV1, got %T", input)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("helm-plugin-%s-", r.metadata.Name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
d := getProtocolCommand(r.RuntimeConfig.ProtocolCommands, msg.Protocol)
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("no downloader found for protocol %q", msg.Protocol)
|
||||
}
|
||||
|
||||
commands := strings.Split(d.Command, " ")
|
||||
args := append(
|
||||
commands[1:],
|
||||
msg.Options.CertFile,
|
||||
msg.Options.KeyFile,
|
||||
msg.Options.CAFile,
|
||||
msg.Href)
|
||||
|
||||
// TODO should we append to input.Env too?
|
||||
env := append(
|
||||
os.Environ(),
|
||||
fmt.Sprintf("HELM_PLUGIN_USERNAME=%s", msg.Options.Username),
|
||||
fmt.Sprintf("HELM_PLUGIN_PASSWORD=%s", msg.Options.Password),
|
||||
fmt.Sprintf("HELM_PLUGIN_PASS_CREDENTIALS_ALL=%t", msg.Options.PassCredentialsAll))
|
||||
|
||||
// TODO should we pass along input.Stdout?
|
||||
buf := bytes.Buffer{} // subprocess getters are expected to write content to stdout
|
||||
|
||||
pluginCommand := filepath.Join(r.pluginDir, commands[0])
|
||||
prog := exec.Command(
|
||||
pluginCommand,
|
||||
args...)
|
||||
prog.Env = env
|
||||
prog.Stdout = &buf
|
||||
prog.Stderr = os.Stderr
|
||||
if err := executeCmd(prog, r.metadata.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Output{
|
||||
Message: &schema.OutputMessageGetterV1{
|
||||
Data: buf.Bytes(),
|
||||
},
|
||||
}, nil
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
)
|
||||
|
||||
func TestSetupEnv(t *testing.T) {
|
||||
name := "pequod"
|
||||
base := filepath.Join("testdata/helmhome/helm/plugins", name)
|
||||
|
||||
s := cli.New()
|
||||
s.PluginsDirectory = "testdata/helmhome/helm/plugins"
|
||||
|
||||
SetupPluginEnv(s, name, base)
|
||||
for _, tt := range []struct {
|
||||
name, expect string
|
||||
}{
|
||||
{"HELM_PLUGIN_NAME", name},
|
||||
{"HELM_PLUGIN_DIR", base},
|
||||
} {
|
||||
if got := os.Getenv(tt.name); got != tt.expect {
|
||||
t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupEnvWithSpace(t *testing.T) {
|
||||
name := "sureshdsk"
|
||||
base := filepath.Join("testdata/helm home/helm/plugins", name)
|
||||
|
||||
s := cli.New()
|
||||
s.PluginsDirectory = "testdata/helm home/helm/plugins"
|
||||
|
||||
SetupPluginEnv(s, name, base)
|
||||
for _, tt := range []struct {
|
||||
name, expect string
|
||||
}{
|
||||
{"HELM_PLUGIN_NAME", name},
|
||||
{"HELM_PLUGIN_DIR", base},
|
||||
} {
|
||||
if got := os.Getenv(tt.name); got != tt.expect {
|
||||
t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
)
|
||||
|
||||
type InputMessageCLIV1 struct {
|
||||
ExtraArgs []string `json:"extraArgs"`
|
||||
Settings *cli.EnvSettings `json:"settings"`
|
||||
}
|
||||
|
||||
type OutputMessageCLIV1 struct {
|
||||
Data *bytes.Buffer `json:"data"`
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO: can we generate these plugin input/outputs?
|
||||
|
||||
type GetterOptionsV1 struct {
|
||||
URL string
|
||||
CertFile string
|
||||
KeyFile string
|
||||
CAFile string
|
||||
UNTar bool
|
||||
InsecureSkipVerifyTLS bool
|
||||
PlainHTTP bool
|
||||
AcceptHeader string
|
||||
Username string
|
||||
Password string
|
||||
PassCredentialsAll bool
|
||||
UserAgent string
|
||||
Version string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type InputMessageGetterV1 struct {
|
||||
Href string `json:"href"`
|
||||
Protocol string `json:"protocol"`
|
||||
Options GetterOptionsV1 `json:"options"`
|
||||
}
|
||||
|
||||
type OutputMessageGetterV1 struct {
|
||||
Data []byte `json:"data"`
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlatformCommand represents a command for a particular operating system and architecture
|
||||
type PlatformCommand struct {
|
||||
OperatingSystem string `yaml:"os"`
|
||||
Architecture string `yaml:"arch"`
|
||||
Command string `yaml:"command"`
|
||||
Args []string `yaml:"args"`
|
||||
}
|
||||
|
||||
// Returns command and args strings based on the following rules in priority order:
|
||||
// - From the PlatformCommand where OS and Arch match the current platform
|
||||
// - From the PlatformCommand where OS matches the current platform and Arch is empty/unspecified
|
||||
// - From the PlatformCommand where OS is empty/unspecified and Arch matches the current platform
|
||||
// - From the PlatformCommand where OS and Arch are both empty/unspecified
|
||||
// - Return nil, nil
|
||||
func getPlatformCommand(cmds []PlatformCommand) ([]string, []string) {
|
||||
var command, args []string
|
||||
found := false
|
||||
foundOs := false
|
||||
|
||||
eq := strings.EqualFold
|
||||
for _, c := range cmds {
|
||||
if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) {
|
||||
// Return early for an exact match
|
||||
return strings.Split(c.Command, " "), c.Args
|
||||
}
|
||||
|
||||
if (len(c.OperatingSystem) > 0 && !eq(c.OperatingSystem, runtime.GOOS)) || len(c.Architecture) > 0 {
|
||||
// Skip if OS is not empty and doesn't match or if arch is set as a set arch requires an OS match
|
||||
continue
|
||||
}
|
||||
|
||||
if !foundOs && len(c.OperatingSystem) > 0 && eq(c.OperatingSystem, runtime.GOOS) {
|
||||
// First OS match with empty arch, can only be overridden by a direct match
|
||||
command = strings.Split(c.Command, " ")
|
||||
args = c.Args
|
||||
found = true
|
||||
foundOs = true
|
||||
} else if !found {
|
||||
// First empty match, can be overridden by a direct match or an OS match
|
||||
command = strings.Split(c.Command, " ")
|
||||
args = c.Args
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
return command, args
|
||||
}
|
||||
|
||||
// PrepareCommands takes a []Plugin.PlatformCommand
|
||||
// and prepares the command and arguments for execution.
|
||||
//
|
||||
// It merges extraArgs into any arguments supplied in the plugin. It
|
||||
// returns the main command and an args array.
|
||||
//
|
||||
// The result is suitable to pass to exec.Command.
|
||||
func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string) (string, []string, error) {
|
||||
cmdParts, args := getPlatformCommand(cmds)
|
||||
if len(cmdParts) == 0 || cmdParts[0] == "" {
|
||||
return "", nil, fmt.Errorf("no plugin command is applicable")
|
||||
}
|
||||
|
||||
main := os.ExpandEnv(cmdParts[0])
|
||||
baseArgs := []string{}
|
||||
if len(cmdParts) > 1 {
|
||||
for _, cmdPart := range cmdParts[1:] {
|
||||
if expandArgs {
|
||||
baseArgs = append(baseArgs, os.ExpandEnv(cmdPart))
|
||||
} else {
|
||||
baseArgs = append(baseArgs, cmdPart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, arg := range args {
|
||||
if expandArgs {
|
||||
baseArgs = append(baseArgs, os.ExpandEnv(arg))
|
||||
} else {
|
||||
baseArgs = append(baseArgs, arg)
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraArgs) > 0 {
|
||||
baseArgs = append(baseArgs, extraArgs...)
|
||||
}
|
||||
|
||||
return main, baseArgs, nil
|
||||
}
|
@ -0,0 +1,259 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPrepareCommand(t *testing.T) {
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
|
||||
platformCommands := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
|
||||
}
|
||||
|
||||
cmd, args, err := PrepareCommands(platformCommands, true, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, cmdArgs) {
|
||||
t.Fatalf("Expected %v, got %v", cmdArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandExtraArgs(t *testing.T) {
|
||||
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
platformCommands := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
}
|
||||
|
||||
extraArgs := []string{"--debug", "--foo", "bar"}
|
||||
|
||||
type testCaseExpected struct {
|
||||
cmdMain string
|
||||
args []string
|
||||
}
|
||||
|
||||
testCases := map[string]struct {
|
||||
ignoreFlags bool
|
||||
expected testCaseExpected
|
||||
}{
|
||||
"ignoreFlags false": {
|
||||
ignoreFlags: false,
|
||||
expected: testCaseExpected{
|
||||
cmdMain: cmdMain,
|
||||
args: []string{"-c", "echo \"test\"", "--debug", "--foo", "bar"},
|
||||
},
|
||||
},
|
||||
"ignoreFlags true": {
|
||||
ignoreFlags: true,
|
||||
expected: testCaseExpected{
|
||||
cmdMain: cmdMain,
|
||||
args: []string{"-c", "echo \"test\""},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
//expectedArgs := append(cmdArgs, extraArgs...)
|
||||
|
||||
// extra args are expected when ignoreFlags is unset or false
|
||||
testExtraArgs := extraArgs
|
||||
if tc.ignoreFlags {
|
||||
testExtraArgs = []string{}
|
||||
}
|
||||
cmd, args, err := PrepareCommands(platformCommands, true, testExtraArgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, tc.expected.cmdMain, cmd, "Expected command to match")
|
||||
assert.Equal(t, tc.expected.args, args, "Expected args to match")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommands(t *testing.T) {
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
}
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, true, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, cmdArgs) {
|
||||
t.Fatalf("Expected %v, got %v", cmdArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsExtraArgs(t *testing.T) {
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
extraArgs := []string{"--debug", "--foo", "bar"}
|
||||
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
}
|
||||
|
||||
expectedArgs := append(cmdArgs, extraArgs...)
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, true, extraArgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, expectedArgs) {
|
||||
t.Fatalf("Expected %v, got %v", expectedArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsNoArch(t *testing.T) {
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
}
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, true, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, cmdArgs) {
|
||||
t.Fatalf("Expected %v, got %v", cmdArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsNoOsNoArch(t *testing.T) {
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: "", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
}
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, true, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, cmdArgs) {
|
||||
t.Fatalf("Expected %v, got %v", cmdArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsNoMatch(t *testing.T) {
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
{OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
}
|
||||
|
||||
if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
|
||||
t.Fatalf("Expected error to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsNoCommands(t *testing.T) {
|
||||
cmds := []PlatformCommand{}
|
||||
|
||||
if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
|
||||
t.Fatalf("Expected error to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsExpand(t *testing.T) {
|
||||
t.Setenv("TEST", "test")
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"${TEST}\""}
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
|
||||
}
|
||||
|
||||
expectedArgs := []string{"-c", "echo \"test\""}
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, true, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, expectedArgs) {
|
||||
t.Fatalf("Expected %v, got %v", expectedArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsNoExpand(t *testing.T) {
|
||||
t.Setenv("TEST", "test")
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"${TEST}\""}
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
|
||||
}
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, false, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, cmdArgs) {
|
||||
t.Fatalf("Expected %v, got %v", cmdArgs, args)
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
name: "echo"
|
||||
---
|
||||
name: "echo-legacy"
|
||||
version: "1.2.3"
|
||||
usage: "echo something"
|
||||
description: |-
|
@ -1,25 +1,22 @@
|
||||
name: "hello"
|
||||
---
|
||||
name: "hello-legacy"
|
||||
version: "0.1.0"
|
||||
usage: "usage"
|
||||
usage: "echo hello message"
|
||||
description: |-
|
||||
description
|
||||
platformCommand:
|
||||
- os: linux
|
||||
arch:
|
||||
command: "sh"
|
||||
args: ["-c", "${HELM_PLUGIN_DIR}/hello.sh"]
|
||||
- os: windows
|
||||
arch:
|
||||
command: "pwsh"
|
||||
args: ["-c", "${HELM_PLUGIN_DIR}/hello.ps1"]
|
||||
ignoreFlags: true
|
||||
platformHooks:
|
||||
install:
|
||||
- os: linux
|
||||
arch: ""
|
||||
command: "sh"
|
||||
args: ["-c", 'echo "installing..."']
|
||||
- os: windows
|
||||
arch: ""
|
||||
command: "pwsh"
|
||||
args: ["-c", 'echo "installing..."']
|
@ -1,4 +0,0 @@
|
||||
name: testplugin
|
||||
usage: "echo test"
|
||||
description: "This echos test"
|
||||
command: "echo test"
|
@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo ENVIRONMENT
|
||||
env
|
||||
|
||||
echo ""
|
||||
echo ARGUMENTS
|
||||
echo $@
|
@ -1,15 +1,6 @@
|
||||
name: "testgetter"
|
||||
version: "0.1.0"
|
||||
usage: "Fetch a package from a test:// source"
|
||||
description: |-
|
||||
Print the environment that the plugin was given, then exit.
|
||||
|
||||
This registers the test:// protocol.
|
||||
|
||||
command: "$HELM_PLUGIN_DIR/get.sh"
|
||||
ignoreFlags: true
|
||||
downloaders:
|
||||
#- command: "$HELM_PLUGIN_DIR/get.sh"
|
||||
- command: "echo"
|
||||
protocols:
|
||||
- "test"
|
||||
- command: "echo"
|
||||
protocols:
|
||||
- "test"
|
||||
|
@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo ENVIRONMENT
|
||||
env
|
||||
|
||||
echo ""
|
||||
echo ARGUMENTS
|
||||
echo $@
|
@ -1,10 +1,6 @@
|
||||
name: "testgetter2"
|
||||
version: "0.1.0"
|
||||
usage: "Fetch a different package from a test2:// source"
|
||||
description: "Handle test2 scheme"
|
||||
command: "$HELM_PLUGIN_DIR/get.sh"
|
||||
ignoreFlags: true
|
||||
downloaders:
|
||||
- command: "echo"
|
||||
protocols:
|
||||
- "test2"
|
||||
- command: "echo"
|
||||
protocols:
|
||||
- "test2"
|
||||
|
Loading…
Reference in new issue