From ba0a8d4589c919547ad990943bc5c063d0a623ae Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Mon, 2 Oct 2023 16:24:44 -0400 Subject: [PATCH] Dynamic shell completion for the context cmds This commit provides dynamic shell completion for: - tanzu context delete - tanzu context use - tanzu context unset - tanzu context get - tanzu context create - tanzu context get-token - tanzu context update tae-active-resource The commit also turns off file completion for: - tanzu context list The commit also provides shell completion for all (non-boolean) flags of the "tanzu context" sub-commands. Units tests are included for each added completion. Provide a PanicOnErr function for coding errors Signed-off-by: Marc Khouzam --- pkg/command/completion_helper.go | 25 ++ pkg/command/completion_helper_test.go | 9 + pkg/command/context.go | 218 ++++++++++++--- pkg/command/context_test.go | 375 ++++++++++++++++++++++++++ pkg/command/plugin.go | 14 +- pkg/command/plugin_search.go | 15 +- pkg/utils/common.go | 9 + 7 files changed, 615 insertions(+), 50 deletions(-) create mode 100644 pkg/command/completion_helper.go create mode 100644 pkg/command/completion_helper_test.go diff --git a/pkg/command/completion_helper.go b/pkg/command/completion_helper.go new file mode 100644 index 000000000..79cc393fe --- /dev/null +++ b/pkg/command/completion_helper.go @@ -0,0 +1,25 @@ +// Copyright 2023 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "github.com/spf13/cobra" +) + +const ( + // Completion strings for the values of the --target flag + compK8sTarget = "k8s\tFor interactions with a Kubernetes cluster" + compTAETarget = "tae\tFor interactions with a Application Engine endpoint" + compTMCTarget = "tmc\tFor interactions with a Mission-Control endpoint" + + // Completion strings for the values of the --output flag + compTableOutput = "table\tOutput results in human-readable format" + compJSONOutput = "json\tOutput results in JSON format" + compYAMLOutput = "yaml\tOutput results in YAML format" +) + +// TODO(khouzam): move this to tanzu-plugin-runtime to be usable by plugins +func completionGetOutputFormats(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{compTableOutput, compJSONOutput, compYAMLOutput}, cobra.ShellCompDirectiveNoFileComp +} diff --git a/pkg/command/completion_helper_test.go b/pkg/command/completion_helper_test.go new file mode 100644 index 000000000..bbfd393c0 --- /dev/null +++ b/pkg/command/completion_helper_test.go @@ -0,0 +1,9 @@ +// Copyright 2023 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package command + +const ( + // Completion output for testing the --output flag + expectedOutForOutputFlag = compTableOutput + "\n" + compJSONOutput + "\n" + compYAMLOutput + "\n" +) diff --git a/pkg/command/context.go b/pkg/command/context.go index 27c583ab0..a8ca5532a 100644 --- a/pkg/command/context.go +++ b/pkg/command/context.go @@ -42,6 +42,7 @@ import ( "github.com/vmware-tanzu/tanzu-cli/pkg/constants" "github.com/vmware-tanzu/tanzu-cli/pkg/discovery" "github.com/vmware-tanzu/tanzu-cli/pkg/pluginmanager" + "github.com/vmware-tanzu/tanzu-cli/pkg/utils" ) var ( @@ -97,21 +98,31 @@ func init() { initCreateCtxCmd() listCtxCmd.Flags().StringVarP(&targetStr, "target", "t", "", "list only contexts associated with the specified target (kubernetes[k8s]/mission-control[tmc]/application-engine[tae])") + utils.PanicOnErr(listCtxCmd.RegisterFlagCompletionFunc("target", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{compK8sTarget, compTAETarget, compTMCTarget}, cobra.ShellCompDirectiveNoFileComp + })) + listCtxCmd.Flags().BoolVar(&onlyCurrent, "current", false, "list only current active contexts") listCtxCmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "output format: table|yaml|json") + utils.PanicOnErr(listCtxCmd.RegisterFlagCompletionFunc("output", completionGetOutputFormats)) getCtxCmd.Flags().StringVarP(&getOutputFmt, "output", "o", "yaml", "output format: yaml|json") + utils.PanicOnErr(getCtxCmd.RegisterFlagCompletionFunc("output", completionGetOutputFormats)) deleteCtxCmd.Flags().BoolVarP(&unattended, "yes", "y", false, "delete the context entry without confirmation") unsetCtxCmd.Flags().StringVarP(&targetStr, "target", "t", "", "unset active context associated with the specified target (kubernetes[k8s]|mission-control[tmc]|application-engine[tae])") + utils.PanicOnErr(unsetCtxCmd.RegisterFlagCompletionFunc("target", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{compK8sTarget, compTAETarget, compTMCTarget}, cobra.ShellCompDirectiveNoFileComp + })) } var createCtxCmd = &cobra.Command{ - Use: "create CONTEXT_NAME", - Short: "Create a Tanzu CLI context", - Args: cobra.MaximumNArgs(1), - RunE: createCtx, + Use: "create CONTEXT_NAME", + Short: "Create a Tanzu CLI context", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completeCreateCtx, + RunE: createCtx, Example: ` # Create a TKG management cluster context using endpoint and type (--type is optional, if not provided the CLI will infer the type from the endpoint) tanzu context create mgmt-cluster --endpoint https://k8s.example.com[:port] --type k8s-cluster-endpoint @@ -155,15 +166,32 @@ func initCreateCtxCmd() { _ = createCtxCmd.Flags().MarkDeprecated("name", "it has been replaced by using an argument to the command") createCtxCmd.Flags().StringVar(&endpoint, "endpoint", "", "endpoint to create a context for") + utils.PanicOnErr(createCtxCmd.RegisterFlagCompletionFunc("endpoint", cobra.NoFileCompletions)) + createCtxCmd.Flags().StringVar(&apiToken, "api-token", "", "API token for the SaaS context") + utils.PanicOnErr(createCtxCmd.RegisterFlagCompletionFunc("api-token", cobra.NoFileCompletions)) + + // Shell completion for this flag is the default behavior of doing file completion createCtxCmd.Flags().StringVar(&kubeConfig, "kubeconfig", "", "path to the kubeconfig file; valid only if user doesn't choose 'endpoint' option.(See [*])") + createCtxCmd.Flags().StringVar(&kubeContext, "kubecontext", "", "the context in the kubeconfig to use; valid only if user doesn't choose 'endpoint' option.(See [*]) ") + utils.PanicOnErr(createCtxCmd.RegisterFlagCompletionFunc("kubecontext", completeKubeContext)) + createCtxCmd.Flags().BoolVar(&stderrOnly, "stderr-only", false, "send all output to stderr rather than stdout") createCtxCmd.Flags().BoolVar(&forceCSP, "force-csp", false, "force the context to use CSP auth") createCtxCmd.Flags().BoolVar(&staging, "staging", false, "use CSP staging issuer") + // Shell completion for this flag is the default behavior of doing file completion createCtxCmd.Flags().StringVar(&endpointCACertPath, "endpoint-ca-certificate", "", "path to the endpoint public certificate") createCtxCmd.Flags().BoolVar(&skipTLSVerify, "insecure-skip-tls-verify", false, "skip endpoint's TLS certificate verification") createCtxCmd.Flags().StringVar(&contextType, "type", "", "type of context to create (mission-control | application-engine | k8s-cluster-endpoint | k8s-local-kubeconfig)") + utils.PanicOnErr(createCtxCmd.RegisterFlagCompletionFunc("type", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{ + "mission-control\tContext for a Tanzu Mission Control endpoint", + "application-engine\tContext for a Tanzu Application Engine endpoint", + "k8s-cluster-endpoint\tContext for a Kubernetes Cluster endpoint", + "k8s-local-kubeconfig\tContext using a Kubernetes local kubeconfig file"}, + cobra.ShellCompDirectiveNoFileComp + })) _ = createCtxCmd.Flags().MarkHidden("api-token") _ = createCtxCmd.Flags().MarkHidden("stderr-only") @@ -694,9 +722,10 @@ func vSphereSupervisorLogin(endpoint string) (mergeFilePath, currentContext stri } var listCtxCmd = &cobra.Command{ - Use: "list", - Short: "List contexts", - RunE: listCtx, + Use: "list", + Short: "List contexts", + ValidArgsFunction: cobra.NoFileCompletions, + RunE: listCtx, } func listCtx(cmd *cobra.Command, _ []string) error { @@ -719,9 +748,10 @@ func listCtx(cmd *cobra.Command, _ []string) error { } var getCtxCmd = &cobra.Command{ - Use: "get CONTEXT_NAME", - Short: "Display a context from the config", - RunE: getCtx, + Use: "get CONTEXT_NAME", + Short: "Display a context from the config", + ValidArgsFunction: completeAllContexts, + RunE: getCtx, } func getCtx(cmd *cobra.Command, args []string) error { @@ -825,9 +855,10 @@ func getValues(m map[configtypes.Target]*configtypes.Context) []*configtypes.Con } var deleteCtxCmd = &cobra.Command{ - Use: "delete CONTEXT_NAME", - Short: "Delete a context from the config", - RunE: deleteCtx, + Use: "delete CONTEXT_NAME", + Short: "Delete a context from the config", + ValidArgsFunction: completeAllContexts, + RunE: deleteCtx, } func deleteCtx(_ *cobra.Command, args []string) error { @@ -860,9 +891,10 @@ func deleteCtx(_ *cobra.Command, args []string) error { } var useCtxCmd = &cobra.Command{ - Use: "use CONTEXT_NAME", - Short: "Set the context to be used by default", - RunE: useCtx, + Use: "use CONTEXT_NAME", + Short: "Set the context to be used by default", + ValidArgsFunction: completeAllContexts, + RunE: useCtx, } func useCtx(_ *cobra.Command, args []string) error { @@ -911,10 +943,11 @@ func syncCurrentKubeContext(ctx *configtypes.Context) error { } var unsetCtxCmd = &cobra.Command{ - Use: "unset CONTEXT_NAME", - Short: "Unset the active context so that it is not used by default.", - Args: cobra.MaximumNArgs(1), - RunE: unsetCtx, + Use: "unset CONTEXT_NAME", + Short: "Unset the active context so that it is not used by default.", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completeActiveContexts, + RunE: unsetCtx, } func unsetCtx(_ *cobra.Command, args []string) error { @@ -1104,11 +1137,12 @@ func displayContextListOutputSplitViewTarget(cfg *configtypes.ClientConfig, writ } var getCtxTokenCmd = &cobra.Command{ - Use: "get-token CONTEXT_NAME", - Short: "Get the valid CSP token for the given TAE context.", - Args: cobra.ExactArgs(1), - Hidden: true, - RunE: getToken, + Use: "get-token CONTEXT_NAME", + Short: "Get the valid CSP token for the given TAE context.", + Args: cobra.ExactArgs(1), + Hidden: true, + ValidArgsFunction: completeTAEContexts, + RunE: getToken, } func getToken(cmd *cobra.Command, args []string) error { @@ -1172,11 +1206,12 @@ func newUpdateCtxCmd() *cobra.Command { // // NOTE!!: This command is EXPERIMENTAL and subject to change in future var taeActiveResourceCmd = &cobra.Command{ - Use: "tae-active-resource CONTEXT_NAME", - Short: "updates the Tanzu Application Engine(TAE) active resource for the given TAE context (subject to change).", - Hidden: true, - Args: cobra.ExactArgs(1), - RunE: setTAECtxActiveResource, + Use: "tae-active-resource CONTEXT_NAME", + Short: "updates the Tanzu Application Engine(TAE) active resource for the given TAE context (subject to change).", + Hidden: true, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeTAEContexts, + RunE: setTAECtxActiveResource, } func setTAECtxActiveResource(_ *cobra.Command, args []string) error { @@ -1239,3 +1274,126 @@ func prepareClusterServerURL(context *configtypes.Context, projectName, spaceNam } return serverURL + "/space/" + spaceName } + +// ==================================== +// Shell completion functions +// ==================================== +func completeAllContexts(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + cfg, err := config.GetClientConfig() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + target := getTarget() + + var allCtxs []*configtypes.Context + for _, ctx := range cfg.KnownContexts { + if target == configtypes.TargetUnknown || target == ctx.Target { + allCtxs = append(allCtxs, ctx) + } + } + return completionFormatCtxs(allCtxs), cobra.ShellCompDirectiveNoFileComp +} + +func completeTAEContexts(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + cfg, err := config.GetClientConfig() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var taeCtxs []*configtypes.Context + for _, ctx := range cfg.KnownContexts { + if ctx.Target == configtypes.TargetTAE { + taeCtxs = append(taeCtxs, ctx) + } + } + return completionFormatCtxs(taeCtxs), cobra.ShellCompDirectiveNoFileComp +} + +func completeActiveContexts(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + currentCtxMap, err := config.GetAllCurrentContextsMap() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + target := getTarget() + + var allCtxs []*configtypes.Context + for _, ctx := range currentCtxMap { + if target == configtypes.TargetUnknown || target == ctx.Target { + allCtxs = append(allCtxs, ctx) + } + } + return completionFormatCtxs(allCtxs), cobra.ShellCompDirectiveNoFileComp +} + +// Setup shell completion for the kube-context flag +func completeKubeContext(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + if kubeConfig == "" { + kubeConfig = getDefaultKubeconfigPath() + } + + cobra.CompDebugln("About to get the different kube-contexts", false) + + kubeclient, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeConfig}, + &clientcmd.ConfigOverrides{}).RawConfig() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var comps []string + for name, context := range kubeclient.Contexts { + comps = append(comps, fmt.Sprintf("%s\t%s@%s", name, context.AuthInfo, context.Cluster)) + } + // Sort the completion to make testing easier + sort.Strings(comps) + return comps, cobra.ShellCompDirectiveNoFileComp +} + +func completeCreateCtx(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + comps := cobra.AppendActiveHelp(nil, "Please specify a name for the context") + return comps, cobra.ShellCompDirectiveNoFileComp + } + + if endpoint == "" && kubeContext == "" { + // The user must provide more info by using flags. + // Note that those flags are not marked as mandatory + // because the prompt mechanism can be used instead. + comps := []string{"--"} + return comps, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } + + // The user has provided enough information + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func completionFormatCtxs(ctxs []*configtypes.Context) []string { + var comps []string + for _, ctx := range ctxs { + info, _ := config.EndpointFromContext(ctx) + + if info == "" && ctx.Target == configtypes.TargetK8s && ctx.ClusterOpts != nil { + info = fmt.Sprintf("%s:%s", ctx.ClusterOpts.Path, ctx.ClusterOpts.Context) + } + + comps = append(comps, fmt.Sprintf("%s\t%s", ctx.Name, info)) + } + + // Sort the completion to make testing easier + sort.Strings(comps) + return comps +} diff --git a/pkg/command/context_test.go b/pkg/command/context_test.go index 6399622ed..bf0db3b97 100644 --- a/pkg/command/context_test.go +++ b/pkg/command/context_test.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -50,6 +52,68 @@ const ( testSpace = "test-space" ) +const kubeconfigContent1 = `apiVersion: v1 +kind: Config +preferences: {} +clusters: +- cluster: + server: https://example.com/1:6443 + name: cluster-name1 +- cluster: + server: https://example.com/2:6443 + name: cluster-name2 +contexts: +- context: + cluster: cluster-name1 + namespace: default + user: user-name1 + name: context-name1 +- context: + cluster: cluster-name2 + namespace: default + user: user-name2 + name: context-name2 +current-context: context-name1 +users: +- name: user-name1 + user: + token: token1 +- name: user-name2 + user: + token: token2 + ` + +const kubeconfigContent2 = `apiVersion: v1 +kind: Config +preferences: {} +clusters: +- cluster: + server: https://example.com/1:6443 + name: cluster-name8 +- cluster: + server: https://example.com/2:6443 + name: cluster-name9 +contexts: +- context: + cluster: cluster-name8 + namespace: default + user: user-name8 + name: context-name8 +- context: + cluster: cluster-name9 + namespace: default + user: user-name9 + name: context-name9 +current-context: context-name8 +users: +- name: user-name8 + user: + token: token8 +- name: user-name9 + user: + token: token9 +` + var _ = Describe("Test tanzu context command", func() { var ( tkgConfigFile *os.File @@ -830,6 +894,317 @@ var _ = Describe("testing context use", func() { }) }) +func Test_completionContext(t *testing.T) { + ctxK8s1 := &configtypes.Context{ + Name: "tkg1", + Target: configtypes.TargetK8s, + ClusterOpts: &configtypes.ClusterServer{Endpoint: "https://example.com/myendpoint/k8s/1"}, + } + ctxK8s2 := &configtypes.Context{ + Name: "tkg2", + Target: configtypes.TargetK8s, + ClusterOpts: &configtypes.ClusterServer{Path: "/example.com/mypath/k8s/2", Context: "ctxTkg2"}, + } + ctxTMC1 := &configtypes.Context{ + Name: "tmc1", + Target: configtypes.TargetTMC, + GlobalOpts: &configtypes.GlobalServer{Endpoint: "https://example.com/myendpoint/tmc/1"}, + } + ctxTMC2 := &configtypes.Context{ + Name: "tmc2", + Target: configtypes.TargetTMC, + GlobalOpts: &configtypes.GlobalServer{Endpoint: "https://example.com/myendpoint/tmc/2"}, + } + ctxTAE1 := &configtypes.Context{ + Name: "tae1", + Target: configtypes.TargetTAE, + ClusterOpts: &configtypes.ClusterServer{Endpoint: "https://example.com/myendpoint/tae/1"}, + } + ctxTAE2 := &configtypes.Context{ + Name: "tae2", + Target: configtypes.TargetTAE, + ClusterOpts: &configtypes.ClusterServer{Endpoint: "https://example.com/myendpoint/tae/2"}, + } + + expectedOutForAllCtxs := ctxTAE1.Name + "\t" + ctxTAE1.ClusterOpts.Endpoint + "\n" + expectedOutForAllCtxs += ctxTAE2.Name + "\t" + ctxTAE2.ClusterOpts.Endpoint + "\n" + expectedOutForAllCtxs += ctxK8s1.Name + "\t" + ctxK8s1.ClusterOpts.Endpoint + "\n" + expectedOutForAllCtxs += ctxK8s2.Name + "\t" + ctxK8s2.ClusterOpts.Path + ":" + ctxK8s2.ClusterOpts.Context + "\n" + expectedOutForAllCtxs += ctxTMC1.Name + "\t" + ctxTMC1.GlobalOpts.Endpoint + "\n" + expectedOutForAllCtxs += ctxTMC2.Name + "\t" + ctxTMC2.GlobalOpts.Endpoint + "\n" + + expectedOutForActiveCtxs := ctxTAE1.Name + "\t" + ctxTAE1.ClusterOpts.Endpoint + "\n" + expectedOutForActiveCtxs += ctxK8s1.Name + "\t" + ctxK8s1.ClusterOpts.Endpoint + "\n" + expectedOutForActiveCtxs += ctxTMC1.Name + "\t" + ctxTMC1.GlobalOpts.Endpoint + "\n" + + expectedOutForTMCActiveCtx := ctxTMC1.Name + "\t" + ctxTMC1.GlobalOpts.Endpoint + "\n" + + expectedOutForTAECtxs := ctxTAE1.Name + "\t" + ctxTAE1.ClusterOpts.Endpoint + "\n" + expectedOutForTAECtxs += ctxTAE2.Name + "\t" + ctxTAE2.ClusterOpts.Endpoint + "\n" + + expectedOutforTargetFlag := compK8sTarget + "\n" + compTAETarget + "\n" + compTMCTarget + "\n" + + kubeconfigFile1, err := os.CreateTemp("", "kubeconfig") + assert.Nil(t, err) + n, err := kubeconfigFile1.WriteString(kubeconfigContent1) + assert.Nil(t, err) + assert.Equal(t, len(kubeconfigContent1), n) + + kubeconfigFile2, err := os.CreateTemp("", "kubeconfig") + assert.Nil(t, err) + n, err = kubeconfigFile2.WriteString(kubeconfigContent2) + assert.Nil(t, err) + assert.Equal(t, len(kubeconfigContent2), n) + + // Set the default config file to the second file + os.Setenv("KUBECONFIG", kubeconfigFile2.Name()) + + tests := []struct { + test string + args []string + expected string + }{ + // ===================== + // tanzu context list + // ===================== + { + test: "no completion after the list command", + args: []string{"__complete", "context", "list", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: ":4\n", + }, + { + test: "completion for the --target flag value of the list command", + args: []string{"__complete", "context", "list", "--target", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutforTargetFlag + ":4\n", + }, + { + test: "completion for the --output flag value of the list command", + args: []string{"__complete", "context", "list", "--output", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutForOutputFlag + ":4\n", + }, + // ===================== + // tanzu context delete + // ===================== + { + test: "complete all contexts after the delete command", + args: []string{"__complete", "context", "delete", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutForAllCtxs + ":4\n", + }, + { + test: "no completion after the first argument of the delete command", + args: []string{"__complete", "context", "delete", "tkg1", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: ":4\n", + }, + // ===================== + // tanzu context get + // ===================== + { + test: "complete all contexts after the get command", + args: []string{"__complete", "context", "get", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutForAllCtxs + ":4\n", + }, + { + test: "no completion after the first argument of the get command", + args: []string{"__complete", "context", "get", "tkg1", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: ":4\n", + }, + { + test: "completion for the --output flag value of the get command", + args: []string{"__complete", "context", "get", "--output", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutForOutputFlag + ":4\n", + }, + // ===================== + // tanzu context use + // ===================== + { + test: "complete all contexts after the use command", + args: []string{"__complete", "context", "use", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutForAllCtxs + ":4\n", + }, + { + test: "no completion after the first argument of the use command", + args: []string{"__complete", "context", "use", "tkg1", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: ":4\n", + }, + // ===================== + // tanzu context unset + // ===================== + { + test: "complete active contexts after the unset command", + args: []string{"__complete", "context", "unset", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutForActiveCtxs + ":4\n", + }, + { + test: "no completion after the first argument of the unset command", + args: []string{"__complete", "context", "unset", "tkg1", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: ":4\n", + }, + { + test: "complete active context matching the --target flag for the unset command", + args: []string{"__complete", "context", "unset", "--target", "tmc", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutForTMCActiveCtx + ":4\n", + }, + { + test: "completion for the --target flag value of the unset command", + args: []string{"__complete", "context", "unset", "--target", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutforTargetFlag + ":4\n", + }, + // ===================== + // tanzu context create + // ===================== + { + test: "completion for the arg of the create command", + args: []string{"__complete", "context", "create", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: "_activeHelp_ Please specify a name for the context\n:4\n", + }, + { + test: "completion after one arg of the create command", + args: []string{"__complete", "context", "create", "tkg1", ""}, + // ":6" is the value of the ShellCompDirectiveNoFileComp | ShellCompDirectiveNoSpace + expected: "--\n:6\n", + }, + { + test: "completion after one arg of the create command with --endpoint", + args: []string{"__complete", "context", "create", "tkg1", "--endpoint", "uri", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: ":4\n", + }, + { + test: "completion after one arg of the create command with --kubecontext", + args: []string{"__complete", "context", "create", "tkg1", "--kubecontext", "ctx", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: ":4\n", + }, + { + test: "completion after one arg of the create command with --kubeconfig", + args: []string{"__complete", "context", "create", "tkg1", "--kubeconfig", "path", ""}, + // ":6" is the value of the ShellCompDirectiveNoFileComp | ShellCompDirectiveNoSpace + expected: "--\n:6\n", + }, + { + test: "completion for the --endpoint flag value of the create command", + args: []string{"__complete", "context", "create", "--endpoint", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: ":4\n", + }, + { + test: "completion for the --api-token flag value of the create command", + args: []string{"__complete", "context", "create", "--api-token", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: ":4\n", + }, + { + test: "completion for the --kubeconfig flag value of the create command", + args: []string{"__complete", "context", "create", "--kubeconfig", ""}, + // ":0" is the value of the ShellCompDirectiveDefault which indicates + // that file completion will be performed + expected: ":0\n", + }, + { + test: "completion for the --kubecontext flag with --kubeconfig", + args: []string{"__complete", "context", "create", "--kubeconfig", kubeconfigFile1.Name(), "--kubecontext", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: "context-name1\tuser-name1@cluster-name1\n" + + "context-name2\tuser-name2@cluster-name2\n" + ":4\n", + }, + { + test: "completion for the --kubecontext flag without --kubeconfig", + args: []string{"__complete", "context", "create", "--kubecontext", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: "context-name8\tuser-name8@cluster-name8\n" + + "context-name9\tuser-name9@cluster-name9\n" + ":4\n", + }, + { + test: "completion for the --type flag", + args: []string{"__complete", "context", "create", "--type", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: "mission-control\tContext for a Tanzu Mission Control endpoint\n" + + "application-engine\tContext for a Tanzu Application Engine endpoint\n" + + "k8s-cluster-endpoint\tContext for a Kubernetes Cluster endpoint\n" + + "k8s-local-kubeconfig\tContext using a Kubernetes local kubeconfig file\n" + + ":4\n", + }, + // ===================== + // tanzu context get-token + // ===================== + { + test: "completion for the context get-token tae command", + args: []string{"__complete", "context", "get-token", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutForTAECtxs + ":4\n", + }, + // ===================== + // tanzu context update + // ===================== + { + test: "completion for the context get-token tae command", + args: []string{"__complete", "context", "update", "tae-active-resource", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: expectedOutForTAECtxs + ":4\n", + }, + } + + // Setup a temporary configuration + configFile, err := os.CreateTemp("", "config") + assert.Nil(t, err) + os.Setenv("TANZU_CONFIG", configFile.Name()) + configFileNG, err := os.CreateTemp("", "config_ng") + assert.Nil(t, err) + os.Setenv("TANZU_CONFIG_NEXT_GEN", configFileNG.Name()) + + // Add some context, two per target + _ = config.SetContext(ctxK8s1, true) + _ = config.SetContext(ctxK8s2, false) + _ = config.SetContext(ctxTMC1, true) + _ = config.SetContext(ctxTMC2, false) + _ = config.SetContext(ctxTAE1, true) + _ = config.SetContext(ctxTAE2, false) + + for _, spec := range tests { + t.Run(spec.test, func(t *testing.T) { + assert := assert.New(t) + + rootCmd, err := NewRootCmd() + assert.Nil(err) + + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetArgs(spec.args) + + err = rootCmd.Execute() + assert.Nil(err) + + assert.Equal(spec.expected, out.String()) + + resetContextCommandFlags() + }) + } + + os.Unsetenv("TANZU_CONFIG") + os.Unsetenv("TANZU_CONFIG_NEXT_GEN") + os.RemoveAll(configFile.Name()) + os.RemoveAll(configFileNG.Name()) + + os.Unsetenv("KUBECONFIG") + os.RemoveAll(kubeconfigFile1.Name()) + os.RemoveAll(kubeconfigFile2.Name()) +} + func resetContextCommandFlags() { ctxName = "" endpoint = "" diff --git a/pkg/command/plugin.go b/pkg/command/plugin.go index a95035ac1..22b8838a6 100644 --- a/pkg/command/plugin.go +++ b/pkg/command/plugin.go @@ -24,6 +24,7 @@ import ( "github.com/vmware-tanzu/tanzu-cli/pkg/discovery" "github.com/vmware-tanzu/tanzu-cli/pkg/pluginmanager" "github.com/vmware-tanzu/tanzu-cli/pkg/pluginsupplier" + "github.com/vmware-tanzu/tanzu-cli/pkg/utils" "github.com/vmware-tanzu/tanzu-plugin-runtime/log" ) @@ -72,20 +73,13 @@ func newPluginCmd() *cobra.Command { // --local is renamed to --local-source installPluginCmd.Flags().StringVarP(&local, "local", "", "", "path to local plugin source") msg := "this was done in the v1.0.0 release, it will be removed following the deprecation policy (6 months). Use the --local-source flag instead.\n" - if err := installPluginCmd.Flags().MarkDeprecated("local", msg); err != nil { - // Will only fail if the flag does not exist, which would indicate a coding error, - // so let's panic so we notice immediately. - panic(err) - } + utils.PanicOnErr(installPluginCmd.Flags().MarkDeprecated("local", msg)) // The --local-source flag for installing plugins is only used in development testing // and should not be used in production. We mark it as hidden to help convey this reality. installPluginCmd.Flags().StringVarP(&local, "local-source", "l", "", "path to local plugin source") - if err := installPluginCmd.Flags().MarkHidden("local-source"); err != nil { - // Will only fail if the flag does not exist, which would indicate a coding error, - // so let's panic so we notice immediately. - panic(err) - } + utils.PanicOnErr(installPluginCmd.Flags().MarkHidden("local-source")) + installPluginCmd.Flags().StringVarP(&version, "version", "v", cli.VersionLatest, "version of the plugin") deletePluginCmd.Flags().BoolVarP(&forceDelete, "yes", "y", false, "delete the plugin without asking for confirmation") diff --git a/pkg/command/plugin_search.go b/pkg/command/plugin_search.go index 4849e501f..9f7fafed9 100644 --- a/pkg/command/plugin_search.go +++ b/pkg/command/plugin_search.go @@ -16,6 +16,7 @@ import ( "github.com/vmware-tanzu/tanzu-cli/pkg/common" "github.com/vmware-tanzu/tanzu-cli/pkg/discovery" "github.com/vmware-tanzu/tanzu-cli/pkg/pluginmanager" + "github.com/vmware-tanzu/tanzu-cli/pkg/utils" "github.com/vmware-tanzu/tanzu-plugin-runtime/component" configtypes "github.com/vmware-tanzu/tanzu-plugin-runtime/config/types" ) @@ -84,19 +85,13 @@ func newSearchPluginCmd() *cobra.Command { f.StringVarP(&local, "local", "", "", "path to local plugin source") msg := fmt.Sprintf("this was done in the %q release, it will be removed following the deprecation policy (6 months). Use the %q flag instead.\n", "v1.0.0", "--local-source") - if err := f.MarkDeprecated("local", msg); err != nil { - // Will only fail if the flag does not exist, which would indicate a coding error, - // so let's panic so we notice immediately. - panic(err) - } + utils.PanicOnErr(f.MarkDeprecated("local", msg)) + f.StringVarP(&local, "local-source", "l", "", "path to local plugin source") // We hide the "local-source" flag because installing from a local-source is not supported in production. // See the "local-source" flag of the "plugin install" command. - if err := f.MarkHidden("local-source"); err != nil { - // Will only fail if the flag does not exist, which would indicate a coding error, - // so let's panic so we notice immediately. - panic(err) - } + utils.PanicOnErr(f.MarkHidden("local-source")) + f.StringVarP(&targetStr, "target", "t", "", fmt.Sprintf("limit the search to plugins of the specified target (%s)", common.TargetList)) searchCmd.MarkFlagsMutuallyExclusive("local", "name") diff --git a/pkg/utils/common.go b/pkg/utils/common.go index dbeb8f11f..0af6f539d 100644 --- a/pkg/utils/common.go +++ b/pkg/utils/common.go @@ -30,3 +30,12 @@ func ContainsString(arr []string, str string) bool { func GenerateKey(parts ...string) string { return strings.Join(parts, ":") } + +// PanicOnErr calls 'panic' if 'err' is non-nil. +func PanicOnErr(err error) { + if err == nil { + return + } + + panic(err) +}