diff --git a/pkg/kn/commands/completion_helper.go b/pkg/kn/commands/completion_helper.go new file mode 100644 index 000000000..69cdbbae5 --- /dev/null +++ b/pkg/kn/commands/completion_helper.go @@ -0,0 +1,125 @@ +// Copyright © 2021 The Knative 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 commands + +import ( + "strings" + + "github.com/spf13/cobra" +) + +type completionConfig struct { + params *KnParams + command *cobra.Command + args []string + toComplete string +} + +var ( + resourceToFuncMap = map[string]func(config *completionConfig) []string{ + "service": completeService, + } +) + +// ResourceNameCompletionFunc will return a function that will autocomplete the name of +// the resource based on the subcommand +func ResourceNameCompletionFunc(p *KnParams) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + + var use string + if cmd.Parent() != nil { + use = cmd.Parent().Use + } + config := completionConfig{ + p, + cmd, + args, + toComplete, + } + return config.getCompletion(use), cobra.ShellCompDirectiveNoFileComp + } +} + +func (config *completionConfig) getCompletion(parent string) []string { + completionFunc := resourceToFuncMap[parent] + if completionFunc == nil { + return []string{} + } + return completionFunc(config) +} + +func getTargetFlagValue(cmd *cobra.Command) string { + flag := cmd.Flag("target") + if flag == nil { + return "" + } + return flag.Value.String() +} + +func completeGitOps(config *completionConfig) (suggestions []string) { + suggestions = make([]string, 0) + if len(config.args) != 0 { + return + } + namespace, err := config.params.GetNamespace(config.command) + if err != nil { + return + } + client, err := config.params.NewGitopsServingClient(namespace, getTargetFlagValue(config.command)) + if err != nil { + return + } + serviceList, err := client.ListServices(config.command.Context()) + if err != nil { + return + } + for _, sug := range serviceList.Items { + if !strings.HasPrefix(sug.Name, config.toComplete) { + continue + } + suggestions = append(suggestions, sug.Name) + } + return +} + +func completeService(config *completionConfig) (suggestions []string) { + if getTargetFlagValue(config.command) != "" { + return completeGitOps(config) + } + + suggestions = make([]string, 0) + if len(config.args) != 0 { + return + } + namespace, err := config.params.GetNamespace(config.command) + if err != nil { + return + } + client, err := config.params.NewServingClient(namespace) + if err != nil { + return + } + serviceList, err := client.ListServices(config.command.Context()) + if err != nil { + return + } + for _, sug := range serviceList.Items { + if !strings.HasPrefix(sug.Name, config.toComplete) { + continue + } + suggestions = append(suggestions, sug.Name) + } + return +} diff --git a/pkg/kn/commands/completion_helper_test.go b/pkg/kn/commands/completion_helper_test.go new file mode 100644 index 000000000..ed6d5714b --- /dev/null +++ b/pkg/kn/commands/completion_helper_test.go @@ -0,0 +1,293 @@ +// Copyright © 2021 The Knative 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 commands + +import ( + "fmt" + "io/ioutil" + "os" + "path" + "testing" + + "github.com/spf13/cobra" + "gotest.tools/v3/assert" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + clienttesting "k8s.io/client-go/testing" + v1 "knative.dev/client/pkg/serving/v1" + v12 "knative.dev/serving/pkg/apis/serving/v1" + servingv1fake "knative.dev/serving/pkg/client/clientset/versioned/typed/serving/v1/fake" +) + +type testType struct { + name string + namespace string + p *KnParams + args []string + toComplete string + resource string +} + +const ( + testNs = "test-ns" + errorNs = "error-ns" +) + +var ( + testSvc1 = v12.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: "serving.knative.dev/v1", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-svc-1", Namespace: testNs}, + } + testSvc2 = v12.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: "serving.knative.dev/v1", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-svc-2", Namespace: testNs}, + } + testSvc3 = v12.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: "serving.knative.dev/v1", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-svc-3", Namespace: testNs}, + } + testNsServices = []v12.Service{testSvc1, testSvc2, testSvc3} + + fakeServing = &servingv1fake.FakeServingV1{Fake: &clienttesting.Fake{}} + knParams = &KnParams{ + NewServingClient: func(namespace string) (v1.KnServingClient, error) { + return v1.NewKnServingClient(fakeServing, namespace), nil + }, + NewGitopsServingClient: func(namespace string, dir string) (v1.KnServingClient, error) { + return v1.NewKnServingGitOpsClient(namespace, dir), nil + }, + } +) + +func TestResourceNameCompletionFuncService(t *testing.T) { + completionFunc := ResourceNameCompletionFunc(knParams) + + fakeServing.AddReactor("list", "services", + func(a clienttesting.Action) (bool, runtime.Object, error) { + if a.GetNamespace() == errorNs { + return true, nil, errors.NewInternalError(fmt.Errorf("unable to list services")) + } + return true, &v12.ServiceList{Items: testNsServices}, nil + }) + + tests := []testType{ + { + "Empty suggestions when no parent command found", + testNs, + knParams, + nil, + "", + "no-parent", + }, + { + "Empty suggestions when non-zero args", + testNs, + knParams, + []string{"xyz"}, + "", + "service", + }, + { + "Empty suggestions when no namespace flag", + "", + knParams, + nil, + "", + "service", + }, + { + "Suggestions when test-ns namespace set", + testNs, + knParams, + nil, + "", + "service", + }, + { + "Empty suggestions when toComplete is not a prefix", + testNs, + knParams, + nil, + "xyz", + "service", + }, + { + "Empty suggestions when error during list operation", + errorNs, + knParams, + nil, + "", + "service", + }, + } + for _, tt := range tests { + cmd := getResourceCommandWithTestSubcommand(tt.resource, tt.namespace != "", tt.resource != "no-parent") + t.Run(tt.name, func(t *testing.T) { + config := &completionConfig{ + params: tt.p, + command: cmd, + args: tt.args, + toComplete: tt.toComplete, + } + expectedFunc := resourceToFuncMap[tt.resource] + if expectedFunc == nil { + expectedFunc = func(config *completionConfig) []string { + return []string{} + } + } + cmd.Flags().Set("namespace", tt.namespace) + actualSuggestions, actualDirective := completionFunc(cmd, tt.args, tt.toComplete) + expectedSuggestions := expectedFunc(config) + expectedDirective := cobra.ShellCompDirectiveNoFileComp + assert.DeepEqual(t, actualSuggestions, expectedSuggestions) + assert.Equal(t, actualDirective, expectedDirective) + }) + } +} + +func TestResourceNameCompletionFuncGitOps(t *testing.T) { + tempDir := setupTempDir(t) + assert.Assert(t, tempDir != "") + defer os.RemoveAll(tempDir) + + completionFunc := ResourceNameCompletionFunc(knParams) + + tests := []testType{ + { + "Empty suggestions when no parent command found", + testNs, + knParams, + nil, + "", + "service", + }, + { + "Empty suggestions when non-zero args", + testNs, + knParams, + []string{"xyz"}, + "", + "service", + }, + { + "Empty suggestions when no namespace flag", + "", + knParams, + nil, + "", + "service", + }, + { + "Suggestions when test-ns namespace set", + testNs, + knParams, + nil, + "", + "service", + }, + { + "Empty suggestions when toComplete is not a prefix", + testNs, + knParams, + nil, + "xyz", + "service", + }, + { + "Empty suggestions when error during list operation", + errorNs, + knParams, + nil, + "", + "service", + }, + } + + for _, tt := range tests { + cmd := getResourceCommandWithTestSubcommand(tt.resource, tt.namespace != "", tt.resource != "no-parent") + t.Run(tt.name, func(t *testing.T) { + config := &completionConfig{ + params: tt.p, + command: cmd, + args: tt.args, + toComplete: tt.toComplete, + } + expectedFunc := resourceToFuncMap[tt.resource] + cmd.Flags().String("target", tempDir, "target directory") + cmd.Flags().Set("namespace", tt.namespace) + + expectedSuggestions := expectedFunc(config) + expectedDirective := cobra.ShellCompDirectiveNoFileComp + actualSuggestions, actualDirective := completionFunc(cmd, tt.args, tt.toComplete) + assert.DeepEqual(t, actualSuggestions, expectedSuggestions) + assert.Equal(t, actualDirective, expectedDirective) + }) + } +} + +func getResourceCommandWithTestSubcommand(resource string, addNamespace, addSubcommand bool) *cobra.Command { + testCommand := &cobra.Command{ + Use: resource, + } + testSubCommand := &cobra.Command{ + Use: "test", + } + if addSubcommand { + testCommand.AddCommand(testSubCommand) + } + if addNamespace { + AddNamespaceFlags(testCommand.Flags(), true) + AddNamespaceFlags(testSubCommand.Flags(), true) + } + return testSubCommand +} + +func setupTempDir(t *testing.T) string { + tempDir, err := ioutil.TempDir("", "test-dir") + assert.NilError(t, err) + + svcPath := path.Join(tempDir, "test-ns", "ksvc") + err = os.MkdirAll(svcPath, 0700) + assert.NilError(t, err) + + for i, testSvc := range []v12.Service{testSvc1, testSvc2, testSvc3} { + tempFile, err := os.Create(path.Join(svcPath, fmt.Sprintf("test-svc-%d.yaml", i+1))) + assert.NilError(t, err) + writeToFile(t, testSvc, tempFile) + } + + return tempDir +} + +func writeToFile(t *testing.T, testSvc v12.Service, tempFile *os.File) { + yamlPrinter, err := genericclioptions.NewJSONYamlPrintFlags().ToPrinter("yaml") + assert.NilError(t, err) + + err = yamlPrinter.PrintObj(&testSvc, tempFile) + assert.NilError(t, err) + + defer tempFile.Close() +} diff --git a/pkg/kn/commands/namespaced.go b/pkg/kn/commands/namespaced.go index 77ece9d09..3b7b90128 100644 --- a/pkg/kn/commands/namespaced.go +++ b/pkg/kn/commands/namespaced.go @@ -15,6 +15,8 @@ package commands import ( + "fmt" + "github.com/spf13/cobra" "github.com/spf13/pflag" "k8s.io/client-go/tools/clientcmd" @@ -43,7 +45,11 @@ func AddNamespaceFlags(flags *pflag.FlagSet, allowAll bool) { // GetNamespace returns namespace from command specified by flag func (params *KnParams) GetNamespace(cmd *cobra.Command) (string, error) { - namespace := cmd.Flag("namespace").Value.String() + namespaceFlag := cmd.Flag("namespace") + if namespaceFlag == nil { + return "", fmt.Errorf("command %s doesn't have --namespace flag", cmd.Name()) + } + namespace := namespaceFlag.Value.String() // check value of all-namespaces only if its defined if cmd.Flags().Lookup("all-namespaces") != nil { all, err := cmd.Flags().GetBool("all-namespaces") diff --git a/pkg/kn/commands/service/delete.go b/pkg/kn/commands/service/delete.go index 552bf2f1a..d7524f167 100644 --- a/pkg/kn/commands/service/delete.go +++ b/pkg/kn/commands/service/delete.go @@ -102,6 +102,7 @@ func NewServiceDeleteCommand(p *commands.KnParams) *cobra.Command { } return nil }, + ValidArgsFunction: commands.ResourceNameCompletionFunc(p), } flags := serviceDeleteCommand.Flags() flags.Bool("all", false, "Delete all services in a namespace.") diff --git a/pkg/kn/commands/service/describe.go b/pkg/kn/commands/service/describe.go index a38ecefdb..4b6cbe9f7 100644 --- a/pkg/kn/commands/service/describe.go +++ b/pkg/kn/commands/service/describe.go @@ -94,9 +94,10 @@ func NewServiceDescribeCommand(p *commands.KnParams) *cobra.Command { machineReadablePrintFlags := genericclioptions.NewPrintFlags("") command := &cobra.Command{ - Use: "describe NAME", - Short: "Show details of a service", - Example: describe_example, + Use: "describe NAME", + Short: "Show details of a service", + Example: describe_example, + ValidArgsFunction: commands.ResourceNameCompletionFunc(p), RunE: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { return errors.New("'service describe' requires the service name given as single argument") diff --git a/pkg/kn/commands/service/update.go b/pkg/kn/commands/service/update.go index 7619d1f40..f9c28e7e3 100644 --- a/pkg/kn/commands/service/update.go +++ b/pkg/kn/commands/service/update.go @@ -65,9 +65,10 @@ func NewServiceUpdateCommand(p *commands.KnParams) *cobra.Command { var waitFlags commands.WaitFlags var trafficFlags flags.Traffic serviceUpdateCommand := &cobra.Command{ - Use: "update NAME", - Short: "Update a service", - Example: updateExample, + Use: "update NAME", + Short: "Update a service", + Example: updateExample, + ValidArgsFunction: commands.ResourceNameCompletionFunc(p), RunE: func(cmd *cobra.Command, args []string) (err error) { if len(args) != 1 { return errors.New("'service update' requires the service name given as single argument")