Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for shell completion #28

Merged
merged 3 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions pkg/cmd/completion/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package completion

import (
_ "embed"

"github.com/spf13/cobra"
"k8s.io/cli-runtime/pkg/genericclioptions"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)

//go:embed kubectl_complete-revisions
var completionScript []byte

type Options struct {
genericclioptions.IOStreams
}

func NewOptions(streams genericclioptions.IOStreams) *Options {
return &Options{
IOStreams: streams,
}
}

func NewCommand(streams genericclioptions.IOStreams) *cobra.Command {
o := NewOptions(streams)

cmd := &cobra.Command{
Use: "completion",
DisableFlagsInUseLine: true,

Short: "Setup shell completion",
Long: `The completion command outputs a script which makes the revisions plugin's completion available to kubectl's completion
(supported in kubectl v1.26+), see https://github.com/kubernetes/kubernetes/pull/105867 and
https://github.com/kubernetes/sample-cli-plugin#shell-completion.

This script needs to be installed as an executable file in PATH named kubectl_complete-revisions. E.g., you could
install it in krew's binary directory. This is not supported natively yet, but can be done manually as follows
(see https://github.com/kubernetes-sigs/krew/issues/812):
SCRIPT="${KREW_ROOT:-$HOME/.krew}/bin/kubectl_complete-revisions"; kubectl revisions completion > "$SCRIPT" && chmod +x "$SCRIPT"

If you don't use krew, you can install the script next to the binary itself as follows:
SCRIPT="$(dirname "$(which kubectl-revisions)")/kubectl_complete-revisions"; kubectl revisions completion > "$SCRIPT" && chmod +x "$SCRIPT"

Alternatively, you can also use https://github.com/marckhouzam/kubectl-plugin_completion to generate completion
scripts for this plugin along with other kubectl plugins that support it.
`,

Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(o.Run())
},
}

return cmd
}

// Run outputs the completion script.
func (o *Options) Run() error {
_, err := o.IOStreams.Out.Write(completionScript)
return err
}
19 changes: 19 additions & 0 deletions pkg/cmd/completion/kubectl_complete-revisions
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash

# This script makes the revisions plugin's completion available to kubectl's completion (supported in kubectl v1.26+),
# see https://github.com/kubernetes/kubernetes/pull/105867 and
# https://github.com/kubernetes/sample-cli-plugin#shell-completion.

# This script needs to be installed as an executable file in PATH named kubectl_complete-revisions. E.g., you could
# install it in krew's binary directory. This is not supported natively yet, but can be done manually as follows
# (see https://github.com/kubernetes-sigs/krew/issues/812):
# SCRIPT="${KREW_ROOT:-$HOME/.krew}/bin/kubectl_complete-revisions"; kubectl revisions completion > "$SCRIPT" && chmod +x "$SCRIPT"

# If you don't use krew, you can install the script next to the binary itself as follows:
# SCRIPT="$(dirname "$(which kubectl-revisions)")/kubectl_complete-revisions"; kubectl revisions completion > "$SCRIPT" && chmod +x "$SCRIPT"

# Alternatively, you can also use https://github.com/marckhouzam/kubectl-plugin_completion to generate completion
# scripts for this plugin along with other kubectl plugins that support it.

# Call cobra's built-in __complete command passing all arguments
kubectl revisions __complete "$@"
10 changes: 4 additions & 6 deletions pkg/cmd/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
corev1 "k8s.io/api/core/v1"
"k8s.io/cli-runtime/pkg/genericclioptions"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
utilcomp "k8s.io/kubectl/pkg/util/completion"
"k8s.io/utils/exec"
"sigs.k8s.io/controller-runtime/pkg/client"

Expand Down Expand Up @@ -86,14 +87,11 @@ files as empty) options.`,
KUBECTL_EXTERNAL_DIFF="code --diff --wait" kubectl revisions diff deploy nginx
`,

RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

