Skip to content
Open
47 changes: 40 additions & 7 deletions cmd/crossplane/completion/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
controllerClient "sigs.k8s.io/controller-runtime/pkg/client"

"github.com/crossplane/cli/v2/cmd/crossplane/internal"
"github.com/crossplane/cli/v2/internal/kube"
)

// Predictors returns all supported predictors.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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{},
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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 := ""
Expand Down
48 changes: 48 additions & 0 deletions cmd/crossplane/completion/completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package completion

import (
"testing"

"github.com/google/go-cmp/cmp"
"github.com/posener/complete"

"github.com/crossplane/cli/v2/internal/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)
}
})
}
}
6 changes: 6 additions & 0 deletions cmd/crossplane/top/top.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/internal/kube"

_ "embed"
)

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions cmd/crossplane/trace/trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xrm"
"github.com/crossplane/cli/v2/cmd/crossplane/internal"
"github.com/crossplane/cli/v2/cmd/crossplane/trace/internal/printer"
"github.com/crossplane/cli/v2/internal/kube"

_ "embed"
)
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion cmd/crossplane/version/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (
ctrl "sigs.k8s.io/controller-runtime"

"github.com/crossplane/crossplane-runtime/v2/pkg/errors"

"github.com/crossplane/cli/v2/internal/kube"
)

const (
Expand All @@ -37,14 +39,16 @@ 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()
if err != nil {
return "", errors.Wrap(err, errKubeConfig)
}

imp.Apply(config)

clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return "", errors.Wrap(err, errCreateK8sClientset)
Expand Down
6 changes: 5 additions & 1 deletion cmd/crossplane/version/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (
"github.com/pkg/errors"

"github.com/crossplane/crossplane-runtime/v2/pkg/version"

"github.com/crossplane/cli/v2/internal/kube"
)

const (
Expand All @@ -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.
Expand All @@ -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)
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/crossplane/xpkg/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/internal/kube"

_ "embed"
// Load all the auth plugins for the cloud providers.
_ "k8s.io/client-go/plugin/pkg/client/auth"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions cmd/crossplane/xpkg/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/internal/kube"

_ "embed"
_ "k8s.io/client-go/plugin/pkg/client/auth" // Load all the auth plugins for the cloud providers.
)
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
51 changes: 51 additions & 0 deletions internal/kube/impersonation.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to put this functionality in internal rather than cmd/crossplane/common. It's CLI-specific, so I don't see any reason for it to be importable by external codebases, and we should get rid of this common directory eventually (see below).

This directory is a leftover from the CLI being in the core crossplane/crossplane repository (which intentionally doesn't have a pkg/ for exported packages) and wanting to expose some code for CLI-adjacent utilities like crossplane-diff to use. We'd like to start moving the code that lives here into either internal/ or pkg/ as appropriate to make it clear what can/should be imported externally (it's a bit of a smell to import code from cmd/).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for letting me know! I'll move it to internal later today.

Original file line number Diff line number Diff line change
@@ -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 `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
// 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
}
}
Loading