feat(helm): add plugin system backend

This adds a backend for a plugin system.

Closes #1572
reviewable/pr1573/r1
Matt Butcher 9 years ago
parent 51bdad4275
commit 8174335a8e
No known key found for this signature in database
GPG Key ID: DCD5F5E5EF32C345

@ -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)

@ -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
}

@ -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 {

@ -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")
}

@ -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)

@ -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)
}
}

@ -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())
}
}
}

@ -0,0 +1,2 @@
#!/bin/bash
echo $*

@ -0,0 +1,4 @@
name: args
usage: "echo args"
description: "This echos args"
command: "$HELM_HOME/plugins/args.sh"

@ -0,0 +1,4 @@
name: echo
usage: "echo stuff"
description: "This echos stuff"
command: "echo hello"

@ -0,0 +1,4 @@
name: env
usage: "env stuff"
description: "show the env"
command: "echo $HELM_HOME"

@ -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

@ -0,0 +1,4 @@
name: fullenv
usage: "show env vars"
description: "show all env vars"
command: "$HELM_HOME/plugins/fullenv.sh"

@ -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.
Loading…
Cancel
Save