diff --git a/cmd/crossplane/completion/completion.go b/cmd/crossplane/completion/completion.go index f102c8ed..ff1b1844 100644 --- a/cmd/crossplane/completion/completion.go +++ b/cmd/crossplane/completion/completion.go @@ -47,7 +47,7 @@ func Predictors() map[string]complete.Predictor { // last completed argument. func kubernetesResourcePredictor() complete.PredictFunc { return func(a complete.Args) []string { - _, kubeconfig, _, err := kubernetesClient(parseConfigOverride(a), parseImpersonation(a)) + _, kubeconfig, _, err := kubernetesClient(parseContext(a), parseImpersonation(a)) if err != nil { return nil } @@ -102,7 +102,7 @@ func kubernetesResourcePredictor() complete.PredictFunc { // last completed argument. func kubernetesResourceNamePredictor() complete.PredictFunc { return func(a complete.Args) []string { - client, kubeconfig, clientconfig, err := kubernetesClient(parseConfigOverride(a), parseImpersonation(a)) + client, kubeconfig, clientconfig, err := kubernetesClient(parseContext(a), parseImpersonation(a)) if err != nil { return nil } @@ -180,12 +180,13 @@ func contextPredictor() complete.PredictFunc { } } -// namespacePredictor returns a predictor that suggests Kubernetes namespaces from the current context. +// namespacePredictor returns a predictor that suggests Kubernetes namespaces from the current context +// or the context specified in the command line arguments. // It uses the Kubernetes client to retrieve the available namespaces and filters them based on the // last completed argument. func namespacePredictor() complete.PredictFunc { return func(a complete.Args) []string { - client, err := kubernetesClientset(parseImpersonation(a)) + client, err := kubernetesClientset(parseContext(a), parseImpersonation(a)) if err != nil { return nil } @@ -207,61 +208,56 @@ func namespacePredictor() complete.PredictFunc { } } -// kubernetesClientset returns a Kubernetes clientset using the default -// kubeconfig and the given impersonation flags. -func kubernetesClientset(imp kube.ImpersonationFlags) (*kubernetes.Clientset, error) { - clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - clientcmd.NewDefaultClientConfigLoadingRules(), - &clientcmd.ConfigOverrides{}, - ) - - kubeConfig, err := clientConfig.ClientConfig() +// kubernetesClientset returns a Kubernetes clientset for the given context +// (the current one if empty) and impersonation flags. +func kubernetesClientset(kubeContext string, imp kube.ImpersonationFlags) (*kubernetes.Clientset, error) { + cfg, _, err := kube.RESTConfig(kubeContext, imp) if err != nil { return nil, err } - imp.Apply(kubeConfig) - - return kubernetes.NewForConfig(kubeConfig) + return kubernetes.NewForConfig(cfg) } -// kubernetesClient returns a Kubernetes client and a rest.Config using the -// provided config overrides and impersonation flags. -func kubernetesClient(configOverrides *clientcmd.ConfigOverrides, imp kube.ImpersonationFlags) (controllerClient.Client, *rest.Config, clientcmd.ClientConfig, error) { - clientconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - clientcmd.NewDefaultClientConfigLoadingRules(), - configOverrides, - ) - - kubeconfig, err := clientconfig.ClientConfig() +// kubernetesClient returns a Kubernetes client, its rest.Config, and the +// ClientConfig it was loaded from, for the given context (the current one if +// empty) and impersonation flags. +func kubernetesClient(kubeContext string, imp kube.ImpersonationFlags) (controllerClient.Client, *rest.Config, clientcmd.ClientConfig, error) { + cfg, clientconfig, err := kube.RESTConfig(kubeContext, imp) if err != nil { return nil, nil, nil, err } - imp.Apply(kubeconfig) - - client, err := controllerClient.New(rest.CopyConfig(kubeconfig), controllerClient.Options{}) + client, err := controllerClient.New(cfg, controllerClient.Options{}) if err != nil { return nil, nil, nil, err } - return client, rest.CopyConfig(kubeconfig), clientconfig, nil + return client, cfg, clientconfig, nil } -// parseConfigOverride parses ConfigOverrides for the k8s client from the completed command line arguments. -func parseConfigOverride(a complete.Args) *clientcmd.ConfigOverrides { - context := "" +// parseContext parses the Kubernetes context override from the completed command line arguments. +func parseContext(a complete.Args) string { + return parseFlagValue(a, "context", "c") +} +// parseFlagValue returns the value of the first occurrence of the flag with the +// given long and short names, in any form kong accepts: "--long value", +// "--long=value", "-s value" and "-svalue". It returns "" if the flag is absent +// or its value is not yet typed. +func parseFlagValue(a complete.Args, long, short string) string { for i, arg := range a.All { - if (arg == "--context" || arg == "-c") && i < len(a.All) { - context = a.All[i+1] - break + switch { + case (arg == "--"+long || arg == "-"+short) && i+1 < len(a.All): + return a.All[i+1] + case strings.HasPrefix(arg, "--"+long+"="): + return strings.TrimPrefix(arg, "--"+long+"=") + case len(arg) > 2 && strings.HasPrefix(arg, "-"+short): + return strings.TrimPrefix(arg, "-"+short) } } - return &clientcmd.ConfigOverrides{ - CurrentContext: context, - } + return "" } // parseImpersonation parses the kubectl-compatible impersonation flags (--as, @@ -292,14 +288,5 @@ func parseImpersonation(a complete.Args) kube.ImpersonationFlags { // parseNamespaceOverride parses the namespace override from the completed command line arguments. func parseNamespaceOverride(a complete.Args) string { - namespace := "" - - for i, arg := range a.All { - if (arg == "--namespace" || arg == "-n") && i < len(a.All) { - namespace = a.All[i+1] - break - } - } - - return namespace + return parseFlagValue(a, "namespace", "n") } diff --git a/cmd/crossplane/completion/completion_integration_test.go b/cmd/crossplane/completion/completion_integration_test.go new file mode 100644 index 00000000..0f41bd46 --- /dev/null +++ b/cmd/crossplane/completion/completion_integration_test.go @@ -0,0 +1,445 @@ +//go:build integration + +/* +Copyright 2026 The Crossplane 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 completion + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/posener/complete" + authenticationv1 "k8s.io/api/authentication/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/crossplane/cli/v2/internal/testutils/integration" +) + +// The contexts writeKubeconfig puts in the kubeconfig the predictors load. +const ( + // ctxAdmin is the envtest cluster admin's, with no namespace. + ctxAdmin = "admin" + // ctxAdminTeamA is ctxAdmin with namespace team-a. + ctxAdminTeamA = "admin-team-a" + // ctxUnreachable points at a port nothing listens on. + ctxUnreachable = "unreachable" +) + +func TestMain(m *testing.M) { + // Kill envtest servers that an earlier run left behind when it died abnormally. + integration.ReapOrphanedEnvtest(func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) }) + os.Exit(m.Run()) +} + +// TestKubernetesResourcePredictorIntegration predicts resource types from a real API server's discovery. +func TestKubernetesResourcePredictorIntegration(t *testing.T) { + cluster := startCluster(t, nil) + + type args struct { + currentContext string + line string + } + + cases := map[string]struct { + reason string + args args + want []string + }{ + "CustomResource": { + reason: "A served CRD is predicted as ...", + args: args{currentContext: ctxAdmin, line: "wid"}, + want: []string{"widgets.v1alpha1.completion.example.org"}, + }, + "CoreResource": { + // The predictor would write ., but discovery leaves a core resource's version unset. + reason: "A core resource is predicted by its plural alone.", + args: args{currentContext: ctxAdmin, line: "namespaces"}, + want: []string{"namespaces"}, + }, + "NoMatch": { + reason: "Nothing is predicted when no resource has the typed prefix.", + args: args{currentContext: ctxAdmin, line: "nosuchresource"}, + }, + "ContextOverride": { + reason: "--context selects the cluster to discover, over the kubeconfig's current context.", + args: args{currentContext: ctxUnreachable, line: "--context admin wid"}, + want: []string{"widgets.v1alpha1.completion.example.org"}, + }, + "ShortContextOverride": { + reason: "-c is --context's short form.", + args: args{currentContext: ctxUnreachable, line: "-c admin wid"}, + want: []string{"widgets.v1alpha1.completion.example.org"}, + }, + "ContextOverrideUnreachable": { + reason: "Nothing is predicted when the --context cluster can't be reached.", + args: args{currentContext: ctxAdmin, line: "--context unreachable wid"}, + }, + "Impersonation": { + reason: "Every authenticated user, so an impersonated one, may discover resources.", + args: args{currentContext: ctxAdmin, line: "--as someone-else wid"}, + want: []string{"widgets.v1alpha1.completion.example.org"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + writeKubeconfig(t, cluster, tc.args.currentContext) + + got := kubernetesResourcePredictor().Predict(completeArgs(tc.args.line)) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("%s\nPredict(%q): -want, +got:\n%s", tc.reason, tc.args.line, diff) + } + }) + } +} + +// TestKubernetesResourceNamePredictorIntegration predicts the names of a resource type's objects from a real API +// server. +func TestKubernetesResourceNamePredictorIntegration(t *testing.T) { + cluster := startCluster(t, []rbacv1.PolicyRule{{ + APIGroups: []string{"completion.example.org"}, + Resources: []string{"widgets"}, + Verbs: []string{"list"}, + }}) + user := userName(t, cluster) + + type args struct { + currentContext string + line string + } + + cases := map[string]struct { + reason string + args args + want []string + }{ + "DefaultNamespace": { + reason: "Without a namespace from the flags or the context, names come from the default namespace.", + args: args{currentContext: ctxAdmin, line: "widgets "}, + want: []string{"widget-alpha", "widget-beta"}, + }, + "Prefix": { + reason: "Only names with the typed prefix are predicted.", + args: args{currentContext: ctxAdmin, line: "widgets widget-a"}, + want: []string{"widget-alpha"}, + }, + "ShortName": { + reason: "A resource type typed by its short name is resolved.", + args: args{currentContext: ctxAdmin, line: "wdg "}, + want: []string{"widget-alpha", "widget-beta"}, + }, + "FullyQualified": { + reason: "A resource type typed as .. is resolved.", + args: args{currentContext: ctxAdmin, line: "widgets.v1alpha1.completion.example.org "}, + want: []string{"widget-alpha", "widget-beta"}, + }, + "UnknownResource": { + reason: "Nothing is predicted for a resource type the server doesn't serve.", + args: args{currentContext: ctxAdmin, line: "nosuchresources "}, + }, + "NamespaceOverride": { + reason: "--namespace selects the namespace to list.", + args: args{currentContext: ctxAdmin, line: "--namespace team-a widgets "}, + want: []string{"widget-gamma"}, + }, + "ShortNamespaceOverride": { + reason: "-n is --namespace's short form.", + args: args{currentContext: ctxAdmin, line: "-n team-a widgets "}, + want: []string{"widget-gamma"}, + }, + "ContextNamespace": { + reason: "Without a namespace flag, names come from the current context's namespace.", + args: args{currentContext: ctxAdminTeamA, line: "widgets "}, + want: []string{"widget-gamma"}, + }, + "NamespaceOverrideBeatsContextNamespace": { + reason: "--namespace wins over the context's namespace.", + args: args{currentContext: ctxAdminTeamA, line: "-n default widgets "}, + want: []string{"widget-alpha", "widget-beta"}, + }, + "ContextOverride": { + reason: "--context selects the cluster to list, over the kubeconfig's current context.", + args: args{currentContext: ctxUnreachable, line: "--context admin widgets "}, + want: []string{"widget-alpha", "widget-beta"}, + }, + "ContextOverrideNamespace": { + reason: "Without a namespace flag, names come from the --context context's namespace.", + args: args{currentContext: ctxAdmin, line: "--context admin-team-a widgets "}, + want: []string{"widget-gamma"}, + }, + "ContextOverrideUnreachable": { + reason: "Nothing is predicted when the --context cluster can't be reached.", + args: args{currentContext: ctxAdmin, line: "--context unreachable widgets "}, + }, + "ImpersonateAllowed": { + reason: "An impersonated user allowed to list the resource type gets its names.", + args: args{currentContext: ctxAdmin, line: "--as " + user + " widgets "}, + want: []string{"widget-alpha", "widget-beta"}, + }, + "ImpersonateEqualsForm": { + reason: "--as= impersonates like --as .", + args: args{currentContext: ctxAdmin, line: "--as=" + user + " widgets "}, + want: []string{"widget-alpha", "widget-beta"}, + }, + "ImpersonateForbiddenResource": { + reason: "Nothing is predicted for a resource type the impersonated user may not list.", + args: args{currentContext: ctxAdmin, line: "--as " + user + " configmaps "}, + }, + "NotImpersonatingResource": { + reason: "The admin lists the resource type the impersonated user may not, so the user's RBAC denied it.", + args: args{currentContext: ctxAdmin, line: "configmaps "}, + want: []string{"settings"}, + }, + "ImpersonateUnboundUser": { + reason: "Nothing is predicted for an impersonated user bound to no role.", + args: args{currentContext: ctxAdmin, line: "--as someone-else widgets "}, + }, + "ImpersonateGroup": { + reason: "--as-group impersonates the user's groups, here one granting everything.", + args: args{currentContext: ctxAdmin, line: "--as someone-else --as-group system:masters widgets "}, + want: []string{"widget-alpha", "widget-beta"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + writeKubeconfig(t, cluster, tc.args.currentContext) + + got := kubernetesResourceNamePredictor().Predict(completeArgs(tc.args.line)) + if diff := cmp.Diff(tc.want, got, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("%s\nPredict(%q): -want, +got:\n%s", tc.reason, tc.args.line, diff) + } + }) + } +} + +// TestContextPredictorIntegration predicts the kubeconfig's contexts. +func TestContextPredictorIntegration(t *testing.T) { + cluster := startCluster(t, nil) + + type args struct { + currentContext string + line string + } + + cases := map[string]struct { + reason string + args args + want []string + }{ + "All": { + reason: "Every context is predicted when nothing is typed.", + args: args{currentContext: ctxAdmin, line: "--context "}, + want: []string{ctxAdmin, ctxAdminTeamA, ctxUnreachable}, + }, + "Prefix": { + reason: "Only contexts with the typed prefix are predicted.", + args: args{currentContext: ctxAdmin, line: "--context adm"}, + want: []string{ctxAdmin, ctxAdminTeamA}, + }, + "NoMatch": { + reason: "Nothing is predicted when no context has the typed prefix.", + args: args{currentContext: ctxAdmin, line: "--context nosuchcontext"}, + }, + "UnreachableCurrentContext": { + reason: "Contexts come from the kubeconfig alone, so an unreachable current context doesn't matter.", + args: args{currentContext: ctxUnreachable, line: "--context unr"}, + want: []string{ctxUnreachable}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + writeKubeconfig(t, cluster, tc.args.currentContext) + + got := contextPredictor().Predict(completeArgs(tc.args.line)) + if diff := cmp.Diff(tc.want, got, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("%s\nPredict(%q): -want, +got:\n%s", tc.reason, tc.args.line, diff) + } + }) + } +} + +// TestNamespacePredictorIntegration predicts namespaces from a real API server. +func TestNamespacePredictorIntegration(t *testing.T) { + cluster := startCluster(t, []rbacv1.PolicyRule{{ + APIGroups: []string{"completion.example.org"}, + Resources: []string{"widgets"}, + Verbs: []string{"list"}, + }}) + user := userName(t, cluster) + + type args struct { + currentContext string + line string + } + + cases := map[string]struct { + reason string + args args + want []string + }{ + "Prefix": { + reason: "Namespaces with the typed prefix are predicted.", + args: args{currentContext: ctxAdmin, line: "--namespace team"}, + want: []string{"team-a", "team-b"}, + }, + "Exact": { + reason: "A fully typed namespace is predicted.", + args: args{currentContext: ctxAdmin, line: "--namespace default"}, + want: []string{"default"}, + }, + "NoMatch": { + reason: "Nothing is predicted when no namespace has the typed prefix.", + args: args{currentContext: ctxAdmin, line: "--namespace nosuchnamespace"}, + }, + "ContextNamespaceIgnored": { + reason: "Namespaces are cluster-scoped, so the current context's namespace doesn't filter them.", + args: args{currentContext: ctxAdminTeamA, line: "--namespace team"}, + want: []string{"team-a", "team-b"}, + }, + "UnreachableCurrentContext": { + reason: "Nothing is predicted when the current context's cluster can't be reached.", + args: args{currentContext: ctxUnreachable, line: "--namespace team"}, + }, + "ContextOverride": { + reason: "--context selects the cluster to list, over the kubeconfig's current context.", + args: args{currentContext: ctxUnreachable, line: "--context admin --namespace team"}, + want: []string{"team-a", "team-b"}, + }, + "ShortContextOverride": { + reason: "-c is --context's short form.", + args: args{currentContext: ctxUnreachable, line: "-c admin --namespace team"}, + want: []string{"team-a", "team-b"}, + }, + "AttachedContextOverride": { + reason: "--context=value selects the cluster to list, like --context value.", + args: args{currentContext: ctxUnreachable, line: "--context=admin --namespace team"}, + want: []string{"team-a", "team-b"}, + }, + "AttachedShortContextOverride": { + reason: "-cvalue selects the cluster to list, like -c value.", + args: args{currentContext: ctxUnreachable, line: "-cadmin --namespace team"}, + want: []string{"team-a", "team-b"}, + }, + "ContextOverrideUnreachable": { + reason: "Nothing is predicted when the --context cluster can't be reached.", + args: args{currentContext: ctxAdmin, line: "--context unreachable --namespace team"}, + }, + "ImpersonateForbidden": { + reason: "Nothing is predicted for an impersonated user who may not list namespaces.", + args: args{currentContext: ctxAdmin, line: "--as " + user + " --namespace team"}, + }, + "ImpersonateGroup": { + reason: "--as-group impersonates the user's groups, here one granting everything.", + args: args{currentContext: ctxAdmin, line: "--as=someone-else --as-group=system:masters --namespace team"}, + want: []string{"team-a", "team-b"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + writeKubeconfig(t, cluster, tc.args.currentContext) + + got := namespacePredictor().Predict(completeArgs(tc.args.line)) + if diff := cmp.Diff(tc.want, got, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("%s\nPredict(%q): -want, +got:\n%s", tc.reason, tc.args.line, diff) + } + }) + } +} + +// startCluster starts a cluster serving widgets in the default and team-a namespaces, and namespaces team-a and +// team-b. Non-nil rules also provision a user granted only them (see integration.Setup.RBACRules). +func startCluster(t *testing.T, rules []rbacv1.PolicyRule) *integration.Cluster { + t.Helper() + + return integration.Start(t, integration.Setup{ + CRDDirs: []string{"testdata/integration/crds"}, + SetupFiles: []string{"testdata/integration/setup.yaml"}, + RBACRules: rules, + }) +} + +// userName returns the name of the user the cluster's RBACRules were granted to, for --as. +func userName(t *testing.T, cluster *integration.Cluster) string { + t.Helper() + + kube, err := kubernetes.NewForConfig(cluster.UserConfig) + if err != nil { + t.Fatalf("cannot create the RBAC-limited user's client: %v", err) + } + + review, err := kube.AuthenticationV1().SelfSubjectReviews().Create(cluster.Context, &authenticationv1.SelfSubjectReview{}, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("cannot review the RBAC-limited user: %v", err) + } + + return review.Status.UserInfo.Username +} + +// writeKubeconfig points KUBECONFIG, which the predictors load, at a kubeconfig of the ctxAdmin, ctxAdminTeamA and +// ctxUnreachable contexts, with currentContext current. +func writeKubeconfig(t *testing.T, cluster *integration.Cluster, currentContext string) { + t.Helper() + + cfg, err := clientcmd.Load(cluster.Kubeconfig) + if err != nil { + t.Fatalf("cannot load the cluster's kubeconfig: %v", err) + } + + admin := cfg.Contexts[cfg.CurrentContext] + cfg.Clusters[ctxUnreachable] = &clientcmdapi.Cluster{Server: "https://127.0.0.1:1"} + cfg.Contexts = map[string]*clientcmdapi.Context{ + ctxAdmin: {Cluster: admin.Cluster, AuthInfo: admin.AuthInfo}, + ctxAdminTeamA: {Cluster: admin.Cluster, AuthInfo: admin.AuthInfo, Namespace: "team-a"}, + ctxUnreachable: {Cluster: ctxUnreachable, AuthInfo: admin.AuthInfo}, + } + cfg.CurrentContext = currentContext + + path := filepath.Join(t.TempDir(), "kubeconfig") + if err := clientcmd.WriteToFile(*cfg, path); err != nil { + t.Fatalf("cannot write kubeconfig: %v", err) + } + + t.Setenv("KUBECONFIG", path) +} + +// completeArgs returns the Args the shell completes for the words after a command: a trailing space means the last +// word is complete and an empty one is being typed. +func completeArgs(line string) complete.Args { + all := strings.Fields(line) + if strings.HasSuffix(line, " ") { + all = append(all, "") + } + + a := complete.Args{All: all, Completed: all[:len(all)-1], Last: all[len(all)-1]} + if n := len(a.Completed); n > 0 { + a.LastCompleted = a.Completed[n-1] + } + + return a +} diff --git a/cmd/crossplane/completion/completion_test.go b/cmd/crossplane/completion/completion_test.go index ef756150..fafc229e 100644 --- a/cmd/crossplane/completion/completion_test.go +++ b/cmd/crossplane/completion/completion_test.go @@ -30,6 +30,11 @@ func TestParseImpersonation(t *testing.T) { args: []string{"--as-group=team-a", "--as-group", "team-b"}, want: kube.ImpersonationFlags{AsGroup: []string{"team-a", "team-b"}}, }, + "FlagLast": { + reason: "A flag that is the last word, its value not yet typed, is ignored.", + args: []string{"--as"}, + want: kube.ImpersonationFlags{}, + }, "None": { reason: "No impersonation flags yields the zero value.", args: []string{"trace", "x"}, @@ -46,3 +51,103 @@ func TestParseImpersonation(t *testing.T) { }) } } + +func TestParseContext(t *testing.T) { + cases := map[string]struct { + reason string + args []string + want string + }{ + "Long": { + reason: "The --context value is parsed.", + args: []string{"trace", "--context", "other", "x"}, + want: "other", + }, + "Short": { + reason: "The -c value is parsed.", + args: []string{"-c", "other", "x"}, + want: "other", + }, + "LongAttached": { + reason: "The --context=value form is parsed.", + args: []string{"trace", "--context=other", "x"}, + want: "other", + }, + "ShortAttached": { + reason: "The -cvalue form is parsed.", + args: []string{"-cother", "x"}, + want: "other", + }, + "FlagLast": { + reason: "A --context that is the last word, its value not yet typed, yields no context.", + args: []string{"trace", "--context"}, + }, + "ShortFlagLast": { + reason: "A -c that is the last word, its value not yet typed, yields no context.", + args: []string{"trace", "-c"}, + }, + "None": { + reason: "No context flag yields no context.", + args: []string{"trace", "x"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := parseContext(complete.Args{All: tc.args}) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("%s\nparseContext(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestParseNamespaceOverride(t *testing.T) { + cases := map[string]struct { + reason string + args []string + want string + }{ + "Long": { + reason: "The --namespace value is parsed.", + args: []string{"trace", "--namespace", "team-a", "x"}, + want: "team-a", + }, + "Short": { + reason: "The -n value is parsed.", + args: []string{"-n", "team-a", "x"}, + want: "team-a", + }, + "LongAttached": { + reason: "The --namespace=value form is parsed.", + args: []string{"trace", "--namespace=team-a", "x"}, + want: "team-a", + }, + "ShortAttached": { + reason: "The -nvalue form is parsed.", + args: []string{"-nteam-a", "x"}, + want: "team-a", + }, + "FlagLast": { + reason: "A --namespace that is the last word, its value not yet typed, yields no namespace.", + args: []string{"trace", "--namespace"}, + }, + "ShortFlagLast": { + reason: "A -n that is the last word, its value not yet typed, yields no namespace.", + args: []string{"trace", "-n"}, + }, + "None": { + reason: "No namespace flag yields no namespace.", + args: []string{"trace", "x"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := parseNamespaceOverride(complete.Args{All: tc.args}) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("%s\nparseNamespaceOverride(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/completion/testdata/integration/crds/widget.yaml b/cmd/crossplane/completion/testdata/integration/crds/widget.yaml new file mode 100644 index 00000000..45164da8 --- /dev/null +++ b/cmd/crossplane/completion/testdata/integration/crds/widget.yaml @@ -0,0 +1,23 @@ +# A namespaced resource with a short name, for the resource and resource-name +# predictors. It has no status subresource, so discovery lists no widgets/status. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.completion.example.org +spec: + group: completion.example.org + names: + kind: Widget + listKind: WidgetList + plural: widgets + singular: widget + shortNames: + - wdg + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object diff --git a/cmd/crossplane/completion/testdata/integration/setup.yaml b/cmd/crossplane/completion/testdata/integration/setup.yaml new file mode 100644 index 00000000..89d1e6da --- /dev/null +++ b/cmd/crossplane/completion/testdata/integration/setup.yaml @@ -0,0 +1,36 @@ +# Two namespaces for the namespace predictor, and widgets in two namespaces for +# the resource-name predictor's namespace resolution. +apiVersion: v1 +kind: Namespace +metadata: + name: team-a +--- +apiVersion: v1 +kind: Namespace +metadata: + name: team-b +--- +apiVersion: completion.example.org/v1alpha1 +kind: Widget +metadata: + name: widget-alpha + namespace: default +--- +apiVersion: completion.example.org/v1alpha1 +kind: Widget +metadata: + name: widget-beta + namespace: default +--- +apiVersion: completion.example.org/v1alpha1 +kind: Widget +metadata: + name: widget-gamma + namespace: team-a +--- +# The RBAC-limited user may list widgets but not configmaps. +apiVersion: v1 +kind: ConfigMap +metadata: + name: settings + namespace: default