diff --git a/README.md b/README.md index 337a59a9edfe1d7103be93cf1be8630774665ef4..40aea5545fe1b5fb857cb5fd20ed4ef285e3de41 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ including installing pre-releases. - [Kubernetes Distribution Notes](docs/kubernetes_distros.md) - [Frequently Asked Questions](docs/install_faq.md) - [Using Helm](docs/using_helm.md) + - [Plugins](docs/plugins.md) - [Developing Charts](docs/charts.md) - [Chart Lifecycle Hooks](docs/charts_hooks.md) - [Chart Tips and Tricks](docs/charts_tips_and_tricks.md) diff --git a/cmd/helm/helm.go b/cmd/helm/helm.go index d9b169f0008de75408305a44c6a783ace9ecc103..59f88f6fb9afe3f8118d26dc5b3bf247c74ade9f 100644 --- a/cmd/helm/helm.go +++ b/cmd/helm/helm.go @@ -31,6 +31,7 @@ import ( "k8s.io/kubernetes/pkg/client/restclient" "k8s.io/kubernetes/pkg/client/unversioned" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/kube" ) @@ -91,7 +92,7 @@ func newRootCmd(out io.Writer) *cobra.Command { p.StringVar(&helmHome, "home", home, "location of your Helm config. Overrides $HELM_HOME") p.StringVar(&tillerHost, "host", thost, "address of tiller. Overrides $HELM_HOST") p.StringVar(&kubeContext, "kube-context", "", "name of the kubeconfig context to use") - p.BoolVarP(&flagDebug, "debug", "", false, "enable verbose output") + p.BoolVar(&flagDebug, "debug", false, "enable verbose output") // Tell gRPC not to log to console. grpclog.SetLogger(log.New(ioutil.Discard, "", log.LstdFlags)) @@ -124,6 +125,10 @@ func newRootCmd(out io.Writer) *cobra.Command { // Deprecated rup, ) + + // Find and add plugins + loadPlugins(cmd, helmpath.Home(homePath()), out) + return cmd } @@ -141,7 +146,7 @@ func setupConnection(c *cobra.Command, args []string) error { return err } - tillerHost = fmt.Sprintf(":%d", tunnel.Local) + tillerHost = fmt.Sprintf("localhost:%d", tunnel.Local) if flagDebug { fmt.Printf("Created tunnel using local port: '%d'\n", tunnel.Local) } @@ -151,6 +156,7 @@ func setupConnection(c *cobra.Command, args []string) error { if flagDebug { fmt.Printf("SERVER: %q\n", tillerHost) } + // Plugin support. return nil } diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go index ac03e178a666f299e5188888b868f119f4cac8f0..682709f2d4cefcd53abdfaec026dc6f0490ace22 100644 --- a/cmd/helm/helm_test.go +++ b/cmd/helm/helm_test.go @@ -241,7 +241,7 @@ func tempHelmHome(t *testing.T) (string, error) { // // t is used only for logging. func ensureTestHome(home helmpath.Home, t *testing.T) error { - configDirectories := []string{home.String(), home.Repository(), home.Cache(), home.LocalRepository(), home.Starters()} + configDirectories := []string{home.String(), home.Repository(), home.Cache(), home.LocalRepository(), home.Plugins(), home.Starters()} for _, p := range configDirectories { if fi, err := os.Stat(p); err != nil { if err := os.MkdirAll(p, 0755); err != nil { diff --git a/cmd/helm/helmpath/helmhome.go b/cmd/helm/helmpath/helmhome.go index 9289c9d451faa6b64a5b34c6dfd774a3c6b4f973..03f65c6bb02fa82866b1be66ff13f5da050a63e8 100644 --- a/cmd/helm/helmpath/helmhome.go +++ b/cmd/helm/helmpath/helmhome.go @@ -67,3 +67,8 @@ func (h Home) LocalRepository(paths ...string) string { frag := append([]string{string(h), "repository/local"}, paths...) return filepath.Join(frag...) } + +// Plugins returns the path to the plugins directory. +func (h Home) Plugins() string { + return filepath.Join(string(h), "plugins") +} diff --git a/cmd/helm/init.go b/cmd/helm/init.go index 8bce43384a2cc8fac6eb9095aa8b21be09b10317..d3e60f629a97666d2ae41a3a5e0dcf8e4d984d60 100644 --- a/cmd/helm/init.go +++ b/cmd/helm/init.go @@ -143,7 +143,7 @@ func (i *initCmd) run() error { // // If $HELM_HOME does not exist, this function will create it. func ensureHome(home helmpath.Home, out io.Writer) error { - configDirectories := []string{home.String(), home.Repository(), home.Cache(), home.LocalRepository(), home.Starters()} + configDirectories := []string{home.String(), home.Repository(), home.Cache(), home.LocalRepository(), home.Plugins(), home.Starters()} for _, p := range configDirectories { if fi, err := os.Stat(p); err != nil { fmt.Fprintf(out, "Creating %s \n", p) diff --git a/cmd/helm/plugins.go b/cmd/helm/plugins.go new file mode 100644 index 0000000000000000000000000000000000000000..a9a4383f5e4a53d00ca8f85e423a0a9125cba933 --- /dev/null +++ b/cmd/helm/plugins.go @@ -0,0 +1,180 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "k8s.io/helm/cmd/helm/helmpath" + "k8s.io/helm/pkg/plugin" +) + +const pluginEnvVar = "HELM_PLUGIN" + +// loadPlugins loads plugins into the command list. +// +// This follows a different pattern than the other commands because it has +// to inspect its environment and then add commands to the base command +// as it finds them. +func loadPlugins(baseCmd *cobra.Command, home helmpath.Home, out io.Writer) { + plugdirs := os.Getenv(pluginEnvVar) + if plugdirs == "" { + plugdirs = home.Plugins() + } + + found, err := findPlugins(plugdirs) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load plugins: %s", err) + return + } + + // 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) + } + + c := &cobra.Command{ + Use: md.Name, + Short: md.Usage, + Long: md.Description, + RunE: func(cmd *cobra.Command, args []string) error { + + k, u := manuallyProcessArgs(args) + if err := cmd.ParseFlags(k); err != nil { + return err + } + + // Call setupEnv before PrepareCommand because + // PrepareCommand uses os.ExpandEnv and expects the + // setupEnv vars. + setupEnv(md.Name, plug.Dir, plugdirs, home) + main, argv := plug.PrepareCommand(u) + + prog := exec.Command(main, argv...) + prog.Env = os.Environ() + prog.Stdout = out + prog.Stderr = os.Stderr + if err := prog.Run(); err != nil { + eerr := err.(*exec.ExitError) + os.Stderr.Write(eerr.Stderr) + return fmt.Errorf("plugin %q exited with error", md.Name) + } + return nil + }, + // This passes all the flags to the subcommand. + DisableFlagParsing: true, + } + + if md.UseTunnel { + c.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + // Parse the parent flag, but not the local flags. + k, _ := manuallyProcessArgs(args) + if err := c.Parent().ParseFlags(k); err != nil { + return err + } + return setupConnection(cmd, args) + } + } + + // TODO: Make sure a command with this name does not already exist. + baseCmd.AddCommand(c) + } +} + +// manuallyProcessArgs processes an arg array, removing special args. +// +// Returns two sets of args: known and unknown (in that order) +func manuallyProcessArgs(args []string) ([]string, []string) { + known := []string{} + unknown := []string{} + kvargs := []string{"--host", "--kube-context", "--home"} + knownArg := func(a string) bool { + for _, pre := range kvargs { + if strings.HasPrefix(a, pre+"=") { + return true + } + } + return false + } + for i := 0; i < len(args); i++ { + switch a := args[i]; a { + case "--debug": + known = append(known, a) + case "--host", "--kube-context", "--home": + known = append(known, a, args[i+1]) + i++ + default: + if knownArg(a) { + known = append(known, a) + continue + } + unknown = append(unknown, a) + } + } + return known, unknown +} + +// findPlugins returns a list of YAML files that describe plugins. +func findPlugins(plugdirs string) ([]*plugin.Plugin, error) { + found := []*plugin.Plugin{} + // Let's get all UNIXy and allow path separators + for _, p := range filepath.SplitList(plugdirs) { + matches, err := plugin.LoadAll(p) + if err != nil { + return matches, err + } + found = append(found, matches...) + } + return found, nil +} + +// setupEnv prepares os.Env for plugins. It operates on os.Env because +// the plugin subsystem itself needs access to the environment variables +// created here. +func setupEnv(shortname, base, plugdirs string, home helmpath.Home) { + // Set extra env vars: + for key, val := range map[string]string{ + "HELM_PLUGIN_NAME": shortname, + "HELM_PLUGIN_DIR": base, + "HELM_BIN": os.Args[0], + + // Set vars that may not have been set, and save client the + // trouble of re-parsing. + pluginEnvVar: plugdirs, + homeEnvVar: home.String(), + + // Set vars that convey common information. + "HELM_PATH_REPOSITORY": home.Repository(), + "HELM_PATH_REPOSITORY_FILE": home.RepositoryFile(), + "HELM_PATH_CACHE": home.Cache(), + "HELM_PATH_LOCAL_REPOSITORY": home.LocalRepository(), + //"HELM_PATH_STARTER": home.Starter(), + + "TILLER_HOST": tillerHost, + } { + os.Setenv(key, val) + } +} diff --git a/cmd/helm/plugins_test.go b/cmd/helm/plugins_test.go new file mode 100644 index 0000000000000000000000000000000000000000..77828ea3bab8db02586c541fa8b0e84445450002 --- /dev/null +++ b/cmd/helm/plugins_test.go @@ -0,0 +1,125 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 main + +import ( + "bytes" + "os" + "strings" + "testing" + + "k8s.io/helm/cmd/helm/helmpath" + + "github.com/spf13/cobra" +) + +func TestManuallyProcessArgs(t *testing.T) { + input := []string{ + "--debug", + "--foo", + "bar", + "--host", + "example.com", + "--kube-context", + "test1", + "--home=/tmp", + "command", + } + + expectKnown := []string{ + "--debug", "--host", "example.com", "--kube-context", "test1", "--home=/tmp", + } + + expectUnknown := []string{ + "--foo", "bar", "command", + } + + known, unknown := manuallyProcessArgs(input) + + for i, k := range known { + if k != expectKnown[i] { + t.Errorf("expected known flag %d to be %q, got %q", i, expectKnown[i], k) + } + } + for i, k := range unknown { + if k != expectUnknown[i] { + t.Errorf("expected unknown flag %d to be %q, got %q", i, expectUnknown[i], k) + } + } + +} + +func TestLoadPlugins(t *testing.T) { + // Set helm home to point to testdata + old := helmHome + helmHome = "testdata/helmhome" + defer func() { + helmHome = old + }() + hh := helmpath.Home(homePath()) + + out := bytes.NewBuffer(nil) + cmd := &cobra.Command{} + loadPlugins(cmd, hh, out) + + envs := strings.Join([]string{ + "fullenv", + "fullenv.yaml", + hh.Plugins(), + hh.String(), + hh.Repository(), + hh.RepositoryFile(), + hh.Cache(), + hh.LocalRepository(), + os.Args[0], + }, "\n") + + // Test that the YAML file was correctly converted to a command. + tests := []struct { + use string + short string + long string + expect string + args []string + }{ + {"args", "echo args", "This echos args", "-a -b -c\n", []string{"-a", "-b", "-c"}}, + {"echo", "echo stuff", "This echos stuff", "hello\n", []string{}}, + {"env", "env stuff", "show the env", hh.String() + "\n", []string{}}, + {"fullenv", "show env vars", "show all env vars", envs + "\n", []string{}}, + } + + plugins := cmd.Commands() + for i := 0; i < len(plugins); i++ { + out.Reset() + tt := tests[i] + pp := plugins[i] + if pp.Use != tt.use { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.use, pp.Use) + } + if pp.Short != tt.short { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.short, pp.Short) + } + if pp.Long != tt.long { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.long, pp.Long) + } + if err := pp.RunE(pp, tt.args); err != nil { + t.Errorf("Error running %s: %s", tt.use, err) + } + if out.String() != tt.expect { + t.Errorf("Expected %s to output:\n%s\ngot\n%s", tt.use, tt.expect, out.String()) + } + } +} diff --git a/cmd/helm/testdata/helmhome/plugins/args.sh b/cmd/helm/testdata/helmhome/plugins/args.sh new file mode 100755 index 0000000000000000000000000000000000000000..678b4eff551c32b69d6aa461dfa7e0923e07e69c --- /dev/null +++ b/cmd/helm/testdata/helmhome/plugins/args.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $* diff --git a/cmd/helm/testdata/helmhome/plugins/args.yaml b/cmd/helm/testdata/helmhome/plugins/args.yaml new file mode 100644 index 0000000000000000000000000000000000000000..dbea13673a91e9086910caaf6ab480fe5abbfe23 --- /dev/null +++ b/cmd/helm/testdata/helmhome/plugins/args.yaml @@ -0,0 +1,4 @@ +name: args +usage: "echo args" +description: "This echos args" +command: "$HELM_HOME/plugins/args.sh" diff --git a/cmd/helm/testdata/helmhome/plugins/echo.yaml b/cmd/helm/testdata/helmhome/plugins/echo.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7b9362a08712590807ae46bb53dbb770270a6998 --- /dev/null +++ b/cmd/helm/testdata/helmhome/plugins/echo.yaml @@ -0,0 +1,4 @@ +name: echo +usage: "echo stuff" +description: "This echos stuff" +command: "echo hello" diff --git a/cmd/helm/testdata/helmhome/plugins/env.yaml b/cmd/helm/testdata/helmhome/plugins/env.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c8ae403502e229ce5b487fca628fbb16418a8d1f --- /dev/null +++ b/cmd/helm/testdata/helmhome/plugins/env.yaml @@ -0,0 +1,4 @@ +name: env +usage: "env stuff" +description: "show the env" +command: "echo $HELM_HOME" diff --git a/cmd/helm/testdata/helmhome/plugins/fullenv.sh b/cmd/helm/testdata/helmhome/plugins/fullenv.sh new file mode 100755 index 0000000000000000000000000000000000000000..e720473331b06cb57704c4d62b8517f505ceab04 --- /dev/null +++ b/cmd/helm/testdata/helmhome/plugins/fullenv.sh @@ -0,0 +1,10 @@ +#!/bin/sh +echo $HELM_PLUGIN_SHORTNAME +echo $HELM_PLUGIN_NAME +echo $HELM_PLUGIN +echo $HELM_HOME +echo $HELM_PATH_REPOSITORY +echo $HELM_PATH_REPOSITORY_FILE +echo $HELM_PATH_CACHE +echo $HELM_PATH_LOCAL_REPOSITORY +echo $HELM_BIN diff --git a/cmd/helm/testdata/helmhome/plugins/fullenv.yaml b/cmd/helm/testdata/helmhome/plugins/fullenv.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a6f4c5a4e051cb0398422104dddd54d5dcb5274c --- /dev/null +++ b/cmd/helm/testdata/helmhome/plugins/fullenv.yaml @@ -0,0 +1,4 @@ +name: fullenv +usage: "show env vars" +description: "show all env vars" +command: "$HELM_HOME/plugins/fullenv.sh" diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000000000000000000000000000000000000..d8aae6bea3b3ba4b568939b321c2cfbf9c22115b --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,176 @@ +# The Helm Plugins Guide + +Helm 2.1.0 introduced the concept of a client-side Helm _plugin_. A plugin is a +tool that can be accessed through the `helm` CLI, but which is not part of the +built-in Helm codebase. + +This guide explains how to use and create plugins. + +## An Overview + +Helm plugins are add-on tools that integrate seemlessly with Helm. They provide +a way to extend the core feature set of Helm, but without requiring every new +feature to be written in Go and added to the core tool. + +Helm plugins have the following features: + +- They can be added and removed from a Helm installation without impacting the + core Helm tool. +- They can be written in any programming language. +- They integrate with Helm, and will show up in `helm help` and other places. + +Helm plugins live in `$(helm home)/plugins`. + +The Helm plugin model is partially modeled on Git's plugin model. To that end, +you may sometimes hear `helm` referred to as the _porcelain_ layer, with +plugins being the _plumbing_. This is a shorthand way of suggesting that +Helm provides the user experience and top level processing logic, while the +plugins do the "detail work" of performing a desired action. + +## Installing a Plugin + +A Helm plugin management system is in the works. But in the short term, plugins +are installed by copying the plugin directory into `$(helm home)/plugins`. + +```console +$ cp -a myplugin/ $(helm home)/plugins/ +``` + +If you have a plugin tar distribution, simply untar the plugin into the +`$(helm home)/plugins` directory. + +## Building Plugins + +In many ways, a plugin is similar to a chart. Each plugin has a top-level +directory, and then a `plugin.yaml` file. + +``` +$(helm home)/plugins/ + |- keybase/ + | + |- plugin.yaml + |- keybase.sh + +``` + +In the example above, the `keybase` plugin is contained inside of a directory +named `keybase`. It has two files: `plugin.yaml` (required) and an executable +script, `keybase.sh` (optional). + +The core of a plugin is a simple YAML file named `plugin.yaml`. +Here is a plugin YAML for a plugin that adds support for Keybase operations: + +``` +name: "keybase" +version: "0.1.0" +usage: "Integreate Keybase.io tools with Helm" +description: |- + This plugin provides Keybase services to Helm. +ignoreFlags: false +useTunnel: false +command: "$HELM_PLUGIN_DIR/keybase.sh" +``` + +The `name` is the name of the plugin. When Helm executes it plugin, this is the +name it will use (e.g. `helm NAME` will invoke this plugin). + +_`name` should match the directory name._ In our example above, that means the +plugin with `name: keybase` should be contained in a directory named `keybase`. + +Restrictions on `name`: + +- `name` cannot duplicate one of the existing `helm` top-level commands. +- `name` must be restricted to the characters ASCII a-z, A-Z, 0-9, `_` and `-`. + +`version` is the SemVer 2 version of the plugin. +`usage` and `description` are both used to generate the help text of a command. + +The `ignoreFlags` switch tells Helm to _not_ pass flags to the plugin. So if a +plugin is called with `helm myplugin --foo` and `ignoreFlags: true`, then `--foo` +is silently discarded. + +The `useTunnel` switch indicates that the plugin needs a tunnel to Tiller. This +should be set to `true` _anytime a plugin talks to Tiller_. It will cause Helm +to open a tunnel, and then set `$TILLER_HOST` to the right local address for that +tunnel. But don't worry: if Helm detects that a tunnel is not necessary because +Tiller is running locally, it will not create the tunnel. + +Finally, and most importantly, `command` is the command that this plugin will +execute when it is called. Environment variables are interpolated before the plugin +is executed. The pattern above illustrates the preferred way to indicate where +the plugin program lives. + +There are some strategies for working with plugin commands: + +- If a plugin includes an executable, the executable for a `command:` should be + packaged in the plugin directory. +- The `command:` line will have any environment variables expanded before + execution. `$HELM_PLUGIN_DIR` will point to the plugin directory. +- The command itself is not executed in a shell. So you can't oneline a shell script. +- Helm injects lots of configuration into environment variables. Take a look at + the environment to see what information is available. +- Helm makes no assumptions about the language of the plugin. You can write it + in whatever you prefer. +- Commands are responsible for implementing specific help text for `-h` and `--help`. + Helm will use `usage` and `description` for `helm help` and `helm help myplugin`, + but will not handle `helm myplugin --help`. + +## Environment Variables + +When Helm executes a plugin, it passes the outer environment to the plugin, and +also injects some additional environment variables. + +Variables like `KUBECONFIG` are set for the plugin if they are set in the +outer environment. + +The following variables are guaranteed to be set: + +- `HELM_PLUGIN`: The path to the plugins directory +- `HELM_PLUGIN_NAME`: The name of the plugin, as invoked by `helm`. So + `helm myplug` will have the short name `myplug`. +- `HELM_PLUGIN_DIR`: The directory that contains the plugin. +- `HELM_BIN`: The path to the `helm` command (as executed by the user). +- `HELM_HOME`: The path to the Helm home. +- `HELM_PATH_*`: Paths to important Helm files and directories are stored in + environment variables prefixed by `HELM_PATH`. +- `TILLER_HOST`: The `domain:port` to Tiller. If a tunnel is created, this + will point to the local endpoint for the tunnel. Otherwise, it will point + to `$HELM_HOST`, `--host`, or the default host (according to Helm's rules of + precedence). + +While `HELM_HOST` _may_ be set, there is no guarantee that it will point to the +correct Tiller instance. This is done to allow plugin developer to access +`HELM_HOST` in its raw state when the plugin itself needs to manually configure +a connection. + +## A Note on `useTunnel` + +If a plugin specifies `useTunnel: true`, Helm will do the following (in order): + +1. Parse global flags and the environment +2. Create the tunnel +3. Set `TILLER_HOST` +4. Execute the plugin +5. Close the tunnel + +The tunnel is removed as soon as the `command` returns. So, for example, a +command cannot background a process and assume that that process will be able +to use the tunnel. + +## A Note on Flag Parsing + +When executing a plugin, Helm will parse global flags for its own use, but pass +all flags to the plugin. + +Plugins MUST NOT produce an error for the following flags: + +- `--debug` +- `--home` +- `--host` +- `--kube-context` +- `-h` +- `--help` + +Plugins _should_ display help text and then exit for `-h` and `--help`. In all +other cases, plugins may simply ignore the flags. + diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go new file mode 100644 index 0000000000000000000000000000000000000000..bc8cbb73283f27757c9c5709c62e7950af17066f --- /dev/null +++ b/pkg/plugin/plugin.go @@ -0,0 +1,135 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 ( + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/ghodss/yaml" +) + +// PluginFileName is the name of a plugin file. +const PluginFileName = "plugin.yaml" + +// Metadata describes a plugin. +// +// This is the plugin equivalent of a chart.Metadata. +type Metadata struct { + // Name is the name of the plugin + Name string `json:"name"` + + // Version is a SemVer 2 version of the plugin. + Version string `json:"version"` + + // Usage is the single-line usage text shown in help + Usage string `json:"usage"` + + // Description is a long description shown in places like `helm help` + Description string `json:"description"` + + // Command is the command, as a single string. + // + // The command will be passed through environment expansion, so env vars can + // be present in this command. Unless IgnoreFlags is set, this will + // also merge the flags passed from Helm. + // + // Note that command is not executed in a shell. To do so, we suggest + // pointing the command to a shell script. + Command string `json:"command"` + + // IgnoreFlags ignores any flags passed in from Helm + // + // For example, if the plugin is invoked as `helm --debug myplugin`, if this + // is false, `--debug` will be appended to `--command`. If this is true, + // the `--debug` flag will be discarded. + IgnoreFlags bool `json:"ignoreFlags"` + + // UseTunnel indicates that this command needs a tunnel. + // Setting this will cause a number of side effects, such as the + // automatic setting of HELM_HOST. + UseTunnel bool `json:"useTunnel"` +} + +// Plugin represents a plugin. +type Plugin struct { + // Metadata is a parsed representation of a plugin.yaml + Metadata *Metadata + // Dir is the string path to the directory that holds the plugin. + Dir string +} + +// PrepareCommand takes a Plugin.Command and prepares it for execution. +// +// It merges extraArgs into any arguments supplied in the plugin. It +// returns the name of the command and an args array. +// +// The result is suitable to pass to exec.Command. +func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string) { + parts := strings.Split(os.ExpandEnv(p.Metadata.Command), " ") + main := parts[0] + baseArgs := []string{} + if len(parts) > 1 { + baseArgs = parts[1:] + } + if !p.Metadata.IgnoreFlags { + baseArgs = append(baseArgs, extraArgs...) + } + return main, baseArgs +} + +// LoadDir loads a plugin from the given directory. +func LoadDir(dirname string) (*Plugin, error) { + data, err := ioutil.ReadFile(filepath.Join(dirname, PluginFileName)) + if err != nil { + return nil, err + } + + plug := &Plugin{Dir: dirname} + if err := yaml.Unmarshal(data, &plug.Metadata); err != nil { + return nil, err + } + return plug, nil +} + +// LoadAll loads all plugins found beneath the base directory. +// +// This scans only one directory level. +func LoadAll(basedir string) ([]*Plugin, error) { + plugins := []*Plugin{} + // We want basedir/*/plugin.yaml + scanpath := filepath.Join(basedir, "*", PluginFileName) + matches, err := filepath.Glob(scanpath) + if err != nil { + return plugins, err + } + + if matches == nil { + return plugins, nil + } + + for _, yaml := range matches { + dir := filepath.Dir(yaml) + p, err := LoadDir(dir) + if err != nil { + return plugins, err + } + plugins = append(plugins, p) + } + return plugins, nil +} diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go new file mode 100644 index 0000000000000000000000000000000000000000..aa976546aca510dad4340cca4e1201a0e4e07397 --- /dev/null +++ b/pkg/plugin/plugin_test.go @@ -0,0 +1,117 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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" + "testing" +) + +func TestPrepareCommand(t *testing.T) { + p := &Plugin{ + Dir: "/tmp", // Unused + Metadata: &Metadata{ + Name: "test", + Command: "echo -n foo", + }, + } + argv := []string{"--debug", "--foo", "bar"} + + cmd, args := p.PrepareCommand(argv) + if cmd != "echo" { + t.Errorf("Expected echo, got %q", cmd) + } + + if l := len(args); l != 5 { + t.Errorf("expected 5 args, got %d", l) + } + + expect := []string{"-n", "foo", "--debug", "--foo", "bar"} + for i := 0; i < len(args); i++ { + if expect[i] != args[i] { + t.Errorf("Expected arg=%q, got %q", expect[i], args[i]) + } + } + + // Test with IgnoreFlags. This should omit --debug, --foo, bar + p.Metadata.IgnoreFlags = true + cmd, args = p.PrepareCommand(argv) + if cmd != "echo" { + t.Errorf("Expected echo, got %q", cmd) + } + if l := len(args); l != 2 { + t.Errorf("expected 2 args, got %d", l) + } + expect = []string{"-n", "foo"} + for i := 0; i < len(args); i++ { + if expect[i] != args[i] { + t.Errorf("Expected arg=%q, got %q", expect[i], args[i]) + } + } +} + +func TestLoadDir(t *testing.T) { + dirname := "testdata/plugdir/hello" + plug, err := LoadDir(dirname) + if err != nil { + t.Fatalf("error loading Hello plugin: %s", err) + } + + if plug.Dir != dirname { + t.Errorf("Expected dir %q, got %q", dirname, plug.Dir) + } + + expect := Metadata{ + Name: "hello", + Version: "0.1.0", + Usage: "usage", + Description: "description", + Command: "$HELM_PLUGIN_SELF/hello.sh", + UseTunnel: true, + IgnoreFlags: true, + } + + if reflect.DeepEqual(expect, plug.Metadata) { + t.Errorf("Expected name %v, got %v", expect, plug.Metadata) + } +} + +func TestLoadAll(t *testing.T) { + + // Verify that empty dir loads: + if plugs, err := LoadAll("testdata"); err != nil { + t.Fatalf("error loading dir with no plugins: %s", err) + } else if len(plugs) > 0 { + t.Fatalf("expected empty dir to have 0 plugins") + } + + basedir := "testdata/plugdir" + plugs, err := LoadAll(basedir) + if err != nil { + t.Fatalf("Could not load %q: %s", basedir, err) + } + + if l := len(plugs); l != 2 { + t.Fatalf("expected 2 plugins, found %d", l) + } + + if plugs[0].Metadata.Name != "echo" { + t.Errorf("Expected first plugin to be echo, got %q", plugs[0].Metadata.Name) + } + if plugs[1].Metadata.Name != "hello" { + t.Errorf("Expected second plugin to be hello, got %q", plugs[1].Metadata.Name) + } +} diff --git a/pkg/plugin/testdata/plugdir/echo/plugin.yaml b/pkg/plugin/testdata/plugdir/echo/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..da6f656ebbc3d732fa1b4963b68de7a1f28722e8 --- /dev/null +++ b/pkg/plugin/testdata/plugdir/echo/plugin.yaml @@ -0,0 +1,6 @@ +name: "echo" +version: "1.2.3" +usage: "echo something" +description: |- + This is a testing fixture. +command: "echo Hello" diff --git a/pkg/plugin/testdata/plugdir/hello/hello.sh b/pkg/plugin/testdata/plugdir/hello/hello.sh new file mode 100755 index 0000000000000000000000000000000000000000..db7c0f54dafb9b1267e725b9ddad04c5d5c0f4c3 --- /dev/null +++ b/pkg/plugin/testdata/plugdir/hello/hello.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +echo "Hello from a Helm plugin" + +echo "PARAMS" +echo $* + +echo "ENVIRONMENT" +echo $TILLER_HOST +echo $HELM_HOME + +$HELM_BIN --host $TILLER_HOST ls --all + diff --git a/pkg/plugin/testdata/plugdir/hello/plugin.yaml b/pkg/plugin/testdata/plugdir/hello/plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6415e6fa01a6085ee3639244a2c0f01c380d651c --- /dev/null +++ b/pkg/plugin/testdata/plugdir/hello/plugin.yaml @@ -0,0 +1,8 @@ +name: "hello" +version: "0.1.0" +usage: "usage" +description: |- + description +command: "$HELM_PLUGIN_SELF/helm-hello" +useTunnel: true +ignoreFlags: true