From a9b4694734799e6cef60964e622f954d887cb919 Mon Sep 17 00:00:00 2001 From: Mike Ditton Date: Mon, 15 Jun 2026 20:52:35 +0200 Subject: [PATCH 1/8] feat(kube): add shared kubectl-compatible impersonation flags Signed-off-by: Mike Ditton --- cmd/crossplane/common/kube/impersonation.go | 51 ++++++++ .../common/kube/impersonation_test.go | 121 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 cmd/crossplane/common/kube/impersonation.go create mode 100644 cmd/crossplane/common/kube/impersonation_test.go diff --git a/cmd/crossplane/common/kube/impersonation.go b/cmd/crossplane/common/kube/impersonation.go new file mode 100644 index 00000000..1760399b --- /dev/null +++ b/cmd/crossplane/common/kube/impersonation.go @@ -0,0 +1,51 @@ +/* +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 kube contains shared helpers for crossplane CLI commands that talk +// to a Kubernetes cluster. +package kube + +import "k8s.io/client-go/rest" + +// ImpersonationFlags are the kubectl-compatible privilege-elevation flags +// (--as, --as-group, --as-uid). Embed it into a command's Kong flag struct with +// the `embed:""` tag, then call Apply on the command's *rest.Config before +// building its client. +type ImpersonationFlags struct { + As string `name:"as" help:"Username to impersonate for the operation. User could be a regular user or a service account in a namespace."` + AsGroup []string `name:"as-group" help:"Group to impersonate for the operation, this flag can be repeated to specify multiple groups." sep:"none"` + AsUID string `name:"as-uid" help:"UID to impersonate for the operation."` +} + +// Apply sets impersonation on the given rest.Config. Unset fields and a nil cfg +// are no-ops, so it is always safe to call. +func (f ImpersonationFlags) Apply(cfg *rest.Config) { + if cfg == nil { + return + } + + if f.As != "" { + cfg.Impersonate.UserName = f.As + } + + if f.AsUID != "" { + cfg.Impersonate.UID = f.AsUID + } + + if len(f.AsGroup) > 0 { + cfg.Impersonate.Groups = f.AsGroup + } +} diff --git a/cmd/crossplane/common/kube/impersonation_test.go b/cmd/crossplane/common/kube/impersonation_test.go new file mode 100644 index 00000000..ef9c5b47 --- /dev/null +++ b/cmd/crossplane/common/kube/impersonation_test.go @@ -0,0 +1,121 @@ +/* +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 kube + +import ( + "testing" + + "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + "k8s.io/client-go/rest" +) + +func TestApply(t *testing.T) { + type args struct { + flags ImpersonationFlags + cfg *rest.Config + } + type want struct { + impersonate rest.ImpersonationConfig + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Empty": { + reason: "No flags set should leave Impersonate empty.", + args: args{flags: ImpersonationFlags{}, cfg: &rest.Config{}}, + want: want{impersonate: rest.ImpersonationConfig{}}, + }, + "UserOnly": { + reason: "Only --as sets UserName.", + args: args{flags: ImpersonationFlags{As: "jane@example.com"}, cfg: &rest.Config{}}, + want: want{impersonate: rest.ImpersonationConfig{UserName: "jane@example.com"}}, + }, + "GroupsOnly": { + reason: "Only --as-group sets Groups.", + args: args{flags: ImpersonationFlags{AsGroup: []string{"team-a", "team-b"}}, cfg: &rest.Config{}}, + want: want{impersonate: rest.ImpersonationConfig{Groups: []string{"team-a", "team-b"}}}, + }, + "UIDOnly": { + reason: "Only --as-uid sets UID.", + args: args{flags: ImpersonationFlags{AsUID: "1000"}, cfg: &rest.Config{}}, + want: want{impersonate: rest.ImpersonationConfig{UID: "1000"}}, + }, + "All": { + reason: "All flags set all fields.", + args: args{ + flags: ImpersonationFlags{As: "system:serviceaccount:team-a:reader", AsGroup: []string{"team-a-admins"}, AsUID: "42"}, + cfg: &rest.Config{}, + }, + want: want{impersonate: rest.ImpersonationConfig{UserName: "system:serviceaccount:team-a:reader", Groups: []string{"team-a-admins"}, UID: "42"}}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + tc.args.flags.Apply(tc.args.cfg) + if diff := cmp.Diff(tc.want.impersonate, tc.args.cfg.Impersonate); diff != "" { + t.Errorf("%s\nApply(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestApplyNilConfig(t *testing.T) { + // Must not panic on a nil config. + ImpersonationFlags{As: "jane"}.Apply(nil) +} + +func TestImpersonationFlagsParse(t *testing.T) { + cases := map[string]struct { + reason string + args []string + want ImpersonationFlags + }{ + "Repeatable": { + reason: "--as-group can be repeated to specify multiple groups.", + args: []string{"--as=jane", "--as-group=team-a", "--as-group=team-b", "--as-uid=42"}, + want: ImpersonationFlags{As: "jane", AsGroup: []string{"team-a", "team-b"}, AsUID: "42"}, + }, + "NoCommaSplit": { + reason: "sep:none means a comma is part of the group name, matching kubectl.", + args: []string{"--as-group=team-a,team-b"}, + want: ImpersonationFlags{AsGroup: []string{"team-a,team-b"}}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var cli struct { + Impersonation ImpersonationFlags `embed:""` + } + p, err := kong.New(&cli) + if err != nil { + t.Fatalf("%s\nkong.New(): unexpected error: %v", tc.reason, err) + } + if _, err := p.Parse(tc.args); err != nil { + t.Fatalf("%s\nParse(%v): unexpected error: %v", tc.reason, tc.args, err) + } + if diff := cmp.Diff(tc.want, cli.Impersonation); diff != "" { + t.Errorf("%s\nParse(%v): -want, +got:\n%s", tc.reason, tc.args, diff) + } + }) + } +} From 025b8924441a88bb9b157ce069b85686e41f8c95 Mon Sep 17 00:00:00 2001 From: Mike Ditton Date: Mon, 15 Jun 2026 20:55:49 +0200 Subject: [PATCH 2/8] feat(trace): support --as/--as-group/--as-uid impersonation Signed-off-by: Mike Ditton --- cmd/crossplane/trace/trace.go | 5 +++++ cmd/crossplane/trace/trace_test.go | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/cmd/crossplane/trace/trace.go b/cmd/crossplane/trace/trace.go index df9b4121..2e40ce01 100644 --- a/cmd/crossplane/trace/trace.go +++ b/cmd/crossplane/trace/trace.go @@ -36,6 +36,7 @@ import ( "github.com/crossplane/crossplane/apis/v2/pkg" + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" "github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xpkg" "github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xrm" @@ -79,6 +80,8 @@ type Cmd struct { ShowPackageRuntimeConfigs bool `default:"false" help:"Show package runtime configs in the output." name:"show-package-runtime-configs"` Concurrency int `default:"5" help:"load concurrency" name:"concurrency"` Watch bool `default:"false" help:"Watch for changes until resource deletion." name:"watch" short:"w"` + + Impersonation kube.ImpersonationFlags `embed:""` } // Help returns help message for the trace command. @@ -97,6 +100,8 @@ func (c *Cmd) setupKubeClient(logger logging.Logger) (clientcmd.ClientConfig, cl return nil, nil, nil, errors.Wrap(err, errKubeConfig) } + c.Impersonation.Apply(kubeconfig) + // NOTE(phisco): We used to get them set as part of // https://github.com/kubernetes-sigs/controller-runtime/blob/2e9781e9fc6054387cf0901c70db56f0b0a63083/pkg/client/config/config.go#L96, // this new approach doesn't set them, so we need to set them here to avoid diff --git a/cmd/crossplane/trace/trace_test.go b/cmd/crossplane/trace/trace_test.go index 4143a593..06a26943 100644 --- a/cmd/crossplane/trace/trace_test.go +++ b/cmd/crossplane/trace/trace_test.go @@ -3,6 +3,7 @@ package trace import ( "testing" + "github.com/alecthomas/kong" "github.com/google/go-cmp/cmp" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -103,3 +104,23 @@ func TestCmd_getResourceAndName(t *testing.T) { }) } } + +func TestImpersonationFlagsParse(t *testing.T) { + var c Cmd + + p, err := kong.New(&c) + if err != nil { + t.Fatalf("kong.New(): unexpected error: %v", err) + } + + if _, err := p.Parse([]string{"--as=jane", "--as-group=team-a", "configuration.example.org"}); err != nil { + t.Fatalf("Parse(): unexpected error: %v", err) + } + + if c.Impersonation.As != "jane" { + t.Errorf("As: want %q, got %q", "jane", c.Impersonation.As) + } + if len(c.Impersonation.AsGroup) != 1 || c.Impersonation.AsGroup[0] != "team-a" { + t.Errorf("AsGroup: want [team-a], got %v", c.Impersonation.AsGroup) + } +} From 46038c1ea1e655f232cf29d79afe11f7114e5697 Mon Sep 17 00:00:00 2001 From: Mike Ditton Date: Mon, 15 Jun 2026 21:05:07 +0200 Subject: [PATCH 3/8] feat(top): support --as/--as-group/--as-uid impersonation Signed-off-by: Mike Ditton --- cmd/crossplane/top/top.go | 6 ++++++ cmd/crossplane/top/top_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/cmd/crossplane/top/top.go b/cmd/crossplane/top/top.go index 29f61cdb..a80bcadc 100644 --- a/cmd/crossplane/top/top.go +++ b/cmd/crossplane/top/top.go @@ -36,6 +36,8 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" + _ "embed" ) @@ -58,6 +60,8 @@ const ( type Cmd struct { Summary bool `help:"Adds summary header for all Crossplane pods." name:"summary" short:"s"` Namespace string `default:"crossplane-system" help:"Show pods from a specific namespace, defaults to crossplane-system." name:"namespace" predictor:"namespace" short:"n"` + + Impersonation kube.ImpersonationFlags `embed:""` } // Help returns help instructions for the top command. @@ -103,6 +107,8 @@ func (c *Cmd) Run(k *kong.Context, logger logging.Logger) error { return errors.Wrap(err, errKubeConfig) } + c.Impersonation.Apply(config) + logger.Debug("Found kubeconfig") // Create the clientset for Kubernetes diff --git a/cmd/crossplane/top/top_test.go b/cmd/crossplane/top/top_test.go index 9ac8ca14..4415b826 100644 --- a/cmd/crossplane/top/top_test.go +++ b/cmd/crossplane/top/top_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/alecthomas/kong" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" corev1 "k8s.io/api/core/v1" @@ -16,6 +17,23 @@ import ( v1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" ) +func TestImpersonationFlagsParse(t *testing.T) { + var c Cmd + + p, err := kong.New(&c) + if err != nil { + t.Fatalf("kong.New(): unexpected error: %v", err) + } + + if _, err := p.Parse([]string{"--as=system:serviceaccount:team-a:reader"}); err != nil { + t.Fatalf("Parse(): unexpected error: %v", err) + } + + if c.Impersonation.As != "system:serviceaccount:team-a:reader" { + t.Errorf("As: want service account, got %q", c.Impersonation.As) + } +} + type errorWriter struct{} func (w *errorWriter) Write(_ []byte) (n int, err error) { From ded3b76b7df60078a9cbb4cb4ff452016c201adf Mon Sep 17 00:00:00 2001 From: Mike Ditton Date: Mon, 15 Jun 2026 21:05:15 +0200 Subject: [PATCH 4/8] feat(xpkg): support --as/--as-group/--as-uid on install and update Signed-off-by: Mike Ditton --- cmd/crossplane/xpkg/impersonation_test.go | 57 +++++++++++++++++++++++ cmd/crossplane/xpkg/install.go | 6 +++ cmd/crossplane/xpkg/update.go | 6 +++ 3 files changed, 69 insertions(+) create mode 100644 cmd/crossplane/xpkg/impersonation_test.go diff --git a/cmd/crossplane/xpkg/impersonation_test.go b/cmd/crossplane/xpkg/impersonation_test.go new file mode 100644 index 00000000..f83720dc --- /dev/null +++ b/cmd/crossplane/xpkg/impersonation_test.go @@ -0,0 +1,57 @@ +/* +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 xpkg + +import ( + "testing" + + "github.com/alecthomas/kong" +) + +func TestInstallImpersonationFlagsParse(t *testing.T) { + var c installCmd + + p, err := kong.New(&c) + if err != nil { + t.Fatalf("kong.New(): unexpected error: %v", err) + } + + if _, err := p.Parse([]string{"--as-group=team-a-admins", "provider", "example.org/provider-foo:v1.0.0"}); err != nil { + t.Fatalf("Parse(): unexpected error: %v", err) + } + + if len(c.Impersonation.AsGroup) != 1 || c.Impersonation.AsGroup[0] != "team-a-admins" { + t.Errorf("AsGroup: want [team-a-admins], got %v", c.Impersonation.AsGroup) + } +} + +func TestUpdateImpersonationFlagsParse(t *testing.T) { + var c updateCmd + + p, err := kong.New(&c) + if err != nil { + t.Fatalf("kong.New(): unexpected error: %v", err) + } + + if _, err := p.Parse([]string{"--as=jane", "provider", "example.org/provider-foo:v1.0.1"}); err != nil { + t.Fatalf("Parse(): unexpected error: %v", err) + } + + if c.Impersonation.As != "jane" { + t.Errorf("As: want %q, got %q", "jane", c.Impersonation.As) + } +} diff --git a/cmd/crossplane/xpkg/install.go b/cmd/crossplane/xpkg/install.go index a9fcc683..ddb847a7 100644 --- a/cmd/crossplane/xpkg/install.go +++ b/cmd/crossplane/xpkg/install.go @@ -40,6 +40,8 @@ import ( v1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" + _ "embed" // Load all the auth plugins for the cloud providers. _ "k8s.io/client-go/plugin/pkg/client/auth" @@ -67,6 +69,8 @@ type installCmd struct { PackagePullSecrets []string `help:"A comma-separated list of secrets the package manager should use to pull the package from the registry." placeholder:"NAME"` RevisionHistoryLimit int64 `help:"Number of package revisions that can exist before garbage collection." placeholder:"LIMIT" short:"r"` Wait time.Duration `default:"0s" help:"How long to wait for the package to install before returning. The command doesn't wait by default." short:"w"` + + Impersonation kube.ImpersonationFlags `embed:""` } func (c *installCmd) Help() string { @@ -148,6 +152,8 @@ func (c *installCmd) Run(k *kong.Context, logger logging.Logger) error { return errors.Wrap(err, errKubeConfig) } + c.Impersonation.Apply(cfg) + logger.Debug("Found kubeconfig") s := runtime.NewScheme() diff --git a/cmd/crossplane/xpkg/update.go b/cmd/crossplane/xpkg/update.go index ec0d91f3..5d7f5a99 100644 --- a/cmd/crossplane/xpkg/update.go +++ b/cmd/crossplane/xpkg/update.go @@ -36,6 +36,8 @@ import ( v1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" + _ "embed" _ "k8s.io/client-go/plugin/pkg/client/auth" // Load all the auth plugins for the cloud providers. ) @@ -49,6 +51,8 @@ type updateCmd struct { Kind string `arg:"" enum:"provider,configuration,function" help:"The kind of package to update. One of 'provider', 'configuration', or 'function'."` Package string `arg:"" help:"The package to update to. Must be fully qualified, including the registry, repository, and tag." placeholder:"REGISTRY/REPOSITORY:TAG"` Name string `arg:"" help:"The name of the package to update in the Crossplane API. Derived from the package repository and tag by default." optional:""` + + Impersonation kube.ImpersonationFlags `embed:""` } func (c *updateCmd) Help() string { @@ -93,6 +97,8 @@ func (c *updateCmd) Run(k *kong.Context, logger logging.Logger) error { return errors.Wrap(err, errKubeConfig) } + c.Impersonation.Apply(cfg) + logger.Debug("Found kubeconfig") s := runtime.NewScheme() From 768496429e5c2d0ade68cfd009fe7315ab162ee2 Mon Sep 17 00:00:00 2001 From: Mike Ditton Date: Mon, 15 Jun 2026 21:05:20 +0200 Subject: [PATCH 5/8] feat(version): support --as/--as-group/--as-uid impersonation Signed-off-by: Mike Ditton --- cmd/crossplane/version/fetch.go | 6 +++- cmd/crossplane/version/version.go | 6 +++- cmd/crossplane/version/version_test.go | 43 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 cmd/crossplane/version/version_test.go diff --git a/cmd/crossplane/version/fetch.go b/cmd/crossplane/version/fetch.go index 927ec4dd..6290216a 100644 --- a/cmd/crossplane/version/fetch.go +++ b/cmd/crossplane/version/fetch.go @@ -26,6 +26,8 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" ) const ( @@ -37,7 +39,7 @@ const ( // FetchCrossplaneVersion initializes a Kubernetes client and fetches // and returns the version of the Crossplane deployment. If the version // does not have a leading 'v', it prepends it. -func FetchCrossplaneVersion(ctx context.Context) (string, error) { +func FetchCrossplaneVersion(ctx context.Context, imp kube.ImpersonationFlags) (string, error) { var version string config, err := ctrl.GetConfig() @@ -45,6 +47,8 @@ func FetchCrossplaneVersion(ctx context.Context) (string, error) { return "", errors.Wrap(err, errKubeConfig) } + imp.Apply(config) + clientset, err := kubernetes.NewForConfig(config) if err != nil { return "", errors.Wrap(err, errCreateK8sClientset) diff --git a/cmd/crossplane/version/version.go b/cmd/crossplane/version/version.go index a7d7197b..9c8ab0c5 100644 --- a/cmd/crossplane/version/version.go +++ b/cmd/crossplane/version/version.go @@ -26,6 +26,8 @@ import ( "github.com/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/version" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" ) const ( @@ -35,6 +37,8 @@ const ( // Cmd represents the version command. type Cmd struct { Client bool `env:"" help:"If true, shows client version only (no server required)."` + + Impersonation kube.ImpersonationFlags `embed:""` } // Run runs the version command. @@ -48,7 +52,7 @@ func (c *Cmd) Run(k *kong.Context) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - vxp, err := FetchCrossplaneVersion(ctx) + vxp, err := FetchCrossplaneVersion(ctx, c.Impersonation) if err != nil { return errors.Wrap(err, errGetCrossplaneVersion) } diff --git a/cmd/crossplane/version/version_test.go b/cmd/crossplane/version/version_test.go new file mode 100644 index 00000000..8daf9605 --- /dev/null +++ b/cmd/crossplane/version/version_test.go @@ -0,0 +1,43 @@ +/* +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 version + +import ( + "testing" + + "github.com/alecthomas/kong" +) + +func TestImpersonationFlagsParse(t *testing.T) { + var c Cmd + + p, err := kong.New(&c) + if err != nil { + t.Fatalf("kong.New(): unexpected error: %v", err) + } + + if _, err := p.Parse([]string{"--as=jane", "--as-uid=42"}); err != nil { + t.Fatalf("Parse(): unexpected error: %v", err) + } + + if c.Impersonation.As != "jane" { + t.Errorf("As: want %q, got %q", "jane", c.Impersonation.As) + } + if c.Impersonation.AsUID != "42" { + t.Errorf("AsUID: want %q, got %q", "42", c.Impersonation.AsUID) + } +} From 4bb5a40225fba2ca17323bf0736de5af5640d681 Mon Sep 17 00:00:00 2001 From: Mike Ditton Date: Mon, 15 Jun 2026 21:05:33 +0200 Subject: [PATCH 6/8] feat(completion): impersonate when predicting resources and namespaces Signed-off-by: Mike Ditton --- cmd/crossplane/completion/completion.go | 47 ++++++++++++++++--- cmd/crossplane/completion/completion_test.go | 49 ++++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 cmd/crossplane/completion/completion_test.go diff --git a/cmd/crossplane/completion/completion.go b/cmd/crossplane/completion/completion.go index d87ac4d8..da20525b 100644 --- a/cmd/crossplane/completion/completion.go +++ b/cmd/crossplane/completion/completion.go @@ -18,6 +18,7 @@ import ( "k8s.io/client-go/tools/clientcmd" controllerClient "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" "github.com/crossplane/cli/v2/cmd/crossplane/internal" ) @@ -48,7 +49,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)) + _, kubeconfig, _, err := kubernetesClient(parseConfigOverride(a), parseImpersonation(a)) if err != nil { return nil } @@ -103,7 +104,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)) + client, kubeconfig, clientconfig, err := kubernetesClient(parseConfigOverride(a), parseImpersonation(a)) if err != nil { return nil } @@ -189,7 +190,7 @@ func contextPredictor() complete.PredictFunc { // last completed argument. func namespacePredictor() complete.PredictFunc { return func(a complete.Args) []string { - client, err := kubernetesClientset() + client, err := kubernetesClientset(parseImpersonation(a)) if err != nil { return nil } @@ -211,8 +212,9 @@ func namespacePredictor() complete.PredictFunc { } } -// kubernetesClientset returns a Kubernetes clientset using the default kubeconfig. -func kubernetesClientset() (*kubernetes.Clientset, error) { +// 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{}, @@ -223,11 +225,14 @@ func kubernetesClientset() (*kubernetes.Clientset, error) { return nil, err } + imp.Apply(kubeConfig) + return kubernetes.NewForConfig(kubeConfig) } -// kubernetesClient returns a Kubernetes client and a rest.Config using the provided config overrides. -func kubernetesClient(configOverrides *clientcmd.ConfigOverrides) (controllerClient.Client, *rest.Config, clientcmd.ClientConfig, error) { +// 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, @@ -238,6 +243,8 @@ func kubernetesClient(configOverrides *clientcmd.ConfigOverrides) (controllerCli return nil, nil, nil, err } + imp.Apply(kubeconfig) + client, err := controllerClient.New(rest.CopyConfig(kubeconfig), controllerClient.Options{}) if err != nil { return nil, nil, nil, err @@ -262,6 +269,32 @@ func parseConfigOverride(a complete.Args) *clientcmd.ConfigOverrides { } } +// parseImpersonation parses the kubectl-compatible impersonation flags (--as, +// --as-group, --as-uid) from the completed command line arguments. Supports +// both "--flag value" and "--flag=value" forms; --as-group may be repeated. +func parseImpersonation(a complete.Args) kube.ImpersonationFlags { + var imp kube.ImpersonationFlags + + for i, arg := range a.All { + switch { + case arg == "--as" && i+1 < len(a.All): + imp.As = a.All[i+1] + case strings.HasPrefix(arg, "--as="): + imp.As = strings.TrimPrefix(arg, "--as=") + case arg == "--as-uid" && i+1 < len(a.All): + imp.AsUID = a.All[i+1] + case strings.HasPrefix(arg, "--as-uid="): + imp.AsUID = strings.TrimPrefix(arg, "--as-uid=") + case arg == "--as-group" && i+1 < len(a.All): + imp.AsGroup = append(imp.AsGroup, a.All[i+1]) + case strings.HasPrefix(arg, "--as-group="): + imp.AsGroup = append(imp.AsGroup, strings.TrimPrefix(arg, "--as-group=")) + } + } + + return imp +} + // parseNamespaceOverride parses the namespace override from the completed command line arguments. func parseNamespaceOverride(a complete.Args) string { namespace := "" diff --git a/cmd/crossplane/completion/completion_test.go b/cmd/crossplane/completion/completion_test.go new file mode 100644 index 00000000..fee988fc --- /dev/null +++ b/cmd/crossplane/completion/completion_test.go @@ -0,0 +1,49 @@ +// Package completion contains Crossplane CLI completions. +package completion + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/posener/complete" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" +) + +func TestParseImpersonation(t *testing.T) { + cases := map[string]struct { + reason string + args []string + want kube.ImpersonationFlags + }{ + "Equals": { + reason: "The --flag=value form is parsed.", + args: []string{"trace", "x", "--as=jane", "--as-uid=42", "--as-group=team-a"}, + want: kube.ImpersonationFlags{As: "jane", AsUID: "42", AsGroup: []string{"team-a"}}, + }, + "Space": { + reason: "The --flag value form is parsed.", + args: []string{"--as", "jane", "--as-uid", "42", "--as-group", "team-a"}, + want: kube.ImpersonationFlags{As: "jane", AsUID: "42", AsGroup: []string{"team-a"}}, + }, + "RepeatableGroups": { + reason: "--as-group can be repeated.", + args: []string{"--as-group=team-a", "--as-group", "team-b"}, + want: kube.ImpersonationFlags{AsGroup: []string{"team-a", "team-b"}}, + }, + "None": { + reason: "No impersonation flags yields the zero value.", + args: []string{"trace", "x"}, + want: kube.ImpersonationFlags{}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := parseImpersonation(complete.Args{All: tc.args}) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("%s\nparseImpersonation(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} From 19299c53faebe1e8b92bf1a63a812050790382e0 Mon Sep 17 00:00:00 2001 From: Mike Ditton Date: Mon, 15 Jun 2026 21:22:36 +0200 Subject: [PATCH 7/8] style: satisfy tagalign, godoclint and revive linters Signed-off-by: Mike Ditton --- cmd/crossplane/common/kube/impersonation.go | 6 +++--- cmd/crossplane/common/kube/impersonation_test.go | 2 +- cmd/crossplane/completion/completion_test.go | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cmd/crossplane/common/kube/impersonation.go b/cmd/crossplane/common/kube/impersonation.go index 1760399b..194cefda 100644 --- a/cmd/crossplane/common/kube/impersonation.go +++ b/cmd/crossplane/common/kube/impersonation.go @@ -25,9 +25,9 @@ import "k8s.io/client-go/rest" // the `embed:""` tag, then call Apply on the command's *rest.Config before // building its client. type ImpersonationFlags struct { - As string `name:"as" help:"Username to impersonate for the operation. User could be a regular user or a service account in a namespace."` - AsGroup []string `name:"as-group" help:"Group to impersonate for the operation, this flag can be repeated to specify multiple groups." sep:"none"` - AsUID string `name:"as-uid" help:"UID to impersonate for the operation."` + As string `help:"Username to impersonate for the operation. User could be a regular user or a service account in a namespace." name:"as"` + AsGroup []string `help:"Group to impersonate for the operation, this flag can be repeated to specify multiple groups." name:"as-group" sep:"none"` + AsUID string `help:"UID to impersonate for the operation." name:"as-uid"` } // Apply sets impersonation on the given rest.Config. Unset fields and a nil cfg diff --git a/cmd/crossplane/common/kube/impersonation_test.go b/cmd/crossplane/common/kube/impersonation_test.go index ef9c5b47..417372b1 100644 --- a/cmd/crossplane/common/kube/impersonation_test.go +++ b/cmd/crossplane/common/kube/impersonation_test.go @@ -78,7 +78,7 @@ func TestApply(t *testing.T) { } } -func TestApplyNilConfig(t *testing.T) { +func TestApplyNilConfig(_ *testing.T) { // Must not panic on a nil config. ImpersonationFlags{As: "jane"}.Apply(nil) } diff --git a/cmd/crossplane/completion/completion_test.go b/cmd/crossplane/completion/completion_test.go index fee988fc..6ceb1b40 100644 --- a/cmd/crossplane/completion/completion_test.go +++ b/cmd/crossplane/completion/completion_test.go @@ -1,4 +1,3 @@ -// Package completion contains Crossplane CLI completions. package completion import ( From a6e44e19eee2b65204bf072bdd1c5265bffea651 Mon Sep 17 00:00:00 2001 From: Mike Ditton Date: Mon, 15 Jun 2026 21:48:48 +0200 Subject: [PATCH 8/8] test: make impersonation flag parse tests table-driven Signed-off-by: Mike Ditton --- cmd/crossplane/top/top_test.go | 43 +++++++++--- cmd/crossplane/trace/trace_test.go | 46 ++++++++---- cmd/crossplane/version/version_test.go | 47 +++++++++---- cmd/crossplane/xpkg/impersonation_test.go | 85 +++++++++++++++++------ 4 files changed, 165 insertions(+), 56 deletions(-) diff --git a/cmd/crossplane/top/top_test.go b/cmd/crossplane/top/top_test.go index 4415b826..543d5bed 100644 --- a/cmd/crossplane/top/top_test.go +++ b/cmd/crossplane/top/top_test.go @@ -15,22 +15,45 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" ) func TestImpersonationFlagsParse(t *testing.T) { - var c Cmd - - p, err := kong.New(&c) - if err != nil { - t.Fatalf("kong.New(): unexpected error: %v", err) + cases := map[string]struct { + reason string + args []string + want kube.ImpersonationFlags + }{ + "None": { + reason: "Without impersonation flags the fields should be empty.", + args: []string{}, + want: kube.ImpersonationFlags{}, + }, + "ServiceAccount": { + reason: "--as should accept a service account username.", + args: []string{"--as=system:serviceaccount:team-a:reader"}, + want: kube.ImpersonationFlags{As: "system:serviceaccount:team-a:reader"}, + }, } - if _, err := p.Parse([]string{"--as=system:serviceaccount:team-a:reader"}); err != nil { - t.Fatalf("Parse(): unexpected error: %v", err) - } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var c Cmd - if c.Impersonation.As != "system:serviceaccount:team-a:reader" { - t.Errorf("As: want service account, got %q", c.Impersonation.As) + p, err := kong.New(&c) + if err != nil { + t.Fatalf("%s\nkong.New(): unexpected error: %v", tc.reason, err) + } + + if _, err := p.Parse(tc.args); err != nil { + t.Fatalf("%s\nParse(%v): unexpected error: %v", tc.reason, tc.args, err) + } + + if diff := cmp.Diff(tc.want, c.Impersonation); diff != "" { + t.Errorf("%s\nParse(%v): -want, +got:\n%s", tc.reason, tc.args, diff) + } + }) } } diff --git a/cmd/crossplane/trace/trace_test.go b/cmd/crossplane/trace/trace_test.go index 06a26943..84ca33b1 100644 --- a/cmd/crossplane/trace/trace_test.go +++ b/cmd/crossplane/trace/trace_test.go @@ -8,6 +8,8 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" ) func TestCmd_getResourceAndName(t *testing.T) { @@ -106,21 +108,39 @@ func TestCmd_getResourceAndName(t *testing.T) { } func TestImpersonationFlagsParse(t *testing.T) { - var c Cmd - - p, err := kong.New(&c) - if err != nil { - t.Fatalf("kong.New(): unexpected error: %v", err) + cases := map[string]struct { + reason string + args []string + want kube.ImpersonationFlags + }{ + "None": { + reason: "Without impersonation flags the fields should be empty.", + args: []string{"configuration.example.org"}, + want: kube.ImpersonationFlags{}, + }, + "UserAndGroup": { + reason: "--as and --as-group should populate the embedded flags.", + args: []string{"--as=jane", "--as-group=team-a", "configuration.example.org"}, + want: kube.ImpersonationFlags{As: "jane", AsGroup: []string{"team-a"}}, + }, } - if _, err := p.Parse([]string{"--as=jane", "--as-group=team-a", "configuration.example.org"}); err != nil { - t.Fatalf("Parse(): unexpected error: %v", err) - } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var c Cmd - if c.Impersonation.As != "jane" { - t.Errorf("As: want %q, got %q", "jane", c.Impersonation.As) - } - if len(c.Impersonation.AsGroup) != 1 || c.Impersonation.AsGroup[0] != "team-a" { - t.Errorf("AsGroup: want [team-a], got %v", c.Impersonation.AsGroup) + p, err := kong.New(&c) + if err != nil { + t.Fatalf("%s\nkong.New(): unexpected error: %v", tc.reason, err) + } + + if _, err := p.Parse(tc.args); err != nil { + t.Fatalf("%s\nParse(%v): unexpected error: %v", tc.reason, tc.args, err) + } + + if diff := cmp.Diff(tc.want, c.Impersonation); diff != "" { + t.Errorf("%s\nParse(%v): -want, +got:\n%s", tc.reason, tc.args, diff) + } + }) } } diff --git a/cmd/crossplane/version/version_test.go b/cmd/crossplane/version/version_test.go index 8daf9605..69bb96d8 100644 --- a/cmd/crossplane/version/version_test.go +++ b/cmd/crossplane/version/version_test.go @@ -20,24 +20,45 @@ import ( "testing" "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" ) func TestImpersonationFlagsParse(t *testing.T) { - var c Cmd - - p, err := kong.New(&c) - if err != nil { - t.Fatalf("kong.New(): unexpected error: %v", err) + cases := map[string]struct { + reason string + args []string + want kube.ImpersonationFlags + }{ + "None": { + reason: "Without impersonation flags the fields should be empty.", + args: []string{}, + want: kube.ImpersonationFlags{}, + }, + "UserAndUID": { + reason: "--as and --as-uid should populate the embedded flags.", + args: []string{"--as=jane", "--as-uid=42"}, + want: kube.ImpersonationFlags{As: "jane", AsUID: "42"}, + }, } - if _, err := p.Parse([]string{"--as=jane", "--as-uid=42"}); err != nil { - t.Fatalf("Parse(): unexpected error: %v", err) - } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var c Cmd - if c.Impersonation.As != "jane" { - t.Errorf("As: want %q, got %q", "jane", c.Impersonation.As) - } - if c.Impersonation.AsUID != "42" { - t.Errorf("AsUID: want %q, got %q", "42", c.Impersonation.AsUID) + p, err := kong.New(&c) + if err != nil { + t.Fatalf("%s\nkong.New(): unexpected error: %v", tc.reason, err) + } + + if _, err := p.Parse(tc.args); err != nil { + t.Fatalf("%s\nParse(%v): unexpected error: %v", tc.reason, tc.args, err) + } + + if diff := cmp.Diff(tc.want, c.Impersonation); diff != "" { + t.Errorf("%s\nParse(%v): -want, +got:\n%s", tc.reason, tc.args, diff) + } + }) } } diff --git a/cmd/crossplane/xpkg/impersonation_test.go b/cmd/crossplane/xpkg/impersonation_test.go index f83720dc..2c756608 100644 --- a/cmd/crossplane/xpkg/impersonation_test.go +++ b/cmd/crossplane/xpkg/impersonation_test.go @@ -20,38 +20,83 @@ import ( "testing" "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/kube" ) func TestInstallImpersonationFlagsParse(t *testing.T) { - var c installCmd - - p, err := kong.New(&c) - if err != nil { - t.Fatalf("kong.New(): unexpected error: %v", err) + cases := map[string]struct { + reason string + args []string + want kube.ImpersonationFlags + }{ + "None": { + reason: "Without impersonation flags the fields should be empty.", + args: []string{"provider", "example.org/provider-foo:v1.0.0"}, + want: kube.ImpersonationFlags{}, + }, + "Group": { + reason: "--as-group should populate the embedded flags.", + args: []string{"--as-group=team-a-admins", "provider", "example.org/provider-foo:v1.0.0"}, + want: kube.ImpersonationFlags{AsGroup: []string{"team-a-admins"}}, + }, } - if _, err := p.Parse([]string{"--as-group=team-a-admins", "provider", "example.org/provider-foo:v1.0.0"}); err != nil { - t.Fatalf("Parse(): unexpected error: %v", err) - } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var c installCmd + + p, err := kong.New(&c) + if err != nil { + t.Fatalf("%s\nkong.New(): unexpected error: %v", tc.reason, err) + } + + if _, err := p.Parse(tc.args); err != nil { + t.Fatalf("%s\nParse(%v): unexpected error: %v", tc.reason, tc.args, err) + } - if len(c.Impersonation.AsGroup) != 1 || c.Impersonation.AsGroup[0] != "team-a-admins" { - t.Errorf("AsGroup: want [team-a-admins], got %v", c.Impersonation.AsGroup) + if diff := cmp.Diff(tc.want, c.Impersonation); diff != "" { + t.Errorf("%s\nParse(%v): -want, +got:\n%s", tc.reason, tc.args, diff) + } + }) } } func TestUpdateImpersonationFlagsParse(t *testing.T) { - var c updateCmd - - p, err := kong.New(&c) - if err != nil { - t.Fatalf("kong.New(): unexpected error: %v", err) + cases := map[string]struct { + reason string + args []string + want kube.ImpersonationFlags + }{ + "None": { + reason: "Without impersonation flags the fields should be empty.", + args: []string{"provider", "example.org/provider-foo:v1.0.1"}, + want: kube.ImpersonationFlags{}, + }, + "User": { + reason: "--as should populate the embedded flags.", + args: []string{"--as=jane", "provider", "example.org/provider-foo:v1.0.1"}, + want: kube.ImpersonationFlags{As: "jane"}, + }, } - if _, err := p.Parse([]string{"--as=jane", "provider", "example.org/provider-foo:v1.0.1"}); err != nil { - t.Fatalf("Parse(): unexpected error: %v", err) - } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var c updateCmd + + p, err := kong.New(&c) + if err != nil { + t.Fatalf("%s\nkong.New(): unexpected error: %v", tc.reason, err) + } + + if _, err := p.Parse(tc.args); err != nil { + t.Fatalf("%s\nParse(%v): unexpected error: %v", tc.reason, tc.args, err) + } - if c.Impersonation.As != "jane" { - t.Errorf("As: want %q, got %q", "jane", c.Impersonation.As) + if diff := cmp.Diff(tc.want, c.Impersonation); diff != "" { + t.Errorf("%s\nParse(%v): -want, +got:\n%s", tc.reason, tc.args, diff) + } + }) } }