...

Source file src/helm.sh/helm/v3/pkg/plugin/plugin.go

Documentation: helm.sh/helm/v3/pkg/plugin

     1  /*
     2  Copyright The Helm Authors.
     3  Licensed under the Apache License, Version 2.0 (the "License");
     4  you may not use this file except in compliance with the License.
     5  You may obtain a copy of the License at
     6  
     7  http://www.apache.org/licenses/LICENSE-2.0
     8  
     9  Unless required by applicable law or agreed to in writing, software
    10  distributed under the License is distributed on an "AS IS" BASIS,
    11  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  See the License for the specific language governing permissions and
    13  limitations under the License.
    14  */
    15  
    16  package plugin // import "helm.sh/helm/v3/pkg/plugin"
    17  
    18  import (
    19  	"fmt"
    20  	"os"
    21  	"path/filepath"
    22  	"regexp"
    23  	"runtime"
    24  	"strings"
    25  	"unicode"
    26  
    27  	"github.com/pkg/errors"
    28  	"sigs.k8s.io/yaml"
    29  
    30  	"helm.sh/helm/v3/pkg/cli"
    31  )
    32  
    33  const PluginFileName = "plugin.yaml"
    34  
    35  // Downloaders represents the plugins capability if it can retrieve
    36  // charts from special sources
    37  type Downloaders struct {
    38  	// Protocols are the list of schemes from the charts URL.
    39  	Protocols []string `json:"protocols"`
    40  	// Command is the executable path with which the plugin performs
    41  	// the actual download for the corresponding Protocols
    42  	Command string `json:"command"`
    43  }
    44  
    45  // PlatformCommand represents a command for a particular operating system and architecture
    46  type PlatformCommand struct {
    47  	OperatingSystem string `json:"os"`
    48  	Architecture    string `json:"arch"`
    49  	Command         string `json:"command"`
    50  }
    51  
    52  // Metadata describes a plugin.
    53  //
    54  // This is the plugin equivalent of a chart.Metadata.
    55  type Metadata struct {
    56  	// Name is the name of the plugin
    57  	Name string `json:"name"`
    58  
    59  	// Version is a SemVer 2 version of the plugin.
    60  	Version string `json:"version"`
    61  
    62  	// Usage is the single-line usage text shown in help
    63  	Usage string `json:"usage"`
    64  
    65  	// Description is a long description shown in places like `helm help`
    66  	Description string `json:"description"`
    67  
    68  	// Command is the command, as a single string.
    69  	//
    70  	// The command will be passed through environment expansion, so env vars can
    71  	// be present in this command. Unless IgnoreFlags is set, this will
    72  	// also merge the flags passed from Helm.
    73  	//
    74  	// Note that command is not executed in a shell. To do so, we suggest
    75  	// pointing the command to a shell script.
    76  	//
    77  	// The following rules will apply to processing commands:
    78  	// - If platformCommand is present, it will be searched first
    79  	// - If both OS and Arch match the current platform, search will stop and the command will be executed
    80  	// - If OS matches and there is no more specific match, the command will be executed
    81  	// - If no OS/Arch match is found, the default command will be executed
    82  	// - If no command is present and no matches are found in platformCommand, Helm will exit with an error
    83  	PlatformCommand []PlatformCommand `json:"platformCommand"`
    84  	Command         string            `json:"command"`
    85  
    86  	// IgnoreFlags ignores any flags passed in from Helm
    87  	//
    88  	// For example, if the plugin is invoked as `helm --debug myplugin`, if this
    89  	// is false, `--debug` will be appended to `--command`. If this is true,
    90  	// the `--debug` flag will be discarded.
    91  	IgnoreFlags bool `json:"ignoreFlags"`
    92  
    93  	// Hooks are commands that will run on events.
    94  	Hooks Hooks
    95  
    96  	// Downloaders field is used if the plugin supply downloader mechanism
    97  	// for special protocols.
    98  	Downloaders []Downloaders `json:"downloaders"`
    99  
   100  	// UseTunnelDeprecated indicates that this command needs a tunnel.
   101  	// Setting this will cause a number of side effects, such as the
   102  	// automatic setting of HELM_HOST.
   103  	// DEPRECATED and unused, but retained for backwards compatibility with Helm 2 plugins. Remove in Helm 4
   104  	UseTunnelDeprecated bool `json:"useTunnel,omitempty"`
   105  }
   106  
   107  // Plugin represents a plugin.
   108  type Plugin struct {
   109  	// Metadata is a parsed representation of a plugin.yaml
   110  	Metadata *Metadata
   111  	// Dir is the string path to the directory that holds the plugin.
   112  	Dir string
   113  }
   114  
   115  // The following rules will apply to processing the Plugin.PlatformCommand.Command:
   116  // - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
   117  // - If OS matches and there is no more specific match, the command will be prepared for execution
   118  // - If no OS/Arch match is found, return nil
   119  func getPlatformCommand(cmds []PlatformCommand) []string {
   120  	var command []string
   121  	eq := strings.EqualFold
   122  	for _, c := range cmds {
   123  		if eq(c.OperatingSystem, runtime.GOOS) {
   124  			command = strings.Split(c.Command, " ")
   125  		}
   126  		if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) {
   127  			return strings.Split(c.Command, " ")
   128  		}
   129  	}
   130  	return command
   131  }
   132  
   133  // PrepareCommand takes a Plugin.PlatformCommand.Command, a Plugin.Command and will applying the following processing:
   134  // - If platformCommand is present, it will be searched first
   135  // - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
   136  // - If OS matches and there is no more specific match, the command will be prepared for execution
   137  // - If no OS/Arch match is found, the default command will be prepared for execution
   138  // - If no command is present and no matches are found in platformCommand, will exit with an error
   139  //
   140  // It merges extraArgs into any arguments supplied in the plugin. It
   141  // returns the name of the command and an args array.
   142  //
   143  // The result is suitable to pass to exec.Command.
   144  func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) {
   145  	var parts []string
   146  	platCmdLen := len(p.Metadata.PlatformCommand)
   147  	if platCmdLen > 0 {
   148  		parts = getPlatformCommand(p.Metadata.PlatformCommand)
   149  	}
   150  	if platCmdLen == 0 || parts == nil {
   151  		parts = strings.Split(p.Metadata.Command, " ")
   152  	}
   153  	if len(parts) == 0 || parts[0] == "" {
   154  		return "", nil, fmt.Errorf("no plugin command is applicable")
   155  	}
   156  
   157  	main := os.ExpandEnv(parts[0])
   158  	baseArgs := []string{}
   159  	if len(parts) > 1 {
   160  		for _, cmdpart := range parts[1:] {
   161  			cmdexp := os.ExpandEnv(cmdpart)
   162  			baseArgs = append(baseArgs, cmdexp)
   163  		}
   164  	}
   165  	if !p.Metadata.IgnoreFlags {
   166  		baseArgs = append(baseArgs, extraArgs...)
   167  	}
   168  	return main, baseArgs, nil
   169  }
   170  
   171  // validPluginName is a regular expression that validates plugin names.
   172  //
   173  // Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, ​_​ and ​-.
   174  var validPluginName = regexp.MustCompile("^[A-Za-z0-9_-]+$")
   175  
   176  // validatePluginData validates a plugin's YAML data.
   177  func validatePluginData(plug *Plugin, filepath string) error {
   178  	// When metadata section missing, initialize with no data
   179  	if plug.Metadata == nil {
   180  		plug.Metadata = &Metadata{}
   181  	}
   182  	if !validPluginName.MatchString(plug.Metadata.Name) {
   183  		return fmt.Errorf("invalid plugin name at %q", filepath)
   184  	}
   185  	plug.Metadata.Usage = sanitizeString(plug.Metadata.Usage)
   186  
   187  	// We could also validate SemVer, executable, and other fields should we so choose.
   188  	return nil
   189  }
   190  
   191  // sanitizeString normalize spaces and removes non-printable characters.
   192  func sanitizeString(str string) string {
   193  	return strings.Map(func(r rune) rune {
   194  		if unicode.IsSpace(r) {
   195  			return ' '
   196  		}
   197  		if unicode.IsPrint(r) {
   198  			return r
   199  		}
   200  		return -1
   201  	}, str)
   202  }
   203  
   204  func detectDuplicates(plugs []*Plugin) error {
   205  	names := map[string]string{}
   206  
   207  	for _, plug := range plugs {
   208  		if oldpath, ok := names[plug.Metadata.Name]; ok {
   209  			return fmt.Errorf(
   210  				"two plugins claim the name %q at %q and %q",
   211  				plug.Metadata.Name,
   212  				oldpath,
   213  				plug.Dir,
   214  			)
   215  		}
   216  		names[plug.Metadata.Name] = plug.Dir
   217  	}
   218  
   219  	return nil
   220  }
   221  
   222  // LoadDir loads a plugin from the given directory.
   223  func LoadDir(dirname string) (*Plugin, error) {
   224  	pluginfile := filepath.Join(dirname, PluginFileName)
   225  	data, err := os.ReadFile(pluginfile)
   226  	if err != nil {
   227  		return nil, errors.Wrapf(err, "failed to read plugin at %q", pluginfile)
   228  	}
   229  
   230  	plug := &Plugin{Dir: dirname}
   231  	if err := yaml.UnmarshalStrict(data, &plug.Metadata); err != nil {
   232  		return nil, errors.Wrapf(err, "failed to load plugin at %q", pluginfile)
   233  	}
   234  	return plug, validatePluginData(plug, pluginfile)
   235  }
   236  
   237  // LoadAll loads all plugins found beneath the base directory.
   238  //
   239  // This scans only one directory level.
   240  func LoadAll(basedir string) ([]*Plugin, error) {
   241  	plugins := []*Plugin{}
   242  	// We want basedir/*/plugin.yaml
   243  	scanpath := filepath.Join(basedir, "*", PluginFileName)
   244  	matches, err := filepath.Glob(scanpath)
   245  	if err != nil {
   246  		return plugins, errors.Wrapf(err, "failed to find plugins in %q", scanpath)
   247  	}
   248  
   249  	if matches == nil {
   250  		return plugins, nil
   251  	}
   252  
   253  	for _, yaml := range matches {
   254  		dir := filepath.Dir(yaml)
   255  		p, err := LoadDir(dir)
   256  		if err != nil {
   257  			return plugins, err
   258  		}
   259  		plugins = append(plugins, p)
   260  	}
   261  	return plugins, detectDuplicates(plugins)
   262  }
   263  
   264  // FindPlugins returns a list of YAML files that describe plugins.
   265  func FindPlugins(plugdirs string) ([]*Plugin, error) {
   266  	found := []*Plugin{}
   267  	// Let's get all UNIXy and allow path separators
   268  	for _, p := range filepath.SplitList(plugdirs) {
   269  		matches, err := LoadAll(p)
   270  		if err != nil {
   271  			return matches, err
   272  		}
   273  		found = append(found, matches...)
   274  	}
   275  	return found, nil
   276  }
   277  
   278  // SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because
   279  // the plugin subsystem itself needs access to the environment variables
   280  // created here.
   281  func SetupPluginEnv(settings *cli.EnvSettings, name, base string) {
   282  	env := settings.EnvVars()
   283  	env["HELM_PLUGIN_NAME"] = name
   284  	env["HELM_PLUGIN_DIR"] = base
   285  	for key, val := range env {
   286  		os.Setenv(key, val)
   287  	}
   288  }
   289  

View as plain text