Merge pull request #37499 from fabianofranz/kubectl_plugins

Automatic merge from submit-queue

kubectl binary plugins

**What this PR does / why we need it**:

Introduces the ability to extend `kubectl` by adding third-party plugins that will be exposed through `kubectl`.

Plugins are executable commands written in any language. To be included as a plugin, a binary or script file has to

1. be located under one of the supported plugin path locations:
1.1 `~/.kubectl/plugins` dir
1.2. one or more directory set in the `KUBECTL_PLUGINS_PATH` env var
1.3. the `kubectl/plugins` dir under one or more directory set in the `XDG_DATA_DIRS` env var, which defaults to `/usr/local/share:/usr/share`
2. in any of the plugin path above, have a subfolder with the plugin file(s)
3. in the subfolder, contain at least a `plugin.yaml` file that describes the plugin

Example:

```
$ cat ~/.kube/plugins/myplugin/plugin.yaml
name: "myplugin"
shortDesc: "My plugin's short description"
command: "echo Hello plugins!"

$ kubectl myplugin
Hello plugins!
```

~~In case the plugin declares `tunnel: true`, the plugin engine will pass the `KUBECTL_PLUGIN_API_HOST` env var when calling the plugin binary. Plugins can then access the Kube REST API in "http://$KUBECTL_PLUGIN_API_HOST/api" using the same context currently in use by `kubectl`.~~

Test plugins are provided in `pkg/kubectl/plugins/examples`. Just copy (or symlink) the files to `~/.kube/plugins` to test.

**Which issue this PR fixes**:

Related to the discussions in the proposal document: https://github.com/kubernetes/kubernetes/pull/30086 and https://github.com/kubernetes/community/pull/122.

**Release note**:
```release-note
Introduces the ability to extend kubectl by adding third-party plugins. Developer preview, please refer to the documentation for instructions about how to use it.
```
pull/6/head
Kubernetes Submit Queue 2017-04-28 12:23:59 -07:00 committed by GitHub
commit d4ece0abc3
33 changed files with 1146 additions and 2 deletions

View File

@ -73,6 +73,7 @@ docs/man/man1/kubectl-label.1
docs/man/man1/kubectl-logs.1 docs/man/man1/kubectl-logs.1
docs/man/man1/kubectl-options.1 docs/man/man1/kubectl-options.1
docs/man/man1/kubectl-patch.1 docs/man/man1/kubectl-patch.1
docs/man/man1/kubectl-plugin.1
docs/man/man1/kubectl-port-forward.1 docs/man/man1/kubectl-port-forward.1
docs/man/man1/kubectl-proxy.1 docs/man/man1/kubectl-proxy.1
docs/man/man1/kubectl-replace.1 docs/man/man1/kubectl-replace.1
@ -162,6 +163,7 @@ docs/user-guide/kubectl/kubectl_label.md
docs/user-guide/kubectl/kubectl_logs.md docs/user-guide/kubectl/kubectl_logs.md
docs/user-guide/kubectl/kubectl_options.md docs/user-guide/kubectl/kubectl_options.md
docs/user-guide/kubectl/kubectl_patch.md docs/user-guide/kubectl/kubectl_patch.md
docs/user-guide/kubectl/kubectl_plugin.md
docs/user-guide/kubectl/kubectl_port-forward.md docs/user-guide/kubectl/kubectl_port-forward.md
docs/user-guide/kubectl/kubectl_proxy.md docs/user-guide/kubectl/kubectl_proxy.md
docs/user-guide/kubectl/kubectl_replace.md docs/user-guide/kubectl/kubectl_replace.md
@ -211,6 +213,7 @@ docs/yaml/kubectl/kubectl_label.yaml
docs/yaml/kubectl/kubectl_logs.yaml docs/yaml/kubectl/kubectl_logs.yaml
docs/yaml/kubectl/kubectl_options.yaml docs/yaml/kubectl/kubectl_options.yaml
docs/yaml/kubectl/kubectl_patch.yaml docs/yaml/kubectl/kubectl_patch.yaml
docs/yaml/kubectl/kubectl_plugin.yaml
docs/yaml/kubectl/kubectl_port-forward.yaml docs/yaml/kubectl/kubectl_port-forward.yaml
docs/yaml/kubectl/kubectl_proxy.yaml docs/yaml/kubectl/kubectl_proxy.yaml
docs/yaml/kubectl/kubectl_replace.yaml docs/yaml/kubectl/kubectl_replace.yaml

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

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

