diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/cmd.go b/staging/src/k8s.io/kubectl/pkg/cmd/cmd.go index d15b710c48b..fd0d3613259 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/cmd.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/cmd.go @@ -279,8 +279,15 @@ func NewKubectlCommand(o KubectlOptions) *cobra.Command { Run: runHelp, // Hook before and after Run initialize and write profiles to disk, // respectively. - PersistentPreRunE: func(*cobra.Command, []string) error { + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { rest.SetDefaultWarningHandler(warningHandler) + + if cmd.Name() == cobra.ShellCompRequestCmd { + // This is the __complete or __completeNoDesc command which + // indicates shell completion has been requested. + plugin.SetupPluginCompletion(cmd, args) + } + return initProfiling() }, PersistentPostRunE: func(*cobra.Command, []string) error { diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin.go b/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin.go index d620a09a0d7..fa0acd46c76 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin.go @@ -115,58 +115,27 @@ func (o *PluginListOptions) Complete(cmd *cobra.Command) error { } func (o *PluginListOptions) Run() error { - pluginsFound := false - isFirstFile := true - pluginErrors := []error{} - pluginWarnings := 0 + plugins, pluginErrors := o.ListPlugins() - for _, dir := range uniquePathsList(o.PluginPaths) { - if len(strings.TrimSpace(dir)) == 0 { - continue - } - - files, err := ioutil.ReadDir(dir) - if err != nil { - if _, ok := err.(*os.PathError); ok { - fmt.Fprintf(o.ErrOut, "Unable to read directory %q from your PATH: %v. Skipping...\n", dir, err) - continue - } - - pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to read directory %q in your PATH: %v", dir, err)) - continue - } - - for _, f := range files { - if f.IsDir() { - continue - } - if !hasValidPrefix(f.Name(), ValidPluginFilenamePrefixes) { - continue - } - - if isFirstFile { - fmt.Fprintf(o.Out, "The following compatible plugins are available:\n\n") - pluginsFound = true - isFirstFile = false - } - - pluginPath := f.Name() - if !o.NameOnly { - pluginPath = filepath.Join(dir, pluginPath) - } - - fmt.Fprintf(o.Out, "%s\n", pluginPath) - if errs := o.Verifier.Verify(filepath.Join(dir, f.Name())); len(errs) != 0 { - for _, err := range errs { - fmt.Fprintf(o.ErrOut, " - %s\n", err) - pluginWarnings++ - } - } - } + if len(plugins) > 0 { + fmt.Fprintf(o.Out, "The following compatible plugins are available:\n\n") + } else { + pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to find any kubectl plugins in your PATH")) } - if !pluginsFound { - pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to find any kubectl plugins in your PATH")) + pluginWarnings := 0 + for _, pluginPath := range plugins { + if o.NameOnly { + fmt.Fprintf(o.Out, "%s\n", filepath.Base(pluginPath)) + } else { + fmt.Fprintf(o.Out, "%s\n", pluginPath) + } + if errs := o.Verifier.Verify(pluginPath); len(errs) != 0 { + for _, err := range errs { + fmt.Fprintf(o.ErrOut, " - %s\n", err) + pluginWarnings++ + } + } } if pluginWarnings > 0 { @@ -187,6 +156,42 @@ func (o *PluginListOptions) Run() error { return nil } +// ListPlugins returns list of plugin paths. +func (o *PluginListOptions) ListPlugins() ([]string, []error) { + plugins := []string{} + errors := []error{} + + for _, dir := range uniquePathsList(o.PluginPaths) { + if len(strings.TrimSpace(dir)) == 0 { + continue + } + + files, err := ioutil.ReadDir(dir) + if err != nil { + if _, ok := err.(*os.PathError); ok { + fmt.Fprintf(o.ErrOut, "Unable to read directory %q from your PATH: %v. Skipping...\n", dir, err) + continue + } + + errors = append(errors, fmt.Errorf("error: unable to read directory %q in your PATH: %v", dir, err)) + continue + } + + for _, f := range files { + if f.IsDir() { + continue + } + if !hasValidPrefix(f.Name(), ValidPluginFilenamePrefixes) { + continue + } + + plugins = append(plugins, filepath.Join(dir, f.Name())) + } + } + + return plugins, errors +} + // pathVerifier receives a path and determines if it is valid or not type PathVerifier interface { // Verify determines if a given path is valid diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin_completion.go b/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin_completion.go new file mode 100644 index 00000000000..357dc7aa6fa --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin_completion.go @@ -0,0 +1,231 @@ +/* +Copyright 2022 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 plugin + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +// SetupPluginCompletion adds a Cobra command to the command tree for each +// plugin. This is only done when performing shell completion that relate +// to plugins. +func SetupPluginCompletion(cmd *cobra.Command, args []string) { + if len(args) > 0 { + if strings.HasPrefix(args[0], "-") { + // Plugins are not supported if the first argument is a flag, + // so no need to add them in that case. + return + } + + if len(args) == 1 { + // We are completing a subcommand at the first level so + // we should include all plugins names. + addPluginCommands(cmd) + return + } + + // We have more than one argument. + // Check if we know the first level subcommand. + // If we don't it could be a plugin and we'll need to add + // the plugin commands for completion to work. + found := false + for _, subCmd := range cmd.Root().Commands() { + if args[0] == subCmd.Name() { + found = true + break + } + } + + if !found { + // We don't know the subcommand for which completion + // is being called: it could be a plugin. + // + // When using a plugin, the kubectl global flags are not supported. + // Therefore, when doing completion, we need to remove these flags + // to avoid them being included in the completion choices. + // This must be done *before* adding the plugin commands so that + // when creating those plugin commands, the flags don't exist. + cmd.Root().ResetFlags() + cobra.CompDebugln("Cleared global flags for plugin completion", true) + + addPluginCommands(cmd) + } + } +} + +// addPluginCommand adds a Cobra command to the command tree +// for each plugin so that the completion logic knows about the plugins +func addPluginCommands(cmd *cobra.Command) { + kubectl := cmd.Root() + streams := genericclioptions.IOStreams{ + In: &bytes.Buffer{}, + Out: ioutil.Discard, + ErrOut: ioutil.Discard, + } + + o := &PluginListOptions{IOStreams: streams} + o.Complete(kubectl) + plugins, _ := o.ListPlugins() + + for _, plugin := range plugins { + plugin = filepath.Base(plugin) + args := []string{} + + // Plugins are named "kubectl-" or with more - such as + // "kubectl--..." + for _, arg := range strings.Split(plugin, "-")[1:] { + // Underscores (_) in plugin's filename are replaced with dashes(-) + // e.g. foo_bar -> foo-bar + args = append(args, strings.Replace(arg, "_", "-", -1)) + } + + // In order to avoid that the same plugin command is added more than once, + // find the lowest command given args from the root command + parentCmd, remainingArgs, _ := kubectl.Find(args) + if parentCmd == nil { + parentCmd = kubectl + } + + for _, remainingArg := range remainingArgs { + cmd := &cobra.Command{ + Use: remainingArg, + // Add a description that will be shown with completion choices. + // Make each one different by including the plugin name to avoid + // all plugins being grouped in a single line during completion for zsh. + Short: fmt.Sprintf("The command %s is a plugin installed by the user", remainingArg), + DisableFlagParsing: true, + // Allow plugins to provide their own completion choices + ValidArgsFunction: pluginCompletion, + // A Run is required for it to be a valid command + Run: func(cmd *cobra.Command, args []string) {}, + } + parentCmd.AddCommand(cmd) + parentCmd = cmd + } + } +} + +// pluginCompletion deals with shell completion beyond the plugin name, it allows to complete +// plugin arguments and flags. +// It will look on $PATH for a specific executable file that will provide completions +// for the plugin in question. +// +// When called, this completion executable should print the completion choices to stdout. +// The arguments passed to the executable file will be the arguments for the plugin currently +// on the command-line. For example, if a user types: +// +// kubectl myplugin arg1 arg2 a +// +// the completion executable will be called with arguments: "arg1" "arg2" "a". +// And if a user types: +// +// kubectl myplugin arg1 arg2 +// +// the completion executable will be called with arguments: "arg1" "arg2" "". Notice the empty +// last argument which indicates that a new word should be completed but that the user has not +// typed anything for it yet. +// +// Kubectl's plugin completion logic supports Cobra's ShellCompDirective system. This means a plugin +// can optionally print : as its very last line to provide +// directives to the shell on how to perform completion. If this directive is not present, the +// cobra.ShellCompDirectiveDefault will be used. Please see Cobra's documentation for more details: +// https://github.com/spf13/cobra/blob/master/shell_completions.md#dynamic-completion-of-nouns +// +// The completion executable should be named kubectl_complete-. For example, for a plugin +// named kubectl-get_all, the completion file should be named kubectl_complete-get_all. The completion +// executable must have executable permissions set on it and must be on $PATH. +func pluginCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Recreate the plugin name from the commandPath + pluginName := strings.Replace(strings.Replace(cmd.CommandPath(), "-", "_", -1), " ", "-", -1) + + path, found := lookupCompletionExec(pluginName) + if !found { + cobra.CompDebugln(fmt.Sprintf("Plugin %s does not provide a matching completion executable", pluginName), true) + return nil, cobra.ShellCompDirectiveDefault + } + + args = append(args, toComplete) + cobra.CompDebugln(fmt.Sprintf("About to call: %s %s", path, strings.Join(args, " ")), true) + return getPluginCompletions(path, args, os.Environ()) +} + +// lookupCompletionExec will look for the existence of an executable +// that can provide completion for the given plugin name. +// The first filepath to match is returned, or a boolean false if +// such an executable is not found. +func lookupCompletionExec(pluginName string) (string, bool) { + // Convert the plugin name into the plugin completion name by inserting "_complete" before the first -. + // For example, convert kubectl-get_all to kubectl_complete-get_all + pluginCompExec := strings.Replace(pluginName, "-", "_complete-", 1) + cobra.CompDebugln(fmt.Sprintf("About to look for: %s", pluginCompExec), true) + path, err := exec.LookPath(pluginCompExec) + if err != nil || len(path) == 0 { + return "", false + } + return path, true +} + +// getPluginCompletions receives an executable's filepath, a slice +// of arguments, and a slice of environment variables +// to relay to the executable. +// The executable is responsible for printing the completions of the +// plugin for the current set of arguments. +func getPluginCompletions(executablePath string, cmdArgs, environment []string) ([]string, cobra.ShellCompDirective) { + buf := new(bytes.Buffer) + + prog := exec.Command(executablePath, cmdArgs...) + prog.Stdin = os.Stdin + prog.Stdout = buf + prog.Stderr = os.Stderr + prog.Env = environment + + var comps []string + directive := cobra.ShellCompDirectiveDefault + if err := prog.Run(); err == nil { + for _, comp := range strings.Split(buf.String(), "\n") { + // Remove any empty lines + if len(comp) > 0 { + comps = append(comps, comp) + } + } + + // Check if the last line of output is of the form :, which + // indicates a Cobra ShellCompDirective. We do this for plugins + // that use Cobra or the ones that wish to use this directive to + // communicate a special behavior for the shell. + if len(comps) > 0 { + lastLine := comps[len(comps)-1] + if len(lastLine) > 1 && lastLine[0] == ':' { + if strInt, err := strconv.Atoi(lastLine[1:]); err == nil { + directive = cobra.ShellCompDirective(strInt) + comps = comps[:len(comps)-1] + } + } + } + } + return comps, directive +} diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin_test.go b/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin_test.go index 013fe7f184e..639bc6f8dac 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin_test.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/plugin/plugin_test.go @@ -20,6 +20,8 @@ import ( "fmt" "io/ioutil" "os" + "path/filepath" + "reflect" "strings" "testing" @@ -178,6 +180,34 @@ func TestPluginPathsAreValid(t *testing.T) { } } +func TestListPlugins(t *testing.T) { + pluginPath, _ := filepath.Abs("./testdata") + expectPlugins := []string{ + filepath.Join(pluginPath, "kubectl-foo"), + filepath.Join(pluginPath, "kubectl-version"), + } + + verifier := newFakePluginPathVerifier() + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + pluginPaths := []string{pluginPath} + + o := &PluginListOptions{ + Verifier: verifier, + IOStreams: ioStreams, + + PluginPaths: pluginPaths, + } + + plugins, errs := o.ListPlugins() + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + + if !reflect.DeepEqual(expectPlugins, plugins) { + t.Fatalf("saw unexpected plugins. Expecting %v, got %v", expectPlugins, plugins) + } +} + type duplicatePathError struct { path string } diff --git a/staging/src/k8s.io/sample-cli-plugin/README.md b/staging/src/k8s.io/sample-cli-plugin/README.md index f13664946bb..0aa94e58d6e 100644 --- a/staging/src/k8s.io/sample-cli-plugin/README.md +++ b/staging/src/k8s.io/sample-cli-plugin/README.md @@ -58,6 +58,29 @@ that kubectl points to. It can also be used as a means of showcasing usage of the cli-runtime set of utilities to aid in third-party plugin development. +## Shell completion + +This plugin supports shell completion when used through kubectl. To enable shell completion for the plugin +you must copy the file `./kubectl_complete-ns` somewhere on `$PATH` and give it executable permissions. + +The `./kubectl_complete-ns` script shows a hybrid approach to providing completions: +1. it uses the builtin `__complete` command provided by [Cobra](https://github.com/spf13/cobra) for flags +1. it calls `kubectl` to obtain the list of namespaces to complete arguments (note that a more elegant approach would be to have the `kubectl-ns` program itself provide completion of arguments by implementing Cobra's `ValidArgsFunction` to fetch the list of namespaces, but it would then be a less varied example) + +One can then do things like: +``` +$ kubectl ns +default kube-node-lease kube-public kube-system + +$ kubectl ns -- +--as -- Username to impersonate for the operation. User could be a regular user or a service account in a namespace. +--as-group -- Group to impersonate for the operation, this flag can be repeated to specify multiple groups. +--as-uid -- UID to impersonate for the operation. +--cache-dir -- Default cache directory +[...] +``` + +Note: kubectl v1.26 or higher is required for shell completion to work for plugins. ## Cleanup You can "uninstall" this plugin from kubectl by simply removing it from your PATH: diff --git a/staging/src/k8s.io/sample-cli-plugin/kubectl_complete-ns b/staging/src/k8s.io/sample-cli-plugin/kubectl_complete-ns new file mode 100755 index 00000000000..1a3e054514a --- /dev/null +++ b/staging/src/k8s.io/sample-cli-plugin/kubectl_complete-ns @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# If we are completing a flag, use Cobra's builtin completion system. +# To know if we are completing a flag we need the last argument starts with a `-` and does not contain an `=` +args=("$@") +lastArg=${args[((${#args[@]}-1))]} +if [[ "$lastArg" == -* ]]; then + if [[ "$lastArg" != *=* ]]; then + kubectl ns __complete "$@" + fi +else + # TODO Make sure we are not completing the value of a flag. + # TODO Only complete a single argument. + # Both are pretty hard to do in a shell script. The better way to do this would be to let + # Cobra do all the completions by using `cobra.ValidArgsFunction` in the program. + # But the below, although imperfect, is a nice example for plugins that don't use Cobra. + + # We are probably completing an argument. This plugin only accepts namespaces, let's fetch them. + kubectl get namespaces --output go-template='{{ range .items }}{{ .metadata.name }}{{"\n"}}{{ end }}' + + # Turn off file completion. See the ShellCompDirective documentation within + # https://github.com/spf13/cobra/blob/main/shell_completions.md#completion-of-nouns + echo :4 +fi