add updated plugin mechanism

pull/8/head
juanvallejo 2018-08-01 18:31:07 -04:00
parent d745508cc0
commit 4bdc636380
No known key found for this signature in database
GPG Key ID: 7D2C958002D6448D
37 changed files with 415 additions and 1794 deletions

View File

@ -243,6 +243,7 @@ docs/man/man1/kubectl-label.1
docs/man/man1/kubectl-logs.1
docs/man/man1/kubectl-options.1
docs/man/man1/kubectl-patch.1
docs/man/man1/kubectl-plugin-list.1
docs/man/man1/kubectl-plugin.1
docs/man/man1/kubectl-port-forward.1
docs/man/man1/kubectl-proxy.1
@ -346,6 +347,7 @@ docs/user-guide/kubectl/kubectl_logs.md
docs/user-guide/kubectl/kubectl_options.md
docs/user-guide/kubectl/kubectl_patch.md
docs/user-guide/kubectl/kubectl_plugin.md
docs/user-guide/kubectl/kubectl_plugin_list.md
docs/user-guide/kubectl/kubectl_port-forward.md
docs/user-guide/kubectl/kubectl_proxy.md
docs/user-guide/kubectl/kubectl_replace.md

View File

@ -0,0 +1,3 @@
This file is autogenerated, but we've stopped checking such files into the
repository to reduce the need for rebases. Please run hack/generate-docs.sh to
populate this file.

View File

@ -0,0 +1,3 @@
This file is autogenerated, but we've stopped checking such files into the
repository to reduce the need for rebases. Please run hack/generate-docs.sh to
populate this file.

View File

