diff --git a/README.md b/README.md index 337a59a9e..40aea5545 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 d9b169f00..c2b87c7c3 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,8 @@ func setupConnection(c *cobra.Command, args []string) error { if flagDebug { fmt.Printf("SERVER: %q\n", tillerHost) } + // Plugin support. + os.Setenv("TILLER_HOST", tillerHost) return nil } diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go index 284d74b50..ef1303958 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()} + configDirectories := []string{home.String(), home.Repository(), home.Cache(), home.LocalRepository(), home.Plugins()} 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 798ab3d5f..699b302a6 100644 --- a/cmd/helm/helmpath/helmhome.go +++ b/cmd/helm/helmpath/helmhome.go @@ -62,3 +62,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 259336b92..94d758f3c 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()} + configDirectories := []string{home.String(), home.Repository(), home.Cache(), home.LocalRepository(), home.Plugins()} 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 000000000..b62705672 --- /dev/null +++ b/cmd/helm/plugins.go @@ -0,0 +1,184 @@ +/* +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" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/ghodss/yaml" + "github.com/spf13/cobra" + + "k8s.io/helm/cmd/helm/helmpath" +) + +const pluginEnvVar = "HELM_PLUGIN" + +// Plugin describes a plugin. +type Plugin 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"` +} + +// 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 := findPlugins(plugdirs) + + // Now we create commands for all of these. + for _, path := range found { + + data, err := ioutil.ReadFile(path) + if err != nil { + // What should we do here? For now, write a message. + fmt.Fprintf(os.Stderr, "Failed to load %s: %s (skipping)", path, err) + continue + } + + plug := &Plugin{} + if err := yaml.Unmarshal(data, plug); err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse %s: %s (skipping)", path, err) + continue + } + + base := filepath.Base(path) + if plug.Usage == "" { + plug.Usage = fmt.Sprintf("the %q plugin", base) + } + + c := &cobra.Command{ + Use: plug.Name, + Short: plug.Usage, + Long: plug.Description, + RunE: func(cmd *cobra.Command, args []string) error { + setupEnv(plug.Name, base, plugdirs, home) + main, argv := plug.prepareCommand(args) + prog := exec.Command(main, argv...) + prog.Stdout = out + return prog.Run() + }, + // This passes all the flags to the subcommand. + DisableFlagParsing: true, + } + + if plug.UseTunnel { + c.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + // Parse the parent flag, but not the local flags. + c.Parent().ParseFlags(args) + return setupConnection(cmd, args) + } + } + + // TODO: Make sure a command with this name does not already exist. + baseCmd.AddCommand(c) + } +} + +func (p *Plugin) prepareCommand(extraArgs []string) (string, []string) { + parts := strings.Split(os.ExpandEnv(p.Command), " ") + main := parts[0] + baseArgs := []string{} + if len(parts) > 1 { + baseArgs = parts[1:] + } + if !p.IgnoreFlags { + baseArgs = append(baseArgs, extraArgs...) + } + return main, baseArgs +} + +// findPlugins returns a list of YAML files that describe plugins. +func findPlugins(plugdirs string) []string { + found := []string{} + // Let's get all UNIXy and allow path separators + for _, p := range filepath.SplitList(plugdirs) { + // Look for any commands that match the Helm plugin pattern: + matches, err := filepath.Glob(filepath.Join(p, "*.yaml")) + if err != nil { + continue + } + found = append(found, matches...) + } + return found +} + +func setupEnv(shortname, base, plugdirs string, home helmpath.Home) { + // Set extra env vars: + for key, val := range map[string]string{ + "HELM_PLUGIN_SHORTNAME": shortname, + "HELM_PLUGIN_NAME": 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(), + + // TODO: Add helm starter packs var when that is merged. + } { + os.Setenv(key, val) + } + +} diff --git a/cmd/helm/plugins_test.go b/cmd/helm/plugins_test.go new file mode 100644 index 000000000..5f7769445 --- /dev/null +++ b/cmd/helm/plugins_test.go @@ -0,0 +1,89 @@ +/* +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 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 000000000..678b4eff5 --- /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 000000000..dbea13673 --- /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 000000000..7b9362a08 --- /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 000000000..c8ae40350 --- /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 000000000..e72047333 --- /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 000000000..a6f4c5a4e --- /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 000000000..f7f45dab6 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,148 @@ +# 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's YAML file into `$(helm home)/plugins`. + +## Building Plugins + +The core of a plugin is a simple YAML file (because, after all, YAML files are +the solution to and cause of all of life's problems). + +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_HOME/plugins/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). + +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 `$HELM_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: + +- 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 `KOBECONFIG` 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_SHORTNAME`: The name of the plugin, as invoked by `helm`. So + `helm myplug` will have the short name `myplug`. +- `HELM_PLUGIN_NAME`: The name of the plugin YAML file (`myplug.yaml`). +- `HELM_BIN`: The path to the `helm` command. +- `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. +