mirror of https://github.com/k3s-io/k3s
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
commit
d4ece0abc3
|
@ -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
|
||||||
|
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
],
|
],
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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{}
|
||||||
|
}
|
||||||
|
|
|
@ -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"],
|
||||||
|
)
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
||||||
|
name: "hello"
|
||||||
|
shortDesc: "I say hello!"
|
||||||
|
command: "echo Hello plugins!"
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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!"
|
|
@ -0,0 +1,3 @@
|
||||||
|
name: "error"
|
||||||
|
shortDesc: "The tremendous plugin that always fails!"
|
||||||
|
command: "false"
|
|
@ -0,0 +1,3 @@
|
||||||
|
name: "get"
|
||||||
|
shortDesc: "The wonderful new plugin-based get!"
|
||||||
|
command: "echo new-get"
|
|
@ -0,0 +1,2 @@
|
||||||
|
name: "incomplete"
|
||||||
|
shortDesc: "Incomplete plugin"
|
|
@ -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 "#######"
|
|
@ -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
|
Loading…
Reference in New Issue