diff --git a/cmd/crossplane/completion/completion.go b/cmd/crossplane/completion/completion.go index 5af51b65..98ec4b46 100644 --- a/cmd/crossplane/completion/completion.go +++ b/cmd/crossplane/completion/completion.go @@ -49,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), parseImpersonation(a)) + _, kubeconfig, _, err := kubernetesClient(parseConfigFlags(a), parseConfigOverride(a), parseImpersonation(a)) if err != nil { return nil } @@ -104,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), parseImpersonation(a)) + client, kubeconfig, clientconfig, err := kubernetesClient(parseConfigFlags(a), parseConfigOverride(a), parseImpersonation(a)) if err != nil { return nil } @@ -163,12 +163,7 @@ func kubernetesResourceNamePredictor() complete.PredictFunc { // contextPredictor returns a predictor that suggests Kubernetes contexts from the KUBECONFIG. func contextPredictor() complete.PredictFunc { return func(a complete.Args) []string { - clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - clientcmd.NewDefaultClientConfigLoadingRules(), - &clientcmd.ConfigOverrides{}, - ) - - kubeConfig, err := clientConfig.RawConfig() + kubeConfig, err := parseConfigFlags(a).ClientConfig(&clientcmd.ConfigOverrides{}).RawConfig() if err != nil { return nil } @@ -190,7 +185,7 @@ func contextPredictor() complete.PredictFunc { // last completed argument. func namespacePredictor() complete.PredictFunc { return func(a complete.Args) []string { - client, err := kubernetesClientset(parseImpersonation(a)) + client, err := kubernetesClientset(parseConfigFlags(a), parseImpersonation(a)) if err != nil { return nil } @@ -212,15 +207,10 @@ 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 using the given config +// and impersonation flags. +func kubernetesClientset(kc kube.ConfigFlags, imp kube.ImpersonationFlags) (*kubernetes.Clientset, error) { + kubeConfig, err := kc.ClientConfig(&clientcmd.ConfigOverrides{}).ClientConfig() if err != nil { return nil, err } @@ -231,12 +221,9 @@ func kubernetesClientset(imp kube.ImpersonationFlags) (*kubernetes.Clientset, er } // 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, - ) +// provided config flags, config overrides and impersonation flags. +func kubernetesClient(kc kube.ConfigFlags, configOverrides *clientcmd.ConfigOverrides, imp kube.ImpersonationFlags) (controllerClient.Client, *rest.Config, clientcmd.ClientConfig, error) { + clientconfig := kc.ClientConfig(configOverrides) kubeconfig, err := clientconfig.ClientConfig() if err != nil { @@ -269,6 +256,23 @@ func parseConfigOverride(a complete.Args) *clientcmd.ConfigOverrides { } } +// parseConfigFlags parses the --kubeconfig flag from the completed command +// line arguments. Supports both "--flag value" and "--flag=value" forms. +func parseConfigFlags(a complete.Args) kube.ConfigFlags { + var kc kube.ConfigFlags + + for i, arg := range a.All { + switch { + case arg == "--kubeconfig" && i+1 < len(a.All): + kc.Kubeconfig = a.All[i+1] + case strings.HasPrefix(arg, "--kubeconfig="): + kc.Kubeconfig = strings.TrimPrefix(arg, "--kubeconfig=") + } + } + + return kc +} + // 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. diff --git a/cmd/crossplane/completion/completion_test.go b/cmd/crossplane/completion/completion_test.go index ef756150..4a389133 100644 --- a/cmd/crossplane/completion/completion_test.go +++ b/cmd/crossplane/completion/completion_test.go @@ -9,6 +9,39 @@ import ( "github.com/crossplane/cli/v2/internal/kube" ) +func TestParseConfigFlags(t *testing.T) { + cases := map[string]struct { + reason string + args []string + want kube.ConfigFlags + }{ + "Equals": { + reason: "The --flag=value form is parsed.", + args: []string{"trace", "x", "--kubeconfig=/tmp/kubeconfig"}, + want: kube.ConfigFlags{Kubeconfig: "/tmp/kubeconfig"}, + }, + "Space": { + reason: "The --flag value form is parsed.", + args: []string{"--kubeconfig", "/tmp/kubeconfig"}, + want: kube.ConfigFlags{Kubeconfig: "/tmp/kubeconfig"}, + }, + "None": { + reason: "No --kubeconfig flag yields the zero value.", + args: []string{"trace", "x"}, + want: kube.ConfigFlags{}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := parseConfigFlags(complete.Args{All: tc.args}) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("%s\nparseConfigFlags(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + func TestParseImpersonation(t *testing.T) { cases := map[string]struct { reason string diff --git a/cmd/crossplane/top/top.go b/cmd/crossplane/top/top.go index 83f65299..99ba331b 100644 --- a/cmd/crossplane/top/top.go +++ b/cmd/crossplane/top/top.go @@ -31,7 +31,6 @@ import ( "k8s.io/cli-runtime/pkg/printers" "k8s.io/client-go/kubernetes" "k8s.io/metrics/pkg/client/clientset/versioned" - ctrl "sigs.k8s.io/controller-runtime" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -61,6 +60,7 @@ 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"` + Config kube.ConfigFlags `embed:""` Impersonation kube.ImpersonationFlags `embed:""` } @@ -102,7 +102,7 @@ func (c *Cmd) Run(k *kong.Context, logger logging.Logger) error { logger.Debug("Tabwriter header created") // Build the config from the kubeconfig path - config, err := ctrl.GetConfig() + config, err := c.Config.RESTConfig() if err != nil { return errors.Wrap(err, errKubeConfig) } diff --git a/cmd/crossplane/trace/help/trace.md b/cmd/crossplane/trace/help/trace.md index cb249c4e..dc22b2aa 100644 --- a/cmd/crossplane/trace/help/trace.md +++ b/cmd/crossplane/trace/help/trace.md @@ -15,7 +15,8 @@ You can further specify the kind as `TYPE[.VERSION][.GROUP]` if needed; for example, `mykind.example.org` or `mykind.v1alpha1.example.org`. By default, `crossplane resource trace` uses the Kubernetes configuration at -`~/.kube/config`. Override with the `KUBECONFIG` environment variable. +`~/.kube/config`. Override with the `--kubeconfig` flag or the `KUBECONFIG` +environment variable. ## Output options diff --git a/cmd/crossplane/trace/trace.go b/cmd/crossplane/trace/trace.go index 302482e1..57dd2a94 100644 --- a/cmd/crossplane/trace/trace.go +++ b/cmd/crossplane/trace/trace.go @@ -81,6 +81,7 @@ type Cmd struct { Concurrency int `default:"5" help:"load concurrency" name:"concurrency"` Watch bool `default:"false" help:"Watch for changes until resource deletion." name:"watch" short:"w"` + Config kube.ConfigFlags `embed:""` Impersonation kube.ImpersonationFlags `embed:""` } @@ -90,10 +91,7 @@ func (c *Cmd) Help() string { } func (c *Cmd) setupKubeClient(logger logging.Logger) (clientcmd.ClientConfig, client.WithWatch, meta.RESTMapper, error) { - clientconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - clientcmd.NewDefaultClientConfigLoadingRules(), - &clientcmd.ConfigOverrides{CurrentContext: c.Context}, - ) + clientconfig := c.Config.ClientConfig(&clientcmd.ConfigOverrides{CurrentContext: c.Context}) kubeconfig, err := clientconfig.ClientConfig() if err != nil { diff --git a/cmd/crossplane/version/fetch.go b/cmd/crossplane/version/fetch.go index ba8e80e8..917ab086 100644 --- a/cmd/crossplane/version/fetch.go +++ b/cmd/crossplane/version/fetch.go @@ -23,7 +23,6 @@ import ( "github.com/google/go-containerregistry/pkg/name" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" - ctrl "sigs.k8s.io/controller-runtime" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -39,10 +38,10 @@ 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, imp kube.ImpersonationFlags) (string, error) { +func FetchCrossplaneVersion(ctx context.Context, kc kube.ConfigFlags, imp kube.ImpersonationFlags) (string, error) { var version string - config, err := ctrl.GetConfig() + config, err := kc.RESTConfig() if err != nil { return "", errors.Wrap(err, errKubeConfig) } diff --git a/cmd/crossplane/version/version.go b/cmd/crossplane/version/version.go index e58c2919..f18e2001 100644 --- a/cmd/crossplane/version/version.go +++ b/cmd/crossplane/version/version.go @@ -38,6 +38,7 @@ const ( type Cmd struct { Client bool `env:"" help:"If true, shows client version only (no server required)."` + Config kube.ConfigFlags `embed:""` Impersonation kube.ImpersonationFlags `embed:""` } @@ -52,7 +53,7 @@ func (c *Cmd) Run(k *kong.Context) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - vxp, err := FetchCrossplaneVersion(ctx, c.Impersonation) + vxp, err := FetchCrossplaneVersion(ctx, c.Config, c.Impersonation) if err != nil { return errors.Wrap(err, errGetCrossplaneVersion) } diff --git a/cmd/crossplane/xpkg/help/install.md b/cmd/crossplane/xpkg/help/install.md index ceba51b4..99b4d929 100644 --- a/cmd/crossplane/xpkg/help/install.md +++ b/cmd/crossplane/xpkg/help/install.md @@ -1,6 +1,6 @@ The `xpkg install` command installs a package in a Crossplane control plane. It uses `~/.kube/config` to connect to the control plane; override the path with -the `KUBECONFIG` environment variable. +the `--kubeconfig` flag or the `KUBECONFIG` environment variable. Specify the package kind, fully qualified package OCI reference, and optionally a name for the package inside Crossplane: diff --git a/cmd/crossplane/xpkg/help/update.md b/cmd/crossplane/xpkg/help/update.md index 84daab97..699b7aa1 100644 --- a/cmd/crossplane/xpkg/help/update.md +++ b/cmd/crossplane/xpkg/help/update.md @@ -1,6 +1,6 @@ The `xpkg update` command updates a package in a Crossplane control plane. It uses `~/.kube/config` to connect to the control plane; override the path with -the `KUBECONFIG` environment variable. +the `--kubeconfig` flag or the `KUBECONFIG` environment variable. Specify the package kind, a new fully qualified package OCI reference, and optionally the name of the package already installed in Crossplane: diff --git a/cmd/crossplane/xpkg/install.go b/cmd/crossplane/xpkg/install.go index 493d6fe3..12662b50 100644 --- a/cmd/crossplane/xpkg/install.go +++ b/cmd/crossplane/xpkg/install.go @@ -29,7 +29,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/wait" - ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -70,6 +69,7 @@ type installCmd struct { 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"` + Config kube.ConfigFlags `embed:""` Impersonation kube.ImpersonationFlags `embed:""` } @@ -147,7 +147,7 @@ func (c *installCmd) Run(k *kong.Context, logger logging.Logger) error { rpkg.SetRuntimeConfigRef(&v1.RuntimeConfigReference{Name: c.RuntimeConfig}) } - cfg, err := ctrl.GetConfig() + cfg, err := c.Config.RESTConfig() if err != nil { return errors.Wrap(err, errKubeConfig) } diff --git a/cmd/crossplane/xpkg/update.go b/cmd/crossplane/xpkg/update.go index 86293e5b..b4da5f54 100644 --- a/cmd/crossplane/xpkg/update.go +++ b/cmd/crossplane/xpkg/update.go @@ -26,7 +26,6 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/retry" - ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -52,6 +51,7 @@ type updateCmd struct { 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:""` + Config kube.ConfigFlags `embed:""` Impersonation kube.ImpersonationFlags `embed:""` } @@ -92,7 +92,7 @@ func (c *updateCmd) Run(k *kong.Context, logger logging.Logger) error { return errors.Errorf("unsupported package kind %q", c.Kind) } - cfg, err := ctrl.GetConfig() + cfg, err := c.Config.RESTConfig() if err != nil { return errors.Wrap(err, errKubeConfig) } diff --git a/internal/kube/config.go b/internal/kube/config.go new file mode 100644 index 00000000..1ca67399 --- /dev/null +++ b/internal/kube/config.go @@ -0,0 +1,70 @@ +/* +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 ( + "os" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// ConfigFlags are the kubectl-compatible flags that select the kubeconfig +// used to reach the cluster. Embed it into a command's Kong flag struct with +// the `embed:""` tag. +type ConfigFlags struct { + Kubeconfig string `help:"Path to the kubeconfig file to use for CLI requests." name:"kubeconfig" placeholder:"PATH" predictor:"file"` +} + +// ClientConfig returns a client config for the selected kubeconfig. An +// explicit --kubeconfig wins over the KUBECONFIG environment variable and the +// default ~/.kube/config location. +func (f ConfigFlags) ClientConfig(overrides *clientcmd.ConfigOverrides) clientcmd.ClientConfig { + rules := clientcmd.NewDefaultClientConfigLoadingRules() + rules.ExplicitPath = f.Kubeconfig + + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, overrides) +} + +// RESTConfig returns a rest.Config with the precedence of controller-runtime's +// config.GetConfig: the --kubeconfig flag, then the KUBECONFIG environment +// variable, then the in-cluster config, then ~/.kube/config. Client-side rate +// limiting is disabled unless the kubeconfig sets it. +func (f ConfigFlags) RESTConfig() (*rest.Config, error) { + if f.Kubeconfig == "" && os.Getenv(clientcmd.RecommendedConfigPathEnvVar) == "" { + if cfg, err := rest.InClusterConfig(); err == nil { + return withDefaultQPS(cfg), nil + } + } + + cfg, err := f.ClientConfig(&clientcmd.ConfigOverrides{}).ClientConfig() + if err != nil { + return nil, errors.Wrap(err, "cannot load a kubeconfig, use --kubeconfig to select one") + } + + return withDefaultQPS(cfg), nil +} + +func withDefaultQPS(cfg *rest.Config) *rest.Config { + if cfg.QPS == 0 { + cfg.QPS = -1 + } + + return cfg +} diff --git a/internal/kube/config_test.go b/internal/kube/config_test.go new file mode 100644 index 00000000..92a28b80 --- /dev/null +++ b/internal/kube/config_test.go @@ -0,0 +1,117 @@ +/* +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 ( + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func TestRESTConfig(t *testing.T) { + dir := t.TempDir() + flag := writeKubeconfig(t, filepath.Join(dir, "flag"), "https://flag.example.com") + env := writeKubeconfig(t, filepath.Join(dir, "env"), "https://env.example.com") + home := writeKubeconfig(t, filepath.Join(dir, "home", ".kube", "config"), "https://home.example.com") + + type args struct { + env string + flags ConfigFlags + } + + type want struct { + host string + qps float32 + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "FlagWinsOverEnv": { + reason: "--kubeconfig takes precedence over KUBECONFIG.", + args: args{env: env, flags: ConfigFlags{Kubeconfig: flag}}, + want: want{host: "https://flag.example.com", qps: -1}, + }, + "FlagOnly": { + reason: "--kubeconfig is used when KUBECONFIG is unset.", + args: args{flags: ConfigFlags{Kubeconfig: flag}}, + want: want{host: "https://flag.example.com", qps: -1}, + }, + "EnvWhenFlagUnset": { + reason: "KUBECONFIG is used when --kubeconfig is unset.", + args: args{env: env}, + want: want{host: "https://env.example.com", qps: -1}, + }, + "MissingFlagFile": { + reason: "A --kubeconfig that does not exist is an error, even if KUBECONFIG is set.", + args: args{env: env, flags: ConfigFlags{Kubeconfig: filepath.Join(dir, "missing")}}, + want: want{err: cmpopts.AnyError}, + }, + "HomeWhenFlagAndEnvUnset": { + reason: "~/.kube/config is used outside a cluster when neither --kubeconfig nor KUBECONFIG is set.", + want: want{host: "https://home.example.com", qps: -1}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Setenv(clientcmd.RecommendedConfigPathEnvVar, tc.args.env) + t.Setenv("KUBERNETES_SERVICE_HOST", "") + t.Setenv("KUBERNETES_SERVICE_PORT", "") + recommended := clientcmd.RecommendedHomeFile + clientcmd.RecommendedHomeFile = home + t.Cleanup(func() { clientcmd.RecommendedHomeFile = recommended }) + + cfg, err := tc.args.flags.RESTConfig() + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("%s\nRESTConfig(): -want error, +got error:\n%s", tc.reason, diff) + } + if err != nil { + return + } + if diff := cmp.Diff(tc.want.host, cfg.Host); diff != "" { + t.Errorf("%s\nRESTConfig(): -want host, +got host:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.qps, cfg.QPS); diff != "" { + t.Errorf("%s\nRESTConfig(): -want QPS, +got QPS:\n%s", tc.reason, diff) + } + }) + } +} + +func writeKubeconfig(t *testing.T, path, server string) string { + t.Helper() + + cfg := clientcmdapi.NewConfig() + cfg.Clusters["cluster"] = &clientcmdapi.Cluster{Server: server} + cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{Token: "token"} + cfg.Contexts["context"] = &clientcmdapi.Context{Cluster: "cluster", AuthInfo: "user"} + cfg.CurrentContext = "context" + + if err := clientcmd.WriteToFile(*cfg, path); err != nil { + t.Fatalf("WriteToFile(%q): unexpected error: %v", path, err) + } + + return path +}