diff --git a/README.md b/README.md index 312eea2..a29df80 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The Kubernetes API server has support for exec over WebSockets, but it has yet t Usage: ``` Usage: - execws [options] + kubectl-execws [options] -- Flags: -c, --container string Container name @@ -31,6 +31,14 @@ Flags: * Supports a full TTY (terminal raw mode) * Can bypass the API server with direct connection to the nodes kubelet API -### Acknowledgements +## Tab Completion + +Tab completion is available for various shells `[bash|zsh|fish|powershell]`. + +This can be used with the standalone binary through use of the `completion` subcommand, eg. `source <(kubectl-execws completion zsh)` + +Completion is also available when using as a kubectl plugin. To set this up it is necessary to symlink to the multi-call binary with a special name: `ln -s kubectl-execws kubectl_complete-execws`. + +## Acknowledgements Work inspired by [rmohr/kubernetes-custom-exec](https://github.com/rmohr/kubernetes-custom-exec) and [kairen/websocket-exec](https://github.com/kairen/websocket-exec). diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..2e035ab --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "context" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func MainValidArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + s, cerr := initCompletionCliSession() + if cerr != nil { + return nil, cobra.ShellCompDirectiveError + } + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completeAvailablePods(s, toComplete) +} + +func NamespaceValidArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + s, cerr := initCompletionCliSession() + if cerr != nil { + return nil, cobra.ShellCompDirectiveError + } + return completeAvailableNS(s, toComplete) +} + +func ContainerValidArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + s, cerr := initCompletionCliSession() + if cerr != nil { + return nil, cobra.ShellCompDirectiveError + } + s.opts.Pod = args[0] + return completeAvailableContainers(s, toComplete) +} + +func initCompletionCliSession() (*cliSession, error) { + opts := Options{ + Kconfig: kconfig, + Namespace: namespace, + noTLSVerify: noTLSVerify, + } + return NewCliSession(&opts) +} + +func completeAvailableNS(c *cliSession, toComplete string) ([]string, cobra.ShellCompDirective) { + res, err := c.k8sClient.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + var nspaces []string + for _, ns := range res.Items { + if strings.HasPrefix(ns.Name, toComplete) { + nspaces = append(nspaces, ns.Name) + } + } + + return nspaces, cobra.ShellCompDirectiveNoFileComp +} + +func completeAvailablePods(c *cliSession, toComplete string) ([]string, cobra.ShellCompDirective) { + res, err := c.k8sClient.CoreV1().Pods(c.namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + var pods []string + for _, pod := range res.Items { + if strings.HasPrefix(pod.Name, toComplete) { + //noCtrs := len(pod.Spec.Containers) + //noEphem := len(pod.Spec.EphemeralContainers) + pods = append(pods, pod.Name) + } + } + + return pods, cobra.ShellCompDirectiveNoFileComp +} + +func completeAvailableContainers(c *cliSession, toComplete string) ([]string, cobra.ShellCompDirective) { + res, err := c.k8sClient.CoreV1().Pods(c.namespace).Get(context.TODO(), c.opts.Pod, metav1.GetOptions{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + var ctrs []string + for _, ctr := range res.Spec.Containers { + if strings.HasPrefix(ctr.Name, toComplete) { + ctrs = append(ctrs, ctr.Name) + } + } + for _, ctr := range res.Spec.EphemeralContainers { + if strings.HasPrefix(ctr.Name, toComplete) { + ctrs = append(ctrs, ctr.Name) + } + } + + return ctrs, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/root.go b/cmd/root.go index 2dd6114..b71a187 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,7 @@ import ( "os" "strings" + "github.com/moby/term" "github.com/spf13/cobra" "k8s.io/klog/v2" ) @@ -28,12 +29,20 @@ var ( ) var rootCmd = &cobra.Command{ - Use: "execws [options] -- ", - Short: "kubectl exec over WebSockets", - Long: `A replacement for "kubectl exec" that works over WebSocket connections.`, - Args: cobra.MinimumNArgs(1), - SilenceUsage: true, - SilenceErrors: true, + Use: "kubectl-execws [options] -- ", + DisableFlagsInUseLine: true, + Short: "kubectl exec over WebSockets", + Long: `A replacement for "kubectl exec" that works over WebSocket connections.`, + Args: cobra.MinimumNArgs(1), + Version: releaseVersion, + SilenceUsage: true, + SilenceErrors: true, + /*CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: false, + HiddenDefaultCmd: true, + DisableNoDescFlag: true, + DisableDescriptions: false, + },*/ RunE: func(cmd *cobra.Command, args []string) error { var object, pod string var command []string @@ -111,8 +120,44 @@ var rootCmd = &cobra.Command{ return s.doExec(req) }, + ValidArgsFunction: MainValidArgs, } +// add our own explicit completion helper +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + DisableFlagsInUseLine: true, + Short: "Generate completion script", + Long: fmt.Sprintf(`Generate the autocompletion script for %[1]s for the specified shell.`, rootCmd.Root().Name()), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + _, stdOut, _ := term.StdStreams() + switch args[0] { + case "bash": + cmd.Root().GenBashCompletionV2(stdOut, true) + case "zsh": + cmd.Root().GenZshCompletion(stdOut) + case "fish": + cmd.Root().GenFishCompletion(stdOut, true) + case "powershell": + cmd.Root().GenPowerShellCompletionWithDesc(stdOut) + } + }, +} + +/*var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print program version", + DisableFlagsInUseLine: true, + Hidden: true, + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf(releaseVersion) + }, +}*/ + func Execute() { klog.InitFlags(nil) @@ -123,6 +168,16 @@ func Execute() { os.Exit(0) } +// shortcut to the hidden subcomand used for completion +func Complete() { + rootCmd.SetArgs(append([]string{cobra.ShellCompRequestCmd}, os.Args[1:]...)) + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } + os.Exit(0) +} + func init() { rootCmd.PersistentFlags().StringVar(&kconfig, "kubeconfig", "", "kubeconfig file (default is $HOME/.kube/config)") rootCmd.PersistentFlags().StringVarP(&namespace, "namespace", "n", "", "Set namespace") @@ -135,4 +190,10 @@ func init() { rootCmd.Flags().BoolVar(&noSanityCheck, "no-sanity-check", false, "Don't make preflight request to ensure pod exists") rootCmd.Flags().BoolVar(&directExec, "node-direct-exec", false, "Partially bypass the API server, by using the kubelet API") rootCmd.Flags().StringVar(&directExecNodeIp, "node-direct-exec-ip", "", "Node IP to use with direct-exec feature") + + rootCmd.AddCommand(completionCmd) + //rootCmd.AddCommand(versionCmd) + rootCmd.RegisterFlagCompletionFunc("namespace", NamespaceValidArgs) + rootCmd.RegisterFlagCompletionFunc("container", ContainerValidArgs) + rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) } diff --git a/main.go b/main.go index bfda99a..ca0acea 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,18 @@ package main -import "github.com/jpts/kubectl-execws/cmd" +import ( + "os" + "path/filepath" + + "github.com/jpts/kubectl-execws/cmd" +) func main() { - cmd.Execute() + name := filepath.Base(os.Args[0]) + switch name { + case "kubectl_complete-execws": + cmd.Complete() + default: + cmd.Execute() + } }