ValidArgsFunction: utilcomp.SpecifiedResourceTypeAndNameNoRepeatCompletionFunc(f, util.Map(history.SupportedKinds, strings.ToLower)),
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(o.Complete(f))
cmdutil.CheckErr(o.Validate())
cmdutil.CheckErr(o.Run(ctx, f, args))

return nil
cmdutil.CheckErr(o.Run(cmd.Context(), f, args))
},
}

Expand Down
10 changes: 4 additions & 6 deletions pkg/cmd/get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"
"k8s.io/cli-runtime/pkg/genericclioptions"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
utilcomp "k8s.io/kubectl/pkg/util/completion"
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/timebertt/kubectl-revisions/pkg/cmd/util"
Expand Down Expand Up @@ -56,14 +57,11 @@ instead.
# Get the latest revision in YAML
kubectl revisions get deploy nginx --revision=-1 -o yaml`,

RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

ValidArgsFunction: utilcomp.SpecifiedResourceTypeAndNameNoRepeatCompletionFunc(f, util.Map(history.SupportedKinds, strings.ToLower)),
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(o.Complete(f))
cmdutil.CheckErr(o.Validate())
cmdutil.CheckErr(o.Run(ctx, f, args))

return nil
cmdutil.CheckErr(o.Run(cmd.Context(), f, args))
},
}

Expand Down
60 changes: 52 additions & 8 deletions pkg/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import (
"github.com/spf13/cobra"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/rest"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
utilcomp "k8s.io/kubectl/pkg/util/completion"
"k8s.io/kubectl/pkg/util/term"

"github.com/timebertt/kubectl-revisions/pkg/cmd/completion"
"github.com/timebertt/kubectl-revisions/pkg/cmd/diff"
"github.com/timebertt/kubectl-revisions/pkg/cmd/get"
"github.com/timebertt/kubectl-revisions/pkg/cmd/util"
Expand Down Expand Up @@ -42,9 +45,9 @@ func NewCommand() *cobra.Command {
},

CompletionOptions: cobra.CompletionOptions{
// Supporting shell completion for a kubectl plugin is a bit more difficult than using cobra's default generated
// completion as the plugin will not be called by its name directly but via `kubectl <plugin-name>`.
// Until that is implemented properly, disable the default completion command to prevent confusion.
// Supporting shell completion for a kubectl plugin requires a dedicated completion executable.
// Disable cobra's completion command in favor of a custom completion command that explains how to set up
// completion.
DisableDefaultCmd: true,
},
}
Expand Down Expand Up @@ -73,16 +76,22 @@ func NewCommand() *cobra.Command {
Title: "Other Commands:",
}
cmd.AddGroup(otherGroup)

cmd.SetCompletionCommandGroupID(otherGroup.ID)
cmd.SetHelpCommandGroupID(otherGroup.ID)

versionCmd := version.NewCommand(o.IOStreams)
versionCmd.GroupID = otherGroup.ID
cmd.AddCommand(versionCmd)
for _, subcommand := range []*cobra.Command{
completion.NewCommand(o.IOStreams),
version.NewCommand(o.IOStreams),
} {
subcommand.GroupID = otherGroup.ID
cmd.AddCommand(subcommand)
hideGlobalFlagsInUsage(cmd)
}

customizeUsageTemplate(cmd)

utilcomp.SetFactoryForCompletion(f)
registerCompletionFuncForGlobalFlags(cmd, f)

return cmd
}

Expand All @@ -108,3 +117,38 @@ func customizeUsageTemplate(cmd *cobra.Command) {

cmd.SetUsageTemplate(tmpl)
}

// hideGlobalFlagsInUsage customizes the help output of subcommands to skip the global flags section.
// The function should be called after adding the subcommand to the parent command, otherwise the customization from
// customizeUsageTemplate will be lost.
func hideGlobalFlagsInUsage(cmd *cobra.Command) {
defaultTmpl := cmd.UsageTemplate()

r := regexp.MustCompile(`([{ ]).HasAvailableInheritedFlags([} ])`)
tmpl := r.ReplaceAllString(defaultTmpl, `${1}false${2}`)

cmd.SetUsageTemplate(tmpl)
}

func registerCompletionFuncForGlobalFlags(cmd *cobra.Command, f cmdutil.Factory) {
cmdutil.CheckErr(cmd.RegisterFlagCompletionFunc(
"namespace",
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return utilcomp.CompGetResource(f, "namespace", toComplete), cobra.ShellCompDirectiveNoFileComp
}))
cmdutil.CheckErr(cmd.RegisterFlagCompletionFunc(
"context",
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return utilcomp.ListContextsInConfig(toComplete), cobra.ShellCompDirectiveNoFileComp
}))
cmdutil.CheckErr(cmd.RegisterFlagCompletionFunc(
"cluster",
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return utilcomp.ListClustersInConfig(toComplete), cobra.ShellCompDirectiveNoFileComp
}))
cmdutil.CheckErr(cmd.RegisterFlagCompletionFunc(
"user",
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return utilcomp.ListUsersInConfig(toComplete), cobra.ShellCompDirectiveNoFileComp
}))
}
13 changes: 13 additions & 0 deletions pkg/cmd/util/print_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/printers"
kubectlget "k8s.io/kubectl/pkg/cmd/get"
"k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/scheme"

"github.com/timebertt/kubectl-revisions/pkg/printer"
Expand Down Expand Up @@ -48,6 +49,18 @@ func (f *PrintFlags) AddFlags(cmd *cobra.Command) {
f.CustomColumnsFlags.AddFlags(cmd)

cmd.Flags().Lookup("output").Usage = f.OutputUsage()
util.CheckErr(cmd.RegisterFlagCompletionFunc(
"output",
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var comps []string
for _, format := range f.AllowedFormats() {
if strings.HasPrefix(format, toComplete) {
comps = append(comps, format)
}
}
return comps, cobra.ShellCompDirectiveNoFileComp
},
))

cmd.Flags().BoolVar(&f.TemplateOnly, "template-only", f.TemplateOnly, "If false, print the full revision object (e.g., ReplicaSet) instead of only the pod template.")
}
Expand Down
12 changes: 12 additions & 0 deletions pkg/cmd/util/slices.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package util

// Map applies the given function to all slice elements.
func Map[S ~[]E, E any](s S, fn func(e E) E) S {
out := make([]E, len(s))

for i, e := range s {
out[i] = fn(e)
}

return out
}
16 changes: 16 additions & 0 deletions pkg/cmd/util/slices_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package util_test

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

. "github.com/timebertt/kubectl-revisions/pkg/cmd/util"
)

var _ = Describe("Map", func() {
It("should correctly map all elements", func() {
Expect(Map([]string{"1", "2", "3"}, func(e string) string {
return e + "m"
})).To(Equal([]string{"1m", "2m", "3m"}))
})
})
13 changes: 13 additions & 0 deletions pkg/cmd/util/util_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package util_test

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestUtil(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Util Suite")
}
6 changes: 3 additions & 3 deletions pkg/cmd/version/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ func NewCommand(streams genericclioptions.IOStreams) *cobra.Command {
Note that the version string's format can be different depending on how the binary was built.
E.g, release builds inject the version via -ldflags, while installing with 'go install' injects
the go module's version (which can also be "(devel)").`,
RunE: func(*cobra.Command, []string) error {

Args: cobra.NoArgs,
Run: func(*cobra.Command, []string) {
if version == "" {
_, _ = fmt.Fprintln(o.Out, "could not determine build information")
} else {
_, _ = fmt.Fprintf(o.Out, "kubectl-revisions %s\n", version)
}

return nil
},
}
}
3 changes: 3 additions & 0 deletions pkg/history/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
)

// SupportedKinds is a list of object kinds supported by this package.
var SupportedKinds = []string{"Deployment", "StatefulSet", "DaemonSet"}

// History is a kind-specific client that knows how to access the revision history of objects of that kind.
// Instantiate a History with For or ForGroupKind.
type History interface {
Expand Down
Loading