diff --git a/README.md b/README.md index d526cb8..6c83b41 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - **Syncs kubeconfigs** — fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local `~/.kube/config`, handling OIDC token caching, deduplication, and prefix-based entry management - **Reports cluster versions** — queries the Kubernetes API version of any context, trying unauthenticated first for speed +- **Self-updates** — checks for and installs the latest cloudctl release from GitHub - **Structured output** — every command supports `--output text|json|yaml` for scripting and pipelines; interactive terminals get a spinner and a colour-coded table ## Installation @@ -44,6 +45,9 @@ cloudctl cluster-version --context # Print version info cloudctl version cloudctl version --output json # machine-readable + +# Update cloudctl to the latest release +cloudctl update ``` ## Output formats @@ -109,16 +113,18 @@ log-level: info ### `sync` -Fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local kubeconfig. +Fetches `ClusterKubeconfig` resources from Greenhouse and merges them into your local kubeconfig. Before connecting, it logs a summary to stderr showing which kubeconfig files, context, and namespace are in use. + +The `--greenhouse-cluster-kubeconfig` and `--remote-cluster-kubeconfig` flags support the standard `KUBECONFIG` environment variable: when no explicit path is given, cloudctl defers to `KUBECONFIG` (multi-file merge, same as `kubectl`). ``` cloudctl sync -n [flags] Flags: - -k, --greenhouse-cluster-kubeconfig Path to Greenhouse cluster kubeconfig (default: ~/.kube/config) + -k, --greenhouse-cluster-kubeconfig Path to Greenhouse cluster kubeconfig (default: $KUBECONFIG or ~/.kube/config) -c, --greenhouse-cluster-context Context inside the Greenhouse kubeconfig -n, --greenhouse-cluster-namespace Greenhouse organization namespace (required) - -r, --remote-cluster-kubeconfig Local kubeconfig to merge into (default: ~/.kube/config) + -r, --remote-cluster-kubeconfig Local kubeconfig to merge into (default: $KUBECONFIG or ~/.kube/config) --remote-cluster-name Sync only this cluster (default: all ready clusters) --prefix Prefix for managed kubeconfig entries (default: cloudctl) --merge-identical-users Share a single auth entry for clusters with identical OIDC config (default: true) @@ -126,17 +132,20 @@ Flags: --kubelogin-path Path to kubelogin binary (default: kubelogin) --kubelogin-extra-args Extra flags passed to kubelogin --kubelogin-token-cache-dir OIDC token cache directory + --dry-run Preview changes without writing to the kubeconfig file ``` ### `cluster-version` -Queries the Kubernetes server version for a given kubeconfig context. Tries an unauthenticated request first; falls back to an authenticated one if needed. +Queries the Kubernetes server version for a given kubeconfig context. Tries an unauthenticated request first; falls back to an authenticated one if needed. Logs a summary to stderr showing the kubeconfig source and context before querying. + +Respects the `KUBECONFIG` environment variable when no explicit `--kubeconfig` path is given. ``` cloudctl cluster-version [flags] Flags: - -k, --kubeconfig Path to kubeconfig file (default: ~/.kube/config) + -k, --kubeconfig Path to kubeconfig file (default: $KUBECONFIG or ~/.kube/config) -c, --context Context to query --timeout Maximum time to wait for the API server (default: 10s) ``` @@ -152,6 +161,17 @@ Flags: --short Print only the version number ``` +### `update` + +Checks for the latest cloudctl release on GitHub and installs it, replacing the current binary. + +``` +cloudctl update [flags] + +Flags: + --check Check for a newer version without installing it +``` + ## Support, Feedback, Contributing This project is open to feature requests, bug reports, and contributions via [GitHub issues](https://github.com/cloudoperators/cloudctl/issues) and pull requests. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. diff --git a/cmd/cluster-version.go b/cmd/cluster-version.go index a95d8ef..df98f2b 100644 --- a/cmd/cluster-version.go +++ b/cmd/cluster-version.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "encoding/json" "fmt" + "log/slog" "net/http" "os" "strings" @@ -55,9 +56,14 @@ var ( ) func runClusterVersion(cmd *cobra.Command, args []string) error { - kubeconfig = viper.GetString("kubeconfig") + kubeconfig = resolveKubeconfig("kubeconfig", viper.GetString("kubeconfig")) kubecontext = viper.GetString("context") + // Reject an explicit empty-string value. + if viper.IsSet("kubeconfig") && kubeconfig == "" { + return fmt.Errorf("--kubeconfig must not be empty") + } + timeoutStr := viper.GetString("timeout") timeout, err := time.ParseDuration(timeoutStr) if err != nil { @@ -66,7 +72,11 @@ func runClusterVersion(cmd *cobra.Command, args []string) error { cfg, err := configWithContext(kubecontext, kubeconfig) if err != nil { - return fmt.Errorf("failed to build kubeconfig with context %q: %w", kubecontext, err) + ctxDisplay := kubecontext + if ctxDisplay == "" { + ctxDisplay = "(current context)" + } + return fmt.Errorf("failed to build kubeconfig (source: %s, context: %s): %w", displayKubeconfig(kubeconfig), ctxDisplay, err) } // Resolve the actual context name used so the output is never empty. @@ -74,12 +84,23 @@ func runClusterVersion(cmd *cobra.Command, args []string) error { // current-context field from the kubeconfig. effectiveContext := kubecontext if effectiveContext == "" { - loadingRules := clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig} + var loadingRules *clientcmd.ClientConfigLoadingRules + if kubeconfig != "" { + loadingRules = &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig} + } else { + loadingRules = clientcmd.NewDefaultClientConfigLoadingRules() + } raw, rawErr := loadingRules.Load() if rawErr == nil && raw != nil { effectiveContext = raw.CurrentContext } } + if effectiveContext == "" { + effectiveContext = "(unknown)" + } + + // Log informational line before querying the server. + slog.Info("querying cluster version", "kubeconfig", displayKubeconfig(kubeconfig), "context", effectiveContext) ctx, cancel := context.WithTimeout(cmd.Context(), timeout) defer cancel() diff --git a/cmd/cluster-version_test.go b/cmd/cluster-version_test.go index af0e044..8dc76e6 100644 --- a/cmd/cluster-version_test.go +++ b/cmd/cluster-version_test.go @@ -14,6 +14,7 @@ import ( . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) @@ -128,3 +129,14 @@ func TestGetAuthenticatedVersion_NonOKStatus(t *testing.T) { g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(ContainSubstring("500")) } + +func TestClusterVersionKubeconfigFlag_DefaultEqualsRecommendedHomeFile(t *testing.T) { + g := NewWithT(t) + + // Verify that the --kubeconfig flag default equals clientcmd.RecommendedHomeFile. + // resolveKubeconfig detects "user did not explicitly set a path" via viper.IsSet: + // when the flag is unset, viper returns the default and IsSet returns false. + f := clusterVersionCmd.Flags().Lookup("kubeconfig") + g.Expect(f).ToNot(BeNil()) + g.Expect(f.DefValue).To(Equal(clientcmd.RecommendedHomeFile)) +} diff --git a/cmd/root.go b/cmd/root.go index 3826043..2af3a95 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -90,10 +90,43 @@ func init() { rootCmd.AddCommand(updateCmd) } +// resolveKubeconfig returns the kubeconfig path to use. +// viperKey is the viper key for the flag (e.g. "kubeconfig", "greenhouse-cluster-kubeconfig"). +// When the key was not explicitly set by the user (via flag, CLOUDCTL_* env var, or config file) +// and the standard KUBECONFIG env var is set, it returns "" so that client-go uses its standard +// multi-file loading rules. An explicitly provided value is always returned as-is. +func resolveKubeconfig(viperKey, flagValue string) string { + if viper.IsSet(viperKey) { + return flagValue + } + if os.Getenv("KUBECONFIG") != "" { + return "" + } + return flagValue +} + +// displayKubeconfig returns a human-readable label for the effective kubeconfig source. +// When path is "" the KUBECONFIG env var is active; otherwise the explicit path is shown. +// If path is "" and KUBECONFIG is also unset, client-go's default (~/.kube/config) is used. +func displayKubeconfig(path string) string { + if path == "" { + if kc := os.Getenv("KUBECONFIG"); kc != "" { + return "$KUBECONFIG (" + kc + ")" + } + return clientcmd.RecommendedHomeFile + } + return path +} + // configWithContext builds a rest.Config for the specified context name from the given kubeconfig path. +// When kubeconfigPath is empty, client-go's default loading rules are used (reads KUBECONFIG env var +// and falls back to ~/.kube/config). func configWithContext(contextName, kubeconfigPath string) (*rest.Config, error) { - loadingRules := &clientcmd.ClientConfigLoadingRules{ - ExplicitPath: kubeconfigPath, + var loadingRules *clientcmd.ClientConfigLoadingRules + if kubeconfigPath != "" { + loadingRules = &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath} + } else { + loadingRules = clientcmd.NewDefaultClientConfigLoadingRules() } overrides := &clientcmd.ConfigOverrides{ CurrentContext: contextName, diff --git a/cmd/root_test.go b/cmd/root_test.go index 47203f6..9f82b8f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/spf13/viper" + "k8s.io/client-go/tools/clientcmd" . "github.com/onsi/gomega" ) @@ -88,3 +89,66 @@ func TestMissingConfigurationFile(t *testing.T) { err := setupConfig() g.Expect(err).NotTo(BeNil()) } + +func TestResolveKubeconfig_ExplicitlySet(t *testing.T) { + g := NewWithT(t) + + t.Setenv("KUBECONFIG", "/some/other/config") + t.Cleanup(func() { viper.Reset() }) + + // When the viper key is explicitly set (simulating flag/CLOUDCTL_* env var), + // the provided value must be returned as-is regardless of KUBECONFIG. + viper.Set("kubeconfig", "/my/explicit/path") + result := resolveKubeconfig("kubeconfig", "/my/explicit/path") + g.Expect(result).To(Equal("/my/explicit/path")) +} + +func TestResolveKubeconfig_NotSetWithKUBECONFIG(t *testing.T) { + g := NewWithT(t) + + t.Setenv("KUBECONFIG", "/env/kubeconfig") + t.Cleanup(func() { viper.Reset() }) + + // When the key was not explicitly set and KUBECONFIG is set, return "" to let client-go handle it. + result := resolveKubeconfig("kubeconfig", clientcmd.RecommendedHomeFile) + g.Expect(result).To(BeEmpty()) +} + +func TestResolveKubeconfig_NotSetWithoutKUBECONFIG(t *testing.T) { + g := NewWithT(t) + + t.Setenv("KUBECONFIG", "") + t.Cleanup(func() { viper.Reset() }) + + // When KUBECONFIG is not set either, return the default path as-is. + result := resolveKubeconfig("kubeconfig", clientcmd.RecommendedHomeFile) + g.Expect(result).To(Equal(clientcmd.RecommendedHomeFile)) +} + +func TestResolveKubeconfig_ViperIsSetFalseForUnparsedCobraFlag(t *testing.T) { + g := NewWithT(t) + + t.Setenv("KUBECONFIG", "/env/kubeconfig") + t.Cleanup(func() { viper.Reset() }) + + // Bind clusterVersionCmd's flags to a fresh viper instance and parse + // with no arguments so the --kubeconfig flag is left at its default. + // viper.IsSet must remain false, meaning resolveKubeconfig defers to + // KUBECONFIG (returns ""). + // + // This guards against a regression where Cobra/pflag/viper semantics + // change such that binding a flag with a default causes IsSet to return + // true even when the user never touched the flag. + localViper := viper.New() + g.Expect(localViper.BindPFlags(clusterVersionCmd.Flags())).To(Succeed()) + g.Expect(clusterVersionCmd.ParseFlags([]string{})).To(Succeed()) + + g.Expect(localViper.IsSet("kubeconfig")).To(BeFalse(), + "viper.IsSet must be false for a flag that was not explicitly set by the user") + + // Confirm resolveKubeconfig returns "" (defer to KUBECONFIG) in this state. + // We use the global viper (which is what resolveKubeconfig reads) — it + // has not been set, so IsSet is also false there. + result := resolveKubeconfig("kubeconfig", clientcmd.RecommendedHomeFile) + g.Expect(result).To(BeEmpty()) +} diff --git a/cmd/sync.go b/cmd/sync.go index a9ef402..5bc30b3 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -114,10 +114,19 @@ Examples: func runSync(cmd *cobra.Command, args []string) error { // Use viper as a source of configuration - greenhouseClusterKubeconfig = viper.GetString("greenhouse-cluster-kubeconfig") + greenhouseClusterKubeconfig = resolveKubeconfig("greenhouse-cluster-kubeconfig", viper.GetString("greenhouse-cluster-kubeconfig")) greenhouseClusterContext = viper.GetString("greenhouse-cluster-context") greenhouseClusterNamespace = viper.GetString("greenhouse-cluster-namespace") - remoteClusterKubeconfig = viper.GetString("remote-cluster-kubeconfig") + remoteClusterKubeconfig = resolveKubeconfig("remote-cluster-kubeconfig", viper.GetString("remote-cluster-kubeconfig")) + + // Reject an explicit empty-string value — it would silently fall through to + // client-go default loading rules instead of failing fast. + if viper.IsSet("greenhouse-cluster-kubeconfig") && greenhouseClusterKubeconfig == "" { + return fmt.Errorf("--greenhouse-cluster-kubeconfig must not be empty") + } + if viper.IsSet("remote-cluster-kubeconfig") && remoteClusterKubeconfig == "" { + return fmt.Errorf("--remote-cluster-kubeconfig must not be empty") + } remoteClusterName = viper.GetString("remote-cluster-name") prefix = viper.GetString("prefix") mergeIdenticalUsers = viper.GetBool("merge-identical-users") @@ -134,31 +143,35 @@ func runSync(cmd *cobra.Command, args []string) error { w := cmd.OutOrStdout() printer := output.New(format, output.IsTTYWriter(w), w) - if greenhouseClusterKubeconfig == "" { - return fmt.Errorf("greenhouse cluster kubeconfig path is empty") - } - - if _, err := os.Stat(greenhouseClusterKubeconfig); err != nil { - return fmt.Errorf("greenhouse cluster kubeconfig file not found at %q: %w", greenhouseClusterKubeconfig, err) + // When path is not empty (explicit file), verify it exists before proceeding. + if greenhouseClusterKubeconfig != "" { + if _, err := os.Stat(greenhouseClusterKubeconfig); err != nil { + return fmt.Errorf("greenhouse cluster kubeconfig file not found at %q: %w", greenhouseClusterKubeconfig, err) + } } if err := validateAuthType(authType, kubeloginPath); err != nil { return err } + // Log informational summary so the user knows which files/context/namespace are active. + ctxLabel := greenhouseClusterContext + if ctxLabel == "" { + ctxLabel = "(current context)" + } + slog.Info("syncing kubeconfigs", + "greenhouse", displayKubeconfig(greenhouseClusterKubeconfig), + "context", ctxLabel, + "namespace", greenhouseClusterNamespace, + "local", displayKubeconfig(remoteClusterKubeconfig), + ) + var ( centralConfig *rest.Config ) - if greenhouseClusterContext != "" { - centralConfig, err = configWithContext(greenhouseClusterContext, greenhouseClusterKubeconfig) - if err != nil { - return fmt.Errorf("failed to build greenhouse kubeconfig with context %q from %q: %w", greenhouseClusterContext, greenhouseClusterKubeconfig, err) - } - } else { - centralConfig, err = clientcmd.BuildConfigFromFlags("", greenhouseClusterKubeconfig) - if err != nil { - return fmt.Errorf("failed to build greenhouse kubeconfig from %q: %w", greenhouseClusterKubeconfig, err) - } + centralConfig, err = configWithContext(greenhouseClusterContext, greenhouseClusterKubeconfig) + if err != nil { + return fmt.Errorf("failed to build greenhouse kubeconfig (source: %s, context: %s): %w", displayKubeconfig(greenhouseClusterKubeconfig), ctxLabel, err) } // Create a scheme and register Greenhouse types. @@ -203,7 +216,12 @@ func runSync(cmd *cobra.Command, args []string) error { return printer.Print(buildSyncResult(nil, notReady)) } - localConfig, err := clientcmd.LoadFromFile(remoteClusterKubeconfig) + var localConfig *clientcmdapi.Config + if remoteClusterKubeconfig != "" { + localConfig, err = clientcmd.LoadFromFile(remoteClusterKubeconfig) + } else { + localConfig, err = clientcmd.NewDefaultClientConfigLoadingRules().Load() + } if err != nil { return fmt.Errorf("failed to load local kubeconfig: %w", err) } @@ -240,7 +258,12 @@ func runSync(cmd *cobra.Command, args []string) error { return printer.Print(buildDryRunResult(diff, localConfigBefore, localConfig)) } - if writeErr := writeConfig(localConfig, remoteClusterKubeconfig); writeErr != nil { + writeTarget, writeTargetErr := resolveWriteTarget(remoteClusterKubeconfig) + if writeTargetErr != nil { + return writeTargetErr + } + + if writeErr := writeConfig(localConfig, writeTarget); writeErr != nil { _ = printer.Print(buildFailedSyncResult(ready, notReady, writeErr)) return fmt.Errorf("failed to write merged kubeconfig: %w", writeErr) } @@ -773,3 +796,21 @@ func validateAuthType(authType, kubeloginPath string) error { return fmt.Errorf("invalid --auth-type %q: must be one of \"auth-provider\" or \"exec-plugin\"", authType) } } + +// resolveWriteTarget returns the kubeconfig file path to write merged config into. +// When remoteKubeconfig is non-empty it is used directly. Otherwise the first +// path from the KUBECONFIG env var is used (kubectl convention for multi-file merge). +// An error is returned when no usable path can be determined. +func resolveWriteTarget(remoteKubeconfig string) (string, error) { + if remoteKubeconfig != "" { + return remoteKubeconfig, nil + } + kc := os.Getenv("KUBECONFIG") + if kc == "" { + return "", fmt.Errorf("cannot determine write target: --remote-cluster-kubeconfig is not set and KUBECONFIG is empty") + } + if parts := strings.SplitN(kc, string(os.PathListSeparator), 2); len(parts) > 0 && parts[0] != "" { + return parts[0], nil + } + return "", fmt.Errorf("cannot determine write target: KUBECONFIG=%q contains no usable first path", kc) +} diff --git a/cmd/sync_test.go b/cmd/sync_test.go index cc44136..fc89fb8 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -14,6 +14,7 @@ import ( greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "github.com/cloudoperators/cloudctl/cmd/output" @@ -985,3 +986,72 @@ func TestBuildAccessDiffs_NoChanges(t *testing.T) { g.Expect(accesses).To(BeEmpty()) } + +func TestSyncKubeconfigFlags_DefaultEqualsRecommendedHomeFile(t *testing.T) { + g := NewWithT(t) + + // Verify flag defaults are clientcmd.RecommendedHomeFile so that resolveKubeconfig + // can detect "user did not explicitly set a path" via viper.IsSet — when a flag is + // not set, viper returns the default, and IsSet returns false. + fGreenhouse := syncCmd.Flags().Lookup("greenhouse-cluster-kubeconfig") + g.Expect(fGreenhouse).ToNot(BeNil()) + g.Expect(fGreenhouse.DefValue).To(Equal(clientcmd.RecommendedHomeFile)) + + fRemote := syncCmd.Flags().Lookup("remote-cluster-kubeconfig") + g.Expect(fRemote).ToNot(BeNil()) + g.Expect(fRemote.DefValue).To(Equal(clientcmd.RecommendedHomeFile)) +} + +func TestWriteTargetFromKUBECONFIG(t *testing.T) { + sep := string(os.PathListSeparator) + + tests := []struct { + name string + remoteKC string + kubeconfig string + wantTarget string + wantErrSubs string + }{ + { + name: "explicit remote path used as-is", + remoteKC: "/explicit/kubeconfig", + kubeconfig: "/env/kubeconfig", + wantTarget: "/explicit/kubeconfig", + }, + { + name: "empty remote: first KUBECONFIG entry used", + remoteKC: "", + kubeconfig: "/first" + sep + "/second", + wantTarget: "/first", + }, + { + name: "empty remote: leading separator yields error", + remoteKC: "", + kubeconfig: sep + "/second", + wantErrSubs: "contains no usable first path", + }, + { + name: "empty remote: KUBECONFIG also empty yields error", + remoteKC: "", + kubeconfig: "", + wantErrSubs: "KUBECONFIG is empty", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + t.Setenv("KUBECONFIG", tc.kubeconfig) + + writeTarget, writeErr := resolveWriteTarget(tc.remoteKC) + + if tc.wantErrSubs != "" { + g.Expect(writeErr).To(HaveOccurred()) + g.Expect(writeErr.Error()).To(ContainSubstring(tc.wantErrSubs)) + } else { + g.Expect(writeErr).ToNot(HaveOccurred()) + g.Expect(writeTarget).To(Equal(tc.wantTarget)) + } + }) + } +}