@ -188,7 +188,6 @@ filegroup(
"//pkg/kubectl/explain:all-srcs",
"//pkg/kubectl/genericclioptions:all-srcs",
"//pkg/kubectl/metricsutil:all-srcs",
"//pkg/kubectl/plugins:all-srcs",
"//pkg/kubectl/polymorphichelpers:all-srcs",
"//pkg/kubectl/proxy:all-srcs",
"//pkg/kubectl/scheme:all-srcs",

View File

@ -78,7 +78,6 @@ go_library(
"//pkg/kubectl/genericclioptions/printers:go_default_library",
"//pkg/kubectl/genericclioptions/resource:go_default_library",
"//pkg/kubectl/metricsutil:go_default_library",
"//pkg/kubectl/plugins:go_default_library",
"//pkg/kubectl/polymorphichelpers:go_default_library",
"//pkg/kubectl/proxy:go_default_library",
"//pkg/kubectl/scheme:go_default_library",
@ -171,7 +170,6 @@ go_test(
"label_test.go",
"logs_test.go",
"patch_test.go",
"plugin_test.go",
"portforward_test.go",
"replace_test.go",
"rollingupdate_test.go",
@ -202,7 +200,6 @@ go_test(
"//pkg/kubectl/genericclioptions:go_default_library",
"//pkg/kubectl/genericclioptions/printers:go_default_library",
"//pkg/kubectl/genericclioptions/resource:go_default_library",
"//pkg/kubectl/plugins:go_default_library",
"//pkg/kubectl/polymorphichelpers:go_default_library",
"//pkg/kubectl/scheme:go_default_library",
"//pkg/kubectl/util/i18n:go_default_library",

View File

@ -21,6 +21,12 @@ import (
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"syscall"
"github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/api/meta"
utilflag "k8s.io/apiserver/pkg/util/flag"
@ -36,7 +42,6 @@ import (
"k8s.io/kubernetes/pkg/kubectl/cmd/wait"
"k8s.io/kubernetes/pkg/kubectl/util/i18n"
"github.com/spf13/cobra"
"k8s.io/kubernetes/pkg/kubectl/genericclioptions"
)
@ -257,7 +262,100 @@ var (
)
func NewDefaultKubectlCommand() *cobra.Command {
return NewKubectlCommand(os.Stdin, os.Stdout, os.Stderr)
return NewDefaultKubectlCommandWithArgs(&defaultPluginHandler{}, os.Args, os.Stdin, os.Stdout, os.Stderr)
}
func NewDefaultKubectlCommandWithArgs(pluginHandler PluginHandler, args []string, in io.Reader, out, errout io.Writer) *cobra.Command {
cmd := NewKubectlCommand(in, out, errout)
if pluginHandler == nil {
return cmd
}
if len(args) > 1 {
cmdPathPieces := args[1:]
// only look for suitable extension executables if
// the specified command does not already exist
if _, _, err := cmd.Find(cmdPathPieces); err != nil {
if err := handleEndpointExtensions(pluginHandler, cmdPathPieces); err != nil {
fmt.Fprintf(errout, "%v\n", err)
os.Exit(1)
}
}
}
return cmd
}
// PluginHandler is capable of parsing command line arguments
// and performing executable filename lookups to search
// for valid plugin files, and execute found plugins.
type PluginHandler interface {
// Lookup receives a potential filename and returns
// a full or relative path to an executable, if one
// exists at the given filename, or an error.
Lookup(filename string) (string, error)
// Execute receives an executable's filepath, a slice
// of arguments, and a slice of environment variables
// to relay to the executable.
Execute(executablePath string, cmdArgs, environment []string) error
}
type defaultPluginHandler struct{}
// Lookup implements PluginHandler
func (h *defaultPluginHandler) Lookup(filename string) (string, error) {
// if on Windows, append the "exe" extension
// to the filename that we are looking up.
if runtime.GOOS == "windows" {
filename = filename + ".exe"
}
return exec.LookPath(filename)
}
// Execute implements PluginHandler
func (h *defaultPluginHandler) Execute(executablePath string, cmdArgs, environment []string) error {
return syscall.Exec(executablePath, cmdArgs, environment)
}
func handleEndpointExtensions(pluginHandler PluginHandler, cmdArgs []string) error {
remainingArgs := []string{} // all "non-flag" arguments
for idx := range cmdArgs {
if strings.HasPrefix(cmdArgs[idx], "-") {
break
}
remainingArgs = append(remainingArgs, strings.Replace(cmdArgs[idx], "-", "_", -1))
}
foundBinaryPath := ""
// attempt to find binary, starting at longest possible name with given cmdArgs
for len(remainingArgs) > 0 {
path, err := pluginHandler.Lookup(fmt.Sprintf("kubectl-%s", strings.Join(remainingArgs, "-")))
if err != nil || len(path) == 0 {
remainingArgs = remainingArgs[:len(remainingArgs)-1]
continue
}
foundBinaryPath = path
break
}
if len(foundBinaryPath) == 0 {
return nil
}
// invoke cmd binary relaying the current environment and args given
// remainingArgs will always have at least one element.
// execve will make remainingArgs[0] the "binary name".
if err := pluginHandler.Execute(foundBinaryPath, append([]string{foundBinaryPath}, cmdArgs[len(remainingArgs):]...), os.Environ()); err != nil {
return err
}
return nil
}
// NewKubectlCommand creates the `kubectl` command and its nested children.

View File

@ -19,6 +19,7 @@ package cmd
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
@ -37,6 +38,7 @@ import (
"k8s.io/kubernetes/pkg/api/testapi"
apitesting "k8s.io/kubernetes/pkg/api/testing"
cmdutil "k8s.io/kubernetes/pkg/kubectl/cmd/util"
"k8s.io/kubernetes/pkg/kubectl/genericclioptions"
"k8s.io/kubernetes/pkg/kubectl/scheme"
)
@ -213,3 +215,106 @@ func Test_deprecatedAlias(t *testing.T) {
t.Errorf("original function doesn't appear to have been called by alias")
}
}
func TestKubectlCommandHandlesPlugins(t *testing.T) {
tests := []struct {
name string
args []string
expectPlugin string
expectPluginArgs []string
expectError string
}{
{
name: "test that normal commands are able to be executed, when no plugin overshadows them",
args: []string{"kubectl", "get", "foo"},
expectPlugin: "",
expectPluginArgs: []string{},
},
{
name: "test that a plugin executable is found based on command args",
args: []string{"kubectl", "foo", "--bar"},
expectPlugin: "testdata/plugin/kubectl-foo",
expectPluginArgs: []string{"foo", "--bar"},
},
{
name: "test that a plugin does not execute over an existing command by the same name",
args: []string{"kubectl", "version"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
pluginsHandler := &testPluginHandler{
pluginsDirectory: "testdata/plugin",
}
_, in, out, errOut := genericclioptions.NewTestIOStreams()
cmdutil.BehaviorOnFatal(func(str string, code int) {
errOut.Write([]byte(str))
})
root := NewDefaultKubectlCommandWithArgs(pluginsHandler, test.args, in, out, errOut)
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pluginsHandler.err != nil && pluginsHandler.err.Error() != test.expectError {
t.Fatalf("unexpected error: expected %q to occur, but got %q", test.expectError, pluginsHandler.err)
}
if pluginsHandler.executedPlugin != test.expectPlugin {
t.Fatalf("unexpected plugin execution: expedcted %q, got %q", test.expectPlugin, pluginsHandler.executedPlugin)
}
if len(pluginsHandler.withArgs) != len(test.expectPluginArgs) {
t.Fatalf("unexpected plugin execution args: expedcted %q, got %q", test.expectPluginArgs, pluginsHandler.withArgs)
}
})
}
}
type testPluginHandler struct {
pluginsDirectory string
// execution results
executedPlugin string
withArgs []string
withEnv []string
err error
}
func (h *testPluginHandler) Lookup(filename string) (string, error) {
dir, err := os.Stat(h.pluginsDirectory)
if err != nil {
h.err = err
return "", err
}
if !dir.IsDir() {
h.err = fmt.Errorf("expected %q to be a directory", h.pluginsDirectory)
return "", h.err
}
plugins, err := ioutil.ReadDir(h.pluginsDirectory)
if err != nil {
h.err = err
return "", err
}
for _, p := range plugins {
if p.Name() == filename {
return fmt.Sprintf("%s/%s", h.pluginsDirectory, p.Name()), nil
}
}
h.err = fmt.Errorf("unable to find a plugin executable %q", filename)
return "", h.err
}
func (h *testPluginHandler) Execute(executablePath string, cmdArgs, env []string) error {
h.executedPlugin = executablePath
h.withArgs = cmdArgs
h.withEnv = env
return nil
}

View File

@ -18,170 +18,212 @@ package cmd
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"syscall"
"path/filepath"
"runtime"
"strings"
"github.com/golang/glog"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"k8s.io/kubernetes/pkg/kubectl/cmd/templates"
cmdutil "k8s.io/kubernetes/pkg/kubectl/cmd/util"
"k8s.io/kubernetes/pkg/kubectl/genericclioptions"
"k8s.io/kubernetes/pkg/kubectl/plugins"
"k8s.io/kubernetes/pkg/kubectl/util/i18n"
)
var (
plugin_long = templates.LongDesc(`
Runs a command-line plugin.
Provides utilities for interacting with plugins.
Plugins are subcommands that are not part of the major command-line distribution
and can even be provided by third-parties. Please refer to the documentation and
examples for more information about how to install and write your own plugins.`)
Plugins provide extended functionality that is not part of the major command-line distribution.
Please refer to the documentation and examples for more information about how write your own plugins.`)
plugin_list_long = templates.LongDesc(`
List all available plugin files on a user's PATH.
Available plugin files are those that are:
- executable
- anywhere on the user's PATH
- begin with "kubectl-"
`)
)
// NewCmdPlugin creates the command that is the top-level for plugin commands.
func NewCmdPlugin(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command {
// Loads plugins and create commands for each plugin identified
loadedPlugins, loadErr := pluginLoader().Load()
if loadErr != nil {
glog.V(1).Infof("Unable to load plugins: %v", loadErr)
}
cmd := &cobra.Command{
Use: "plugin NAME",
Use: "plugin [flags]",
DisableFlagsInUseLine: true,
Short: i18n.T("Runs a command-line plugin"),
Short: i18n.T("Provides utilities for interacting with plugins."),
Long: plugin_long,
Run: func(cmd *cobra.Command, args []string) {
if len(loadedPlugins) == 0 {
cmdutil.CheckErr(fmt.Errorf("no plugins installed."))
}
cmdutil.DefaultSubCommandRun(streams.ErrOut)(cmd, args)
},
}
if len(loadedPlugins) > 0 {
pluginRunner := pluginRunner()
for _, p := range loadedPlugins {
cmd.AddCommand(NewCmdForPlugin(f, p, pluginRunner, streams))
}
}
cmd.AddCommand(NewCmdPluginList(f, streams))
return cmd
}
// NewCmdForPlugin creates a command capable of running the provided plugin.
func NewCmdForPlugin(f cmdutil.Factory, plugin *plugins.Plugin, runner plugins.PluginRunner, streams genericclioptions.IOStreams) *cobra.Command {
if !plugin.IsValid() {
return nil
type PluginListOptions struct {
Verifier PathVerifier
NameOnly bool
genericclioptions.IOStreams
}
// NewCmdPluginList provides a way to list all plugin executables visible to kubectl
func NewCmdPluginList(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command {
o := &PluginListOptions{
IOStreams: streams,
}
cmd := &cobra.Command{
Use: plugin.Name,
Short: plugin.ShortDesc,
Long: templates.LongDesc(plugin.LongDesc),
Example: templates.Examples(plugin.Example),
Use: "list",
Short: "list all visible plugin executables on a user's PATH",
Long: plugin_list_long,
Run: func(cmd *cobra.Command, args []string) {
if len(plugin.Command) == 0 {
cmdutil.DefaultSubCommandRun(streams.ErrOut)(cmd, args)
return
}
envProvider := &plugins.MultiEnvProvider{
&plugins.PluginCallerEnvProvider{},
&plugins.OSEnvProvider{},
&plugins.PluginDescriptorEnvProvider{
Plugin: plugin,
},
&flagsPluginEnvProvider{
cmd: cmd,
},
&factoryAttrsPluginEnvProvider{
factory: f,
},
}
runningContext := plugins.RunningContext{
IOStreams: streams,
Args: args,
EnvProvider: envProvider,
WorkingDir: plugin.Dir,
}
if err := runner.Run(plugin, runningContext); err != nil {
if exiterr, ok := err.(*exec.ExitError); ok {
// check for (and exit with) the correct exit code
// from a failed plugin command execution
if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
fmt.Fprintf(streams.ErrOut, "error: %v\n", err)
os.Exit(status.ExitStatus())
}
}
cmdutil.CheckErr(err)
}
cmdutil.CheckErr(o.Complete(cmd))
cmdutil.CheckErr(o.Run())
},
}
for _, flag := range plugin.Flags {
cmd.Flags().StringP(flag.Name, flag.Shorthand, flag.DefValue, flag.Desc)
}
for _, childPlugin := range plugin.Tree {
cmd.AddCommand(NewCmdForPlugin(f, childPlugin, runner, streams))
}
cmd.Flags().BoolVar(&o.NameOnly, "name-only", o.NameOnly, "If true, display only the binary name of each plugin, rather than its full path")
return cmd
}
type flagsPluginEnvProvider struct {
cmd *cobra.Command
func (o *PluginListOptions) Complete(cmd *cobra.Command) error {
o.Verifier = &CommandOverrideVerifier{
root: cmd.Root(),
seenPlugins: make(map[string]string, 0),
}
return nil
}
func (p *flagsPluginEnvProvider) Env() (plugins.EnvList, error) {
globalPrefix := "KUBECTL_PLUGINS_GLOBAL_FLAG_"
env := plugins.EnvList{}
p.cmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) {
env = append(env, plugins.FlagToEnv(flag, globalPrefix))
})
localPrefix := "KUBECTL_PLUGINS_LOCAL_FLAG_"
p.cmd.LocalFlags().VisitAll(func(flag *pflag.Flag) {
env = append(env, plugins.FlagToEnv(flag, localPrefix))
})
return env, nil
func (o *PluginListOptions) Run() error {
path := "PATH"
if runtime.GOOS == "windows" {
path = "path"
}
pluginsFound := false
isFirstFile := true
pluginWarnings := 0
for _, dir := range filepath.SplitList(os.Getenv(path)) {
files, err := ioutil.ReadDir(dir)
if err != nil {
continue
}
for _, f := range files {
if f.IsDir() {
continue
}
if !strings.HasPrefix(f.Name(), "kubectl-") {
continue
}
if isFirstFile {
fmt.Fprintf(o.ErrOut, "The following kubectl-compatible plugins are available:\n\n")
pluginsFound = true
isFirstFile = false
}
pluginPath := f.Name()
if !o.NameOnly {
pluginPath = filepath.Join(dir, pluginPath)
}
fmt.Fprintf(o.Out, "%s\n", pluginPath)
if errs := o.Verifier.Verify(filepath.Join(dir, f.Name())); len(errs) != 0 {
for _, err := range errs {
fmt.Fprintf(o.ErrOut, " - %s\n", err)
pluginWarnings++
}
}
}
}
if !pluginsFound {
return fmt.Errorf("error: unable to find any kubectl plugins in your PATH")
}
if pluginWarnings > 0 {
fmt.Fprintln(o.ErrOut)
if pluginWarnings == 1 {
return fmt.Errorf("one plugin warning was found")
}
return fmt.Errorf("%v plugin warnings were found", pluginWarnings)
}
return nil
}
type factoryAttrsPluginEnvProvider struct {
factory cmdutil.Factory
// pathVerifier receives a path and determines if it is valid or not
type PathVerifier interface {
// Verify determines if a given path is valid
Verify(path string) []error
}
func (p *factoryAttrsPluginEnvProvider) Env() (plugins.EnvList, error) {
cmdNamespace, _, err := p.factory.ToRawKubeConfigLoader().Namespace()
type CommandOverrideVerifier struct {
root *cobra.Command
seenPlugins map[string]string
}
// Verify implements PathVerifier and determines if a given path
// is valid depending on whether or not it overwrites an existing
// kubectl command path, or a previously seen plugin.
func (v *CommandOverrideVerifier) Verify(path string) []error {
if v.root == nil {
return []error{fmt.Errorf("unable to verify path with nil root")}
}
// extract the plugin binary name
segs := strings.Split(path, "/")
binName := segs[len(segs)-1]
cmdPath := strings.Split(binName, "-")
if len(cmdPath) > 1 {
// the first argument is always "kubectl" for a plugin binary
cmdPath = cmdPath[1:]
}
errors := []error{}
if isExec, err := isExecutable(path); err == nil && !isExec {
errors = append(errors, fmt.Errorf("warning: %s identified as a kubectl plugin, but it is not executable", path))
} else if err != nil {
errors = append(errors, fmt.Errorf("error: unable to identify %s as an executable file: %v", path, err))
}
if existingPath, ok := v.seenPlugins[binName]; ok {
errors = append(errors, fmt.Errorf("warning: %s is overshadowed by a similarly named plugin: %s", path, existingPath))
} else {
v.seenPlugins[binName] = path
}
if cmd, _, err := v.root.Find(cmdPath); err == nil {
errors = append(errors, fmt.Errorf("warning: %s overwrites existing command: %q", binName, cmd.CommandPath()))
}
return errors
}
func isExecutable(fullPath string) (bool, error) {
info, err := os.Stat(fullPath)
if err != nil {
return plugins.EnvList{}, err
return false, err
}
return plugins.EnvList{
plugins.Env{N: "KUBECTL_PLUGINS_CURRENT_NAMESPACE", V: cmdNamespace},
}, nil
}
// pluginLoader loads plugins from a path set by the KUBECTL_PLUGINS_PATH env var.
// If this env var is not set, it defaults to
// "~/.kube/plugins", plus
// "./kubectl/plugins" directory under the "data dir" directory specified by the XDG
// system directory structure spec for the given platform.
func pluginLoader() plugins.PluginLoader {
if len(os.Getenv("KUBECTL_PLUGINS_PATH")) > 0 {
return plugins.KubectlPluginsPathPluginLoader()
if runtime.GOOS == "windows" {
if strings.HasSuffix(info.Name(), ".exe") {
return true, nil
}
return false, nil
}
return plugins.TolerantMultiPluginLoader{
plugins.XDGDataDirsPluginLoader(),
plugins.UserDirPluginLoader(),
}
}
func pluginRunner() plugins.PluginRunner {
return &plugins.ExecPluginRunner{}
if m := info.Mode(); !m.IsDir() && m&0111 != 0 {
return true, nil
}
return false, nil
}

View File

@ -1,115 +0,0 @@
/*
Copyright 2017 The Kubernetes 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 cmd
import (
"fmt"
"testing"
cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing"
cmdutil "k8s.io/kubernetes/pkg/kubectl/cmd/util"
"k8s.io/kubernetes/pkg/kubectl/genericclioptions"
"k8s.io/kubernetes/pkg/kubectl/plugins"
)
type mockPluginRunner struct {
success bool
}
func (r *mockPluginRunner) Run(p *plugins.Plugin, ctx plugins.RunningContext) error {
if !r.success {
return fmt.Errorf("oops %s", p.Name)
}
ctx.Out.Write([]byte(fmt.Sprintf("ok: %s", p.Name)))
return nil
}
func TestPluginCmd(t *testing.T) {
tests := []struct {
name string
plugin *plugins.Plugin
expectedSuccess bool
expectedNilCmd bool
}{
{
name: "success",
plugin: &plugins.Plugin{
Description: plugins.Description{
Name: "success",
ShortDesc: "The Test Plugin",
Command: "echo ok",
},
},
expectedSuccess: true,
},
{
name: "incomplete",
plugin: &plugins.Plugin{
Description: plugins.Description{
Name: "incomplete",
ShortDesc: "The Incomplete Plugin",
},
},
expectedNilCmd: true,
},
{
name: "failure",
plugin: &plugins.Plugin{
Description: plugins.Description{
Name: "failure",
ShortDesc: "The Failing Plugin",
Command: "false",
},
},
expectedSuccess: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
streams, _, outBuf, errBuf := genericclioptions.NewTestIOStreams()
cmdutil.BehaviorOnFatal(func(str string, code int) {
errBuf.Write([]byte(str))
})
runner := &mockPluginRunner{
success: test.expectedSuccess,
}
f := cmdtesting.NewTestFactory()
defer f.Cleanup()
cmd := NewCmdForPlugin(f, test.plugin, runner, streams)
if cmd == nil {
if !test.expectedNilCmd {
t.Fatalf("%s: command was unexpectedly not registered", test.name)
}
return
}
cmd.Run(cmd, []string{})
if test.expectedSuccess && outBuf.String() != fmt.Sprintf("ok: %s", test.plugin.Name) {
t.Errorf("%s: unexpected output: %q", test.name, outBuf.String())
}
if !test.expectedSuccess && errBuf.String() != fmt.Sprintf("error: oops %s", test.plugin.Name) {
t.Errorf("%s: unexpected err output: %q", test.name, errBuf.String())
}
})
}
}

View File

@ -510,6 +510,10 @@ func TestRunValidations(t *testing.T) {
tf.ClientConfigVal = defaultClientConfig()
streams, _, _, bufErr := genericclioptions.NewTestIOStreams()
cmdutil.BehaviorOnFatal(func(str string, code int) {
bufErr.Write([]byte(str))
})
cmd := NewCmdRun(tf, streams)
for flagName, flagValue := range test.flags {
cmd.Flags().Set(flagName, flagValue)

3
pkg/kubectl/cmd/testdata/plugin/kubectl-foo vendored Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
echo "I am plugin foo"

View File

@ -0,0 +1,4 @@
#!/bin/bash
# This plugin is a no-op and is used to test plugins
# that overshadow existing kubectl commands

View File

@ -1,53 +0,0 @@
package(default_visibility = ["//visibility:public"])
load(
"@io_bazel_rules_go//go:def.bzl",
"go_library",
"go_test",
)
go_library(
name = "go_default_library",
srcs = [
"env.go",
"loader.go",
"plugins.go",
"runner.go",
],
importpath = "k8s.io/kubernetes/pkg/kubectl/plugins",
deps = [
"//pkg/kubectl/genericclioptions:go_default_library",
"//staging/src/k8s.io/client-go/tools/clientcmd:go_default_library",
"//vendor/github.com/ghodss/yaml:go_default_library",
"//vendor/github.com/golang/glog:go_default_library",
"//vendor/github.com/spf13/pflag:go_default_library",
],
)
filegroup(
name = "package-srcs",
srcs = glob(["**"]),
tags = ["automanaged"],
visibility = ["//visibility:private"],
)
filegroup(
name = "all-srcs",
srcs = [":package-srcs"],
tags = ["automanaged"],
)
go_test(
name = "go_default_test",
srcs = [
"env_test.go",
"loader_test.go",
"plugins_test.go",
"runner_test.go",
],
embed = [":go_default_library"],
deps = [
"//pkg/kubectl/genericclioptions:go_default_library",
"//vendor/github.com/spf13/pflag:go_default_library",
],
)

View File

@ -1,161 +0,0 @@
/*
Copyright 2017 The Kubernetes 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 plugins
import (
"fmt"
"os"
"strings"
"github.com/spf13/pflag"
)
// Env represents an environment variable with its name and value.
type Env struct {
N string
V string
}
// String returns "name=value" string.
func (e Env) String() string {
return fmt.Sprintf("%s=%s", e.N, e.V)
}
// EnvList is a list of Env.
type EnvList []Env
// Slice returns a slice of "name=value" strings.
func (e EnvList) Slice() []string {
envs := []string{}
for _, env := range e {
envs = append(envs, env.String())
}
return envs
}
// Merge converts "name=value" strings into Env values and merges them into e.
func (e EnvList) Merge(s ...string) EnvList {
newList := e
newList = append(newList, fromSlice(s)...)
return newList
}
// EnvProvider provides the environment in which the plugin will run.
type EnvProvider interface {
// Env returns the env list.
Env() (EnvList, error)
}
// MultiEnvProvider satisfies the EnvProvider interface for multiple env providers.
type MultiEnvProvider []EnvProvider
// Env returns the combined env list of multiple env providers, returns on first error.
func (p MultiEnvProvider) Env() (EnvList, error) {
env := EnvList{}
for _, provider := range p {
pEnv, err := provider.Env()
if err != nil {
return EnvList{}, err
}
env = append(env, pEnv...)
}
return env, nil
}
// PluginCallerEnvProvider satisfies the EnvProvider interface.
type PluginCallerEnvProvider struct{}
// Env returns env with the path to the caller binary (usually full path to 'kubectl').
func (p *PluginCallerEnvProvider) Env() (EnvList, error) {
caller, err := os.Executable()
if err != nil {
return EnvList{}, err
}
return EnvList{
{"KUBECTL_PLUGINS_CALLER", caller},
}, nil
}
// PluginDescriptorEnvProvider satisfies the EnvProvider interface.
type PluginDescriptorEnvProvider struct {
Plugin *Plugin
}
// Env returns env with information about the running plugin.
func (p *PluginDescriptorEnvProvider) Env() (EnvList, error) {
if p.Plugin == nil {
return []Env{}, fmt.Errorf("plugin not present to extract env")
}
prefix := "KUBECTL_PLUGINS_DESCRIPTOR_"
env := EnvList{
{prefix + "NAME", p.Plugin.Name},
{prefix + "SHORT_DESC", p.Plugin.ShortDesc},
{prefix + "LONG_DESC", p.Plugin.LongDesc},
{prefix + "EXAMPLE", p.Plugin.Example},
{prefix + "COMMAND", p.Plugin.Command},
}
return env, nil
}
// OSEnvProvider satisfies the EnvProvider interface.
type OSEnvProvider struct{}
// Env returns the current environment from the operating system.
func (p *OSEnvProvider) Env() (EnvList, error) {
return fromSlice(os.Environ()), nil
}
// EmptyEnvProvider satisfies the EnvProvider interface.
type EmptyEnvProvider struct{}
// Env returns an empty environment.
func (p *EmptyEnvProvider) Env() (EnvList, error) {
return EnvList{}, nil
}
// FlagToEnvName converts a flag string into a UNIX like environment variable name.
// e.g --some-flag => "PREFIX_SOME_FLAG"
func FlagToEnvName(flagName, prefix string) string {
envName := strings.TrimPrefix(flagName, "--")
envName = strings.ToUpper(envName)
envName = strings.Replace(envName, "-", "_", -1)
envName = prefix + envName
return envName
}
// FlagToEnv converts a flag and its value into an Env.
// e.g --some-flag some-value => Env{N: "PREFIX_SOME_FLAG", V="SOME_VALUE"}
func FlagToEnv(flag *pflag.Flag, prefix string) Env {
envName := FlagToEnvName(flag.Name, prefix)
return Env{envName, flag.Value.String()}
}
func fromSlice(envs []string) EnvList {
list := EnvList{}
for _, env := range envs {
list = append(list, parseEnv(env))
}
return list
}
func parseEnv(env string) Env {
if !strings.Contains(env, "=") {
env = env + "="
}
parsed := strings.SplitN(env, "=", 2)
return Env{parsed[0], parsed[1]}
}

View File

@ -1,186 +0,0 @@
/*
Copyright 2017 The Kubernetes 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 plugins
import (
"reflect"
"testing"
"github.com/spf13/pflag"
)
func TestEnv(t *testing.T) {
tests := []struct {
name string
env Env
expected string
}{
{
name: "test1",
env: Env{"FOO", "BAR"},
expected: "FOO=BAR",
},
{
name: "test2",
env: Env{"FOO", "BAR="},
expected: "FOO=BAR=",
},
{
name: "test3",
env: Env{"FOO", ""},
expected: "FOO=",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if s := tt.env.String(); s != tt.expected {
t.Errorf("%v: expected string %q, got %q", tt.env, tt.expected, s)
}
})
}
}
func TestEnvListToSlice(t *testing.T) {
tests := []struct {
name string
env EnvList
expected []string
}{
{
name: "test1",
env: EnvList{
{"FOO", "BAR"},
{"ZEE", "YO"},
{"ONE", "1"},
{"EQUALS", "=="},
{"EMPTY", ""},
},
expected: []string{"FOO=BAR", "ZEE=YO", "ONE=1", "EQUALS===", "EMPTY="},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if s := tt.env.Slice(); !reflect.DeepEqual(tt.expected, s) {
t.Errorf("%v: expected %v, got %v", tt.env, tt.expected, s)
}
})
}
}
func TestAddToEnvList(t *testing.T) {
tests := []struct {
name string
add []string
expected EnvList
}{
{
name: "test1",
add: []string{"FOO=BAR", "EMPTY=", "EQUALS===", "JUSTNAME"},
expected: EnvList{
{"FOO", "BAR"},
{"EMPTY", ""},
{"EQUALS", "=="},
{"JUSTNAME", ""},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := EnvList{}.Merge(tt.add...)
if !reflect.DeepEqual(tt.expected, env) {
t.Errorf("%v: expected %v, got %v", tt.add, tt.expected, env)
}
})
}
}
func TestFlagToEnv(t *testing.T) {
flags := pflag.NewFlagSet("", pflag.ContinueOnError)
flags.String("test", "ok", "")
flags.String("kube-master", "http://something", "")
flags.String("from-file", "default", "")
flags.Parse([]string{"--from-file=nondefault"})
tests := []struct {
name string
flag *pflag.Flag
prefix string
expected Env
}{
{
name: "test1",
flag: flags.Lookup("test"),
expected: Env{"TEST", "ok"},
},
{
name: "test2",
flag: flags.Lookup("kube-master"),
expected: Env{"KUBE_MASTER", "http://something"},
},
{
name: "test3",
prefix: "KUBECTL_",
flag: flags.Lookup("from-file"),
expected: Env{"KUBECTL_FROM_FILE", "nondefault"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if env := FlagToEnv(tt.flag, tt.prefix); !reflect.DeepEqual(tt.expected, env) {
t.Errorf("%v: expected %v, got %v", tt.flag.Name, tt.expected, env)
}
})
}
}
func TestPluginDescriptorEnvProvider(t *testing.T) {
tests := []struct {
name string
plugin *Plugin
expected EnvList
}{
{
name: "test1",
plugin: &Plugin{
Description: Description{
Name: "test",
ShortDesc: "Short Description",
Command: "foo --bar",
},
},
expected: EnvList{
{"KUBECTL_PLUGINS_DESCRIPTOR_NAME", "test"},
{"KUBECTL_PLUGINS_DESCRIPTOR_SHORT_DESC", "Short Description"},
{"KUBECTL_PLUGINS_DESCRIPTOR_LONG_DESC", ""},
{"KUBECTL_PLUGINS_DESCRIPTOR_EXAMPLE", ""},
{"KUBECTL_PLUGINS_DESCRIPTOR_COMMAND", "foo --bar"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider := &PluginDescriptorEnvProvider{
Plugin: tt.plugin,
}
env, _ := provider.Env()
if !reflect.DeepEqual(tt.expected, env) {
t.Errorf("%v: expected %v, got %v", tt.plugin.Name, tt.expected, env)
}
})
}
}

View File

@ -1,70 +0,0 @@
#!/usr/bin/env ruby
require 'json'
require 'date'
class Numeric
def duration
secs = self.to_int
mins = secs / 60
hours = mins / 60
days = hours / 24
if days > 0
"#{days} days and #{hours % 24} hours"
elsif hours > 0
"#{hours} hours and #{mins % 60} minutes"
elsif mins > 0
"#{mins} minutes and #{secs % 60} seconds"
elsif secs >= 0
"#{secs} seconds"
end
end
end
namespace = ENV['KUBECTL_PLUGINS_CURRENT_NAMESPACE'] || 'default'
pods_json = `kubectl --namespace #{namespace} get pods -o json`
pods_parsed = JSON.parse(pods_json)
puts "The Magnificent Aging Plugin."
data = Hash.new
max_name_length = 0
max_age = 0
min_age = 0
pods_parsed['items'].each { |pod|
name = pod['metadata']['name']
creation = pod['metadata']['creationTimestamp']
age = Time.now - DateTime.parse(creation).to_time
data[name] = age
if name.length > max_name_length
max_name_length = name.length
end
if age > max_age
max_age = age
end
if age < min_age
min_age = age
end
}
data = data.sort_by{ |name, age| age }
if data.length > 0
puts ""
data.each { |name, age|
output = ""
output += name.rjust(max_name_length, ' ') + ": "
bar_size = (age*80/max_age).ceil
bar_size.times{ output += "" }
output += " " + age.duration
puts output
puts ""
}
else
puts "No pods"
end

View File

@ -1,5 +0,0 @@
name: "aging"
shortDesc: "Aging shows pods by age"
longDesc: >
Aging shows pods from the current namespace by age.
command: ./aging.rb

View File

@ -1,3 +0,0 @@
name: "hello"
shortDesc: "I say hello!"
command: "echo Hello plugins!"

View File

@ -1,204 +0,0 @@
/*
Copyright 2017 The Kubernetes 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 plugins
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"github.com/ghodss/yaml"
"github.com/golang/glog"
"k8s.io/client-go/tools/clientcmd"
)
// PluginDescriptorFilename is the default file name for plugin descriptions.
const PluginDescriptorFilename = "plugin.yaml"
// PluginLoader is capable of loading a list of plugin descriptions.
type PluginLoader interface {
// Load loads the plugin descriptions.
Load() (Plugins, error)
}
// DirectoryPluginLoader is a PluginLoader that loads plugin descriptions
// from a given directory in the filesystem. Plugins are located in subdirs
// under the loader "root", where each subdir must contain, at least, a plugin
// descriptor file called "plugin.yaml" that translates into a PluginDescription.
type DirectoryPluginLoader struct {
Directory string
}
// Load reads the directory the loader holds and loads plugin descriptions.
func (l *DirectoryPluginLoader) Load() (Plugins, error) {
if len(l.Directory) == 0 {
return nil, fmt.Errorf("directory not specified")
}
list := Plugins{}
stat, err := os.Stat(l.Directory)
if err != nil {
return nil, err
}
if !stat.IsDir() {
return nil, fmt.Errorf("not a directory: %s", l.Directory)
}
base, err := filepath.Abs(l.Directory)
if err != nil {
return nil, err
}
// read the base directory tree searching for plugin descriptors
// fails silently (descriptors unable to be read or unmarshalled are logged but skipped)
err = filepath.Walk(base, func(path string, fileInfo os.FileInfo, walkErr error) error {
if walkErr != nil || fileInfo.IsDir() || fileInfo.Name() != PluginDescriptorFilename {
return nil
}
file, err := ioutil.ReadFile(path)
if err != nil {
glog.V(1).Infof("Unable to read plugin descriptor %s: %v", path, err)
return nil
}
plugin := &Plugin{}
if err := yaml.Unmarshal(file, plugin); err != nil {
glog.V(1).Infof("Unable to unmarshal plugin descriptor %s: %v", path, err)
return nil
}
if err := plugin.Validate(); err != nil {
glog.V(1).Infof("%v", err)
return nil
}
var setSource func(path string, fileInfo os.FileInfo, p *Plugin)
setSource = func(path string, fileInfo os.FileInfo, p *Plugin) {
p.Dir = filepath.Dir(path)
p.DescriptorName = fileInfo.Name()
for _, child := range p.Tree {
setSource(path, fileInfo, child)
}
}
setSource(path, fileInfo, plugin)
glog.V(6).Infof("Plugin loaded: %s", plugin.Name)
list = append(list, plugin)
return nil
})
return list, err
}
// UserDirPluginLoader returns a PluginLoader that loads plugins from the
// "plugins" directory under the user's kubeconfig dir (usually "~/.kube/plugins/").
func UserDirPluginLoader() PluginLoader {
dir := filepath.Join(clientcmd.RecommendedConfigDir, "plugins")
return &DirectoryPluginLoader{
Directory: dir,
}
}
// PathFromEnvVarPluginLoader returns a PluginLoader that loads plugins from one or more
// directories specified by the provided env var name. In case the env var is not
// set, the PluginLoader just loads nothing. A list of subdirectories can be provided,
// which will be appended to each path specified by the env var.
func PathFromEnvVarPluginLoader(envVarName string, subdirs ...string) PluginLoader {
env := os.Getenv(envVarName)
if len(env) == 0 {
return &DummyPluginLoader{}
}
loader := MultiPluginLoader{}
for _, path := range filepath.SplitList(env) {
dir := append([]string{path}, subdirs...)
loader = append(loader, &DirectoryPluginLoader{
Directory: filepath.Join(dir...),
})
}
return loader
}
// KubectlPluginsPathPluginLoader returns a PluginLoader that loads plugins from one or more
// directories specified by the KUBECTL_PLUGINS_PATH env var.
func KubectlPluginsPathPluginLoader() PluginLoader {
return PathFromEnvVarPluginLoader("KUBECTL_PLUGINS_PATH")
}
// XDGDataDirsPluginLoader returns a PluginLoader that loads plugins from one or more
// directories specified by the XDG system directory structure spec in the
// XDG_DATA_DIRS env var, plus the "kubectl/plugins/" suffix. According to the
// spec, if XDG_DATA_DIRS is not set it defaults to "/usr/local/share:/usr/share".
func XDGDataDirsPluginLoader() PluginLoader {
envVarName := "XDG_DATA_DIRS"
if len(os.Getenv(envVarName)) > 0 {
return PathFromEnvVarPluginLoader(envVarName, "kubectl", "plugins")
}
return TolerantMultiPluginLoader{
&DirectoryPluginLoader{
Directory: "/usr/local/share/kubectl/plugins",
},
&DirectoryPluginLoader{
Directory: "/usr/share/kubectl/plugins",
},
}
}
// MultiPluginLoader is a PluginLoader that can encapsulate multiple plugin loaders,
// a successful loading means every encapsulated loader was able to load without errors.
type MultiPluginLoader []PluginLoader
// Load calls Load() for each of the encapsulated Loaders.
func (l MultiPluginLoader) Load() (Plugins, error) {
plugins := Plugins{}
for _, loader := range l {
loaded, err := loader.Load()
if err != nil {
return nil, err
}
plugins = append(plugins, loaded...)
}
return plugins, nil
}
// TolerantMultiPluginLoader is a PluginLoader than encapsulates multiple plugins loaders,
// but is tolerant to errors while loading from them.
type TolerantMultiPluginLoader []PluginLoader
// Load calls Load() for each of the encapsulated Loaders.
func (l TolerantMultiPluginLoader) Load() (Plugins, error) {
plugins := Plugins{}
for _, loader := range l {
loaded, _ := loader.Load()
if loaded != nil {
plugins = append(plugins, loaded...)
}
}
return plugins, nil
}
// DummyPluginLoader is a noop PluginLoader.
type DummyPluginLoader struct{}
// Load loads nothing.
func (l *DummyPluginLoader) Load() (Plugins, error) {
return Plugins{}, nil
}

View File

@ -1,262 +0,0 @@
/*
Copyright 2017 The Kubernetes 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 plugins
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestSuccessfulDirectoryPluginLoader(t *testing.T) {
tmp, err := setupValidPlugins(3, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer os.RemoveAll(tmp)
loader := &DirectoryPluginLoader{
Directory: tmp,
}
plugins, err := loader.Load()
if err != nil {
t.Errorf("Unexpected error loading plugins: %v", err)
}
if count := len(plugins); count != 3 {
t.Errorf("Unexpected number of loaded plugins, wanted 3, got %d", count)
}
for _, plugin := range plugins {
if m, _ := regexp.MatchString("^plugin[123]$", plugin.Name); !m {
t.Errorf("Unexpected plugin name %s", plugin.Name)
}
if m, _ := regexp.MatchString("^The plugin[123] test plugin$", plugin.ShortDesc); !m {
t.Errorf("Unexpected plugin short desc %s", plugin.ShortDesc)
}
if m, _ := regexp.MatchString("^echo plugin[123]$", plugin.Command); !m {
t.Errorf("Unexpected plugin command %s", plugin.Command)
}
if count := len(plugin.Tree); count != 0 {
t.Errorf("Unexpected number of loaded child plugins, wanted 0, got %d", count)
}
}
}
func TestEmptyDirectoryPluginLoader(t *testing.T) {
loader := &DirectoryPluginLoader{}
_, err := loader.Load()
if err == nil {
t.Errorf("Expected error, got none")
}
if m, _ := regexp.MatchString("^directory not specified$", err.Error()); !m {
t.Errorf("Unexpected error %v", err)
}
}
func TestNotDirectoryPluginLoader(t *testing.T) {
tmp, err := ioutil.TempDir("", "")
if err != nil {
t.Fatalf("unexpected ioutil.TempDir error: %v", err)
}
defer os.RemoveAll(tmp)
file := filepath.Join(tmp, "test.tmp")
if err := ioutil.WriteFile(file, []byte("test"), 644); err != nil {
t.Fatalf("unexpected ioutil.WriteFile error: %v", err)
}
loader := &DirectoryPluginLoader{
Directory: file,
}
_, err = loader.Load()
if err == nil {
t.Errorf("Expected error, got none")
}
if !strings.Contains(err.Error(), "not a directory") {
t.Errorf("Unexpected error %v", err)
}
}
func TestUnexistentDirectoryPluginLoader(t *testing.T) {
loader := &DirectoryPluginLoader{
Directory: "/hopefully-does-not-exist",
}
_, err := loader.Load()
if err == nil {
t.Errorf("Expected error, got none")
}
if !strings.Contains(err.Error(), "no such file or directory") {
t.Errorf("Unexpected error %v", err)
}
}
func TestKubectlPluginsPathPluginLoader(t *testing.T) {
tmp, err := setupValidPlugins(1, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer os.RemoveAll(tmp)
env := "KUBECTL_PLUGINS_PATH"
os.Setenv(env, tmp)
defer os.Unsetenv(env)
loader := KubectlPluginsPathPluginLoader()
plugins, err := loader.Load()
if err != nil {
t.Errorf("Unexpected error loading plugins: %v", err)
}
if count := len(plugins); count != 1 {
t.Errorf("Unexpected number of loaded plugins, wanted 1, got %d", count)
}
plugin := plugins[0]
if "plugin1" != plugin.Name {
t.Errorf("Unexpected plugin name %s", plugin.Name)
}
if "The plugin1 test plugin" != plugin.ShortDesc {
t.Errorf("Unexpected plugin short desc %s", plugin.ShortDesc)
}
if "echo plugin1" != plugin.Command {
t.Errorf("Unexpected plugin command %s", plugin.Command)
}
}
func TestIncompletePluginDescriptor(t *testing.T) {
tmp, err := ioutil.TempDir("", "")
if err != nil {
t.Fatalf("unexpected ioutil.TempDir error: %v", err)
}
descriptor := `
name: incomplete
shortDesc: The incomplete test plugin`
if err := os.Mkdir(filepath.Join(tmp, "incomplete"), 0755); err != nil {
t.Fatalf("unexpected os.Mkdir error: %v", err)
}
if err := ioutil.WriteFile(filepath.Join(tmp, "incomplete", "plugin.yaml"), []byte(descriptor), 0644); err != nil {
t.Fatalf("unexpected ioutil.WriteFile error: %v", err)
}
defer os.RemoveAll(tmp)
loader := &DirectoryPluginLoader{
Directory: tmp,
}
plugins, err := loader.Load()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if count := len(plugins); count != 0 {
t.Errorf("Unexpected number of loaded plugins, wanted 0, got %d", count)
}
}
func TestDirectoryTreePluginLoader(t *testing.T) {
tmp, err := setupValidPlugins(1, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer os.RemoveAll(tmp)
loader := &DirectoryPluginLoader{
Directory: tmp,
}
plugins, err := loader.Load()
if err != nil {
t.Errorf("Unexpected error loading plugins: %v", err)
}
if count := len(plugins); count != 1 {
t.Errorf("Unexpected number of loaded plugins, wanted 1, got %d", count)
}
for _, plugin := range plugins {
if m, _ := regexp.MatchString("^plugin1$", plugin.Name); !m {
t.Errorf("Unexpected plugin name %s", plugin.Name)
}
if m, _ := regexp.MatchString("^The plugin1 test plugin$", plugin.ShortDesc); !m {
t.Errorf("Unexpected plugin short desc %s", plugin.ShortDesc)
}
if m, _ := regexp.MatchString("^echo plugin1$", plugin.Command); !m {
t.Errorf("Unexpected plugin command %s", plugin.Command)
}
if count := len(plugin.Tree); count != 2 {
t.Errorf("Unexpected number of loaded child plugins, wanted 2, got %d", count)
}
for _, child := range plugin.Tree {
if m, _ := regexp.MatchString("^child[12]$", child.Name); !m {
t.Errorf("Unexpected plugin child name %s", child.Name)
}
if m, _ := regexp.MatchString("^The child[12] test plugin child of plugin1 of House Targaryen$", child.ShortDesc); !m {
t.Errorf("Unexpected plugin child short desc %s", child.ShortDesc)
}
if m, _ := regexp.MatchString("^echo child[12]$", child.Command); !m {
t.Errorf("Unexpected plugin child command %s", child.Command)
}
}
}
}
func setupValidPlugins(nPlugins, nChildren int) (string, error) {
tmp, err := ioutil.TempDir("", "")
if err != nil {
return "", fmt.Errorf("unexpected ioutil.TempDir error: %v", err)
}
for i := 1; i <= nPlugins; i++ {
name := fmt.Sprintf("plugin%d", i)
descriptor := fmt.Sprintf(`
name: %[1]s
shortDesc: The %[1]s test plugin
command: echo %[1]s
flags:
- name: %[1]s-flag
desc: A flag for %[1]s`, name)
if nChildren > 0 {
descriptor += `
tree:`
}
for j := 1; j <= nChildren; j++ {
child := fmt.Sprintf("child%d", i)
descriptor += fmt.Sprintf(`
- name: %[1]s
shortDesc: The %[1]s test plugin child of %[2]s of House Targaryen
command: echo %[1]s`, child, name)
}
if err := os.Mkdir(filepath.Join(tmp, name), 0755); err != nil {
return "", fmt.Errorf("unexpected os.Mkdir error: %v", err)
}
if err := ioutil.WriteFile(filepath.Join(tmp, name, "plugin.yaml"), []byte(descriptor), 0644); err != nil {
return "", fmt.Errorf("unexpected ioutil.WriteFile error: %v", err)
}
}
return tmp, nil
}

View File

@ -1,123 +0,0 @@
/*
Copyright 2017 The Kubernetes 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 plugins
import (
"fmt"
"strings"
"unicode"
)
var (
// ErrIncompletePlugin indicates plugin is incomplete.
ErrIncompletePlugin = fmt.Errorf("incomplete plugin descriptor: name, shortDesc and command fields are required")
// ErrInvalidPluginName indicates plugin name is invalid.
ErrInvalidPluginName = fmt.Errorf("plugin name can't contain spaces")
// ErrIncompleteFlag indicates flag is incomplete.
ErrIncompleteFlag = fmt.Errorf("incomplete flag descriptor: name and desc fields are required")
// ErrInvalidFlagName indicates flag name is invalid.
ErrInvalidFlagName = fmt.Errorf("flag name can't contain spaces")
// ErrInvalidFlagShorthand indicates flag shorthand is invalid.
ErrInvalidFlagShorthand = fmt.Errorf("flag shorthand must be only one letter")
)
// Plugin is the representation of a CLI extension (plugin).
type Plugin struct {
Description
Source
Context RunningContext `json:"-"`
}
// Description holds everything needed to register a
// plugin as a command. Usually comes from a descriptor file.
type Description struct {
Name string `json:"name"`
ShortDesc string `json:"shortDesc"`
LongDesc string `json:"longDesc,omitempty"`
Example string `json:"example,omitempty"`
Command string `json:"command"`
Flags []Flag `json:"flags,omitempty"`
Tree Plugins `json:"tree,omitempty"`
}
// Source holds the location of a given plugin in the filesystem.
type Source struct {
Dir string `json:"-"`
DescriptorName string `json:"-"`
}
// Validate validates plugin data.
func (p Plugin) Validate() error {
if len(p.Name) == 0 || len(p.ShortDesc) == 0 || (len(p.Command) == 0 && len(p.Tree) == 0) {
return ErrIncompletePlugin
}
if strings.Contains(p.Name, " ") {
return ErrInvalidPluginName
}
for _, flag := range p.Flags {
if err := flag.Validate(); err != nil {
return err
}
}
for _, child := range p.Tree {
if err := child.Validate(); err != nil {
return err
}
}
return nil
}
// IsValid returns true if plugin data is valid.
func (p Plugin) IsValid() bool {
return p.Validate() == nil
}
// Plugins is a list of plugins.
type Plugins []*Plugin
// Flag describes a single flag supported by a given plugin.
type Flag struct {
Name string `json:"name"`
Shorthand string `json:"shorthand,omitempty"`
Desc string `json:"desc"`
DefValue string `json:"defValue,omitempty"`
}
// Validate validates flag data.
func (f Flag) Validate() error {
if len(f.Name) == 0 || len(f.Desc) == 0 {
return ErrIncompleteFlag
}
if strings.Contains(f.Name, " ") {
return ErrInvalidFlagName
}
return f.ValidateShorthand()
}
// ValidateShorthand validates flag shorthand data.
func (f Flag) ValidateShorthand() error {
length := len(f.Shorthand)
if length == 0 || (length == 1 && unicode.IsLetter(rune(f.Shorthand[0]))) {
return nil
}
return ErrInvalidFlagShorthand
}
// Shorthanded returns true if flag shorthand data is valid.
func (f Flag) Shorthanded() bool {
return f.ValidateShorthand() == nil
}

View File

@ -1,181 +0,0 @@
/*
Copyright 2017 The Kubernetes 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 plugins
import "testing"
func TestPlugin(t *testing.T) {
tests := []struct {
name string
plugin *Plugin
expectedErr error
}{
{
name: "test1",
plugin: &Plugin{
Description: Description{
Name: "test",
ShortDesc: "The test",
Command: "echo 1",
},
},
},
{
name: "test2",
plugin: &Plugin{
Description: Description{
Name: "test",
ShortDesc: "The test",
},
},
expectedErr: ErrIncompletePlugin,
},
{
name: "test3",
plugin: &Plugin{},
expectedErr: ErrIncompletePlugin,
},
{
name: "test4",
plugin: &Plugin{
Description: Description{
Name: "test spaces",
ShortDesc: "The test",
Command: "echo 1",
},
},
expectedErr: ErrInvalidPluginName,
},
{
name: "test5",
plugin: &Plugin{
Description: Description{
Name: "test",
ShortDesc: "The test",
Command: "echo 1",
Flags: []Flag{
{
Name: "aflag",
},
},
},
},
expectedErr: ErrIncompleteFlag,
},
{
name: "test6",
plugin: &Plugin{
Description: Description{
Name: "test",
ShortDesc: "The test",
Command: "echo 1",
Flags: []Flag{
{
Name: "a flag",
Desc: "Invalid flag",
},
},
},
},
expectedErr: ErrInvalidFlagName,
},
{
name: "test7",
plugin: &Plugin{
Description: Description{
Name: "test",
ShortDesc: "The test",
Command: "echo 1",
Flags: []Flag{
{
Name: "aflag",
Desc: "Invalid shorthand",
Shorthand: "aa",
},
},
},
},
expectedErr: ErrInvalidFlagShorthand,
},
{
name: "test8",
plugin: &Plugin{
Description: Description{
Name: "test",
ShortDesc: "The test",
Command: "echo 1",
Flags: []Flag{
{
Name: "aflag",
Desc: "Invalid shorthand",
Shorthand: "2",
},
},
},
},
expectedErr: ErrInvalidFlagShorthand,
},
{
name: "test9",
plugin: &Plugin{
Description: Description{
Name: "test",
ShortDesc: "The test",
Command: "echo 1",
Flags: []Flag{
{
Name: "aflag",
Desc: "A flag",
Shorthand: "a",
},
},
Tree: Plugins{
&Plugin{
Description: Description{
Name: "child",
ShortDesc: "The child",
LongDesc: "The child long desc",
Example: "You can use it like this but you're not supposed to",
Command: "echo 1",
Flags: []Flag{
{
Name: "childflag",
Desc: "A child flag",
},
{
Name: "childshorthand",
Desc: "A child shorthand flag",
Shorthand: "s",
},
},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.plugin.Validate()
if err != tt.expectedErr {
t.Errorf("%s: expected error %v, got %v", tt.plugin.Name, tt.expectedErr, err)
}
})
}
}

View File

@ -1,72 +0,0 @@
/*
Copyright 2017 The Kubernetes 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 plugins
import (
"os"
"os/exec"
"strings"
"github.com/golang/glog"
"k8s.io/kubernetes/pkg/kubectl/genericclioptions"
)
// PluginRunner is capable of running a plugin in a given running context.
type PluginRunner interface {
Run(plugin *Plugin, ctx RunningContext) error
}
// RunningContext holds the context in which a given plugin is running - the
// in, out, and err streams, arguments and environment passed to it, and the
// working directory.
type RunningContext struct {
genericclioptions.IOStreams
Args []string
EnvProvider EnvProvider
WorkingDir string
}
// ExecPluginRunner is a PluginRunner that uses Go's os/exec to run plugins.
type ExecPluginRunner struct{}
// Run takes a given plugin and runs it in a given context using os/exec, returning
// any error found while running.
func (r *ExecPluginRunner) Run(plugin *Plugin, ctx RunningContext) error {
command := strings.Split(os.ExpandEnv(plugin.Command), " ")
base := command[0]
args := []string{}
if len(command) > 1 {
args = command[1:]
}
args = append(args, ctx.Args...)
cmd := exec.Command(base, args...)
cmd.Stdin = ctx.In
cmd.Stdout = ctx.Out
cmd.Stderr = ctx.ErrOut
env, err := ctx.EnvProvider.Env()
if err != nil {
return err
}
cmd.Env = env.Slice()
cmd.Dir = ctx.WorkingDir
glog.V(9).Infof("Running plugin %q as base command %q with args %v", plugin.Name, base, args)
return cmd.Run()
}

View File

@ -1,83 +0,0 @@
/*
Copyright 2017 The Kubernetes 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 plugins
import (
"os"
"testing"
"k8s.io/kubernetes/pkg/kubectl/genericclioptions"
)
func TestExecRunner(t *testing.T) {
tests := []struct {
name string
command string
expectedMsg string
expectedErr string
}{
{
name: "success",
command: "echo test ok",
expectedMsg: "test ok\n",
},
{
name: "invalid",
command: "false",
expectedErr: "exit status 1",
},
{
name: "env",
command: "echo $KUBECTL_PLUGINS_TEST",
expectedMsg: "ok\n",
},
}
os.Setenv("KUBECTL_PLUGINS_TEST", "ok")
defer os.Unsetenv("KUBECTL_PLUGINS_TEST")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
streams, _, outBuf, _ := genericclioptions.NewTestIOStreams()
plugin := &Plugin{
Description: Description{
Name: tt.name,
ShortDesc: "Test Runner Plugin",
Command: tt.command,
},
}
ctx := RunningContext{
IOStreams: streams,
WorkingDir: ".",
EnvProvider: &EmptyEnvProvider{},
}
runner := &ExecPluginRunner{}
err := runner.Run(plugin, ctx)
if outBuf.String() != tt.expectedMsg {
t.Errorf("%s: unexpected output: %q", tt.name, outBuf.String())
}
if err != nil && err.Error() != tt.expectedErr {
t.Errorf("%s: unexpected err output: %v", tt.name, err)
}
})
}
}

View File

@ -24,81 +24,30 @@ run_plugins_tests() {
kube::log::status "Testing kubectl plugins"
# top-level plugin command
output_message=$(KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins kubectl -h 2>&1)
kube::test::if_has_string "${output_message}" 'plugin\s\+Runs a command-line plugin'
# test plugins that overwrite existing kubectl commands
output_message=$(! PATH=${PATH}:"test/fixtures/pkg/kubectl/plugins/version" kubectl plugin list 2>&1)
kube::test::if_has_string "${output_message}" 'kubectl-version overwrites existing command: "kubectl version"'
# test plugins that overwrite similarly-named plugins
output_message=$(! PATH=${PATH}:"test/fixtures/pkg/kubectl/plugins:test/fixtures/pkg/kubectl/plugins/foo" kubectl plugin list 2>&1)
kube::test::if_has_string "${output_message}" 'test/fixtures/pkg/kubectl/plugins/foo/kubectl-foo is overshadowed by a similarly named plugin'
# test plugins with no warnings
output_message=$(PATH=${PATH}:"test/fixtures/pkg/kubectl/plugins" kubectl plugin list 2>&1)
kube::test::if_has_string "${output_message}" 'plugins are available'
# no plugins
output_message=$(! kubectl plugin 2>&1)
kube::test::if_has_string "${output_message}" 'no plugins installed'
output_message=$(! PATH=${PATH}:"test/fixtures/pkg/kubectl/plugins/empty" kubectl plugin list 2>&1)
kube::test::if_has_string "${output_message}" 'unable to find any kubectl plugins in your PATH'
# single plugins path
output_message=$(! KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins kubectl plugin 2>&1)
kube::test::if_has_string "${output_message}" 'echo\s\+Echoes for test-cmd'
kube::test::if_has_string "${output_message}" 'get\s\+The wonderful new plugin-based get!'
kube::test::if_has_string "${output_message}" 'error\s\+The tremendous plugin that always fails!'
kube::test::if_has_not_string "${output_message}" 'The hello plugin'
kube::test::if_has_not_string "${output_message}" 'Incomplete plugin'
kube::test::if_has_not_string "${output_message}" 'no plugins installed'
# attempt to run a plugin in the user's PATH
output_message=$(PATH=${PATH}:"test/fixtures/pkg/kubectl/plugins" kubectl foo)
kube::test::if_has_string "${output_message}" 'plugin foo'
# multiple plugins path
output_message=$(KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins/:test/fixtures/pkg/kubectl/plugins2/ kubectl plugin -h 2>&1)
kube::test::if_has_string "${output_message}" 'echo\s\+Echoes for test-cmd'
kube::test::if_has_string "${output_message}" 'get\s\+The wonderful new plugin-based get!'
kube::test::if_has_string "${output_message}" 'error\s\+The tremendous plugin that always fails!'
kube::test::if_has_string "${output_message}" 'hello\s\+The hello plugin'
kube::test::if_has_not_string "${output_message}" 'Incomplete plugin'
# don't override existing commands
output_message=$(KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins/:test/fixtures/pkg/kubectl/plugins2/ kubectl get -h 2>&1)
kube::test::if_has_string "${output_message}" 'Display one or many resources'
kube::test::if_has_not_string "$output_message{output_message}" 'The wonderful new plugin-based get'
# plugin help
output_message=$(KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins/:test/fixtures/pkg/kubectl/plugins2/ kubectl plugin hello -h 2>&1)
kube::test::if_has_string "${output_message}" 'The hello plugin is a new plugin used by test-cmd to test multiple plugin locations.'
kube::test::if_has_string "${output_message}" 'Usage:'
# run plugin
output_message=$(KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins/:test/fixtures/pkg/kubectl/plugins2/ kubectl plugin hello 2>&1)
kube::test::if_has_string "${output_message}" '#hello#'
output_message=$(KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins/:test/fixtures/pkg/kubectl/plugins2/ kubectl plugin echo 2>&1)
kube::test::if_has_string "${output_message}" 'This plugin works!'
output_message=$(! KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins/ kubectl plugin hello 2>&1)
kube::test::if_has_string "${output_message}" 'unknown command'
output_message=$(! KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins/ kubectl plugin error 2>&1)
kube::test::if_has_string "${output_message}" 'error: exit status 1'
# plugin tree
output_message=$(! KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins kubectl plugin tree 2>&1)
kube::test::if_has_string "${output_message}" 'Plugin with a tree of commands'
kube::test::if_has_string "${output_message}" 'child1\s\+The first child of a tree'
kube::test::if_has_string "${output_message}" 'child2\s\+The second child of a tree'
kube::test::if_has_string "${output_message}" 'child3\s\+The third child of a tree'
output_message=$(KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins kubectl plugin tree child1 --help 2>&1)
kube::test::if_has_string "${output_message}" 'The first child of a tree'
kube::test::if_has_not_string "${output_message}" 'The second child'
kube::test::if_has_not_string "${output_message}" 'child2'
output_message=$(KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins kubectl plugin tree child1 2>&1)
kube::test::if_has_string "${output_message}" 'child one'
kube::test::if_has_not_string "${output_message}" 'child1'
kube::test::if_has_not_string "${output_message}" 'The first child'
# plugin env
output_message=$(KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins kubectl plugin env -h 2>&1)
kube::test::if_has_string "${output_message}" "This is a flag 1"
kube::test::if_has_string "${output_message}" "This is a flag 2"
kube::test::if_has_string "${output_message}" "This is a flag 3"
output_message=$(KUBECTL_PLUGINS_PATH=test/fixtures/pkg/kubectl/plugins kubectl plugin env --test1=value1 -t value2 2>&1)
kube::test::if_has_string "${output_message}" 'KUBECTL_PLUGINS_CURRENT_NAMESPACE'
kube::test::if_has_string "${output_message}" 'KUBECTL_PLUGINS_CALLER'
kube::test::if_has_string "${output_message}" 'KUBECTL_PLUGINS_DESCRIPTOR_COMMAND=./env.sh'
kube::test::if_has_string "${output_message}" 'KUBECTL_PLUGINS_DESCRIPTOR_SHORT_DESC=The plugin envs plugin'
kube::test::if_has_string "${output_message}" 'KUBECTL_PLUGINS_GLOBAL_FLAG_KUBECONFIG'
kube::test::if_has_string "${output_message}" 'KUBECTL_PLUGINS_GLOBAL_FLAG_REQUEST_TIMEOUT=0'
kube::test::if_has_string "${output_message}" 'KUBECTL_PLUGINS_LOCAL_FLAG_TEST1=value1'
kube::test::if_has_string "${output_message}" 'KUBECTL_PLUGINS_LOCAL_FLAG_TEST2=value2'
kube::test::if_has_string "${output_message}" 'KUBECTL_PLUGINS_LOCAL_FLAG_TEST3=default'
# ensure that a kubectl command supersedes a plugin that overshadows it
output_message=$(PATH=${PATH}:"test/fixtures/pkg/kubectl/plugins/version" kubectl version)
kube::test::if_has_string "${output_message}" 'Client Version'
kube::test::if_has_not_string "${output_message}" 'overshadows an existing plugin'
set +o nounset
set +o errexit

View File

@ -1,4 +0,0 @@
name: "echo"
shortDesc: "Echoes for test-cmd"
longDesc: "Long description for the test-cmd echo plugin"
command: "echo This plugin works!"

View File

@ -1,17 +0,0 @@
#!/usr/bin/env bash
# Copyright 2017 The Kubernetes 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.
env | grep 'KUBECTL_PLUGINS' | sort

View File

@ -1,12 +0,0 @@
name: env
shortDesc: "The plugin envs plugin"
command: "./env.sh"
flags:
- name: "test1"
desc: "This is a flag 1"
- name: "test2"
desc: "This is a flag 2"
shorthand: "t"
- name: "test3"
desc: "This is a flag 3"
defValue: "default"

View File

@ -1,3 +0,0 @@
name: "error"
shortDesc: "The tremendous plugin that always fails!"
command: "false"

View File

@ -0,0 +1,3 @@
#!/bin/bash
echo "I am plugin foo"

View File

@ -1,3 +0,0 @@
name: "get"
shortDesc: "The wonderful new plugin-based get!"
command: "echo new-get"

View File

@ -1,2 +0,0 @@
name: "incomplete"
shortDesc: "Incomplete plugin"

View File

@ -0,0 +1,3 @@
#!/bin/bash
echo "I am plugin foo"

View File

@ -1,13 +0,0 @@
name: "tree"
shortDesc: "Plugin with a tree of commands"
tree:
- name: "child1"
shortDesc: "The first child of a tree"
command: echo child one
- name: "child2"
shortDesc: "The second child of a tree"
command: echo child two
- name: "child3"
shortDesc: "The third child of a tree"
command: echo child three

View File

@ -0,0 +1,4 @@
#!/bin/bash
# This plugin is a no-op and is used to test
# a plugin that overshadows an existing plugin

View File

@ -1,19 +0,0 @@
#!/usr/bin/env bash
# Copyright 2017 The Kubernetes 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.
echo "#######"
echo "#hello#"
echo "#######"

View File

@ -1,7 +0,0 @@
name: hello
shortDesc: "The hello plugin"
longDesc: >
The hello plugin is a new
plugin used by test-cmd
to test multiple plugin locations.
command: ./hello.sh