@ -3679,5 +3679,55 @@ __EOF__
kube::test::get_object_assert csr "{{range.items}}{{$id_field}}{{end}}" '' kube::test::get_object_assert csr "{{range.items}}{{$id_field}}{{end}}" ''
fi fi
###########
# Plugins #
###########
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'
# no plugins
output_message=$(! kubectl plugin 2>&1)
kube::test::if_has_string "${output_message}" 'no plugins installed'
# 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'
# 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'
kube::test::clear_all kube::test::clear_all
} }

View File

@ -190,6 +190,7 @@ filegroup(
":package-srcs", ":package-srcs",
"//pkg/kubectl/cmd:all-srcs", "//pkg/kubectl/cmd:all-srcs",
"//pkg/kubectl/metricsutil:all-srcs", "//pkg/kubectl/metricsutil:all-srcs",
"//pkg/kubectl/plugins:all-srcs",
"//pkg/kubectl/resource:all-srcs", "//pkg/kubectl/resource:all-srcs",
"//pkg/kubectl/testing:all-srcs", "//pkg/kubectl/testing:all-srcs",
], ],

View File

@ -51,6 +51,7 @@ go_library(
"logs.go", "logs.go",
"options.go", "options.go",
"patch.go", "patch.go",
"plugin.go",
"portforward.go", "portforward.go",
"proxy.go", "proxy.go",
"replace.go", "replace.go",
@ -93,6 +94,7 @@ go_library(
"//pkg/kubectl/cmd/util:go_default_library", "//pkg/kubectl/cmd/util:go_default_library",
"//pkg/kubectl/cmd/util/editor:go_default_library", "//pkg/kubectl/cmd/util/editor:go_default_library",
"//pkg/kubectl/metricsutil:go_default_library", "//pkg/kubectl/metricsutil:go_default_library",
"//pkg/kubectl/plugins:go_default_library",
"//pkg/kubectl/resource:go_default_library", "//pkg/kubectl/resource:go_default_library",
"//pkg/kubelet/types:go_default_library", "//pkg/kubelet/types:go_default_library",
"//pkg/printers:go_default_library", "//pkg/printers:go_default_library",
@ -173,6 +175,7 @@ go_test(
"label_test.go", "label_test.go",
"logs_test.go", "logs_test.go",
"patch_test.go", "patch_test.go",
"plugin_test.go",
"portforward_test.go", "portforward_test.go",
"replace_test.go", "replace_test.go",
"rollingupdate_test.go", "rollingupdate_test.go",
@ -207,6 +210,7 @@ go_test(
"//pkg/kubectl:go_default_library", "//pkg/kubectl:go_default_library",
"//pkg/kubectl/cmd/testing:go_default_library", "//pkg/kubectl/cmd/testing:go_default_library",
"//pkg/kubectl/cmd/util:go_default_library", "//pkg/kubectl/cmd/util:go_default_library",
"//pkg/kubectl/plugins:go_default_library",
"//pkg/kubectl/resource:go_default_library", "//pkg/kubectl/resource:go_default_library",
"//pkg/printers:go_default_library", "//pkg/printers:go_default_library",
"//pkg/printers/internalversion:go_default_library", "//pkg/printers/internalversion:go_default_library",

View File

@ -368,6 +368,7 @@ func NewKubectlCommand(f cmdutil.Factory, in io.Reader, out, err io.Writer) *cob
} }
cmds.AddCommand(cmdconfig.NewCmdConfig(clientcmd.NewDefaultPathOptions(), out, err)) cmds.AddCommand(cmdconfig.NewCmdConfig(clientcmd.NewDefaultPathOptions(), out, err))
cmds.AddCommand(NewCmdPlugin(f, in, out, err))
cmds.AddCommand(NewCmdVersion(f, out)) cmds.AddCommand(NewCmdVersion(f, out))
cmds.AddCommand(NewCmdApiVersions(f, out)) cmds.AddCommand(NewCmdApiVersions(f, out))
cmds.AddCommand(NewCmdOptions()) cmds.AddCommand(NewCmdOptions())

96
pkg/kubectl/cmd/plugin.go Normal file
View File

@ -0,0 +1,96 @@
/*
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"
"io"
"os"
"github.com/golang/glog"
"github.com/spf13/cobra"
"k8s.io/kubernetes/pkg/kubectl/cmd/templates"
cmdutil "k8s.io/kubernetes/pkg/kubectl/cmd/util"
"k8s.io/kubernetes/pkg/kubectl/plugins"
"k8s.io/kubernetes/pkg/util/i18n"
)
var (
plugin_long = templates.LongDesc(`
Runs a command-line plugin.
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.`)
)
// NewCmdPlugin creates the command that is the top-level for plugin commands.
func NewCmdPlugin(f cmdutil.Factory, in io.Reader, out, err io.Writer) *cobra.Command {
// Loads plugins and create commands for each plugin identified
loadedPlugins, loadErr := f.PluginLoader().Load()
if loadErr != nil {
glog.V(1).Infof("Unable to load plugins: %v", loadErr)
}
cmd := &cobra.Command{
Use: "plugin NAME",
Short: i18n.T("Runs a command-line plugin"),
Long: plugin_long,
Run: func(cmd *cobra.Command, args []string) {
if len(loadedPlugins) == 0 {
cmdutil.CheckErr(fmt.Errorf("no plugins installed."))
}
cmdutil.DefaultSubCommandRun(err)(cmd, args)
},
}
if len(loadedPlugins) > 0 {
pluginRunner := f.PluginRunner()
for _, p := range loadedPlugins {
cmd.AddCommand(NewCmdForPlugin(p, pluginRunner, in, out, err))
}
}
return cmd
}
// NewCmdForPlugin creates a command capable of running the provided plugin.
func NewCmdForPlugin(plugin *plugins.Plugin, runner plugins.PluginRunner, in io.Reader, out, errout io.Writer) *cobra.Command {
if !plugin.IsValid() {
return nil
}
return &cobra.Command{
Use: plugin.Name,
Short: plugin.ShortDesc,
Long: templates.LongDesc(plugin.LongDesc),
Example: templates.Examples(plugin.Example),
Run: func(cmd *cobra.Command, args []string) {
ctx := plugins.RunningContext{
In: in,
Out: out,
ErrOut: errout,
Args: args,
Env: os.Environ(),
WorkingDir: plugin.Dir,
}
if err := runner.Run(plugin, ctx); err != nil {
cmdutil.CheckErr(err)
}
},
}
}

View File

@ -0,0 +1,111 @@
/*
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 (
"bytes"
"fmt"
"testing"
cmdutil "k8s.io/kubernetes/pkg/kubectl/cmd/util"
"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 {
inBuf := bytes.NewBuffer([]byte{})
outBuf := bytes.NewBuffer([]byte{})
errBuf := bytes.NewBuffer([]byte{})
cmdutil.BehaviorOnFatal(func(str string, code int) {
errBuf.Write([]byte(str))
})
runner := &mockPluginRunner{
success: test.expectedSuccess,
}
cmd := NewCmdForPlugin(test.plugin, runner, inBuf, outBuf, errBuf)
if cmd == nil {
if !test.expectedNilCmd {
t.Fatalf("%s: command was unexpectedly not registered", test.name)
}
continue
}
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

@ -28,11 +28,17 @@ const Indentation = ` `
// LongDesc normalizes a command's long description to follow the conventions. // LongDesc normalizes a command's long description to follow the conventions.
func LongDesc(s string) string { func LongDesc(s string) string {
if len(s) == 0 {
return s
}
return normalizer{s}.heredoc().markdown().trim().string return normalizer{s}.heredoc().markdown().trim().string
} }
// Examples normalizes a command's examples to follow the conventions. // Examples normalizes a command's examples to follow the conventions.
func Examples(s string) string { func Examples(s string) string {
if len(s) == 0 {
return s
}
return normalizer{s}.trim().indent().string return normalizer{s}.trim().indent().string
} }

View File

@ -20,6 +20,7 @@ go_library(
"//pkg/kubectl:go_default_library", "//pkg/kubectl:go_default_library",
"//pkg/kubectl/cmd/util:go_default_library", "//pkg/kubectl/cmd/util:go_default_library",
"//pkg/kubectl/cmd/util/openapi:go_default_library", "//pkg/kubectl/cmd/util/openapi:go_default_library",
"//pkg/kubectl/plugins:go_default_library",
"//pkg/kubectl/resource:go_default_library", "//pkg/kubectl/resource:go_default_library",
"//pkg/printers:go_default_library", "//pkg/printers:go_default_library",
"//vendor/github.com/emicklei/go-restful/swagger:go_default_library", "//vendor/github.com/emicklei/go-restful/swagger:go_default_library",

View File

@ -43,6 +43,7 @@ import (
"k8s.io/kubernetes/pkg/kubectl" "k8s.io/kubernetes/pkg/kubectl"
cmdutil "k8s.io/kubernetes/pkg/kubectl/cmd/util" cmdutil "k8s.io/kubernetes/pkg/kubectl/cmd/util"
"k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi" "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi"
"k8s.io/kubernetes/pkg/kubectl/plugins"
"k8s.io/kubernetes/pkg/kubectl/resource" "k8s.io/kubernetes/pkg/kubectl/resource"
"k8s.io/kubernetes/pkg/printers" "k8s.io/kubernetes/pkg/printers"
) )
@ -481,6 +482,14 @@ func (f *FakeFactory) SuggestedPodTemplateResources() []schema.GroupResource {
return []schema.GroupResource{} return []schema.GroupResource{}
} }
func (f *FakeFactory) PluginLoader() plugins.PluginLoader {
return &plugins.DummyPluginLoader{}
}
func (f *FakeFactory) PluginRunner() plugins.PluginRunner {
return &plugins.ExecPluginRunner{}
}
type fakeMixedFactory struct { type fakeMixedFactory struct {
cmdutil.Factory cmdutil.Factory
tf *TestFactory tf *TestFactory

View File

@ -38,6 +38,7 @@ go_library(
"//pkg/controller:go_default_library", "//pkg/controller:go_default_library",
"//pkg/kubectl:go_default_library", "//pkg/kubectl:go_default_library",
"//pkg/kubectl/cmd/util/openapi:go_default_library", "//pkg/kubectl/cmd/util/openapi:go_default_library",
"//pkg/kubectl/plugins:go_default_library",
"//pkg/kubectl/resource:go_default_library", "//pkg/kubectl/resource:go_default_library",
"//pkg/printers:go_default_library", "//pkg/printers:go_default_library",
"//pkg/printers/internalversion:go_default_library", "//pkg/printers/internalversion:go_default_library",

View File

@ -52,6 +52,7 @@ import (
coreclient "k8s.io/kubernetes/pkg/client/clientset_generated/internalclientset/typed/core/internalversion" coreclient "k8s.io/kubernetes/pkg/client/clientset_generated/internalclientset/typed/core/internalversion"
"k8s.io/kubernetes/pkg/kubectl" "k8s.io/kubernetes/pkg/kubectl"
"k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi" "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi"
"k8s.io/kubernetes/pkg/kubectl/plugins"
"k8s.io/kubernetes/pkg/kubectl/resource" "k8s.io/kubernetes/pkg/kubectl/resource"
"k8s.io/kubernetes/pkg/printers" "k8s.io/kubernetes/pkg/printers"
) )
@ -240,6 +241,10 @@ type BuilderFactory interface {
PrintObject(cmd *cobra.Command, mapper meta.RESTMapper, obj runtime.Object, out io.Writer) error PrintObject(cmd *cobra.Command, mapper meta.RESTMapper, obj runtime.Object, out io.Writer) error
// One stop shopping for a Builder // One stop shopping for a Builder
NewBuilder() *resource.Builder NewBuilder() *resource.Builder
// PluginLoader provides the implementation to be used to load cli plugins.
PluginLoader() plugins.PluginLoader
// PluginRunner provides the implementation to be used to run cli plugins.
PluginRunner() plugins.PluginRunner
} }
func getGroupVersionKinds(gvks []schema.GroupVersionKind, group string) []schema.GroupVersionKind { func getGroupVersionKinds(gvks []schema.GroupVersionKind, group string) []schema.GroupVersionKind {

View File

@ -21,12 +21,14 @@ package util
import ( import (
"fmt" "fmt"
"io" "io"
"os"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/kubernetes/pkg/kubectl/plugins"
"k8s.io/kubernetes/pkg/kubectl/resource" "k8s.io/kubernetes/pkg/kubectl/resource"
"k8s.io/kubernetes/pkg/printers" "k8s.io/kubernetes/pkg/printers"
) )
@ -130,3 +132,22 @@ func (f *ring2Factory) NewBuilder() *resource.Builder {
return resource.NewBuilder(mapper, categoryExpander, typer, resource.ClientMapperFunc(f.objectMappingFactory.ClientForMapping), f.clientAccessFactory.Decoder(true)) return resource.NewBuilder(mapper, categoryExpander, typer, resource.ClientMapperFunc(f.objectMappingFactory.ClientForMapping), f.clientAccessFactory.Decoder(true))
} }
// 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 (f *ring2Factory) PluginLoader() plugins.PluginLoader {
if len(os.Getenv("KUBECTL_PLUGINS_PATH")) > 0 {
return plugins.PluginsEnvVarPluginLoader()
}
return plugins.TolerantMultiPluginLoader{
plugins.XDGDataPluginLoader(),
plugins.UserDirPluginLoader(),
}
}
func (f *ring2Factory) PluginRunner() plugins.PluginRunner {
return &plugins.ExecPluginRunner{}
}

48
pkg/kubectl/plugins/BUILD Normal file
View File

@ -0,0 +1,48 @@
package(default_visibility = ["//visibility:public"])
licenses(["notice"])
load(
"@io_bazel_rules_go//go:def.bzl",
"go_library",
"go_test",
)
go_library(
name = "go_default_library",
srcs = [
"loader.go",
"plugins.go",
"runner.go",
],
tags = ["automanaged"],
deps = [
"//vendor/github.com/ghodss/yaml:go_default_library",
"//vendor/github.com/golang/glog:go_default_library",
"//vendor/k8s.io/client-go/tools/clientcmd: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 = [
"loader_test.go",
"plugins_test.go",
"runner_test.go",
],
library = ":go_default_library",
tags = ["automanaged"],
)

View File

@ -0,0 +1,69 @@
#!/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
pods_json = `kubectl 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

@ -0,0 +1,8 @@
name: "aging"
shortDesc: "Aging shows pods by age"
longDesc: >
Aging shows pods from the current namespace by age.
Once we have plugin support for global flags through
env vars (planned for V1) we'll be able to switch
between namespaces using the --namespace flag.
command: ./aging.rb

View File

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

View File

@ -0,0 +1,192 @@
/*
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"
)
const PluginDescriptorFilename = "plugin.yaml"
// PluginLoader is capable of loading a list of plugin descriptions.
type PluginLoader interface {
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 fileInfo.IsDir() || fileInfo.Name() != PluginDescriptorFilename || walkErr != nil {
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
}
plugin.Dir = filepath.Dir(path)
plugin.DescriptorName = fileInfo.Name()
glog.V(6).Infof("Plugin loaded: %s", plugin.Name)
list = append(list, plugin)
return nil
})
return list, err
}
// UserDirPluginLoader is 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 is 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
}
// PluginsEnvVarPluginLoader is a PluginLoader that loads plugins from one or more
// directories specified by the KUBECTL_PLUGINS_PATH env var.
func PluginsEnvVarPluginLoader() PluginLoader {
return PathFromEnvVarPluginLoader("KUBECTL_PLUGINS_PATH")
}
// XDGDataPluginLoader is 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 XDGDataPluginLoader() PluginLoader {
envVarName := "XDG_DATA_DIRS"
if len(os.Getenv(envVarName)) > 0 {
return PathFromEnvVarPluginLoader(envVarName, "kubectl", "plugins")
}
return TolerantMultiPluginLoader{
&DirectoryPluginLoader{
Directory: "/usr/local/share",
},
&DirectoryPluginLoader{
Directory: "/usr/share",
},
}
}
// 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
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
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 loads nothing.
type DummyPluginLoader struct{}
func (l *DummyPluginLoader) Load() (Plugins, error) {
return Plugins{}, nil
}

View File

@ -0,0 +1,197 @@
/*
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)
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)
}
}
}
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 TestPluginsEnvVarPluginLoader(t *testing.T) {
tmp, err := setupValidPlugins(1)
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 := PluginsEnvVarPluginLoader()
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 setupValidPlugins(count int) (string, error) {
tmp, err := ioutil.TempDir("", "")
if err != nil {
return "", fmt.Errorf("unexpected ioutil.TempDir error: %v", err)
}
for i := 1; i <= count; i++ {
name := fmt.Sprintf("plugin%d", i)
descriptor := fmt.Sprintf(`
name: %[1]s
shortDesc: The %[1]s test plugin
command: echo %[1]s`, 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

@ -0,0 +1,58 @@
/*
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"
// Plugin is the representation of a CLI extension (plugin).
type Plugin struct {
Description
Source
Context RunningContext `json:"-"`
}
// PluginDescription 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"`
}
// PluginSource holds the location of a given plugin in the filesystem.
type Source struct {
Dir string `json:"-"`
DescriptorName string `json:"-"`
}
var IncompleteError = fmt.Errorf("incomplete plugin descriptor: name, shortDesc and command fields are required")
func (p Plugin) Validate() error {
if len(p.Name) == 0 || len(p.ShortDesc) == 0 || len(p.Command) == 0 {
return IncompleteError
}
return nil
}
func (p Plugin) IsValid() bool {
return p.Validate() == nil
}
// Plugins is a list of plugins.
type Plugins []*Plugin

View File

@ -0,0 +1,70 @@
/*
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 (
"strings"
"testing"
)
func TestPlugin(t *testing.T) {
tests := []struct {
plugin Plugin
expectedErr string
expectedValid bool
}{
{
plugin: Plugin{
Description: Description{
Name: "test",
ShortDesc: "The test",
Command: "echo 1",
},
},
expectedValid: true,
},
{
plugin: Plugin{
Description: Description{
Name: "test",
ShortDesc: "The test",
},
},
expectedErr: "incomplete",
},
{
plugin: Plugin{},
expectedErr: "incomplete",
},
}
for _, test := range tests {
if is := test.plugin.IsValid(); test.expectedValid != is {
t.Errorf("%s: expected valid=%v, got %v", test.plugin.Name, test.expectedValid, is)
}
err := test.plugin.Validate()
if len(test.expectedErr) > 0 {
if err == nil {
t.Errorf("%s: expected error, got none", test.plugin.Name)
} else if !strings.Contains(err.Error(), test.expectedErr) {
t.Errorf("%s: expected error containing %q, got %v", test.plugin.Name, test.expectedErr, err)
}
} else if err != nil {
t.Errorf("%s: expected no error, got %v", test.plugin.Name, err)
}
}
}

View File

@ -0,0 +1,69 @@
/*
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 (
"io"
"os/exec"
"strings"
"github.com/golang/glog"
)
// 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 {
In io.Reader
Out io.Writer
ErrOut io.Writer
Args []string
Env []string
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(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
cmd.Env = ctx.Env
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

@ -0,0 +1,71 @@
/*
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 (
"bytes"
"testing"
)
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",
},
}
for _, test := range tests {
outBuf := bytes.NewBuffer([]byte{})
plugin := &Plugin{
Description: Description{
Name: test.name,
ShortDesc: "Test Runner Plugin",
Command: test.command,
},
}
ctx := RunningContext{
Out: outBuf,
WorkingDir: ".",
}
runner := &ExecPluginRunner{}
err := runner.Run(plugin, ctx)
if outBuf.String() != test.expectedMsg {
t.Errorf("%s: unexpected output: %q", test.name, outBuf.String())
}
if err != nil && err.Error() != test.expectedErr {
t.Errorf("%s: unexpected err output: %v", test.name, err)
}
}
}

View File

@ -47,8 +47,11 @@ const (
RecommendedSchemaName = "schema" RecommendedSchemaName = "schema"
) )
var RecommendedHomeFile = path.Join(homedir.HomeDir(), RecommendedHomeDir, RecommendedFileName) var (
var RecommendedSchemaFile = path.Join(homedir.HomeDir(), RecommendedHomeDir, RecommendedSchemaName) RecommendedConfigDir = path.Join(homedir.HomeDir(), RecommendedHomeDir)
RecommendedHomeFile = path.Join(RecommendedConfigDir, RecommendedFileName)
RecommendedSchemaFile = path.Join(RecommendedConfigDir, RecommendedSchemaName)
)
// currentMigrationRules returns a map that holds the history of recommended home directories used in previous versions. // currentMigrationRules returns a map that holds the history of recommended home directories used in previous versions.
// Any future changes to RecommendedHomeFile and related are expected to add a migration rule here, in order to make // Any future changes to RecommendedHomeFile and related are expected to add a migration rule here, in order to make

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,19 @@
#!/bin/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

@ -0,0 +1,7 @@
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