From 6cab8c933c20c40348dd4d73b0a8cbb08c6cb462 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 11 Nov 2026 10:53:12 -0500 Subject: [PATCH 1/5] test(completion): pin the kube predictors against envtest Cover the resource, resource-name, context and namespace predictors against a real API server: context and namespace overrides, short names, and impersonation as an RBAC-limited user. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- .../completion/completion_integration_test.go | 421 ++++++++++++++++++ .../testdata/integration/crds/widget.yaml | 23 + .../testdata/integration/setup.yaml | 36 ++ 3 files changed, 480 insertions(+) create mode 100644 cmd/crossplane/completion/completion_integration_test.go create mode 100644 cmd/crossplane/completion/testdata/integration/crds/widget.yaml create mode 100644 cmd/crossplane/completion/testdata/integration/setup.yaml diff --git a/cmd/crossplane/completion/completion_integration_test.go b/cmd/crossplane/completion/completion_integration_test.go new file mode 100644 index 00000000..cf3cb04b --- /dev/null +++ b/cmd/crossplane/completion/completion_integration_test.go @@ -0,0 +1,421 @@ +//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"}, + }, + "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/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 From c6ffa8efa9cea7c35b9c57ac907b84e58757a12d Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 11 Nov 2026 10:58:39 -0500 Subject: [PATCH 2/5] refactor(completion): build the predictors' clients on kube.RESTConfig kubernetesClient and kubernetesClientset duplicated kube.RESTConfig's kubeconfig loading and impersonation. parseConfigOverride only ever set the context, so it becomes parseContext, returning the name. The predictors' clients also get RESTConfig's raised QPS/Burst. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/completion/completion.go | 47 +++++++++---------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/cmd/crossplane/completion/completion.go b/cmd/crossplane/completion/completion.go index f102c8ed..e45c8fbb 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 } @@ -207,49 +207,36 @@ func namespacePredictor() complete.PredictFunc { } } -// kubernetesClientset returns a Kubernetes clientset using the default -// kubeconfig and the given impersonation flags. +// kubernetesClientset returns a Kubernetes clientset for the current context +// of 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() + cfg, _, err := kube.RESTConfig("", 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 { +// parseContext parses the Kubernetes context override from the completed command line arguments. +func parseContext(a complete.Args) string { context := "" for i, arg := range a.All { @@ -259,9 +246,7 @@ func parseConfigOverride(a complete.Args) *clientcmd.ConfigOverrides { } } - return &clientcmd.ConfigOverrides{ - CurrentContext: context, - } + return context } // parseImpersonation parses the kubectl-compatible impersonation flags (--as, From f19ed277fec9d787a68ab717fd8148c46b0a6f1d Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 11 Nov 2026 11:04:45 -0500 Subject: [PATCH 3/5] fix(completion): honor --context when predicting namespaces The namespace predictor always listed the kubeconfig's current context's namespaces, so `--context other -n ` suggested the wrong cluster's. Pass the --context override like the other kube predictors do. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/completion/completion.go | 13 +++++++------ .../completion/completion_integration_test.go | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/cmd/crossplane/completion/completion.go b/cmd/crossplane/completion/completion.go index e45c8fbb..11c4971f 100644 --- a/cmd/crossplane/completion/completion.go +++ b/cmd/crossplane/completion/completion.go @@ -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,10 +208,10 @@ func namespacePredictor() complete.PredictFunc { } } -// kubernetesClientset returns a Kubernetes clientset for the current context -// of the default kubeconfig and the given impersonation flags. -func kubernetesClientset(imp kube.ImpersonationFlags) (*kubernetes.Clientset, error) { - cfg, _, err := kube.RESTConfig("", imp) +// 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 } diff --git a/cmd/crossplane/completion/completion_integration_test.go b/cmd/crossplane/completion/completion_integration_test.go index cf3cb04b..1507b1a4 100644 --- a/cmd/crossplane/completion/completion_integration_test.go +++ b/cmd/crossplane/completion/completion_integration_test.go @@ -325,6 +325,20 @@ func TestNamespacePredictorIntegration(t *testing.T) { 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"}, + }, + "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"}, From 8fc7b3caebb5e230586a299be95c6a9bacbe920a Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 11 Nov 2026 11:12:38 -0500 Subject: [PATCH 4/5] fix(completion): don't panic when --context or --namespace is the last word parseContext and parseNamespaceOverride read the word after the flag without checking it exists, so completing right after `--context`, `-c`, `--namespace` or `-n` indexed past the end of the arguments. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/completion/completion.go | 4 +- cmd/crossplane/completion/completion_test.go | 85 ++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/completion/completion.go b/cmd/crossplane/completion/completion.go index 11c4971f..0038a1b0 100644 --- a/cmd/crossplane/completion/completion.go +++ b/cmd/crossplane/completion/completion.go @@ -241,7 +241,7 @@ func parseContext(a complete.Args) string { context := "" for i, arg := range a.All { - if (arg == "--context" || arg == "-c") && i < len(a.All) { + if (arg == "--context" || arg == "-c") && i+1 < len(a.All) { context = a.All[i+1] break } @@ -281,7 +281,7 @@ func parseNamespaceOverride(a complete.Args) string { namespace := "" for i, arg := range a.All { - if (arg == "--namespace" || arg == "-n") && i < len(a.All) { + if (arg == "--namespace" || arg == "-n") && i+1 < len(a.All) { namespace = a.All[i+1] break } diff --git a/cmd/crossplane/completion/completion_test.go b/cmd/crossplane/completion/completion_test.go index ef756150..3820c7b4 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,83 @@ 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", + }, + "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", + }, + "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) + } + }) + } +} From 602ccb75005405199262fdc5c847afe49231f7d5 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 11 Nov 2026 14:40:30 -0500 Subject: [PATCH 5/5] Parse attached --context and --namespace values in completion Kong accepts --flag=value and -svalue as well as the separated forms, so completion now reads the context and namespace overrides in all four. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/completion/completion.go | 31 ++++++++++--------- .../completion/completion_integration_test.go | 10 ++++++ cmd/crossplane/completion/completion_test.go | 20 ++++++++++++ 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/cmd/crossplane/completion/completion.go b/cmd/crossplane/completion/completion.go index 0038a1b0..ff1b1844 100644 --- a/cmd/crossplane/completion/completion.go +++ b/cmd/crossplane/completion/completion.go @@ -238,16 +238,26 @@ func kubernetesClient(kubeContext string, imp kube.ImpersonationFlags) (controll // parseContext parses the Kubernetes context override from the completed command line arguments. func parseContext(a complete.Args) string { - context := "" + 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+1 < 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 context + return "" } // parseImpersonation parses the kubectl-compatible impersonation flags (--as, @@ -278,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+1 < 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 index 1507b1a4..0f41bd46 100644 --- a/cmd/crossplane/completion/completion_integration_test.go +++ b/cmd/crossplane/completion/completion_integration_test.go @@ -335,6 +335,16 @@ func TestNamespacePredictorIntegration(t *testing.T) { 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"}, diff --git a/cmd/crossplane/completion/completion_test.go b/cmd/crossplane/completion/completion_test.go index 3820c7b4..fafc229e 100644 --- a/cmd/crossplane/completion/completion_test.go +++ b/cmd/crossplane/completion/completion_test.go @@ -68,6 +68,16 @@ func TestParseContext(t *testing.T) { 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"}, @@ -108,6 +118,16 @@ func TestParseNamespaceOverride(t *testing.T) { 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"},